fix: deployed state preview persists across workflows

This commit is contained in:
Adam Gough
2025-06-02 10:01:58 -07:00
parent ad91e59d38
commit 9c8e0d8dcf
6 changed files with 246 additions and 105 deletions
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Info, Loader2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -66,11 +66,10 @@ export function DeploymentInfo({
// If deployedState is already loaded, use it directly
if (deployedState) {
logger.info(`Using cached deployed state for workflow: ${workflowId}`)
setIsViewingDeployed(true)
return
} else if (!isLoadingDeployedState) {
logger.debug(`[${workflowId}] No deployed state found`)
logger.debug(`No deployed state found`)
addNotification('error', 'Cannot view deployment: No deployed state available', workflowId)
}
}
@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { createLogger } from '@/lib/logs/console-logger'
import { WorkflowPreview } from '@/app/w/components/workflow-preview/workflow-preview'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('DeployedWorkflowCard')
@@ -16,11 +17,23 @@ interface DeployedWorkflowCardProps {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
_metadata?: {
workflowId?: string
fetchTimestamp?: number
requestId?: number
[key: string]: any
}
}
deployedWorkflowState: {
blocks: Record<string, any>
edges: Array<any>
loops: Record<string, any>
_metadata?: {
workflowId?: string
fetchTimestamp?: number
requestId?: number
[key: string]: any
}
}
className?: string
}
@@ -32,13 +45,24 @@ export function DeployedWorkflowCard({
}: DeployedWorkflowCardProps) {
const [showingDeployed, setShowingDeployed] = useState(true)
const workflowToShow = showingDeployed ? deployedWorkflowState : currentWorkflowState
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Create sanitized workflow state
const sanitizedWorkflowState = useMemo(() => {
if (!workflowToShow) return null;
// Verify the workflow ID matches if metadata exists
if (workflowToShow._metadata?.workflowId &&
workflowToShow._metadata.workflowId !== activeWorkflowId) {
logger.warn('Workflow ID mismatch detected in card', {
stateWorkflowId: workflowToShow._metadata.workflowId,
activeWorkflowId,
isDeployed: showingDeployed
});
}
// Filter out invalid blocks and make deep clone to avoid reference issues
return {
const result = {
blocks: Object.fromEntries(
Object.entries(workflowToShow.blocks || {})
.filter(([_, block]) => block && block.type) // Filter out invalid blocks
@@ -49,14 +73,22 @@ export function DeployedWorkflowCard({
})
),
edges: workflowToShow.edges ? JSON.parse(JSON.stringify(workflowToShow.edges)) : [],
loops: workflowToShow.loops ? JSON.parse(JSON.stringify(workflowToShow.loops)) : {}
loops: workflowToShow.loops ? JSON.parse(JSON.stringify(workflowToShow.loops)) : {},
_metadata: {
...(workflowToShow._metadata || {}),
workflowId: activeWorkflowId,
viewType: showingDeployed ? 'deployed' : 'current',
sanitizedAt: Date.now()
}
};
}, [workflowToShow]);
return result;
}, [workflowToShow, showingDeployed, activeWorkflowId]);
// Generate a unique key for the workflow preview
const previewKey = useMemo(() => {
return `${showingDeployed ? 'deployed' : 'current'}-preview`;
}, [showingDeployed]);
return `${showingDeployed ? 'deployed' : 'current'}-preview-${activeWorkflowId}-${Date.now()}`;
}, [showingDeployed, activeWorkflowId]);
return (
<Card className={cn('relative overflow-hidden', className)}>
@@ -94,6 +126,8 @@ export function DeployedWorkflowCard({
</div>
</div>
</CardHeader>
<div className="h-px w-full bg-border shadow-sm"></div>
<CardContent className='p-0'>
{/* Workflow preview with fixed height */}
@@ -1,6 +1,6 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useRef } from 'react'
import {
AlertDialog,
AlertDialogAction,
@@ -36,6 +36,12 @@ interface DeployedWorkflowModalProps {
edges: Array<any>
loops: Record<string, any>
parallels: Record<string, any>
_metadata?: {
workflowId?: string
fetchTimestamp?: number
requestId?: number
[key: string]: any
}
}
}
@@ -48,6 +54,27 @@ export function DeployedWorkflowModal({
const [isLoading, setIsLoading] = useState(false)
const { revertToDeployedState } = useWorkflowStore()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Add instance ID to track component lifecycle
const instanceId = useRef(Date.now());
const modalOpenCount = useRef(0);
// Keep track of the original deployed state when modal opens
const initialDeployedStateRef = useRef<any>(null);
useEffect(() => {
if (isOpen) {
modalOpenCount.current += 1;
// Store the initial deployed state when modal first opens
if (!initialDeployedStateRef.current) {
initialDeployedStateRef.current = deployedWorkflowState;
}
} else if (initialDeployedStateRef.current) {
// Reset the initial state reference when modal closes
initialDeployedStateRef.current = null;
}
}, [isOpen, deployedWorkflowState, activeWorkflowId]);
// Get current workflow state to compare with deployed state
const currentWorkflowState = useWorkflowStore((state) => ({
@@ -61,11 +88,6 @@ export function DeployedWorkflowModal({
const sanitizedCurrentState = useMemo(() => {
if (!currentWorkflowState) return undefined;
logger.info('Before current state sanitization', {
workflowId: activeWorkflowId,
blockCount: Object.keys(currentWorkflowState.blocks || {}).length
});
const result = {
blocks: Object.fromEntries(
Object.entries(currentWorkflowState.blocks || {})
@@ -76,14 +98,14 @@ export function DeployedWorkflowModal({
})
),
edges: currentWorkflowState.edges ? [...currentWorkflowState.edges] : [],
loops: currentWorkflowState.loops ? {...currentWorkflowState.loops} : {}
loops: currentWorkflowState.loops ? {...currentWorkflowState.loops} : {},
_metadata: {
workflowId: activeWorkflowId || undefined,
type: 'current',
timestamp: Date.now()
}
};
logger.info('After current state sanitization', {
workflowId: activeWorkflowId,
blockCount: Object.keys(result.blocks).length
});
return result;
}, [currentWorkflowState, activeWorkflowId]);
@@ -91,10 +113,40 @@ export function DeployedWorkflowModal({
if (!deployedWorkflowState) return {
blocks: {},
edges: [],
loops: {}
loops: {},
_metadata: {
workflowId: activeWorkflowId || undefined,
type: 'deployed-empty',
timestamp: Date.now()
}
};
return {
const stateWorkflowId = deployedWorkflowState?._metadata?.workflowId;
const stateMatch = stateWorkflowId === activeWorkflowId;
// Check if the deployed state belongs to the current workflow
// This is a critical safety check to prevent showing the wrong workflow state
if (stateWorkflowId && !stateMatch) {
logger.error('Attempted to use deployed state from wrong workflow', {
stateWorkflowId,
activeWorkflowId,
});
// Return empty state to prevent showing wrong workflow data
return {
blocks: {},
edges: [],
loops: {},
_metadata: {
workflowId: activeWorkflowId || undefined,
type: 'deployed-empty-mismatch',
originalWorkflowId: stateWorkflowId,
timestamp: Date.now()
}
};
}
const result = {
blocks: Object.fromEntries(
Object.entries(deployedWorkflowState.blocks || {})
.filter(([_, block]) => block && block.type)
@@ -104,47 +156,25 @@ export function DeployedWorkflowModal({
})
),
edges: deployedWorkflowState.edges ? [...deployedWorkflowState.edges] : [],
loops: deployedWorkflowState.loops ? {...deployedWorkflowState.loops} : {}
loops: deployedWorkflowState.loops ? {...deployedWorkflowState.loops} : {},
_metadata: {
...(deployedWorkflowState._metadata || {}),
workflowId: deployedWorkflowState._metadata?.workflowId || activeWorkflowId || undefined,
type: 'deployed-sanitized',
sanitizedAt: Date.now()
}
};
}, [deployedWorkflowState]);
return result;
}, [deployedWorkflowState, activeWorkflowId]);
const handleRevert = () => {
<<<<<<< HEAD
// Revert to the deployed state
revertToDeployedState(deployedWorkflowState)
setShowRevertDialog(false)
onClose()
=======
if (activeWorkflowId) {
logger.info(`Reverting to deployed state for workflow: ${activeWorkflowId}`)
revertToDeployedState(sanitizedDeployedState)
setShowRevertDialog(false)
onClose()
}
>>>>>>> 9594f7db (fix: good except for subblocks)
}
useEffect(() => {
if (isOpen && activeWorkflowId) {
logger.info('DeployedWorkflowModal opened', {
workflowId: activeWorkflowId,
deployedStateBlockCount: Object.keys(deployedWorkflowState?.blocks || {}).length,
currentStateBlockCount: Object.keys(currentWorkflowState?.blocks || {}).length,
deployedStateChecksum: JSON.stringify(deployedWorkflowState).length,
currentStateChecksum: JSON.stringify(currentWorkflowState).length
});
// Log a sample of block IDs to verify they match expected workflow
const deployedBlockIds = Object.keys(deployedWorkflowState?.blocks || {}).slice(0, 2);
const currentBlockIds = Object.keys(currentWorkflowState?.blocks || {}).slice(0, 2);
logger.info('State block samples', {
deployedBlockIds,
currentBlockIds
});
}
}, [isOpen, activeWorkflowId, deployedWorkflowState, currentWorkflowState]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
@@ -1,6 +1,10 @@
'use client'
<<<<<<< HEAD
import { useEffect, useState } from 'react'
=======
import { useState, useEffect, useRef } from 'react'
>>>>>>> 2d314bcc (fix: deployed state preview persists across workflows)
import { Loader2, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -42,6 +46,68 @@ export function DeploymentControls({
const [isDeploying, _setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
// Add a ref to track the last seen workflow ID and deployed state
const lastWorkflowIdRef = useRef<string | null>(null)
const lastDeployedStateRef = useRef<any>(null)
// Log when workflow ID changes
useEffect(() => {
if (activeWorkflowId !== lastWorkflowIdRef.current) {
logger.info('Workflow ID changed in DeploymentControls', {
previousId: lastWorkflowIdRef.current,
currentId: activeWorkflowId,
timestamp: Date.now()
});
lastWorkflowIdRef.current = activeWorkflowId;
}
}, [activeWorkflowId]);
// Log when deployed state changes
useEffect(() => {
if (deployedState && deployedState !== lastDeployedStateRef.current) {
const blockIds = Object.keys(deployedState?.blocks || {});
logger.info('Deployed state changed in DeploymentControls', {
workflowId: activeWorkflowId,
blockCount: blockIds.length,
blockIds: JSON.stringify(blockIds.slice(0, 3)),
isLoadingState: isLoadingDeployedState,
timestamp: Date.now(),
stateHash: JSON.stringify(deployedState).length
});
lastDeployedStateRef.current = deployedState;
}
}, [deployedState, activeWorkflowId, isLoadingDeployedState]);
// Add wrapper around refetchDeployedState to track timing
const refetchWithLogging = async () => {
if (!activeWorkflowId) return;
const fetchId = Date.now();
logger.info('Starting deployedState refetch', {
workflowId: activeWorkflowId,
fetchId,
timestamp: Date.now()
});
try {
await refetchDeployedState();
logger.info('Completed deployedState refetch', {
workflowId: activeWorkflowId,
fetchId,
timestamp: Date.now(),
duration: Date.now() - fetchId
});
} catch (error) {
logger.error('Error in deployedState refetch', {
workflowId: activeWorkflowId,
fetchId,
error,
timestamp: Date.now()
});
}
};
// Update parent component when workflow-specific status changes
useEffect(() => {
@@ -108,7 +174,7 @@ export function DeploymentControls({
setNeedsRedeployment={setNeedsRedeployment}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
refetchDeployedState={refetchDeployedState}
refetchDeployedState={refetchWithLogging}
/>
</>
)
@@ -111,6 +111,11 @@ export function ControlBar() {
// Add deployedState management
const [deployedState, setDeployedState] = useState<any>(null)
const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false)
// Add refs to manage fetch state and prevent race conditions
const abortControllerRef = useRef<AbortController | null>(null)
const lastFetchedWorkflowIdRef = useRef<string | null>(null)
const lastDeployedStateRef = useRef<boolean>(false)
// Workflow name editing state
const [isEditing, setIsEditing] = useState(false)
@@ -301,50 +306,68 @@ export function ControlBar() {
* @returns Promise that resolves when the deployed state is fetched
*/
const fetchDeployedState = async (options = { forceRefetch: false }) => {
// Cancel any in-flight requests
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller for this request
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
const requestId = Date.now();
const currentWorkflowId = activeWorkflowId;
// Skip fetching if we don't have an active workflow ID or it's not deployed
// unless we're explicitly forcing a refetch
if ((!activeWorkflowId || !isDeployed) && !options.forceRefetch) {
if ((!currentWorkflowId || !isDeployed) && !options.forceRefetch) {
setDeployedState(null);
return;
}
try {
setIsLoadingDeployedState(true);
const logMessage = options.forceRefetch
? `[CENTRAL] Explicitly refetching deployed state for workflow: ${activeWorkflowId}`
: `[CENTRAL] Fetching deployed state for workflow: ${activeWorkflowId} (Control Bar - Single Source of Truth)`;
logger.info(logMessage);
// Pass the abort signal to the fetch call
const response = await fetch(
`/api/workflows/${currentWorkflowId}/deployed`,
{ signal }
);
const response = await fetch(`/api/workflows/${activeWorkflowId}/deployed`);
if (!response.ok) {
throw new Error(`Failed to fetch deployed state: ${response.status}`);
}
const data = await response.json();
// Final workflow ID check before updating state
if (currentWorkflowId !== activeWorkflowId) {
return;
}
if (data.deployedState) {
const successMessage = options.forceRefetch
? 'Successfully refetched deployed state from DB after redeployment'
: 'Successfully fetched deployed state from DB - This is the only place that should fetch deployed state';
// Create a single deep clone with metadata
const deployedStateWithMetadata = {
...JSON.parse(JSON.stringify(data.deployedState)),
_metadata: {
workflowId: currentWorkflowId,
fetchTimestamp: Date.now(),
requestId
}
};
logger.info(successMessage);
// Create a deep clone to ensure no reference sharing with current state
const deepClonedState = JSON.parse(JSON.stringify(data.deployedState));
logger.info('deepClonedState', deepClonedState);
setDeployedState(deepClonedState);
setDeployedState(deployedStateWithMetadata);
} else {
const warningMessage = options.forceRefetch
? 'No deployed state found in the database after refetch'
: 'No deployed state found in the database';
logger.warn(warningMessage);
setDeployedState(null);
}
} catch (error) {
logger.error('Error fetching deployed state:', error);
setDeployedState(null);
} catch (error: unknown) {
// Don't log AbortError as it's expected when cancelling requests
if (error instanceof Error && error.name === 'AbortError') {
// Silently ignore abort errors
} else {
logger.error(`Error fetching deployed state:`, { error });
setDeployedState(null);
}
} finally {
setIsLoadingDeployedState(false);
}
@@ -355,7 +378,16 @@ export function ControlBar() {
// Fetch deployed state when the workflow ID changes or deployment status changes
useEffect(() => {
fetchDeployedState();
// Only fetch if the workflow ID or deployed status has actually changed
if (activeWorkflowId !== lastFetchedWorkflowIdRef.current ||
isDeployed !== lastDeployedStateRef.current) {
// Update refs to track what we're fetching for
lastFetchedWorkflowIdRef.current = activeWorkflowId;
lastDeployedStateRef.current = isDeployed;
fetchDeployedState();
}
}, [activeWorkflowId, isDeployed]);
// Listen for deployment status changes
@@ -27,11 +27,7 @@ import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edg
// import { LoopLabel } from '@/app/w/[id]/components/workflow-loop/components/loop-label/loop-label'
// import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-loop'
import { getBlock } from '@/blocks'
<<<<<<< HEAD
import type { SubBlockConfig } from '@/blocks/types'
=======
import { cn } from '@/lib/utils'
>>>>>>> 6f129dfc (fix: subblock rerender fixed)
const logger = createLogger('WorkflowPreview')
@@ -72,10 +68,6 @@ const edgeTypes: EdgeTypes = {
workflowEdge: WorkflowEdge,
}
// The subblocks should be getting passed from the state and not the subBlockStore.
// Create optional parameter boolan isPreview to pass in the block state to know how to render
// the subblocks
export function WorkflowPreview({
workflowState,
showSubBlocks = true,
@@ -86,14 +78,6 @@ export function WorkflowPreview({
defaultPosition,
defaultZoom,
}: WorkflowPreviewProps) {
// Use effect to log the workflow state once outside of useMemo
useEffect(() => {
logger.info('WorkflowPreview received new state', {
blockCount: Object.keys(workflowState?.blocks || {}).length,
withSubBlocks: Object.values(workflowState?.blocks || {}).filter(b => b.subBlocks && Object.keys(b.subBlocks).length > 0).length,
});
}, [workflowState]);
// Transform blocks and loops into ReactFlow nodes
const nodes: Node[] = useMemo(() => {
const nodeArray: Node[] = []
@@ -144,7 +128,6 @@ export function WorkflowPreview({
subBlockValues: subBlocksClone, // Use the deep clone to avoid reference issues
},
})
<<<<<<< HEAD
// Add children of this block if it's a loop
if (block.type === 'loop') {
@@ -178,14 +161,12 @@ export function WorkflowPreview({
})
})
}
=======
logger.info(`Preview node created: ${blockId}`, {
blockType: block.type,
hasSubBlocks: block.subBlocks && Object.keys(block.subBlocks).length > 0
});
>>>>>>> 6f129dfc (fix: subblock rerender fixed)
})
return nodeArray
}, [JSON.stringify(workflowState.blocks), JSON.stringify(workflowState.loops), showSubBlocks])
@@ -201,13 +182,12 @@ export function WorkflowPreview({
}))
}, [JSON.stringify(workflowState.edges)])
useEffect(() => {
logger.info('Rendering workflow state', { workflowState })
}, [workflowState])
return (
<ReactFlowProvider>
<div style={{ height, width }} className={cn(className, 'preview-mode')}>
<div
style={{ height, width }}
className={cn(className, 'preview-mode')}
>
<ReactFlow
nodes={nodes}
edges={edges}