diff --git a/api/alembic/versions/a399b39479fe_add_versioning_in_workflow_definitions.py b/api/alembic/versions/a399b39479fe_add_versioning_in_workflow_definitions.py new file mode 100644 index 0000000..ad6ea97 --- /dev/null +++ b/api/alembic/versions/a399b39479fe_add_versioning_in_workflow_definitions.py @@ -0,0 +1,132 @@ +"""add versioning in workflow definitions + +Revision ID: a399b39479fe +Revises: c71db647d354 +Create Date: 2026-04-07 14:43:50.042973 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a399b39479fe" +down_revision: Union[str, None] = "c71db647d354" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_api_keys_key_hash"), table_name="api_keys") + op.create_index(op.f("ix_api_keys_key_hash"), "api_keys", ["key_hash"], unique=True) + op.add_column( + "workflow_definitions", + sa.Column( + "status", sa.String(), server_default=sa.text("'published'"), nullable=False + ), + ) + op.add_column( + "workflow_definitions", sa.Column("version_number", sa.Integer(), nullable=True) + ) + op.add_column( + "workflow_definitions", + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "workflow_definitions", + sa.Column( + "workflow_configurations", + sa.JSON(), + server_default=sa.text("'{}'::json"), + nullable=False, + ), + ) + op.add_column( + "workflow_definitions", + sa.Column( + "template_context_variables", + sa.JSON(), + server_default=sa.text("'{}'::json"), + nullable=False, + ), + ) + op.add_column( + "workflow_definitions", + sa.Column( + "call_disposition_codes", + sa.JSON(), + server_default=sa.text("'{}'::json"), + nullable=False, + ), + ) + op.alter_column( + "workflow_definitions", + "workflow_hash", + existing_type=sa.VARCHAR(), + nullable=True, + ) + op.drop_index( + op.f("ix_workflow_hash_workflow_id"), table_name="workflow_definitions" + ) + op.drop_constraint( + op.f("uq_workflow_hash_workflow_id"), "workflow_definitions", type_="unique" + ) + op.create_index( + "ix_workflow_definitions_workflow_status", + "workflow_definitions", + ["workflow_id", "status"], + unique=False, + ) + op.add_column( + "workflows", sa.Column("released_definition_id", sa.Integer(), nullable=True) + ) + op.create_foreign_key( + None, + "workflows", + "workflow_definitions", + ["released_definition_id"], + ["id"], + use_alter=True, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "workflows", type_="foreignkey") + op.drop_column("workflows", "released_definition_id") + op.drop_index( + "ix_workflow_definitions_workflow_status", table_name="workflow_definitions" + ) + op.create_unique_constraint( + op.f("uq_workflow_hash_workflow_id"), + "workflow_definitions", + ["workflow_hash", "workflow_id"], + postgresql_nulls_not_distinct=False, + ) + op.create_index( + op.f("ix_workflow_hash_workflow_id"), + "workflow_definitions", + ["workflow_hash", "workflow_id"], + unique=False, + ) + op.alter_column( + "workflow_definitions", + "workflow_hash", + existing_type=sa.VARCHAR(), + nullable=False, + ) + op.drop_column("workflow_definitions", "call_disposition_codes") + op.drop_column("workflow_definitions", "template_context_variables") + op.drop_column("workflow_definitions", "workflow_configurations") + op.drop_column("workflow_definitions", "published_at") + op.drop_column("workflow_definitions", "version_number") + op.drop_column("workflow_definitions", "status") + op.drop_index(op.f("ix_api_keys_key_hash"), table_name="api_keys") + op.create_index( + op.f("ix_api_keys_key_hash"), "api_keys", ["key_hash"], unique=False + ) + # ### end Alembic commands ### diff --git a/api/alembic/versions/d688d0da1123_backfill_workflow_definition_versioning.py b/api/alembic/versions/d688d0da1123_backfill_workflow_definition_versioning.py new file mode 100644 index 0000000..25bbae2 --- /dev/null +++ b/api/alembic/versions/d688d0da1123_backfill_workflow_definition_versioning.py @@ -0,0 +1,89 @@ +"""backfill workflow definition versioning + +Copy workflow_configurations, template_context_variables, call_disposition_codes +from the workflows table into the is_current=True definition for each workflow. +Set that definition as status='published', version_number=1. +Set all other definitions to status='archived'. +Point workflows.released_definition_id to the published definition. + +Revision ID: d688d0da1123 +Revises: a399b39479fe +Create Date: 2026-04-07 15:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d688d0da1123" +down_revision: Union[str, None] = "a399b39479fe" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Step 1: For each workflow's is_current=True definition, copy configs from + # the workflow table and mark as published with version_number=1. + conn.execute( + sa.text(""" + UPDATE workflow_definitions wd + SET + workflow_configurations = w.workflow_configurations, + template_context_variables = w.template_context_variables, + status = 'published', + version_number = 1, + published_at = wd.created_at + FROM workflows w + WHERE wd.workflow_id = w.id + AND wd.is_current = true + """) + ) + + # Step 2: Mark all pre-versioning non-current definitions as legacy. + conn.execute( + sa.text(""" + UPDATE workflow_definitions + SET status = 'legacy' + WHERE is_current = false + """) + ) + + # Step 3: Set released_definition_id on workflows to their published definition. + conn.execute( + sa.text(""" + UPDATE workflows w + SET released_definition_id = wd.id + FROM workflow_definitions wd + WHERE wd.workflow_id = w.id + AND wd.is_current = true + """) + ) + + +def downgrade() -> None: + conn = op.get_bind() + + # Clear the released pointer + conn.execute( + sa.text(""" + UPDATE workflows SET released_definition_id = NULL + """) + ) + + # Reset all definitions back to server defaults + conn.execute( + sa.text(""" + UPDATE workflow_definitions + SET + status = 'published', + version_number = NULL, + published_at = NULL, + workflow_configurations = '{}', + template_context_variables = '{}' + """) + ) diff --git a/api/db/looptalk_client.py b/api/db/looptalk_client.py index 3d09a6c..8f65968 100644 --- a/api/db/looptalk_client.py +++ b/api/db/looptalk_client.py @@ -9,6 +9,7 @@ from api.db.base_client import BaseDBClient from api.db.models import ( LoopTalkConversation, LoopTalkTestSession, + WorkflowModel, ) @@ -50,8 +51,12 @@ class LoopTalkClient(BaseDBClient): result = await session.execute( select(LoopTalkTestSession) .options( - selectinload(LoopTalkTestSession.actor_workflow), - selectinload(LoopTalkTestSession.adversary_workflow), + selectinload(LoopTalkTestSession.actor_workflow).selectinload( + WorkflowModel.released_definition + ), + selectinload(LoopTalkTestSession.adversary_workflow).selectinload( + WorkflowModel.released_definition + ), selectinload(LoopTalkTestSession.conversations), ) .where( diff --git a/api/db/models.py b/api/db/models.py index b0855d0..6c2079d 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -1,7 +1,6 @@ import uuid from datetime import UTC, datetime -from loguru import logger from pgvector.sqlalchemy import Vector from sqlalchemy import ( JSON, @@ -199,7 +198,7 @@ class IntegrationModel(Base): class WorkflowDefinitionModel(Base): __tablename__ = "workflow_definitions" id = Column(Integer, primary_key=True, index=True) - workflow_hash = Column(String, nullable=False) + workflow_hash = Column(String, nullable=True) # Legacy, no longer used workflow_json = Column(JSON, nullable=False, default=dict) workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=True) is_current = Column( @@ -207,12 +206,29 @@ class WorkflowDefinitionModel(Base): ) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) - # Table constraints and indexes + # Versioning columns + status = Column( + String, + nullable=False, + default="published", + server_default=text("'published'"), + ) # draft | published | archived + version_number = Column( + Integer, nullable=True + ) # Sequential per workflow, display only + published_at = Column(DateTime(timezone=True), nullable=True) + + # Full behavioral snapshot (moved from WorkflowModel to enable versioning) + workflow_configurations = Column( + JSON, nullable=False, default=dict, server_default=text("'{}'::json") + ) + template_context_variables = Column( + JSON, nullable=False, default=dict, server_default=text("'{}'::json") + ) + + # Table constraints and indexes — unique hash constraint removed (no more dedup) __table_args__ = ( - UniqueConstraint( - "workflow_hash", "workflow_id", name="uq_workflow_hash_workflow_id" - ), - Index("ix_workflow_hash_workflow_id", "workflow_hash", "workflow_id"), + Index("ix_workflow_definitions_workflow_status", "workflow_id", "status"), ) # Relationships @@ -247,6 +263,19 @@ class WorkflowModel(Base): runs = relationship("WorkflowRunModel", back_populates="workflow") created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + # Pointer to the currently-live (published) version + released_definition_id = Column( + Integer, + ForeignKey("workflow_definitions.id", use_alter=True), + nullable=True, + ) + released_definition = relationship( + "WorkflowDefinitionModel", + foreign_keys=[released_definition_id], + uselist=False, + viewonly=True, + ) + # All versions / historical definitions of this workflow definitions = relationship( "WorkflowDefinitionModel", @@ -255,6 +284,7 @@ class WorkflowModel(Base): ) # Relationship to fetch the current (is_current=True) definition + # Kept for backward compatibility during transition current_definition = relationship( "WorkflowDefinitionModel", primaryjoin=lambda: and_( @@ -277,36 +307,6 @@ class WorkflowModel(Base): # that scenario so callers can handle the absence explicitly. return None - @property - def workflow_definition_with_fallback(self): - """ - Get workflow definition with fallback to legacy workflow_definition field. - - Returns: - dict: The workflow definition JSON - """ - # Access the relationship only if it has ALREADY been eagerly loaded on this - # instance to avoid triggering an implicit lazy load once the SQLAlchemy - # Session has been closed (which would raise a DetachedInstanceError). - - # ``__dict__`` will contain "current_definition" **only** when the attribute - # has been populated (e.g. via `selectinload` or an explicit access while - # the session was still open). Using ``__dict__.get`` guarantees that we - # do not accidentally issue a lazy load query on a detached instance. - - current_definition = self.__dict__.get("current_definition") - - if current_definition is not None: - return current_definition.workflow_json - - # Fallback for backwards-compatibility when the relationship is not (yet) - # loaded. In this case we fall back to the legacy ``workflow_definition`` - # column that always contains the most recent definition JSON. - logger.warning( - f"Workflow {self.id} has no loaded current definition, using workflow_definition as fallback", - ) - return self.workflow_definition - class WorkflowTemplates(Base): __tablename__ = "workflow_templates" diff --git a/api/db/workflow_client.py b/api/db/workflow_client.py index 8d2d406..0df6431 100644 --- a/api/db/workflow_client.py +++ b/api/db/workflow_client.py @@ -1,5 +1,4 @@ -import hashlib -import json +from datetime import UTC, datetime from typing import Optional from loguru import logger @@ -12,41 +11,15 @@ from api.db.models import WorkflowDefinitionModel, WorkflowModel, WorkflowRunMod class WorkflowClient(BaseDBClient): - def _generate_workflow_hash(self, workflow_definition: dict) -> str: - """Generate a consistent hash for workflow definition.""" - # Convert to JSON with sorted keys for consistent hashing - json_str = json.dumps( - workflow_definition, sort_keys=True, separators=(",", ":") - ) - return hashlib.sha256(json_str.encode()).hexdigest() - - async def _get_or_create_workflow_definition( - self, workflow_definition: dict, session, workflow_id: int = None - ) -> WorkflowDefinitionModel: - """Get existing workflow definition by hash or create a new one.""" - workflow_hash = self._generate_workflow_hash(workflow_definition) - - # Try to find existing definition + async def _next_version_number(self, session, workflow_id: int) -> int: + """Get the next version number for a workflow.""" result = await session.execute( - select(WorkflowDefinitionModel).where( - WorkflowDefinitionModel.workflow_hash == workflow_hash, + select(func.max(WorkflowDefinitionModel.version_number)).where( WorkflowDefinitionModel.workflow_id == workflow_id, ) ) - existing_definition = result.scalars().first() - - if existing_definition: - return existing_definition - - # Create new definition if it doesn't exist - new_definition = WorkflowDefinitionModel( - workflow_hash=workflow_hash, - workflow_json=workflow_definition, - workflow_id=workflow_id, - ) - session.add(new_definition) - await session.flush() # Flush to get the ID without committing - return new_definition + current_max = result.scalar() + return (current_max or 0) + 1 async def create_workflow( self, @@ -66,21 +39,23 @@ class WorkflowClient(BaseDBClient): session.add(new_workflow) await session.flush() # Flush to get the workflow ID - # Now get or create workflow definition with the workflow_id - definition = await self._get_or_create_workflow_definition( - workflow_definition, session, new_workflow.id + # Create the first definition as V1 published + definition = WorkflowDefinitionModel( + workflow_json=workflow_definition, + workflow_id=new_workflow.id, + is_current=True, + status="published", + version_number=1, + published_at=datetime.now(UTC), + workflow_configurations=new_workflow.workflow_configurations or {}, + template_context_variables=new_workflow.template_context_variables + or {}, ) + session.add(definition) + await session.flush() - # Mark this definition as the current one and unset others - definition.is_current = True - await session.execute( - update(WorkflowDefinitionModel) - .where( - WorkflowDefinitionModel.workflow_id == new_workflow.id, - WorkflowDefinitionModel.id != definition.id, - ) - .values(is_current=False) - ) + # Set the released pointer + new_workflow.released_definition_id = definition.id await session.commit() except Exception as e: @@ -89,6 +64,257 @@ class WorkflowClient(BaseDBClient): await session.refresh(new_workflow) return new_workflow + # ------------------------------------------------------------------ + # Versioning methods + # ------------------------------------------------------------------ + + async def save_workflow_draft( + self, + workflow_id: int, + workflow_definition: dict | None = None, + workflow_configurations: dict | None = None, + template_context_variables: dict | None = None, + ) -> WorkflowDefinitionModel: + """Create or update a draft version for this workflow. + + If a draft already exists, it is updated in place. + If no draft exists, a new one is created with the next version number. + """ + async with self.async_session() as session: + # Check for existing draft + result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status == "draft", + ) + ) + draft = result.scalars().first() + + if draft: + # Update existing draft in place + if workflow_definition is not None: + draft.workflow_json = workflow_definition + if workflow_configurations is not None: + draft.workflow_configurations = workflow_configurations + if template_context_variables is not None: + draft.template_context_variables = template_context_variables + else: + # Get current published to use as base for unspecified fields + pub_result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status == "published", + ) + ) + published = pub_result.scalars().first() + + next_version = await self._next_version_number(session, workflow_id) + + draft = WorkflowDefinitionModel( + workflow_id=workflow_id, + workflow_json=workflow_definition + if workflow_definition is not None + else (published.workflow_json if published else {}), + workflow_configurations=workflow_configurations + if workflow_configurations is not None + else (published.workflow_configurations if published else {}), + template_context_variables=template_context_variables + if template_context_variables is not None + else (published.template_context_variables if published else {}), + status="draft", + version_number=next_version, + is_current=False, + ) + session.add(draft) + + # Keep legacy columns on workflows table in sync with draft + wf_result = await session.execute( + select(WorkflowModel).where(WorkflowModel.id == workflow_id) + ) + workflow = wf_result.scalars().first() + if workflow: + workflow.workflow_definition = draft.workflow_json + workflow.workflow_configurations = draft.workflow_configurations + workflow.template_context_variables = draft.template_context_variables + + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + await session.refresh(draft) + return draft + + async def publish_workflow_draft( + self, + workflow_id: int, + ) -> WorkflowDefinitionModel: + """Promote the current draft to published. + + - Draft → published + - Previous published → archived + - Updates released_definition_id on the workflow + - Sets is_current for backward compatibility + """ + async with self.async_session() as session: + # Find the draft + result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status == "draft", + ) + ) + draft = result.scalars().first() + if not draft: + raise ValueError(f"No draft exists for workflow {workflow_id}") + + # Archive the current published version + await session.execute( + update(WorkflowDefinitionModel) + .where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status == "published", + ) + .values(status="archived", is_current=False) + ) + + # Promote draft → published + draft.status = "published" + draft.published_at = datetime.now(UTC) + draft.is_current = True + + # Update workflow's released pointer + legacy fields + wf_result = await session.execute( + select(WorkflowModel).where(WorkflowModel.id == workflow_id) + ) + workflow = wf_result.scalars().first() + workflow.released_definition_id = draft.id + workflow.workflow_definition = draft.workflow_json + workflow.workflow_configurations = draft.workflow_configurations + workflow.template_context_variables = draft.template_context_variables + + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + await session.refresh(draft) + return draft + + async def discard_workflow_draft( + self, + workflow_id: int, + ) -> None: + """Delete the current draft version.""" + async with self.async_session() as session: + result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status == "draft", + ) + ) + draft = result.scalars().first() + if not draft: + raise ValueError(f"No draft exists for workflow {workflow_id}") + + await session.delete(draft) + + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + + async def revert_to_version( + self, + workflow_id: int, + definition_id: int, + ) -> WorkflowDefinitionModel: + """Create a new draft from an archived version's snapshot. + + Raises ValueError if a draft already exists (must discard first). + """ + async with self.async_session() as session: + # Ensure no existing draft + draft_result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status == "draft", + ) + ) + if draft_result.scalars().first(): + raise ValueError( + f"Draft already exists for workflow {workflow_id}. " + "Discard it before reverting." + ) + + # Fetch the source version + source_result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.id == definition_id, + WorkflowDefinitionModel.workflow_id == workflow_id, + ) + ) + source = source_result.scalars().first() + if not source: + raise ValueError( + f"Version {definition_id} not found for workflow {workflow_id}" + ) + + next_version = await self._next_version_number(session, workflow_id) + + # Create new draft from the source snapshot + draft = WorkflowDefinitionModel( + workflow_id=workflow_id, + workflow_json=source.workflow_json, + workflow_configurations=source.workflow_configurations, + template_context_variables=source.template_context_variables, + status="draft", + version_number=next_version, + is_current=False, + ) + session.add(draft) + + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + await session.refresh(draft) + return draft + + async def get_draft_version( + self, + workflow_id: int, + ) -> WorkflowDefinitionModel | None: + """Get the draft version for a workflow, or None if no draft exists.""" + async with self.async_session() as session: + result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status == "draft", + ) + ) + return result.scalars().first() + + async def get_workflow_versions( + self, + workflow_id: int, + ) -> list[WorkflowDefinitionModel]: + """List all versions for a workflow, newest first.""" + async with self.async_session() as session: + result = await session.execute( + select(WorkflowDefinitionModel) + .where( + WorkflowDefinitionModel.workflow_id == workflow_id, + WorkflowDefinitionModel.status.in_( + ["published", "draft", "archived"] + ), + ) + .order_by(WorkflowDefinitionModel.version_number.desc()) + ) + return result.scalars().all() + async def get_all_workflows( self, user_id: int = None, organization_id: int = None, status: str = None ) -> list[WorkflowModel]: @@ -191,7 +417,10 @@ class WorkflowClient(BaseDBClient): async with self.async_session() as session: query = ( select(WorkflowModel) - .options(selectinload(WorkflowModel.current_definition)) + .options( + selectinload(WorkflowModel.current_definition), + selectinload(WorkflowModel.released_definition), + ) .where(WorkflowModel.id == workflow_id) ) @@ -209,7 +438,10 @@ class WorkflowClient(BaseDBClient): async with self.async_session() as session: result = await session.execute( select(WorkflowModel) - .options(selectinload(WorkflowModel.current_definition)) + .options( + selectinload(WorkflowModel.current_definition), + selectinload(WorkflowModel.released_definition), + ) .where(WorkflowModel.id == workflow_id) ) return result.scalars().first() @@ -227,11 +459,16 @@ class WorkflowClient(BaseDBClient): """ Update an existing workflow in the database. + Name changes are applied directly to the workflow. + Definition/config/template_var changes are saved as a draft version + via save_workflow_draft, keeping the published version unchanged. + Args: workflow_id: The ID of the workflow to update name: The new name for the workflow workflow_definition: The new workflow definition template_context_variables: The template context variables + workflow_configurations: The workflow configurations user_id: The user ID (for backwards compatibility) organization_id: The organization ID @@ -249,10 +486,8 @@ class WorkflowClient(BaseDBClient): ) if organization_id: - # Filter by organization_id when provided query = query.where(WorkflowModel.organization_id == organization_id) elif user_id: - # Fallback to user_id for backwards compatibility query = query.where(WorkflowModel.user_id == user_id) result = await session.execute(query) @@ -260,42 +495,38 @@ class WorkflowClient(BaseDBClient): if not workflow: raise ValueError(f"Workflow with ID {workflow_id} not found") + # Name is a workflow-level field, not versioned if name is not None: workflow.name = name - if template_context_variables is not None: - workflow.template_context_variables = template_context_variables - - if workflow_configurations is not None: - workflow.workflow_configurations = workflow_configurations - - # In case of only name update, the workflow_definition can be None - if workflow_definition: - # Get or create new workflow definition - definition = await self._get_or_create_workflow_definition( - workflow_definition, session, workflow_id - ) - - # Update legacy field for backwards compatibility - workflow.workflow_definition = workflow_definition - - # Mark new definition as current and reset others - definition.is_current = True - await session.execute( - update(WorkflowDefinitionModel) - .where( - WorkflowDefinitionModel.workflow_id == workflow_id, - WorkflowDefinitionModel.id != definition.id, - ) - .values(is_current=False) - ) - try: await session.commit() except Exception as e: await session.rollback() raise e await session.refresh(workflow) + + # Save versioned changes as a draft + has_versioned_changes = any( + v is not None + for v in [ + workflow_definition, + workflow_configurations, + template_context_variables, + ] + ) + if has_versioned_changes: + await self.save_workflow_draft( + workflow_id=workflow_id, + workflow_definition=workflow_definition, + workflow_configurations=workflow_configurations, + template_context_variables=template_context_variables, + ) + # Re-fetch with updated state + workflow = await self.get_workflow( + workflow_id, user_id=user_id, organization_id=organization_id + ) + return workflow async def get_workflows_by_ids( @@ -353,7 +584,10 @@ class WorkflowClient(BaseDBClient): async with self.async_session() as session: query = ( select(WorkflowModel) - .options(selectinload(WorkflowModel.current_definition)) + .options( + selectinload(WorkflowModel.current_definition), + selectinload(WorkflowModel.released_definition), + ) .where(WorkflowModel.id == workflow_id) ) diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 85fe5c3..912c303 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -30,6 +30,7 @@ class WorkflowRunClient(BaseDBClient): gathered_context: dict = None, campaign_id: int = None, queued_run_id: int = None, + use_draft: bool = False, ) -> WorkflowRunModel: async with self.async_session() as session: # Get workflow and user to check organization @@ -44,41 +45,51 @@ class WorkflowRunClient(BaseDBClient): if not workflow: raise ValueError(f"Workflow with ID {workflow_id} not found") - # # Check quota if user has an organization - # if workflow.user and workflow.user.selected_organization_id: - # # Import here to avoid circular dependency - # from api.db.organization_usage_client import OrganizationUsageClient + # Resolve which definition to bind to this run + target_def = None - # usage_client = OrganizationUsageClient() - - # # Check quota (no reservation for now, actual cost will be added after completion) - # has_quota = await usage_client.check_and_reserve_quota( - # workflow.user.selected_organization_id, estimated_tokens=0 - # ) - - # if not has_quota: - # raise ValueError( - # "Organization quota exceeded. Please contact your administrator." - # ) - - # Fetch the current definition for this workflow - current_def_result = await session.execute( - select(WorkflowDefinitionModel).where( - WorkflowDefinitionModel.workflow_id == workflow.id, - WorkflowDefinitionModel.is_current == True, + if use_draft: + # For test calls: prefer draft if it exists, fall back to published + draft_result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow.id, + WorkflowDefinitionModel.status == "draft", + ) ) - ) - current_def = current_def_result.scalars().first() + target_def = draft_result.scalars().first() + + if target_def is None: + # Use the published version via released_definition_id (preferred) + # or fall back to is_current for backward compatibility + if workflow.released_definition_id: + target_def = await session.get( + WorkflowDefinitionModel, workflow.released_definition_id + ) + else: + pub_result = await session.execute( + select(WorkflowDefinitionModel).where( + WorkflowDefinitionModel.workflow_id == workflow.id, + WorkflowDefinitionModel.is_current == True, + ) + ) + target_def = pub_result.scalars().first() # Get the current storage backend based on ENABLE_AWS_S3 flag current_backend = StorageBackend.get_current_backend() + # Use initial_context from the version if available, else from workflow + default_context = ( + target_def.template_context_variables + if target_def and target_def.template_context_variables + else workflow.template_context_variables + ) + new_run = WorkflowRunModel( name=name, workflow=workflow, mode=mode, - definition_id=current_def.id if current_def else None, - initial_context=initial_context or workflow.template_context_variables, + definition_id=target_def.id if target_def else None, + initial_context=initial_context or default_context, gathered_context=gathered_context or {}, campaign_id=campaign_id, queued_run_id=queued_run_id, @@ -189,7 +200,11 @@ class WorkflowRunClient(BaseDBClient): self, run_id: int, user_id: int = None, organization_id: int = None ) -> WorkflowRunModel | None: async with self.async_session() as session: - query = select(WorkflowRunModel).join(WorkflowRunModel.workflow) + query = ( + select(WorkflowRunModel) + .options(selectinload(WorkflowRunModel.definition)) + .join(WorkflowRunModel.workflow) + ) if organization_id: # Filter by organization_id when provided diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 070503c..7b2f96b 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -295,7 +295,7 @@ async def create_campaign( from api.services.workflow.dto import ReactFlowDTO from api.services.workflow.workflow import WorkflowGraph - workflow_def = workflow.workflow_definition_with_fallback + workflow_def = workflow.released_definition.workflow_json if workflow_def: try: dto = ReactFlowDTO(**workflow_def) diff --git a/api/routes/public_agent.py b/api/routes/public_agent.py index c9cae40..1b9b955 100644 --- a/api/routes/public_agent.py +++ b/api/routes/public_agent.py @@ -103,14 +103,13 @@ async def initiate_call( if not workflow: raise HTTPException(status_code=404, detail="Workflow not found") - # Get workflow definition (with fallback to legacy field) - workflow_definition = workflow.workflow_definition_with_fallback + workflow_definition = workflow.released_definition.workflow_json # Validate trigger node still exists in the workflow definition if not trigger_exists_in_workflow(workflow_definition, uuid): raise HTTPException( status_code=404, - detail="Trigger not found or has been removed from workflow", + detail="Trigger not found in the published Agent", ) # 6. Get telephony provider for the organization diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 475f40a..7fe75ad 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -143,7 +143,8 @@ class StatusCallbackRequest(BaseModel): async def initiate_call( request: InitiateCallRequest, user: UserModel = Depends(get_user) ): - """Initiate a call using the configured telephony provider.""" + """Initiate a call using the configured telephony provider from web browser. This is + supposed to be a test call method for the draft version of the agent.""" # Get the telephony provider for the organization provider = await get_telephony_provider(user.selected_organization_id) @@ -190,6 +191,7 @@ async def initiate_call( "called_number": phone_number, "provider": provider.PROVIDER_NAME, }, + use_draft=True, ) workflow_run_id = workflow_run.id else: diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 8d15743..557e8c0 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -15,10 +15,12 @@ from api.db.workflow_template_client import WorkflowTemplateClient from api.enums import CallType from api.schemas.workflow import WorkflowRunResponseSchema from api.services.auth.depends import get_user +from api.services.configuration.check_validity import UserConfigurationValidator from api.services.configuration.masking import ( mask_workflow_definition, merge_workflow_api_keys, ) +from api.services.configuration.resolve import resolve_effective_config from api.services.mps_service_key_client import mps_service_key_client from api.services.workflow.dto import ReactFlowDTO from api.services.workflow.duplicate import duplicate_workflow @@ -104,6 +106,8 @@ class WorkflowResponse(BaseModel): call_disposition_codes: CallDispositionCodes | None = None total_runs: int | None = None workflow_configurations: dict | None = None + version_number: int | None = None + version_status: str | None = None class WorkflowListResponse(BaseModel): @@ -149,6 +153,17 @@ class UpdateWorkflowRequest(BaseModel): workflow_configurations: dict | None = None +class WorkflowVersionResponse(BaseModel): + id: int + version_number: int + status: str + created_at: datetime + published_at: datetime | None = None + workflow_json: dict + workflow_configurations: dict | None = None + template_context_variables: dict | None = None + + class UpdateWorkflowStatusRequest(BaseModel): status: str # "active" or "archived" @@ -200,8 +215,11 @@ async def validate_workflow( errors: list[WorkflowError] = [] - # Get workflow definition from WorkflowDefinition table, fallback to workflow_definition field - workflow_definition = workflow.workflow_definition_with_fallback + # Validate draft if it exists (user is editing), else validate published + draft = await db_client.get_draft_version(workflow_id) + workflow_definition = ( + draft.workflow_json if draft else workflow.released_definition.workflow_json + ) # ----------- DTO Validation ------------ dto: Optional[ReactFlowDTO] = None @@ -282,9 +300,7 @@ async def create_workflow( "name": workflow.name, "status": workflow.status, "created_at": workflow.created_at, - "workflow_definition": mask_workflow_definition( - workflow.workflow_definition_with_fallback - ), + "workflow_definition": mask_workflow_definition(request.workflow_definition), "current_definition_id": workflow.current_definition_id, "template_context_variables": workflow.template_context_variables, "call_disposition_codes": workflow.call_disposition_codes, @@ -362,9 +378,7 @@ async def create_workflow_from_template( "name": workflow.name, "status": workflow.status, "created_at": workflow.created_at, - "workflow_definition": mask_workflow_definition( - workflow.workflow_definition_with_fallback - ), + "workflow_definition": mask_workflow_definition(workflow_def), "current_definition_id": workflow.current_definition_id, "template_context_variables": workflow.template_context_variables, "call_disposition_codes": workflow.call_disposition_codes, @@ -461,7 +475,11 @@ async def get_workflow( workflow_id: int, user: UserModel = Depends(get_user), ) -> WorkflowResponse: - """Get a single workflow by ID""" + """Get a single workflow by ID. + + If a draft version exists, returns the draft content for editing. + Otherwise returns the published version's content. + """ workflow = await db_client.get_workflow( workflow_id, organization_id=user.selected_organization_id ) @@ -470,21 +488,123 @@ async def get_workflow( status_code=404, detail=f"Workflow with id {workflow_id} not found" ) + # Check for draft — editor should show draft content if it exists + draft = await db_client.get_draft_version(workflow_id) + + if draft: + workflow_def = draft.workflow_json + workflow_configs = draft.workflow_configurations + template_vars = draft.template_context_variables + else: + published = workflow.released_definition + workflow_def = published.workflow_json + workflow_configs = published.workflow_configurations + template_vars = published.template_context_variables + + active_def = draft or workflow.released_definition return { "id": workflow.id, "name": workflow.name, "status": workflow.status, "created_at": workflow.created_at, - "workflow_definition": mask_workflow_definition( - workflow.workflow_definition_with_fallback - ), + "workflow_definition": mask_workflow_definition(workflow_def), "current_definition_id": workflow.current_definition_id, - "template_context_variables": workflow.template_context_variables, + "template_context_variables": template_vars, "call_disposition_codes": workflow.call_disposition_codes, - "workflow_configurations": workflow.workflow_configurations, + "workflow_configurations": workflow_configs, + "version_number": active_def.version_number if active_def else None, + "version_status": active_def.status if active_def else None, } +@router.get("/{workflow_id}/versions") +async def get_workflow_versions( + workflow_id: int, + user: UserModel = Depends(get_user), +) -> list[WorkflowVersionResponse]: + """List all versions for a workflow, newest first.""" + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if workflow is None: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + + versions = await db_client.get_workflow_versions(workflow_id) + return [ + WorkflowVersionResponse( + id=v.id, + version_number=v.version_number, + status=v.status, + created_at=v.created_at, + published_at=v.published_at, + workflow_json=mask_workflow_definition(v.workflow_json), + workflow_configurations=v.workflow_configurations, + template_context_variables=v.template_context_variables, + ) + for v in versions + if v.version_number is not None + ] + + +@router.post("/{workflow_id}/publish") +async def publish_workflow( + workflow_id: int, + user: UserModel = Depends(get_user), +): + """Publish the current draft version of a workflow.""" + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if workflow is None: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + + try: + published = await db_client.publish_workflow_draft(workflow_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return { + "id": published.id, + "version_number": published.version_number, + "status": published.status, + "published_at": published.published_at, + } + + +@router.post("/{workflow_id}/create-draft") +async def create_workflow_draft( + workflow_id: int, + user: UserModel = Depends(get_user), +) -> WorkflowVersionResponse: + """Create a draft version from the current published version. + + If a draft already exists, returns the existing draft. + """ + workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if workflow is None: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + + draft = await db_client.save_workflow_draft(workflow_id) + return WorkflowVersionResponse( + id=draft.id, + version_number=draft.version_number, + status=draft.status, + created_at=draft.created_at, + published_at=draft.published_at, + workflow_json=mask_workflow_definition(draft.workflow_json), + workflow_configurations=draft.workflow_configurations, + template_context_variables=draft.template_context_variables, + ) + + @router.get("/summary") async def get_workflows_summary( user: UserModel = Depends(get_user), @@ -528,7 +648,7 @@ async def update_workflow_status( "status": workflow.status, "created_at": workflow.created_at, "workflow_definition": mask_workflow_definition( - workflow.workflow_definition_with_fallback + workflow.released_definition.workflow_json ), "current_definition_id": workflow.current_definition_id, "template_context_variables": workflow.template_context_variables, @@ -569,11 +689,37 @@ async def update_workflow( workflow_id, organization_id=user.selected_organization_id ) if existing_workflow: + # Merge against what the user was editing (draft or published) + existing_draft = await db_client.get_draft_version(workflow_id) + existing_def = ( + existing_draft.workflow_json + if existing_draft + else existing_workflow.released_definition.workflow_json + ) workflow_definition = merge_workflow_api_keys( workflow_definition, - existing_workflow.workflow_definition_with_fallback, + existing_def, ) + # Validate model_overrides: resolve onto global config, then + # run the same validator used by the user-configurations endpoint. + if request.workflow_configurations and request.workflow_configurations.get( + "model_overrides" + ): + user_config = await db_client.get_user_configurations(user.id) + try: + effective = resolve_effective_config( + user_config, + request.workflow_configurations["model_overrides"], + ) + await UserConfigurationValidator().validate( + effective, + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + workflow = await db_client.update_workflow( workflow_id=workflow_id, name=request.name, @@ -592,19 +738,35 @@ async def update_workflow( trigger_paths=trigger_paths, ) + # Return draft content if one exists (save creates a draft) + draft = await db_client.get_draft_version(workflow_id) + if draft: + workflow_def = draft.workflow_json + workflow_configs = draft.workflow_configurations + template_vars = draft.template_context_variables + else: + published = workflow.released_definition + workflow_def = published.workflow_json + workflow_configs = published.workflow_configurations + template_vars = published.template_context_variables + + # Include version info from the active definition (draft or published) + active_def = draft or workflow.released_definition return { "id": workflow.id, "name": workflow.name, "status": workflow.status, "created_at": workflow.created_at, - "workflow_definition": mask_workflow_definition( - workflow.workflow_definition_with_fallback - ), + "workflow_definition": mask_workflow_definition(workflow_def), "current_definition_id": workflow.current_definition_id, - "template_context_variables": workflow.template_context_variables, + "template_context_variables": template_vars, "call_disposition_codes": workflow.call_disposition_codes, - "workflow_configurations": workflow.workflow_configurations, + "workflow_configurations": workflow_configs, + "version_number": active_def.version_number if active_def else None, + "version_status": active_def.status if active_def else None, } + except HTTPException: + raise except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: @@ -629,7 +791,7 @@ async def duplicate_workflow_endpoint( "status": workflow.status, "created_at": workflow.created_at, "workflow_definition": mask_workflow_definition( - workflow.workflow_definition_with_fallback + workflow.released_definition.workflow_json ), "current_definition_id": workflow.current_definition_id, "template_context_variables": workflow.template_context_variables, @@ -658,7 +820,7 @@ async def create_workflow_run( user: The user to create the workflow run for """ run = await db_client.create_workflow_run( - request.name, workflow_id, request.mode, user.id + request.name, workflow_id, request.mode, user.id, use_draft=True ) return { "id": run.id, @@ -862,9 +1024,7 @@ async def duplicate_workflow_template( "name": workflow.name, "status": workflow.status, "created_at": workflow.created_at, - "workflow_definition": mask_workflow_definition( - workflow.workflow_definition_with_fallback - ), + "workflow_definition": mask_workflow_definition(workflow_def), "current_definition_id": workflow.current_definition_id, "template_context_variables": workflow.template_context_variables, "call_disposition_codes": workflow.call_disposition_codes, diff --git a/api/services/configuration/resolve.py b/api/services/configuration/resolve.py new file mode 100644 index 0000000..a55e61a --- /dev/null +++ b/api/services/configuration/resolve.py @@ -0,0 +1,83 @@ +"""Resolve effective config by merging per-workflow model overrides onto global config.""" + +from __future__ import annotations + +from api.schemas.user_configuration import UserConfiguration +from api.services.configuration.registry import ( + REGISTRY, + ServiceType, +) + +# Maps override key → (UserConfiguration field, ServiceType for registry lookup) +_SECTION_MAP: dict[str, ServiceType] = { + "llm": ServiceType.LLM, + "tts": ServiceType.TTS, + "stt": ServiceType.STT, + "realtime": ServiceType.REALTIME, +} + + +def _build_section_from_override(service_type: ServiceType, override: dict): + """Construct a typed config object from a raw override dict using the registry.""" + provider = override.get("provider") + if not provider: + return None + registry = REGISTRY.get(service_type, {}) + config_cls = registry.get(provider) + if config_cls is None: + return None + return config_cls(**override) + + +def resolve_effective_config( + user_config: UserConfiguration, + model_overrides: dict | None, +) -> UserConfiguration: + """Deep-merge workflow model_overrides onto global user config. + + - If model_overrides is None or empty, returns a copy of user_config unchanged. + - For each section (llm, tts, stt, realtime), if the override contains that key: + - If the global section is None, construct a new config from the override. + - If the provider changes, construct a new config from the override. + - Otherwise, merge override fields onto the existing config (model_copy). + - is_realtime is a simple boolean override. + - Sections not in the override are inherited from global unchanged. + - The original user_config is never mutated. + """ + if not model_overrides: + return user_config.model_copy(deep=True) + + effective = user_config.model_copy(deep=True) + + # Handle is_realtime boolean + if "is_realtime" in model_overrides: + effective.is_realtime = model_overrides["is_realtime"] + + # Handle service sections + for section_key, service_type in _SECTION_MAP.items(): + if section_key not in model_overrides: + continue + + override = model_overrides[section_key] + base = getattr(effective, section_key) + + if base is None: + # No global config for this section — build from override + setattr( + effective, + section_key, + _build_section_from_override(service_type, override), + ) + elif "provider" in override and override["provider"] != base.provider: + # Provider changed — must construct new typed object + setattr( + effective, + section_key, + _build_section_from_override(service_type, override), + ) + else: + # Same provider — merge fields onto existing config + merged = base.model_copy(update=override) + setattr(effective, section_key, merged) + + return effective diff --git a/api/services/looptalk/core/pipeline_builder.py b/api/services/looptalk/core/pipeline_builder.py index bc7b186..9d08b93 100644 --- a/api/services/looptalk/core/pipeline_builder.py +++ b/api/services/looptalk/core/pipeline_builder.py @@ -76,13 +76,15 @@ class LoopTalkPipelineBuilder: pipeline_sample_rate=16000, ) + # Use published definition for graph + configs + released_def = workflow.released_definition + wf_json = released_def.workflow_json + wf_configs = released_def.workflow_configurations or {} + # Extract keyterms from workflow configurations keyterms = None - if ( - workflow.workflow_configurations - and "dictionary" in workflow.workflow_configurations - ): - dictionary = workflow.workflow_configurations["dictionary"] + if wf_configs and "dictionary" in wf_configs: + dictionary = wf_configs["dictionary"] if dictionary and isinstance(dictionary, str): keyterms = [ term.strip() for term in dictionary.split(",") if term.strip() @@ -90,6 +92,12 @@ class LoopTalkPipelineBuilder: if keyterms: logger.info(f"Using {len(keyterms)} keyterms for STT: {keyterms}") + # Resolve model overrides from the version onto global user config + from api.services.configuration.resolve import resolve_effective_config + + model_overrides = wf_configs.get("model_overrides") + user_config = resolve_effective_config(user_config, model_overrides) + # Create services stt = create_stt_service(user_config, audio_config, keyterms=keyterms) llm = create_llm_service(user_config) @@ -98,9 +106,7 @@ class LoopTalkPipelineBuilder: logger.debug(f"Created services for {role}: STT={stt}, LLM={llm}, TTS={tts}") # Get workflow graph - workflow_graph = WorkflowGraph( - ReactFlowDTO.model_validate(workflow.workflow_definition_with_fallback) - ) + workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(wf_json)) # Create engine first (needed for create_pipeline_components) engine = PipecatEngine( diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 82ea3d1..fb97c19 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -562,50 +562,49 @@ async def _run_pipeline( # Get user configuration user_config = await db_client.get_user_configurations(user_id) - # Get workflow first so we can extract configurations before creating services + # Get workflow for metadata (name, organization_id, call_disposition_codes) workflow = await db_client.get_workflow(workflow_id, user_id) if not workflow: raise HTTPException(status_code=404, detail="Workflow not found") - # Extract configurations from workflow configurations + # Use the run's pinned definition for graph + configs (not the workflow's current) + run_definition = workflow_run.definition + run_workflow_json = run_definition.workflow_json + run_configs = run_definition.workflow_configurations or {} + + # Extract configurations from the version's workflow_configurations max_call_duration_seconds = 300 # Default 5 minutes max_user_idle_timeout = 10.0 # Default 10 seconds smart_turn_stop_secs = 2.0 # Default 2 seconds for incomplete turn timeout turn_stop_strategy = "transcription" # Default to transcription-based detection keyterms = None # Dictionary words for STT boosting - if workflow.workflow_configurations: - # Use workflow-specific max call duration if provided - if "max_call_duration" in workflow.workflow_configurations: - max_call_duration_seconds = workflow.workflow_configurations[ - "max_call_duration" - ] + if run_configs: + if "max_call_duration" in run_configs: + max_call_duration_seconds = run_configs["max_call_duration"] - # Use workflow-specific max user idle timeout if provided - if "max_user_idle_timeout" in workflow.workflow_configurations: - max_user_idle_timeout = workflow.workflow_configurations[ - "max_user_idle_timeout" - ] + if "max_user_idle_timeout" in run_configs: + max_user_idle_timeout = run_configs["max_user_idle_timeout"] - # Use workflow-specific smart turn stop timeout if provided - if "smart_turn_stop_secs" in workflow.workflow_configurations: - smart_turn_stop_secs = workflow.workflow_configurations[ - "smart_turn_stop_secs" - ] + if "smart_turn_stop_secs" in run_configs: + smart_turn_stop_secs = run_configs["smart_turn_stop_secs"] - # Use workflow-specific turn stop strategy if provided - if "turn_stop_strategy" in workflow.workflow_configurations: - turn_stop_strategy = workflow.workflow_configurations["turn_stop_strategy"] + if "turn_stop_strategy" in run_configs: + turn_stop_strategy = run_configs["turn_stop_strategy"] - # Extract dictionary words and convert to keyterms list - if "dictionary" in workflow.workflow_configurations: - dictionary = workflow.workflow_configurations["dictionary"] + if "dictionary" in run_configs: + dictionary = run_configs["dictionary"] if dictionary and isinstance(dictionary, str): - # Split by comma and strip whitespace from each term keyterms = [ term.strip() for term in dictionary.split(",") if term.strip() ] + # Resolve model overrides from the version onto global user config + from api.services.configuration.resolve import resolve_effective_config + + model_overrides = run_configs.get("model_overrides") + user_config = resolve_effective_config(user_config, model_overrides) + # Detect realtime mode (speech-to-speech services like OpenAI Realtime, Gemini Live) is_realtime = user_config.is_realtime and user_config.realtime is not None @@ -619,9 +618,7 @@ async def _run_pipeline( tts = create_tts_service(user_config, audio_config) llm = create_llm_service(user_config) - workflow_graph = WorkflowGraph( - ReactFlowDTO.model_validate(workflow.workflow_definition_with_fallback) - ) + workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(run_workflow_json)) # Pre-call fetch: fire early so it runs concurrently with remaining setup pre_call_fetch_task = None diff --git a/api/services/pipecat/service_factory.py b/api/services/pipecat/service_factory.py index ec02bf3..800f8ec 100644 --- a/api/services/pipecat/service_factory.py +++ b/api/services/pipecat/service_factory.py @@ -325,8 +325,6 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): silence_time_s=1.0, ) elif user_config.tts.provider == ServiceProviders.RIME.value: - from pipecat.transcriptions.language import Language - speed = getattr(user_config.tts, "speed", None) language_code = getattr(user_config.tts, "language", None) or "en" rime_language_mapping = { diff --git a/api/services/workflow/duplicate.py b/api/services/workflow/duplicate.py index 7f530c6..a14ae0b 100644 --- a/api/services/workflow/duplicate.py +++ b/api/services/workflow/duplicate.py @@ -74,13 +74,17 @@ async def duplicate_workflow( if source is None: raise ValueError(f"Workflow with id {workflow_id} not found") - workflow_definition = copy.deepcopy(source.workflow_definition_with_fallback) + # 2. Prefer draft over released definition (duplicate latest state) + draft = await db_client.get_draft_version(workflow_id) + source_def = draft if draft else source.released_definition - # 2. Regenerate trigger UUIDs to avoid conflicts + workflow_definition = copy.deepcopy(source_def.workflow_json) + + # 3. Regenerate trigger UUIDs to avoid conflicts if workflow_definition: workflow_definition = _regenerate_trigger_uuids(workflow_definition) - # 3. Create the new workflow + # 4. Create the new workflow new_name = f"{source.name} - Duplicate" new_workflow = await db_client.create_workflow( name=new_name, @@ -89,21 +93,20 @@ async def duplicate_workflow( organization_id=organization_id, ) - # 4. Copy template_context_variables and workflow_configurations - has_extra_fields = ( - source.template_context_variables or source.workflow_configurations - ) - if has_extra_fields: + # 5. Copy template_context_variables and workflow_configurations from source definition + source_tcv = source_def.template_context_variables + source_wc = source_def.workflow_configurations + if source_tcv or source_wc: new_workflow = await db_client.update_workflow( workflow_id=new_workflow.id, name=None, workflow_definition=None, - template_context_variables=copy.deepcopy(source.template_context_variables), - workflow_configurations=copy.deepcopy(source.workflow_configurations), + template_context_variables=copy.deepcopy(source_tcv), + workflow_configurations=copy.deepcopy(source_wc), organization_id=organization_id, ) - # 5. Copy recordings with new IDs and storage paths scoped to new workflow + # 6. Copy recordings with new IDs and storage paths scoped to new workflow recording_id_map = await _duplicate_recordings( source_workflow_id=workflow_id, new_workflow_id=new_workflow.id, @@ -111,7 +114,7 @@ async def duplicate_workflow( user_id=user_id, ) - # 6. Replace old recording IDs with new ones in the workflow definition + # 7. Replace old recording IDs with new ones in the workflow definition if recording_id_map: workflow_definition = _replace_recording_ids( workflow_definition, recording_id_map @@ -125,7 +128,7 @@ async def duplicate_workflow( organization_id=organization_id, ) - # 7. Sync triggers for the new workflow + # 8. Sync triggers for the new workflow if workflow_definition: trigger_paths = _extract_trigger_paths(workflow_definition) if trigger_paths: diff --git a/api/tasks/run_integrations.py b/api/tasks/run_integrations.py index 24cd2f9..4a035b5 100644 --- a/api/tasks/run_integrations.py +++ b/api/tasks/run_integrations.py @@ -187,15 +187,9 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int): secret_key=langfuse_config.get("secret_key"), ) - # Step 2: Get workflow definition (prefer the run-specific definition) - if workflow_run.definition: - workflow_definition = workflow_run.definition.workflow_json - definition_id = workflow_run.definition.id - else: - workflow_definition = ( - workflow_run.workflow.workflow_definition_with_fallback - ) - definition_id = workflow_run.workflow.current_definition_id + # Step 2: Get workflow definition from the run's pinned version + workflow_definition = workflow_run.definition.workflow_json + definition_id = workflow_run.definition.id if not workflow_definition: logger.debug("No workflow definition, skipping integrations") diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 8c01580..1ed93b1 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -80,11 +80,6 @@ class MockWorkflowModel: workflow_id: int = 1 organization_id: int = 1 workflow_configurations: Dict[str, Any] = field(default_factory=dict) - workflow_definition_with_fallback: Dict[str, Any] = field(default_factory=dict) - - def __post_init__(self): - if not self.workflow_definition_with_fallback: - self.workflow_definition_with_fallback = DEFAULT_WORKFLOW_DEFINITION.copy() @dataclass @@ -120,6 +115,7 @@ class MockToolModel: name: str description: str definition: Dict[str, Any] + category: str = "http_api" @pytest.fixture diff --git a/api/tests/test_resolve_effective_config.py b/api/tests/test_resolve_effective_config.py new file mode 100644 index 0000000..5d37058 --- /dev/null +++ b/api/tests/test_resolve_effective_config.py @@ -0,0 +1,353 @@ +""" +TDD tests for resolve_effective_config(). + +This function deep-merges workflow-level model_overrides onto the global +UserConfiguration. Fields not overridden inherit from global. + +Module under test: api.services.configuration.resolve +""" + +import pytest + +from api.schemas.user_configuration import UserConfiguration +from api.services.configuration.registry import ( + DeepgramSTTConfiguration, + ElevenlabsTTSConfiguration, + GoogleRealtimeLLMConfiguration, + OpenAILLMService, +) +from api.services.configuration.resolve import resolve_effective_config + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def global_config() -> UserConfiguration: + """A realistic global user configuration.""" + return UserConfiguration( + llm=OpenAILLMService( + provider="openai", api_key="sk-global-llm", model="gpt-4.1" + ), + tts=ElevenlabsTTSConfiguration( + provider="elevenlabs", + api_key="el-global-tts", + voice="Rachel", + model="eleven_flash_v2_5", + ), + stt=DeepgramSTTConfiguration( + provider="deepgram", + api_key="dg-global-stt", + model="nova-3-general", + language="multi", + ), + is_realtime=False, + realtime=None, + ) + + +@pytest.fixture +def global_config_realtime() -> UserConfiguration: + """Global config with realtime enabled.""" + return UserConfiguration( + llm=OpenAILLMService( + provider="openai", api_key="sk-global-llm", model="gpt-4.1" + ), + tts=ElevenlabsTTSConfiguration( + provider="elevenlabs", + api_key="el-global-tts", + voice="Rachel", + model="eleven_flash_v2_5", + ), + stt=DeepgramSTTConfiguration( + provider="deepgram", + api_key="dg-global-stt", + model="nova-3-general", + language="multi", + ), + is_realtime=True, + realtime=GoogleRealtimeLLMConfiguration( + provider="google_realtime", + api_key="goog-global-rt", + model="gemini-3.1-flash-live-preview", + voice="Puck", + language="en", + ), + ) + + +# --------------------------------------------------------------------------- +# No overrides → global returned unchanged +# --------------------------------------------------------------------------- + + +class TestNoOverrides: + def test_none_overrides_returns_global(self, global_config): + result = resolve_effective_config(global_config, None) + assert result.llm.model == "gpt-4.1" + assert result.tts.voice == "Rachel" + assert result.stt.model == "nova-3-general" + assert result.is_realtime is False + + def test_empty_dict_overrides_returns_global(self, global_config): + result = resolve_effective_config(global_config, {}) + assert result.llm.model == "gpt-4.1" + assert result.tts.voice == "Rachel" + + def test_does_not_mutate_original(self, global_config): + """The original config object must not be modified.""" + resolve_effective_config(global_config, {"llm": {"model": "gpt-4.1-mini"}}) + assert global_config.llm.model == "gpt-4.1" + + +# --------------------------------------------------------------------------- +# Single-field overrides within a section (same provider) +# --------------------------------------------------------------------------- + + +class TestSingleFieldOverride: + def test_override_llm_model_only(self, global_config): + result = resolve_effective_config( + global_config, {"llm": {"model": "gpt-4.1-mini"}} + ) + assert result.llm.model == "gpt-4.1-mini" + assert result.llm.provider == "openai" # inherited + assert result.llm.api_key == "sk-global-llm" # inherited + + def test_override_tts_voice_only(self, global_config): + result = resolve_effective_config(global_config, {"tts": {"voice": "shimmer"}}) + assert result.tts.voice == "shimmer" + assert result.tts.provider == "elevenlabs" # inherited + assert result.tts.api_key == "el-global-tts" # inherited + + def test_override_stt_language_only(self, global_config): + result = resolve_effective_config(global_config, {"stt": {"language": "en"}}) + assert result.stt.language == "en" + assert result.stt.model == "nova-3-general" # inherited + assert result.stt.provider == "deepgram" # inherited + + +# --------------------------------------------------------------------------- +# Provider change (requires full section replacement) +# --------------------------------------------------------------------------- + + +class TestProviderChange: + def test_override_llm_to_different_provider(self, global_config): + result = resolve_effective_config( + global_config, + { + "llm": { + "provider": "groq", + "api_key": "groq-key", + "model": "llama-3.3-70b-versatile", + } + }, + ) + assert result.llm.provider == "groq" + assert result.llm.model == "llama-3.3-70b-versatile" + assert result.llm.api_key == "groq-key" + + def test_provider_change_does_not_affect_other_sections(self, global_config): + result = resolve_effective_config( + global_config, + { + "llm": { + "provider": "groq", + "api_key": "groq-key", + "model": "llama-3.3-70b-versatile", + } + }, + ) + # TTS and STT unchanged + assert result.tts.provider == "elevenlabs" + assert result.stt.provider == "deepgram" + + +# --------------------------------------------------------------------------- +# API key inheritance +# --------------------------------------------------------------------------- + + +class TestAPIKeyInheritance: + def test_no_api_key_in_override_inherits_global(self, global_config): + """When override omits api_key, global key is used.""" + result = resolve_effective_config( + global_config, {"llm": {"model": "gpt-4.1-mini"}} + ) + assert result.llm.api_key == "sk-global-llm" + + def test_explicit_api_key_in_override_wins(self, global_config): + """When override includes api_key, it takes precedence.""" + result = resolve_effective_config( + global_config, + {"llm": {"model": "gpt-4.1-mini", "api_key": "sk-override-key"}}, + ) + assert result.llm.api_key == "sk-override-key" + + +# --------------------------------------------------------------------------- +# is_realtime override +# --------------------------------------------------------------------------- + + +class TestRealtimeOverride: + def test_enable_realtime_on_non_realtime_global(self, global_config): + result = resolve_effective_config( + global_config, + { + "is_realtime": True, + "realtime": { + "provider": "google_realtime", + "api_key": "goog-override", + "model": "gemini-3.1-flash-live-preview", + "voice": "Charon", + "language": "en", + }, + }, + ) + assert result.is_realtime is True + assert result.realtime.provider == "google_realtime" + assert result.realtime.voice == "Charon" + + def test_disable_realtime_on_realtime_global(self, global_config_realtime): + result = resolve_effective_config( + global_config_realtime, {"is_realtime": False} + ) + assert result.is_realtime is False + # Realtime config may still be present but is_realtime flag controls usage + + def test_override_realtime_voice_only(self, global_config_realtime): + result = resolve_effective_config( + global_config_realtime, {"realtime": {"voice": "Kore"}} + ) + assert result.realtime.voice == "Kore" + assert result.realtime.provider == "google_realtime" # inherited + assert result.realtime.api_key == "goog-global-rt" # inherited + + def test_override_is_realtime_only_without_realtime_section(self, global_config): + """Override is_realtime=True but provide no realtime config. + Should set the flag; realtime section stays None from global.""" + result = resolve_effective_config(global_config, {"is_realtime": True}) + assert result.is_realtime is True + assert result.realtime is None # no config provided + + +# --------------------------------------------------------------------------- +# Section override when global has None for that section +# --------------------------------------------------------------------------- + + +class TestOverrideOnNullGlobal: + def test_override_stt_when_global_is_none(self): + """When global has no STT config, override creates one from scratch.""" + config = UserConfiguration( + llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"), + stt=None, + tts=None, + is_realtime=False, + ) + result = resolve_effective_config( + config, + { + "stt": { + "provider": "deepgram", + "api_key": "dg-new", + "model": "nova-3-general", + "language": "en", + } + }, + ) + assert result.stt is not None + assert result.stt.provider == "deepgram" + assert result.stt.model == "nova-3-general" + + def test_override_realtime_when_global_is_none(self): + """Realtime section can be created from override even if global has none.""" + config = UserConfiguration( + llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"), + is_realtime=False, + realtime=None, + ) + result = resolve_effective_config( + config, + { + "is_realtime": True, + "realtime": { + "provider": "google_realtime", + "api_key": "goog-new", + "model": "gemini-3.1-flash-live-preview", + "voice": "Puck", + "language": "en", + }, + }, + ) + assert result.is_realtime is True + assert result.realtime.provider == "google_realtime" + + +# --------------------------------------------------------------------------- +# Multi-section overrides +# --------------------------------------------------------------------------- + + +class TestMultiSectionOverride: + def test_override_llm_and_tts_not_stt(self, global_config): + result = resolve_effective_config( + global_config, + { + "llm": {"model": "gpt-4.1-mini"}, + "tts": {"voice": "shimmer"}, + }, + ) + assert result.llm.model == "gpt-4.1-mini" + assert result.tts.voice == "shimmer" + # STT untouched + assert result.stt.model == "nova-3-general" + assert result.stt.language == "multi" + + def test_override_all_sections(self, global_config): + result = resolve_effective_config( + global_config, + { + "llm": {"model": "gpt-4.1-mini"}, + "tts": {"voice": "shimmer"}, + "stt": {"language": "en"}, + "is_realtime": True, + "realtime": { + "provider": "google_realtime", + "api_key": "goog-key", + "model": "gemini-3.1-flash-live-preview", + "voice": "Fenrir", + "language": "en", + }, + }, + ) + assert result.llm.model == "gpt-4.1-mini" + assert result.tts.voice == "shimmer" + assert result.stt.language == "en" + assert result.is_realtime is True + assert result.realtime.voice == "Fenrir" + + +# --------------------------------------------------------------------------- +# Ignored / unknown keys +# --------------------------------------------------------------------------- + + +class TestUnknownKeys: + def test_unknown_section_in_overrides_is_ignored(self, global_config): + """Override with a key that doesn't map to any section should not crash.""" + result = resolve_effective_config( + global_config, {"unknown_section": {"foo": "bar"}} + ) + assert result.llm.model == "gpt-4.1" + + def test_embeddings_not_overridable(self, global_config): + """Embeddings stay global — overrides for embeddings should be ignored.""" + result = resolve_effective_config( + global_config, + {"embeddings": {"provider": "openai", "model": "text-embedding-3-small"}}, + ) + assert result.embeddings is None # was None in global, stays None diff --git a/api/tests/test_user_turn_stop_scenarios.py b/api/tests/test_user_turn_stop_scenarios.py deleted file mode 100644 index 594dcff..0000000 --- a/api/tests/test_user_turn_stop_scenarios.py +++ /dev/null @@ -1,960 +0,0 @@ -"""Tests validating user turn stop strategy behavior during bot speaking scenarios. - -These tests validate the scenarios described in scenarios.md. They demonstrate -how the ExternalUserTurnStopStrategy and UserTurnController interact when frames -are suppressed (muted) during bot speaking. - -Key concepts: -- When the bot is speaking, AlwaysUserMuteStrategy causes the LLMUserAggregator - to suppress user frames (UserStartedSpeaking, UserStoppedSpeaking, Transcription, VAD). -- The ExternalUserTurnStopStrategy accumulates _text from TranscriptionFrames and - triggers a stop when _user_speaking is False and _text is truthy. -- The UserTurnController only allows a stop if _user_turn is True (a start must - have occurred first). When a stop is rejected, the controller unconditionally - resets all stop strategies, clearing any dangling state (e.g. _text). -- This unconditional reset prevents stale _text from causing premature stops - or contaminating subsequent turns. -""" - -import asyncio - -import pytest - -from pipecat.frames.frames import ( - BotStartedSpeakingFrame, - BotStoppedSpeakingFrame, - EndTaskFrame, - Frame, - TranscriptionFrame, - UserStartedSpeakingFrame, - UserStoppedSpeakingFrame, - VADUserStartedSpeakingFrame, - VADUserStoppedSpeakingFrame, -) -from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineParams, PipelineTask -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import ( - LLMAssistantAggregatorParams, - LLMContextAggregatorPair, - LLMUserAggregatorParams, -) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.tests import MockLLMService -from pipecat.turns.user_mute import AlwaysUserMuteStrategy -from pipecat.turns.user_start import VADUserTurnStartStrategy -from pipecat.turns.user_stop import ExternalUserTurnStopStrategy -from pipecat.turns.user_turn_strategies import UserTurnStrategies -from pipecat.utils.time import time_now_iso8601 - -# Short timeout for faster tests -STOP_STRATEGY_TIMEOUT = 0.15 -# Delay to allow async processing -ASYNC_DELAY = 0.05 -# Delay to wait for stop strategy timeout to fire -TIMEOUT_WAIT = STOP_STRATEGY_TIMEOUT + 0.1 - - -class FrameInjector(FrameProcessor): - """Simple processor that can inject frames into the pipeline.""" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - await self.push_frame(frame, direction) - - async def inject( - self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM - ): - """Inject a frame into the pipeline.""" - await self.push_frame(frame, direction) - - -def _build_components(llm_steps=None): - """Build pipeline components for testing. - - Uses: - - VADUserTurnStartStrategy: turn starts only when VADUserStartedSpeakingFrame arrives - - ExternalUserTurnStopStrategy: turn stops based on UserStoppedSpeakingFrame + _text - - AlwaysUserMuteStrategy: suppresses user frames while bot is speaking - - Returns a tuple of (injector, user_aggregator, stop_strategy, turn_controller, mock_llm, pipeline). - """ - context = LLMContext() - - stop_strategy = ExternalUserTurnStopStrategy(timeout=STOP_STRATEGY_TIMEOUT) - - user_turn_strategies = UserTurnStrategies( - start=[VADUserTurnStartStrategy()], - stop=[stop_strategy], - ) - - user_params = LLMUserAggregatorParams( - user_turn_strategies=user_turn_strategies, - user_mute_strategies=[AlwaysUserMuteStrategy()], - ) - assistant_params = LLMAssistantAggregatorParams(expect_stripped_words=True) - - context_aggregator = LLMContextAggregatorPair( - context, assistant_params=assistant_params, user_params=user_params - ) - user_agg = context_aggregator.user() - assistant_agg = context_aggregator.assistant() - - if llm_steps is None: - llm_steps = [ - MockLLMService.create_text_chunks(text="Response 1"), - MockLLMService.create_text_chunks(text="Response 2"), - MockLLMService.create_text_chunks(text="Response 3"), - ] - mock_llm = MockLLMService(mock_steps=llm_steps, chunk_delay=0.001) - - injector = FrameInjector() - pipeline = Pipeline([injector, user_agg, mock_llm, assistant_agg]) - - turn_controller = user_agg._user_turn_controller - - return ( - injector, - user_agg, - stop_strategy, - turn_controller, - mock_llm, - context, - pipeline, - ) - - -async def _run_scenario(pipeline, inject_fn): - """Run a pipeline with a frame injection coroutine.""" - task = PipelineTask(pipeline, params=PipelineParams(), enable_rtvi=False) - runner = PipelineRunner() - - async def run(): - await runner.run(task) - - async def inject(): - # Wait for pipeline to start (StartFrame to propagate) - await asyncio.sleep(ASYNC_DELAY) - await inject_fn() - - await asyncio.gather(run(), inject()) - - -async def _inject_user_turn(injector, text, delay=ASYNC_DELAY): - """Inject a complete user turn: VAD start + external start + transcription + external stop. - - This simulates what happens in a real pipeline when the user speaks: - 1. VAD detects speech -> VADUserStartedSpeakingFrame (triggers turn start) - 2. External processor sends UserStartedSpeakingFrame (stop strategy tracks _user_speaking) - 3. STT produces TranscriptionFrame (stop strategy accumulates _text) - 4. External processor sends UserStoppedSpeakingFrame (stop strategy triggers stop) - """ - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(delay) - await injector.inject(TranscriptionFrame(text, "user-1", time_now_iso8601())) - - -class TestUserTurnStopScenarios: - """Test scenarios from scenarios.md. - - Each test simulates a specific frame ordering to validate the interaction - between ExternalUserTurnStopStrategy and UserTurnController, particularly - around frame suppression during bot speaking. - """ - - # ========================================================================= - # Scenario 1 (✅): All frames suppressed during bot speaking - # - # BotStartedSpeaking (muted) - # UserStartedSpeaking (suppressed) - # TranscriptionFrame (suppressed) - # UserStoppedSpeaking (suppressed) - # BotStoppedSpeaking (unmuted) - # - # Stop strategy _text is empty because TranscriptionFrame was suppressed. - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_1_all_suppressed_then_bot_stops(self): - """All user frames suppressed during bot speaking, then bot stops. - - Expected: _text is empty, no turn triggered, clean state. - Second turn works correctly. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Bot speaking, all user frames suppressed === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # These are all suppressed by mute - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject( - TranscriptionFrame("hello", "user-1", time_now_iso8601()) - ) - await asyncio.sleep(0) - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(VADUserStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(TIMEOUT_WAIT) - - # Assert: _text should be empty (all frames suppressed) - assert stop_strategy._text == "", ( - f"Expected empty _text after all frames suppressed, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn, "Expected _user_turn to be False" - - # === Turn 2: Normal turn should work correctly === - await _inject_user_turn(injector, "second turn text") - await asyncio.sleep(TIMEOUT_WAIT) - - # Assert: turn completed, _text cleared by reset - assert stop_strategy._text == "", ( - f"Expected empty _text after clean turn, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn, ( - "Expected _user_turn to be False after turn" - ) - assert mock_llm.get_current_step() == 1, ( - f"Expected 1 LLM call (turn 2 only), got {mock_llm.get_current_step()}" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Scenario 2 (✅): User frames suppressed, user stops after bot stops - # - # BotStartedSpeaking (muted) - # UserStartedSpeaking (suppressed) - # TranscriptionFrame (suppressed) - # BotStoppedSpeaking (unmuted) - # UserStoppedSpeaking (stop strategy has no _text -> no trigger) - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_2_user_stops_after_bot_stops_no_text(self): - """User stops speaking after bot stops, but transcription was suppressed. - - Expected: _text is empty because transcription was suppressed. - UserStoppedSpeaking doesn't trigger stop (no _text). - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Bot speaking, user frames partially suppressed === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Suppressed during bot speaking - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject( - TranscriptionFrame("hello", "user-1", time_now_iso8601()) - ) - await asyncio.sleep(ASYNC_DELAY) - - # Bot stops -> unmuted - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # UserStoppedSpeaking arrives after unmute, but _text is empty - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(TIMEOUT_WAIT) - - # Assert: _text empty (TranscriptionFrame was suppressed) - assert stop_strategy._text == "", ( - f"Expected empty _text, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn, "Expected _user_turn to be False" - - # === Turn 2: Normal turn should work === - await _inject_user_turn(injector, "second turn") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "", "Expected clean _text after turn 2" - assert mock_llm.get_current_step() == 1, ( - f"Expected 1 LLM call, got {mock_llm.get_current_step()}" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Scenario 3 (✅ after fix): Transcription arrives after unmute - # - # BotStartedSpeaking (muted) - # UserStartedSpeaking (suppressed) - # BotStoppedSpeaking (unmuted) - # TranscriptionFrame -> stop strategy _text = "hello" - # UserStoppedSpeaking -> stop strategy triggers (text truthy, not speaking) - # Turn controller ignores (user_turn is False), BUT unconditionally - # resets stop strategies -> _text cleared. No dangling state. - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_3_transcription_after_unmute_text_cleared(self): - """Transcription arrives after bot stops but turn was never started. - - The VADUserStartedSpeakingFrame was suppressed, so no turn started. - But TranscriptionFrame arrives after unmute and accumulates _text. - The stop strategy triggers, but the turn controller rejects it - (no active turn). The unconditional reset clears _text, preventing - any dangling state from contaminating subsequent turns. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Rejected stop with unconditional reset === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Suppressed: VAD and UserStartedSpeaking - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Bot stops -> unmuted - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Install spy on trigger_user_turn_stopped to track every call - # and the _user_turn state at the time of each call. - trigger_stop_calls = [] - original_trigger_stop = stop_strategy.trigger_user_turn_stopped - - async def spy_trigger_stop(): - trigger_stop_calls.append(turn_ctrl._user_turn) - await original_trigger_stop() - - stop_strategy.trigger_user_turn_stopped = spy_trigger_stop - - # TranscriptionFrame arrives AFTER unmute -> reaches stop strategy - await injector.inject( - TranscriptionFrame("hello", "user-1", time_now_iso8601()) - ) - await asyncio.sleep(ASYNC_DELAY) - - # UserStoppedSpeaking arrives AFTER unmute - # Stop strategy: _user_speaking is False (UserStartedSpeaking was suppressed), - # _text is "hello" -> triggers stop via _handle_user_stopped_speaking - # Turn controller: _user_turn is False -> rejects, but resets -> _text cleared - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Call #1: _handle_user_stopped_speaking -> _maybe_trigger_user_turn_stopped - assert len(trigger_stop_calls) == 1, ( - f"Expected exactly 1 trigger_user_turn_stopped call from " - f"_handle_user_stopped_speaking, got {len(trigger_stop_calls)}" - ) - assert trigger_stop_calls[0] is False, ( - "Expected _user_turn=False when _handle_user_stopped_speaking triggered stop" - ) - - # Wait for _task_handler timeout period - await asyncio.sleep(TIMEOUT_WAIT) - - # The unconditional reset cleared _text after the rejected stop, - # so the timeout's _maybe_trigger_user_turn_stopped sees _text="" and - # does NOT call trigger_user_turn_stopped again. - assert len(trigger_stop_calls) == 1, ( - f"Expected no additional trigger_user_turn_stopped calls after " - f"reset cleared _text, but got {len(trigger_stop_calls)} total call(s)" - ) - - # Restore original method - stop_strategy.trigger_user_turn_stopped = original_trigger_stop - - # Transcript is not suppressed, so we should have hello in user aggregator - assert user_agg._aggregation[0].text == "hello" - - # Assert: _text is cleared by the unconditional reset (no dangling state) - assert stop_strategy._text == "", ( - f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn, ( - "Expected _user_turn to be False (turn was never started)" - ) - # No LLM call should have happened - assert mock_llm.get_current_step() == 0, ( - f"Expected 0 LLM calls, got {mock_llm.get_current_step()}" - ) - - # === Turn 2: No premature stop, normal flow === - # _text is clean, so UserStoppedSpeaking won't trigger a premature stop. - # The turn completes normally when the timeout fires after TranscriptionFrame. - # The aggregator still has dangling "hello" from turn 1, which gets - # combined with turn 2's "world" — this is acceptable behavior. - await _inject_user_turn(injector, "world") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "", ( - f"Expected clean _text after normal turn, got '{stop_strategy._text}'" - ) - assert mock_llm.get_current_step() == 1, ( - f"Expected 1 LLM call (normal turn), got {mock_llm.get_current_step()}" - ) - - # The LLM received both "hello" (dangling in aggregator from turn 1) - # and "world" (from turn 2). This is acceptable — the aggregator's - # _aggregation is a separate concern from the stop strategy's _text. - messages = context.messages - user_messages = [m for m in messages if m.get("role") == "user"] - assert len(user_messages) == 1, ( - f"Expected 1 user message, got {len(user_messages)}" - ) - user_text = user_messages[0]["content"] - assert "hello" in user_text, ( - f"Expected 'hello' (from aggregator) in user message, got: '{user_text}'" - ) - assert "world" in user_text, ( - f"Expected 'world' (from turn 2) in user message, got: '{user_text}'" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Scenario 4 (✅): User speaks after bot stops -> normal flow - # - # BotStartedSpeaking (muted) - # BotStoppedSpeaking (unmuted) - # UserStartedSpeaking (triggers interruption/turn start) - # TranscriptionFrame - # UserStoppedSpeaking - # - # Turn starts because VAD frame is not suppressed. Everything works. - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_4_user_speaks_after_bot_stops(self): - """User speaks after bot stops speaking. Normal flow, everything works. - - All frames arrive after unmute, so VAD triggers turn start normally. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Bot speaks, then user speaks after bot stops === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Normal user turn after bot stopped - await _inject_user_turn(injector, "hello after bot") - await asyncio.sleep(TIMEOUT_WAIT) - - # Assert: clean state - assert stop_strategy._text == "", ( - f"Expected empty _text after clean turn, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn, "Expected _user_turn False after turn" - assert mock_llm.get_current_step() == 1, ( - f"Expected 1 LLM call, got {mock_llm.get_current_step()}" - ) - - # === Turn 2: Another normal turn === - await _inject_user_turn(injector, "second turn") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "", "Expected clean _text after turn 2" - assert mock_llm.get_current_step() == 2, ( - f"Expected 2 LLM calls, got {mock_llm.get_current_step()}" - ) - - # Verify clean context - each turn should be separate - user_messages = [m for m in context.messages if m.get("role") == "user"] - assert len(user_messages) == 2, ( - f"Expected 2 user messages (one per turn), got {len(user_messages)}" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Scenario 5 (✅): Late transcription - all suppressed - # - # BotStartedSpeaking (muted) - # UserStartedSpeaking (suppressed) - # UserStoppedSpeaking (suppressed) - # TranscriptionFrame (suppressed) <- late, but still during bot speaking - # BotStoppedSpeaking (unmuted) - # - # Everything suppressed, _text empty. Clean state. - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_5_late_transcription_all_suppressed(self): - """Late transcription arrives during bot speaking. All suppressed. - - Even though transcription is late, it still arrives before BotStoppedSpeaking - so it's still muted. Clean state. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Late transcription, but all still suppressed === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(VADUserStoppedSpeakingFrame()) - await asyncio.sleep(0) - # Late transcription - but still during bot speaking - await injector.inject( - TranscriptionFrame("late hello", "user-1", time_now_iso8601()) - ) - await asyncio.sleep(ASYNC_DELAY) - - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(TIMEOUT_WAIT) - - # Assert: all suppressed, clean state - assert stop_strategy._text == "", ( - f"Expected empty _text, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn - - # === Turn 2: Normal turn works === - await _inject_user_turn(injector, "clean turn") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "" - assert mock_llm.get_current_step() == 1 - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Scenario 6 (✅ after fix): Late transcription arrives after bot stops - # - # BotStartedSpeaking (muted) - # UserStartedSpeaking (suppressed) - # UserStoppedSpeaking (suppressed) - # BotStoppedSpeaking (unmuted) - # TranscriptionFrame -> reaches stop strategy, _text = "late hello" - # - # Stop strategy timeout fires: _user_speaking is False (from initial state, - # UserStartedSpeaking was suppressed), _text truthy -> triggers stop. - # Turn controller: _user_turn False -> rejects, but unconditionally resets - # -> _text cleared. No dangling state. - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_6_late_transcription_after_unmute_text_cleared(self): - """Late transcription arrives after bot stops. No turn was started. - - UserStartedSpeaking was suppressed so _user_turn never started. - The late TranscriptionFrame accumulates _text after unmute. - The stop strategy timeout triggers, but controller rejects it. - The unconditional reset clears _text, preventing dangling state. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Late transcription scenario === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Suppressed - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(VADUserStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Bot stops -> unmuted - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Install spy on trigger_user_turn_stopped to track calls - trigger_stop_calls = [] - original_trigger_stop = stop_strategy.trigger_user_turn_stopped - - async def spy_trigger_stop(): - trigger_stop_calls.append(turn_ctrl._user_turn) - await original_trigger_stop() - - stop_strategy.trigger_user_turn_stopped = spy_trigger_stop - - # Late transcription arrives after unmute - await injector.inject( - TranscriptionFrame("late hello", "user-1", time_now_iso8601()) - ) - - # No UserStoppedSpeakingFrame in this scenario — the stop is - # triggered ONLY by the _task_handler timeout path. - await asyncio.sleep(TIMEOUT_WAIT) - - # The _task_handler timeout fired _maybe_trigger_user_turn_stopped: - # _user_speaking=False (UserStartedSpeaking was suppressed), - # _text="late hello" -> trigger_user_turn_stopped called - # Turn controller: _user_turn=False -> rejects, but resets -> _text cleared - assert len(trigger_stop_calls) == 1, ( - f"Expected exactly 1 trigger_user_turn_stopped call from " - f"_task_handler timeout, got {len(trigger_stop_calls)}" - ) - assert trigger_stop_calls[0] is False, ( - "Expected _user_turn=False when timeout triggered stop" - ) - - # Restore original method - stop_strategy.trigger_user_turn_stopped = original_trigger_stop - - # Transcript is not suppressed, so we should have late hello in user aggregator - assert user_agg._aggregation[0].text == "late hello" - - # Assert: _text is cleared by the unconditional reset (no dangling state) - assert stop_strategy._text == "", ( - f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn, "Turn should not have started" - assert mock_llm.get_current_step() == 0, "No LLM call expected" - - # === Turn 2: No premature stop, normal flow === - # _text is clean, so no premature stop occurs. - # The turn completes normally when the timeout fires after TranscriptionFrame. - # The aggregator still has dangling "late hello" from turn 1, which gets - # combined with turn 2's "real speech" — this is acceptable behavior. - await _inject_user_turn(injector, "real speech") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "", ( - f"Expected clean _text after normal turn, got '{stop_strategy._text}'" - ) - assert mock_llm.get_current_step() == 1, ( - f"Expected 1 LLM call (normal turn), got {mock_llm.get_current_step()}" - ) - - # The LLM received both "late hello" (dangling in aggregator from turn 1) - # and "real speech" (from turn 2). - user_messages = [m for m in context.messages if m.get("role") == "user"] - assert len(user_messages) == 1, ( - f"Expected 1 user message, got {len(user_messages)}" - ) - user_text = user_messages[0]["content"] - assert "late hello" in user_text, ( - f"Expected 'late hello' (from aggregator) in user message, got: '{user_text}'" - ) - assert "real speech" in user_text, ( - f"Expected 'real speech' (from turn 2) in user message, got: '{user_text}'" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Scenario 7 (✅ after fix): Late transcription - user stops before transcription - # - # BotStartedSpeaking (muted) - # UserStartedSpeaking (suppressed) - # BotStoppedSpeaking (unmuted) - # UserStoppedSpeaking (no _text yet -> no trigger from _handle_user_stopped) - # TranscriptionFrame -> _text = "late", timeout triggers stop - # - # Turn controller: _user_turn False -> rejects, but unconditionally resets - # -> _text cleared. No dangling state. - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_7_late_transcription_after_user_stops_text_cleared(self): - """User stops speaking, then late transcription arrives. No turn started. - - UserStoppedSpeaking arrives first (no _text yet, so no trigger). - Then TranscriptionFrame arrives (sets _text). The timeout fires and - triggers stop, but controller rejects it. The unconditional reset - clears _text, preventing dangling state. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Late transcription after user stops === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Suppressed - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Bot stops -> unmuted - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # UserStoppedSpeaking arrives after unmute, but _text is still empty - # -> _maybe_trigger_user_turn_stopped: _text is "" -> no trigger - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Late transcription arrives AFTER user stopped - await injector.inject( - TranscriptionFrame("late text", "user-1", time_now_iso8601()) - ) - # Wait for timeout to fire - await asyncio.sleep(TIMEOUT_WAIT) - - # Transcript is not suppressed, so we should have late text in user aggregator - assert user_agg._aggregation[0].text == "late text" - - # Assert: _text is cleared by the unconditional reset - # The timeout fired _maybe_trigger_user_turn_stopped: - # _user_speaking=False (was never set, UserStartedSpeaking suppressed), - # _text="late text" -> triggers stop - # Turn controller: _user_turn=False -> rejects, but resets -> _text cleared - assert stop_strategy._text == "", ( - f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn - assert mock_llm.get_current_step() == 0 - - # === Turn 2: No premature stop, normal flow === - # _text is clean, so no premature stop occurs. - # The turn completes normally when the timeout fires after TranscriptionFrame. - # The aggregator still has dangling "late text" from turn 1, which gets - # combined with turn 2's "next speech" — this is acceptable behavior. - await _inject_user_turn(injector, "next speech") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "", ( - f"Expected clean _text after normal turn, got '{stop_strategy._text}'" - ) - assert mock_llm.get_current_step() == 1 - - # The LLM received both "late text" (dangling in aggregator from turn 1) - # and "next speech" (from turn 2). - user_messages = [m for m in context.messages if m.get("role") == "user"] - assert len(user_messages) == 1 - user_text = user_messages[0]["content"] - assert "late text" in user_text, ( - f"Expected 'late text' (from aggregator) in context, got: '{user_text}'" - ) - assert "next speech" in user_text, ( - f"Expected 'next speech' (from turn 2) in context, got: '{user_text}'" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Scenario 8 (✅): Late transcription - user speaks after bot stops - # - # BotStartedSpeaking (muted) - # BotStoppedSpeaking (unmuted) - # UserStartedSpeaking (not suppressed -> turn starts, start strategies reset) - # UserStoppedSpeaking (no _text -> no trigger) - # TranscriptionFrame (timeout triggers stop) - # - # Turn controller: _user_turn IS True -> allows stop -> resets strategies - # Clean state! - # ========================================================================= - - @pytest.mark.asyncio - async def test_scenario_8_late_transcription_user_speaks_after_bot_stops(self): - """User speaks after bot stops, then late transcription arrives. - - Because user spoke after unmute, VAD triggers turn start -> _user_turn=True. - When the late transcription triggers the stop, controller allows it and - resets strategies. Clean state. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Turn 1: Late transcription but user spoke after unmute === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # User speaks AFTER bot stops -> not suppressed - await injector.inject(VADUserStartedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # User stops speaking (no _text yet, so stop strategy doesn't trigger) - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(0) - await injector.inject(VADUserStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Late transcription arrives - await injector.inject( - TranscriptionFrame("late but ok", "user-1", time_now_iso8601()) - ) - # Wait for timeout to trigger stop - await asyncio.sleep(TIMEOUT_WAIT) - - # Assert: turn controller allowed the stop, strategies were reset - assert stop_strategy._text == "", ( - f"Expected clean _text after allowed stop, got '{stop_strategy._text}'" - ) - assert not turn_ctrl._user_turn, "Turn should have stopped" - assert mock_llm.get_current_step() == 1, ( - f"Expected 1 LLM call, got {mock_llm.get_current_step()}" - ) - - # === Turn 2: Clean subsequent turn === - await _inject_user_turn(injector, "clean turn") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "" - assert mock_llm.get_current_step() == 2 - - # Verify each turn is separate in context - user_messages = [m for m in context.messages if m.get("role") == "user"] - assert len(user_messages) == 2, ( - f"Expected 2 separate user messages, got {len(user_messages)}" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) - - # ========================================================================= - # Combined test: validates _text is cleared independently after each - # rejected stop, preventing accumulation across muted periods. - # ========================================================================= - - @pytest.mark.asyncio - async def test_text_cleared_independently_across_failed_stops(self): - """Validates _text does not accumulate across multiple failed stop attempts. - - Two consecutive muted periods with late transcriptions each trigger - a rejected stop. The unconditional reset clears _text after each - rejection, so no accumulation occurs. The subsequent normal turn - completes correctly. - """ - injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = ( - _build_components() - ) - - async def inject(): - # === Muted period 1: _text cleared after rejected stop === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - await injector.inject(VADUserStartedSpeakingFrame()) # suppressed - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) # suppressed - await asyncio.sleep(ASYNC_DELAY) - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - # Late transcription after unmute - await injector.inject( - TranscriptionFrame("first", "user-1", time_now_iso8601()) - ) - await asyncio.sleep(0) - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(TIMEOUT_WAIT) - - # Transcript is not suppressed, so we should have first in user aggregator - assert user_agg._aggregation[0].text == "first" - - # _text is cleared by the unconditional reset after rejected stop - assert stop_strategy._text == "", ( - f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'" - ) - - # === Muted period 2: _text cleared independently, no accumulation === - await injector.inject(BotStartedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - await injector.inject(VADUserStartedSpeakingFrame()) # suppressed - await asyncio.sleep(0) - await injector.inject(UserStartedSpeakingFrame()) # suppressed - await asyncio.sleep(ASYNC_DELAY) - await injector.inject(BotStoppedSpeakingFrame()) - await asyncio.sleep(ASYNC_DELAY) - - await injector.inject( - TranscriptionFrame("second", "user-1", time_now_iso8601()) - ) - await asyncio.sleep(0) - await injector.inject(UserStoppedSpeakingFrame()) - await asyncio.sleep(TIMEOUT_WAIT) - - # _text is cleared again — no accumulation of "first" + "second" - assert stop_strategy._text == "", ( - f"Expected empty _text after second unconditional reset, got '{stop_strategy._text}'" - ) - # Aggregator accumulated both (separate concern, acceptable) - assert len(user_agg._aggregation) == 2 - assert user_agg._aggregation[0].text == "first" - assert user_agg._aggregation[1].text == "second" - - # === Turn 3: No premature stop, normal flow === - # _text is clean, so no premature stop occurs. - # The turn completes normally when the timeout fires after TranscriptionFrame. - # The aggregator has dangling "first" + "second" from muted periods, - # which get combined with turn 3's "actual speech". - await _inject_user_turn(injector, "actual speech") - await asyncio.sleep(TIMEOUT_WAIT) - - assert stop_strategy._text == "", ( - f"Expected clean _text after normal turn, got '{stop_strategy._text}'" - ) - assert mock_llm.get_current_step() == 1 - - # The LLM received all three: "first" + "second" (from aggregator) - # and "actual speech" (from turn 3). - user_messages = [m for m in context.messages if m.get("role") == "user"] - assert len(user_messages) == 1, ( - f"Expected 1 user message, got {len(user_messages)}" - ) - user_text = user_messages[0]["content"] - assert "first" in user_text, f"Expected 'first' in '{user_text}'" - assert "second" in user_text, f"Expected 'second' in '{user_text}'" - assert "actual speech" in user_text, ( - f"Expected 'actual speech' in '{user_text}'" - ) - - await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) - - await _run_scenario(pipeline, inject) diff --git a/api/tests/test_workflow_versioning.py b/api/tests/test_workflow_versioning.py new file mode 100644 index 0000000..800fe73 --- /dev/null +++ b/api/tests/test_workflow_versioning.py @@ -0,0 +1,608 @@ +""" +TDD tests for workflow versioning lifecycle. + +Tests the version lifecycle on WorkflowDefinitionModel: + - status: draft / published / archived + - version_number: sequential per workflow + - released_definition_id on WorkflowModel + +Modules under test: + - api.db.workflow_client (new versioning methods) + - api.db.models (new columns on WorkflowDefinitionModel, WorkflowModel) + +These are DB integration tests using the transactional test session. +""" + +import pytest + +from api.db.models import ( + OrganizationModel, + UserModel, +) + +# Sample workflow definitions (graph JSON) +GRAPH_V1 = { + "nodes": [ + {"id": "1", "type": "startCall", "data": {"name": "Start", "prompt": "Hello"}}, + {"id": "2", "type": "endCall", "data": {"name": "End", "prompt": "Bye"}}, + ], + "edges": [{"id": "e1", "source": "1", "target": "2", "data": {"label": "End"}}], +} + +GRAPH_V2 = { + "nodes": [ + { + "id": "1", + "type": "startCall", + "data": {"name": "Start", "prompt": "Hello v2"}, + }, + { + "id": "2", + "type": "agentNode", + "data": {"name": "Agent", "prompt": "Collect info"}, + }, + {"id": "3", "type": "endCall", "data": {"name": "End", "prompt": "Bye"}}, + ], + "edges": [ + {"id": "e1", "source": "1", "target": "2", "data": {"label": "Collect"}}, + {"id": "e2", "source": "2", "target": "3", "data": {"label": "End"}}, + ], +} + +GRAPH_V3 = { + "nodes": [ + { + "id": "1", + "type": "startCall", + "data": {"name": "Start", "prompt": "Hello v3"}, + }, + {"id": "2", "type": "endCall", "data": {"name": "End", "prompt": "Goodbye"}}, + ], + "edges": [{"id": "e1", "source": "1", "target": "2", "data": {"label": "End"}}], +} + +CONFIG_V1 = {"max_call_duration": 300} +CONFIG_V2 = { + "max_call_duration": 600, + "model_overrides": {"llm": {"model": "gpt-4.1-mini"}}, +} +TEMPLATE_VARS_V1 = {"company_name": "Acme"} +TEMPLATE_VARS_V2 = {"company_name": "Acme Inc"} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def org_and_user(async_session): + """Create an organization and user for workflow tests.""" + org = OrganizationModel(provider_id="test-org-versioning") + async_session.add(org) + await async_session.flush() + + user = UserModel( + provider_id="test-user-versioning", selected_organization_id=org.id + ) + async_session.add(user) + await async_session.flush() + + return org, user + + +@pytest.fixture +async def workflow_with_v1(db_session, org_and_user): + """Create a workflow — should produce V1 as published.""" + org, user = org_and_user + workflow = await db_session.create_workflow( + name="Test Workflow", + workflow_definition=GRAPH_V1, + user_id=user.id, + organization_id=org.id, + ) + return workflow, user + + +# --------------------------------------------------------------------------- +# Workflow creation → V1 published +# --------------------------------------------------------------------------- + + +class TestWorkflowCreation: + async def test_create_workflow_produces_published_v1( + self, db_session, org_and_user + ): + """Creating a new workflow should produce exactly one definition + with status='published' and version_number=1.""" + org, user = org_and_user + workflow = await db_session.create_workflow( + name="New Workflow", + workflow_definition=GRAPH_V1, + user_id=user.id, + organization_id=org.id, + ) + + versions = await db_session.get_workflow_versions(workflow.id) + assert len(versions) == 1 + + v1 = versions[0] + assert v1.status == "published" + assert v1.version_number == 1 + assert v1.workflow_json == GRAPH_V1 + + async def test_create_workflow_sets_released_pointer( + self, db_session, org_and_user + ): + """The workflow's released_definition_id should point to V1.""" + org, user = org_and_user + workflow = await db_session.create_workflow( + name="Pointer Test", + workflow_definition=GRAPH_V1, + user_id=user.id, + organization_id=org.id, + ) + + versions = await db_session.get_workflow_versions(workflow.id) + assert workflow.released_definition_id == versions[0].id + + +# --------------------------------------------------------------------------- +# Saving a draft +# --------------------------------------------------------------------------- + + +class TestSaveDraft: + async def test_save_draft_creates_draft_version(self, db_session, workflow_with_v1): + """Saving changes to a published workflow creates a draft version.""" + workflow, user = workflow_with_v1 + + draft = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + workflow_configurations=CONFIG_V2, + template_context_variables=TEMPLATE_VARS_V2, + ) + + assert draft.status == "draft" + assert draft.version_number == 2 + assert draft.workflow_json == GRAPH_V2 + assert draft.workflow_configurations == CONFIG_V2 + assert draft.template_context_variables == TEMPLATE_VARS_V2 + + async def test_save_draft_does_not_change_released_pointer( + self, db_session, workflow_with_v1 + ): + """Creating a draft must not move the released pointer.""" + workflow, user = workflow_with_v1 + original_released_id = workflow.released_definition_id + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + + refreshed = await db_session.get_workflow(workflow.id) + assert refreshed.released_definition_id == original_released_id + + async def test_save_draft_twice_updates_in_place( + self, db_session, workflow_with_v1 + ): + """Saving a second draft should update the existing draft, not create a new row.""" + workflow, user = workflow_with_v1 + + draft1 = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + + draft2 = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V3, + ) + + assert draft1.id == draft2.id # same row + assert draft2.workflow_json == GRAPH_V3 + assert draft2.version_number == 2 # unchanged + + versions = await db_session.get_workflow_versions(workflow.id) + assert len(versions) == 2 # V1 published + V2 draft, no extras + + async def test_save_draft_with_only_config_change( + self, db_session, workflow_with_v1 + ): + """A draft can change only configs, keeping the same graph.""" + workflow, user = workflow_with_v1 + + draft = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V1, # same graph + workflow_configurations=CONFIG_V2, # different config + ) + + assert draft.status == "draft" + assert draft.workflow_json == GRAPH_V1 + assert draft.workflow_configurations == CONFIG_V2 + + +# --------------------------------------------------------------------------- +# Publishing a draft +# --------------------------------------------------------------------------- + + +class TestPublishDraft: + async def test_publish_promotes_draft_to_published( + self, db_session, workflow_with_v1 + ): + """Publishing moves draft → published and old published → archived.""" + workflow, user = workflow_with_v1 + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + workflow_configurations=CONFIG_V2, + ) + + published = await db_session.publish_workflow_draft(workflow.id) + + assert published.status == "published" + assert published.workflow_json == GRAPH_V2 + + versions = await db_session.get_workflow_versions(workflow.id) + statuses = {v.version_number: v.status for v in versions} + assert statuses[1] == "archived" + assert statuses[2] == "published" + + async def test_publish_updates_released_pointer(self, db_session, workflow_with_v1): + """After publishing, released_definition_id should point to the new version.""" + workflow, user = workflow_with_v1 + + draft = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + + await db_session.publish_workflow_draft(workflow.id) + + refreshed = await db_session.get_workflow(workflow.id) + assert refreshed.released_definition_id == draft.id + + async def test_publish_sets_published_at(self, db_session, workflow_with_v1): + """Published version should have a published_at timestamp.""" + workflow, user = workflow_with_v1 + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + + published = await db_session.publish_workflow_draft(workflow.id) + assert published.published_at is not None + + async def test_publish_with_no_draft_raises(self, db_session, workflow_with_v1): + """Publishing when no draft exists should raise an error.""" + workflow, user = workflow_with_v1 + + with pytest.raises(ValueError, match="[Nn]o draft"): + await db_session.publish_workflow_draft(workflow.id) + + async def test_exactly_one_published_after_multiple_cycles( + self, db_session, workflow_with_v1 + ): + """After several draft/publish cycles, exactly one version is published.""" + workflow, user = workflow_with_v1 + + # Cycle 1 + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + await db_session.publish_workflow_draft(workflow.id) + + # Cycle 2 + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V3, + ) + await db_session.publish_workflow_draft(workflow.id) + + versions = await db_session.get_workflow_versions(workflow.id) + published = [v for v in versions if v.status == "published"] + assert len(published) == 1 + assert published[0].version_number == 3 + + +# --------------------------------------------------------------------------- +# Discarding a draft +# --------------------------------------------------------------------------- + + +class TestDiscardDraft: + async def test_discard_removes_draft(self, db_session, workflow_with_v1): + """Discarding a draft should delete the draft row.""" + workflow, user = workflow_with_v1 + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + + await db_session.discard_workflow_draft(workflow.id) + + versions = await db_session.get_workflow_versions(workflow.id) + assert len(versions) == 1 + assert versions[0].status == "published" + + async def test_discard_does_not_affect_published( + self, db_session, workflow_with_v1 + ): + """Published version and released pointer are unchanged after discard.""" + workflow, user = workflow_with_v1 + original_released_id = workflow.released_definition_id + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + await db_session.discard_workflow_draft(workflow.id) + + refreshed = await db_session.get_workflow(workflow.id) + assert refreshed.released_definition_id == original_released_id + + async def test_discard_when_no_draft_raises(self, db_session, workflow_with_v1): + """Discarding when no draft exists should raise an error.""" + workflow, user = workflow_with_v1 + + with pytest.raises(ValueError, match="[Nn]o draft"): + await db_session.discard_workflow_draft(workflow.id) + + async def test_new_draft_after_discard_gets_next_version_number( + self, db_session, workflow_with_v1 + ): + """After discarding V2 draft, the next draft should still be V2 + (since V2 was deleted and never published).""" + workflow, user = workflow_with_v1 + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + await db_session.discard_workflow_draft(workflow.id) + + new_draft = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V3, + ) + # Version number reuse is acceptable since V2 was never published + assert new_draft.version_number == 2 + + +# --------------------------------------------------------------------------- +# Reverting to an archived version +# --------------------------------------------------------------------------- + + +class TestRevert: + async def _publish_v2(self, db_session, workflow): + """Helper: create and publish V2, making V1 archived.""" + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + workflow_configurations=CONFIG_V2, + template_context_variables=TEMPLATE_VARS_V2, + ) + return await db_session.publish_workflow_draft(workflow.id) + + async def test_revert_creates_draft_from_archived( + self, db_session, workflow_with_v1 + ): + """Reverting copies the archived version's full snapshot into a new draft.""" + workflow, user = workflow_with_v1 + + # Get V1's definition ID before it gets archived + versions_before = await db_session.get_workflow_versions(workflow.id) + v1_id = versions_before[0].id + + # Publish V2, archiving V1 + await self._publish_v2(db_session, workflow) + + # Revert to V1 + draft = await db_session.revert_to_version(workflow.id, v1_id) + + assert draft.status == "draft" + assert draft.workflow_json == GRAPH_V1 + + async def test_revert_preserves_all_snapshot_fields( + self, db_session, workflow_with_v1 + ): + """Revert should copy graph, configs, and template vars.""" + workflow, user = workflow_with_v1 + + # Publish V2 with full config + v2 = await self._publish_v2(db_session, workflow) + + # Publish V3, archiving V2 + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V3, + ) + await db_session.publish_workflow_draft(workflow.id) + + # Revert to V2 + draft = await db_session.revert_to_version(workflow.id, v2.id) + + assert draft.workflow_json == GRAPH_V2 + assert draft.workflow_configurations == CONFIG_V2 + assert draft.template_context_variables == TEMPLATE_VARS_V2 + + async def test_revert_when_draft_exists_raises(self, db_session, workflow_with_v1): + """Cannot revert when a draft already exists — must discard first.""" + workflow, user = workflow_with_v1 + versions = await db_session.get_workflow_versions(workflow.id) + v1_id = versions[0].id + + await self._publish_v2(db_session, workflow) + + # Create a draft + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V3, + ) + + with pytest.raises(ValueError, match="[Dd]raft.*exists"): + await db_session.revert_to_version(workflow.id, v1_id) + + async def test_revert_does_not_change_released_pointer( + self, db_session, workflow_with_v1 + ): + """Revert creates a draft — the released pointer stays on the published version.""" + workflow, user = workflow_with_v1 + versions = await db_session.get_workflow_versions(workflow.id) + v1_id = versions[0].id + + v2 = await self._publish_v2(db_session, workflow) + + await db_session.revert_to_version(workflow.id, v1_id) + + refreshed = await db_session.get_workflow(workflow.id) + assert refreshed.released_definition_id == v2.id # still V2 + + +# --------------------------------------------------------------------------- +# Version listing & ordering +# --------------------------------------------------------------------------- + + +class TestVersionListing: + async def test_versions_ordered_by_version_number_desc( + self, db_session, workflow_with_v1 + ): + """Versions should be returned newest first.""" + workflow, user = workflow_with_v1 + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + await db_session.publish_workflow_draft(workflow.id) + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V3, + ) + + versions = await db_session.get_workflow_versions(workflow.id) + version_numbers = [v.version_number for v in versions] + assert version_numbers == sorted(version_numbers, reverse=True) + + async def test_versions_include_status(self, db_session, workflow_with_v1): + """Each version should have an explicit status.""" + workflow, user = workflow_with_v1 + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + await db_session.publish_workflow_draft(workflow.id) + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V3, + ) + + versions = await db_session.get_workflow_versions(workflow.id) + statuses = {v.version_number: v.status for v in versions} + assert statuses == {1: "archived", 2: "published", 3: "draft"} + + +# --------------------------------------------------------------------------- +# Version data stored on definition, not workflow +# --------------------------------------------------------------------------- + + +class TestVersionDataOnDefinition: + async def test_configs_stored_on_definition(self, db_session, workflow_with_v1): + """workflow_configurations should be on the definition, not just the workflow.""" + workflow, user = workflow_with_v1 + + draft = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + workflow_configurations=CONFIG_V2, + template_context_variables=TEMPLATE_VARS_V2, + ) + + assert draft.workflow_configurations == CONFIG_V2 + assert draft.template_context_variables == TEMPLATE_VARS_V2 + + async def test_different_versions_have_different_configs( + self, db_session, workflow_with_v1 + ): + """V1 and V2 can have different configs stored independently.""" + workflow, user = workflow_with_v1 + + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + workflow_configurations=CONFIG_V2, + ) + await db_session.publish_workflow_draft(workflow.id) + + versions = await db_session.get_workflow_versions(workflow.id) + configs_by_version = { + v.version_number: v.workflow_configurations for v in versions + } + + assert configs_by_version[1] != configs_by_version[2] + + +# --------------------------------------------------------------------------- +# Run creation uses published (or draft for testing) +# --------------------------------------------------------------------------- + + +class TestRunDefinitionBinding: + async def test_campaign_run_uses_published_version( + self, db_session, workflow_with_v1 + ): + """A campaign-initiated run should use the published version, not draft.""" + workflow, user = workflow_with_v1 + + # Create a draft (unpublished) + await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + + # Create a run (simulating campaign dispatch) + run = await db_session.create_workflow_run( + name="Campaign Run", + workflow_id=workflow.id, + mode="webrtc", + user_id=user.id, + ) + + # Run should be bound to the published V1, not the draft V2 + versions = await db_session.get_workflow_versions(workflow.id) + published = next(v for v in versions if v.status == "published") + assert run.definition_id == published.id + + async def test_test_run_uses_draft_if_exists(self, db_session, workflow_with_v1): + """A test/phone call should use the draft version for pre-publish testing.""" + workflow, user = workflow_with_v1 + + draft = await db_session.save_workflow_draft( + workflow_id=workflow.id, + workflow_definition=GRAPH_V2, + ) + + # Create a test run + run = await db_session.create_workflow_run( + name="Test Run", + workflow_id=workflow.id, + mode="webrtc", # test mode + user_id=user.id, + use_draft=True, + ) + + assert run.definition_id == draft.id diff --git a/ui/eslint.config.mjs b/ui/eslint.config.mjs index b8220fc..1f5199b 100644 --- a/ui/eslint.config.mjs +++ b/ui/eslint.config.mjs @@ -12,6 +12,8 @@ const compat = new FlatCompat({ }); const eslintConfig = [ + // Ignore auto-generated hey-api client files + { ignores: ["src/client/client/", "src/client/core/", "src/client/client.gen.ts", "src/client/sdk.gen.ts", "src/client/types.gen.ts", "src/client/index.ts"] }, ...compat.extends("next/core-web-vitals", "next/typescript"), { plugins: { diff --git a/ui/openapi-ts.config.ts b/ui/openapi-ts.config.ts index d6ab3b4..1097fd4 100644 --- a/ui/openapi-ts.config.ts +++ b/ui/openapi-ts.config.ts @@ -5,6 +5,6 @@ export default defineConfig({ output: 'src/client', plugins: [{ name: '@hey-api/client-fetch', - runtimeConfigPath: './src/lib/apiClient.ts', + runtimeConfigPath: '../lib/apiClient', }], }); diff --git a/ui/package-lock.json b/ui/package-lock.json index 2c48004..39c9484 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,7 +9,6 @@ "version": "1.20.0", "dependencies": { "@dagrejs/dagre": "^1.1.4", - "@hey-api/client-fetch": "^0.10.0", "@nangohq/frontend": "^0.69.47", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", @@ -1756,22 +1755,11 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, - "node_modules/@hey-api/client-fetch": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.10.0.tgz", - "integrity": "sha512-C7vzj4t52qPiHCqjn1l8cRTI2p4pZCd7ViLtJDTHr5ZwI4sWOYC1tmv6bd529qqY6HFFbhGCz4TAZSwKAMJncg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/hey-api" - }, - "peerDependencies": { - "@hey-api/openapi-ts": "< 2" - } - }, "node_modules/@hey-api/codegen-core": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.4.tgz", "integrity": "sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==", + "dev": true, "license": "MIT", "dependencies": { "@hey-api/types": "0.1.4", @@ -1790,6 +1778,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.1.tgz", "integrity": "sha512-7atnpUkT8TyUPHYPLk91j/GyaqMuwTEHanLOe50Dlx0EEvNuQqFD52Yjg8x4KU0UFL1mWlyhE+sUE/wAtQ1N2A==", + "dev": true, "license": "MIT", "dependencies": { "@jsdevtools/ono": "7.1.3", @@ -1807,6 +1796,7 @@ "version": "0.95.0", "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.95.0.tgz", "integrity": "sha512-lk5C+WKl5yqEmliQihEyhX/jNcWlAykTSEqkDeKa9xSq5YDAzOFvx7oos8YTqiIzdc4TemtlEaB8Rns7+8A0qg==", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1837,6 +1827,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.3.0.tgz", "integrity": "sha512-G+4GPojdLEh9bUwRG88teMPM1HdqMm/IsJ38cbnNxhyDu1FkFGwilkA1EqnULCzfTam/ZoZkaLdmAd8xEh4Xsw==", + "dev": true, "license": "MIT", "dependencies": { "@hey-api/codegen-core": "0.7.4", @@ -1859,6 +1850,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.1.0.tgz", "integrity": "sha512-StS4RrAO5pyJCBwe6uF9MAuPflkztriW+FPnVb7oEjzDYv1sxPwP+f7fL6u6D+UVrKpZ/9bPNx/xXVdkeWPU6A==", + "dev": true, "license": "MIT", "dependencies": { "@hey-api/types": "0.1.4" @@ -1871,6 +1863,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz", "integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==", + "dev": true, "license": "MIT" }, "node_modules/@hookform/resolvers": { @@ -2476,6 +2469,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, "license": "MIT" }, "node_modules/@nangohq/frontend": { @@ -11402,6 +11396,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11448,6 +11443,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -11875,6 +11871,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -11890,6 +11887,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", "integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==", + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -11918,6 +11916,7 @@ "version": "17.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -12034,6 +12033,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -12058,6 +12058,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -12199,6 +12200,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, "license": "ISC", "bin": { "color-support": "bin.js" @@ -12247,6 +12249,7 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -12269,12 +12272,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -12366,6 +12371,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -12699,6 +12705,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -12715,6 +12722,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -12745,6 +12753,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12775,6 +12784,7 @@ "version": "6.1.6", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "dev": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -12790,6 +12800,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -13673,6 +13684,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, "license": "MIT" }, "node_modules/fast-copy": { @@ -14097,6 +14109,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -14594,6 +14607,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -14674,6 +14688,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -14686,6 +14701,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -14897,6 +14913,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -14972,6 +14989,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -15014,6 +15032,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -15729,6 +15748,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, "license": "MIT" }, "node_modules/node-releases": { @@ -15756,6 +15776,7 @@ "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.0", @@ -15773,6 +15794,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "dev": true, "license": "MIT" }, "node_modules/oauth4webapi": { @@ -15910,6 +15932,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, "license": "MIT" }, "node_modules/on-exit-leak-free": { @@ -15934,6 +15957,7 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "dev": true, "license": "MIT", "dependencies": { "default-browser": "^5.4.0", @@ -16083,6 +16107,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16123,12 +16148,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, "license": "MIT" }, "node_modules/pg-int8": { @@ -16257,6 +16284,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -16388,6 +16416,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -16532,6 +16561,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -16789,6 +16819,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -17062,6 +17093,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -17374,6 +17406,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -17386,6 +17419,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17923,6 +17957,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -18165,6 +18200,7 @@ "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -18684,6 +18720,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "dev": true, "license": "MIT", "dependencies": { "is-wsl": "^3.1.0", diff --git a/ui/package.json b/ui/package.json index 3b04b3d..27f9325 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@dagrejs/dagre": "^1.1.4", - "@hey-api/client-fetch": "^0.10.0", "@nangohq/frontend": "^0.69.47", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", diff --git a/ui/src/app/campaigns/GoogleSheetSelector.tsx b/ui/src/app/campaigns/GoogleSheetSelector.tsx index 12a0e01..de3cfc6 100644 --- a/ui/src/app/campaigns/GoogleSheetSelector.tsx +++ b/ui/src/app/campaigns/GoogleSheetSelector.tsx @@ -88,7 +88,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele if (response.data) { const integrations = Array.isArray(response.data) ? response.data : [response.data]; - const googleSheet = integrations.find(i => i.provider === 'google-sheet'); + const googleSheet = integrations.find((i: IntegrationResponse) => i.provider === 'google-sheet'); setGoogleIntegration(googleSheet || null); } } catch (error) { diff --git a/ui/src/app/campaigns/[campaignId]/edit/page.tsx b/ui/src/app/campaigns/[campaignId]/edit/page.tsx index cf2629c..2935a73 100644 --- a/ui/src/app/campaigns/[campaignId]/edit/page.tsx +++ b/ui/src/app/campaigns/[campaignId]/edit/page.tsx @@ -106,7 +106,7 @@ export default function EditCampaignPage() { setScheduleEnabled(c.schedule_config.enabled); setScheduleTimezone(c.schedule_config.timezone); if (c.schedule_config.slots.length > 0) { - setTimeSlots(c.schedule_config.slots.map(s => ({ ...s }))); + setTimeSlots(c.schedule_config.slots.map((s: TimeSlot) => ({ ...s }))); } } diff --git a/ui/src/app/reports/page.tsx b/ui/src/app/reports/page.tsx index b65b923..3714092 100644 --- a/ui/src/app/reports/page.tsx +++ b/ui/src/app/reports/page.tsx @@ -9,6 +9,7 @@ import { getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet, getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet } from '@/client/sdk.gen'; +import type { WorkflowRunDetail } from '@/client/types.gen'; import { Button } from '@/components/ui/button'; import { Calendar as CalendarPicker } from '@/components/ui/calendar'; import { Card } from '@/components/ui/card'; @@ -140,7 +141,7 @@ export default function ReportsPage() { if (response.data && response.data.length > 0) { // Prepare CSV content const headers = ['Phone Number', 'Disposition', 'Duration (seconds)', 'Workflow Run URL']; - const rows = response.data.map(run => { + const rows = response.data.map((run: WorkflowRunDetail) => { const url = `${window.location.origin}/workflow/${run.workflow_id}/run/${run.run_id}`; return [ run.phone_number || '', @@ -153,7 +154,7 @@ export default function ReportsPage() { // Create CSV content const csvContent = [ headers.join(','), - ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ...rows.map((row: string[]) => row.map((cell: string) => `"${cell}"`).join(',')) ].join('\n'); // Create blob and download diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index 0e87e2e..8b0e7f3 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -6,10 +6,11 @@ import { Panel, ReactFlow, } from "@xyflow/react"; -import { BookA, BrushCleaning, Maximize2, Mic, Minus, PhoneOff, Plus, Rocket, Settings, Variable } from 'lucide-react'; -import React, { useEffect, useMemo, useState } from 'react'; +import { BrushCleaning, Maximize2, Minus, Plus, Settings } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client'; +import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client'; import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from '@/client/types.gen'; import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types"; import { Button } from '@/components/ui/button'; @@ -20,13 +21,8 @@ import { WorkflowConfigurations } from '@/types/workflow-configurations'; import AddNodePanel from "../../../components/flow/AddNodePanel"; import CustomEdge from "../../../components/flow/edges/CustomEdge"; import { AgentNode, EndCall, GlobalNode, QANode, StartCall, TriggerNode, WebhookNode } from "../../../components/flow/nodes"; -import { ConfigurationsDialog } from './components/ConfigurationsDialog'; -import { DictionaryDialog } from './components/DictionaryDialog'; -import { EmbedDialog } from './components/EmbedDialog'; import { PhoneCallDialog } from './components/PhoneCallDialog'; -import { RecordingsDialog } from './components/RecordingsDialog'; -import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog'; -import { VoicemailDetectionDialog } from './components/VoicemailDetectionDialog'; +import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel'; import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader"; import { WorkflowProvider } from "./contexts/WorkflowContext"; import { useWorkflowState } from "./hooks/useWorkflowState"; @@ -61,22 +57,27 @@ interface RenderWorkflowProps { }; initialTemplateContextVariables?: Record; initialWorkflowConfigurations?: WorkflowConfigurations; + initialVersionNumber?: number | null; + initialVersionStatus?: string | null; user: { id: string; email?: string }; } -function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user }: RenderWorkflowProps) { +function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) { + const router = useRouter(); const { userConfig } = useUserConfig(); const ttsProvider = (userConfig?.tts?.provider as string) ?? ""; const ttsModel = (userConfig?.tts?.model as string) ?? ""; const ttsVoiceId = (userConfig?.tts?.voice as string) ?? ""; - const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false); - const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false); - const [isDictionaryDialogOpen, setIsDictionaryDialogOpen] = useState(false); - const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false); const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false); - const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false); - const [isVoicemailDialogOpen, setIsVoicemailDialogOpen] = useState(false); + const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false); + const [versions, setVersions] = useState([]); + const [versionsLoading, setVersionsLoading] = useState(false); + const [activeVersionId, setActiveVersionId] = useState(null); + // Version info that updates immediately from the GET/save/publish responses. + const [currentVersionNumber, setCurrentVersionNumber] = useState(initialVersionNumber ?? null); + const [currentVersionStatus, setCurrentVersionStatus] = useState(initialVersionStatus ?? null); + const versionsFetched = useRef(false); const [documents, setDocuments] = useState(undefined); const [tools, setTools] = useState(undefined); const [recordings, setRecordings] = useState([]); @@ -89,9 +90,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT workflowName, isDirty, workflowValidationErrors, - templateContextVariables, - workflowConfigurations, setNodes, + setEdges, setIsDirty, setIsAddNodePanelOpen, handleNodeSelect, @@ -100,10 +100,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT onEdgesChange, onNodesChange, onRun, - saveTemplateContextVariables, - saveWorkflowConfigurations, - dictionary, - saveDictionary } = useWorkflowState({ initialWorkflowName, workflowId, @@ -113,6 +109,123 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT user, }); + // Derive hasDraft from the current version status + const hasDraft = currentVersionStatus === "draft"; + + // Fetch workflow versions, optionally forcing a refresh + const fetchVersions = useCallback(async (force = false) => { + if (versionsFetched.current && !force) return; + setVersionsLoading(true); + try { + const response = await getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet({ + path: { workflow_id: workflowId }, + }); + const data = response.data as WorkflowVersion[] | undefined; + if (data) { + setVersions(data); + // Set active version to draft if exists, else published + const current = data.find((v) => v.status === "draft") ?? data.find((v) => v.status === "published"); + if (current) { + setActiveVersionId(current.id); + setCurrentVersionNumber(current.version_number); + setCurrentVersionStatus(current.status); + } + } + versionsFetched.current = true; + } finally { + setVersionsLoading(false); + } + }, [workflowId]); + + const handleOpenVersionPanel = useCallback(() => { + setIsVersionPanelOpen(true); + fetchVersions(); + }, [fetchVersions]); + + const handleSelectVersion = useCallback((version: WorkflowVersion) => { + setActiveVersionId(version.id); + const wfJson = version.workflow_json; + const flowNodes = (wfJson.nodes ?? []) as FlowNode[]; + const flowEdges = (wfJson.edges ?? []) as FlowEdge[]; + + // Update the Zustand store directly instead of rfInstance.current.setNodes(). + // This keeps data flow unidirectional (store → props → ReactFlow) and avoids + // xyflow's d3 event handlers interfering with React's event delegation. + // The key={activeVersionId} on forces a clean remount. + setNodes(flowNodes); + setEdges(flowEdges); + // Never mark dirty when switching versions — historical versions are + // read-only, and loading the draft is restoring the saved state. + setIsDirty(false); + setIsVersionPanelOpen(false); + }, [setNodes, setEdges, setIsDirty]); + + // Determine if we are viewing a historical (non-current) version. + // The "current" version is the draft if one exists, otherwise the published version. + // Anything else (archived, or published while a draft exists) is historical. + const isViewingHistoricalVersion = useMemo(() => { + if (!activeVersionId || versions.length === 0) return false; + const activeVersion = versions.find((v) => v.id === activeVersionId); + if (!activeVersion) return false; + if (activeVersion.status === "draft") return false; + if (activeVersion.status === "published" && !hasDraft) return false; + return true; + }, [activeVersionId, versions, hasDraft]); + + // Return to the draft version, creating one from published if needed + const handleBackToDraft = useCallback(async () => { + const existingDraft = versions.find((v) => v.status === "draft"); + if (existingDraft) { + handleSelectVersion(existingDraft); + return; + } + + // No draft exists — ask the backend to create one from published + const response = await createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost({ + path: { workflow_id: workflowId }, + }); + const draft = response.data; + if (draft) { + setCurrentVersionNumber(draft.version_number); + setCurrentVersionStatus(draft.status); + // Load draft nodes/edges via the Zustand store (same approach as handleSelectVersion) + const flowNodes = (draft.workflow_json?.nodes ?? []) as FlowNode[]; + const flowEdges = (draft.workflow_json?.edges ?? []) as FlowEdge[]; + setNodes(flowNodes); + setEdges(flowEdges); + setActiveVersionId(draft.id); + setIsDirty(false); + // Refresh the version list so the new draft appears + fetchVersions(true); + } + }, [versions, handleSelectVersion, workflowId, setNodes, setEdges, setIsDirty, fetchVersions]); + + // After a successful publish, refresh the version list and update status + const handlePublished = useCallback(() => { + setCurrentVersionStatus("published"); + fetchVersions(true); + }, [fetchVersions]); + + // Compute version label for the header. + // Uses currentVersionNumber/Status which update immediately from save responses, + // falling back to the versions list for history navigation. + const activeVersionLabel = useMemo(() => { + // When viewing a version from the history panel, use the versions list + if (activeVersionId && versions.length > 0) { + const v = versions.find((ver) => ver.id === activeVersionId); + if (v) { + const statusSuffix = v.status === "draft" ? " (Draft)" : v.status === "published" ? " (Published)" : ""; + return `v${v.version_number}${statusSuffix}`; + } + } + // Otherwise use the immediately-available version info from save responses + if (currentVersionNumber != null) { + const statusSuffix = currentVersionStatus === "draft" ? " (Draft)" : currentVersionStatus === "published" ? " (Published)" : ""; + return `v${currentVersionNumber}${statusSuffix}`; + } + return undefined; + }, [activeVersionId, versions, currentVersionNumber, currentVersionStatus]); + // Fetch documents, tools, and recordings once for the entire workflow useEffect(() => { const fetchData = async () => { @@ -161,13 +274,37 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT type: "custom" }), []); + // Guard saveWorkflow so it's a no-op when viewing a historical version. + // This is the single safety net that covers every save path: header button, + // Cmd+S, node edit dialogs, stale doc/tool cleanup, etc. + // Uses the save response to immediately update version label and hasDraft. + const guardedSaveWorkflow = useCallback(async (updateWorkflowDefinition?: boolean) => { + if (isViewingHistoricalVersion) return; + const result = await saveWorkflow(updateWorkflowDefinition); + if (result) { + // If the versions list has been fetched (user interacted with versioning + // or published), refresh it so that activeVersionId points to the correct + // version. This is critical when a save creates a new draft from a + // published version: without refreshing, activeVersionId would still + // point to the old published version, causing isViewingHistoricalVersion + // to incorrectly return true and lock the editor into read-only mode. + if (versionsFetched.current) { + await fetchVersions(true); + } else { + if (result.versionNumber != null) setCurrentVersionNumber(result.versionNumber); + if (result.versionStatus) setCurrentVersionStatus(result.versionStatus); + } + } + }, [saveWorkflow, isViewingHistoricalVersion, fetchVersions]); + // Memoize the context value to prevent unnecessary re-renders const workflowContextValue = useMemo(() => ({ - saveWorkflow, + saveWorkflow: guardedSaveWorkflow, documents, tools, recordings, - }), [saveWorkflow, documents, tools, recordings]); + readOnly: isViewingHistoricalVersion, + }), [guardedSaveWorkflow, documents, tools, recordings, isViewingHistoricalVersion]); return ( @@ -180,21 +317,29 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT rfInstance={rfInstance} onRun={onRun} workflowId={workflowId} - saveWorkflow={saveWorkflow} + saveWorkflow={guardedSaveWorkflow} user={user} onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)} + onHistoryClick={handleOpenVersionPanel} + activeVersionLabel={activeVersionLabel} + isViewingHistoricalVersion={isViewingHistoricalVersion} + onBackToDraft={handleBackToDraft} + hasDraft={hasDraft} + onPublished={handlePublished} /> {/* Workflow Canvas */}
{ rfInstance.current = instance; // Center the workflow on load @@ -204,6 +349,11 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT }} defaultEdgeOptions={defaultEdgeOptions} defaultViewport={initialFlow?.viewport} + nodesDraggable={!isViewingHistoricalVersion} + nodesConnectable={!isViewingHistoricalVersion} + edgesReconnectable={!isViewingHistoricalVersion} + zoomOnDoubleClick={false} + deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"} > - {/* Top-right controls - vertical layout */} - - -
- - - - - -

Add node

-
-
+ {/* Top-right controls - vertical layout (hidden when viewing history) */} + {!isViewingHistoricalVersion && ( + + +
+ + + + + +

Add node

+
+
- - - - - -

Configurations

-
-
- - - - - - -

Template Context Variables

-
-
- - - - - - -

Dictionary

-
-
- - - - - - -

Recordings

-
-
- - - - - - -

Voicemail Detection

-
-
- - - - - - -

Deploy Workflow

-
-
-
-
-
+ + + + + +

Workflow settings

+
+
+
+
+
+ )}
{/* Bottom-left controls - horizontal layout with custom buttons */} @@ -386,25 +458,27 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT - {/* Tidy/Arrange Nodes */} - - - - - -

Tidy Up

-
-
+ {/* Tidy/Arrange Nodes (hidden when viewing history) */} + {!isViewingHistoricalVersion && ( + + + + + +

Tidy Up

+
+
+ )}
@@ -415,33 +489,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT onClose={() => setIsAddNodePanelOpen(false)} /> - - - - - - - setIsVersionPanelOpen(false)} + versions={versions} + loading={versionsLoading} + activeVersionId={activeVersionId} + onSelectVersion={handleSelectVersion} /> - - - - {workflowConfigurations && ( - saveWorkflowConfigurations(configurations, workflowName)} - /> - )}
); diff --git a/ui/src/app/workflow/[workflowId]/components/ModelConfigurationDialog.tsx b/ui/src/app/workflow/[workflowId]/components/ModelConfigurationDialog.tsx new file mode 100644 index 0000000..a4d9fac --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/components/ModelConfigurationDialog.tsx @@ -0,0 +1,54 @@ +import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { WorkflowConfigurations } from "@/types/workflow-configurations"; + +interface ModelConfigurationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workflowConfigurations: WorkflowConfigurations | null; + workflowName: string; + onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise; +} + +export const ModelConfigurationDialog = ({ + open, + onOpenChange, + workflowConfigurations, + workflowName, + onSave, +}: ModelConfigurationDialogProps) => { + return ( + + + + Model Configuration + + Override global model settings for this workflow. Toggle individual services to customize. + + + + { + await onSave( + { + ...workflowConfigurations, + model_overrides: config.model_overrides as WorkflowConfigurations["model_overrides"], + } as WorkflowConfigurations, + workflowName, + ); + onOpenChange(false); + }} + /> + + + ); +}; diff --git a/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx b/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx index 0931141..7c7a0ae 100644 --- a/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx +++ b/ui/src/app/workflow/[workflowId]/components/RecordingsDialog.tsx @@ -9,7 +9,7 @@ import { listRecordingsApiV1WorkflowRecordingsGet, transcribeAudioApiV1WorkflowRecordingsTranscribePost, } from "@/client"; -import type { RecordingResponseSchema } from "@/client/types.gen"; +import type { RecordingResponseSchema, RecordingUploadResponseSchema } from "@/client/types.gen"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -294,7 +294,7 @@ export const RecordingsDialog = ({ // Step 2: Upload all files to storage in parallel await Promise.all( - items.map(async (item, idx) => { + items.map(async (item: RecordingUploadResponseSchema, idx: number) => { const file = ready[idx].file; const uploadResponse = await fetch(item.upload_url, { method: "PUT", @@ -312,7 +312,7 @@ export const RecordingsDialog = ({ // Step 3: Create all recording records await createRecordingsApiV1WorkflowRecordingsPost({ body: { - recordings: items.map((item, idx) => ({ + recordings: items.map((item: RecordingUploadResponseSchema, idx: number) => ({ recording_id: item.recording_id, workflow_id: workflowId, tts_provider: ttsProvider, diff --git a/ui/src/app/workflow/[workflowId]/components/VersionHistoryPanel.tsx b/ui/src/app/workflow/[workflowId]/components/VersionHistoryPanel.tsx new file mode 100644 index 0000000..3b52c94 --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/components/VersionHistoryPanel.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { formatDistanceToNow } from "date-fns"; +import { FileText, LoaderCircle, X } from "lucide-react"; +import { useEffect } from "react"; + +import { Button } from "@/components/ui/button"; + +export interface WorkflowVersion { + id: number; + version_number: number; + status: string; + created_at: string; + published_at: string | null; + workflow_json: { nodes?: unknown[]; edges?: unknown[]; viewport?: unknown }; + workflow_configurations: Record | null; + template_context_variables: Record | null; +} + +interface VersionHistoryPanelProps { + isOpen: boolean; + onClose: () => void; + versions: WorkflowVersion[]; + loading: boolean; + activeVersionId: number | null; + onSelectVersion: (version: WorkflowVersion) => void; +} + +const statusLabel: Record = { + draft: "Draft", + published: "Published", + archived: "Archived", +}; + +const statusColor: Record = { + draft: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", + published: "bg-green-500/20 text-green-400 border-green-500/30", + archived: "bg-gray-500/20 text-gray-400 border-gray-500/30", +}; + +export const VersionHistoryPanel = ({ + isOpen, + onClose, + versions, + loading, + activeVersionId, + onSelectVersion, +}: VersionHistoryPanelProps) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isOpen) { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + return ( +
+
+
+

+ Version History +

+ +
+ + {loading ? ( +
+ +
+ ) : versions.length === 0 ? ( +

+ No versions found. +

+ ) : ( +
+ {versions.map((version) => { + const isActive = version.id === activeVersionId; + const date = version.published_at || version.created_at; + return ( + + ); + })} +
+ )} +
+
+ ); +}; diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx index 988683b..7085fbe 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowEditorHeader.tsx @@ -1,12 +1,15 @@ "use client"; import { ReactFlowInstance } from "@xyflow/react"; -import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react"; +import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, Eye, History, LoaderCircle, MoreVertical, Phone, Rocket } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; -import { duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost } from "@/client/sdk.gen"; +import { + duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost, + publishWorkflowApiV1WorkflowWorkflowIdPublishPost, +} from "@/client/sdk.gen"; import { WorkflowError } from "@/client/types.gen"; import { FlowEdge, FlowNode } from "@/components/flow/types"; import { Button } from "@/components/ui/button"; @@ -33,6 +36,12 @@ interface WorkflowEditorHeaderProps { saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise; user: { id: string; email?: string }; onPhoneCallClick: () => void; + onHistoryClick: () => void; + activeVersionLabel?: string; + isViewingHistoricalVersion: boolean; + onBackToDraft: () => void; + hasDraft: boolean; + onPublished: () => void; } export const WorkflowEditorHeader = ({ @@ -43,11 +52,18 @@ export const WorkflowEditorHeader = ({ saveWorkflow, onRun, onPhoneCallClick, + onHistoryClick, + activeVersionLabel, + isViewingHistoricalVersion, + onBackToDraft, + hasDraft, + onPublished, workflowId, }: WorkflowEditorHeaderProps) => { const router = useRouter(); const [savingWorkflow, setSavingWorkflow] = useState(false); const [duplicating, setDuplicating] = useState(false); + const [publishing, setPublishing] = useState(false); const hasValidationErrors = workflowValidationErrors.length > 0; const isCallDisabled = isDirty || hasValidationErrors; @@ -58,6 +74,25 @@ export const WorkflowEditorHeader = ({ setSavingWorkflow(false); }; + const handlePublish = async () => { + if (publishing) return; + setPublishing(true); + const promise = publishWorkflowApiV1WorkflowWorkflowIdPublishPost({ + path: { workflow_id: workflowId }, + }); + toast.promise(promise, { + loading: "Publishing...", + success: "Workflow published successfully", + error: "Failed to publish workflow", + }); + try { + await promise; + onPublished(); + } finally { + setPublishing(false); + } + }; + const handleBack = () => { router.push("/workflow"); }; @@ -121,10 +156,41 @@ export const WorkflowEditorHeader = ({ - {/* Right section: Unsaved indicator + Call button + Save button */} + {/* Right section: Version + Unsaved indicator + Call button + Save button */}
- {/* Unsaved changes indicator */} - {isDirty && ( + {/* Read-only banner when viewing a historical version */} + {isViewingHistoricalVersion && ( +
+ + + Viewing {activeVersionLabel} — Read only + +
+ )} + + {/* Back to Draft button when viewing history */} + {isViewingHistoricalVersion && ( + + )} + + {/* Version history button */} + + + {/* Unsaved changes indicator (hidden when viewing history) */} + {isDirty && !isViewingHistoricalVersion && (
Unsaved changes @@ -177,57 +243,83 @@ export const WorkflowEditorHeader = ({ )} - {/* Call button with dropdown */} - - - - - - onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC)} - className="text-white hover:bg-[#2a2a2a] cursor-pointer" - > - - Web Call - - { - // Delay opening dialog to next event cycle to allow DropdownMenu - // to clean up first, preventing pointer-events: none stuck on body - // See: https://github.com/radix-ui/primitives/issues/1241 - setTimeout(onPhoneCallClick, 0); - }} - className="text-white hover:bg-[#2a2a2a] cursor-pointer" - > - - Phone Call - - - + {/* Call button with dropdown (hidden when viewing history) */} + {!isViewingHistoricalVersion && ( + + + + + + onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC)} + className="text-white hover:bg-[#2a2a2a] cursor-pointer" + > + + Web Call + + { + // Delay opening dialog to next event cycle to allow DropdownMenu + // to clean up first, preventing pointer-events: none stuck on body + // See: https://github.com/radix-ui/primitives/issues/1241 + setTimeout(onPhoneCallClick, 0); + }} + className="text-white hover:bg-[#2a2a2a] cursor-pointer" + > + + Phone Call + + + + )} - {/* Save button */} - + {/* Save button (only shown when editing the draft) */} + {!isViewingHistoricalVersion && ( + + )} + + {/* Publish button (only when on draft with no unsaved changes) */} + {!isViewingHistoricalVersion && hasDraft && ( + + )} {/* More options dropdown */} diff --git a/ui/src/app/workflow/[workflowId]/contexts/WorkflowContext.tsx b/ui/src/app/workflow/[workflowId]/contexts/WorkflowContext.tsx index 80c2b58..0e23831 100644 --- a/ui/src/app/workflow/[workflowId]/contexts/WorkflowContext.tsx +++ b/ui/src/app/workflow/[workflowId]/contexts/WorkflowContext.tsx @@ -8,6 +8,7 @@ interface WorkflowContextType { documents?: DocumentResponseSchema[]; tools?: ToolResponse[]; recordings?: RecordingResponseSchema[]; + readOnly?: boolean; } const WorkflowContext = createContext(undefined); diff --git a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts index 5aac5a4..4e18d9d 100644 --- a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts +++ b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts @@ -165,7 +165,7 @@ interface UseWorkflowStateProps { }; initialTemplateContextVariables?: Record; initialWorkflowConfigurations?: WorkflowConfigurations; - user: { id: string; email?: string }; // Minimal user type needed + user: { id: string; email?: string } | null; } export const useWorkflowState = ({ @@ -300,7 +300,7 @@ export const useWorkflowState = ({ // Validate workflow function const validateWorkflow = useCallback(async () => { - if (!user) return; + if (!user?.id) return; try { const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({ path: { @@ -342,7 +342,7 @@ export const useWorkflowState = ({ if (response.data.is_valid === false && response.data.errors) { const errors = response.data.errors; - errors.forEach((error) => { + errors.forEach((error: WorkflowError) => { if (error.kind === 'node' && error.id) { markNodeAsInvalid(error.id, error.message); } else if (error.kind === 'edge' && error.id) { @@ -355,14 +355,14 @@ export const useWorkflowState = ({ logger.info('Workflow is valid'); } } - } catch (error) { + } catch (error: unknown) { logger.error(`Unexpected validation error: ${error}`); } }, [workflowId, user, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]); - // Save workflow function - const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => { - if (!user || !rfInstance.current) return; + // Save workflow function. Returns version info from the API response. + const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true): Promise<{ versionNumber?: number; versionStatus?: string } | undefined> => { + if (!user?.id || !rfInstance.current) return; // Read nodes/edges from the Zustand store (synchronously up-to-date) // and viewport from the ReactFlow instance to build the flow object. // This avoids a race condition where rfInstance.toObject() may return @@ -370,8 +370,9 @@ export const useWorkflowState = ({ const { nodes: currentNodes, edges: currentEdges } = useWorkflowStore.getState(); const viewport = rfInstance.current.getViewport(); const flow = { nodes: currentNodes, edges: currentEdges, viewport }; + let result: { versionNumber?: number; versionStatus?: string } | undefined; try { - await updateWorkflowApiV1WorkflowWorkflowIdPut({ + const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({ path: { workflow_id: workflowId, }, @@ -381,12 +382,19 @@ export const useWorkflowState = ({ }, }); setIsDirty(false); + if (response.data) { + result = { + versionNumber: response.data.version_number ?? undefined, + versionStatus: response.data.version_status ?? undefined, + }; + } } catch (error) { logger.error(`Error saving workflow: ${error}`); } // Validate after saving await validateWorkflow(); + return result; }, [workflowId, workflowName, setIsDirty, user, validateWorkflow]); // Set up keyboard shortcut for save (Cmd/Ctrl + S) @@ -394,7 +402,9 @@ export const useWorkflowState = ({ const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); - saveWorkflow(); + if (useWorkflowStore.getState().isDirty) { + saveWorkflow(); + } } }; @@ -439,7 +449,7 @@ export const useWorkflowState = ({ ); const onRun = async (mode: string) => { - if (!user) return; + if (!user?.id) return; const workflowRunName = `WR-${getRandomId()}`; const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({ path: { @@ -455,7 +465,7 @@ export const useWorkflowState = ({ // Save template context variables const saveTemplateContextVariables = useCallback(async (variables: Record) => { - if (!user) return; + if (!user?.id) return; try { await updateWorkflowApiV1WorkflowWorkflowIdPut({ path: { @@ -477,12 +487,12 @@ export const useWorkflowState = ({ // Save workflow configurations const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => { - if (!user) return; + if (!user?.id) return; // Preserve the current dictionary when saving other configurations const currentDictionary = useWorkflowStore.getState().dictionary; const configurationsWithDictionary: WorkflowConfigurations = { ...configurations, dictionary: currentDictionary }; try { - await updateWorkflowApiV1WorkflowWorkflowIdPut({ + const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({ path: { workflow_id: workflowId, }, @@ -492,6 +502,22 @@ export const useWorkflowState = ({ workflow_configurations: configurationsWithDictionary as Record, }, }); + + if (response.error) { + const detail = (response.error as { detail?: unknown }).detail; + let msg = 'Failed to save workflow configurations'; + if (typeof detail === 'string') { + msg = detail; + } else if (Array.isArray(detail)) { + msg = detail + .map((e: { model?: string; message?: string; msg?: string }) => + e.model && e.message ? `${e.model}: ${e.message}` : (e.msg || JSON.stringify(e)) + ) + .join('\n'); + } + throw new Error(msg); + } + setWorkflowConfigurations(configurationsWithDictionary); // Set name directly in the store to avoid setWorkflowName which marks isDirty: true useWorkflowStore.setState({ workflowName: newWorkflowName }); @@ -550,6 +576,7 @@ export const useWorkflowState = ({ workflowConfigurations, dictionary, setNodes, + setEdges, setIsDirty, setIsAddNodePanelOpen, handleNodeSelect, diff --git a/ui/src/app/workflow/[workflowId]/page.tsx b/ui/src/app/workflow/[workflowId]/page.tsx index 9c80631..d3e918c 100644 --- a/ui/src/app/workflow/[workflowId]/page.tsx +++ b/ui/src/app/workflow/[workflowId]/page.tsx @@ -82,6 +82,8 @@ export default function WorkflowDetailPage() { }} initialTemplateContextVariables={workflow.template_context_variables as Record || {}} initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS} + initialVersionNumber={workflow.version_number ?? null} + initialVersionStatus={workflow.version_status ?? null} user={stableUser} /> ) : null; diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx index 908cbb9..4b27c9d 100644 --- a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx +++ b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx @@ -526,7 +526,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia }); if (turnResponse.data) { turnCredentialsRef.current = turnResponse.data; - logger.info(`TURN credentials obtained, TTL: ${turnCredentialsRef.current.ttl}s`); + logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.ttl}s`); } else if (turnResponse.response.status === 503) { // TURN not configured on server - this is OK, we'll use STUN only logger.info('TURN server not configured, using STUN only'); diff --git a/ui/src/app/workflow/[workflowId]/settings/page.tsx b/ui/src/app/workflow/[workflowId]/settings/page.tsx new file mode 100644 index 0000000..5c2aa28 --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/settings/page.tsx @@ -0,0 +1,926 @@ +"use client"; + +import { ArrowLeft, BookA, Brain, ExternalLink, Mic, PhoneOff, Rocket, Settings, Trash2Icon, Variable } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen"; +import type { WorkflowResponse } from "@/client/types.gen"; +import { FlowEdge, FlowNode } from "@/components/flow/types"; +import { LLMConfigSelector } from "@/components/LLMConfigSelector"; +import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm"; +import SpinLoader from "@/components/SpinLoader"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation"; +import { useAuth } from "@/lib/auth"; +import logger from "@/lib/logger"; +import { + type AmbientNoiseConfiguration, + DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION, + DEFAULT_WORKFLOW_CONFIGURATIONS, + type TurnStopStrategy, + type VoicemailDetectionConfiguration, + type WorkflowConfigurations, +} from "@/types/workflow-configurations"; + +import { EmbedDialog } from "../components/EmbedDialog"; +import { RecordingsDialog } from "../components/RecordingsDialog"; +import { useWorkflowState } from "../hooks/useWorkflowState"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_AMBIENT_NOISE_CONFIG: AmbientNoiseConfiguration = { + enabled: false, + volume: 0.3, +}; + +const DEFAULT_VOICEMAIL_SYSTEM_PROMPT = `You are a voicemail detection classifier for an OUTBOUND calling system. A bot has called a phone number and you need to determine if a human answered or if the call went to voicemail based on the provided text. + +HUMAN ANSWERED - LIVE CONVERSATION (respond "CONVERSATION"): +- Personal greetings: "Hello?", "Hi", "Yeah?", "John speaking" +- Interactive responses: "Who is this?", "What do you want?", "Can I help you?" +- Conversational tone expecting back-and-forth dialogue +- Questions directed at the caller: "Hello? Anyone there?" +- Informal responses: "Yep", "What's up?", "Speaking" +- Natural, spontaneous speech patterns +- Immediate acknowledgment of the call + +VOICEMAIL SYSTEM (respond "VOICEMAIL"): +- Automated voicemail greetings: "Hi, you've reached [name], please leave a message" +- Phone carrier messages: "The number you have dialed is not in service", "Please leave a message", "All circuits are busy" +- Professional voicemail: "This is [name], I'm not available right now" +- Instructions about leaving messages: "leave a message", "leave your name and number" +- References to callback or messaging: "call me back", "I'll get back to you" +- Carrier system messages: "mailbox is full", "has not been set up" +- Business hours messages: "our office is currently closed" + +Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's voicemail/recording.`; + +// Sidebar navigation items +const NAV_ITEMS = [ + { id: "general", label: "General", icon: Settings }, + { id: "models", label: "Model Overrides", icon: Brain }, + { id: "variables", label: "Template Variables", icon: Variable }, + { id: "dictionary", label: "Dictionary", icon: BookA }, + { id: "voicemail", label: "Voicemail Detection", icon: PhoneOff }, + { id: "recordings", label: "Recordings", icon: Mic }, + { id: "deployment", label: "Deployment", icon: Rocket }, +]; + +// --------------------------------------------------------------------------- +// Section: General +// --------------------------------------------------------------------------- + +function GeneralSection({ + workflowConfigurations, + workflowName, + onSave, +}: { + workflowConfigurations: WorkflowConfigurations; + workflowName: string; + onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise; +}) { + const [name, setName] = useState(workflowName); + const [ambientNoiseConfig, setAmbientNoiseConfig] = useState( + workflowConfigurations.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG, + ); + const [maxCallDuration, setMaxCallDuration] = useState(workflowConfigurations.max_call_duration || 600); + const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState(workflowConfigurations.max_user_idle_timeout || 10); + const [smartTurnStopSecs, setSmartTurnStopSecs] = useState(workflowConfigurations.smart_turn_stop_secs || 2); + const [turnStopStrategy, setTurnStopStrategy] = useState( + workflowConfigurations.turn_stop_strategy || "transcription", + ); + const [contextCompactionEnabled, setContextCompactionEnabled] = useState( + workflowConfigurations.context_compaction_enabled ?? false, + ); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async () => { + setIsSaving(true); + try { + await onSave( + { + ...workflowConfigurations, + ambient_noise_configuration: ambientNoiseConfig, + max_call_duration: maxCallDuration, + max_user_idle_timeout: maxUserIdleTimeout, + smart_turn_stop_secs: smartTurnStopSecs, + turn_stop_strategy: turnStopStrategy, + context_compaction_enabled: contextCompactionEnabled, + }, + name, + ); + } catch (error) { + console.error("Failed to save general settings:", error); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + + General + + Agent name, call behavior, and turn detection.{" "} + Learn more + + + + {/* Agent Name */} +
+ + setName(e.target.value)} + placeholder="Enter Agent name" + /> +
+ + + + {/* Ambient Noise */} +
+
+

Ambient Noise

+

+ Add background office ambient noise to make the conversation sound more natural. +

+
+
+ + + setAmbientNoiseConfig((prev) => ({ ...prev, enabled: checked })) + } + /> +
+ {ambientNoiseConfig.enabled && ( +
+ + { + const value = parseFloat(e.target.value); + if (!isNaN(value)) setAmbientNoiseConfig((prev) => ({ ...prev, volume: value })); + }} + /> +
+ )} +
+ + + + {/* Turn Detection */} +
+
+

