From 3b0fba92ede019c2c2f94e48d5660299f5544b3d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:13:22 +0000 Subject: [PATCH 1/2] fix(workflows): keep edits and refresh the status while a workflow goes live While a workflow waits for its email domain, the editor checks its status every 10 seconds while the tab is visible, and the list does the same for a list that shows a waiting workflow. A go-live shows without a reload and keeps unsaved edits. An auto-save that meets a go-live takes the go-live's lifecycle and saves again, unless the workflow changed somewhere else. A form save queued behind a go-live routes to the draft of the live copy, and only the save that enables a workflow reports "workflow enabled". Ported from PostHog/posthog#123660 (231fe7f662f, 8a18ec1968a, ede32a6942c, d48d20fa57e, 34299c42f82, 5b8a6b62edb, 4d367f40265). Co-Authored-By: Claude Opus 5.5 --- .../frontend/Workflows/WorkflowsTable.tsx | 4 +- .../frontend/Workflows/waitingStatusCheck.ts | 15 + .../Workflows/workflowLogic.autosave.test.ts | 60 ++-- .../workflowLogic.sendOnceVerified.test.ts | 284 ++++++++++++++++++ .../frontend/Workflows/workflowLogic.ts | 161 ++++++++-- .../frontend/Workflows/workflowsLogic.ts | 4 +- .../workflowsWaitingRefreshLogic.test.ts | 82 +++++ .../Workflows/workflowsWaitingRefreshLogic.ts | 100 ++++++ 8 files changed, 669 insertions(+), 41 deletions(-) create mode 100644 products/workflows/frontend/Workflows/waitingStatusCheck.ts create mode 100644 products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts create mode 100644 products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.test.ts create mode 100644 products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.ts diff --git a/products/workflows/frontend/Workflows/WorkflowsTable.tsx b/products/workflows/frontend/Workflows/WorkflowsTable.tsx index fb38f7ee8f13..6ba2fab5d0ff 100644 --- a/products/workflows/frontend/Workflows/WorkflowsTable.tsx +++ b/products/workflows/frontend/Workflows/WorkflowsTable.tsx @@ -1,4 +1,4 @@ -import { useActions, useValues } from 'kea' +import { useActions, useMountedLogic, useValues } from 'kea' import { useMemo } from 'react' import { @@ -47,6 +47,7 @@ import { workflowsLogic, } from './workflowsLogic' import { WorkflowStepMatches } from './WorkflowStepMatches' +import { workflowsWaitingRefreshLogic } from './workflowsWaitingRefreshLogic' import { WorkflowTagsCell } from './WorkflowTagsCell' import { hasMessagingAction } from './workflowTypeFilters' @@ -125,6 +126,7 @@ export function WorkflowsTable(): JSX.Element { const overviewTabEnabled = !!featureFlags[FEATURE_FLAGS.WORKFLOWS_OVERVIEW_TAB] const { suggestionsAvailable } = useValues(workflowSuggestionsAvailabilityLogic) const logic = workflowsLogic() + useMountedLogic(workflowsWaitingRefreshLogic) const { workflowsLoading, workflows, diff --git a/products/workflows/frontend/Workflows/waitingStatusCheck.ts b/products/workflows/frontend/Workflows/waitingStatusCheck.ts new file mode 100644 index 000000000000..eb297a2c2454 --- /dev/null +++ b/products/workflows/frontend/Workflows/waitingStatusCheck.ts @@ -0,0 +1,15 @@ +const WAITING_STATUS_CHECK_INTERVAL_MS = 10_000 + +interface Disposables { + add: (setup: () => () => void) => void +} + +export function checkWaitingStatusWhileVisible(disposables: Disposables, checkStatus: () => void): void { + // Setup runs again each time a hidden tab shows. The person often verifies the domain in another + // tab, so check at once on return instead of waiting for the next tick. + disposables.add(() => { + checkStatus() + const timer = window.setInterval(checkStatus, WAITING_STATUS_CHECK_INTERVAL_MS) + return () => clearInterval(timer) + }) +} diff --git a/products/workflows/frontend/Workflows/workflowLogic.autosave.test.ts b/products/workflows/frontend/Workflows/workflowLogic.autosave.test.ts index 545086703c61..bf1786f4c084 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.autosave.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.autosave.test.ts @@ -381,27 +381,45 @@ describe('workflowLogic auto-save', () => { expect(logic.values.hasStagedDraft).toBe(false) }) - it('reports enabling a workflow once, with the product surface it was opened from', async () => { - const captureSpy = jest.spyOn(posthog, 'capture') - saveEntrySource(WORKFLOW_ID, 'survey') - useMocks(activeMocks({ ...activeWorkflow, status: 'draft' })) - initKeaTests() - logic = workflowLogic({ id: WORKFLOW_ID }) - logic.mount() - await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) - - await expectLogic(logic, () => { - logic.actions.saveWorkflowPartial({ status: 'active' }) - }).toDispatchActions(['saveWorkflowSuccess']) - await expectLogic(logic, () => { - logic.actions.setWorkflowValue('name', 'Renamed while live') - logic.actions.saveWorkflowPartial({ name: 'Renamed while live' }) - }).toDispatchActions(['saveWorkflowSuccess']) - - const enabledCalls = captureSpy.mock.calls.filter(([event]) => event === 'workflow enabled') - expect(enabledCalls).toEqual([['workflow enabled', { workflow_id: WORKFLOW_ID, entry_source: 'survey' }]]) - captureSpy.mockRestore() - }) + it.each([ + ['goes live', 'active', [['workflow enabled', { workflow_id: WORKFLOW_ID, entry_source: 'survey' }]]], + ['waits for its email domain', 'waiting_for_domain', []], + ] as [string, HogFlow['status'], unknown[]][])( + 'reports an enable that %s at most once, with the product surface it was opened from', + async (_name, enabledStatus, expectedCalls) => { + const captureSpy = jest.spyOn(posthog, 'capture') + saveEntrySource(WORKFLOW_ID, 'survey') + const draftWorkflow: HogFlow = { ...activeWorkflow, status: 'draft' } + // The enable lands with `enabledStatus`; by the next save the domain has verified. + const responseStatuses: HogFlow['status'][] = [enabledStatus, 'active'] + useMocks({ + ...activeMocks(draftWorkflow), + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + const body = (await request.json()) as Record + return [200, { ...draftWorkflow, ...body, status: responseStatuses.shift() }] + }, + }, + }) + initKeaTests() + logic = workflowLogic({ id: WORKFLOW_ID }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + + await expectLogic(logic, () => { + logic.actions.saveWorkflowPartial({ status: 'active' }) + }).toDispatchActions(['saveWorkflowSuccess']) + await expectLogic(logic, () => { + logic.actions.setWorkflowValue('name', 'Renamed while live') + logic.actions.markAutoSave(true) + logic.actions.saveWorkflow(logic.values.workflow) + }).toDispatchActions(['saveWorkflowSuccess']) + + const enabledCalls = captureSpy.mock.calls.filter(([event]) => event === 'workflow enabled') + expect(enabledCalls).toEqual(expectedCalls) + captureSpy.mockRestore() + } + ) it('a status transition sends lifecycle and metadata only, never stage_draft', async () => { useMocks( diff --git a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts new file mode 100644 index 000000000000..077f7efc172e --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts @@ -0,0 +1,284 @@ +import { expectLogic } from 'kea-test-utils' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { HogFlow } from './hogflows/types' +import { workflowLogic } from './workflowLogic' + +const WORKFLOW_ID = 'wf-send-once-verified-1' + +const makeWorkflow = (overrides: Partial = {}): HogFlow => ({ + id: WORKFLOW_ID, + name: 'Welcome', + actions: [ + { + id: 'trigger_node', + type: 'trigger', + name: 'Trigger', + description: '', + created_at: 0, + updated_at: 0, + config: { + type: 'event', + filters: { events: [{ id: '$pageview', name: '$pageview', type: 'events', order: 0 }] }, + }, + }, + { + id: 'exit_node', + type: 'exit', + name: 'Exit', + description: '', + created_at: 0, + updated_at: 0, + config: { reason: 'Default exit' }, + }, + ], + edges: [{ from: 'trigger_node', to: 'exit_node', type: 'continue' }], + conversion: { filters: [] }, + exit_condition: 'exit_only_at_end', + version: 1, + status: 'draft', + team_id: 1, + trigger: { type: 'event', filters: {} } as HogFlow['trigger'], + created_at: '2026-05-01T00:00:00.000Z', + updated_at: '2026-05-01T00:00:00.000Z', + ...overrides, +}) + +describe('workflowLogic send once verified', () => { + let logic: ReturnType + let patchBodies: Record[] + let storedWorkflow: HogFlow + let retrieveCount: number + + const mountWith = async (workflow: HogFlow): Promise => { + storedWorkflow = workflow + initKeaTests() + logic = workflowLogic({ id: WORKFLOW_ID }) + logic.mount() + await expectLogic(logic).toDispatchActions(['loadWorkflowSuccess']) + } + + beforeEach(() => { + patchBodies = [] + retrieveCount = 0 + localStorage.clear() + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': () => { + retrieveCount += 1 + return [200, storedWorkflow] + }, + '/api/projects/:team_id/hog_function_templates/': { results: [], count: 0 }, + }, + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + patchBodies.push((await request.json()) as Record) + return [200, { ...storedWorkflow, status: 'waiting_for_domain', enable_requested_at: '2026-05-02' }] + }, + }, + }) + }) + + afterEach(() => { + logic?.unmount() + jest.useRealTimers() + }) + + describe('while the workflow waits for its domain', () => { + const waiting = makeWorkflow({ + status: 'waiting_for_domain', + enable_requested_at: '2026-05-02T10:00:00Z', + }) + const wentLive: HogFlow = { + ...waiting, + status: 'active', + went_live_at: '2026-05-03T08:30:00Z', + updated_at: '2026-05-03T08:30:00Z', + } + + const mountWaiting = async (): Promise => { + jest.useFakeTimers({ advanceTimers: true }) + await mountWith(waiting) + retrieveCount = 0 + } + + it('shows the workflow going live without a reload, keeps unsaved edits, and stops checking', async () => { + await mountWaiting() + logic.actions.setAutoSaveEnabled(false) + logic.actions.setWorkflowValue('name', 'Renamed while waiting') + storedWorkflow = wentLive + + await jest.advanceTimersByTimeAsync(10_000) + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.originalWorkflow?.status).toEqual('active') + expect(logic.values.workflow.name).toEqual('Renamed while waiting') + expect(logic.values.hasUnsavedChanges).toBe(true) + + await jest.advanceTimersByTimeAsync(60_000) + expect(retrieveCount).toEqual(1) + }) + + it('stages later edits as a draft of the live copy, without a conflict', async () => { + useMocks({ + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + patchBodies.push((await request.json()) as Record) + return [200, storedWorkflow] + }, + }, + }) + await mountWaiting() + logic.actions.setAutoSaveEnabled(false) + storedWorkflow = wentLive + await jest.advanceTimersByTimeAsync(10_000) + await expectLogic(logic).toFinishAllListeners() + + logic.actions.setWorkflowValue('exit_condition', 'exit_on_conversion') + await expectLogic(logic, () => { + logic.actions.submitWorkflow() + }).toDispatchActions(['saveWorkflowSuccess']) + + expect(patchBodies).toEqual([ + expect.objectContaining({ + exit_condition: 'exit_on_conversion', + stage_draft: true, + base_updated_at: wentLive.updated_at, + }), + ]) + }) + + it('keeps an edit whose auto-save lands after the workflow went live, and saves it to the live copy', async () => { + useMocks({ + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + const body = (await request.json()) as Record + patchBodies.push(body) + if (body.base_updated_at !== storedWorkflow.updated_at) { + return [409, { detail: 'stale_update', code: 'stale_update' }] + } + storedWorkflow = { ...storedWorkflow, name: body.name, updated_at: '2026-05-03T09:00:00Z' } + return [200, storedWorkflow] + }, + }, + }) + await mountWaiting() + storedWorkflow = wentLive + + logic.actions.setWorkflowValue('name', 'Renamed while waiting') + await jest.advanceTimersByTimeAsync(3_000) + await expectLogic(logic).toFinishAllListeners() + + expect(logic.values.originalWorkflow?.status).toEqual('active') + expect(logic.values.workflow.name).toEqual('Renamed while waiting') + expect(storedWorkflow).toMatchObject({ status: 'active', name: 'Renamed while waiting' }) + expect(patchBodies.at(-1)).not.toHaveProperty('status') + }) + + it('does not save over an edit made elsewhere when the auto-save meets the go-live', async () => { + useMocks({ + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + patchBodies.push((await request.json()) as Record) + return [409, { detail: 'stale_update', code: 'stale_update' }] + }, + }, + }) + await mountWaiting() + storedWorkflow = { ...wentLive, name: 'Renamed elsewhere', updated_at: '2026-05-03T08:45:00Z' } + + logic.actions.setWorkflowValue('name', 'Renamed while waiting') + await jest.advanceTimersByTimeAsync(3_000) + await expectLogic(logic).toFinishAllListeners() + + expect(patchBodies).toHaveLength(1) + expect(logic.values.workflow.name).toEqual('Renamed elsewhere') + }) + + it('stages a content edit queued while the auto-save takes the go-live', async () => { + await mountWaiting() + let releaseFetch!: () => void + const fetchHeld = new Promise((resolve) => { + releaseFetch = resolve + }) + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': async () => { + await fetchHeld + return [200, storedWorkflow] + }, + }, + patch: { + '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { + const body = (await request.json()) as Record + patchBodies.push(body) + if (body.base_updated_at !== storedWorkflow.updated_at) { + return [409, { detail: 'stale_update', code: 'stale_update' }] + } + storedWorkflow = { ...storedWorkflow, name: body.name, updated_at: '2026-05-03T09:00:00Z' } + return [200, storedWorkflow] + }, + }, + }) + storedWorkflow = wentLive + + logic.actions.setWorkflowValue('name', 'Renamed while waiting') + await jest.advanceTimersByTimeAsync(3_000) + logic.actions.setWorkflowValue('exit_condition', 'exit_on_conversion') + await jest.advanceTimersByTimeAsync(3_000) + await expectLogic(logic, releaseFetch).toDispatchActions(['saveWorkflowSuccess', 'saveWorkflowSuccess']) + + expect(patchBodies.at(-1)).toMatchObject({ exit_condition: 'exit_on_conversion', stage_draft: true }) + }) + + it('drops a status answer that lands after the editor closed', async () => { + await mountWaiting() + let releaseCheck!: () => void + const checkReleased = new Promise((resolve) => { + releaseCheck = resolve + }) + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/:id/': async () => { + retrieveCount += 1 + await checkReleased + return [200, wentLive] + }, + }, + }) + await jest.advanceTimersByTimeAsync(10_000) + expect(retrieveCount).toEqual(1) + + logic.unmount() + releaseCheck() + await jest.advanceTimersByTimeAsync(0) + await new Promise((resolve) => setImmediate(resolve)) + }) + + it('checks only while the tab is visible, and checks at once when it shows again', async () => { + let hidden = true + const hiddenSpy = jest.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + try { + await mountWaiting() + document.dispatchEvent(new Event('visibilitychange')) + + await jest.advanceTimersByTimeAsync(60_000) + expect(retrieveCount).toEqual(0) + + storedWorkflow = wentLive + hidden = false + document.dispatchEvent(new Event('visibilitychange')) + await jest.advanceTimersByTimeAsync(0) + await expectLogic(logic).toFinishAllListeners() + + expect(retrieveCount).toEqual(1) + expect(logic.values.originalWorkflow?.status).toEqual('active') + expect(logic.values.hasUnsavedChanges).toBe(false) + } finally { + hiddenSpy.mockRestore() + } + }) + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index d57675e5e4b7..1d29b0b5e242 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -1,4 +1,17 @@ -import { MakeLogicType, actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { + MakeLogicType, + actions, + afterMount, + connect, + getContext, + kea, + key, + listeners, + path, + props, + reducers, + selectors, +} from 'kea' import { DeepPartialMap, ValidationErrorType, forms } from 'kea-forms' import type { DeepPartial, FieldName } from 'kea-forms' import { lazyLoaders, loaders } from 'kea-loaders' @@ -75,6 +88,7 @@ import { } from './hogflows/types' import { openPublishConfirmDialog } from './PublishImpactDialog' import { ResourceSaveQueue } from './resourceSaveQueue' +import { checkWaitingStatusWhileVisible } from './waitingStatusCheck' import { prepareWorkflowDuplicate } from './workflowDuplication' import { prefilledEmailContent, prefilledWorkflow, withEmailSender, withPrefilledEmail } from './workflowEmailPrefill' import { workflowSceneLogic } from './workflowSceneLogic' @@ -241,6 +255,52 @@ function pickWorkflowEdits(workflow: HogFlow): Partial { return result as Partial } +const WORKFLOW_LIFECYCLE_FIELDS = ['status', 'enable_requested_at', 'went_live_at'] as const + +function lifecycleUpdate(latest: HogFlow, fresh: HogFlow): Partial | null { + const update: Record = {} + for (const field of WORKFLOW_LIFECYCLE_FIELDS) { + if (!objectsEqual(latest[field], fresh[field])) { + update[field] = fresh[field] + } + } + if (Object.keys(update).length === 0) { + return null + } + // The go-live bumps updated_at. Take the new stamp only when nothing else changed, so the next + // save does not conflict with the go-live, but still conflicts with an edit made somewhere else. + const onlyLifecycleChanged = + latest.draft_updated_at === fresh.draft_updated_at && + objectsEqual(pickWorkflowEdits(latest), pickWorkflowEdits(fresh)) + if (onlyLifecycleChanged) { + update.updated_at = fresh.updated_at + } + return update as Partial +} + +/** The lifecycle a go-live wrote since `latest`, when nothing else changed on the server. */ +async function goLiveSince(workflowId: string, latest: HogFlow): Promise | null> { + let fresh: HogFlow + try { + fresh = await api.hogFlows.getHogFlow(workflowId) + } catch { + return null + } + const lifecycle = lifecycleUpdate(latest, fresh) + return lifecycle && 'updated_at' in lifecycle ? lifecycle : null +} + +function applyWorkflowLifecycle( + cache: Record, + actions: { setWorkflowLifecycle: (lifecycle: Partial) => void }, + lifecycle: Partial +): void { + if (cache.lastSavedWorkflow) { + cache.lastSavedWorkflow = { ...cache.lastSavedWorkflow, ...lifecycle } + } + actions.setWorkflowLifecycle(lifecycle) +} + /** What a save was dispatched with, kept per save because a later save moves the shared state. */ interface SaveContext { initiatedByAutoSave: boolean @@ -251,6 +311,7 @@ interface SaveContext { movedToBroadcasts?: number // A status change made while the form had unsaved edits. The edits stay in the form, unsaved. keepsFormEdits: boolean + enablesWorkflow: boolean } /** The schedule editor state, kept across a status change so unsaved schedule edits survive it. */ @@ -282,6 +343,7 @@ export interface workflowLogicValues { aiEmailDraftLoading: boolean aiEmailDraftStatus: AiEmailDraftStatusProps | null autoSaveBlockedByValidation: boolean + isWaitingForDomain: boolean autoSaveEnabled: boolean currentSchedule: HogFlowSchedule | null discardDisabledReason: string | undefined @@ -358,6 +420,9 @@ export interface workflowLogicActions { autoSaveWorkflow: () => { value: true } + checkWaitingStatus: () => { + value: true + } clearAutoSavePending: () => { value: true } @@ -370,6 +435,9 @@ export interface workflowLogicActions { confirmResumeEmailSending: () => { value: true } + setWorkflowLifecycle: (lifecycle: Partial) => { + lifecycle: Partial + } discardChanges: () => { value: true } @@ -3006,6 +3074,7 @@ export interface workflowLogicMeta { aiEmailDraft: AiEmailDraftResult | null ) => AiEmailDraftStatusProps | null logicProps: (arg: WorkflowLogicProps) => WorkflowLogicProps + isWaitingForDomain: (originalWorkflow: HogFlow | null) => boolean workflowUserAccessLevel: (originalWorkflow: HogFlow | null) => AccessControlLevel | null currentSchedule: (schedules: HogFlowSchedule[]) => HogFlowSchedule | null pendingSchedule: ( @@ -3282,6 +3351,8 @@ export const workflowLogic = kea([ setSchedules: (schedules: HogFlowSchedule[]) => ({ schedules }), restoreScheduleEdits: (edits: ScheduleEdits) => ({ edits }), saveWorkflowPartial: (workflow: Partial) => ({ workflow }), + checkWaitingStatus: true, + setWorkflowLifecycle: (lifecycle: Partial) => ({ lifecycle }), markSaveAttempted: (actionIds: string[]) => ({ actionIds }), triggerManualWorkflow: (variables: Record) => ({ variables, @@ -3373,6 +3444,15 @@ export const workflowLogic = kea([ // auto-save's own 409 would then take the manual path and raise the banner. // Read the origin at dispatch time, while it still describes this save. const initiatedByAutoSave = values.isAutoSave + // Whether this save means to change the lifecycle. Only the enable and disable + // control does, and it says so when it dispatches. Comparing the payload's + // status against the stored one cannot tell the difference: that control does + // not write its new status back to the form, so a form save queued behind it + // carries the old value and reads as a transition back to it. + const isStatusSave = cache.nextSaveChangesStatus === true + cache.nextSaveChangesStatus = false + const createsBroadcast = cache.nextSaveCreatesBroadcast === true + cache.nextSaveCreatesBroadcast = false // Saves run one at a time, so their success and failure actions arrive in // dispatch order. Queue what each save needs alongside them, so the success // listener reads its own values rather than shared state a later save moved. @@ -3386,20 +3466,14 @@ export const workflowLogic = kea([ pendingSchedule: keepsFormEdits ? false : values.pendingSchedule, moveToBroadcastIds: (cache.nextSaveMovesWorkflowIds ?? []) as string[], keepsFormEdits, + enablesWorkflow: + isStatusSave && updates.status === 'active' && values.originalWorkflow?.status !== 'active', } saveContexts.push(saveContext) cache.nextSaveMovesWorkflowIds = [] cache.nextSaveKeepsFormEdits = false - // Whether this save means to change the lifecycle. Only the enable and disable - // control does, and it says so when it dispatches. Comparing the payload's - // status against the stored one cannot tell the difference: that control does - // not write its new status back to the form, so a form save queued behind it - // carries the old value and reads as a transition back to it. - const isStatusSave = cache.nextSaveChangesStatus === true - cache.nextSaveChangesStatus = false - const createsBroadcast = cache.nextSaveCreatesBroadcast === true - cache.nextSaveCreatesBroadcast = false + let reconciledGoLive = false const runSave = async (): Promise => { updates = sanitizeWorkflow(updates, values.hogFunctionTemplatesById) @@ -3479,8 +3553,10 @@ export const workflowLogic = kea([ // Content edits on an active workflow stage into its draft (publish promotes them). // Metadata-only saves (rename, description) must not: staging the unchanged content // would create a phantom draft identical to live. - const stagingDraft = - latest?.status === 'active' && updates.status === 'active' && contentChanged + // A form save takes its status from the server copy, because one queued behind a + // go-live still carries the status the workflow had when the save was dispatched. + const routedStatus = isStatusSave ? updates.status : latest?.status + const stagingDraft = latest?.status === 'active' && routedStatus === 'active' && contentChanged // A status transition (enable/disable) toggles the lifecycle only. The button is // disabled while the form is dirty, so content in the payload is at best a no-op // re-send of the live row and at worst (with a staged draft merged into the form) @@ -3528,6 +3604,24 @@ export const workflowLogic = kea([ return result } catch (error) { if (error instanceof ApiError && error.status === 409) { + if ( + initiatedByAutoSave && + !reconciledGoLive && + latest?.status === 'waiting_for_domain' + ) { + // The go-live bumped the stamp under this save. Take its lifecycle and + // save again, instead of a reload that would drop the edit. + reconciledGoLive = true + const lifecycle = await goLiveSince(props.id, latest) + if (cache.disposables.isDisposed) { + throw error + } + if (lifecycle) { + applyWorkflowLifecycle(cache, actions, lifecycle) + updates = { ...updates, ...lifecycle } + return await runSave() + } + } if (initiatedByAutoSave && values.pendingSchedule === false) { // An auto-save losing the race is not a decision point: the user never // asked to overwrite anything, so reconcile to the newer copy silently. @@ -3796,8 +3890,18 @@ export const workflowLogic = kea([ setResumeEmailSendingPending: (_, { pending }) => pending, }, ], + originalWorkflow: { + setWorkflowLifecycle: (state, { lifecycle }) => (state ? { ...state, ...lifecycle } : state), + }, + workflow: { + setWorkflowLifecycle: (state, { lifecycle }) => ({ ...state, ...lifecycle }), + }, }), selectors({ + isWaitingForDomain: [ + (s) => [s.originalWorkflow], + (originalWorkflow: HogFlow | null): boolean => originalWorkflow?.status === 'waiting_for_domain', + ], aiEmailDraftStatus: [ (s) => [s.aiEmailDraftLoading, s.aiEmailDraftApplied, s.aiEmailDraft], (loading: boolean, applied: boolean, result: AiEmailDraftResult | null): AiEmailDraftStatusProps | null => { @@ -4405,7 +4509,6 @@ export const workflowLogic = kea([ // This is the one path that means to change the lifecycle. The loader reads the flag // as it handles the action below, so it describes this save and no other. cache.nextSaveChangesStatus = 'status' in workflow - cache.enablingWorkflow = workflow.status === 'active' && values.originalWorkflow?.status !== 'active' cache.nextSaveKeepsFormEdits = keepsFormEdits actions.saveWorkflow(merged) }, @@ -4440,13 +4543,36 @@ export const workflowLogic = kea([ loadWorkflowFailure: () => { actions.replayDeferredResourceEdited() }, + checkWaitingStatus: async () => { + const latest = (cache.lastSavedWorkflow as HogFlow | undefined) ?? values.originalWorkflow + if (!props.id || !latest || !values.isWaitingForDomain || values.workflowLoading) { + return + } + const context = getContext() + let fresh: HogFlow + try { + fresh = await api.hogFlows.getHogFlow(props.id) + } catch { + return + } + if (cache.disposables.isDisposed || getContext() !== context) { + return + } + const latestNow = (cache.lastSavedWorkflow as HogFlow | undefined) ?? values.originalWorkflow + if (latestNow !== latest || values.workflowLoading) { + return + } + const lifecycle = lifecycleUpdate(latest, fresh) + if (lifecycle) { + applyWorkflowLifecycle(cache, actions, lifecycle) + } + }, saveWorkflow: () => { cache.saveEditVersion = values.workflowEditVersion }, saveWorkflowFailure: () => { // Keep the queue aligned with the saves still in flight. ;(cache.saveContexts as SaveContext[] | undefined)?.shift() - cache.enablingWorkflow = false actions.replayDeferredResourceEdited() }, replayDeferredResourceEdited: () => { @@ -4462,8 +4588,8 @@ export const workflowLogic = kea([ const saveContext = (cache.saveContexts as SaveContext[] | undefined)?.shift() const isAutoSave = saveContext?.initiatedByAutoSave ?? values.isAutoSave - if (cache.enablingWorkflow && originalWorkflow.status === 'active') { - cache.enablingWorkflow = false + // An enable that waits for its email domain is not reported here: the server records the request. + if (saveContext?.enablesWorkflow && originalWorkflow.status === 'active') { // pinned: analytics event name posthog.capture('workflow enabled', { workflow_id: originalWorkflow.id, @@ -4799,9 +4925,10 @@ export const workflowLogic = kea([ } }, })), - afterMount(({ actions }) => { + afterMount(({ actions, cache }) => { actions.loadWorkflow() actions.loadHogFunctionTemplatesById() + checkWaitingStatusWhileVisible(cache.disposables, actions.checkWaitingStatus) }), beforeUnload((logic) => ({ enabled: (newLocation) => { diff --git a/products/workflows/frontend/Workflows/workflowsLogic.ts b/products/workflows/frontend/Workflows/workflowsLogic.ts index 43c4da931642..7e9cff23ecf9 100644 --- a/products/workflows/frontend/Workflows/workflowsLogic.ts +++ b/products/workflows/frontend/Workflows/workflowsLogic.ts @@ -73,9 +73,9 @@ const DEFAULT_FILTERS: WorkflowsFilters = { page: 1, } -type WorkflowsResult = CountedPaginatedResponse +export type WorkflowsResult = CountedPaginatedResponse -interface WorkflowsListParams { +export interface WorkflowsListParams { search?: string status?: HogFlow['status'] created_by?: string diff --git a/products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.test.ts b/products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.test.ts new file mode 100644 index 000000000000..0883ebb97778 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.test.ts @@ -0,0 +1,82 @@ +import { expectLogic } from 'kea-test-utils' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { HogFlow } from './hogflows/types' +import { workflowsLogic } from './workflowsLogic' +import { workflowsWaitingRefreshLogic } from './workflowsWaitingRefreshLogic' + +const waitingRow = { id: 'wf-waiting', name: 'Welcome', status: 'waiting_for_domain' } as HogFlow +const draftRow = { id: 'wf-draft', name: 'Follow up', status: 'draft' } as HogFlow + +describe('workflowsWaitingRefreshLogic', () => { + let listLogic: ReturnType + let logic: ReturnType + let listedRows: HogFlow[] + let listCount: number + + beforeEach(async () => { + listCount = 0 + listedRows = [waitingRow, draftRow] + useMocks({ + get: { + '/api/environments/:team_id/hog_flows/': () => { + listCount += 1 + return [200, { results: listedRows, count: listedRows.length }] + }, + }, + }) + jest.useFakeTimers({ advanceTimers: true }) + initKeaTests() + listLogic = workflowsLogic() + listLogic.mount() + logic = workflowsWaitingRefreshLogic() + logic.mount() + await expectLogic(listLogic, () => { + listLogic.actions.loadWorkflows() + }).toDispatchActions(['loadWorkflowsSuccess']) + listCount = 0 + }) + + afterEach(() => { + logic.unmount() + listLogic.unmount() + jest.useRealTimers() + }) + + it('refreshes the list while a workflow waits, and stops once none waits', async () => { + listedRows = [{ ...waitingRow, status: 'active' }, draftRow] + + await jest.advanceTimersByTimeAsync(10_000) + await expectLogic(logic).toFinishAllListeners() + + expect(listLogic.values.workflows.results.map(({ status }) => status)).toEqual(['active', 'draft']) + + await jest.advanceTimersByTimeAsync(60_000) + expect(listCount).toEqual(1) + }) + + it('keeps a row tag edit whose save is still in flight', async () => { + let releaseSave!: () => void + const saveReleased = new Promise((resolve) => { + releaseSave = resolve + }) + useMocks({ + patch: { + '/api/:scope/:team_id/hog_flows/:id/': async () => { + await saveReleased + return [200, { ...waitingRow, tags: ['growth'] }] + }, + }, + }) + listLogic.actions.updateWorkflowTags(waitingRow, ['growth']) + + await jest.advanceTimersByTimeAsync(10_000) + await jest.advanceTimersByTimeAsync(100) + + expect(listCount).toEqual(1) + expect(listLogic.values.localWorkflowTags).toEqual({ [waitingRow.id]: ['growth'] }) + releaseSave() + }) +}) diff --git a/products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.ts b/products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.ts new file mode 100644 index 000000000000..97639edd3939 --- /dev/null +++ b/products/workflows/frontend/Workflows/workflowsWaitingRefreshLogic.ts @@ -0,0 +1,100 @@ +import { MakeLogicType, actions, afterMount, connect, getContext, kea, listeners, path, selectors } from 'kea' + +import api from 'lib/api' +import { objectsEqual } from 'lib/utils/objects' + +import { HogFlow } from './hogflows/types' +import { checkWaitingStatusWhileVisible } from './waitingStatusCheck' +import { WorkflowsListParams, WorkflowsResult, workflowsLogic } from './workflowsLogic' + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowsWaitingRefreshLogicValues { + paramsFromFilters: WorkflowsListParams // workflowsLogic + workflows: WorkflowsResult // workflowsLogic + workflowsLoading: boolean // workflowsLogic + localWorkflowTags: Record // workflowsLogic + hasWaitingWorkflows: boolean +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowsWaitingRefreshLogicActions { + loadWorkflowsSuccess: ( + workflows: WorkflowsResult, + payload?: {} + ) => { + workflows: WorkflowsResult + payload?: {} + } // workflowsLogic + refreshWaitingWorkflows: () => { + value: true + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface workflowsWaitingRefreshLogicMeta { + __keaTypeGenInternalSelectorTypes: { + hasWaitingWorkflows: (workflows: WorkflowsResult) => boolean + } +} + +export type workflowsWaitingRefreshLogicType = MakeLogicType< + workflowsWaitingRefreshLogicValues, + workflowsWaitingRefreshLogicActions, + {}, + workflowsWaitingRefreshLogicMeta +> + +// The success action clears the row tag edits, so wait until the server has every edit. +function hasUnsavedTagEdit(response: WorkflowsResult, localTags: Record): boolean { + return response.results.some(({ id, tags }: HogFlow) => id in localTags && !objectsEqual(tags ?? [], localTags[id])) +} + +export const workflowsWaitingRefreshLogic = kea([ + path(['products', 'workflows', 'frontend', 'workflowsWaitingRefreshLogic']), + connect(() => ({ + values: [workflowsLogic, ['paramsFromFilters', 'workflows', 'workflowsLoading', 'localWorkflowTags']], + actions: [workflowsLogic, ['loadWorkflowsSuccess']], + })), + actions({ + refreshWaitingWorkflows: true, + }), + selectors({ + hasWaitingWorkflows: [ + (s) => [s.workflows], + (workflows: WorkflowsResult): boolean => + workflows.results.some(({ status }: HogFlow) => status === 'waiting_for_domain'), + ], + }), + listeners(({ actions, values, cache }) => ({ + refreshWaitingWorkflows: async () => { + if (!values.hasWaitingWorkflows || values.workflowsLoading) { + return + } + const shown = values.workflows + const params = values.paramsFromFilters + const context = getContext() + let response: WorkflowsResult + try { + response = await api.hogFlows.getHogFlows(params) + } catch { + return + } + if (cache.disposables.isDisposed || getContext() !== context) { + return + } + const superseded = + values.workflowsLoading || + values.workflows !== shown || + !objectsEqual(values.paramsFromFilters, params) || + hasUnsavedTagEdit(response, values.localWorkflowTags) + if (superseded) { + return + } + // The success action, not the loader, so the table does not flash its loading state. + actions.loadWorkflowsSuccess(response) + }, + })), + afterMount(({ actions, cache }) => { + checkWaitingStatusWhileVisible(cache.disposables, actions.refreshWaitingWorkflows) + }), +]) From 8b60abc148d986aad2ecbd77edf0c9616b0b73c7 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:01:59 +0000 Subject: [PATCH 2/2] fix(workflows): keep the saved baseline's compiled inputs when a save sanitizes Sanitizing a save rewrote each function step's inputs in place. The form shares its steps with the saved baseline, so the baseline lost the compiled inputs the server returns. An auto-save that met a go-live then saw a content change that was not there and reloaded over the edit. Sanitizing now copies the steps. Refs Silthus/personal#196. Co-Authored-By: Claude Opus 5.5 --- .../workflowLogic.sendOnceVerified.test.ts | 38 ++++++++++++++++--- .../frontend/Workflows/workflowLogic.ts | 32 +++++++++------- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts index 077f7efc172e..abce4f9fd503 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.sendOnceVerified.test.ts @@ -7,6 +7,7 @@ import { HogFlow } from './hogflows/types' import { workflowLogic } from './workflowLogic' const WORKFLOW_ID = 'wf-send-once-verified-1' +const WEBHOOK_TEMPLATE = { id: 'template-webhook', inputs_schema: [{ key: 'url', type: 'string', templating: true }] } const makeWorkflow = (overrides: Partial = {}): HogFlow => ({ id: WORKFLOW_ID, @@ -70,7 +71,7 @@ describe('workflowLogic send once verified', () => { retrieveCount += 1 return [200, storedWorkflow] }, - '/api/projects/:team_id/hog_function_templates/': { results: [], count: 0 }, + '/api/projects/:team_id/hog_function_templates/': { results: [WEBHOOK_TEMPLATE], count: 1 }, }, patch: { '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { @@ -91,12 +92,32 @@ describe('workflowLogic send once verified', () => { status: 'waiting_for_domain', enable_requested_at: '2026-05-02T10:00:00Z', }) - const wentLive: HogFlow = { - ...waiting, + const wentLiveLifecycle: Partial = { status: 'active', went_live_at: '2026-05-03T08:30:00Z', updated_at: '2026-05-03T08:30:00Z', } + const wentLive: HogFlow = { ...waiting, ...wentLiveLifecycle } + const [trigger, exit] = waiting.actions + const waitingWithWebhook = makeWorkflow({ + ...waiting, + actions: [ + trigger, + { + id: 'webhook_node', + type: 'function', + name: 'Webhook', + description: '', + created_at: 0, + updated_at: 0, + config: { + template_id: WEBHOOK_TEMPLATE.id, + inputs: { url: { value: 'https://example.com/hook', bytecode: ['_H', 1], order: 0 } }, + }, + }, + exit, + ] as HogFlow['actions'], + }) const mountWaiting = async (): Promise => { jest.useFakeTimers({ advanceTimers: true }) @@ -150,7 +171,10 @@ describe('workflowLogic send once verified', () => { ]) }) - it('keeps an edit whose auto-save lands after the workflow went live, and saves it to the live copy', async () => { + it.each<{ name: string; workflow: HogFlow }>([ + { name: 'a workflow without function steps', workflow: waiting }, + { name: 'a workflow with a function step', workflow: waitingWithWebhook }, + ])('keeps an edit whose auto-save lands after the workflow went live, for $name', async ({ workflow }) => { useMocks({ patch: { '/api/environments/:team_id/hog_flows/:id/': async ({ request }) => { @@ -164,8 +188,10 @@ describe('workflowLogic send once verified', () => { }, }, }) - await mountWaiting() - storedWorkflow = wentLive + jest.useFakeTimers({ advanceTimers: true }) + await mountWith(workflow) + await expectLogic(logic).toDispatchActions(['loadHogFunctionTemplatesByIdSuccess']) + storedWorkflow = { ...workflow, ...wentLiveLifecycle } logic.actions.setWorkflowValue('name', 'Renamed while waiting') await jest.advanceTimersByTimeAsync(3_000) diff --git a/products/workflows/frontend/Workflows/workflowLogic.ts b/products/workflows/frontend/Workflows/workflowLogic.ts index 1d29b0b5e242..b63747313738 100644 --- a/products/workflows/frontend/Workflows/workflowLogic.ts +++ b/products/workflows/frontend/Workflows/workflowLogic.ts @@ -199,20 +199,24 @@ export function sanitizeWorkflow( workflow: HogFlow, hogFunctionTemplatesById: Record ): HogFlow { - // Sanitize all function-like actions the same as we would a hog function - workflow.actions.forEach((action) => { - if (isFunctionAction(action) || isTriggerFunction(action)) { - const inputs = action.config.inputs - const template = hogFunctionTemplatesById[action.config.template_id] - if (template) { - action.config.inputs = sanitizeInputs({ - inputs_schema: template.inputs_schema, - inputs: inputs, - }) - } - } - }) - return workflow + // Sanitize all function-like actions the same as we would a hog function. Copy, never mutate: the + // actions are shared with the saved baseline, which must keep the compiled inputs the server returns. + return { ...workflow, actions: workflow.actions.map((action) => sanitizeAction(action, hogFunctionTemplatesById)) } +} + +function sanitizeAction( + action: HogFlowAction, + hogFunctionTemplatesById: Record +): HogFlowAction { + if (!isFunctionAction(action) && !isTriggerFunction(action)) { + return action + } + const template = hogFunctionTemplatesById[action.config.template_id] + if (!template) { + return action + } + const inputs = sanitizeInputs({ inputs_schema: template.inputs_schema, inputs: action.config.inputs }) + return { ...action, config: { ...action.config, inputs } } as HogFlowAction } /**