mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
fix: make trigger paths globally unique
This commit is contained in:
parent
3e3773f400
commit
a1d4a1fab2
7 changed files with 496 additions and 137 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue