mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
Add workflow run tag event data layer (#7204)
This commit is contained in:
parent
bee2b836ce
commit
78fa30ed11
5 changed files with 927 additions and 113 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue