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

@ -0,0 +1,133 @@
"""add workflow run tag events table
Revision ID: bec06d149264
Revises: 5b618ec6dce6
Create Date: 2026-07-08T17:54:31.712270+00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "bec06d149264"
down_revision: Union[str, None] = "5b618ec6dce6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"workflow_run_tag_events",
sa.Column("tag_event_id", sa.String(), nullable=False),
sa.Column("workflow_run_id", sa.String(), nullable=False),
sa.Column("organization_id", sa.String(), nullable=False),
sa.Column("key", sa.String(), nullable=True),
sa.Column("value", sa.String(), nullable=True),
sa.Column("event_type", sa.String(), nullable=False),
sa.Column("set_at", sa.DateTime(), nullable=False),
sa.Column("set_by", sa.String(), nullable=False),
sa.Column("source", sa.String(), nullable=False),
sa.Column("caller_type", sa.String(), nullable=True),
sa.Column("superseded_at", sa.DateTime(), nullable=True),
sa.Column("inherited_from_tag_event_id", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("modified_at", sa.DateTime(), nullable=False),
sa.CheckConstraint("event_type IN ('set', 'delete')", name="ck_workflow_run_tag_events_event_type"),
sa.CheckConstraint(
"source IN ('manual', 'bulk_apply', 'backfill', 'inherited', 'import', 'system')",
name="ck_workflow_run_tag_events_source",
),
sa.CheckConstraint(
"caller_type IS NULL OR caller_type IN ('user', 'api_key', 'system')",
name="ck_workflow_run_tag_events_caller_type",
),
sa.CheckConstraint(
"event_type != 'set' OR value IS NOT NULL",
name="ck_workflow_run_tag_events_set_has_value",
),
sa.ForeignKeyConstraint(
["workflow_run_id"],
["workflow_runs.workflow_run_id"],
),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.organization_id"],
),
sa.PrimaryKeyConstraint("tag_event_id"),
)
op.create_index(
"workflow_run_tag_events_org_wr_set_at_idx",
"workflow_run_tag_events",
["organization_id", "workflow_run_id", "set_at"],
unique=False,
)
op.create_index(
"workflow_run_tag_events_org_key_value_active_idx",
"workflow_run_tag_events",
["organization_id", "key", "value"],
unique=False,
postgresql_include=["workflow_run_id"],
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
)
op.create_index(
"workflow_run_tag_events_org_value_active_idx",
"workflow_run_tag_events",
["organization_id", "value"],
unique=False,
postgresql_include=["workflow_run_id"],
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
)
op.create_index(
"workflow_run_tag_events_org_set_at_idx",
"workflow_run_tag_events",
["organization_id", "set_at"],
unique=False,
)
op.create_index(
"workflow_run_tag_events_active_grouped_unique",
"workflow_run_tag_events",
["organization_id", "workflow_run_id", "key"],
unique=True,
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
sqlite_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
)
op.create_index(
"workflow_run_tag_events_active_label_unique",
"workflow_run_tag_events",
["organization_id", "workflow_run_id", "value"],
unique=True,
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
sqlite_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
)
def downgrade() -> None:
op.drop_index(
"workflow_run_tag_events_active_label_unique",
table_name="workflow_run_tag_events",
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NULL"),
)
op.drop_index(
"workflow_run_tag_events_active_grouped_unique",
table_name="workflow_run_tag_events",
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set' AND key IS NOT NULL"),
)
op.drop_index("workflow_run_tag_events_org_set_at_idx", table_name="workflow_run_tag_events")
op.drop_index(
"workflow_run_tag_events_org_value_active_idx",
table_name="workflow_run_tag_events",
postgresql_include=["workflow_run_id"],
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
)
op.drop_index(
"workflow_run_tag_events_org_key_value_active_idx",
table_name="workflow_run_tag_events",
postgresql_include=["workflow_run_id"],
postgresql_where=sa.text("superseded_at IS NULL AND event_type = 'set'"),
)
op.drop_index("workflow_run_tag_events_org_wr_set_at_idx", table_name="workflow_run_tag_events")
op.drop_table("workflow_run_tag_events")

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

View file

@ -16,10 +16,13 @@ from skyvern.forge.sdk.db.models import (
TagKeyModel,
TagValueModel,
WorkflowModel,
WorkflowRunModel,
WorkflowRunTagEventModel,
WorkflowTagEventModel,
)
from skyvern.forge.sdk.db.repositories.tags import (
MAX_TAGS_PER_WORKFLOW,
RunTagWorkflowRunMismatch,
TagCountLimitExceeded,
TagsRepository,
TagValueRenameCollision,
@ -34,6 +37,7 @@ from skyvern.forge.sdk.workflow.models.validators import TAG_COLOR_PALETTE
ORG_ID = "o_test"
WPID = "wpid_alpha"
WRID = "wr_alpha"
@pytest_asyncio.fixture
@ -59,6 +63,16 @@ async def _all_events(repo: TagsRepository) -> list[WorkflowTagEventModel]:
return list(result.scalars().all())
async def _all_run_events(repo: TagsRepository) -> list[WorkflowRunTagEventModel]:
async with repo.Session() as session:
result = await session.execute(
select(WorkflowRunTagEventModel).order_by(
WorkflowRunTagEventModel.set_at, WorkflowRunTagEventModel.tag_event_id
)
)
return list(result.scalars().all())
async def _registered_keys(repo: TagsRepository) -> list[str]:
async with repo.Session() as session:
result = await session.execute(select(TagKeyModel.key).order_by(TagKeyModel.key))
@ -95,6 +109,20 @@ async def _seed_workflow(repo: TagsRepository, wpid: str, *, org_id: str = ORG_I
await session.commit()
async def _seed_workflow_run(repo: TagsRepository, wrid: str, *, org_id: str = ORG_ID) -> None:
async with repo.Session() as session:
session.add(
WorkflowRunModel(
workflow_run_id=wrid,
workflow_id=f"w_{wrid}",
workflow_permanent_id=f"wpid_{wrid}",
organization_id=org_id,
status="completed",
)
)
await session.commit()
@pytest.mark.asyncio
async def test_no_op_when_sets_and_deletes_empty(repo: TagsRepository) -> None:
changes = await repo.apply_tag_changes(WPID, ORG_ID, sets={}, deletes=set(), context=_ctx())
@ -431,6 +459,92 @@ async def test_get_active_tags_for_workflows_empty_input(repo: TagsRepository) -
assert await repo.get_active_tags_for_workflows([], ORG_ID) == {}
@pytest.mark.asyncio
async def test_apply_run_tag_changes_set_supersede_delete_and_history_key_filter(repo: TagsRepository) -> None:
await _seed_workflow_run(repo, WRID)
await repo.apply_run_tag_changes(WRID, ORG_ID, sets={"env": "prod", "team": "core"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes(WRID, ORG_ID, sets={"env": "stg"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes(WRID, ORG_ID, sets={}, deletes={"team"}, context=_ctx())
assert await repo.get_active_grouped_tags_for_run(WRID, ORG_ID) == {"env": "stg"}
history = await repo.get_run_tag_event_history(WRID, ORG_ID, key="env")
assert [(event.event_type, event.key, event.value) for event in history] == [
(TagEventType.SET.value, "env", "stg"),
(TagEventType.SET.value, "env", "prod"),
]
assert history[1].superseded_at is not None
@pytest.mark.asyncio
async def test_apply_run_tag_changes_supports_standalone_labels_and_cap(repo: TagsRepository) -> None:
await _seed_workflow_run(repo, WRID)
grouped = {f"k{i}": "v" for i in range(MAX_TAGS_PER_WORKFLOW - 1)}
await repo.apply_run_tag_changes(
WRID, ORG_ID, sets=grouped, deletes=set(), context=_ctx(), label_sets=["one_label"]
)
rows = await repo.get_active_tag_events_for_run(WRID, ORG_ID)
assert {row.value for row in rows if row.key is None} == {"one_label"}
with pytest.raises(TagCountLimitExceeded):
await repo.apply_run_tag_changes(WRID, ORG_ID, sets={}, deletes=set(), context=_ctx(), label_sets=["over"])
@pytest.mark.asyncio
async def test_apply_run_tag_changes_rejects_run_from_other_org(repo: TagsRepository) -> None:
await _seed_workflow_run(repo, WRID, org_id=ORG_ID)
with pytest.raises(RunTagWorkflowRunMismatch):
await repo.apply_run_tag_changes(WRID, "o_other", sets={"env": "prod"}, deletes=set(), context=_ctx())
assert await repo.get_active_grouped_tags_for_run(WRID, "o_other") == {}
@pytest.mark.asyncio
async def test_get_active_tags_for_runs_batch(repo: TagsRepository) -> None:
await _seed_workflow_run(repo, WRID)
await _seed_workflow_run(repo, "wr_beta")
await _seed_workflow_run(repo, "wr_other", org_id="o_other")
await repo.apply_run_tag_changes(
WRID, ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx(), label_sets=["urgent"]
)
await repo.apply_run_tag_changes("wr_beta", ORG_ID, sets={"team": "growth"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes(
"wr_other", "o_other", sets={"secret": "leak"}, deletes=set(), context=_ctx("other")
)
result = await repo.get_active_tags_for_runs([WRID, "wr_beta", "wr_missing"], ORG_ID)
assert set(result[WRID]) == {("env", "prod"), (None, "urgent")}
assert result["wr_beta"] == [("team", "growth")]
assert "wr_missing" not in result
@pytest.mark.asyncio
async def test_get_active_tags_for_runs_empty_input(repo: TagsRepository) -> None:
assert await repo.get_active_tags_for_runs([], ORG_ID) == {}
@pytest.mark.asyncio
async def test_count_active_runs_per_key_and_value(repo: TagsRepository) -> None:
for wrid in ("wr_a", "wr_b", "wr_c", "wr_d"):
await _seed_workflow_run(repo, wrid)
await repo.apply_run_tag_changes("wr_a", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes(
"wr_b", ORG_ID, sets={"env": "prod", "team": "core"}, deletes=set(), context=_ctx()
)
await repo.apply_run_tag_changes("wr_c", ORG_ID, sets={"env": "stg"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_c", ORG_ID, sets={}, deletes={"env"}, context=_ctx())
await repo.apply_run_tag_changes("wr_d", ORG_ID, sets={}, deletes=set(), context=_ctx(), label_sets=["standalone"])
assert await repo.count_active_runs_per_key(ORG_ID) == {"env": 2, "team": 1}
assert await repo.count_active_runs_per_value(ORG_ID) == {("env", "prod"): 2, ("team", "core"): 1}
@pytest.mark.asyncio
async def test_list_tag_keys_orders_alphabetically(repo: TagsRepository) -> None:
await repo.apply_tag_changes(
@ -560,6 +674,29 @@ async def test_delete_tag_key_cascades_across_workflows(repo: TagsRepository) ->
assert [row.key for row in await repo.list_tag_keys(ORG_ID)] == ["team"]
@pytest.mark.asyncio
async def test_delete_tag_key_cascades_across_workflow_runs(repo: TagsRepository) -> None:
await _seed_workflow_run(repo, "wr_a")
await _seed_workflow_run(repo, "wr_b")
await repo.apply_tag_changes("wpid_a", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_a", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_b", ORG_ID, sets={"env": "stg", "team": "core"}, deletes=set(), context=_ctx())
removed_workflows = await repo.delete_tag_key(ORG_ID, "env", _ctx())
assert removed_workflows == 1
assert await repo.get_active_grouped_tags_for_workflow("wpid_a", ORG_ID) == {}
assert await repo.get_active_grouped_tags_for_run("wr_a", ORG_ID) == {}
assert await repo.get_active_grouped_tags_for_run("wr_b", ORG_ID) == {"team": "core"}
rows = await _all_run_events(repo)
run_deletes = [row for row in rows if row.event_type == TagEventType.DELETE.value]
assert {(row.workflow_run_id, row.key, row.value) for row in run_deletes} == {
("wr_a", "env", None),
("wr_b", "env", None),
}
@pytest.mark.asyncio
async def test_delete_tag_key_unknown_returns_none(repo: TagsRepository) -> None:
assert await repo.delete_tag_key(ORG_ID, "never_seen", _ctx()) is None
@ -903,6 +1040,22 @@ async def test_rename_tag_value_cascades_and_carries_color(repo: TagsRepository)
assert [(v.key, v.value, v.color) for v in await repo.list_tag_values(ORG_ID)] == [("env", "production", "blue")]
@pytest.mark.asyncio
async def test_rename_tag_value_cascades_across_workflow_runs(repo: TagsRepository) -> None:
await _seed_workflow_run(repo, "wr_a")
await _seed_workflow_run(repo, "wr_b")
await repo.apply_run_tag_changes("wr_a", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_b", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
result = await repo.rename_tag_value(ORG_ID, "env", "prod", "production", _ctx())
assert result is not None
assert result.renamed_workflow_count == 0
assert await repo.get_active_grouped_tags_for_run("wr_a", ORG_ID) == {"env": "production"}
assert await repo.get_active_grouped_tags_for_run("wr_b", ORG_ID) == {"env": "production"}
assert [(v.key, v.value) for v in await repo.list_tag_values(ORG_ID)] == [("env", "production")]
@pytest.mark.asyncio
async def test_rename_tag_value_preserves_history(repo: TagsRepository) -> None:
"""Append-only invariant: the old value's SET event stays in history (superseded),
@ -954,6 +1107,27 @@ async def test_rename_tag_value_collision_detects_in_use_value(repo: TagsReposit
await repo.rename_tag_value(ORG_ID, "env", "prod", "stg", _ctx())
@pytest.mark.asyncio
async def test_rename_tag_value_collision_detects_in_use_run_value(repo: TagsRepository) -> None:
"""A target value in use on a workflow run blocks rename even if its color row was soft-deleted."""
from sqlalchemy import update as _update
await _seed_workflow_run(repo, "wr_source")
await _seed_workflow_run(repo, "wr_target")
await repo.apply_run_tag_changes("wr_source", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_target", ORG_ID, sets={"env": "stg"}, deletes=set(), context=_ctx())
async with repo.Session() as session:
await session.execute(
_update(TagValueModel)
.where(TagValueModel.key == "env", TagValueModel.value == "stg")
.values(deleted_at=datetime.now(timezone.utc))
)
await session.commit()
with pytest.raises(TagValueRenameCollision):
await repo.rename_tag_value(ORG_ID, "env", "prod", "stg", _ctx())
@pytest.mark.asyncio
async def test_rename_tag_value_reuses_soft_deleted_target(repo: TagsRepository) -> None:
"""A previously soft-deleted target value is NOT a collision — rename succeeds."""
@ -1003,6 +1177,31 @@ async def test_delete_tag_value_cascades_and_counts(repo: TagsRepository) -> Non
assert await repo.get_active_grouped_tags_for_workflow("wpid_c", ORG_ID) == {"env": "stg"}
@pytest.mark.asyncio
async def test_delete_tag_value_cascades_across_workflow_runs(repo: TagsRepository) -> None:
for wrid in ("wr_a", "wr_b", "wr_c"):
await _seed_workflow_run(repo, wrid)
await repo.apply_tag_changes("wpid_a", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_a", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_b", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())
await repo.apply_run_tag_changes("wr_c", ORG_ID, sets={"env": "stg"}, deletes=set(), context=_ctx())
removed_workflows = await repo.delete_tag_value(ORG_ID, "env", "prod", _ctx())
assert removed_workflows == 1
assert await repo.get_active_grouped_tags_for_workflow("wpid_a", ORG_ID) == {}
assert await repo.get_active_grouped_tags_for_run("wr_a", ORG_ID) == {}
assert await repo.get_active_grouped_tags_for_run("wr_b", ORG_ID) == {}
assert await repo.get_active_grouped_tags_for_run("wr_c", ORG_ID) == {"env": "stg"}
rows = await _all_run_events(repo)
run_deletes = [row for row in rows if row.event_type == TagEventType.DELETE.value]
assert {(row.workflow_run_id, row.key, row.value) for row in run_deletes} == {
("wr_a", "env", "prod"),
("wr_b", "env", "prod"),
}
@pytest.mark.asyncio
async def test_delete_tag_value_writes_delete_event_carrying_value(repo: TagsRepository) -> None:
await repo.apply_tag_changes("wpid_a", ORG_ID, sets={"env": "prod"}, deletes=set(), context=_ctx())