From 05d370fa3316e1952662d8e7a49fe21e2d2625db Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:15:23 +0000 Subject: [PATCH 1/3] feat(workflows): show waiting for domain and count it as enabled in guards, search and the scout Co-Authored-By: Claude Opus 5.5 --- .../cohorts/CohortBlockingWorkflowsModal.tsx | 2 +- .../scenes/cohorts/cohortBlockingWorkflows.ts | 2 +- posthog/api/cohort.py | 20 ++++++------ posthog/api/test/test_cohort.py | 11 ++++--- posthog/api/test/test_search.py | 7 ++-- .../cohorts/frontend/generated/api.schemas.ts | 6 ++-- products/workflows/backend/facade/api.py | 17 +++++----- .../backend/presentation/views/hog_flow.py | 24 ++++++++------ .../backend/services/cohort_usage.py | 15 +++++---- .../backend/services/suggestions_scout.py | 5 +-- .../tests/api/test_workflow_proposals.py | 31 ++++++++++++++++++ .../frontend/Workflows/WorkflowStatusBar.tsx | 32 +++++++++++-------- .../frontend/Workflows/WorkflowsTable.tsx | 4 ++- .../frontend/Workflows/hogflows/types.ts | 5 ++- .../frontend/Workflows/workflowsLogic.test.ts | 30 +++++++++++++++-- .../frontend/Workflows/workflowsLogic.ts | 6 ++-- services/mcp/src/api/generated.ts | 6 ++-- 17 files changed, 149 insertions(+), 74 deletions(-) diff --git a/frontend/src/scenes/cohorts/CohortBlockingWorkflowsModal.tsx b/frontend/src/scenes/cohorts/CohortBlockingWorkflowsModal.tsx index 90dd01f02162..204ff936d0d3 100644 --- a/frontend/src/scenes/cohorts/CohortBlockingWorkflowsModal.tsx +++ b/frontend/src/scenes/cohorts/CohortBlockingWorkflowsModal.tsx @@ -23,7 +23,7 @@ export function CohortBlockingWorkflowsModal(): JSX.Element { diff --git a/frontend/src/scenes/cohorts/cohortBlockingWorkflows.ts b/frontend/src/scenes/cohorts/cohortBlockingWorkflows.ts index 4fc0122aa6bb..0c969c6f7ef6 100644 --- a/frontend/src/scenes/cohorts/cohortBlockingWorkflows.ts +++ b/frontend/src/scenes/cohorts/cohortBlockingWorkflows.ts @@ -9,7 +9,7 @@ import type { CohortUsedInWorkflowsBlockApi } from 'products/cohorts/frontend/ge export const COHORT_USED_BY_WORKFLOWS_ERROR_CODE = 'cohort_used_by_workflows' /** - * The active workflows that block deleting this cohort or editing it so it stops updating in real + * The enabled workflows that block deleting this cohort or editing it so it stops updating in real * time, or null when nothing blocks it. A failed check returns null, because the API still refuses * the change and the caller then shows the API error. */ diff --git a/posthog/api/cohort.py b/posthog/api/cohort.py index c35e6397d2c3..cfd9e2a79b66 100644 --- a/posthog/api/cohort.py +++ b/posthog/api/cohort.py @@ -141,15 +141,15 @@ from products.surveys.backend.models import Survey from products.workflows.backend.facade.api import ( filter_hog_flow_references_by_access_level, - get_active_workflows_using_realtime_cohort, - team_has_active_workflows_using_cohorts, + get_enabled_workflows_using_realtime_cohort, + team_has_enabled_workflows_using_cohorts, ) from products.workflows.backend.facade.contracts import CohortDependentWorkflow, HogFlowReference def _workflows_relying_on_cohort(cohort: Cohort) -> list[CohortDependentWorkflow]: # Most teams have no active cohort workflows, so skip loading every cohort in the project for them. - if not team_has_active_workflows_using_cohorts(team_id=cohort.team_id): + if not team_has_enabled_workflows_using_cohorts(team_id=cohort.team_id): return [] # A cohort that includes this one as a criterion reads its membership, so a workflow on that cohort # also relies on this one. Load the project's cohort references in one query and walk them in memory. @@ -165,7 +165,7 @@ def _workflows_relying_on_cohort(cohort: Cohort) -> list[CohortDependentWorkflow for dependent_id in dependents[pending.pop()] - cohort_ids: cohort_ids.add(dependent_id) pending.append(dependent_id) - return get_active_workflows_using_realtime_cohort(team_id=cohort.team_id, cohort_ids=cohort_ids) + return get_enabled_workflows_using_realtime_cohort(team_id=cohort.team_id, cohort_ids=cohort_ids) def _token_can_read(request: Request, scope_object: str) -> bool: @@ -688,7 +688,7 @@ class CohortUsedInWorkflowSerializer(serializers.Serializer): id = serializers.CharField(help_text="Workflow ID (UUID)") name = serializers.CharField(allow_blank=True, help_text="Workflow display name") status = serializers.CharField( - help_text="Workflow status. Always 'active', because only active workflows rely on the cohort" + help_text="Workflow status: 'active', or 'waiting_for_domain' for an enabled workflow that goes live once its email domain is verified" ) @@ -747,11 +747,11 @@ class CohortUsedInWorkflowsBlockSerializer(serializers.Serializer): results = CohortUsedInWorkflowSerializer( many=True, help_text=( - f"Active workflows relying on this cohort that the caller can view, capped at {COHORT_USED_IN_PAGE_SIZE} results" + f"Enabled workflows relying on this cohort that the caller can view, capped at {COHORT_USED_IN_PAGE_SIZE} results" ), ) total = serializers.IntegerField( - help_text="Total number of active workflows relying on this cohort, including ones the caller cannot view" + help_text="Total number of enabled workflows relying on this cohort, including ones the caller cannot view" ) hidden = serializers.IntegerField( help_text="Number of relying workflows the caller cannot view. They count toward total but are never listed" @@ -1923,12 +1923,12 @@ def update(self, cohort: Cohort, validated_data: dict, *args: Any, **kwargs: Any if stops_realtime: raise ValidationError( f"This change stops the cohort updating in real time, and {count} " - f"active workflow(s) rely on that{names}. " + f"enabled workflow(s) rely on that{names}. " "Please remove the cohort from these workflows before making this change.", code=COHORT_USED_BY_WORKFLOWS_ERROR_CODE, ) raise ValidationError( - f"This cohort is used in {count} active workflow(s){names}. Changing its criteria would send " + f"This cohort is used in {count} enabled workflow(s){names}. Changing its criteria would send " "its current members through them again. " "Please remove the cohort from these workflows before changing its criteria.", code=COHORT_USED_BY_WORKFLOWS_ERROR_CODE, @@ -1977,7 +1977,7 @@ def update(self, cohort: Cohort, validated_data: dict, *args: Any, **kwargs: Any self._reject_delete( cohort, "workflows", - f"This cohort is used in {len(workflows_with_cohort)} active workflow(s){names}. " + f"This cohort is used in {len(workflows_with_cohort)} enabled workflow(s){names}. " "Please remove the cohort from these workflows before deleting it.", code=COHORT_USED_BY_WORKFLOWS_ERROR_CODE, ) diff --git a/posthog/api/test/test_cohort.py b/posthog/api/test/test_cohort.py index c0c0284da274..aad4de63b7e1 100644 --- a/posthog/api/test/test_cohort.py +++ b/posthog/api/test/test_cohort.py @@ -7541,10 +7541,11 @@ def _create_workflow_using_cohort(self, cohort_id: int, usage: str, status: str) ("active_trigger", "trigger", "active", 400), ("active_branch", "branch", "active", 400), ("active_branch_precalculated", "branch_precalculated", "active", 400), + ("waiting_trigger", "trigger", "waiting_for_domain", 400), ("draft_trigger", "trigger", "draft", 200), ] ) - def test_delete_cohort_is_blocked_by_active_workflows_using_it(self, _name, usage, workflow_status, expected): + def test_delete_cohort_is_blocked_by_enabled_workflows_using_it(self, _name, usage, workflow_status, expected): cohort_id = self._create_realtime_cohort() self._create_workflow_using_cohort(cohort_id, usage, workflow_status) @@ -7552,7 +7553,7 @@ def test_delete_cohort_is_blocked_by_active_workflows_using_it(self, _name, usag assert response.status_code == expected, response.json() if expected == 400: - assert f"used in 1 active workflow(s): Workflow {usage}" in response.json()["detail"] + assert f"used in 1 enabled workflow(s): Workflow {usage}" in response.json()["detail"] assert response.json()["code"] == "cohort_used_by_workflows" @parameterized.expand( @@ -7572,7 +7573,7 @@ def test_blocked_delete_names_workflows_only_for_workflow_readers(self, _name, s ) assert response.status_code == 400, response.json() - assert "used in 1 active workflow(s)" in response.json()["detail"] + assert "used in 1 enabled workflow(s)" in response.json()["detail"] assert ("Workflow trigger" in response.json()["detail"]) == names_shown def test_blocked_delete_hides_names_of_workflows_the_member_cannot_view(self): @@ -7594,7 +7595,7 @@ def test_blocked_delete_hides_names_of_workflows_the_member_cannot_view(self): response = self.client.patch(f"/api/projects/{self.team.id}/cohorts/{cohort_id}", {"deleted": True}) assert response.status_code == 400, response.json() - assert "used in 2 active workflow(s): Workflow branch and 1 more." in response.json()["detail"] + assert "used in 2 enabled workflow(s): Workflow branch and 1 more." in response.json()["detail"] assert "Workflow trigger" not in response.json()["detail"] @parameterized.expand([("used_in", "workflows"), ("blocking_workflows", None)]) @@ -7728,7 +7729,7 @@ def test_edit_that_stops_realtime_is_blocked_by_workflows_on_a_cohort_that_inclu ) assert response.status_code == 400, response.json() - assert "1 active workflow(s) rely on that: Workflow trigger." in response.json()["detail"] + assert "1 enabled workflow(s) rely on that: Workflow trigger." in response.json()["detail"] def test_cohort_type_not_set_when_not_provided(self): """cohort_type should remain None when not provided""" diff --git a/posthog/api/test/test_search.py b/posthog/api/test/test_search.py index 800db0e179cb..93a01f6a1c68 100644 --- a/posthog/api/test/test_search.py +++ b/posthog/api/test/test_search.py @@ -5,6 +5,8 @@ from django.db import connection from django.test.utils import CaptureQueriesContext +from parameterized import parameterized + from posthog.api.search import ENTITY_MAP, SEARCHABLE_ENTITIES, class_queryset, search_entities from posthog.helpers.full_text_search import build_search_vector, process_query from posthog.models import OrganizationMembership, Team, User @@ -247,9 +249,10 @@ def test_early_access_features(self): self.assertEqual(results[0]["type"], "early_access_feature") self.assertEqual(results[0]["extra_fields"]["name"], "second feature") - def test_hog_flows(self): + @parameterized.expand([("draft",), ("active",), ("waiting_for_domain",)]) + def test_hog_flows(self, status): create_workflow_for_test(team_id=self.team.id, name="first workflow") - create_workflow_for_test(team_id=self.team.id, name="second workflow") + create_workflow_for_test(team_id=self.team.id, name="second workflow", status=status) create_workflow_for_test(team_id=self.team.id, name="third workflow") response = self.client.get("/api/projects/@current/search?q=sec&entities=hog_flow") diff --git a/products/cohorts/frontend/generated/api.schemas.ts b/products/cohorts/frontend/generated/api.schemas.ts index ed19319832da..ee77ce20d18c 100644 --- a/products/cohorts/frontend/generated/api.schemas.ts +++ b/products/cohorts/frontend/generated/api.schemas.ts @@ -597,14 +597,14 @@ export interface CohortUsedInWorkflowApi { id: string /** Workflow display name */ name: string - /** Workflow status. Always 'active', because only active workflows rely on the cohort */ + /** Workflow status: 'active', or 'waiting_for_domain' for an enabled workflow that goes live once its email domain is verified */ status: string } export interface CohortUsedInWorkflowsBlockApi { - /** Active workflows relying on this cohort that the caller can view, capped at 100 results */ + /** Enabled workflows relying on this cohort that the caller can view, capped at 100 results */ results: CohortUsedInWorkflowApi[] - /** Total number of active workflows relying on this cohort, including ones the caller cannot view */ + /** Total number of enabled workflows relying on this cohort, including ones the caller cannot view */ total: number /** Number of relying workflows the caller cannot view. They count toward total but are never listed */ hidden: number diff --git a/products/workflows/backend/facade/api.py b/products/workflows/backend/facade/api.py index 7b8c945580fd..a9a03bec783b 100644 --- a/products/workflows/backend/facade/api.py +++ b/products/workflows/backend/facade/api.py @@ -28,6 +28,7 @@ WorkflowTaskDailyLimits, WorkflowVisionScanDailyLimits, ) +from products.workflows.backend.facade.enums import ENABLED_HOG_FLOW_STATES from products.workflows.backend.models import HogFlow, TeamWorkflowsConfig from products.workflows.backend.models.hog_flow.hog_flow import opt_out_category_ids, static_cohort_ids from products.workflows.backend.services import email_utm_defaults @@ -98,7 +99,7 @@ def search_workflows( ``with_access_levels`` adds ``user_access_level``, the user's resolved level for each workflow. ``include_count=False`` skips the count query and reports a total of 0. """ - statuses = [HogFlow.State.DRAFT, HogFlow.State.ACTIVE] + statuses = [HogFlow.State.DRAFT, *ENABLED_HOG_FLOW_STATES] if include_archived: statuses.append(HogFlow.State.ARCHIVED) queryset = access_control.filter_queryset_by_access_level( @@ -289,28 +290,28 @@ def enable_waiting_workflows(*, team_id: int, sender_ids: Iterable[int]) -> None transaction.on_commit(lambda: queue_enable_waiting_workflows(team_id=team_id, sender_ids=ids)) -def get_active_workflows_using_realtime_cohort( +def get_enabled_workflows_using_realtime_cohort( *, team_id: int, cohort_ids: Collection[int] ) -> list[CohortDependentWorkflow]: # Deferred to keep the cdp filter compiler, and the HogQL schema it loads, off the django.setup() path. from products.workflows.backend.services.cohort_usage import ( # noqa: PLC0415 - get_active_hog_flows_using_realtime_cohort, + get_enabled_hog_flows_using_realtime_cohort, ) return [ CohortDependentWorkflow(id=str(flow.id), name=flow.name or "", status=flow.status) - for flow in get_active_hog_flows_using_realtime_cohort(team_id=team_id, cohort_ids=cohort_ids) + for flow in get_enabled_hog_flows_using_realtime_cohort(team_id=team_id, cohort_ids=cohort_ids) ] -def team_has_active_workflows_using_cohorts(*, team_id: int) -> bool: - """Cheap pre-check: False means no active workflow in the team can rely on any cohort.""" +def team_has_enabled_workflows_using_cohorts(*, team_id: int) -> bool: + """Cheap pre-check: False means no enabled workflow in the team can rely on any cohort.""" # Deferred for the same reason as get_active_workflows_using_realtime_cohort. from products.workflows.backend.services.cohort_usage import ( # noqa: PLC0415 - team_has_active_hog_flows_mentioning_cohorts, + team_has_enabled_hog_flows_mentioning_cohorts, ) - return team_has_active_hog_flows_mentioning_cohorts(team_id) + return team_has_enabled_hog_flows_mentioning_cohorts(team_id) def recompute_email_sending_tier(team_id: int) -> TierDecision | None: diff --git a/products/workflows/backend/presentation/views/hog_flow.py b/products/workflows/backend/presentation/views/hog_flow.py index d6de5efc41f2..5397c1330c67 100644 --- a/products/workflows/backend/presentation/views/hog_flow.py +++ b/products/workflows/backend/presentation/views/hog_flow.py @@ -229,6 +229,7 @@ verified_email_domains, ) from products.workflows.backend.facade.enums import ( + ENABLED_HOG_FLOW_STATES, HogFlowBatchJobState, HogFlowExitCondition, HogFlowOriginProduct, @@ -5679,15 +5680,18 @@ def perform_update(self, serializer): ) self._emit_resource_edited(serializer.instance) - self._report_went_live(result, serializer.instance) + self._report_turned_on(result, serializer.instance) - def _report_went_live(self, result: WorkflowWriteResult, instance: Workflow) -> None: - if result.previous.get("status") == HogFlowState.ACTIVE or instance.status != HogFlowState.ACTIVE: - return - # A workflow that kept suggestions on while it was off needs its scout back when it goes live again. - if is_optimization_enabled(instance.id): + def _report_turned_on(self, result: WorkflowWriteResult, instance: Workflow) -> None: + previous = result.previous.get("status") + # A workflow that kept suggestions on while it was off needs its scout back when it is enabled again. + if ( + previous not in ENABLED_HOG_FLOW_STATES + and instance.status in ENABLED_HOG_FLOW_STATES + and is_optimization_enabled(instance.id) + ): self._sync_suggestions_scout(self.request) - if result.previous.get("status") in (HogFlowState.DRAFT, HogFlowState.WAITING_FOR_DOMAIN): + if previous in (HogFlowState.DRAFT, HogFlowState.WAITING_FOR_DOMAIN) and instance.status == HogFlowState.ACTIVE: self._report_workflow_action( "hog_flow_activated", instance, @@ -5817,7 +5821,7 @@ def edit(state: WorkflowEditState, base_actions: list[dict], base_edges: list[di instance, {"operations_count": len(operations), "routed_to_draft": result.routed_to_draft}, ) - self._report_went_live(result, instance) + self._report_turned_on(result, instance) return Response(self.get_serializer(instance).data) @@ -5888,7 +5892,7 @@ def edit(state: WorkflowEditState, base_actions: list[dict], base_edges: list[di "routed_to_draft": result.routed_to_draft, }, ) - self._report_went_live(result, instance) + self._report_turned_on(result, instance) return Response(self.get_serializer(instance).data) @@ -6036,7 +6040,7 @@ def validate(state: WorkflowEditState, content: dict) -> dict: self._log_activity(locked.id, locked.name, "published", previous=result.previous, current=result.current) self._emit_resource_edited(locked) self._report_workflow_action("hog_flow_draft_published", locked) - self._report_went_live(result, locked) + self._report_turned_on(result, locked) return Response( { diff --git a/products/workflows/backend/services/cohort_usage.py b/products/workflows/backend/services/cohort_usage.py index 8a6136bf1949..25cd99446b75 100644 --- a/products/workflows/backend/services/cohort_usage.py +++ b/products/workflows/backend/services/cohort_usage.py @@ -5,6 +5,7 @@ from django.db.models.functions import Cast from products.cdp.backend.presentation.hog_functions import filter_cohort_ids +from products.workflows.backend.facade.enums import ENABLED_HOG_FLOW_STATES from products.workflows.backend.models.hog_flow.hog_flow import HogFlow @@ -23,25 +24,25 @@ def _action_uses_realtime_cohort(action: Any, cohort_ids: set[int]) -> bool: return False -def _active_hog_flows_mentioning_cohorts(team_id: int) -> QuerySet[HogFlow]: +def _enabled_hog_flows_mentioning_cohorts(team_id: int) -> QuerySet[HogFlow]: # Postgres skips workflows whose actions never mention a cohort, so only those graphs reach Python. return ( - HogFlow.objects.filter(team_id=team_id, status=HogFlow.State.ACTIVE) + HogFlow.objects.filter(team_id=team_id, status__in=ENABLED_HOG_FLOW_STATES) .alias(actions_text=Cast("actions", output_field=TextField())) .filter(actions_text__contains="cohort") ) -def team_has_active_hog_flows_mentioning_cohorts(team_id: int) -> bool: - return _active_hog_flows_mentioning_cohorts(team_id).exists() +def team_has_enabled_hog_flows_mentioning_cohorts(team_id: int) -> bool: + return _enabled_hog_flows_mentioning_cohorts(team_id).exists() -def get_active_hog_flows_using_realtime_cohort(team_id: int, cohort_ids: Collection[int]) -> Iterator[HogFlow]: - """Active workflows in the environment that read the realtime membership of any of these cohorts, as a +def get_enabled_hog_flows_using_realtime_cohort(team_id: int, cohort_ids: Collection[int]) -> Iterator[HogFlow]: + """Enabled workflows in the environment that read the realtime membership of any of these cohorts, as a cohort trigger or in a conditional branch.""" # Only the fields the check reads, streamed: full rows carry encrypted inputs and the whole graph. ids = set(cohort_ids) - flows = _active_hog_flows_mentioning_cohorts(team_id).only("id", "name", "status", "actions") + flows = _enabled_hog_flows_mentioning_cohorts(team_id).only("id", "name", "status", "actions") for flow in flows.iterator(): if any(_action_uses_realtime_cohort(action, ids) for action in (flow.actions or [])): yield flow diff --git a/products/workflows/backend/services/suggestions_scout.py b/products/workflows/backend/services/suggestions_scout.py index 3816ac4bf0b4..f69287f13346 100644 --- a/products/workflows/backend/services/suggestions_scout.py +++ b/products/workflows/backend/services/suggestions_scout.py @@ -24,6 +24,7 @@ scout_status_for_product, ) from products.workflows.backend.facade.contracts import SuggestionsSample +from products.workflows.backend.facade.enums import ENABLED_HOG_FLOW_STATES from products.workflows.backend.metrics import ( HOG_FLOW_VERSION_APP_SOURCE, MIN_EVIDENCE_SAMPLE, @@ -61,7 +62,7 @@ def sync_suggestions_scout(team: Team, *, acting_user: User | None, may_grant: bool) -> None: - """Switch the scout on or off to match the project's live opted-in workflows. + """Switch the scout on or off to match the project's enabled opted-in workflows. Switching it on grants `hog_flow_proposal:write` and makes `acting_user` the person its runs act as, so it happens only with an `acting_user` and `may_grant` set. Switching it off needs neither. @@ -75,7 +76,7 @@ def sync_suggestions_scout(team: Team, *, acting_user: User | None, may_grant: b environments = Team.objects.filter(Q(id=project.id) | Q(parent_team_id=project.id)).values("id") wanted = ( HogFlowOptimization.objects.unscoped() - .filter(team_id__in=environments, enabled=True, hog_flow__status=HogFlow.State.ACTIVE) + .filter(team_id__in=environments, enabled=True, hog_flow__status__in=ENABLED_HOG_FLOW_STATES) .exists() ) if not wanted: diff --git a/products/workflows/backend/tests/api/test_workflow_proposals.py b/products/workflows/backend/tests/api/test_workflow_proposals.py index e2da1bf46bf8..d0221e01117e 100644 --- a/products/workflows/backend/tests/api/test_workflow_proposals.py +++ b/products/workflows/backend/tests/api/test_workflow_proposals.py @@ -15,6 +15,7 @@ from posthog.constants import AvailableFeature from posthog.models import Team, User from posthog.models.activity_logging.activity_log import ActivityLog +from posthog.models.integration import Integration from posthog.models.messaging import MessagingRecord, get_email_hash from posthog.models.organization import OrganizationMembership from posthog.models.personal_api_key import PersonalAPIKey @@ -679,6 +680,36 @@ def test_suggestions_scout_runs_while_any_workflow_asks_for_suggestions(self, _m self._toggle(second, False) assert self._suggestions_scout() is None + def test_enabling_a_workflow_that_waits_for_its_email_domain_restarts_its_suggestions_scout(self, _mock_flag): + flow_id = self._create_active_flow() + with self.captureOnCommitCallbacks(execute=True): + turned_off = self.client.patch(f"/api/projects/{self.team.id}/hog_flows/{flow_id}", {"status": "draft"}) + assert turned_off.status_code == 200, turned_off.json() + assert self._suggestions_scout() is None + sender = Integration.objects.create( + team=self.team, kind="email", config={"email": "hi@example.com", "domain": "example.com", "verified": False} + ) + flow = HogFlow.objects.get(pk=flow_id) + email_step = next(action for action in flow.actions if action["type"] == "function_email") + email_step["config"]["inputs"]["email"]["value"]["from"] = {"integrationId": sender.id} + flow.save() + + with patch("posthoganalytics.feature_enabled", return_value=True): + response = self.client.patch(f"/api/projects/{self.team.id}/hog_flows/{flow_id}", {"status": "active"}) + + assert response.json()["status"] == "waiting_for_domain", response.json() + assert self._suggestions_scout() is not None + + def test_suggestions_scout_keeps_running_while_its_workflow_waits_for_its_email_domain(self, _mock_flag): + flow = HogFlow.objects.get(pk=self._create_active_flow()) + assert self._suggestions_scout() is not None + + flow.status = HogFlow.State.WAITING_FOR_DOMAIN + with self.captureOnCommitCallbacks(execute=True): + flow.save() + + assert self._suggestions_scout() is not None + @parameterized.expand( [ ("paused by a person", "paused", "paused_by_user"), diff --git a/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx b/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx index f1b367f99a98..78c879c4e16a 100644 --- a/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx +++ b/products/workflows/frontend/Workflows/WorkflowStatusBar.tsx @@ -45,7 +45,20 @@ export function WorkflowStatusBar({ const showWorkflowStatus = !props.editTemplateId const historyWorkflowId = props.id && props.id !== 'new' ? props.id : null const isActive = originalWorkflow.status === 'active' + const isWaitingForDomain = originalWorkflow.status === 'waiting_for_domain' const isEditingDraftOfLive = isActive && (hasStagedDraft || hasUnsavedChanges) + const statusExplanation = isEditingDraftOfLive + ? 'The live version keeps running until you publish.' + : isActive + ? 'Changes you make save as a draft.' + : isWaitingForDomain + ? 'Sending starts once every email domain is verified.' + : null + const autoSaveExplanation = isActive + ? 'Auto-save stores your changes as a draft. Nothing goes live until you publish.' + : isWaitingForDomain + ? 'Auto-save stores your changes as you edit. Sending starts once every email domain is verified.' + : 'Draft workflows auto-save as you edit.' return (
@@ -76,15 +89,13 @@ export function WorkflowStatusBar({ Editing draft ) : isActive ? ( Live + ) : isWaitingForDomain ? ( + Waiting for domain ) : ( Draft ))} - {showWorkflowStatus && isActive && ( - - {isEditingDraftOfLive - ? 'The live version keeps running until you publish.' - : 'Changes you make save as a draft.'} - + {showWorkflowStatus && statusExplanation && ( + {statusExplanation} )} {commands}
@@ -106,14 +117,7 @@ export function WorkflowStatusBar({ label="Auto-save" size="small" /> - + diff --git a/products/workflows/frontend/Workflows/WorkflowsTable.tsx b/products/workflows/frontend/Workflows/WorkflowsTable.tsx index 3da776396121..fb38f7ee8f13 100644 --- a/products/workflows/frontend/Workflows/WorkflowsTable.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsTable.tsx @@ -50,9 +50,10 @@ import { WorkflowStepMatches } from './WorkflowStepMatches' import { WorkflowTagsCell } from './WorkflowTagsCell' import { hasMessagingAction } from './workflowTypeFilters' -const STATUS_CONFIG: Record = { +const STATUS_CONFIG: Record = { active: { label: 'Active', type: 'success' }, draft: { label: 'Draft', type: 'default' }, + waiting_for_domain: { label: 'Waiting for domain', type: 'warning' }, archived: { label: 'Archived', type: 'muted' }, } @@ -484,6 +485,7 @@ export function WorkflowsTable(): JSX.Element { options={[ { label: 'All', value: 'all' }, { label: 'Active', value: 'active' }, + { label: 'Waiting for domain', value: 'waiting_for_domain' }, { label: 'Draft', value: 'draft' }, { label: 'Archived', value: 'archived' }, ]} diff --git a/products/workflows/frontend/Workflows/hogflows/types.ts b/products/workflows/frontend/Workflows/hogflows/types.ts index 2e64d5fe89e4..2fa6f7dcf182 100644 --- a/products/workflows/frontend/Workflows/hogflows/types.ts +++ b/products/workflows/frontend/Workflows/hogflows/types.ts @@ -22,7 +22,7 @@ export const HogFlowSchema = z.object({ version: z.number(), name: z.string(), description: z.string().optional(), - status: z.enum(['active', 'draft', 'archived']), + status: z.enum(['active', 'draft', 'waiting_for_domain', 'archived']), trigger: HogFlowTriggerSchema.optional(), // Optional masking config for the trigger, allows HogFlows to be rate limited per distinct ID or other property trigger_masking: z @@ -109,6 +109,9 @@ export interface HogFlow extends z.infer { // True when only PostHog can lift the pause: staff placed it, or it re-tripped soon after a resume. email_sending_pause_requires_support?: boolean email_sending_resumed_at?: string | null + enable_requested_at?: string | null + // Later than enable_requested_at when the workflow went live after waiting for its email domain. + went_live_at?: string | null // Suggested changes waiting for a person. Only the list endpoint counts it. pending_suggestions?: number | null suggestions_enabled?: boolean | null diff --git a/products/workflows/frontend/Workflows/workflowsLogic.test.ts b/products/workflows/frontend/Workflows/workflowsLogic.test.ts index b4a901e757e7..805c5763512e 100644 --- a/products/workflows/frontend/Workflows/workflowsLogic.test.ts +++ b/products/workflows/frontend/Workflows/workflowsLogic.test.ts @@ -24,15 +24,18 @@ describe('workflowsLogic', () => { afterEach(() => logic.unmount()) - it.each(['/workflows', '/workflows/workflows'])('fills the filters from the query params on %s', (path) => { + it.each([ + ['/workflows', 'active'], + ['/workflows/workflows', 'waiting_for_domain'], + ])('fills the filters from the query params on %s with status %s', (path, status) => { router.actions.push( - `${path}?created_by=user-uuid&status=active&trigger_type=event&type=messaging&tags=${encodeURIComponent('["growth","billing"]')}` + `${path}?created_by=user-uuid&status=${status}&trigger_type=event&type=messaging&tags=${encodeURIComponent('["growth","billing"]')}` ) expect(logic.values.filters).toEqual({ search: '', createdBy: 'user-uuid', - status: 'active', + status, type: 'messaging', triggerType: 'event', tags: ['growth', 'billing'], @@ -40,6 +43,27 @@ describe('workflowsLogic', () => { }) }) + it.each([ + ['draft', 'active'], + ['active', 'draft'], + ['waiting_for_domain', 'draft'], + ] as const)('the list toggle on a %s workflow saves %s', async (status, expected) => { + const workflow = { id: 'flow-1', name: 'Welcome', status } as HogFlow + const bodies: unknown[] = [] + useMocks({ + patch: { + '/api/projects/:team_id/hog_flows/:id/': async ({ request }) => { + bodies.push(await request.json()) + return [200, { ...workflow, status: expected }] + }, + }, + }) + + await expectLogic(logic, () => logic.actions.toggleWorkflowStatus(workflow)).toFinishAllListeners() + + expect(bodies).toEqual([{ status: expected }]) + }) + it('saves a row tag edit with only the tags, and shows the name the server stored', async () => { const workflow = { id: 'flow-1', name: 'Churn win-back', tags: [] } as unknown as HogFlow const bodies: unknown[] = [] diff --git a/products/workflows/frontend/Workflows/workflowsLogic.ts b/products/workflows/frontend/Workflows/workflowsLogic.ts index 0f83ce837e32..13d4de701804 100644 --- a/products/workflows/frontend/Workflows/workflowsLogic.ts +++ b/products/workflows/frontend/Workflows/workflowsLogic.ts @@ -17,9 +17,9 @@ import { hogFlowsPartialUpdate } from '../generated/api' import type { HogFlow } from './hogflows/types' import { prepareWorkflowDuplicate } from './workflowDuplication' -export type WorkflowStatusFilter = 'all' | 'active' | 'draft' | 'archived' +export type WorkflowStatusFilter = 'all' | 'active' | 'waiting_for_domain' | 'draft' | 'archived' -const WORKFLOW_STATUS_FILTERS: WorkflowStatusFilter[] = ['all', 'active', 'draft', 'archived'] +const WORKFLOW_STATUS_FILTERS: WorkflowStatusFilter[] = ['all', 'active', 'waiting_for_domain', 'draft', 'archived'] export type WorkflowTypeFilter = 'all' | 'messaging' | 'automation' | 'loop' @@ -382,7 +382,7 @@ export const workflowsLogic = kea([ }, toggleWorkflowStatus: async ({ workflow }) => { await api.hogFlows.updateHogFlow(workflow.id, { - status: workflow.status === 'active' ? 'draft' : 'active', + status: workflow.status === 'draft' ? 'active' : 'draft', }) // Reload instead of patching in place: the new status may no longer match the // active status filter, so the row has to be re-fetched through the server query. diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index b1a5408b34f8..83da412effb3 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -25170,14 +25170,14 @@ export namespace Schemas { id: string; /** Workflow display name */ name: string; - /** Workflow status. Always 'active', because only active workflows rely on the cohort */ + /** Workflow status: 'active', or 'waiting_for_domain' for an enabled workflow that goes live once its email domain is verified */ status: string; } export interface CohortUsedInWorkflowsBlock { - /** Active workflows relying on this cohort that the caller can view, capped at 100 results */ + /** Enabled workflows relying on this cohort that the caller can view, capped at 100 results */ results: CohortUsedInWorkflow[]; - /** Total number of active workflows relying on this cohort, including ones the caller cannot view */ + /** Total number of enabled workflows relying on this cohort, including ones the caller cannot view */ total: number; /** Number of relying workflows the caller cannot view. They count toward total but are never listed */ hidden: number; From 71dff538321339612ae22a41ec5dc02d8dd6991e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:29:23 +0000 Subject: [PATCH 2/3] fix(workflows): restart the suggestions scout when a waiting workflow goes live Co-Authored-By: Claude Opus 5.5 --- .../backend/presentation/views/hog_flow.py | 4 +- .../tests/api/test_workflow_proposals.py | 44 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/products/workflows/backend/presentation/views/hog_flow.py b/products/workflows/backend/presentation/views/hog_flow.py index 5397c1330c67..2286d46741bd 100644 --- a/products/workflows/backend/presentation/views/hog_flow.py +++ b/products/workflows/backend/presentation/views/hog_flow.py @@ -5685,8 +5685,10 @@ def perform_update(self, serializer): def _report_turned_on(self, result: WorkflowWriteResult, instance: Workflow) -> None: previous = result.previous.get("status") # A workflow that kept suggestions on while it was off needs its scout back when it is enabled again. + # Going live from waiting checks again, because the enable that started the wait may not have granted it. if ( - previous not in ENABLED_HOG_FLOW_STATES + previous != HogFlowState.ACTIVE + and previous != instance.status and instance.status in ENABLED_HOG_FLOW_STATES and is_optimization_enabled(instance.id) ): diff --git a/products/workflows/backend/tests/api/test_workflow_proposals.py b/products/workflows/backend/tests/api/test_workflow_proposals.py index d0221e01117e..bad180098076 100644 --- a/products/workflows/backend/tests/api/test_workflow_proposals.py +++ b/products/workflows/backend/tests/api/test_workflow_proposals.py @@ -680,24 +680,56 @@ def test_suggestions_scout_runs_while_any_workflow_asks_for_suggestions(self, _m self._toggle(second, False) assert self._suggestions_scout() is None - def test_enabling_a_workflow_that_waits_for_its_email_domain_restarts_its_suggestions_scout(self, _mock_flag): + def _turned_off_flow_sending_from(self, sender: Integration) -> str: flow_id = self._create_active_flow() with self.captureOnCommitCallbacks(execute=True): turned_off = self.client.patch(f"/api/projects/{self.team.id}/hog_flows/{flow_id}", {"status": "draft"}) assert turned_off.status_code == 200, turned_off.json() assert self._suggestions_scout() is None - sender = Integration.objects.create( - team=self.team, kind="email", config={"email": "hi@example.com", "domain": "example.com", "verified": False} - ) flow = HogFlow.objects.get(pk=flow_id) email_step = next(action for action in flow.actions if action["type"] == "function_email") email_step["config"]["inputs"]["email"]["value"]["from"] = {"integrationId": sender.id} flow.save() + return flow_id + def _enable(self, flow_id: str, headers: dict | None = None) -> dict: with patch("posthoganalytics.feature_enabled", return_value=True): - response = self.client.patch(f"/api/projects/{self.team.id}/hog_flows/{flow_id}", {"status": "active"}) + response = self.client.patch( + f"/api/projects/{self.team.id}/hog_flows/{flow_id}", {"status": "active"}, headers=headers or {} + ) + assert response.status_code == 200, response.json() + return response.json() + + def _email_sender(self, *, verified: bool) -> Integration: + return Integration.objects.create( + team=self.team, + kind="email", + config={"email": "hi@example.com", "domain": "example.com", "verified": verified}, + ) + + def test_enabling_a_workflow_that_waits_for_its_email_domain_restarts_its_suggestions_scout(self, _mock_flag): + flow_id = self._turned_off_flow_sending_from(self._email_sender(verified=False)) + + assert self._enable(flow_id)["status"] == "waiting_for_domain" + + assert self._suggestions_scout() is not None + + def test_a_waiting_workflow_that_goes_live_restarts_a_scout_its_enable_could_not_grant(self, _mock_flag): + sender = self._email_sender(verified=False) + flow_id = self._turned_off_flow_sending_from(sender) + key = generate_random_token_personal() + PersonalAPIKey.objects.create( + label="enable", user=self.user, secure_value=hash_key_value(key), scopes=["hog_flow:write"] + ) + self.client.logout() + assert self._enable(flow_id, {"authorization": f"Bearer {key}"})["status"] == "waiting_for_domain" + assert self._suggestions_scout() is None + sender.config = {**sender.config, "verified": True} + sender.save() + self.client.force_login(self.user) + + assert self._enable(flow_id)["status"] == "active" - assert response.json()["status"] == "waiting_for_domain", response.json() assert self._suggestions_scout() is not None def test_suggestions_scout_keeps_running_while_its_workflow_waits_for_its_email_domain(self, _mock_flag): From ec421bb421930c1248b906ead20daf472d86f8da Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:00:50 +0000 Subject: [PATCH 3/3] chore(workflows): toggle a workflow's status through the generated client The list toggle line changed in this slice, so it now uses the generated hogFlowsPartialUpdate, like the tag edit in the same logic. Co-Authored-By: Claude Opus 5.5 --- products/workflows/frontend/Workflows/workflowsLogic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/workflows/frontend/Workflows/workflowsLogic.ts b/products/workflows/frontend/Workflows/workflowsLogic.ts index 13d4de701804..43c4da931642 100644 --- a/products/workflows/frontend/Workflows/workflowsLogic.ts +++ b/products/workflows/frontend/Workflows/workflowsLogic.ts @@ -381,7 +381,7 @@ export const workflowsLogic = kea([ return response }, toggleWorkflowStatus: async ({ workflow }) => { - await api.hogFlows.updateHogFlow(workflow.id, { + await hogFlowsPartialUpdate(String(ApiConfig.getCurrentTeamId()), workflow.id, { status: workflow.status === 'draft' ? 'active' : 'draft', }) // Reload instead of patching in place: the new status may no longer match the