Add workflow run tag event data layer (#7204)

This commit is contained in:
Aaron Perez 2026-07-08 13:12:20 -05:00 committed by GitHub
parent bee2b836ce
commit 78fa30ed11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 927 additions and 113 deletions

View file

@ -79,6 +79,7 @@ WORKFLOW_RUN_CREDENTIAL_SELECTION_PREFIX = "wrcs"
SCRIPT_FALLBACK_EPISODE_PREFIX = "sfe"
WORKFLOW_SCHEDULE_PREFIX = "wfs"
TAG_EVENT_PREFIX = "tge"
RUN_TAG_EVENT_PREFIX = "rtge"
TAG_KEY_PREFIX = "tkey"
TAG_VALUE_PREFIX = "tval"
@ -263,6 +264,11 @@ def generate_tag_event_id() -> str:
return f"{TAG_EVENT_PREFIX}_{int_id}"
def generate_run_tag_event_id() -> str:
int_id = generate_id()
return f"{RUN_TAG_EVENT_PREFIX}_{int_id}"
def generate_tag_key_id() -> str:
int_id = generate_id()
return f"{TAG_KEY_PREFIX}_{int_id}"

View file

@ -46,6 +46,7 @@ from skyvern.forge.sdk.db.id import (
generate_organization_bitwarden_collection_id,
generate_output_parameter_id,
generate_persistent_browser_session_id,
generate_run_tag_event_id,
generate_script_block_id,
generate_script_fallback_episode_id,
generate_script_file_id,
@ -417,6 +418,78 @@ class WorkflowTagEventModel(Base):
deleted_at = Column(DateTime, nullable=True)
class WorkflowRunTagEventModel(Base):
__tablename__ = "workflow_run_tag_events"
__table_args__ = (
Index("workflow_run_tag_events_org_wr_set_at_idx", "organization_id", "workflow_run_id", "set_at"),
Index(
"workflow_run_tag_events_org_key_value_active_idx",
"organization_id",
"key",
"value",
postgresql_include=["workflow_run_id"],
postgresql_where=text("superseded_at IS NULL AND event_type = 'set'"),
),
Index(
"workflow_run_tag_events_org_value_active_idx",
"organization_id",
"value",
postgresql_include=["workflow_run_id"],
postgresql_where=text("superseded_at IS NULL AND event_type = 'set'"),
),
Index("workflow_run_tag_events_org_set_at_idx", "organization_id", "set_at"),
Index(
"workflow_run_tag_events_active_grouped_unique",
"organization_id",
"workflow_run_id",
"key",
unique=True,
postgresql_where=text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
sqlite_where=text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
),
Index(
"workflow_run_tag_events_active_label_unique",
"organization_id",
"workflow_run_id",
"value",
unique=True,
postgresql_where=text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
sqlite_where=text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
),
CheckConstraint("event_type IN ('set', 'delete')", name="ck_workflow_run_tag_events_event_type"),
CheckConstraint(
"source IN ('manual', 'bulk_apply', 'backfill', 'inherited', 'import', 'system')",
name="ck_workflow_run_tag_events_source",
),
CheckConstraint(
"caller_type IS NULL OR caller_type IN ('user', 'api_key', 'system')",
name="ck_workflow_run_tag_events_caller_type",
),
CheckConstraint("event_type != 'set' OR value IS NOT NULL", name="ck_workflow_run_tag_events_set_has_value"),
)
tag_event_id = Column(String, primary_key=True, default=generate_run_tag_event_id)
workflow_run_id = Column(String, ForeignKey("workflow_runs.workflow_run_id"), nullable=False)
organization_id = Column(String, ForeignKey("organizations.organization_id"), nullable=False)
key = Column(String, nullable=True)
value = Column(String, nullable=True)
event_type = Column(String, nullable=False)
set_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
set_by = Column(String, nullable=False)
source = Column(String, nullable=False)
caller_type = Column(String, nullable=True)
superseded_at = Column(DateTime, nullable=True)
inherited_from_tag_event_id = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
modified_at = Column(
DateTime,
default=datetime.datetime.utcnow,
onupdate=datetime.datetime.utcnow,
nullable=False,
)
class TagKeyModel(Base):
"""Org-scoped registry of tag keys and their descriptions. Auto-registered
on first use; partial UNIQUE on (org, key) WHERE deleted_at IS NULL races

View file

@ -12,7 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from skyvern.forge.sdk.db._error_handling import db_operation, register_passthrough_exception
from skyvern.forge.sdk.db.base_repository import BaseRepository
from skyvern.forge.sdk.db.models import TagKeyModel, TagValueModel, WorkflowModel, WorkflowTagEventModel
from skyvern.forge.sdk.db.models import (
TagKeyModel,
TagValueModel,
WorkflowModel,
WorkflowRunModel,
WorkflowRunTagEventModel,
WorkflowTagEventModel,
)
from skyvern.forge.sdk.workflow.models.tags import TagEventType, TagWriteContext
from skyvern.forge.sdk.workflow.models.validators import RUN_METADATA_MAX_KEYS, random_tag_color
@ -20,6 +27,7 @@ LOG = structlog.get_logger()
# Aliased to RUN_METADATA_MAX_KEYS so tag and run_metadata caps stay coupled.
MAX_TAGS_PER_WORKFLOW = RUN_METADATA_MAX_KEYS
MAX_TAGS_PER_RUN = RUN_METADATA_MAX_KEYS
class TagCountLimitExceeded(ValueError):
@ -31,10 +39,15 @@ class TagValueRenameCollision(ValueError):
already exists active org-wide. v1 rejects rather than merging."""
class RunTagWorkflowRunMismatch(ValueError):
"""Raised when a run-tag write targets a workflow run outside the supplied org."""
# Cap breaches and rename collisions are user input, not infra failures — log as
# BusinessLogicError (WARN), not UnexpectedError (ERROR).
register_passthrough_exception(TagCountLimitExceeded)
register_passthrough_exception(TagValueRenameCollision)
register_passthrough_exception(RunTagWorkflowRunMismatch)
@dataclass(frozen=True)
@ -78,6 +91,65 @@ class TagsRepository(BaseRepository):
(keys), standalone labels via ``label_sets``/``label_deletes``; set wins over delete.
``colors`` maps a grouped tag's key to a palette color for the value being set;
a SET key absent from ``colors`` keeps its existing color or gets a random one."""
return await self._apply_tag_changes(
event_model=WorkflowTagEventModel,
entity_id_name="workflow_permanent_id",
entity_id=workflow_permanent_id,
entity_label="workflow",
organization_id=organization_id,
sets=sets,
deletes=deletes,
context=context,
label_sets=label_sets,
label_deletes=label_deletes,
colors=colors,
max_tags=MAX_TAGS_PER_WORKFLOW,
)
@db_operation("apply_run_tag_changes")
async def apply_run_tag_changes(
self,
workflow_run_id: str,
organization_id: str,
sets: dict[str, str],
deletes: set[str],
context: TagWriteContext,
label_sets: list[str] | None = None,
label_deletes: list[str] | None = None,
colors: dict[str, str] | None = None,
) -> list[TagChange]:
"""Atomically apply SET/DELETE tag events to a workflow run."""
return await self._apply_tag_changes(
event_model=WorkflowRunTagEventModel,
entity_id_name="workflow_run_id",
entity_id=workflow_run_id,
entity_label="workflow run",
organization_id=organization_id,
sets=sets,
deletes=deletes,
context=context,
label_sets=label_sets,
label_deletes=label_deletes,
colors=colors,
max_tags=MAX_TAGS_PER_RUN,
)
async def _apply_tag_changes(
self,
*,
event_model: type[WorkflowTagEventModel] | type[WorkflowRunTagEventModel],
entity_id_name: str,
entity_id: str,
entity_label: str,
organization_id: str,
sets: dict[str, str],
deletes: set[str],
context: TagWriteContext,
label_sets: list[str] | None,
label_deletes: list[str] | None,
colors: dict[str, str] | None,
max_tags: int,
) -> list[TagChange]:
label_sets = label_sets or []
label_deletes = label_deletes or []
colors = colors or {}
@ -90,9 +162,18 @@ class TagsRepository(BaseRepository):
now = datetime.now(timezone.utc)
async with self.Session() as session:
active_rows = await self._get_active_set_rows(
if event_model is WorkflowRunTagEventModel:
await self._validate_workflow_run_org(
session,
workflow_run_id=entity_id,
organization_id=organization_id,
)
active_rows = await self._get_active_set_rows_for_entity(
session,
workflow_permanent_id=workflow_permanent_id,
event_model=event_model,
entity_id_name=entity_id_name,
entity_id=entity_id,
organization_id=organization_id,
)
# Grouped tags keyed by their group; standalone labels keyed by value.
@ -104,26 +185,27 @@ class TagsRepository(BaseRepository):
projected_grouped = (set(grouped_current.keys()) | set(sets.keys())) - effective_deletes
projected_labels = (set(label_current.keys()) | label_set_values) - effective_label_deletes
projected_total = len(projected_grouped) + len(projected_labels)
if projected_total > MAX_TAGS_PER_WORKFLOW:
if projected_total > max_tags:
raise TagCountLimitExceeded(
f"workflow {workflow_permanent_id} would have {projected_total} tags; "
f"max is {MAX_TAGS_PER_WORKFLOW}"
f"{entity_label} {entity_id} would have {projected_total} tags; max is {max_tags}"
)
changes: list[TagChange] = []
def _add_event(*, key: str | None, value: str | None, event_type: TagEventType) -> None:
session.add(
WorkflowTagEventModel(
workflow_permanent_id=workflow_permanent_id,
organization_id=organization_id,
key=key,
value=value,
event_type=event_type.value,
set_at=now,
set_by=context.caller_id,
source=context.source.value,
caller_type=context.caller_type.value if context.caller_type else None,
event_model(
**{
entity_id_name: entity_id,
"organization_id": organization_id,
"key": key,
"value": value,
"event_type": event_type.value,
"set_at": now,
"set_by": context.caller_id,
"source": context.source.value,
"caller_type": context.caller_type.value if context.caller_type else None,
}
)
)
@ -219,6 +301,26 @@ class TagsRepository(BaseRepository):
await session.commit()
return changes
async def _validate_workflow_run_org(
self,
session: AsyncSession,
*,
workflow_run_id: str,
organization_id: str,
) -> None:
row = (
await session.execute(
select(WorkflowRunModel.workflow_run_id)
.where(WorkflowRunModel.workflow_run_id == workflow_run_id)
.where(WorkflowRunModel.organization_id == organization_id)
.limit(1)
)
).first()
if row is None:
raise RunTagWorkflowRunMismatch(
f"workflow run {workflow_run_id} does not exist in organization {organization_id}"
)
async def _register_tag_value_colors(
self,
session: AsyncSession,
@ -270,6 +372,31 @@ class TagsRepository(BaseRepository):
)
await session.execute(stmt)
async def _get_active_set_rows_for_entity(
self,
session: AsyncSession,
*,
event_model: type[WorkflowTagEventModel] | type[WorkflowRunTagEventModel],
entity_id_name: str,
entity_id: str,
organization_id: str,
) -> list[WorkflowTagEventModel] | list[WorkflowRunTagEventModel]:
"""Active SET event rows for a workflow or workflow run, including
grouped tags and standalone labels."""
filters = [
event_model.organization_id == organization_id,
getattr(event_model, entity_id_name) == entity_id,
event_model.superseded_at.is_(None),
event_model.event_type == TagEventType.SET.value,
]
deleted_at = getattr(event_model, "deleted_at", None)
if deleted_at is not None:
filters.append(deleted_at.is_(None))
stmt = select(event_model).where(and_(*filters))
result = await session.execute(stmt)
return list(result.scalars().all())
async def _get_active_set_rows(
self,
session: AsyncSession,
@ -277,19 +404,30 @@ class TagsRepository(BaseRepository):
workflow_permanent_id: str,
organization_id: str,
) -> list[WorkflowTagEventModel]:
"""Active (non-superseded, non-deleted) SET event rows for a workflow —
both grouped tags and standalone labels."""
stmt = select(WorkflowTagEventModel).where(
and_(
WorkflowTagEventModel.organization_id == organization_id,
WorkflowTagEventModel.workflow_permanent_id == workflow_permanent_id,
WorkflowTagEventModel.superseded_at.is_(None),
WorkflowTagEventModel.event_type == TagEventType.SET.value,
WorkflowTagEventModel.deleted_at.is_(None),
)
rows = await self._get_active_set_rows_for_entity(
session,
event_model=WorkflowTagEventModel,
entity_id_name="workflow_permanent_id",
entity_id=workflow_permanent_id,
organization_id=organization_id,
)
result = await session.execute(stmt)
return list(result.scalars().all())
return list(rows)
async def _get_active_set_rows_for_run(
self,
session: AsyncSession,
*,
workflow_run_id: str,
organization_id: str,
) -> list[WorkflowRunTagEventModel]:
rows = await self._get_active_set_rows_for_entity(
session,
event_model=WorkflowRunTagEventModel,
entity_id_name="workflow_run_id",
entity_id=workflow_run_id,
organization_id=organization_id,
)
return list(rows)
@db_operation("get_active_grouped_tags_for_workflow")
async def get_active_grouped_tags_for_workflow(
@ -323,6 +461,35 @@ class TagsRepository(BaseRepository):
result[row.key] = row.value
return result
@db_operation("get_active_grouped_tags_for_run")
async def get_active_grouped_tags_for_run(
self,
workflow_run_id: str,
organization_id: str,
) -> dict[str, str]:
"""Current {key: value} map of a workflow run's grouped tags."""
async with self.Session() as session:
rows = await self._get_active_set_rows_for_run(
session,
workflow_run_id=workflow_run_id,
organization_id=organization_id,
)
result: dict[str, str] = {}
for row in rows:
if row.key is None:
continue
if row.value is None:
LOG.warning(
"active SET run tag row has null value; skipping",
tag_event_id=row.tag_event_id,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
key=row.key,
)
continue
result[row.key] = row.value
return result
@db_operation("get_active_tag_events_for_workflow")
async def get_active_tag_events_for_workflow(
self,
@ -338,6 +505,20 @@ class TagsRepository(BaseRepository):
organization_id=organization_id,
)
@db_operation("get_active_tag_events_for_run")
async def get_active_tag_events_for_run(
self,
workflow_run_id: str,
organization_id: str,
) -> list[WorkflowRunTagEventModel]:
"""Active SET rows (grouped + standalone) with full run-tag attribution."""
async with self.Session() as session:
return await self._get_active_set_rows_for_run(
session,
workflow_run_id=workflow_run_id,
organization_id=organization_id,
)
@db_operation("get_tag_event_history")
async def get_tag_event_history(
self,
@ -348,20 +529,63 @@ class TagsRepository(BaseRepository):
key: str | None = None,
) -> list[WorkflowTagEventModel]:
"""Return tag events newest-first for a workflow. Includes DELETE and superseded SET rows."""
rows = await self._get_tag_event_history_for_entity(
event_model=WorkflowTagEventModel,
entity_id_name="workflow_permanent_id",
entity_id=workflow_permanent_id,
organization_id=organization_id,
limit=limit,
since=since,
key=key,
)
return list(rows)
@db_operation("get_run_tag_event_history")
async def get_run_tag_event_history(
self,
workflow_run_id: str,
organization_id: str,
limit: int = 100,
since: datetime | None = None,
key: str | None = None,
) -> list[WorkflowRunTagEventModel]:
"""Return tag events newest-first for a workflow run, with optional key filter."""
rows = await self._get_tag_event_history_for_entity(
event_model=WorkflowRunTagEventModel,
entity_id_name="workflow_run_id",
entity_id=workflow_run_id,
organization_id=organization_id,
limit=limit,
since=since,
key=key,
)
return list(rows)
async def _get_tag_event_history_for_entity(
self,
*,
event_model: type[WorkflowTagEventModel] | type[WorkflowRunTagEventModel],
entity_id_name: str,
entity_id: str,
organization_id: str,
limit: int,
since: datetime | None,
key: str | None,
) -> list[WorkflowTagEventModel] | list[WorkflowRunTagEventModel]:
async with self.Session() as session:
stmt = (
select(WorkflowTagEventModel)
.where(WorkflowTagEventModel.organization_id == organization_id)
.where(WorkflowTagEventModel.workflow_permanent_id == workflow_permanent_id)
.where(WorkflowTagEventModel.deleted_at.is_(None))
select(event_model)
.where(event_model.organization_id == organization_id)
.where(getattr(event_model, entity_id_name) == entity_id)
)
deleted_at = getattr(event_model, "deleted_at", None)
if deleted_at is not None:
stmt = stmt.where(deleted_at.is_(None))
if since is not None:
stmt = stmt.where(WorkflowTagEventModel.set_at >= since)
stmt = stmt.where(event_model.set_at >= since)
if key is not None:
stmt = stmt.where(WorkflowTagEventModel.key == key)
stmt = stmt.order_by(WorkflowTagEventModel.set_at.desc(), WorkflowTagEventModel.tag_event_id.desc()).limit(
limit
)
stmt = stmt.where(event_model.key == key)
stmt = stmt.order_by(event_model.set_at.desc(), event_model.tag_event_id.desc()).limit(limit)
result = await session.execute(stmt)
return list(result.scalars().all())
@ -373,33 +597,67 @@ class TagsRepository(BaseRepository):
) -> dict[str, list[tuple[str | None, str]]]:
"""Batch read of current SET tags (grouped + standalone) as a list of
(key, value) per workflow. The org filter silently drops foreign wpids."""
if not workflow_permanent_ids:
return await self._get_active_tags_for_entities(
event_model=WorkflowTagEventModel,
entity_id_name="workflow_permanent_id",
entity_ids=workflow_permanent_ids,
organization_id=organization_id,
log_entity_name="workflow_permanent_id",
)
@db_operation("get_active_tags_for_runs")
async def get_active_tags_for_runs(
self,
workflow_run_ids: list[str],
organization_id: str,
) -> dict[str, list[tuple[str | None, str]]]:
"""Batch read of current SET tags (grouped + standalone) per workflow run."""
return await self._get_active_tags_for_entities(
event_model=WorkflowRunTagEventModel,
entity_id_name="workflow_run_id",
entity_ids=workflow_run_ids,
organization_id=organization_id,
log_entity_name="workflow_run_id",
)
async def _get_active_tags_for_entities(
self,
*,
event_model: type[WorkflowTagEventModel] | type[WorkflowRunTagEventModel],
entity_id_name: str,
entity_ids: list[str],
organization_id: str,
log_entity_name: str,
) -> dict[str, list[tuple[str | None, str]]]:
if not entity_ids:
return {}
filters = [
event_model.organization_id == organization_id,
getattr(event_model, entity_id_name).in_(entity_ids),
event_model.superseded_at.is_(None),
event_model.event_type == TagEventType.SET.value,
]
deleted_at = getattr(event_model, "deleted_at", None)
if deleted_at is not None:
filters.append(deleted_at.is_(None))
async with self.Session() as session:
stmt = select(WorkflowTagEventModel).where(
and_(
WorkflowTagEventModel.organization_id == organization_id,
WorkflowTagEventModel.workflow_permanent_id.in_(workflow_permanent_ids),
WorkflowTagEventModel.superseded_at.is_(None),
WorkflowTagEventModel.event_type == TagEventType.SET.value,
WorkflowTagEventModel.deleted_at.is_(None),
)
)
stmt = select(event_model).where(and_(*filters))
rows = (await session.execute(stmt)).scalars().all()
result: dict[str, list[tuple[str | None, str]]] = {}
for row in rows:
entity_id = getattr(row, entity_id_name)
if row.value is None:
LOG.warning(
"active SET tag row has null value; skipping",
tag_event_id=row.tag_event_id,
organization_id=organization_id,
workflow_permanent_id=row.workflow_permanent_id,
key=row.key,
**{log_entity_name: entity_id, "key": row.key},
)
continue
result.setdefault(row.workflow_permanent_id, []).append((row.key, row.value))
result.setdefault(entity_id, []).append((row.key, row.value))
return result
@db_operation("list_tag_keys")
@ -513,6 +771,25 @@ class TagsRepository(BaseRepository):
rows = await session.execute(stmt)
return {key: count for key, count in rows.all()}
@db_operation("count_active_runs_per_key")
async def count_active_runs_per_key(self, organization_id: str) -> dict[str, int]:
"""Map of tag key -> number of workflow runs carrying it. Standalone labels are excluded."""
async with self.Session() as session:
stmt = (
select(WorkflowRunTagEventModel.key, func.count(WorkflowRunTagEventModel.tag_event_id))
.where(
and_(
WorkflowRunTagEventModel.organization_id == organization_id,
WorkflowRunTagEventModel.superseded_at.is_(None),
WorkflowRunTagEventModel.event_type == TagEventType.SET.value,
WorkflowRunTagEventModel.key.isnot(None),
)
)
.group_by(WorkflowRunTagEventModel.key)
)
rows = await session.execute(stmt)
return {key: count for key, count in rows.all()}
@staticmethod
def _non_deleted_workflow_exists(organization_id: str) -> Exists:
"""Correlated EXISTS: the tag event's workflow still has a live version.
@ -554,6 +831,29 @@ class TagsRepository(BaseRepository):
rows = await session.execute(stmt)
return {(key, value): count for key, value, count in rows.all()}
@db_operation("count_active_runs_per_value")
async def count_active_runs_per_value(self, organization_id: str) -> dict[tuple[str, str], int]:
"""Map of grouped ``(key, value)`` -> number of workflow runs carrying it."""
async with self.Session() as session:
stmt = (
select(
WorkflowRunTagEventModel.key,
WorkflowRunTagEventModel.value,
func.count(func.distinct(WorkflowRunTagEventModel.workflow_run_id)),
)
.where(
and_(
WorkflowRunTagEventModel.organization_id == organization_id,
WorkflowRunTagEventModel.superseded_at.is_(None),
WorkflowRunTagEventModel.event_type == TagEventType.SET.value,
WorkflowRunTagEventModel.key.isnot(None),
)
)
.group_by(WorkflowRunTagEventModel.key, WorkflowRunTagEventModel.value)
)
rows = await session.execute(stmt)
return {(key, value): count for key, value, count in rows.all()}
@db_operation("count_active_workflows_for_value")
async def count_active_workflows_for_value(self, organization_id: str, key: str, value: str) -> int:
"""Number of non-deleted workflows with an active SET on grouped ``(key, value)``.
@ -596,6 +896,60 @@ class TagsRepository(BaseRepository):
stmt = stmt.where(TagValueModel.value == value)
await session.execute(stmt.values(deleted_at=now))
async def _get_active_grouped_set_rows(
self,
session: AsyncSession,
*,
event_model: type[WorkflowTagEventModel] | type[WorkflowRunTagEventModel],
organization_id: str,
key: str,
value: str | None = None,
) -> list[WorkflowTagEventModel] | list[WorkflowRunTagEventModel]:
filters = [
event_model.organization_id == organization_id,
event_model.key == key,
event_model.superseded_at.is_(None),
event_model.event_type == TagEventType.SET.value,
]
if value is not None:
filters.append(event_model.value == value)
deleted_at = getattr(event_model, "deleted_at", None)
if deleted_at is not None:
filters.append(deleted_at.is_(None))
result = await session.execute(select(event_model).where(and_(*filters)))
return list(result.scalars().all())
@staticmethod
def _supersede_and_add_delete_event(
session: AsyncSession,
*,
event_model: type[WorkflowTagEventModel] | type[WorkflowRunTagEventModel],
entity_id_name: str,
existing: WorkflowTagEventModel | WorkflowRunTagEventModel,
organization_id: str,
key: str,
value: str | None,
context: TagWriteContext,
now: datetime,
) -> None:
existing.superseded_at = now
session.add(
event_model(
**{
entity_id_name: getattr(existing, entity_id_name),
"organization_id": organization_id,
"key": key,
"value": value,
"event_type": TagEventType.DELETE.value,
"set_at": now,
"set_by": context.caller_id,
"source": context.source.value,
"caller_type": context.caller_type.value if context.caller_type else None,
}
)
)
@db_operation("delete_tag_key")
async def delete_tag_key(
self,
@ -643,38 +997,42 @@ class TagsRepository(BaseRepository):
if key_row is None:
return None
active_sets = (
(
await session.execute(
select(WorkflowTagEventModel).where(
and_(
WorkflowTagEventModel.organization_id == organization_id,
WorkflowTagEventModel.key == key,
WorkflowTagEventModel.superseded_at.is_(None),
WorkflowTagEventModel.event_type == TagEventType.SET.value,
WorkflowTagEventModel.deleted_at.is_(None),
)
)
)
)
.scalars()
.all()
active_sets = await self._get_active_grouped_set_rows(
session,
event_model=WorkflowTagEventModel,
organization_id=organization_id,
key=key,
)
active_run_sets = await self._get_active_grouped_set_rows(
session,
event_model=WorkflowRunTagEventModel,
organization_id=organization_id,
key=key,
)
for existing in active_sets:
existing.superseded_at = now
session.add(
WorkflowTagEventModel(
workflow_permanent_id=existing.workflow_permanent_id,
organization_id=organization_id,
key=key,
value=None,
event_type=TagEventType.DELETE.value,
set_at=now,
set_by=context.caller_id,
source=context.source.value,
caller_type=context.caller_type.value if context.caller_type else None,
)
self._supersede_and_add_delete_event(
session,
event_model=WorkflowTagEventModel,
entity_id_name="workflow_permanent_id",
existing=existing,
organization_id=organization_id,
key=key,
value=None,
context=context,
now=now,
)
for existing in active_run_sets:
self._supersede_and_add_delete_event(
session,
event_model=WorkflowRunTagEventModel,
entity_id_name="workflow_run_id",
existing=existing,
organization_id=organization_id,
key=key,
value=None,
context=context,
now=now,
)
key_row.deleted_at = now
@ -716,42 +1074,47 @@ class TagsRepository(BaseRepository):
)
).scalar_one_or_none()
active_sets = (
(
await session.execute(
select(WorkflowTagEventModel).where(
and_(
WorkflowTagEventModel.organization_id == organization_id,
WorkflowTagEventModel.key == key,
WorkflowTagEventModel.value == value,
WorkflowTagEventModel.superseded_at.is_(None),
WorkflowTagEventModel.event_type == TagEventType.SET.value,
WorkflowTagEventModel.deleted_at.is_(None),
)
)
)
)
.scalars()
.all()
active_sets = await self._get_active_grouped_set_rows(
session,
event_model=WorkflowTagEventModel,
organization_id=organization_id,
key=key,
value=value,
)
active_run_sets = await self._get_active_grouped_set_rows(
session,
event_model=WorkflowRunTagEventModel,
organization_id=organization_id,
key=key,
value=value,
)
if value_row is None and not active_sets:
if value_row is None and not active_sets and not active_run_sets:
return None
for existing in active_sets:
existing.superseded_at = now
session.add(
WorkflowTagEventModel(
workflow_permanent_id=existing.workflow_permanent_id,
organization_id=organization_id,
key=key,
value=value,
event_type=TagEventType.DELETE.value,
set_at=now,
set_by=context.caller_id,
source=context.source.value,
caller_type=context.caller_type.value if context.caller_type else None,
)
self._supersede_and_add_delete_event(
session,
event_model=WorkflowTagEventModel,
entity_id_name="workflow_permanent_id",
existing=existing,
organization_id=organization_id,
key=key,
value=value,
context=context,
now=now,
)
for existing in active_run_sets:
self._supersede_and_add_delete_event(
session,
event_model=WorkflowRunTagEventModel,
entity_id_name="workflow_run_id",
existing=existing,
organization_id=organization_id,
key=key,
value=value,
context=context,
now=now,
)
await self._soft_delete_tag_value_rows(
@ -825,8 +1188,15 @@ class TagsRepository(BaseRepository):
.scalars()
.all()
)
active_run_sets = await self._get_active_grouped_set_rows(
session,
event_model=WorkflowRunTagEventModel,
organization_id=organization_id,
key=key,
value=old_value,
)
if old_row is None and not active_sets:
if old_row is None and not active_sets and not active_run_sets:
return None
if await self._grouped_value_active(session, organization_id=organization_id, key=key, value=new_value):
@ -836,6 +1206,8 @@ class TagsRepository(BaseRepository):
for existing in active_sets:
existing.superseded_at = now
for existing in active_run_sets:
existing.superseded_at = now
# Flush supersede UPDATEs before the new SET INSERTs so the active-SET
# partial UNIQUE on (org, wpid, key) sees a consistent state.
await session.flush()
@ -853,6 +1225,20 @@ class TagsRepository(BaseRepository):
caller_type=context.caller_type.value if context.caller_type else None,
)
)
for existing in active_run_sets:
session.add(
WorkflowRunTagEventModel(
workflow_run_id=existing.workflow_run_id,
organization_id=organization_id,
key=key,
value=new_value,
event_type=TagEventType.SET.value,
set_at=now,
set_by=context.caller_id,
source=context.source.value,
caller_type=context.caller_type.value if context.caller_type else None,
)
)
carried_color = old_row.color if old_row is not None else random_tag_color()
dialect_name = session.bind.dialect.name if session.bind is not None else "postgresql"
@ -887,7 +1273,7 @@ class TagsRepository(BaseRepository):
value: str,
) -> bool:
"""True when grouped label ``(key, value)`` exists active org-wide — either a
registered color row or an active SET event in use on some workflow."""
registered color row or an active SET event in use on some workflow/run."""
registered = (
await session.execute(
select(TagValueModel.tag_value_id)
@ -920,4 +1306,21 @@ class TagsRepository(BaseRepository):
.limit(1)
)
).first()
return in_use is not None
if in_use is not None:
return True
run_in_use = (
await session.execute(
select(WorkflowRunTagEventModel.tag_event_id)
.where(
and_(
WorkflowRunTagEventModel.organization_id == organization_id,
WorkflowRunTagEventModel.key == key,
WorkflowRunTagEventModel.value == value,
WorkflowRunTagEventModel.superseded_at.is_(None),
WorkflowRunTagEventModel.event_type == TagEventType.SET.value,
)
)
.limit(1)
)
).first()
return run_in_use is not None