fix: make trigger paths globally unique

This commit is contained in:
Abhishek Kumar 2026-04-25 19:44:44 +05:30
parent 3e3773f400
commit a1d4a1fab2
7 changed files with 496 additions and 137 deletions

View file

@ -3,24 +3,36 @@
from typing import List, Optional
from loguru import logger
from sqlalchemy import and_, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy import and_, insert, select, update
from api.db.base_client import BaseDBClient
from api.db.models import AgentTriggerModel
from api.enums import TriggerState
class TriggerPathConflictError(Exception):
"""Raised when a trigger path is already in use by a different workflow.
``trigger_path`` is globally unique, so any conflict same org or
cross-org surfaces here.
"""
def __init__(self, trigger_paths: List[str]):
self.trigger_paths = list(trigger_paths)
joined = ", ".join(self.trigger_paths)
super().__init__(f"Trigger path(s) already in use by another agent: {joined}")
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).
"""Get an agent trigger by its globally unique path (UUID).
Args:
trigger_path: The unique trigger UUID
trigger_path: The trigger UUID
active_only: If True, only return active triggers
Returns:
@ -39,13 +51,63 @@ class AgentTriggerClient(BaseDBClient):
result = await session.execute(query)
return result.scalar_one_or_none()
async def check_trigger_path_conflicts(
self,
trigger_paths: List[str],
exclude_workflow_id: Optional[int] = None,
) -> List[str]:
"""Return any trigger paths already in use by a different workflow.
Archived triggers count as conflicts we never silently repurpose
another workflow's trigger.
Args:
trigger_paths: Paths to check
exclude_workflow_id: Workflow that may legitimately own these paths
(used during updates to ignore the workflow's own triggers)
Returns:
List of conflicting trigger paths (empty if no conflicts).
"""
if not trigger_paths:
return []
async with self.async_session() as session:
query = select(AgentTriggerModel.trigger_path).where(
AgentTriggerModel.trigger_path.in_(trigger_paths),
)
if exclude_workflow_id is not None:
query = query.where(
AgentTriggerModel.workflow_id != exclude_workflow_id
)
result = await session.execute(query)
return [row[0] for row in result.all()]
async def assert_trigger_paths_available(
self,
trigger_paths: List[str],
exclude_workflow_id: Optional[int] = None,
) -> None:
"""Raise TriggerPathConflictError if any path is already in use."""
conflicts = await self.check_trigger_path_conflicts(
trigger_paths=trigger_paths,
exclude_workflow_id=exclude_workflow_id,
)
if conflicts:
raise TriggerPathConflictError(conflicts)
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.
Creates/reactivates triggers that are in the workflow definition and
archives triggers that are no longer in the workflow.
Raises TriggerPathConflictError if any new trigger path is already in
use by another workflow. Callers should invoke
``assert_trigger_paths_available`` upfront so the workflow is not
created/updated when a conflict will block trigger sync.
Args:
workflow_id: ID of the workflow
@ -53,7 +115,7 @@ class AgentTriggerClient(BaseDBClient):
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)
# Existing triggers tied to THIS workflow (any state)
result = await session.execute(
select(AgentTriggerModel).where(
AgentTriggerModel.workflow_id == workflow_id
@ -63,26 +125,48 @@ class AgentTriggerClient(BaseDBClient):
existing_paths = set(existing_triggers.keys())
new_paths = set(trigger_paths)
paths_to_add = new_paths - existing_paths
# Refuse to take over a trigger owned by another workflow
# (active or archived). The global unique constraint on
# trigger_path backstops races between this check and the
# insert below.
if paths_to_add:
conflict_result = await session.execute(
select(AgentTriggerModel.trigger_path).where(
AgentTriggerModel.trigger_path.in_(paths_to_add),
AgentTriggerModel.workflow_id != workflow_id,
)
)
conflicts = [row[0] for row in conflict_result.all()]
if conflicts:
raise TriggerPathConflictError(conflicts)
# 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))
.where(
and_(
AgentTriggerModel.workflow_id == workflow_id,
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
# Reactivate this workflow's previously-archived triggers
paths_to_reactivate = new_paths & existing_paths
if paths_to_reactivate:
await session.execute(
update(AgentTriggerModel)
.where(
and_(
AgentTriggerModel.workflow_id == workflow_id,
AgentTriggerModel.trigger_path.in_(paths_to_reactivate),
AgentTriggerModel.state == TriggerState.ARCHIVED.value,
)
@ -90,25 +174,15 @@ class AgentTriggerClient(BaseDBClient):
.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,
await session.execute(
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(

View file

@ -243,6 +243,13 @@ class WorkflowDefinitionModel(Base):
class WorkflowModel(Base):
__tablename__ = "workflows"
id = Column(Integer, primary_key=True, index=True)
workflow_uuid = Column(
String(36),
unique=True,
nullable=False,
index=True,
default=lambda: str(uuid.uuid4()),
)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
user = relationship("UserModel", back_populates="workflows")
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
@ -723,7 +730,7 @@ class AgentTriggerModel(Base):
id = Column(Integer, primary_key=True, index=True)
# Unique trigger path (UUID format) - generated by UI when trigger node is created
# Globally unique trigger path (UUID format)
trigger_path = Column(String(36), unique=True, nullable=False, index=True)
# Link to workflow