Turn Detection

+

+ Configure how the agent detects when the user has finished speaking. +

+
+
+ + +

+ {turnStopStrategy === "transcription" + ? "Best for short responses (1-2 word statements). Ends turn when transcription indicates completion." + : "Best for longer responses with natural pauses. Uses ML model to detect end of turn."} +

+
+ {turnStopStrategy === "turn_analyzer" && ( +
+ + { + const value = parseFloat(e.target.value); + if (!isNaN(value) && value >= 0.5) setSmartTurnStopSecs(value); + }} + /> +

+ Max silence duration before ending an incomplete turn. Default: 2 seconds +

+
+ )} +
+ + + + {/* Context Compaction */} +
+
+

Context Compaction

+

+ Automatically summarize conversation context when transitioning between nodes. +

+
+
+ + +
+
+ + + + {/* Call Management */} +
+
+

Call Management

+

+ Configure call duration limits and idle timeout settings. +

+
+
+
+ + { + const value = parseInt(e.target.value); + if (!isNaN(value) && value > 0) setMaxCallDuration(value); + }} + /> +

Default: 600 (10 minutes)

+
+
+ + { + const value = parseInt(e.target.value); + if (!isNaN(value) && value > 0) setMaxUserIdleTimeout(value); + }} + /> +

Default: 10 seconds

