diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 1e7947e12d..fd86952052 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -78,12 +78,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { // Store hooks const { lastSaved, setNeedsRedeploymentFlag, blocks } = useWorkflowStore() - const { - workflows, - activeWorkflowId, - duplicateWorkflow, - isLoading: isRegistryLoading, - } = useWorkflowRegistry() + const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry() + const isRegistryLoading = + hydration.phase === 'idle' || + hydration.phase === 'metadata-loading' || + hydration.phase === 'state-loading' const { isExecuting, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() const { setActiveTab, togglePanel, isOpen } = usePanelStore() const { getFolderTree, expandedFolders } = useFolderStore() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts index 4a0bb28ca6..aac0a29590 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -118,7 +118,11 @@ export function useMentionData(props: UseMentionDataProps) { // Use workflow registry as source of truth for workflows const registryWorkflows = useWorkflowRegistry((state) => state.workflows) - const isLoadingWorkflows = useWorkflowRegistry((state) => state.isLoading) + const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) + const isLoadingWorkflows = + hydrationPhase === 'idle' || + hydrationPhase === 'metadata-loading' || + hydrationPhase === 'state-loading' // Convert registry workflows to mention format, filtered by workspace and sorted const workflows: WorkflowItem[] = Object.values(registryWorkflows) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/deploy.tsx index af8289a744..8ddd9424b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/deploy.tsx @@ -21,7 +21,11 @@ interface DeployProps { */ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) - const { isLoading: isRegistryLoading } = useWorkflowRegistry() + const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) + const isRegistryLoading = + hydrationPhase === 'idle' || + hydrationPhase === 'metadata-loading' || + hydrationPhase === 'state-loading' const { hasBlocks } = useCurrentWorkflow() // Get deployment status from registry diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx index 20f93a9ca1..399db7c6ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx @@ -81,12 +81,11 @@ export function Panel() { // Hooks const userPermissions = useUserPermissionsContext() const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) - const { - workflows, - activeWorkflowId, - duplicateWorkflow, - isLoading: isRegistryLoading, - } = useWorkflowRegistry() + const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry() + const isRegistryLoading = + hydration.phase === 'idle' || + hydration.phase === 'metadata-loading' || + hydration.phase === 'state-loading' const { getJson } = useWorkflowJsonStore() const { blocks } = useWorkflowStore() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 95d6640e7f..c752abc080 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -47,7 +47,7 @@ import { useCopilotStore } from '@/stores/panel-new/copilot/store' import { usePanelEditorStore } from '@/stores/panel-new/editor/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import { hasWorkflowsInitiallyLoaded, useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { getUniqueBlockName } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -83,7 +83,6 @@ interface BlockData { const WorkflowContent = React.memo(() => { // State - const [isWorkflowReady, setIsWorkflowReady] = useState(false) // State for tracking node dragging const [draggedNodeId, setDraggedNodeId] = useState(null) @@ -102,11 +101,12 @@ const WorkflowContent = React.memo(() => { // Get workspace ID from the params const workspaceId = params.workspaceId as string + const workflowIdParam = params.workflowId as string // Notification store const addNotification = useNotificationStore((state) => state.addNotification) - const { workflows, activeWorkflowId, isLoading, setActiveWorkflow } = useWorkflowRegistry() + const { workflows, activeWorkflowId, hydration, setActiveWorkflow } = useWorkflowRegistry() // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() @@ -127,6 +127,13 @@ const WorkflowContent = React.memo(() => { // Extract workflow data from the abstraction const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow + const isWorkflowReady = + hydration.phase === 'ready' && + hydration.workflowId === workflowIdParam && + activeWorkflowId === workflowIdParam && + Boolean(workflows[workflowIdParam]) && + lastSaved !== undefined + // Node utilities hook for position/hierarchy calculations (requires blocks) const { getNodeDepth, @@ -1209,10 +1216,19 @@ const WorkflowContent = React.memo(() => { useEffect(() => { let cancelled = false const currentId = params.workflowId as string + const currentWorkspaceHydration = hydration.workspaceId + + const isRegistryReady = hydration.phase !== 'metadata-loading' && hydration.phase !== 'idle' // Wait for registry to be ready to prevent race conditions - // Don't proceed if: no workflowId, registry is loading, or workflow not in registry - if (!currentId || isLoading || !workflows[currentId]) return + if ( + !currentId || + !workflows[currentId] || + !isRegistryReady || + (currentWorkspaceHydration && currentWorkspaceHydration !== workspaceId) + ) { + return + } if (activeWorkflowId !== currentId) { // Clear diff and set as active @@ -1229,25 +1245,15 @@ const WorkflowContent = React.memo(() => { return () => { cancelled = true } - }, [params.workflowId, workflows, activeWorkflowId, setActiveWorkflow, isLoading]) - - // Track when workflow is ready for rendering - useEffect(() => { - const currentId = params.workflowId as string - - // Workflow is ready when: - // 1. We have an active workflow that matches the URL - // 2. The workflow exists in the registry - // 3. Workflows are not currently loading - // 4. The workflow store has been initialized (lastSaved exists means state was loaded) - const shouldBeReady = - activeWorkflowId === currentId && - Boolean(workflows[currentId]) && - !isLoading && - lastSaved !== undefined - - setIsWorkflowReady(shouldBeReady) - }, [activeWorkflowId, params.workflowId, workflows, isLoading, lastSaved]) + }, [ + params.workflowId, + workflows, + activeWorkflowId, + setActiveWorkflow, + hydration.phase, + hydration.workspaceId, + workspaceId, + ]) // Preload workspace environment - React Query handles caching automatically useWorkspaceEnvironment(workspaceId) @@ -1258,8 +1264,8 @@ const WorkflowContent = React.memo(() => { const workflowIds = Object.keys(workflows) const currentId = params.workflowId as string - // Wait for initial load to complete before making navigation decisions - if (!hasWorkflowsInitiallyLoaded() || isLoading) { + // Wait for metadata to finish loading before making navigation decisions + if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') { return } @@ -1302,7 +1308,7 @@ const WorkflowContent = React.memo(() => { } validateAndNavigate() - }, [params.workflowId, workflows, isLoading, workspaceId, router]) + }, [params.workflowId, workflows, hydration.phase, workspaceId, router]) // Cache block configs to prevent unnecessary re-fetches const blockConfigCache = useRef>(new Map()) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 890da55b22..40f6715a94 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -81,7 +81,12 @@ interface TemplateData { export function Sidebar() { // useGlobalShortcuts() - const { workflows, isLoading: workflowsLoading, switchToWorkspace } = useWorkflowRegistry() + const { workflows, switchToWorkspace } = useWorkflowRegistry() + const workflowsHydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) + const workflowsLoading = + workflowsHydrationPhase === 'idle' || + workflowsHydrationPhase === 'metadata-loading' || + workflowsHydrationPhase === 'state-loading' const { data: sessionData, isPending: sessionLoading } = useSession() const userPermissions = useUserPermissionsContext() const isLoading = workflowsLoading || sessionLoading diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 34156587f1..1972c6aea8 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -43,7 +43,9 @@ async function fetchWorkflows(workspaceId: string): Promise } export function useWorkflows(workspaceId?: string) { - const setWorkflows = useWorkflowRegistry((state) => state.setWorkflows) + const beginMetadataLoad = useWorkflowRegistry((state) => state.beginMetadataLoad) + const completeMetadataLoad = useWorkflowRegistry((state) => state.completeMetadataLoad) + const failMetadataLoad = useWorkflowRegistry((state) => state.failMetadataLoad) const query = useQuery({ queryKey: workflowKeys.list(workspaceId), @@ -54,10 +56,24 @@ export function useWorkflows(workspaceId?: string) { }) useEffect(() => { - if (query.data) { - setWorkflows(query.data) + if (workspaceId && query.status === 'pending') { + beginMetadataLoad(workspaceId) } - }, [query.data, setWorkflows]) + }, [workspaceId, query.status, beginMetadataLoad]) + + useEffect(() => { + if (workspaceId && query.status === 'success' && query.data) { + completeMetadataLoad(workspaceId, query.data) + } + }, [workspaceId, query.status, query.data, completeMetadataLoad]) + + useEffect(() => { + if (workspaceId && query.status === 'error') { + const message = + query.error instanceof Error ? query.error.message : 'Failed to fetch workflows' + failMetadataLoad(workspaceId, message) + } + }, [workspaceId, query.status, query.error, failMetadataLoad]) return query } diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 2ef0e711b3..497f4997c9 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -207,8 +207,15 @@ export const resetAllStores = () => { useWorkflowRegistry.setState({ workflows: {}, activeWorkflowId: null, - isLoading: false, error: null, + deploymentStatuses: {}, + hydration: { + phase: 'idle', + workspaceId: null, + workflowId: null, + requestId: null, + error: null, + }, }) useWorkflowStore.getState().clear() useSubBlockStore.getState().clear() diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 3b77471f69..de69fa8448 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -7,6 +7,7 @@ import { API_ENDPOINTS } from '@/stores/constants' import { useVariablesStore } from '@/stores/panel/variables/store' import type { DeploymentStatus, + HydrationState, WorkflowMetadata, WorkflowRegistry, } from '@/stores/workflows/registry/types' @@ -15,164 +16,16 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('WorkflowRegistry') - -let isFetching = false -let lastFetchTimestamp = 0 - -async function fetchWorkflowsFromDB(workspaceId?: string): Promise { - if (typeof window === 'undefined') return - - // Prevent concurrent fetch operations - if (isFetching) { - logger.info('Fetch already in progress, skipping duplicate request') - return - } - - const fetchStartTime = Date.now() - isFetching = true - - try { - useWorkflowRegistry.getState().setLoading(true) - - const url = new URL(API_ENDPOINTS.WORKFLOWS, window.location.origin) - - if (workspaceId) { - url.searchParams.append('workspaceId', workspaceId) - } - - const response = await fetch(url.toString(), { method: 'GET' }) - - if (!response.ok) { - if (response.status === 401) { - logger.warn('User not authenticated for workflow fetch') - useWorkflowRegistry.setState({ workflows: {}, isLoading: false }) - return - } - throw new Error(`Failed to fetch workflows: ${response.statusText}`) - } - - // Check if this fetch is still relevant (not superseded by a newer fetch) - if (fetchStartTime < lastFetchTimestamp) { - logger.info('Fetch superseded by newer operation, discarding results') - return - } - - // Update timestamp to mark this as the most recent fetch - lastFetchTimestamp = fetchStartTime - - const { data } = await response.json() - - if (!data || !Array.isArray(data)) { - logger.info('No workflows found in database') - - // Only clear workflows if we're confident this is a legitimate empty state - const currentWorkflows = useWorkflowRegistry.getState().workflows - const hasExistingWorkflows = Object.keys(currentWorkflows).length > 0 - - if (hasExistingWorkflows) { - logger.warn( - 'Received empty workflow data but local workflows exist - possible race condition, preserving local state' - ) - useWorkflowRegistry.setState({ isLoading: false }) - return - } - - useWorkflowRegistry.setState({ workflows: {}, isLoading: false }) - return - } - - // Process workflows - const registryWorkflows: Record = {} - const deploymentStatuses: Record = {} - - data.forEach((workflow) => { - const { - id, - name, - description, - color, - variables, - createdAt, - workspaceId, - folderId, - isDeployed, - deployedAt, - apiKey, - } = workflow - - // Add to registry - registryWorkflows[id] = { - id, - name, - description: description || '', - color: color || '#3972F6', - lastModified: createdAt ? new Date(createdAt) : new Date(), - createdAt: createdAt ? new Date(createdAt) : new Date(), - workspaceId, - folderId: folderId || null, - } - - // Extract deployment status from database - if (isDeployed || deployedAt) { - deploymentStatuses[id] = { - isDeployed: isDeployed || false, - deployedAt: deployedAt ? new Date(deployedAt) : undefined, - apiKey: apiKey || undefined, - needsRedeployment: false, - } - } - - if (variables && typeof variables === 'object') { - useVariablesStore.setState((state) => { - const withoutWorkflow = Object.fromEntries( - Object.entries(state.variables).filter(([, v]: any) => v.workflowId !== id) - ) - return { - variables: { ...withoutWorkflow, ...variables }, - } - }) - } - }) - - // Update registry with loaded workflows and deployment statuses - useWorkflowRegistry.setState({ - workflows: registryWorkflows, - deploymentStatuses: deploymentStatuses, - isLoading: false, - error: null, - }) - - // Mark that initial load has completed - hasInitiallyLoaded = true - - // Only set first workflow as active if no active workflow is set and we have workflows - const currentState = useWorkflowRegistry.getState() - if (!currentState.activeWorkflowId && Object.keys(registryWorkflows).length > 0) { - const firstWorkflowId = Object.keys(registryWorkflows)[0] - useWorkflowRegistry.setState({ activeWorkflowId: firstWorkflowId }) - logger.info(`Set first workflow as active: ${firstWorkflowId}`) - } - - logger.info( - `Successfully loaded ${Object.keys(registryWorkflows).length} workflows from database` - ) - } catch (error) { - logger.error('Error fetching workflows from DB:', error) - - // Mark that initial load has completed even on error - // This prevents indefinite waiting for workflows that failed to load - hasInitiallyLoaded = true - - useWorkflowRegistry.setState({ - isLoading: false, - error: `Failed to load workflows: ${error instanceof Error ? error.message : 'Unknown error'}`, - }) - throw error - } finally { - isFetching = false - } +const initialHydration: HydrationState = { + phase: 'idle', + workspaceId: null, + workflowId: null, + requestId: null, + error: null, } +const createRequestId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}` + // Track workspace transitions to prevent race conditions let isWorkspaceTransitioning = false const TRANSITION_TIMEOUT = 5000 // 5 seconds maximum for workspace transitions @@ -215,49 +68,62 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void { } } -/** - * Checks if workspace is currently in transition - * @returns True if workspace is transitioning - */ -export function isWorkspaceInTransition(): boolean { - return isWorkspaceTransitioning -} - -/** - * Checks if workflows have been initially loaded - * @returns True if the initial workflow load has completed at least once - */ -export function hasWorkflowsInitiallyLoaded(): boolean { - return hasInitiallyLoaded -} - -// Track if initial load has happened to prevent premature navigation -let hasInitiallyLoaded = false - export const useWorkflowRegistry = create()( devtools( (set, get) => ({ // Store state workflows: {}, activeWorkflowId: null, - isLoading: false, error: null, deploymentStatuses: {}, + hydration: initialHydration, - setLoading: (loading: boolean) => { - set({ isLoading: loading }) + beginMetadataLoad: (workspaceId: string) => { + set((state) => ({ + error: null, + hydration: { + phase: 'metadata-loading', + workspaceId, + workflowId: null, + requestId: null, + error: null, + }, + })) }, - setWorkflows: (workflows: WorkflowMetadata[]) => { - set({ - workflows: workflows.reduce( - (acc, w) => { - acc[w.id] = w - return acc - }, - {} as Record - ), - }) + completeMetadataLoad: (workspaceId: string, workflows: WorkflowMetadata[]) => { + const mapped = workflows.reduce>((acc, workflow) => { + acc[workflow.id] = workflow + return acc + }, {}) + + set((state) => ({ + workflows: mapped, + error: null, + hydration: + state.hydration.phase === 'state-loading' + ? state.hydration + : { + phase: 'metadata-ready', + workspaceId, + workflowId: null, + requestId: null, + error: null, + }, + })) + }, + + failMetadataLoad: (workspaceId: string | null, errorMessage: string) => { + set((state) => ({ + error: errorMessage, + hydration: { + phase: 'error', + workspaceId: workspaceId ?? state.hydration.workspaceId, + workflowId: state.hydration.workflowId, + requestId: null, + error: errorMessage, + }, + })) }, // Switch to workspace - just clear state, let sidebar handle workflow loading @@ -276,9 +142,6 @@ export const useWorkflowRegistry = create()( try { logger.info(`Switching to workspace: ${workspaceId}`) - // Reset the initial load flag when switching workspaces - hasInitiallyLoaded = false - // Clear current workspace state resetWorkflowStores() @@ -286,8 +149,15 @@ export const useWorkflowRegistry = create()( set({ activeWorkflowId: null, workflows: {}, - isLoading: true, + deploymentStatuses: {}, error: null, + hydration: { + phase: 'metadata-loading', + workspaceId, + workflowId: null, + requestId: null, + error: null, + }, }) logger.info(`Successfully switched to workspace: ${workspaceId}`) @@ -295,7 +165,13 @@ export const useWorkflowRegistry = create()( logger.error(`Error switching to workspace ${workspaceId}:`, { error }) set({ error: `Failed to switch workspace: ${error instanceof Error ? error.message : 'Unknown error'}`, - isLoading: false, + hydration: { + phase: 'error', + workspaceId, + workflowId: null, + requestId: null, + error: error instanceof Error ? error.message : 'Unknown error', + }, }) } finally { setWorkspaceTransitioning(false) @@ -406,11 +282,152 @@ export const useWorkflowRegistry = create()( } }, + loadWorkflowState: async (workflowId: string) => { + const { workflows } = get() + + if (!workflows[workflowId]) { + const message = `Workflow not found: ${workflowId}` + logger.error(message) + set({ error: message }) + throw new Error(message) + } + + const requestId = createRequestId() + + set((state) => ({ + error: null, + hydration: { + phase: 'state-loading', + workspaceId: state.hydration.workspaceId, + workflowId, + requestId, + error: null, + }, + })) + + try { + const response = await fetch(`/api/workflows/${workflowId}`, { method: 'GET' }) + if (!response.ok) { + throw new Error(`Failed to load workflow ${workflowId}`) + } + + const workflowData = (await response.json()).data + let workflowState: any + + if (workflowData?.state) { + workflowState = { + blocks: workflowData.state.blocks || {}, + edges: workflowData.state.edges || [], + loops: workflowData.state.loops || {}, + parallels: workflowData.state.parallels || {}, + isDeployed: workflowData.isDeployed || false, + deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined, + apiKey: workflowData.apiKey, + lastSaved: Date.now(), + deploymentStatuses: {}, + } + } else { + workflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + isDeployed: false, + deployedAt: undefined, + deploymentStatuses: {}, + lastSaved: Date.now(), + } + + logger.info( + `Workflow ${workflowId} has no state yet - will load from DB or show empty canvas` + ) + } + + const nextDeploymentStatuses = + workflowData?.isDeployed || workflowData?.deployedAt + ? { + ...get().deploymentStatuses, + [workflowId]: { + isDeployed: workflowData.isDeployed || false, + deployedAt: workflowData.deployedAt + ? new Date(workflowData.deployedAt) + : undefined, + apiKey: workflowData.apiKey || undefined, + needsRedeployment: false, + }, + } + : get().deploymentStatuses + + const currentHydration = get().hydration + if ( + currentHydration.requestId !== requestId || + currentHydration.workflowId !== workflowId + ) { + logger.info('Discarding stale workflow hydration result', { + workflowId, + requestId, + }) + return + } + + useWorkflowStore.setState(workflowState) + useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks || {}) + + if (workflowData?.variables && typeof workflowData.variables === 'object') { + useVariablesStore.setState((state) => { + const withoutWorkflow = Object.fromEntries( + Object.entries(state.variables).filter(([, v]: any) => v.workflowId !== workflowId) + ) + return { + variables: { ...withoutWorkflow, ...workflowData.variables }, + } + }) + } + + window.dispatchEvent( + new CustomEvent('active-workflow-changed', { + detail: { workflowId }, + }) + ) + + set((state) => ({ + activeWorkflowId: workflowId, + error: null, + deploymentStatuses: nextDeploymentStatuses, + hydration: { + phase: 'ready', + workspaceId: state.hydration.workspaceId, + workflowId, + requestId, + error: null, + }, + })) + + logger.info(`Switched to workflow ${workflowId}`) + } catch (error) { + const message = + error instanceof Error + ? error.message + : `Failed to load workflow ${workflowId}: Unknown error` + logger.error(message) + set((state) => ({ + error: message, + hydration: { + phase: 'error', + workspaceId: state.hydration.workspaceId, + workflowId, + requestId: null, + error: message, + }, + })) + throw error + } + }, + // Modified setActiveWorkflow to work with clean DB-only architecture setActiveWorkflow: async (id: string) => { - const { workflows, activeWorkflowId } = get() + const { activeWorkflowId } = get() - // Check if workflow is already active AND has data loaded const workflowStoreState = useWorkflowStore.getState() const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0 @@ -419,88 +436,7 @@ export const useWorkflowRegistry = create()( return } - if (!workflows[id]) { - logger.error(`Workflow ${id} not found in registry`) - set({ error: `Workflow not found: ${id}` }) - throw new Error(`Workflow not found: ${id}`) - } - - logger.info(`Switching to workflow ${id}`) - - // Fetch workflow state from database - const response = await fetch(`/api/workflows/${id}`, { method: 'GET' }) - const workflowData = response.ok ? (await response.json()).data : null - - let workflowState: any - - if (workflowData?.state) { - // API returns normalized data in state - workflowState = { - blocks: workflowData.state.blocks || {}, - edges: workflowData.state.edges || [], - loops: workflowData.state.loops || {}, - parallels: workflowData.state.parallels || {}, - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined, - apiKey: workflowData.apiKey, - lastSaved: Date.now(), - deploymentStatuses: {}, - } - } else { - // If no state in DB, use empty state (Start block was created during workflow creation) - workflowState = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - isDeployed: false, - deployedAt: undefined, - deploymentStatuses: {}, - lastSaved: Date.now(), - } - - logger.info(`Workflow ${id} has no state yet - will load from DB or show empty canvas`) - } - - if (workflowData?.isDeployed || workflowData?.deployedAt) { - set((state) => ({ - deploymentStatuses: { - ...state.deploymentStatuses, - [id]: { - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined, - apiKey: workflowData.apiKey || undefined, - needsRedeployment: false, // Default to false when loading from DB - }, - }, - })) - } - - // Update all stores atomically to prevent race conditions - // Set activeWorkflowId and workflow state together - set({ activeWorkflowId: id, error: null }) - useWorkflowStore.setState(workflowState) - useSubBlockStore.getState().initializeFromWorkflow(id, (workflowState as any).blocks || {}) - - // Load workflow variables if they exist - if (workflowData?.variables && typeof workflowData.variables === 'object') { - useVariablesStore.setState((state) => { - const withoutWorkflow = Object.fromEntries( - Object.entries(state.variables).filter(([, v]: any) => v.workflowId !== id) - ) - return { - variables: { ...withoutWorkflow, ...workflowData.variables }, - } - }) - } - - window.dispatchEvent( - new CustomEvent('active-workflow-changed', { - detail: { workflowId: id }, - }) - ) - - logger.info(`Switched to workflow ${id}`) + await get().loadWorkflowState(id) }, /** @@ -712,7 +648,6 @@ export const useWorkflowRegistry = create()( set({ workflows: newWorkflows, activeWorkflowId: newActiveWorkflowId, - isLoading: true, error: null, }) @@ -760,9 +695,6 @@ export const useWorkflowRegistry = create()( logger.info(`Rolled back deletion of workflow ${id}`) }, - onComplete: () => { - set({ isLoading: false }) - }, errorMessage: `Failed to delete workflow ${id}`, }) }, @@ -843,8 +775,10 @@ export const useWorkflowRegistry = create()( set({ activeWorkflowId: null, - isLoading: true, + workflows: {}, + deploymentStatuses: {}, error: null, + hydration: initialHydration, }) logger.info('Logout complete - all workflow data cleared') diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 75de9cd2c1..5db4999bc0 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -16,18 +16,36 @@ export interface WorkflowMetadata { folderId?: string | null } +export type HydrationPhase = + | 'idle' + | 'metadata-loading' + | 'metadata-ready' + | 'state-loading' + | 'ready' + | 'error' + +export interface HydrationState { + phase: HydrationPhase + workspaceId: string | null + workflowId: string | null + requestId: string | null + error: string | null +} + export interface WorkflowRegistryState { workflows: Record activeWorkflowId: string | null - isLoading: boolean error: string | null deploymentStatuses: Record + hydration: HydrationState } export interface WorkflowRegistryActions { - setLoading: (loading: boolean) => void - setWorkflows: (workflows: WorkflowMetadata[]) => void + beginMetadataLoad: (workspaceId: string) => void + completeMetadataLoad: (workspaceId: string, workflows: WorkflowMetadata[]) => void + failMetadataLoad: (workspaceId: string | null, error: string) => void setActiveWorkflow: (id: string) => Promise + loadWorkflowState: (workflowId: string) => Promise switchToWorkspace: (id: string) => Promise removeWorkflow: (id: string) => Promise updateWorkflow: (id: string, metadata: Partial) => Promise diff --git a/apps/sim/stores/workflows/subblock/store.ts b/apps/sim/stores/workflows/subblock/store.ts index 529517bc15..2d8c895c65 100644 --- a/apps/sim/stores/workflows/subblock/store.ts +++ b/apps/sim/stores/workflows/subblock/store.ts @@ -115,9 +115,6 @@ export const useSubBlockStore = create()( }, })) - const originalActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - useWorkflowRegistry.setState({ activeWorkflowId: workflowId }) - Object.entries(blocks).forEach(([blockId, block]) => { const blockConfig = getBlock(block.type) if (!blockConfig) return @@ -159,10 +156,6 @@ export const useSubBlockStore = create()( } } }) - - if (originalActiveWorkflowId !== workflowId) { - useWorkflowRegistry.setState({ activeWorkflowId: originalActiveWorkflowId }) - } }, })) )