+
+
+
+
+ + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Section: Template Variables +// --------------------------------------------------------------------------- + +function TemplateVariablesSection({ + templateContextVariables, + onSave, +}: { + templateContextVariables: Record; + onSave: (variables: Record) => Promise; +}) { + const [contextVars, setContextVars] = useState>(templateContextVariables); + const [newKey, setNewKey] = useState(""); + const [newValue, setNewValue] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + const handleAdd = () => { + if (newKey && newValue) { + setContextVars((prev) => ({ ...prev, [newKey]: newValue })); + } + setNewKey(""); + setNewValue(""); + }; + + const handleRemove = (key: string) => { + setContextVars((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + let varsToSave = contextVars; + if (newKey && newValue) { + varsToSave = { ...varsToSave, [newKey]: newValue }; + } + await onSave(varsToSave); + } catch (error) { + console.error("Failed to save variables:", error); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + + Template Variables + + + Variables available in workflow prompts via {`{{variable_name}}`} syntax for testing the workflow.{" "} + Learn more + + + + {/* Existing Variables */} + {Object.entries(contextVars).length > 0 && ( +
+ + {Object.entries(contextVars).map(([key, value]) => ( +
+
+
{key}
+
{value}
+
+ +
+ ))} +
+ )} + + {/* Add New Variable */} +
+ +
+
+ + setNewKey(e.target.value)} + /> +
+
+ + setNewValue(e.target.value)} + /> +
+
+ +
+
+ + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Section: Dictionary +// --------------------------------------------------------------------------- + +function DictionarySection({ + dictionary, + onSave, +}: { + dictionary: string; + onSave: (dictionary: string) => Promise; +}) { + const [dictionaryValue, setDictionaryValue] = useState(dictionary); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async () => { + setIsSaving(true); + try { + await onSave(dictionaryValue); + } catch (error) { + console.error("Failed to save dictionary:", error); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + + Dictionary + + + Add words the agent should actively listen for — company jargon, names, + industry terms. May incur extra cost depending on provider. + + + +