diff --git a/.husky/pre-commit b/.husky/pre-commit index f54fc9cd5cf..36946c38ebf 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -bunx lint-staged \ No newline at end of file +bun lint \ No newline at end of file diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index a0e8512a7d1..7c211c45299 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -11,7 +11,7 @@ import { chat, environment as envTable, userStats, workflow } from '@/db/schema' import { Executor } from '@/executor' import type { BlockLog } from '@/executor/types' import { Serializer } from '@/serializer' -import { mergeSubblockState } from '@/stores/workflows/utils' +import { mergeSubblockState } from '@/stores/workflows/server-utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' declare global { diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index e347aa27dcf..c63eba20570 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -19,7 +19,7 @@ import { db } from '@/db' import { environment, userStats, workflow, workflowSchedule } from '@/db/schema' import { Executor } from '@/executor' import { Serializer } from '@/serializer' -import { mergeSubblockState } from '@/stores/workflows/utils' +import { mergeSubblockState } from '@/stores/workflows/server-utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' // Add dynamic export to prevent caching diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 2e53a314f4d..4c1f363c950 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -12,7 +12,7 @@ import { db } from '@/db' import { environment, userStats } from '@/db/schema' import { Executor } from '@/executor' import { Serializer } from '@/serializer' -import { mergeSubblockState } from '@/stores/workflows/utils' +import { mergeSubblockState } from '@/stores/workflows/server-utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { validateWorkflowAccess } from '../../middleware' import { createErrorResponse, createSuccessResponse } from '../../utils' diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index ac317fb6faf..7221f4c5b50 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,31 +1,32 @@ import { and, eq } from 'drizzle-orm' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { workflow, workspaceMember } from '@/db/schema' -const logger = createLogger('WorkflowDetailAPI') +const logger = createLogger('WorkflowByIdAPI') -export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { +/** + * GET /api/workflows/[id] + * Fetch a single workflow by ID + */ +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = crypto.randomUUID().slice(0, 8) const startTime = Date.now() + const { id: workflowId } = await params try { // Get the session const session = await getSession() if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workflow access attempt`) + logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { id: workflowId } = await params + const userId = session.user.id - if (!workflowId) { - return NextResponse.json({ error: 'Workflow ID is required' }, { status: 400 }) - } - - // Fetch the workflow from database + // Fetch the workflow const workflowData = await db .select() .from(workflow) @@ -38,42 +39,124 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: } // Check if user has access to this workflow - // User can access if they own it OR if it's in a workspace they're part of - const canAccess = workflowData.userId === session.user.id + let hasAccess = false + + // Case 1: User owns the workflow + if (workflowData.userId === userId) { + hasAccess = true + } - if (!canAccess && workflowData.workspaceId) { - // Check workspace membership + // Case 2: Workflow belongs to a workspace the user is a member of + if (!hasAccess && workflowData.workspaceId) { const membership = await db - .select() + .select({ id: workspaceMember.id }) .from(workspaceMember) .where( and( eq(workspaceMember.workspaceId, workflowData.workspaceId), - eq(workspaceMember.userId, session.user.id) + eq(workspaceMember.userId, userId) ) ) .then((rows) => rows[0]) if (membership) { - // User is a member of the workspace, allow access - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Workflow ${workflowId} fetched in ${elapsed}ms`) - return NextResponse.json({ data: workflowData }, { status: 200 }) + hasAccess = true + } + } + + if (!hasAccess) { + logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`) + + return NextResponse.json({ data: workflowData }, { status: 200 }) + } catch (error: any) { + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * DELETE /api/workflows/[id] + * Delete a workflow by ID + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + const startTime = Date.now() + const { id: workflowId } = await params + + try { + // Get the session + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + // Fetch the workflow to check ownership/access + const workflowData = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .then((rows) => rows[0]) + + if (!workflowData) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + // Check if user has permission to delete this workflow + let canDelete = false + + // Case 1: User owns the workflow + if (workflowData.userId === userId) { + canDelete = true + } + + // Case 2: Workflow belongs to a workspace and user has admin/owner role + if (!canDelete && workflowData.workspaceId) { + const membership = await db + .select({ role: workspaceMember.role }) + .from(workspaceMember) + .where( + and( + eq(workspaceMember.workspaceId, workflowData.workspaceId), + eq(workspaceMember.userId, userId) + ) + ) + .then((rows) => rows[0]) + + if (membership && (membership.role === 'owner' || membership.role === 'admin')) { + canDelete = true } - } else if (canAccess) { - // User owns the workflow, allow access - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Workflow ${workflowId} fetched in ${elapsed}ms`) - return NextResponse.json({ data: workflowData }, { status: 200 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted to access workflow ${workflowId} without permission` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + if (!canDelete) { + logger.warn( + `[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}` + ) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + // Delete the workflow + await db.delete(workflow).where(eq(workflow.id, workflowId)) + + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) + + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error fetching workflow after ${elapsed}ms:`, error) - return NextResponse.json({ error: 'Failed to fetch workflow' }, { status: 500 }) + logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index 7b72967ce6e..de6c8fad76d 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -377,9 +377,9 @@ export async function POST(req: NextRequest) { processedIds.add(id) const dbWorkflow = dbWorkflowMap.get(id) - // Handle legacy published workflows migration + // Handle legacy published workflows migration (only if state is provided) // If client workflow has isPublished but no marketplaceData, create marketplaceData with owner status - if (clientWorkflow.state.isPublished && !clientWorkflow.marketplaceData) { + if (clientWorkflow.state?.isPublished && !clientWorkflow.marketplaceData) { clientWorkflow.marketplaceData = { id: clientWorkflow.id, status: 'owner' } } @@ -387,7 +387,7 @@ export async function POST(req: NextRequest) { const effectiveWorkspaceId = clientWorkflow.workspaceId || workspaceId if (!dbWorkflow) { - // New workflow - create + // New workflow - create (state is required by schema) operations.push( db.insert(workflow).values({ id: clientWorkflow.id, @@ -417,61 +417,57 @@ export async function POST(req: NextRequest) { continue // Skip this workflow update and move to the next one } - // Existing workflow - update if needed - const needsUpdate = - JSON.stringify(dbWorkflow.state) !== JSON.stringify(clientWorkflow.state) || - dbWorkflow.name !== clientWorkflow.name || - dbWorkflow.description !== clientWorkflow.description || - dbWorkflow.color !== clientWorkflow.color || - dbWorkflow.workspaceId !== effectiveWorkspaceId || - dbWorkflow.folderId !== (clientWorkflow.folderId || null) || + // For existing workflows, determine what needs updating + let needsUpdate = false + const updateData: any = {} + + // Check metadata changes + if (dbWorkflow.name !== clientWorkflow.name) { + updateData.name = clientWorkflow.name + needsUpdate = true + } + if (dbWorkflow.description !== clientWorkflow.description) { + updateData.description = clientWorkflow.description + needsUpdate = true + } + if (dbWorkflow.color !== clientWorkflow.color) { + updateData.color = clientWorkflow.color + needsUpdate = true + } + if (dbWorkflow.workspaceId !== effectiveWorkspaceId) { + updateData.workspaceId = effectiveWorkspaceId + needsUpdate = true + } + if (dbWorkflow.folderId !== (clientWorkflow.folderId || null)) { + updateData.folderId = clientWorkflow.folderId || null + needsUpdate = true + } + if ( JSON.stringify(dbWorkflow.marketplaceData) !== - JSON.stringify(clientWorkflow.marketplaceData) + JSON.stringify(clientWorkflow.marketplaceData) + ) { + updateData.marketplaceData = clientWorkflow.marketplaceData || null + needsUpdate = true + } - if (needsUpdate) { - operations.push( - db - .update(workflow) - .set({ - name: clientWorkflow.name, - description: clientWorkflow.description, - color: clientWorkflow.color, - workspaceId: effectiveWorkspaceId, - folderId: clientWorkflow.folderId || null, - state: clientWorkflow.state, - marketplaceData: clientWorkflow.marketplaceData || null, - lastSynced: now, - updatedAt: now, - }) - .where(eq(workflow.id, id)) - ) + // Always update state since we only sync the active workflow with valid state + if (JSON.stringify(dbWorkflow.state) !== JSON.stringify(clientWorkflow.state)) { + updateData.state = clientWorkflow.state + needsUpdate = true } - } - } - // Handle deletions - workflows in DB but not in client - // Only delete workflows for the current workspace and only those the user can modify - for (const dbWorkflow of dbWorkflows) { - if ( - !processedIds.has(dbWorkflow.id) && - (!workspaceId || dbWorkflow.workspaceId === workspaceId) - ) { - // Check if the user has permission to delete this workflow - // Users can delete their own workflows, or any workflow if they're a workspace owner/admin - const canDelete = - dbWorkflow.userId === session.user.id || - (workspaceId && (userRole === 'owner' || userRole === 'admin' || userRole === 'member')) + if (needsUpdate) { + updateData.lastSynced = now + updateData.updatedAt = now - if (canDelete) { - operations.push(db.delete(workflow).where(eq(workflow.id, dbWorkflow.id))) - } else { - logger.warn( - `[${requestId}] User ${session.user.id} attempted to delete workflow ${dbWorkflow.id} without permission` - ) + operations.push(db.update(workflow).set(updateData).where(eq(workflow.id, id))) } } } + // NOTE: We don't delete workflows here since we're only syncing the active workflow + // Other workflows remain untouched in the database + // Execute all operations in parallel await Promise.all(operations) diff --git a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx index 9dbcd5aeac5..d7907a05816 100644 --- a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -409,21 +409,25 @@ export function ControlBar() { /** * Workflow deletion handler */ - const handleDeleteWorkflow = () => { + const handleDeleteWorkflow = async () => { if (!activeWorkflowId) return - // Get remaining workflow IDs + // Navigate to another workflow first const remainingIds = Object.keys(workflows).filter((id) => id !== activeWorkflowId) - // Navigate before removing the workflow to avoid any state inconsistencies if (remainingIds.length > 0) { router.push(`/w/${remainingIds[0]}`) } else { router.push('/') } - // Remove the workflow from the registry - removeWorkflow(activeWorkflowId) + // Remove the workflow from the registry (now async) + try { + await removeWorkflow(activeWorkflowId) + } catch (error) { + // Handle error gracefully - could show user notification instead + logger.error('Failed to delete workflow:', error) + } } // /** diff --git a/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx b/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx new file mode 100644 index 00000000000..141700a1ec2 --- /dev/null +++ b/apps/sim/app/w/[id]/components/skeleton-loading/skeleton-loading.tsx @@ -0,0 +1,205 @@ +'use client' + +import { Bell, Bug, ChevronDown, Copy, History, Layers, Play, Rocket, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useSidebarStore } from '@/stores/sidebar/store' + +// Skeleton Components +const SkeletonControlBar = () => { + return ( +
+ {/* Left Section - Workflow Name Skeleton */} +
+ {/* Workflow name skeleton */} + + {/* "Saved X time ago" skeleton */} + +
+ + {/* Middle Section */} +
+ + {/* Right Section - Action Buttons with Real Icons */} +
+ {/* Delete Button */} + + + {/* History Button */} + + + {/* Notifications Button */} + + + {/* Duplicate Button */} + + + {/* Auto Layout Button */} + + + {/* Debug Mode Button */} + + + {/* Deploy Button */} + + + {/* Run Button with Dropdown */} +
+ {/* Main Run Button */} + + + {/* Dropdown Trigger */} + +
+
+
+ ) +} + +const SkeletonPanelComponent = () => { + return ( +
+ {/* Panel skeleton */} +
+ {/* Tab headers skeleton */} +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ + {/* Content skeleton */} +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+ ) +} + +const SkeletonNodes = () => { + return [ + // Starter node skeleton + { + id: 'skeleton-starter', + type: 'workflowBlock', + position: { x: 100, y: 100 }, + data: { + type: 'skeleton', + config: { name: '', description: '', bgColor: '#9CA3AF' }, + name: '', + isActive: false, + isPending: false, + isSkeleton: true, + }, + dragHandle: '.workflow-drag-handle', + }, + // Additional skeleton nodes + { + id: 'skeleton-node-1', + type: 'workflowBlock', + position: { x: 500, y: 100 }, + data: { + type: 'skeleton', + config: { name: '', description: '', bgColor: '#9CA3AF' }, + name: '', + isActive: false, + isPending: false, + isSkeleton: true, + }, + dragHandle: '.workflow-drag-handle', + }, + { + id: 'skeleton-node-2', + type: 'workflowBlock', + position: { x: 300, y: 300 }, + data: { + type: 'skeleton', + config: { name: '', description: '', bgColor: '#9CA3AF' }, + name: '', + isActive: false, + isPending: false, + isSkeleton: true, + }, + dragHandle: '.workflow-drag-handle', + }, + ] +} + +interface SkeletonLoadingProps { + showSkeleton: boolean + isSidebarCollapsed: boolean + children: React.ReactNode +} + +export function SkeletonLoading({ + showSkeleton, + isSidebarCollapsed, + children, +}: SkeletonLoadingProps) { + const { mode, isExpanded } = useSidebarStore() + + return ( +
+
+ {/* Skeleton Control Bar */} +
+ +
+ + {/* Real Control Bar */} +
+ {children} +
+
+ + {/* Real content will be rendered by children - sidebar will show its own loading state */} +
+ ) +} + +export function SkeletonPanelWrapper({ showSkeleton }: { showSkeleton: boolean }) { + return ( +
+ +
+ ) +} + +export { SkeletonNodes, SkeletonPanelComponent } diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 57d8f12d2e1..8c06924f53e 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -18,7 +18,7 @@ import { OAUTH_PROVIDERS, type OAuthProvider, parseProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' const logger = createLogger('OAuthRequiredModal') diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx index ad08ad9f887..717dbacd072 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/credential-selector.tsx @@ -20,7 +20,7 @@ import { OAUTH_PROVIDERS, type OAuthProvider, parseProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from './components/oauth-required-modal' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx index f453137370c..a7f2e9e7bd2 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/confluence-file-selector.tsx @@ -18,7 +18,7 @@ import { getProviderIdFromServiceId, getServiceIdFromScopes, type OAuthProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx index a151b14835e..2d0938bae5d 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx @@ -23,7 +23,7 @@ import { OAUTH_PROVIDERS, type OAuthProvider, parseProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx index cc9af8850b5..2a2c2922996 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/jira-issue-selector.tsx @@ -19,7 +19,7 @@ import { getProviderIdFromServiceId, getServiceIdFromScopes, type OAuthProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx index b13104085f7..a04f7e01c6b 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/microsoft-file-selector.tsx @@ -22,7 +22,7 @@ import { OAUTH_PROVIDERS, type OAuthProvider, parseProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx index df2c3a25bf0..f71abcfdce5 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/teams-message-selector.tsx @@ -19,7 +19,7 @@ import { getProviderIdFromServiceId, getServiceIdFromScopes, type OAuthProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx index a28983b8e82..2a0d9990eab 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/folder-selector/folder-selector.tsx @@ -14,11 +14,7 @@ import { } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { createLogger } from '@/lib/logs/console-logger' -import { - type Credential, - getProviderIdFromServiceId, - getServiceIdFromScopes, -} from '@/lib/oauth/oauth' +import { type Credential, getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth' import { OAuthRequiredModal } from '@/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal' import { saveToStorage } from '@/stores/workflows/persistence' diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx index d9f171edb72..5d562708a2a 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/jira-project-selector.tsx @@ -19,7 +19,7 @@ import { getProviderIdFromServiceId, getServiceIdFromScopes, type OAuthProvider, -} from '@/lib/oauth/oauth' +} from '@/lib/oauth' import { saveToStorage } from '@/stores/workflows/persistence' import { OAuthRequiredModal } from '../../credential-selector/components/oauth-required-modal' diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index ff10a7b9d6f..3aa89114b09 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -12,7 +12,6 @@ import ReactFlow, { } from 'reactflow' import 'reactflow/dist/style.css' -import { LoadingAgent } from '@/components/ui/loading-agent' import { createLogger } from '@/lib/logs/console-logger' import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' import { NotificationList } from '@/app/w/[id]/components/notifications/notifications' @@ -23,13 +22,14 @@ import { useNotificationStore } from '@/stores/notifications/store' import { useVariablesStore } from '@/stores/panel/variables/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useSidebarStore } from '@/stores/sidebar/store' -import { initializeSyncManagers, isSyncInitialized } from '@/stores/sync-registry' +import { initializeSyncManagers } from '@/stores/sync-registry' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ControlBar } from './components/control-bar/control-bar' import { ErrorBoundary } from './components/error/index' import { Panel } from './components/panel/panel' +import { SkeletonLoading } from './components/skeleton-loading/skeleton-loading' import { Toolbar } from './components/toolbar/toolbar' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' @@ -56,7 +56,7 @@ const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge } function WorkflowContent() { // State - const [isInitialized, setIsInitialized] = useState(false) + const [isWorkflowReady, setIsWorkflowReady] = useState(false) const { mode, isExpanded } = useSidebarStore() // In hover mode, act as if sidebar is always collapsed for layout purposes const isSidebarCollapsed = @@ -76,7 +76,13 @@ function WorkflowContent() { const { project, getNodes, fitView } = useReactFlow() // Store access - const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() + const { + workflows, + activeWorkflowId, + setActiveWorkflow, + createWorkflow, + isLoading: workflowsLoading, + } = useWorkflowRegistry() const { blocks, edges, @@ -247,22 +253,17 @@ function WorkflowContent() { } }, [debouncedAutoLayout]) - // Initialize workflow + // Initialize workflow system useEffect(() => { if (typeof window !== 'undefined') { - // Ensure sync system is initialized before proceeding const initSync = async () => { // Initialize sync system if not already initialized await initializeSyncManagers() - setIsInitialized(true) + // Note: setIsWorkflowReady is handled in the workflow data tracking effect below } - // Check if already initialized - if (isSyncInitialized()) { - setIsInitialized(true) - } else { - initSync() - } + // Initialize sync system + initSync() } }, []) @@ -727,46 +728,85 @@ function WorkflowContent() { [project, isPointInLoopNodeWrapper, getNodes] ) - // Init workflow + // Track when workflow is fully ready for rendering useEffect(() => { - if (!isInitialized) return + const currentId = params.id as string + + // Reset workflow ready state when workflow changes + if (activeWorkflowId !== currentId) { + setIsWorkflowReady(false) + return + } + + // Check if we have the necessary data to render the workflow + const hasActiveWorkflow = activeWorkflowId === currentId + const hasWorkflowInRegistry = Boolean(workflows[currentId]) + const isNotLoading = !workflowsLoading + + // 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 + if (hasActiveWorkflow && hasWorkflowInRegistry && isNotLoading) { + // Add a small delay to ensure blocks state has settled + const timeoutId = setTimeout(() => { + setIsWorkflowReady(true) + }, 100) + + return () => clearTimeout(timeoutId) + } + setIsWorkflowReady(false) + }, [activeWorkflowId, params.id, workflows, workflowsLoading]) + // Init workflow + useEffect(() => { const validateAndNavigate = async () => { const workflowIds = Object.keys(workflows) const currentId = params.id as string + // Wait for both initialization and workflow loading to complete + if (workflowsLoading) { + logger.info('Workflows still loading, waiting...') + return + } + + // If no workflows exist after loading is complete, create initial workflow if (workflowIds.length === 0) { - // Create initial workflow using the centralized function - const newId = createWorkflow({ isInitial: true }) + logger.info('No workflows found after loading complete, creating initial workflow') + + // Generate numbered workflow name based on existing workflows + const existingWorkflowCount = Object.keys(workflows).length + const workflowNumber = existingWorkflowCount + 1 + const workflowName = `Workflow ${workflowNumber}` + + const newId = createWorkflow({ + name: workflowName, + description: 'Getting started with agents', + isInitial: true, + }) router.replace(`/w/${newId}`) return } + // Navigate to existing workflow or first available if (!workflows[currentId]) { router.replace(`/w/${workflowIds[0]}`) return } - // Import the isActivelyLoadingFromDB function to check sync status - const { isActivelyLoadingFromDB } = await import('@/stores/workflows/sync') - - // Wait for any active DB loading to complete before switching workflows - if (isActivelyLoadingFromDB()) { - const checkInterval = setInterval(() => { - if (!isActivelyLoadingFromDB()) { - clearInterval(checkInterval) - // Reset variables loaded state before setting active workflow - resetVariablesLoaded() - setActiveWorkflow(currentId) - markAllAsRead(currentId) - } - }, 100) - return - } - // Reset variables loaded state before setting active workflow resetVariablesLoaded() - setActiveWorkflow(currentId) + + // Always call setActiveWorkflow when workflow ID changes to ensure proper state + const { activeWorkflowId } = useWorkflowRegistry.getState() + + if (activeWorkflowId !== currentId) { + setActiveWorkflow(currentId) + } else { + // Even if the workflow is already active, call setActiveWorkflow to ensure state consistency + setActiveWorkflow(currentId) + } + markAllAsRead(currentId) } @@ -774,10 +814,10 @@ function WorkflowContent() { }, [ params.id, workflows, + workflowsLoading, setActiveWorkflow, createWorkflow, router, - isInitialized, markAllAsRead, resetVariablesLoaded, ]) @@ -1327,10 +1367,27 @@ function WorkflowContent() { } }, [setSubBlockValue]) - if (!isInitialized) { + // Show skeleton UI while loading, then smoothly transition to real content + const showSkeletonUI = !isWorkflowReady + + if (showSkeletonUI) { return ( -
- +
+ + + + +
+
+ + +
+
+ +
+
) } @@ -1371,7 +1428,6 @@ function WorkflowContent() { }} connectionLineType={ConnectionLineType.SmoothStep} onNodeClick={(e, node) => { - // Allow selecting nodes, but stop propagation to prevent triggering other events e.stopPropagation() }} onPaneClick={onPaneClick} diff --git a/apps/sim/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx b/apps/sim/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx index c1d5637c8a7..da4faef5dc4 100644 --- a/apps/sim/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx +++ b/apps/sim/app/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx @@ -11,7 +11,6 @@ import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console-logger' import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth' import { cn } from '@/lib/utils' -import { loadFromStorage, removeFromStorage, saveToStorage } from '@/stores/workflows/persistence' const logger = createLogger('Credentials') @@ -141,7 +140,30 @@ export function Credentials({ onOpenChange }: CredentialsProps) { // Handle OAuth callback if (code && state) { - // This is an OAuth callback - set success flag + // This is an OAuth callback - try to restore state from localStorage + try { + const stored = localStorage.getItem('pending_oauth_state') + if (stored) { + const oauthState = JSON.parse(stored) + logger.info('OAuth callback with restored state:', oauthState) + + // Mark as pending if we have context about what service was being connected + if (oauthState.serviceId) { + setPendingService(oauthState.serviceId) + setShowActionRequired(true) + } + + // Clean up the state (one-time use) + localStorage.removeItem('pending_oauth_state') + } else { + logger.warn('OAuth callback but no state found in localStorage') + } + } catch (error) { + logger.error('Error loading OAuth state from localStorage:', error) + localStorage.removeItem('pending_oauth_state') // Clean up corrupted state + } + + // Set success flag setAuthSuccess(true) // Refresh connections to show the new connection @@ -157,50 +179,6 @@ export function Credentials({ onOpenChange }: CredentialsProps) { } }, [searchParams, router, userId]) - // Check for pending OAuth connections and return URL - useEffect(() => { - if (typeof window === 'undefined') return - - // Check if there's a pending OAuth connection - const serviceId = loadFromStorage('pending_service_id') - const scopes = loadFromStorage('pending_oauth_scopes') || [] - const returnUrl = loadFromStorage('pending_oauth_return_url') - const fromOAuthModal = loadFromStorage('from_oauth_modal') - - if (serviceId) { - setPendingService(serviceId) - setPendingScopes(scopes) - - // Only show action required notification if navigated from the OAuth modal - setShowActionRequired(!!fromOAuthModal) - - // Clear the pending connection after a short delay - // This gives the user time to see the highlighted connection - setTimeout(() => { - removeFromStorage('pending_service_id') - removeFromStorage('pending_oauth_scopes') - removeFromStorage('from_oauth_modal') - }, 500) - } - - // Handle successful authentication return - if (authSuccess && returnUrl && onOpenChange) { - // Clear the success flag - setAuthSuccess(false) - removeFromStorage('pending_oauth_return_url') - - // Close the settings modal and return to workflow - setTimeout(() => { - onOpenChange(false) - - // Navigate back to the workflow if needed - if (returnUrl !== window.location.href) { - router.push(returnUrl) - } - }, 1500) // Slightly longer delay to show the connected state - } - }, [authSuccess, onOpenChange, router]) - // Fetch services on mount useEffect(() => { if (userId) { @@ -213,12 +191,6 @@ export function Credentials({ onOpenChange }: CredentialsProps) { try { setIsConnecting(service.id) - // Store information about the connection - saveToStorage('pending_service_id', service.id) - saveToStorage('pending_oauth_scopes', service.scopes) - saveToStorage('pending_oauth_return_url', window.location.href) - saveToStorage('pending_oauth_provider_id', service.providerId) - logger.info('Connecting service:', { serviceId: service.id, providerId: service.providerId, diff --git a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx index 56a7d059eac..473a3494c04 100644 --- a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -241,7 +241,7 @@ export function WorkspaceHeader({ const router = useRouter() // Get workflowRegistry state and actions - const { activeWorkspaceId, setActiveWorkspace: setActiveWorkspaceId } = useWorkflowRegistry() + const { activeWorkspaceId, switchToWorkspace, setActiveWorkspaceId } = useWorkflowRegistry() const userName = sessionData?.user?.name || sessionData?.user?.email || 'User' @@ -271,21 +271,23 @@ export function WorkspaceHeader({ const fetchedWorkspaces = data.workspaces as Workspace[] setWorkspaces(fetchedWorkspaces) - // Find workspace that matches the active ID from registry or use first workspace - const matchingWorkspace = fetchedWorkspaces.find( - (workspace) => workspace.id === activeWorkspaceId - ) - const workspaceToActivate = matchingWorkspace || fetchedWorkspaces[0] - - // If we found a workspace, set it as active and update registry if needed - if (workspaceToActivate) { - setActiveWorkspace(workspaceToActivate) - - // If active workspace in UI doesn't match registry, update registry - if (workspaceToActivate.id !== activeWorkspaceId) { - setActiveWorkspaceId(workspaceToActivate.id) + // Only update workspace if we have a valid activeWorkspaceId from registry + if (activeWorkspaceId) { + const matchingWorkspace = fetchedWorkspaces.find( + (workspace) => workspace.id === activeWorkspaceId + ) + if (matchingWorkspace) { + setActiveWorkspace(matchingWorkspace) + } else { + // Active workspace not found, fallback to first workspace + const fallbackWorkspace = fetchedWorkspaces[0] + if (fallbackWorkspace) { + setActiveWorkspace(fallbackWorkspace) + setActiveWorkspaceId(fallbackWorkspace.id) + } } } + // If no activeWorkspaceId, let loadWorkspaceFromWorkflowId handle workspace selection } setIsWorkspacesLoading(false) }) @@ -306,8 +308,8 @@ export function WorkspaceHeader({ setActiveWorkspace(workspace) setIsOpen(false) - // Update the workflow registry store with the new active workspace - setActiveWorkspaceId(workspace.id) + // Use full workspace switch which now handles localStorage automatically + switchToWorkspace(workspace.id) // Update URL to include workspace ID router.push(`/w/${workspace.id}`) @@ -330,8 +332,9 @@ export function WorkspaceHeader({ setWorkspaces((prev) => [...prev, newWorkspace]) setActiveWorkspace(newWorkspace) - // Update the workflow registry store with the new active workspace - setActiveWorkspaceId(newWorkspace.id) + // Use switchToWorkspace to properly load workflows for the new workspace + // This will clear existing workflows, set loading state, and fetch workflows from DB + switchToWorkspace(newWorkspace.id) // Update URL to include new workspace ID router.push(`/w/${newWorkspace.id}`) diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index 6d8ce037df8..7a72043e4ce 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -35,6 +35,7 @@ export function Sidebar() { const isLoading = workflowsLoading || sessionLoading const router = useRouter() const pathname = usePathname() + const [showSettings, setShowSettings] = useState(false) const [showHelp, setShowHelp] = useState(false) const [showInviteMembers, setShowInviteMembers] = useState(false) @@ -129,15 +130,6 @@ export function Sidebar() { // Create workflow const handleCreateWorkflow = async (folderId?: string) => { try { - // Import the isActivelyLoadingFromDB function to check sync status - const { isActivelyLoadingFromDB } = await import('@/stores/workflows/sync') - - // Prevent creating workflows during active DB operations - if (isActivelyLoadingFromDB()) { - console.log('Please wait, syncing in progress...') - return - } - // Create the workflow and ensure it's associated with the active workspace and folder const id = createWorkflow({ workspaceId: activeWorkspaceId || undefined, diff --git a/apps/sim/app/w/hooks/use-registry-loading.ts b/apps/sim/app/w/hooks/use-registry-loading.ts index 0ca7ba5134a..207514e18fd 100644 --- a/apps/sim/app/w/hooks/use-registry-loading.ts +++ b/apps/sim/app/w/hooks/use-registry-loading.ts @@ -1,27 +1,94 @@ 'use client' import { useEffect } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +const logger = createLogger('UseRegistryLoading') + /** - * Custom hook to manage workflow registry loading state + * Extract workflow ID from pathname + * @param pathname - Current pathname + * @returns workflow ID if found, null otherwise + */ +function extractWorkflowIdFromPathname(pathname: string): string | null { + try { + const pathSegments = pathname.split('/') + // Check if URL matches pattern /w/{workflowId} + if (pathSegments.length >= 3 && pathSegments[1] === 'w') { + const workflowId = pathSegments[2] + // Basic UUID validation (36 characters, contains hyphens) + if (workflowId && workflowId.length === 36 && workflowId.includes('-')) { + return workflowId + } + } + return null + } catch (error) { + logger.warn('Failed to extract workflow ID from pathname:', error) + return null + } +} + +/** + * Custom hook to manage workflow registry loading state and handle first-time navigation * * This hook initializes the loading state and automatically clears it - * when workflows are loaded or after a timeout + * when workflows are loaded. It also handles smart workspace selection + * and navigation for first-time users. */ export function useRegistryLoading() { - const { workflows, setLoading } = useWorkflowRegistry() + const { workflows, setLoading, isLoading, activeWorkspaceId, loadWorkspaceFromWorkflowId } = + useWorkflowRegistry() + const pathname = usePathname() + const router = useRouter() + + // Handle workspace selection from URL + useEffect(() => { + if (!activeWorkspaceId) { + const workflowIdFromUrl = extractWorkflowIdFromPathname(pathname) + if (workflowIdFromUrl) { + loadWorkspaceFromWorkflowId(workflowIdFromUrl).catch((error) => { + logger.warn('Failed to load workspace from workflow ID:', error) + }) + } + } + }, [activeWorkspaceId, pathname, loadWorkspaceFromWorkflowId]) + // Handle first-time navigation: if we're at /w and have workflows, navigate to first one useEffect(() => { - // Set loading state initially - setLoading(true) + if (!isLoading && activeWorkspaceId && Object.keys(workflows).length > 0) { + const workflowCount = Object.keys(workflows).length + const currentWorkflowId = extractWorkflowIdFromPathname(pathname) + + // If we're at a generic workspace URL (/w, /w/, or /w/workspaceId) without a specific workflow + if ( + !currentWorkflowId && + (pathname === '/w' || pathname === '/w/' || pathname === `/w/${activeWorkspaceId}`) + ) { + const firstWorkflowId = Object.keys(workflows)[0] + logger.info('First-time navigation: redirecting to first workflow:', firstWorkflowId) + router.replace(`/w/${firstWorkflowId}`) + } + } + }, [isLoading, activeWorkspaceId, workflows, pathname, router]) + + // Handle loading states + useEffect(() => { + // Only set loading if we don't have workflows and aren't already loading + if (Object.keys(workflows).length === 0 && !isLoading) { + setLoading(true) + } // If workflows are already loaded, clear loading state - if (Object.keys(workflows).length > 0) { - setTimeout(() => setLoading(false), 300) + if (Object.keys(workflows).length > 0 && isLoading) { + setTimeout(() => setLoading(false), 100) return } + // Only create timeout if we're actually loading + if (!isLoading) return + // Create a timeout to clear loading state after max time const timeout = setTimeout(() => { setLoading(false) @@ -40,5 +107,5 @@ export function useRegistryLoading() { clearTimeout(timeout) clearInterval(checkInterval) } - }, [setLoading, workflows]) + }, [setLoading, workflows, isLoading]) } diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts new file mode 100644 index 00000000000..3ca648a5bff --- /dev/null +++ b/apps/sim/lib/oauth/index.ts @@ -0,0 +1 @@ +export * from './oauth' diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index 42369f973fd..50f82abecfc 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -12,7 +12,7 @@ import { db } from '@/db' import { environment, userStats, webhook } from '@/db/schema' import { Executor } from '@/executor' import { Serializer } from '@/serializer' -import { mergeSubblockStateAsync } from '@/stores/workflows/utils' +import { mergeSubblockStateAsync } from '@/stores/workflows/server-utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WebhookUtils') diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index 372881e643b..42ccb70db49 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -1,8 +1,4 @@ -export const STORAGE_KEYS = { - REGISTRY: 'workflow-registry', - WORKFLOW: (id: string) => `workflow-${id}`, - SUBBLOCK: (id: string) => `subblock-values-${id}`, -} +// localStorage persistence removed - STORAGE_KEYS no longer needed export const API_ENDPOINTS = { SYNC: '/api/workflows/sync', diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index d9712570e87..59d687bc3e9 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -1,6 +1,7 @@ +'use client' + import { useEffect } from 'react' import { createLogger } from '@/lib/logs/console-logger' -import type { SubBlockType } from '@/blocks/types' import { useCopilotStore } from './copilot/store' import { useCustomToolsStore } from './custom-tools/store' import { useExecutionStore } from './execution/store' @@ -8,40 +9,23 @@ import { useNotificationStore } from './notifications/store' import { useConsoleStore } from './panel/console/store' import { useVariablesStore } from './panel/variables/store' import { useEnvironmentStore } from './settings/environment/store' -import { - getSyncManagers, - initializeSyncManagers, - isSyncInitialized, - resetSyncManagers, -} from './sync-registry' +import { disposeSyncManagers, initializeSyncManagers } from './sync-registry' // Import the syncWorkflows function directly import { syncWorkflows } from './workflows' -import { - loadRegistry, - loadSubblockValues, - loadWorkflowState, - saveSubblockValues, - saveWorkflowState, -} from './workflows/persistence' import { useWorkflowRegistry } from './workflows/registry/store' import { useSubBlockStore } from './workflows/subblock/store' -import { isRegistryInitialized } from './workflows/sync' import { useWorkflowStore } from './workflows/workflow/store' -import type { BlockState } from './workflows/workflow/types' const logger = createLogger('Stores') // Track initialization state let isInitializing = false let appFullyInitialized = false +let dataInitialized = false // Flag for actual data loading completion /** * Initialize the application state and sync system - * - * Note: Workflow scheduling is handled automatically by the workflowSync manager - * when workflows are synced to the database. The scheduling logic checks if a - * workflow has scheduling enabled in its starter block and updates the schedule - * accordingly. + * localStorage persistence has been removed - relies on DB and Zustand stores only */ async function initializeApplication(): Promise { if (typeof window === 'undefined' || isInitializing) return @@ -59,46 +43,24 @@ async function initializeApplication(): Promise { // Load custom tools from server await useCustomToolsStore.getState().loadCustomTools() - // Set a flag in sessionStorage to detect new login sessions - // This helps identify fresh logins in private browsers - const isNewLoginSession = !sessionStorage.getItem('app_initialized') - sessionStorage.setItem('app_initialized', 'true') - - // Initialize sync system for other stores - await initializeSyncManagers() - - // After DB sync, check if we need to load from localStorage - // This is a fallback in case DB sync failed or there's no data in DB - const registryState = useWorkflowRegistry.getState() - const hasDbWorkflows = Object.keys(registryState.workflows).length > 0 - - if (!hasDbWorkflows) { - // No workflows loaded from DB, try localStorage as fallback - const workflows = loadRegistry() - if (workflows && Object.keys(workflows).length > 0) { - logger.info('Loading workflows from localStorage as fallback') - useWorkflowRegistry.setState({ workflows }) - - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (activeWorkflowId) { - initializeWorkflowState(activeWorkflowId) - } - } else if (isNewLoginSession) { - // Critical safeguard: For new login sessions with no DB workflows - // and no localStorage, we disable sync temporarily to prevent data loss - logger.info('New login session with no workflows - preventing initial sync') - const syncManagers = getSyncManagers() - syncManagers.forEach((manager) => manager.stopIntervalSync()) - - // Create the first starter workflow with an agent block for new users - logger.info('Creating first workflow with agent block for new user') - createFirstWorkflowWithAgentBlock() - } - } else { - logger.info('Using workflows loaded from DB, ignoring localStorage') + // Extract workflow ID from URL for smart workspace selection + const workflowIdFromUrl = extractWorkflowIdFromUrl() + + // Load workspace based on workflow ID in URL, with fallback to last active workspace + await useWorkflowRegistry.getState().loadWorkspaceFromWorkflowId(workflowIdFromUrl) + + // Initialize sync system and wait for data to load completely + const syncInitialized = await initializeSyncManagers() + + if (!syncInitialized) { + logger.error('Failed to initialize sync managers') + return } - // 2. Register cleanup + // Mark data as initialized only after sync managers have loaded data from DB + dataInitialized = true + + // Register cleanup window.addEventListener('beforeunload', handleBeforeUnload) // Log initialization timing information @@ -111,45 +73,51 @@ async function initializeApplication(): Promise { logger.error('Error during application initialization:', { error }) // Still mark as initialized to prevent being stuck in initializing state appFullyInitialized = true + // But don't mark data as initialized on error + dataInitialized = false } finally { isInitializing = false } } /** - * Checks if application is fully initialized + * Extract workflow ID from current URL + * @returns workflow ID if found in URL, null otherwise */ -export function isAppInitialized(): boolean { - return appFullyInitialized && isRegistryInitialized() && isSyncInitialized() -} +function extractWorkflowIdFromUrl(): string | null { + if (typeof window === 'undefined') return null -function initializeWorkflowState(workflowId: string): void { - // Load the specific workflow state from localStorage - const workflowState = loadWorkflowState(workflowId) - if (!workflowState) { - logger.warn(`No saved state found for workflow ${workflowId}`) - return + try { + const pathSegments = window.location.pathname.split('/') + // Check if URL matches pattern /w/{workflowId} + if (pathSegments.length >= 3 && pathSegments[1] === 'w') { + const workflowId = pathSegments[2] + // Basic UUID validation (36 characters, contains hyphens) + if (workflowId && workflowId.length === 36 && workflowId.includes('-')) { + logger.info(`Extracted workflow ID from URL: ${workflowId}`) + return workflowId + } + } + return null + } catch (error) { + logger.warn('Failed to extract workflow ID from URL:', error) + return null } +} - // Set the workflow store state with the loaded state - useWorkflowStore.setState(workflowState) - - // Initialize subblock values for this workflow - const subblockValues = loadSubblockValues(workflowId) - if (subblockValues) { - // Update the subblock store with the loaded values - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [workflowId]: subblockValues, - }, - })) - } else if (workflowState.blocks) { - // If no saved subblock values, initialize from blocks - useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks) - } +/** + * Checks if application is fully initialized + */ +export function isAppInitialized(): boolean { + return appFullyInitialized +} - logger.info(`Initialized workflow state for ${workflowId}`) +/** + * Checks if data has been loaded from the database + * This should be checked before any sync operations + */ +export function isDataInitialized(): boolean { + return dataInitialized } /** @@ -170,44 +138,9 @@ function handleBeforeUnload(event: BeforeUnloadEvent): void { } } - // 1. Persist current state - const currentId = useWorkflowRegistry.getState().activeWorkflowId - if (currentId) { - const currentState = useWorkflowStore.getState() - - // Save the current workflow state with its ID - saveWorkflowState(currentId, { - blocks: currentState.blocks, - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, - isDeployed: currentState.isDeployed, - deployedAt: currentState.deployedAt, - lastSaved: Date.now(), - // Include history for undo/redo functionality - history: currentState.history, - }) - - // Save subblock values for the current workflow - const subblockValues = useSubBlockStore.getState().workflowValues[currentId] - if (subblockValues) { - saveSubblockValues(currentId, subblockValues) - } - } - // Mark workflows as dirty to ensure sync on exit syncWorkflows() - // 2. Final sync for managers that need it - getSyncManagers() - .filter((manager) => manager.config.syncOnExit) - .forEach((manager) => { - manager.sync() - }) - - // 3. Cleanup managers - getSyncManagers().forEach((manager) => manager.dispose()) - // Standard beforeunload pattern event.preventDefault() event.returnValue = '' @@ -218,30 +151,31 @@ function handleBeforeUnload(event: BeforeUnloadEvent): void { */ function cleanupApplication(): void { window.removeEventListener('beforeunload', handleBeforeUnload) - getSyncManagers().forEach((manager) => manager.dispose()) + disposeSyncManagers() } /** * Clear all user data when signing out - * This ensures data from one account doesn't persist to another + * localStorage persistence has been removed */ export async function clearUserData(): Promise { if (typeof window === 'undefined') return try { - // 1. Reset all sync managers to prevent any pending syncs - resetSyncManagers() + // Reset all sync managers to prevent any pending syncs + disposeSyncManagers() - // 2. Reset all stores to their initial state + // Reset all stores to their initial state resetAllStores() - // 3. Clear localStorage except for essential app settings + // Clear localStorage except for essential app settings (minimal usage) const keysToKeep = ['next-favicon', 'theme'] const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) keysToRemove.forEach((key) => localStorage.removeItem(key)) // Reset application initialization state appFullyInitialized = false + dataInitialized = false logger.info('User data cleared successfully') } catch (error) { @@ -273,6 +207,36 @@ export function useLoginInitialization() { }, []) } +/** + * Reinitialize the application after login + * This ensures we load fresh data from the database for the new user + */ +export async function reinitializeAfterLogin(): Promise { + if (typeof window === 'undefined') return + + try { + // Reset application initialization state + appFullyInitialized = false + dataInitialized = false + + // Reset sync managers to prevent any active syncs during reinitialization + disposeSyncManagers() + + // Clean existing state to avoid stale data + resetAllStores() + + // Reset initialization flags to force a fresh load + isInitializing = false + + // Reinitialize the application + await initializeApplication() + + logger.info('Application reinitialized after login') + } catch (error) { + logger.error('Error reinitializing application:', { error }) + } +} + // Initialize immediately when imported on client if (typeof window !== 'undefined') { initializeApplication() @@ -289,6 +253,7 @@ export { useCopilotStore, useCustomToolsStore, useVariablesStore, + useSubBlockStore, } // Helper function to reset all stores @@ -334,170 +299,5 @@ export const logAllStores = () => { return state } -/** - * Reinitialize the application after login - * This ensures we load fresh data from the database for the new user - */ -export async function reinitializeAfterLogin(): Promise { - if (typeof window === 'undefined') return - - try { - // Reset application initialization state - appFullyInitialized = false - - // Reset sync managers to prevent any active syncs during reinitialization - resetSyncManagers() - - // Clean existing state to avoid stale data - resetAllStores() - - // Mark as a new login session - sessionStorage.removeItem('app_initialized') - - // Reset initialization flags to force a fresh load - isInitializing = false - - // Reinitialize the application - await initializeApplication() - - logger.info('Application reinitialized after login') - } catch (error) { - logger.error('Error reinitializing application:', { error }) - } -} - -/** - * Creates the first workflow with a starter and agent block for new users - */ -function createFirstWorkflowWithAgentBlock(): void { - // Create a workflow with default settings - const workflowId = useWorkflowRegistry.getState().createWorkflow({ - name: 'My First Workflow', - description: 'Getting started with agents', - isInitial: true, - }) - - // Get the current workflow state - const workflowState = useWorkflowStore.getState() - const starterBlockId = Object.keys(workflowState.blocks)[0] - - if (!starterBlockId) { - logger.error('Failed to find starter block in new workflow') - return - } - - // Create an agent block - const agentBlockId = crypto.randomUUID() - const agentBlock: BlockState = { - id: agentBlockId, - type: 'agent', - name: 'Agent', - position: { x: 577.2367674819552, y: -173.0961530669049 }, - subBlocks: { - systemPrompt: { - id: 'systemPrompt', - type: 'long-input' as SubBlockType, - value: 'You are a helpful assistant.', - }, - context: { - id: 'context', - type: 'short-input' as SubBlockType, - value: '', - }, - model: { - id: 'model', - type: 'dropdown' as SubBlockType, - value: 'gpt-4o', - }, - temperature: { - id: 'temperature', - type: 'slider' as SubBlockType, - value: 0.7, - }, - apiKey: { - id: 'apiKey', - type: 'short-input' as SubBlockType, - value: '', - }, - tools: { - id: 'tools', - type: 'tool-input' as SubBlockType, - value: '[]', - }, - responseFormat: { - id: 'responseFormat', - type: 'code' as SubBlockType, - value: null, - }, - }, - outputs: { - response: { - content: 'string', - model: 'string', - tokens: 'any', - toolCalls: 'any', - }, - }, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 642, - } - - // Create an edge connecting starter to agent - const edgeId = crypto.randomUUID() - const edge = { - id: edgeId, - source: starterBlockId, - target: agentBlockId, - } - - // Update the workflow state with the new block and edge - const updatedState = { - ...workflowState, - blocks: { - ...workflowState.blocks, - [agentBlockId]: agentBlock, - }, - edges: [...workflowState.edges, edge], - history: { - ...workflowState.history, - present: { - ...workflowState.history.present, - state: { - ...workflowState.history.present.state, - blocks: { - ...workflowState.history.present.state.blocks, - [agentBlockId]: agentBlock, - }, - edges: [...(workflowState.history.present.state.edges || []), edge], - }, - }, - }, - lastSaved: Date.now(), - } - - // Set the updated state - useWorkflowStore.setState(updatedState) - - // Initialize subblock values for agent block - useSubBlockStore.getState().initializeFromWorkflow(workflowId, updatedState.blocks) - - // Save the updated workflow state - saveWorkflowState(workflowId, updatedState) - - // Mark as dirty to ensure sync - syncWorkflows() - - // Resume sync managers after initialization - setTimeout(() => { - const syncManagers = getSyncManagers() - syncManagers.forEach((manager) => manager.startIntervalSync()) - syncWorkflows() - }, 1000) - - logger.info('First workflow with agent block created successfully') -} - // Re-export sync managers export { workflowSync } from './workflows/sync' diff --git a/apps/sim/stores/sync-registry.ts b/apps/sim/stores/sync-registry.ts index 56536ce34a6..9cd6bf37be4 100644 --- a/apps/sim/stores/sync-registry.ts +++ b/apps/sim/stores/sync-registry.ts @@ -2,12 +2,7 @@ import { createLogger } from '@/lib/logs/console-logger' import type { SyncManager } from './sync' -import { - fetchWorkflowsFromDB, - isRegistryInitialized, - resetRegistryInitialization, - workflowSync, -} from './workflows/sync' +import { fetchWorkflowsFromDB, workflowSync } from './workflows/sync' const logger = createLogger('SyncRegistry') @@ -17,13 +12,7 @@ let initializing = false let managers: SyncManager[] = [] /** - * Initialize sync managers and fetch data from DB - * Returns a promise that resolves when initialization is complete - * - * Note: Workflow scheduling is handled automatically by the workflowSync manager - * when workflows are synced to the database. The scheduling logic checks if a - * workflow has scheduling enabled in its starter block and updates the schedule - * accordingly. + * Simplified sync managers initialization */ export async function initializeSyncManagers(): Promise { // Skip if already initialized or initializing @@ -37,63 +26,53 @@ export async function initializeSyncManagers(): Promise { // Initialize sync managers managers = [workflowSync] - // Reset registry initialization state before fetching - resetRegistryInitialization() - - // Fetch data from DB - try { - // Remove environment variables fetch - await fetchWorkflowsFromDB() - - // Wait for a short period to ensure registry is properly initialized - if (!isRegistryInitialized()) { - logger.info('Waiting for registry initialization to complete...') - await new Promise((resolve) => setTimeout(resolve, 500)) - } - - // Verify initialization complete - if (!isRegistryInitialized()) { - logger.warn('Registry initialization may not have completed properly') - } else { - logger.info('Registry initialization verified') - } - } catch (error) { - logger.error('Error fetching data from DB:', { error }) - } + // Fetch data from DB and properly await completion + await fetchWorkflowsFromDB() + logger.info('Workflows loaded from database successfully') initialized = true return true } catch (error) { - logger.error('Error initializing sync managers:', { error }) - return false + logger.error('Error initializing sync managers:', error) + // Still mark as initialized to allow the app to function with empty state + initialized = true + return true } finally { initializing = false } } /** - * Check if sync managers are initialized + * Force resync all managers */ -export function isSyncInitialized(): boolean { - return initialized && isRegistryInitialized() -} +export function forceSyncAll(): void { + if (!initialized) { + logger.warn('Sync managers not initialized, cannot force sync') + return + } -/** - * Get all sync managers - */ -export function getSyncManagers(): SyncManager[] { - return managers + managers.forEach((manager) => { + try { + manager.sync() + } catch (error) { + logger.error('Error forcing sync for manager:', error) + } + }) } /** - * Reset all sync managers + * Dispose all sync managers */ -export function resetSyncManagers(): void { +export function disposeSyncManagers(): void { + managers.forEach((manager) => { + try { + manager.dispose() + } catch (error) { + logger.error('Error disposing sync manager:', error) + } + }) + + managers = [] initialized = false initializing = false - managers = [] - resetRegistryInitialization() } - -// Export individual sync managers for direct use -export { workflowSync } diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index f00b26f4499..ea9bdd8e141 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -1,7 +1,6 @@ import { createLogger } from '@/lib/logs/console-logger' -import { loadWorkflowState } from './persistence' import { useWorkflowRegistry } from './registry/store' -import { useSubBlockStore } from './subblock/store' +import { workflowSync } from './sync' import { mergeSubblockState } from './utils' import { useWorkflowStore } from './workflow/store' import type { BlockState, WorkflowState } from './workflow/types' @@ -10,8 +9,9 @@ const logger = createLogger('Workflows') /** * Get a workflow with its state merged in by ID + * Note: Since localStorage has been removed, this only works for the active workflow * @param workflowId ID of the workflow to retrieve - * @returns The workflow with merged state values or null if not found + * @returns The workflow with merged state values or null if not found/not active */ export function getWorkflowWithValues(workflowId: string) { const { workflows } = useWorkflowRegistry.getState() @@ -23,39 +23,26 @@ export function getWorkflowWithValues(workflowId: string) { return null } + // Since localStorage persistence has been removed, only return data for active workflow + if (workflowId !== activeWorkflowId) { + logger.warn(`Cannot get state for non-active workflow ${workflowId} - localStorage removed`) + return null + } + const metadata = workflows[workflowId] // Get deployment status from registry const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId) - // Load the specific state for this workflow - let workflowState: WorkflowState - - if (workflowId === activeWorkflowId) { - // For the active workflow, use the current state from the store - workflowState = { - blocks: currentState.blocks, - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, - isDeployed: deploymentStatus?.isDeployed || false, - deployedAt: deploymentStatus?.deployedAt, - lastSaved: currentState.lastSaved, - } - } else { - // For other workflows, load their state from localStorage - const savedState = loadWorkflowState(workflowId) - if (!savedState) { - logger.warn(`No saved state found for workflow ${workflowId}`) - return null - } - - // Use registry deployment status instead of relying on saved state - workflowState = { - ...savedState, - isDeployed: deploymentStatus?.isDeployed || savedState.isDeployed || false, - deployedAt: deploymentStatus?.deployedAt || savedState.deployedAt, - } + // Use the current state from the store (only available for active workflow) + const workflowState: WorkflowState = { + blocks: currentState.blocks, + edges: currentState.edges, + loops: currentState.loops, + parallels: currentState.parallels, + isDeployed: deploymentStatus?.isDeployed || false, + deployedAt: deploymentStatus?.deployedAt, + lastSaved: currentState.lastSaved, } // Merge the subblock values for this specific workflow @@ -98,8 +85,8 @@ export function getBlockWithValues(blockId: string): BlockState | null { /** * Get all workflows with their values merged - * Used for sync operations to prepare the payload - * @returns An object containing all workflows with their merged state values + * Note: Since localStorage has been removed, this only includes the active workflow state + * @returns An object containing workflows, with state only for the active workflow */ export function getAllWorkflowsWithValues() { const { workflows, activeWorkspaceId } = useWorkflowRegistry.getState() @@ -107,63 +94,47 @@ export function getAllWorkflowsWithValues() { const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const currentState = useWorkflowStore.getState() - for (const [id, metadata] of Object.entries(workflows)) { - // Skip workflows that don't belong to the active workspace + // Only sync the active workflow to ensure we always send valid state data + if (activeWorkflowId && workflows[activeWorkflowId]) { + const metadata = workflows[activeWorkflowId] + + // Skip if workflow doesn't belong to the active workspace if (activeWorkspaceId && metadata.workspaceId !== activeWorkspaceId) { logger.debug( - `Skipping workflow ${id} - belongs to workspace ${metadata.workspaceId}, not active workspace ${activeWorkspaceId}` + `Skipping active workflow ${activeWorkflowId} - belongs to workspace ${metadata.workspaceId}, not active workspace ${activeWorkspaceId}` ) - continue + return result } // Get deployment status from registry - const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(id) - - // Load the specific state for this workflow - let workflowState: WorkflowState - - if (id === activeWorkflowId) { - // For the active workflow, use the current state from the store - workflowState = { - blocks: currentState.blocks, - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, - isDeployed: deploymentStatus?.isDeployed || false, - deployedAt: deploymentStatus?.deployedAt, - lastSaved: currentState.lastSaved, - } - } else { - // For other workflows, load their state from localStorage - const savedState = loadWorkflowState(id) - if (!savedState) { - // Skip workflows with no saved state - logger.warn(`No saved state found for workflow ${id}`) - continue - } - - // Use registry deployment status instead of relying on saved state - workflowState = { - ...savedState, - isDeployed: deploymentStatus?.isDeployed || savedState.isDeployed || false, - deployedAt: deploymentStatus?.deployedAt || savedState.deployedAt, - } + const deploymentStatus = useWorkflowRegistry + .getState() + .getWorkflowDeploymentStatus(activeWorkflowId) + + // Ensure state has all required fields for Zod validation + const workflowState: WorkflowState = { + blocks: currentState.blocks || {}, + edges: currentState.edges || [], + loops: currentState.loops || {}, + parallels: currentState.parallels || {}, + isDeployed: deploymentStatus?.isDeployed || false, + deployedAt: deploymentStatus?.deployedAt, + lastSaved: currentState.lastSaved || Date.now(), } // Merge the subblock values for this specific workflow - const mergedBlocks = mergeSubblockState(workflowState.blocks, id) + const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId) // Include the API key in the state if it exists in the deployment status const apiKey = deploymentStatus?.apiKey - result[id] = { - id, + result[activeWorkflowId] = { + id: activeWorkflowId, name: metadata.name, description: metadata.description, color: metadata.color || '#3972F6', marketplaceData: metadata.marketplaceData || null, - workspaceId: metadata.workspaceId, // Include workspaceId in the result - folderId: metadata.folderId, // Include folderId in the result + folderId: metadata.folderId, state: { blocks: mergedBlocks, edges: workflowState.edges, @@ -172,10 +143,16 @@ export function getAllWorkflowsWithValues() { lastSaved: workflowState.lastSaved, isDeployed: workflowState.isDeployed, deployedAt: workflowState.deployedAt, + marketplaceData: metadata.marketplaceData || null, }, // Include API key if available apiKey, } + + // Only include workspaceId if it's not null/undefined + if (metadata.workspaceId) { + result[activeWorkflowId].workspaceId = metadata.workspaceId + } } return result @@ -186,9 +163,19 @@ export function getAllWorkflowsWithValues() { * This is a shortcut for other files to trigger sync operations */ export function syncWorkflows() { - const workflowStore = useWorkflowStore.getState() - workflowStore.sync.markDirty() - workflowStore.sync.forceSync() + workflowSync.sync() } -export { useWorkflowRegistry, useWorkflowStore, useSubBlockStore } +// Workflows store exports - localStorage persistence removed + +export { useWorkflowRegistry } from './registry/store' +export type { WorkflowMetadata } from './registry/types' +export { useSubBlockStore } from './subblock/store' +export type { SubBlockStore } from './subblock/types' +// Re-export utilities +export { workflowSync } from './sync' +export { mergeSubblockState } from './utils' +// Re-export store hooks +export { useWorkflowStore } from './workflow/store' +// Re-export types +export type { WorkflowState } from './workflow/types' diff --git a/apps/sim/stores/workflows/middleware.ts b/apps/sim/stores/workflows/middleware.ts index 9fb98e881c5..9ea95e07916 100644 --- a/apps/sim/stores/workflows/middleware.ts +++ b/apps/sim/stores/workflows/middleware.ts @@ -1,5 +1,4 @@ import type { StateCreator } from 'zustand' -import { saveSubblockValues, saveWorkflowState } from './persistence' import { useWorkflowRegistry } from './registry/store' import { useSubBlockStore } from './subblock/store' import type { WorkflowState, WorkflowStore } from './workflow/types' @@ -108,23 +107,7 @@ export const withHistory = ( [activeWorkflowId]: previous.subblockValues, }, }) - - // Save to localStorage - saveSubblockValues(activeWorkflowId, previous.subblockValues) } - - // Save workflow state after undo - const currentState = get() - saveWorkflowState(activeWorkflowId, { - blocks: currentState.blocks, - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, - history: currentState.history, - isDeployed: currentState.isDeployed, - deployedAt: currentState.deployedAt, - lastSaved: Date.now(), - }) }, // Restore next state from history @@ -160,23 +143,7 @@ export const withHistory = ( [activeWorkflowId]: next.subblockValues, }, }) - - // Save to localStorage - saveSubblockValues(activeWorkflowId, next.subblockValues) } - - // Save workflow state after redo - const currentState = get() - saveWorkflowState(activeWorkflowId, { - blocks: currentState.blocks, - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, - history: currentState.history, - isDeployed: currentState.isDeployed, - deployedAt: currentState.deployedAt, - lastSaved: Date.now(), - }) }, // Reset workflow to empty state @@ -234,23 +201,7 @@ export const withHistory = ( [activeWorkflowId]: targetState.subblockValues, }, }) - - // Save to localStorage - saveSubblockValues(activeWorkflowId, targetState.subblockValues) } - - // Save workflow state after revert - const currentState = get() - saveWorkflowState(activeWorkflowId, { - blocks: currentState.blocks, - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, - history: currentState.history, - isDeployed: currentState.isDeployed, - deployedAt: currentState.deployedAt, - lastSaved: Date.now(), - }) }, } } diff --git a/apps/sim/stores/workflows/persistence.ts b/apps/sim/stores/workflows/persistence.ts index 1429da02f47..cc7a57118b7 100644 --- a/apps/sim/stores/workflows/persistence.ts +++ b/apps/sim/stores/workflows/persistence.ts @@ -1,17 +1,26 @@ /** - * Centralized persistence layer for workflow stores - * Handles localStorage interactions and synchronization + * OAuth state persistence for secure OAuth redirects + * This is the ONLY localStorage usage in the app - for temporary OAuth state during redirects */ import { createLogger } from '@/lib/logs/console-logger' -import { STORAGE_KEYS } from '../constants' -import { useWorkflowRegistry } from './registry/store' -import { useSubBlockStore } from './subblock/store' -import { useWorkflowStore } from './workflow/store' -const logger = createLogger('WorkflowsPersistence') +const logger = createLogger('OAuthPersistence') + +interface OAuthState { + providerId: string + serviceId: string + requiredScopes: string[] + returnUrl: string + context: string + timestamp: number + data?: Record +} + +const OAUTH_STATE_KEY = 'pending_oauth_state' +const OAUTH_STATE_EXPIRY = 10 * 60 * 1000 // 10 minutes /** - * Save data to localStorage with error handling + * Generic function to save data to localStorage (used by main branch OAuth flow) */ export function saveToStorage(key: string, data: T): boolean { try { @@ -24,12 +33,13 @@ export function saveToStorage(key: string, data: T): boolean { } /** - * Load data from localStorage with error handling + * Generic function to load data from localStorage */ export function loadFromStorage(key: string): T | null { try { - const data = localStorage.getItem(key) - return data ? JSON.parse(data) : null + const stored = localStorage.getItem(key) + if (!stored) return null + return JSON.parse(stored) as T } catch (error) { logger.error(`Failed to load data from ${key}:`, { error }) return null @@ -37,161 +47,82 @@ export function loadFromStorage(key: string): T | null { } /** - * Remove data from localStorage with error handling + * Save OAuth state to localStorage before redirect */ -export function removeFromStorage(key: string): boolean { +export function saveOAuthState(state: OAuthState): boolean { try { - localStorage.removeItem(key) + const stateWithTimestamp = { + ...state, + timestamp: Date.now(), + } + localStorage.setItem(OAUTH_STATE_KEY, JSON.stringify(stateWithTimestamp)) return true } catch (error) { - logger.error(`Failed to remove data from ${key}:`, { error }) + logger.error('Failed to save OAuth state to localStorage:', error) return false } } /** - * Save workflow state to localStorage + * Load and remove OAuth state from localStorage after redirect */ -export function saveWorkflowState(workflowId: string, state: any): boolean { - // We need to handle history separately since it's not part of the base WorkflowState - return saveToStorage(STORAGE_KEYS.WORKFLOW(workflowId), state) -} +export function loadOAuthState(): OAuthState | null { + try { + const stored = localStorage.getItem(OAUTH_STATE_KEY) + if (!stored) return null -/** - * Load workflow state from localStorage - */ -export function loadWorkflowState(workflowId: string): any { - return loadFromStorage(STORAGE_KEYS.WORKFLOW(workflowId)) -} + const state = JSON.parse(stored) as OAuthState -/** - * Save subblock values to localStorage - */ -export function saveSubblockValues(workflowId: string, values: any): boolean { - return saveToStorage(STORAGE_KEYS.SUBBLOCK(workflowId), values) -} + // Check if state has expired + if (Date.now() - state.timestamp > OAUTH_STATE_EXPIRY) { + localStorage.removeItem(OAUTH_STATE_KEY) + logger.warn('OAuth state expired, removing from localStorage') + return null + } -/** - * Load subblock values from localStorage - */ -export function loadSubblockValues(workflowId: string): any { - return loadFromStorage(STORAGE_KEYS.SUBBLOCK(workflowId)) -} + // Remove state after loading (one-time use) + localStorage.removeItem(OAUTH_STATE_KEY) -/** - * Save registry to localStorage - */ -export function saveRegistry(registry: any): boolean { - return saveToStorage(STORAGE_KEYS.REGISTRY, registry) -} - -/** - * Load registry from localStorage - */ -export function loadRegistry(): any { - return loadFromStorage(STORAGE_KEYS.REGISTRY) + return state + } catch (error) { + logger.error('Failed to load OAuth state from localStorage:', error) + // Clean up corrupted state + localStorage.removeItem(OAUTH_STATE_KEY) + return null + } } /** - * Initialize all stores from localStorage - * This is the main initialization function that should be called once at app startup + * Remove OAuth state from localStorage (cleanup) */ -export function initializeStores(): void { - if (typeof window === 'undefined') return - - // Initialize registry first - const workflows = loadRegistry() - if (workflows) { - useWorkflowRegistry.setState({ workflows }) - - // If there's an active workflow ID in the registry, load it - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (activeWorkflowId) { - // Load workflow state - const workflowState = loadWorkflowState(activeWorkflowId) - if (workflowState) { - // Initialize workflow store with saved state - useWorkflowStore.setState(workflowState) - - // Initialize subblock store with workflow values - const subblockValues = loadSubblockValues(activeWorkflowId) - if (subblockValues) { - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: subblockValues, - }, - })) - } else if (workflowState.blocks) { - // If no saved subblock values, initialize from blocks - useSubBlockStore.getState().initializeFromWorkflow(activeWorkflowId, workflowState.blocks) - } - } - } +export function clearOAuthState(): void { + try { + localStorage.removeItem(OAUTH_STATE_KEY) + } catch (error) { + logger.error('Failed to clear OAuth state from localStorage:', error) } - - // Setup unload persistence - setupUnloadPersistence() } /** - * Setup persistence for page unload events + * Check if there's pending OAuth state */ -export function setupUnloadPersistence(): void { - if (typeof window === 'undefined') return - - window.addEventListener('beforeunload', (event) => { - // Check if we're on an authentication page and skip confirmation if we are - const path = window.location.pathname - // Skip confirmation for auth-related pages - if ( - path === '/login' || - path === '/signup' || - path === '/reset-password' || - path === '/verify' - ) { - return - } +export function hasPendingOAuthState(): boolean { + try { + const stored = localStorage.getItem(OAUTH_STATE_KEY) + if (!stored) return false - const currentId = useWorkflowRegistry.getState().activeWorkflowId - if (currentId) { - // Save workflow state - const currentState = useWorkflowStore.getState() - - // Generate loops from the current blocks for consistency - const generatedLoops = currentState.generateLoopBlocks - ? currentState.generateLoopBlocks() - : {} - - // Generate parallels from the current blocks for consistency - const generatedParallels = currentState.generateParallelBlocks - ? currentState.generateParallelBlocks() - : {} - - // Save the complete state including history which is added by middleware - saveWorkflowState(currentId, { - blocks: currentState.blocks, - edges: currentState.edges, - loops: generatedLoops, - parallels: generatedParallels, - isDeployed: currentState.isDeployed, - deployedAt: currentState.deployedAt, - lastSaved: Date.now(), - history: currentState.history, - }) - - // Save subblock values - const subblockValues = useSubBlockStore.getState().workflowValues[currentId] - if (subblockValues) { - saveSubblockValues(currentId, subblockValues) - } - } + const state = JSON.parse(stored) as OAuthState - // Save registry - saveRegistry(useWorkflowRegistry.getState().workflows) + // Check if expired + if (Date.now() - state.timestamp > OAUTH_STATE_EXPIRY) { + localStorage.removeItem(OAUTH_STATE_KEY) + return false + } - // Only prevent navigation on non-auth pages - event.preventDefault() - event.returnValue = '' - }) + return true + } catch (error) { + logger.error('Failed to check pending OAuth state:', error) + localStorage.removeItem(OAUTH_STATE_KEY) + return false + } } diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 4cd1ae74b68..42a401e7be5 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -2,111 +2,20 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console-logger' import { clearWorkflowVariablesTracking } from '@/stores/panel/variables/store' -import { STORAGE_KEYS } from '../../constants' -import { - loadWorkflowState, - removeFromStorage, - saveRegistry, - saveSubblockValues, - saveWorkflowState, -} from '../persistence' +import { API_ENDPOINTS } from '../../constants' import { useSubBlockStore } from '../subblock/store' -import { fetchWorkflowsFromDB, resetRegistryInitialization, workflowSync } from '../sync' +import { fetchWorkflowsFromDB, workflowSync } from '../sync' import { useWorkflowStore } from '../workflow/store' +import type { BlockState } from '../workflow/types' import type { DeploymentStatus, WorkflowMetadata, WorkflowRegistry } from './types' import { generateUniqueName, getNextWorkflowColor } from './utils' const logger = createLogger('WorkflowRegistry') -// Storage key for active workspace -const ACTIVE_WORKSPACE_KEY = 'active-workspace-id' - // Track workspace transitions to prevent race conditions let isWorkspaceTransitioning = false const TRANSITION_TIMEOUT = 5000 // 5 seconds maximum for workspace transitions -// Helps clean up any localStorage data that isn't needed for the current workspace -function cleanupLocalStorageForWorkspace(workspaceId: string): void { - if (typeof window === 'undefined') return - - try { - const { workflows } = useWorkflowRegistry.getState() - const workflowIds = Object.keys(workflows) - - // Find all localStorage keys that start with workflow- or subblock-values- - const localStorageKeys = Object.keys(localStorage) - const workflowKeys = localStorageKeys.filter( - (key) => key.startsWith('workflow-') || key.startsWith('subblock-values-') - ) - - // Extract the workflow ID from each key (remove the prefix) - for (const key of workflowKeys) { - let workflowId: string | null = null - - if (key.startsWith('workflow-')) { - workflowId = key.replace('workflow-', '') - } else if (key.startsWith('subblock-values-')) { - workflowId = key.replace('subblock-values-', '') - } - - if (workflowId) { - // Case 1: Clean up workflows not in the registry - if (!workflowIds.includes(workflowId)) { - // Check if this workflow exists in a different workspace - // We don't want to remove data for workflows in other workspaces - const exists = localStorage.getItem(`workflow-${workflowId}`) - if (exists) { - try { - const parsed = JSON.parse(exists) - // If we can't determine the workspace, leave it alone for safety - if (!parsed || !parsed.workspaceId) continue - - // Only remove if it belongs to the current workspace - if (parsed.workspaceId === workspaceId) { - localStorage.removeItem(key) - } - } catch (_e) {} - } else { - // If we can't determine the workspace, remove it to be safe - localStorage.removeItem(key) - } - } - // Case 2: Clean up workflows that reference deleted workspaces - else { - const exists = localStorage.getItem(`workflow-${workflowId}`) - if (exists) { - try { - const parsed = JSON.parse(exists) - if (parsed?.workspaceId && parsed.workspaceId !== workspaceId) { - // Check if this workspace still exists in our list - const workspacesData = localStorage.getItem('workspaces') - if (workspacesData) { - try { - const workspaces = JSON.parse(workspacesData) - const workspaceExists = workspaces.some((w: any) => w.id === parsed.workspaceId) - - if (!workspaceExists) { - // Workspace doesn't exist, update the workflow to use current workspace - parsed.workspaceId = workspaceId - localStorage.setItem(`workflow-${workflowId}`, JSON.stringify(parsed)) - } - } catch (_e) { - // Skip if we can't parse workspaces data - } - } - } - } catch (_e) { - // Skip if we can't parse the data - } - } - } - } - } - } catch (error) { - logger.error('Error cleaning up localStorage:', error) - } -} - // Resets workflow and subblock stores to prevent data leakage between workspaces function resetWorkflowStores() { // Reset variable tracking to prevent stale API calls @@ -180,8 +89,7 @@ export const useWorkflowRegistry = create()( // Store state workflows: {}, activeWorkflowId: null, - activeWorkspaceId: - typeof window !== 'undefined' ? localStorage.getItem(ACTIVE_WORKSPACE_KEY) : null, + activeWorkspaceId: null, // No longer persisted in localStorage isLoading: false, error: null, // Initialize deployment statuses @@ -189,14 +97,13 @@ export const useWorkflowRegistry = create()( // Set loading state setLoading: (loading: boolean) => { - // Only set loading to true if workflows is empty - if (!loading || Object.keys(get().workflows).length === 0) { - set({ isLoading: loading }) - } + // Remove the broken logic that prevents loading when workflows exist + // This was causing race conditions during deletion and sync operations + set({ isLoading: loading }) }, // Handle cleanup on workspace deletion - handleWorkspaceDeletion: (newWorkspaceId: string) => { + handleWorkspaceDeletion: async (newWorkspaceId: string) => { const currentWorkspaceId = get().activeWorkspaceId if (!newWorkspaceId || newWorkspaceId === currentWorkspaceId) { @@ -207,110 +114,211 @@ export const useWorkflowRegistry = create()( // Set transition state setWorkspaceTransitioning(true) - logger.info(`Switching from deleted workspace ${currentWorkspaceId} to ${newWorkspaceId}`) - - // Reset all workflow state - resetWorkflowStores() - - // Reset registry initialization state - resetRegistryInitialization() - - // Save to localStorage for persistence - if (typeof window !== 'undefined') { - localStorage.setItem(ACTIVE_WORKSPACE_KEY, newWorkspaceId) - } - - // Set loading state while we fetch workflows - set({ - isLoading: true, - workflows: {}, - activeWorkspaceId: newWorkspaceId, - activeWorkflowId: null, - }) - - // Fetch workflows specifically for this workspace - fetchWorkflowsFromDB() - .then(() => { - set({ isLoading: false }) + try { + logger.info(`Switching from deleted workspace ${currentWorkspaceId} to ${newWorkspaceId}`) - // Clean up any stale localStorage data - cleanupLocalStorageForWorkspace(newWorkspaceId) + // Reset all workflow state + resetWorkflowStores() - // End transition state - setWorkspaceTransitioning(false) + // Set loading state while we fetch workflows + set({ + isLoading: true, + workflows: {}, + activeWorkspaceId: newWorkspaceId, + activeWorkflowId: null, }) - .catch((error) => { - logger.error('Error fetching workflows after workspace deletion:', { - error, - workspaceId: newWorkspaceId, - }) - set({ isLoading: false, error: 'Failed to load workspace data' }) - // End transition state even on error - setWorkspaceTransitioning(false) + // Properly await workflow fetching to prevent race conditions + await fetchWorkflowsFromDB() + + set({ isLoading: false }) + logger.info(`Successfully switched to workspace after deletion: ${newWorkspaceId}`) + } catch (error) { + logger.error('Error fetching workflows after workspace deletion:', { + error, + workspaceId: newWorkspaceId, }) + set({ isLoading: false, error: 'Failed to load workspace data' }) + } finally { + // End transition state + setWorkspaceTransitioning(false) + } }, - // Set active workspace and update UI - setActiveWorkspace: (id: string) => { - const currentWorkspaceId = get().activeWorkspaceId - - // Only perform the switch if the workspace is different - if (id === currentWorkspaceId) { + // Switch to workspace with comprehensive error handling and loading states + switchToWorkspace: async (workspaceId: string) => { + // Prevent multiple simultaneous transitions + if (isWorkspaceTransitioning) { + logger.warn( + `Ignoring workspace switch to ${workspaceId} - transition already in progress` + ) return } - // Prevent multiple workspace transitions at once - if (isWorkspaceTransitioning) { - logger.warn('Workspace already transitioning, ignoring new request') + const { activeWorkspaceId: currentWorkspaceId } = get() + + // Early return if switching to the same workspace (before setting flag) + if (currentWorkspaceId === workspaceId) { + logger.info(`Already in workspace ${workspaceId}`) return } - // Set transition state + // Only set transition flag AFTER validating the switch is needed setWorkspaceTransitioning(true) - logger.info(`Switching workspace from ${currentWorkspaceId} to ${id}`) + try { + logger.info(`Switching workspace from ${currentWorkspaceId || 'none'} to ${workspaceId}`) - // Reset all workflow state - resetWorkflowStores() + // Save to localStorage first before any async operations + get().setActiveWorkspaceId(workspaceId) - // Reset registry initialization state - resetRegistryInitialization() + // Clear current workspace state + resetWorkflowStores() - // Save to localStorage for persistence - if (typeof window !== 'undefined') { - localStorage.setItem(ACTIVE_WORKSPACE_KEY, id) + // Update workspace in state + set({ + activeWorkspaceId: workspaceId, + activeWorkflowId: null, + workflows: {}, + isLoading: true, + error: null, + }) + + // Fetch workflows for the new workspace + await fetchWorkflowsFromDB() + + logger.info(`Successfully switched to workspace: ${workspaceId}`) + } catch (error) { + logger.error(`Error switching to workspace ${workspaceId}:`, { error }) + set({ + error: `Failed to switch workspace: ${error instanceof Error ? error.message : 'Unknown error'}`, + isLoading: false, + }) + } finally { + setWorkspaceTransitioning(false) } + }, - // Set loading state while we fetch workflows - set({ - isLoading: true, - // Clear workflows to prevent showing old data during transition - workflows: {}, - activeWorkspaceId: id, - // Reset active workflow when switching workspaces - activeWorkflowId: null, - }) + // Load user's last active workspace from localStorage + loadLastActiveWorkspace: async () => { + try { + const savedWorkspaceId = localStorage.getItem('lastActiveWorkspaceId') + if (!savedWorkspaceId || savedWorkspaceId === get().activeWorkspaceId) { + return // No saved workspace or already active + } - // Fetch workflows specifically for this workspace - // This is better than just triggering a sync as it's more immediate - fetchWorkflowsFromDB() - .then(() => { - set({ isLoading: false }) + logger.info(`Attempting to restore last active workspace: ${savedWorkspaceId}`) + + // Validate that the workspace exists by making a simple API call + try { + const response = await fetch('/api/workspaces') + if (response.ok) { + const data = await response.json() + const workspaces = data.workspaces || [] + const workspaceExists = workspaces.some((ws: any) => ws.id === savedWorkspaceId) + + if (workspaceExists) { + // Set the validated workspace ID + set({ activeWorkspaceId: savedWorkspaceId }) + logger.info(`Restored last active workspace from localStorage: ${savedWorkspaceId}`) + } else { + logger.warn( + `Saved workspace ${savedWorkspaceId} no longer exists, clearing from localStorage` + ) + localStorage.removeItem('lastActiveWorkspaceId') + } + } + } catch (apiError) { + logger.warn('Failed to validate saved workspace, will use default:', apiError) + // Don't remove from localStorage in case it's a temporary network issue + } + } catch (error) { + logger.warn('Failed to load last active workspace from localStorage:', error) + // This is non-critical, so we continue with default behavior + } + }, - // Clean up any stale localStorage data for this workspace - cleanupLocalStorageForWorkspace(id) + // Load workspace based on workflow ID from URL, with fallback to last active workspace + loadWorkspaceFromWorkflowId: async (workflowId: string | null) => { + try { + logger.info(`Loading workspace for workflow ID: ${workflowId}`) - // End transition state - setWorkspaceTransitioning(false) - }) - .catch((error) => { - logger.error('Error fetching workflows for workspace:', { error, workspaceId: id }) - set({ isLoading: false, error: 'Failed to load workspace data' }) + // If workflow ID provided, try to get its workspace + if (workflowId) { + try { + const response = await fetch(`/api/workflows/${workflowId}`) + if (response.ok) { + const data = await response.json() + const workflow = data.data + + if (workflow?.workspaceId) { + // Validate workspace access + const workspacesResponse = await fetch('/api/workspaces') + if (workspacesResponse.ok) { + const workspacesData = await workspacesResponse.json() + const workspaces = workspacesData.workspaces || [] + const workspaceExists = workspaces.some( + (ws: any) => ws.id === workflow.workspaceId + ) + + if (workspaceExists) { + set({ activeWorkspaceId: workflow.workspaceId }) + localStorage.setItem('lastActiveWorkspaceId', workflow.workspaceId) + logger.info(`Set active workspace from workflow: ${workflow.workspaceId}`) + return + } + } + } + } + } catch (error) { + logger.warn('Error fetching workflow:', error) + } + } - // End transition state even on error - setWorkspaceTransitioning(false) - }) + // Fallback: use last active workspace or first available + const savedWorkspaceId = localStorage.getItem('lastActiveWorkspaceId') + const response = await fetch('/api/workspaces') + + if (response.ok) { + const data = await response.json() + const workspaces = data.workspaces || [] + + if (workspaces.length === 0) { + logger.warn('No workspaces found') + return + } + + // Try saved workspace first + let targetWorkspace = savedWorkspaceId + ? workspaces.find((ws: any) => ws.id === savedWorkspaceId) + : null + + // Fall back to first workspace + if (!targetWorkspace) { + targetWorkspace = workspaces[0] + if (savedWorkspaceId) { + localStorage.removeItem('lastActiveWorkspaceId') + } + } + + set({ activeWorkspaceId: targetWorkspace.id }) + localStorage.setItem('lastActiveWorkspaceId', targetWorkspace.id) + logger.info(`Set active workspace: ${targetWorkspace.id}`) + } + } catch (error) { + logger.error('Error in loadWorkspaceFromWorkflowId:', error) + } + }, + + // Simple method to set active workspace ID without triggering full switch + setActiveWorkspaceId: (id: string) => { + set({ activeWorkspaceId: id }) + // Save to localStorage as well + try { + localStorage.setItem('lastActiveWorkspaceId', id) + } catch (error) { + logger.warn('Failed to save workspace to localStorage:', error) + } }, // Method to get deployment status for a specific workflow @@ -323,29 +331,11 @@ export const useWorkflowRegistry = create()( const { deploymentStatuses = {} } = get() - // First try to get from the workflow-specific deployment statuses + // Get from the workflow-specific deployment statuses in the registry if (deploymentStatuses[workflowId]) { return deploymentStatuses[workflowId] } - // For backward compatibility, check the workflow state in workflow store - // This will only be relevant during the transition period - const workflowState = loadWorkflowState(workflowId) - if (workflowState) { - // Check workflow-specific status in the workflow state - if (workflowState.deploymentStatuses?.[workflowId]) { - return workflowState.deploymentStatuses[workflowId] - } - - // Fallback to legacy fields if needed - if (workflowState.isDeployed) { - return { - isDeployed: workflowState.isDeployed || false, - deployedAt: workflowState.deployedAt, - } - } - } - // No deployment status found return null }, @@ -402,32 +392,6 @@ export const useWorkflowRegistry = create()( })) } - // Save the deployment status in the workflow state - const workflowState = loadWorkflowState(workflowId) - if (workflowState) { - saveWorkflowState(workflowId, { - ...workflowState, - // Update both legacy and new fields for compatibility - isDeployed: workflowId === activeWorkflowId ? isDeployed : workflowState.isDeployed, - deployedAt: - workflowId === activeWorkflowId - ? deployedAt || (isDeployed ? new Date() : undefined) - : workflowState.deployedAt, - deploymentStatuses: { - ...(workflowState.deploymentStatuses || {}), - [workflowId]: { - isDeployed, - deployedAt: deployedAt || (isDeployed ? new Date() : undefined), - apiKey, - needsRedeployment: isDeployed - ? false - : ((workflowState.deploymentStatuses?.[workflowId] as any)?.needsRedeployment ?? - false), - }, - }, - }) - } - // Trigger workflow sync to update server state workflowSync.sync() }, @@ -460,156 +424,167 @@ export const useWorkflowRegistry = create()( if (workflowId === activeWorkflowId) { useWorkflowStore.getState().setNeedsRedeploymentFlag(needsRedeployment) } - // Save to persistent storage - const workflowState = loadWorkflowState(workflowId) - if (workflowState) { - const deploymentStatuses = workflowState.deploymentStatuses || {} - const currentStatus = deploymentStatuses[workflowId] || { isDeployed: false } - - saveWorkflowState(workflowId, { - ...workflowState, - deploymentStatuses: { - ...deploymentStatuses, - [workflowId]: { - ...currentStatus, - needsRedeployment, - }, - }, - }) - } }, - // Modified setActiveWorkflow to load deployment statuses + // Modified setActiveWorkflow to work with clean DB-only architecture setActiveWorkflow: async (id: string) => { - const { workflows } = get() + const { workflows, activeWorkflowId } = get() if (!workflows[id]) { set({ error: `Workflow ${id} not found` }) return } - // Get current workflow ID - const currentId = get().activeWorkflowId - // Save current workflow state before switching - if (currentId) { - const currentState = useWorkflowStore.getState() - - // Generate loops and parallels from current blocks - const generatedLoops = currentState.generateLoopBlocks - ? currentState.generateLoopBlocks() - : {} - const generatedParallels = currentState.generateParallelBlocks - ? currentState.generateParallelBlocks() - : {} - - // Save the complete state for the current workflow - saveWorkflowState(currentId, { - blocks: currentState.blocks, - edges: currentState.edges, - loops: generatedLoops, - parallels: generatedParallels, - history: currentState.history, - isDeployed: currentState.isDeployed, - deployedAt: currentState.deployedAt, - deploymentStatuses: currentState.deploymentStatuses, - lastSaved: Date.now(), - }) - - // Also save current subblock values - const currentSubblockValues = useSubBlockStore.getState().workflowValues[currentId] - if (currentSubblockValues) { - saveSubblockValues(currentId, currentSubblockValues) - } + // First, sync the current workflow before switching (if there is one) + if (activeWorkflowId && activeWorkflowId !== id) { + // Mark current workflow as dirty and sync (fire and forget) + useWorkflowStore.getState().sync.markDirty() + useWorkflowStore.getState().sync.forceSync() } - // Load workflow state for the new active workflow - const parsedState = loadWorkflowState(id) - if (parsedState) { - const { - blocks, - edges, - parallels, - history, - loops, - isDeployed, - deployedAt, - deploymentStatuses, - needsRedeployment, - } = parsedState - - // Get workflow-specific deployment status - let workflowIsDeployed = isDeployed - let workflowDeployedAt = deployedAt - let workflowNeedsRedeployment = needsRedeployment - - // Check if we have a workflow-specific deployment status - if (deploymentStatuses?.[id]) { - workflowIsDeployed = deploymentStatuses[id].isDeployed - workflowDeployedAt = deploymentStatuses[id].deployedAt - workflowNeedsRedeployment = deploymentStatuses[id].needsRedeployment - } - - // Initialize subblock store with workflow values - useSubBlockStore.getState().initializeFromWorkflow(id, blocks) - - // Set the workflow store state with the loaded state - useWorkflowStore.setState({ - blocks, - edges, - loops, - parallels, - isDeployed: workflowIsDeployed !== undefined ? workflowIsDeployed : false, - deployedAt: workflowDeployedAt ? new Date(workflowDeployedAt) : undefined, - needsRedeployment: - workflowNeedsRedeployment !== undefined ? workflowNeedsRedeployment : false, - deploymentStatuses: deploymentStatuses || {}, + // Fetch workflow state from database + const { fetchWorkflowStateFromDB } = await import('@/stores/workflows/sync') + const workflowData = await fetchWorkflowStateFromDB(id) + + let workflowState: any + + if (workflowData?.state) { + // Use the state from the database + 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(), + marketplaceData: workflowData.marketplaceData || null, + deploymentStatuses: {}, hasActiveSchedule: false, - history: history || { + history: { past: [], present: { - state: { - blocks, - edges, - loops, - parallels, - isDeployed: workflowIsDeployed !== undefined ? workflowIsDeployed : false, - deployedAt: workflowDeployedAt, - }, + state: workflowData.state, timestamp: Date.now(), - action: 'Initial state', + action: 'Loaded from database', subblockValues: {}, }, future: [], }, - lastSaved: parsedState.lastSaved || Date.now(), + } + + // Extract and update subblock values + const subblockValues: Record> = {} + Object.entries(workflowState.blocks).forEach(([blockId, block]) => { + const blockState = block as any + subblockValues[blockId] = {} + Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = (subblock as any).value + }) }) - // Update the deployment statuses in the registry - if (deploymentStatuses) { - set((state) => ({ - deploymentStatuses: { - ...state.deploymentStatuses, - ...deploymentStatuses, + // Update subblock store for this workflow + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [id]: subblockValues, + }, + })) + } else { + // If no state in DB, initialize with starter block (for newly created workflows) + const starterId = crypto.randomUUID() + const starterBlock = { + id: starterId, + type: 'starter' as const, + name: 'Start', + position: { x: 100, y: 100 }, + subBlocks: { + startWorkflow: { + id: 'startWorkflow', + type: 'dropdown' as const, + value: 'manual', + }, + webhookPath: { + id: 'webhookPath', + type: 'short-input' as const, + value: '', + }, + webhookSecret: { + id: 'webhookSecret', + type: 'short-input' as const, + value: '', + }, + scheduleType: { + id: 'scheduleType', + type: 'dropdown' as const, + value: 'daily', }, - })) - } else if (workflowIsDeployed !== undefined) { - // If there's no deployment statuses object but we have legacy deployment status, - // create an entry in the deploymentStatuses map - set((state) => ({ - deploymentStatuses: { - ...state.deploymentStatuses, - [id]: { - isDeployed: workflowIsDeployed as boolean, - deployedAt: workflowDeployedAt ? new Date(workflowDeployedAt) : undefined, + minutesInterval: { + id: 'minutesInterval', + type: 'short-input' as const, + value: '', + }, + minutesStartingAt: { + id: 'minutesStartingAt', + type: 'short-input' as const, + value: '', + }, + hourlyMinute: { + id: 'hourlyMinute', + type: 'short-input' as const, + value: '', + }, + dailyTime: { + id: 'dailyTime', + type: 'short-input' as const, + value: '', + }, + weeklyDay: { + id: 'weeklyDay', + type: 'dropdown' as const, + value: 'MON', + }, + weeklyDayTime: { + id: 'weeklyDayTime', + type: 'short-input' as const, + value: '', + }, + monthlyDay: { + id: 'monthlyDay', + type: 'short-input' as const, + value: '', + }, + monthlyTime: { + id: 'monthlyTime', + type: 'short-input' as const, + value: '', + }, + cronExpression: { + id: 'cronExpression', + type: 'short-input' as const, + value: '', + }, + timezone: { + id: 'timezone', + type: 'dropdown' as const, + value: 'UTC', + }, + }, + outputs: { + response: { + type: { + input: 'any', }, }, - })) + }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, } - logger.info(`Switched to workflow ${id}`) - } else { - // If no saved state, initialize with empty state - useWorkflowStore.setState({ - blocks: {}, + workflowState = { + blocks: { [starterId]: starterBlock }, edges: [], loops: {}, parallels: {}, @@ -621,7 +596,7 @@ export const useWorkflowRegistry = create()( past: [], present: { state: { - blocks: {}, + blocks: { [starterId]: starterBlock }, edges: [], loops: {}, parallels: {}, @@ -629,19 +604,36 @@ export const useWorkflowRegistry = create()( deployedAt: undefined, }, timestamp: Date.now(), - action: 'Initial state', + action: 'Initial state with starter block', subblockValues: {}, }, future: [], }, lastSaved: Date.now(), + } + + // Initialize subblock values for starter block + const subblockValues: Record> = {} + subblockValues[starterId] = {} + Object.entries(starterBlock.subBlocks).forEach(([subblockId, subblock]) => { + subblockValues[starterId][subblockId] = (subblock as any).value }) - logger.warn(`No saved state found for workflow ${id}, initialized with empty state`) + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [id]: subblockValues, + }, + })) } + // Set the workflow state in the store + useWorkflowStore.setState(workflowState) + // Update the active workflow ID set({ activeWorkflowId: id, error: null }) + + logger.info(`Switched to workflow ${id}`) }, /** @@ -835,7 +827,7 @@ export const useWorkflowRegistry = create()( } } - // Add workflow to registry + // Add workflow to registry first set((state) => ({ workflows: { ...state.workflows, @@ -844,32 +836,38 @@ export const useWorkflowRegistry = create()( error: null, })) - // Save workflow list to localStorage - const updatedWorkflows = get().workflows - saveRegistry(updatedWorkflows) - - // Save initial workflow state to localStorage - saveWorkflowState(id, initialState) - // Initialize subblock values if this is a marketplace import if (options.marketplaceId && options.marketplaceState?.blocks) { useSubBlockStore.getState().initializeFromWorkflow(id, options.marketplaceState.blocks) } - // If this is the first workflow or it's an initial workflow, set it as active - if (options.isInitial || Object.keys(updatedWorkflows).length === 1) { - set({ activeWorkflowId: id }) - useWorkflowStore.setState(initialState) - } else { - // Make sure we switch to this workflow - set({ activeWorkflowId: id }) - useWorkflowStore.setState(initialState) + // Initialize subblock values to ensure they're available for sync + if (!options.marketplaceId) { + // For non-marketplace workflows, initialize subblock values from the starter block + const subblockValues: Record> = {} + const blocks = initialState.blocks as Record + for (const [blockId, block] of Object.entries(blocks)) { + subblockValues[blockId] = {} + for (const [subblockId, subblock] of Object.entries(block.subBlocks)) { + subblockValues[blockId][subblockId] = (subblock as any).value + } + } + + // Update the subblock store with the initial values + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [id]: subblockValues, + }, + })) } - // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty() + // Properly set as active workflow and initialize state + set({ activeWorkflowId: id }) + useWorkflowStore.setState(initialState) - // Trigger sync + // Mark as dirty for sync and trigger immediate sync + useWorkflowStore.getState().sync.markDirty() useWorkflowStore.getState().sync.forceSync() logger.info(`Created new workflow with ID ${id} in workspace ${workspaceId || 'none'}`) @@ -879,10 +877,6 @@ export const useWorkflowRegistry = create()( /** * Creates a new workflow from a marketplace workflow - * @param marketplaceId - The ID of the marketplace workflow to import - * @param state - The state of the marketplace workflow (blocks, edges, loops) - * @param metadata - Additional metadata like name, description from marketplace - * @returns The ID of the newly created workflow */ createMarketplaceWorkflow: ( marketplaceId: string, @@ -939,18 +933,15 @@ export const useWorkflowRegistry = create()( error: null, })) - // Save workflow list to localStorage - const updatedWorkflows = get().workflows - saveRegistry(updatedWorkflows) - - // Save workflow state to localStorage - saveWorkflowState(id, initialState) - // Initialize subblock values from state blocks if (state.blocks) { useSubBlockStore.getState().initializeFromWorkflow(id, state.blocks) } + // Set as active workflow and update store + set({ activeWorkflowId: id }) + useWorkflowStore.setState(initialState) + // Mark as dirty to ensure sync useWorkflowStore.getState().sync.markDirty() @@ -964,8 +955,6 @@ export const useWorkflowRegistry = create()( /** * Duplicates an existing workflow - * @param sourceId - The ID of the workflow to duplicate - * @returns The ID of the newly created workflow */ duplicateWorkflow: (sourceId: string) => { const { workflows, activeWorkspaceId } = get() @@ -978,13 +967,6 @@ export const useWorkflowRegistry = create()( const id = crypto.randomUUID() - // Load the source workflow state - const sourceState = loadWorkflowState(sourceId) - if (!sourceState) { - set({ error: `No state found for workflow ${sourceId}` }) - return null - } - // Get the workspace ID from the source workflow or fall back to active workspace const workspaceId = sourceWorkflow.workspaceId || activeWorkspaceId || undefined @@ -999,27 +981,144 @@ export const useWorkflowRegistry = create()( // Do not copy marketplace data } - // Create new workflow state without deployment data + // Get the current workflow state to copy from + const currentWorkflowState = useWorkflowStore.getState() + + // If we're duplicating the active workflow, use current state + // Otherwise, we need to fetch it from DB or use empty state + let sourceState: any + + if (sourceId === get().activeWorkflowId) { + // Source is the active workflow, copy current state + sourceState = { + blocks: currentWorkflowState.blocks || {}, + edges: currentWorkflowState.edges || [], + loops: currentWorkflowState.loops || {}, + parallels: currentWorkflowState.parallels || {}, + } + } else { + // Source is not active workflow, create with starter block for now + // In a future enhancement, we could fetch from DB + const starterId = crypto.randomUUID() + const starterBlock = { + id: starterId, + type: 'starter' as const, + name: 'Start', + position: { x: 100, y: 100 }, + subBlocks: { + startWorkflow: { + id: 'startWorkflow', + type: 'dropdown' as const, + value: 'manual', + }, + webhookPath: { + id: 'webhookPath', + type: 'short-input' as const, + value: '', + }, + webhookSecret: { + id: 'webhookSecret', + type: 'short-input' as const, + value: '', + }, + scheduleType: { + id: 'scheduleType', + type: 'dropdown' as const, + value: 'daily', + }, + minutesInterval: { + id: 'minutesInterval', + type: 'short-input' as const, + value: '', + }, + minutesStartingAt: { + id: 'minutesStartingAt', + type: 'short-input' as const, + value: '', + }, + hourlyMinute: { + id: 'hourlyMinute', + type: 'short-input' as const, + value: '', + }, + dailyTime: { + id: 'dailyTime', + type: 'short-input' as const, + value: '', + }, + weeklyDay: { + id: 'weeklyDay', + type: 'dropdown' as const, + value: 'MON', + }, + weeklyDayTime: { + id: 'weeklyDayTime', + type: 'short-input' as const, + value: '', + }, + monthlyDay: { + id: 'monthlyDay', + type: 'short-input' as const, + value: '', + }, + monthlyTime: { + id: 'monthlyTime', + type: 'short-input' as const, + value: '', + }, + cronExpression: { + id: 'cronExpression', + type: 'short-input' as const, + value: '', + }, + timezone: { + id: 'timezone', + type: 'dropdown' as const, + value: 'UTC', + }, + }, + outputs: { + response: { + type: { + input: 'any', + }, + }, + }, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + } + + sourceState = { + blocks: { [starterId]: starterBlock }, + edges: [], + loops: {}, + parallels: {}, + } + } + + // Create the new workflow state with copied content const newState = { - blocks: sourceState.blocks || {}, - edges: sourceState.edges || [], - loops: sourceState.loops || {}, - parallels: sourceState.parallels || {}, - isDeployed: false, // Reset deployment status - deployedAt: undefined, // Reset deployment timestamp - workspaceId, // Include workspaceId in state - deploymentStatuses: {}, // Start with empty deployment statuses map + blocks: sourceState.blocks, + edges: sourceState.edges, + loops: sourceState.loops, + parallels: sourceState.parallels, + isDeployed: false, + deployedAt: undefined, + workspaceId, + deploymentStatuses: {}, history: { past: [], present: { state: { - blocks: sourceState.blocks || {}, - edges: sourceState.edges || [], - loops: sourceState.loops || {}, - parallels: sourceState.parallels || {}, + blocks: sourceState.blocks, + edges: sourceState.edges, + loops: sourceState.loops, + parallels: sourceState.parallels, isDeployed: false, deployedAt: undefined, - workspaceId, // Include workspaceId in history state + workspaceId, }, timestamp: Date.now(), action: 'Duplicated workflow', @@ -1039,31 +1138,40 @@ export const useWorkflowRegistry = create()( error: null, })) - // Save workflow list to localStorage - const updatedWorkflows = get().workflows - saveRegistry(updatedWorkflows) - - // Save workflow state to localStorage - saveWorkflowState(id, newState) - - // Copy subblock values from the source workflow - const sourceSubblockValues = useSubBlockStore.getState().workflowValues[sourceId] - if (sourceSubblockValues) { + // Copy subblock values if duplicating active workflow + if (sourceId === get().activeWorkflowId) { + const sourceSubblockValues = useSubBlockStore.getState().workflowValues[sourceId] || {} useSubBlockStore.setState((state) => ({ workflowValues: { ...state.workflowValues, - [id]: JSON.parse(JSON.stringify(sourceSubblockValues)), // Deep copy + [id]: sourceSubblockValues, }, })) + } else { + // Initialize subblock values for starter block + const subblockValues: Record> = {} + Object.entries(newState.blocks).forEach(([blockId, block]) => { + const blockState = block as any + subblockValues[blockId] = {} + Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = (subblock as any).value + }) + }) - // Save the copied subblock values - saveSubblockValues(id, JSON.parse(JSON.stringify(sourceSubblockValues))) + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [id]: subblockValues, + }, + })) } - // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty() + // Set as active workflow and update store + set({ activeWorkflowId: id }) + useWorkflowStore.setState(newState) - // Trigger sync + // Mark as dirty for sync and trigger immediate sync + useWorkflowStore.getState().sync.markDirty() useWorkflowStore.getState().sync.forceSync() logger.info( @@ -1074,59 +1182,63 @@ export const useWorkflowRegistry = create()( }, // Delete workflow and clean up associated storage - removeWorkflow: (id: string) => { + removeWorkflow: async (id: string) => { + const { workflows } = get() + const workflowToDelete = workflows[id] + + if (!workflowToDelete) { + logger.warn(`Attempted to delete non-existent workflow: ${id}`) + return + } + set({ isLoading: true, error: null }) + + try { + // Call DELETE endpoint to remove from database + const response = await fetch(`/api/workflows/${id}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error || 'Failed to delete workflow') + } + + logger.info(`Successfully deleted workflow ${id} from database`) + } catch (error) { + logger.error(`Failed to delete workflow ${id} from database:`, error) + set({ + error: `Failed to delete workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, + isLoading: false, + }) + return + } + + // Only update local state after successful deletion from database set((state) => { const newWorkflows = { ...state.workflows } delete newWorkflows[id] - // Clean up localStorage - removeFromStorage(STORAGE_KEYS.WORKFLOW(id)) - removeFromStorage(STORAGE_KEYS.SUBBLOCK(id)) - saveRegistry(newWorkflows) - - // Mark as dirty to ensure sync - useWorkflowStore.getState().sync.markDirty() - - // Sync deletion with database - useWorkflowStore.getState().sync.forceSync() + // Clean up subblock values for this workflow + useSubBlockStore.setState((subBlockState) => { + const newWorkflowValues = { ...subBlockState.workflowValues } + delete newWorkflowValues[id] + return { workflowValues: newWorkflowValues } + }) - // If deleting active workflow, switch to another one + // If deleting active workflow, switch to another one or clear state let newActiveWorkflowId = state.activeWorkflowId if (state.activeWorkflowId === id) { const remainingIds = Object.keys(newWorkflows) - // Switch to first available workflow - newActiveWorkflowId = remainingIds[0] - const savedState = loadWorkflowState(newActiveWorkflowId) - if (savedState) { - const { blocks, edges, history, loops, parallels, isDeployed, deployedAt } = - savedState - useWorkflowStore.setState({ - blocks, - edges, - loops, - parallels, - isDeployed: isDeployed || false, - deployedAt: deployedAt ? new Date(deployedAt) : undefined, - hasActiveSchedule: false, - history: history || { - past: [], - present: { - state: { - blocks, - edges, - loops, - parallels, - isDeployed: isDeployed || false, - deployedAt, - }, - timestamp: Date.now(), - action: 'Initial state', - subblockValues: {}, - }, - future: [], - }, - }) - } else { + newActiveWorkflowId = remainingIds[0] || null + + // Ensure the workflow we're switching to actually exists + if (newActiveWorkflowId && !newWorkflows[newActiveWorkflowId]) { + logger.warn(`Attempted to switch to non-existent workflow ${newActiveWorkflowId}`) + newActiveWorkflowId = null + } + + if (!newActiveWorkflowId) { + // No workflows left, initialize empty state useWorkflowStore.setState({ blocks: {}, edges: [], @@ -1157,10 +1269,29 @@ export const useWorkflowRegistry = create()( } } + // Cancel any schedule for this workflow (async, don't wait) + fetch(API_ENDPOINTS.SCHEDULE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowId: id, + state: { + blocks: {}, + edges: [], + loops: {}, + }, + }), + }).catch((error) => { + logger.error(`Error cancelling schedule for deleted workflow ${id}:`, error) + }) + + logger.info(`Removed workflow ${id} from local state`) + return { workflows: newWorkflows, activeWorkflowId: newActiveWorkflowId, error: null, + isLoading: false, // Clear loading state after successful deletion } }) }, @@ -1180,9 +1311,6 @@ export const useWorkflowRegistry = create()( }, } - // Update localStorage - saveRegistry(updatedWorkflows) - // Mark as dirty to ensure sync useWorkflowStore.getState().sync.markDirty() @@ -1195,6 +1323,23 @@ export const useWorkflowRegistry = create()( } }) }, + + logout: () => { + logger.info('Logging out - clearing all workflow data') + + // Clear all state + resetWorkflowStores() + + set({ + workflows: {}, + activeWorkflowId: null, + activeWorkspaceId: null, + isLoading: false, + error: null, + }) + + logger.info('Logout complete - all workflow data cleared') + }, }), { name: 'workflow-registry' } ) diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index facb08ce540..797886778ae 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -33,9 +33,12 @@ export interface WorkflowRegistryState { export interface WorkflowRegistryActions { setLoading: (loading: boolean) => void setActiveWorkflow: (id: string) => Promise - setActiveWorkspace: (id: string) => void + switchToWorkspace: (id: string) => void + setActiveWorkspaceId: (id: string) => void + loadLastActiveWorkspace: () => Promise + loadWorkspaceFromWorkflowId: (workflowId: string | null) => Promise handleWorkspaceDeletion: (newWorkspaceId: string) => void - removeWorkflow: (id: string) => void + removeWorkflow: (id: string) => Promise updateWorkflow: (id: string, metadata: Partial) => void createWorkflow: (options?: { isInitial?: boolean @@ -46,6 +49,11 @@ export interface WorkflowRegistryActions { workspaceId?: string folderId?: string | null }) => string + createMarketplaceWorkflow: ( + marketplaceId: string, + state: any, + metadata: Partial + ) => string duplicateWorkflow: (sourceId: string) => string | null getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null setDeploymentStatus: ( diff --git a/apps/sim/stores/workflows/server-utils.ts b/apps/sim/stores/workflows/server-utils.ts new file mode 100644 index 00000000000..19884bb8f8b --- /dev/null +++ b/apps/sim/stores/workflows/server-utils.ts @@ -0,0 +1,116 @@ +/** + * Server-Safe Workflow Utilities + * + * This file contains workflow utility functions that can be safely imported + * by server-side API routes without causing client/server boundary violations. + * + * Unlike the main utils.ts file, this does NOT import any client-side stores + * or React hooks, making it safe for use in Next.js API routes. + */ + +import type { BlockState, SubBlockState } from './workflow/types' + +/** + * Server-safe version of mergeSubblockState for API routes + * + * Merges workflow block states with provided subblock values while maintaining block structure. + * This version takes explicit subblock values instead of reading from client stores. + * + * @param blocks - Block configurations from workflow state + * @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value + * @param blockId - Optional specific block ID to merge (merges all if not provided) + * @returns Merged block states with updated values + */ +export function mergeSubblockState( + blocks: Record, + subBlockValues: Record> = {}, + blockId?: string +): Record { + const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks + + return Object.entries(blocksToProcess).reduce( + (acc, [id, block]) => { + // Skip if block is undefined + if (!block) { + return acc + } + + // Initialize subBlocks if not present + const blockSubBlocks = block.subBlocks || {} + + // Get stored values for this block + const blockValues = subBlockValues[id] || {} + + // Create a deep copy of the block's subBlocks to maintain structure + const mergedSubBlocks = Object.entries(blockSubBlocks).reduce( + (subAcc, [subBlockId, subBlock]) => { + // Skip if subBlock is undefined + if (!subBlock) { + return subAcc + } + + // Get the stored value for this subblock + const storedValue = blockValues[subBlockId] + + // Create a new subblock object with the same structure but updated value + subAcc[subBlockId] = { + ...subBlock, + value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value, + } + + return subAcc + }, + {} as Record + ) + + // Return the full block state with updated subBlocks + acc[id] = { + ...block, + subBlocks: mergedSubBlocks, + } + + // Add any values that exist in the provided values but aren't in the block structure + // This handles cases where block config has been updated but values still exist + Object.entries(blockValues).forEach(([subBlockId, value]) => { + if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) { + // Create a minimal subblock structure + mergedSubBlocks[subBlockId] = { + id: subBlockId, + type: 'short-input', // Default type that's safe to use + value: value, + } + } + }) + + // Update the block with the final merged subBlocks (including orphaned values) + acc[id] = { + ...block, + subBlocks: mergedSubBlocks, + } + + return acc + }, + {} as Record + ) +} + +/** + * Server-safe async version of mergeSubblockState for API routes + * + * Asynchronously merges workflow block states with provided subblock values. + * This version takes explicit subblock values instead of reading from client stores. + * + * @param blocks - Block configurations from workflow state + * @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value + * @param blockId - Optional specific block ID to merge (merges all if not provided) + * @returns Promise resolving to merged block states with updated values + */ +export async function mergeSubblockStateAsync( + blocks: Record, + subBlockValues: Record> = {}, + blockId?: string +): Promise> { + // Since we're not reading from client stores, we can just return the sync version + // The async nature was only needed for the client-side store operations + return mergeSubblockState(blocks, subBlockValues, blockId) +} diff --git a/apps/sim/stores/workflows/subblock/store.ts b/apps/sim/stores/workflows/subblock/store.ts index 0fdf11f02f6..bd1f64d25ca 100644 --- a/apps/sim/stores/workflows/subblock/store.ts +++ b/apps/sim/stores/workflows/subblock/store.ts @@ -1,9 +1,8 @@ import { create } from 'zustand' -import { devtools, persist } from 'zustand/middleware' +import { devtools } from 'zustand/middleware' import type { SubBlockConfig } from '@/blocks/types' import { useEnvironmentStore } from '../../settings/environment/store' import { useGeneralStore } from '../../settings/general/store' -import { loadSubblockValues, saveSubblockValues } from '../persistence' import { useWorkflowRegistry } from '../registry/store' import { workflowSync } from '../sync' import type { SubBlockStore } from './types' @@ -26,307 +25,259 @@ const DEBOUNCE_DELAY = 500 // 500ms delay for sync */ export const useSubBlockStore = create()( - devtools( - persist( - (set, get) => ({ - workflowValues: {}, - // Initialize tool params-related state - toolParams: {}, - clearedParams: {}, - - setValue: (blockId: string, subBlockId: string, value: any) => { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) return - - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: { - ...state.workflowValues[activeWorkflowId], - [blockId]: { - ...state.workflowValues[activeWorkflowId]?.[blockId], - [subBlockId]: value, - }, - }, + devtools((set, get) => ({ + workflowValues: {}, + // Initialize tool params-related state + toolParams: {}, + clearedParams: {}, + + setValue: (blockId: string, subBlockId: string, value: any) => { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) return + + set((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: { + ...state.workflowValues[activeWorkflowId], + [blockId]: { + ...state.workflowValues[activeWorkflowId]?.[blockId], + [subBlockId]: value, }, - })) - - // Persist to localStorage for backup - const currentValues = get().workflowValues[activeWorkflowId] || {} - saveSubblockValues(activeWorkflowId, currentValues) - - // Trigger debounced sync to DB - get().syncWithDB() + }, }, + })) - getValue: (blockId: string, subBlockId: string) => { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) return null - - return get().workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null - }, + // Trigger debounced sync to DB + get().syncWithDB() + }, - clear: () => { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) return + getValue: (blockId: string, subBlockId: string) => { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) return null - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: {}, - }, - })) + return get().workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null + }, - saveSubblockValues(activeWorkflowId, {}) + clear: () => { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) return - // Trigger sync to DB immediately on clear - workflowSync.sync() + set((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: {}, }, - - initializeFromWorkflow: (workflowId: string, blocks: Record) => { - // First, try to load from localStorage - const savedValues = loadSubblockValues(workflowId) - - if (savedValues) { - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [workflowId]: savedValues, - }, - })) - return - } - - // If no saved values, initialize from blocks - const values: Record> = {} - Object.entries(blocks).forEach(([blockId, block]) => { - values[blockId] = {} - Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { - values[blockId][subBlockId] = (subBlock as SubBlockConfig).value - }) - }) - - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [workflowId]: values, - }, - })) - - // Save to localStorage - saveSubblockValues(workflowId, values) + })) + + // Trigger sync to DB immediately on clear + workflowSync.sync() + }, + + initializeFromWorkflow: (workflowId: string, blocks: Record) => { + // Initialize from blocks + const values: Record> = {} + Object.entries(blocks).forEach(([blockId, block]) => { + values[blockId] = {} + Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { + values[blockId][subBlockId] = (subBlock as SubBlockConfig).value + }) + }) + + set((state) => ({ + workflowValues: { + ...state.workflowValues, + [workflowId]: values, }, + })) + }, + + // Debounced sync function to trigger DB sync + syncWithDB: () => { + // Clear any existing timeout + if (syncDebounceTimer) { + clearTimeout(syncDebounceTimer) + } - // Debounced sync function to trigger DB sync - syncWithDB: () => { - // Clear any existing timeout - if (syncDebounceTimer) { - clearTimeout(syncDebounceTimer) + // Set new timeout + syncDebounceTimer = setTimeout(() => { + // Trigger workflow sync to DB + workflowSync.sync() + }, DEBOUNCE_DELAY) + }, + + // Tool params related functionality + setToolParam: (toolId: string, paramId: string, value: string) => { + // If setting a non-empty value, we should remove it from clearedParams if it exists + if (value.trim() !== '') { + set((state) => { + const newClearedParams = { ...state.clearedParams } + if (newClearedParams[toolId]?.[paramId]) { + delete newClearedParams[toolId][paramId] + // Clean up empty objects + if (Object.keys(newClearedParams[toolId]).length === 0) { + delete newClearedParams[toolId] + } } - // Set new timeout - syncDebounceTimer = setTimeout(() => { - // Trigger workflow sync to DB - workflowSync.sync() - }, DEBOUNCE_DELAY) + return { clearedParams: newClearedParams } + }) + } + + // Set the parameter value + set((state) => ({ + toolParams: { + ...state.toolParams, + [toolId]: { + ...(state.toolParams[toolId] || {}), + [paramId]: value, + }, }, + })) - // Tool params related functionality - setToolParam: (toolId: string, paramId: string, value: string) => { - // If setting a non-empty value, we should remove it from clearedParams if it exists - if (value.trim() !== '') { - set((state) => { - const newClearedParams = { ...state.clearedParams } - if (newClearedParams[toolId]?.[paramId]) { - delete newClearedParams[toolId][paramId] - // Clean up empty objects - if (Object.keys(newClearedParams[toolId]).length === 0) { - delete newClearedParams[toolId] - } - } - - return { clearedParams: newClearedParams } - }) - } + // For API keys, also store under a normalized tool name for cross-referencing + // This allows both blocks and tools to share the same parameters + if (paramId.toLowerCase() === 'apikey' || paramId.toLowerCase() === 'api_key') { + // Extract the tool name part (e.g., "exa" from "exa-search") + const baseTool = toolId.split('-')[0].toLowerCase() - // Set the parameter value + if (baseTool !== toolId) { + // Set the same value for the base tool to enable cross-referencing set((state) => ({ toolParams: { ...state.toolParams, - [toolId]: { - ...(state.toolParams[toolId] || {}), + [baseTool]: { + ...(state.toolParams[baseTool] || {}), [paramId]: value, }, }, })) - - // For API keys, also store under a normalized tool name for cross-referencing - // This allows both blocks and tools to share the same parameters - if (paramId.toLowerCase() === 'apikey' || paramId.toLowerCase() === 'api_key') { - // Extract the tool name part (e.g., "exa" from "exa-search") - const baseTool = toolId.split('-')[0].toLowerCase() - - if (baseTool !== toolId) { - // Set the same value for the base tool to enable cross-referencing - set((state) => ({ - toolParams: { - ...state.toolParams, - [baseTool]: { - ...(state.toolParams[baseTool] || {}), - [paramId]: value, - }, - }, - })) - } - } - }, - - markParamAsCleared: (instanceId: string, paramId: string) => { - // Mark this specific instance as cleared - set((state) => ({ - clearedParams: { - ...state.clearedParams, - [instanceId]: { - ...(state.clearedParams[instanceId] || {}), - [paramId]: true, - }, - }, - })) - }, - - unmarkParamAsCleared: (instanceId: string, paramId: string) => { - // Remove the cleared flag for this parameter - set((state) => { - const newClearedParams = { ...state.clearedParams } - if (newClearedParams[instanceId]?.[paramId]) { - delete newClearedParams[instanceId][paramId] - // Clean up empty objects - if (Object.keys(newClearedParams[instanceId]).length === 0) { - delete newClearedParams[instanceId] - } - } - return { clearedParams: newClearedParams } - }) - }, - - isParamCleared: (instanceId: string, paramId: string) => { - // Only check this specific instance - return !!get().clearedParams[instanceId]?.[paramId] + } + } + }, + + markParamAsCleared: (instanceId: string, paramId: string) => { + // Mark this specific instance as cleared + set((state) => ({ + clearedParams: { + ...state.clearedParams, + [instanceId]: { + ...(state.clearedParams[instanceId] || {}), + [paramId]: true, + }, }, - - getToolParam: (toolId: string, paramId: string) => { - // Check for direct match first - const directValue = get().toolParams[toolId]?.[paramId] - if (directValue) return directValue - - // Try base tool name if it's a compound tool ID - if (toolId.includes('-')) { - const baseTool = toolId.split('-')[0].toLowerCase() - return get().toolParams[baseTool]?.[paramId] + })) + }, + + unmarkParamAsCleared: (instanceId: string, paramId: string) => { + // Remove the cleared flag for this parameter + set((state) => { + const newClearedParams = { ...state.clearedParams } + if (newClearedParams[instanceId]?.[paramId]) { + delete newClearedParams[instanceId][paramId] + // Clean up empty objects + if (Object.keys(newClearedParams[instanceId]).length === 0) { + delete newClearedParams[instanceId] } + } + return { clearedParams: newClearedParams } + }) + }, + + isParamCleared: (instanceId: string, paramId: string) => { + // Only check this specific instance + return !!get().clearedParams[instanceId]?.[paramId] + }, + + getToolParam: (toolId: string, paramId: string) => { + // Check for direct match first + const directValue = get().toolParams[toolId]?.[paramId] + if (directValue) return directValue + + // Try base tool name if it's a compound tool ID + if (toolId.includes('-')) { + const baseTool = toolId.split('-')[0].toLowerCase() + return get().toolParams[baseTool]?.[paramId] + } - // Try matching against any stored tool that starts with this ID - // This helps match "exa" with "exa-search" etc. - const matchingToolIds = Object.keys(get().toolParams).filter( - (id) => id.startsWith(toolId) || id.split('-')[0] === toolId - ) + // Try matching against any stored tool that starts with this ID + // This helps match "exa" with "exa-search" etc. + const matchingToolIds = Object.keys(get().toolParams).filter( + (id) => id.startsWith(toolId) || id.split('-')[0] === toolId + ) - for (const id of matchingToolIds) { - const value = get().toolParams[id]?.[paramId] - if (value) return value - } - - return undefined - }, + for (const id of matchingToolIds) { + const value = get().toolParams[id]?.[paramId] + if (value) return value + } - getToolParams: (toolId: string) => { - return get().toolParams[toolId] || {} - }, + return undefined + }, - isEnvVarReference, + getToolParams: (toolId: string) => { + return get().toolParams[toolId] || {} + }, - resolveToolParamValue: (toolId: string, paramId: string, instanceId?: string) => { - // If this is a specific instance that has been deliberately cleared, don't auto-fill it - if (instanceId && get().isParamCleared(instanceId, paramId)) { - return undefined - } + isEnvVarReference, - // Check if auto-fill environment variables is enabled - const isAutoFillEnvVarsEnabled = useGeneralStore.getState().isAutoFillEnvVarsEnabled - if (!isAutoFillEnvVarsEnabled) { - // When auto-fill is disabled, we still return existing stored values, but don't - // attempt to resolve environment variables or set new values - return get().toolParams[toolId]?.[paramId] - } + resolveToolParamValue: (toolId: string, paramId: string, instanceId?: string) => { + // If this is a specific instance that has been deliberately cleared, don't auto-fill it + if (instanceId && get().isParamCleared(instanceId, paramId)) { + return undefined + } - const envStore = useEnvironmentStore.getState() + // Check if auto-fill environment variables is enabled + const isAutoFillEnvVarsEnabled = useGeneralStore.getState().isAutoFillEnvVarsEnabled + if (!isAutoFillEnvVarsEnabled) { + // When auto-fill is disabled, we still return existing stored values, but don't + // attempt to resolve environment variables or set new values + return get().toolParams[toolId]?.[paramId] + } - // First check params store for previously entered value - const storedValue = get().getToolParam(toolId, paramId) + const envStore = useEnvironmentStore.getState() - if (storedValue) { - // If the stored value is an environment variable reference like {{EXA_API_KEY}} - if (isEnvVarReference(storedValue)) { - // Extract variable name from {{VAR_NAME}} - const envVarName = extractEnvVarName(storedValue) - if (!envVarName) return undefined + // First check params store for previously entered value + const storedValue = get().getToolParam(toolId, paramId) - // Check if this environment variable still exists - const envValue = envStore.getVariable(envVarName) + if (storedValue) { + // If the stored value is an environment variable reference like {{EXA_API_KEY}} + if (isEnvVarReference(storedValue)) { + // Extract variable name from {{VAR_NAME}} + const envVarName = extractEnvVarName(storedValue) + if (!envVarName) return undefined - if (envValue) { - // Environment variable exists, return the reference - return storedValue - } - // Environment variable no longer exists - return undefined - } + // Check if this environment variable still exists + const envValue = envStore.getVariable(envVarName) - // Return the stored value directly if it's not an env var reference + if (envValue) { + // Environment variable exists, return the reference return storedValue } - - // If no stored value, try to guess based on parameter name - // This handles cases where the user hasn't entered a value yet - if (paramId.toLowerCase() === 'apikey' || paramId.toLowerCase() === 'api_key') { - const matchingVar = findMatchingEnvVar(toolId) - if (matchingVar) { - const envReference = `{{${matchingVar}}}` - get().setToolParam(toolId, paramId, envReference) - return envReference - } - } - - // No value found + // Environment variable no longer exists return undefined - }, + } - clearToolParams: () => { - set({ toolParams: {}, clearedParams: {} }) - }, - }), - { - name: 'subblock-store', - partialize: (state) => ({ - workflowValues: state.workflowValues, - toolParams: state.toolParams, - clearedParams: state.clearedParams, - }), - // Use default storage - storage: { - getItem: (name) => { - const value = localStorage.getItem(name) - return value ? JSON.parse(value) : null - }, - setItem: (name, value) => { - localStorage.setItem(name, JSON.stringify(value)) - }, - removeItem: (name) => { - localStorage.removeItem(name) - }, - }, + // Return the stored value directly if it's not an env var reference + return storedValue } - ), - { name: 'subblock-store' } - ) + + // If no stored value, try to guess based on parameter name + // This handles cases where the user hasn't entered a value yet + if (paramId.toLowerCase() === 'apikey' || paramId.toLowerCase() === 'api_key') { + const matchingVar = findMatchingEnvVar(toolId) + if (matchingVar) { + const envReference = `{{${matchingVar}}}` + get().setToolParam(toolId, paramId, envReference) + return envReference + } + } + + // No value found + return undefined + }, + + clearToolParams: () => { + set({ toolParams: {}, clearedParams: {} }) + }, + })) ) diff --git a/apps/sim/stores/workflows/sync.ts b/apps/sim/stores/workflows/sync.ts index 9eea7f8b58f..0318d4b59c1 100644 --- a/apps/sim/stores/workflows/sync.ts +++ b/apps/sim/stores/workflows/sync.ts @@ -2,227 +2,203 @@ import { createLogger } from '@/lib/logs/console-logger' import { API_ENDPOINTS } from '../constants' +import { isDataInitialized } from '../index' import { createSingletonSyncManager } from '../sync' import { getAllWorkflowsWithValues } from '.' -import { useWorkflowRegistry } from './registry/store' +import { isWorkspaceInTransition, useWorkflowRegistry } from './registry/store' import type { WorkflowMetadata } from './registry/types' import { useSubBlockStore } from './subblock/store' -import { useWorkflowStore } from './workflow/store' import type { BlockState } from './workflow/types' const logger = createLogger('WorkflowsSync') -// Add debounce utility -let syncDebounceTimer: NodeJS.Timeout | null = null -const DEBOUNCE_DELAY = 500 // 500ms delay - -// Flag to prevent immediate sync back to DB after loading from DB -let _isLoadingFromDB = false -let loadingFromDBToken: string | null = null -let loadingFromDBStartTime = 0 -const LOADING_TIMEOUT = 3000 // 3 seconds maximum loading time - -// Add registry initialization tracking -let registryFullyInitialized = false -const _REGISTRY_INIT_TIMEOUT = 10000 // 10 seconds maximum for registry initialization +// Simplified sync state tracking +let lastSyncedData = '' +let isSyncing = false +let isFetching = false // Add lock to prevent concurrent fetches +let lastFetchTimestamp = 0 // Track when we last fetched to prevent race conditions /** - * Checks if the system is currently in the process of loading data from the database - * Includes safety timeout to prevent permanent blocking of syncs - * @returns true if loading is active, false otherwise + * Simplified workflow sync - no more complex flags and initialization checks */ -export function isActivelyLoadingFromDB(): boolean { - if (!loadingFromDBToken) return false - - // Safety check: ensure loading doesn't block syncs indefinitely - const elapsedTime = Date.now() - loadingFromDBStartTime - if (elapsedTime > LOADING_TIMEOUT) { - loadingFromDBToken = null - return false - } - - return true -} +const workflowSyncConfig = { + endpoint: API_ENDPOINTS.SYNC, + preparePayload: () => { + if (typeof window === 'undefined') return { skipSync: true } -/** - * Checks if the workflow registry is fully initialized - * This is used to prevent syncs before the registry is ready - * @returns true if registry is initialized, false otherwise - */ -export function isRegistryInitialized(): boolean { - return registryFullyInitialized -} + // Skip sync if data is not yet initialized from database + if (!isDataInitialized()) { + logger.info('Skipping sync: Data not yet initialized from database') + return { skipSync: true } + } -/** - * Marks registry as initialized after successful load - * Should be called only after all workflows have been loaded from DB - */ -function setRegistryInitialized(): void { - registryFullyInitialized = true - logger.info('Workflow registry fully initialized') -} + // Prevent concurrent syncs + if (isSyncing) { + return { skipSync: true } + } -/** - * Reset registry initialization state when needed (e.g., workspace switch, logout) - */ -export function resetRegistryInitialization(): void { - registryFullyInitialized = false - logger.info('Workflow registry initialization reset') -} + // Block sync during workspace transitions to prevent race conditions + if (isWorkspaceInTransition()) { + logger.info('Skipping sync: Workspace transition in progress') + return { skipSync: true } + } -// Enhanced workflow state tracking -let lastWorkflowState: Record = {} -let isDirty = false + // Get all workflows with values + const allWorkflowsData = getAllWorkflowsWithValues() -/** - * Checks if workflow state has actually changed since last sync - * @param currentState Current workflow state to compare - * @returns true if changes detected, false otherwise - */ -function hasWorkflowChanges(currentState: Record): boolean { - if (!currentState || Object.keys(currentState).length === 0) { - return false // Empty state should not trigger sync - } + // Skip sync if no workflows + if (Object.keys(allWorkflowsData).length === 0) { + return { skipSync: true } + } - if (Object.keys(lastWorkflowState).length === 0) { - // First time check, mark as changed - lastWorkflowState = JSON.parse(JSON.stringify(currentState)) - return true - } + // Safety check: Never sync if any workflow has empty state + // A valid workflow should always have at least a start block + const allWorkflowsHaveBlocks = Object.values(allWorkflowsData).every((workflow) => { + const blocks = workflow.state?.blocks || {} + return Object.keys(blocks).length > 0 + }) - // Check if workflow count changed - if (Object.keys(currentState).length !== Object.keys(lastWorkflowState).length) { - lastWorkflowState = JSON.parse(JSON.stringify(currentState)) - return true - } + if (!allWorkflowsHaveBlocks) { + logger.warn( + 'Skipping sync: One or more workflows have empty state (no blocks). This indicates corrupted or incomplete workflow data.' + ) + return { skipSync: true } + } - // Deep comparison of workflow states - let hasChanges = false - for (const [id, workflow] of Object.entries(currentState)) { - if ( - !lastWorkflowState[id] || - JSON.stringify(workflow) !== JSON.stringify(lastWorkflowState[id]) - ) { - hasChanges = true - break + // Skip sync if no changes detected + const currentDataHash = JSON.stringify(allWorkflowsData) + if (currentDataHash === lastSyncedData) { + return { skipSync: true } } - } - if (hasChanges) { - lastWorkflowState = JSON.parse(JSON.stringify(currentState)) - } + // Update last synced data hash + lastSyncedData = currentDataHash - return hasChanges -} + // Get the active workspace ID + const activeWorkspaceId = useWorkflowRegistry.getState().activeWorkspaceId -/** - * Mark workflows as dirty (changed) to force a sync - */ -export function markWorkflowsDirty(): void { - isDirty = true - logger.info('Workflows marked as dirty, will sync on next opportunity') -} + // Ensure all workflows have required fields for validation + const workflowsData: Record = {} + Object.entries(allWorkflowsData).forEach(([id, workflow]) => { + // Ensure state has required fields for Zod validation + const safeWorkflow = { + ...workflow, + state: { + blocks: workflow.state?.blocks || {}, + edges: workflow.state?.edges || [], + loops: workflow.state?.loops || {}, + parallels: workflow.state?.parallels || {}, + ...workflow.state, + }, + } -/** - * Checks if workflows are currently marked as dirty - * @returns true if workflows are dirty and need syncing - */ -export function areWorkflowsDirty(): boolean { - return isDirty -} + // Only include workspaceId if it exists + if (workflow.workspaceId || activeWorkspaceId) { + safeWorkflow.workspaceId = workflow.workspaceId || activeWorkspaceId + } -/** - * Reset the dirty flag after a successful sync - */ -export function resetDirtyFlag(): void { - isDirty = false + workflowsData[id] = safeWorkflow + }) + + isSyncing = true + + const payload: any = { + workflows: workflowsData, + } + + // Only include workspaceId if it exists (not null/undefined) + if (activeWorkspaceId) { + payload.workspaceId = activeWorkspaceId + } + + return payload + }, + method: 'POST' as const, + syncOnInterval: true, + syncOnExit: true, + onSyncSuccess: () => { + isSyncing = false + logger.info('Workflow sync completed successfully') + }, + onSyncError: (error: any) => { + isSyncing = false + logger.error('Workflow sync failed:', error) + }, } +// Create the sync manager without debouncing or complex initialization checks +export const workflowSync = createSingletonSyncManager('workflow-sync', () => workflowSyncConfig) + /** - * Fetches workflows from the database and updates the local stores - * This function handles backwards syncing on initialization + * Simplified function to fetch workflows from DB */ export async function fetchWorkflowsFromDB(): Promise { if (typeof window === 'undefined') return - try { - // Reset registry initialization state - resetRegistryInitialization() + // Prevent concurrent fetch operations + if (isFetching) { + logger.info('Fetch already in progress, skipping duplicate request') + return + } - // Set loading state in registry - useWorkflowRegistry.getState().setLoading(true) + const fetchStartTime = Date.now() + isFetching = true - // Set flag to prevent sync back to DB during loading - _isLoadingFromDB = true - loadingFromDBToken = 'loading' - loadingFromDBStartTime = Date.now() + try { + useWorkflowRegistry.getState().setLoading(true) - // Get active workspace ID to filter workflows const activeWorkspaceId = useWorkflowRegistry.getState().activeWorkspaceId - - // Call the API endpoint to get workflows from DB with workspace filter const url = new URL(API_ENDPOINTS.SYNC, window.location.origin) + if (activeWorkspaceId) { url.searchParams.append('workspaceId', activeWorkspaceId) - logger.info(`Fetching workflows for workspace: ${activeWorkspaceId}`) - } else { - logger.info('Fetching workflows without workspace filter') } - const response = await fetch(url.toString(), { - method: 'GET', - }) + 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}`) + } - // Handle case when workspace not found - if (response.status === 404) { - const responseData = await response.json() - if (responseData.code === 'WORKSPACE_NOT_FOUND' && activeWorkspaceId) { - logger.warn(`Workspace ${activeWorkspaceId} not found, it may have been deleted`) - - // Fetch user's available workspaces to switch to a valid one - const workspacesResponse = await fetch('/api/workspaces', { method: 'GET' }) - if (workspacesResponse.ok) { - const { workspaces } = await workspacesResponse.json() - - if (workspaces && workspaces.length > 0) { - // Switch to the first available workspace - const firstWorkspace = workspaces[0] - logger.info(`Switching to available workspace: ${firstWorkspace.id}`) - useWorkflowRegistry.getState().setActiveWorkspace(firstWorkspace.id) - return - } - } - } - } - - logger.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) || data.length === 0) { - logger.info( - `No workflows found in database for ${activeWorkspaceId ? `workspace ${activeWorkspaceId}` : 'user'}` - ) - // Clear any existing workflows to ensure a clean state - useWorkflowRegistry.setState({ workflows: {} }) + 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 + // Avoid overwriting existing workflows during race conditions + const currentWorkflows = useWorkflowRegistry.getState().workflows + const hasExistingWorkflows = Object.keys(currentWorkflows).length > 0 - // Mark registry as initialized even with empty data - setRegistryInitialized() + 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 and update stores + // Process workflows const registryWorkflows: Record = {} - // Process each workflow from the database data.forEach((workflow) => { const { id, @@ -230,87 +206,43 @@ export async function fetchWorkflowsFromDB(): Promise { description, color, state, - lastSynced, - isDeployed, - deployedAt, - apiKey, createdAt, marketplaceData, - workspaceId, // Extract workspaceId - folderId, // Extract folderId + workspaceId, + folderId, } = workflow - // Ensure this workflow belongs to the current workspace + // Skip if workflow doesn't belong to active workspace if (activeWorkspaceId && workspaceId !== activeWorkspaceId) { - logger.warn( - `Skipping workflow ${id} as it belongs to workspace ${workspaceId}, not the active workspace ${activeWorkspaceId}` - ) return } - // 1. Update registry store with workflow metadata + // Add to registry registryWorkflows[id] = { id, name, description: description || '', color: color || '#3972F6', - // Use createdAt for sorting if available, otherwise fall back to lastSynced - lastModified: createdAt ? new Date(createdAt) : new Date(lastSynced), - marketplaceData: marketplaceData || null, - workspaceId, // Include workspaceId in metadata - folderId: folderId || null, // Include folderId in metadata - } - - // 2. Prepare workflow state data - const workflowState = { - blocks: state.blocks || {}, - edges: state.edges || [], - loops: state.loops || {}, - parallels: state.parallels || {}, - isDeployed: isDeployed || false, - deployedAt: deployedAt ? new Date(deployedAt) : undefined, - apiKey, - lastSaved: Date.now(), + lastModified: createdAt ? new Date(createdAt) : new Date(), marketplaceData: marketplaceData || null, + workspaceId, + folderId: folderId || null, } - // 3. Initialize subblock values from the workflow state + // Initialize subblock values const subblockValues: Record> = {} - - // Extract subblock values from blocks - Object.entries(workflowState.blocks).forEach(([blockId, block]) => { - const blockState = block as BlockState - subblockValues[blockId] = {} - - Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = subblock.value - }) - }) - - // Get any additional subblock values that might not be in the state but are in the store - const storedValues = useSubBlockStore.getState().workflowValues[id] || {} - Object.entries(storedValues).forEach(([blockId, blockValues]) => { - if (!subblockValues[blockId]) { + if (state?.blocks) { + Object.entries(state.blocks).forEach(([blockId, block]) => { + const blockState = block as BlockState subblockValues[blockId] = {} - } - - Object.entries(blockValues).forEach(([subblockId, value]) => { - // Only update if not already set or if value is null - if ( - subblockValues[blockId][subblockId] === null || - subblockValues[blockId][subblockId] === undefined - ) { - subblockValues[blockId][subblockId] = value - } - }) - }) - // 4. Store the workflow state and subblock values in localStorage - // This ensures compatibility with existing code that loads from localStorage - localStorage.setItem(`workflow-${id}`, JSON.stringify(workflowState)) - localStorage.setItem(`subblock-values-${id}`, JSON.stringify(subblockValues)) + Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = subblock.value + }) + }) + } - // 5. Update subblock store for this workflow + // Update subblock store useSubBlockStore.setState((state) => ({ workflowValues: { ...state.workflowValues, @@ -319,199 +251,66 @@ export async function fetchWorkflowsFromDB(): Promise { })) }) - logger.info( - `Loaded ${Object.keys(registryWorkflows).length} workflows for ${activeWorkspaceId ? `workspace ${activeWorkspaceId}` : 'user'}` - ) - - // 8. Update registry store with all workflows - useWorkflowRegistry.setState({ workflows: registryWorkflows }) - - // Capture initial state for change detection - lastWorkflowState = getAllWorkflowsWithValues() + // Update registry with loaded workflows + useWorkflowRegistry.setState({ + workflows: registryWorkflows, + isLoading: false, + error: null, + }) - // 9. Set the first workflow as active if there's no active workflow - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId && Object.keys(registryWorkflows).length > 0) { + // Only set first workflow as active if no active workflow is set and we have workflows + // This prevents race conditions from overriding an already-set active workflow + const currentState = useWorkflowRegistry.getState() + if (!currentState.activeWorkflowId && Object.keys(registryWorkflows).length > 0) { const firstWorkflowId = Object.keys(registryWorkflows)[0] - - // Load the first workflow as active - const workflowState = JSON.parse(localStorage.getItem(`workflow-${firstWorkflowId}`) || '{}') - - if (Object.keys(workflowState).length > 0) { - useWorkflowStore.setState(workflowState) - useWorkflowRegistry.setState({ activeWorkflowId: firstWorkflowId }) - logger.info(`Set first workflow ${firstWorkflowId} as active`) - } + useWorkflowRegistry.setState({ activeWorkflowId: firstWorkflowId }) + logger.info(`Set first workflow as active: ${firstWorkflowId}`) } - // Mark registry as fully initialized now that all data is loaded - setRegistryInitialized() + logger.info( + `Successfully loaded ${Object.keys(registryWorkflows).length} workflows from database` + ) } catch (error) { - logger.error('Error fetching workflows from DB:', { error }) - - // Mark registry as initialized even on error to allow fallback mechanisms - setRegistryInitialized() + logger.error('Error fetching workflows from DB:', error) + useWorkflowRegistry.setState({ + isLoading: false, + error: `Failed to load workflows: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) + // Re-throw to allow caller to handle the error appropriately + throw error } finally { - // Reset the flag after a short delay to allow state to settle - setTimeout(() => { - _isLoadingFromDB = false - loadingFromDBToken = null - - // Set loading state to false - useWorkflowRegistry.getState().setLoading(false) - - // Verify if registry has workflows as a final check - const registryWorkflows = useWorkflowRegistry.getState().workflows - const workflowCount = Object.keys(registryWorkflows).length - logger.info(`DB loading complete. Workflows in registry: ${workflowCount}`) - - // Only trigger a final sync if necessary (don't do this for normal loads) - // This helps reduce unnecessary POST requests - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (workflowCount > 0 && activeWorkflowId && activeDBSyncNeeded()) { - // Small delay for state to fully settle before allowing syncs - setTimeout(() => { - isDirty = true // Explicitly mark as dirty for first sync - workflowSync.sync() - }, 500) - } - }, 1000) // Increased to 1 second for more reliable state settling + isFetching = false } } -// Helper to determine if an active DB sync is actually needed -function activeDBSyncNeeded(): boolean { - // In most cases after initial load, we don't need to sync back to DB - // Only sync if we have detected a change that needs to be persisted - const lastSynced = localStorage.getItem('last_db_sync_timestamp') - const currentTime = Date.now() - - if (!lastSynced) { - // First sync - record it and return true - localStorage.setItem('last_db_sync_timestamp', currentTime.toString()) - return true - } - - // Add additional checks here if needed for specific workflow changes - // For now, we'll simply avoid the automatic sync after load - return isDirty -} - -// Create the basic sync configuration -const workflowSyncConfig = { - endpoint: API_ENDPOINTS.SYNC, - preparePayload: () => { - if (typeof window === 'undefined') return {} - - // Skip sync if registry is not fully initialized yet - if (!isRegistryInitialized()) { - logger.info('Skipping workflow sync while registry is not fully initialized') - return { skipSync: true } - } - - // Skip sync if we're currently loading from DB to prevent overwriting DB data - if (isActivelyLoadingFromDB()) { - logger.info('Skipping workflow sync while loading from DB') - return { skipSync: true } - } - - // Get all workflows with values - const allWorkflowsData = getAllWorkflowsWithValues() - - // Only sync if there are actually changes - if (!isDirty && !hasWorkflowChanges(allWorkflowsData)) { - logger.info('Skipping workflow sync - no changes detected') - return { skipSync: true } - } - - // Reset dirty flag since we're about to sync - resetDirtyFlag() - - // Get the active workspace ID - const activeWorkspaceId = useWorkflowRegistry.getState().activeWorkspaceId +/** + * Fetch a single workflow state from the database + */ +export async function fetchWorkflowStateFromDB(workflowId: string): Promise { + try { + const response = await fetch(`/api/workflows/${workflowId}`, { method: 'GET' }) - // Skip sync if there are no workflows to sync - if (Object.keys(allWorkflowsData).length === 0) { - // Safety check: if registry has workflows but we're sending empty data, something is wrong - const registryWorkflows = useWorkflowRegistry.getState().workflows - if (Object.keys(registryWorkflows).length > 0) { - logger.warn( - 'Potential data loss prevented: Registry has workflows but sync payload is empty' - ) - return { skipSync: true } + if (!response.ok) { + if (response.status === 404) { + logger.warn(`Workflow ${workflowId} not found in database`) + return null } - - logger.info('Skipping workflow sync - no workflows to sync') - return { skipSync: true } + throw new Error(`Failed to fetch workflow: ${response.statusText}`) } - // Filter out any workflows associated with workspaces other than the active one - // This prevents foreign key constraint errors when a workspace has been deleted - const workflowsData: Record = {} - Object.entries(allWorkflowsData).forEach(([id, workflow]) => { - // Include workflows if: - // 1. They match the active workspace, OR - // 2. They don't have a workspace ID (legacy workflows) - if (workflow.workspaceId === activeWorkspaceId || !workflow.workspaceId) { - // For workflows without workspace ID, assign the active workspace ID - if (!workflow.workspaceId) { - workflow.workspaceId = activeWorkspaceId - logger.info(`Assigning workspace ${activeWorkspaceId} to orphaned workflow ${id}`) - } - workflowsData[id] = workflow - } else { - logger.warn( - `Skipping sync for workflow ${id} - associated with non-active workspace ${workflow.workspaceId}` - ) - } - }) - - // Skip sync if after filtering there are no workflows to sync - if (Object.keys(workflowsData).length === 0) { - logger.info('Skipping workflow sync - no workflows for active workspace to sync') - return { skipSync: true } - } - - // Always include the workspace ID in the payload for correct DB filtering - return { - workflows: workflowsData, - workspaceId: activeWorkspaceId, // Include active workspace ID in the payload - } - }, - method: 'POST' as const, - syncOnInterval: true, - syncOnExit: true, + const { data } = await response.json() + return data + } catch (error) { + logger.error(`Error fetching workflow ${workflowId} from DB:`, error) + return null + } } -// Create the sync manager -const baseWorkflowSync = createSingletonSyncManager('workflow-sync', () => workflowSyncConfig) - -// Create a debounced version of the sync manager -export const workflowSync = { - ...baseWorkflowSync, - sync: () => { - // Skip sync if not initialized - if (!isRegistryInitialized()) { - logger.info('Sync requested but registry not fully initialized yet - delaying') - // If we're not initialized, mark dirty and check again later - isDirty = true - return - } - - // Clear any existing timeout - if (syncDebounceTimer) { - clearTimeout(syncDebounceTimer) - } - - // Set new timeout - syncDebounceTimer = setTimeout(() => { - // Perform the sync - baseWorkflowSync.sync() - - // Update the last sync timestamp - if (typeof window !== 'undefined') { - localStorage.setItem('last_db_sync_timestamp', Date.now().toString()) - } - }, DEBOUNCE_DELAY) - }, +/** + * Mark workflows as dirty for sync + */ +export function markWorkflowsDirty(): void { + // Force a sync by clearing the last synced data hash + lastSyncedData = '' + logger.info('Workflows marked as dirty') } diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index eba99288519..8b417fbfdf9 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -3,8 +3,8 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { getBlock } from '@/blocks' import { resolveOutputType } from '@/blocks/utils' +import { isDataInitialized } from '../../index' import { pushHistory, type WorkflowStoreWithHistory, withHistory } from '../middleware' -import { saveWorkflowState } from '../persistence' import { useWorkflowRegistry } from '../registry/store' import { useSubBlockStore } from '../subblock/store' import { markWorkflowsDirty, workflowSync } from '../sync' @@ -47,31 +47,32 @@ const initialState = { // Create a consolidated sync control implementation /** - * The SyncControl implementation provides a clean, centralized way to handle workflow syncing. - * - * This pattern offers several advantages: - * 1. It encapsulates sync logic through a clear, standardized interface - * 2. It allows components to mark workflows as dirty without direct dependencies - * 3. It prevents race conditions by ensuring changes are properly tracked before syncing - * 4. It centralizes sync decisions to avoid redundant or conflicting operations - * - * Usage: - * - Call markDirty() when workflow state changes but sync can be deferred - * - Call forceSync() when an immediate sync to the server is needed - * - Use isDirty() to check if there are unsaved changes + * Simplified SyncControl implementation */ const createSyncControl = (): SyncControl => ({ markDirty: () => { + // Only mark dirty if data is initialized + if (!isDataInitialized()) { + return + } + // Simply mark workflows as dirty for sync markWorkflowsDirty() }, isDirty: () => { - // This calls into the sync module to check dirty status - // Actual implementation in sync.ts - return true // Always return true as the sync module will do the actual checking + // Always return true - let the sync system decide if sync is needed + return true }, forceSync: () => { - markWorkflowsDirty() // Always mark as dirty before forcing a sync - workflowSync.sync() + // Only force sync if data is initialized + if (!isDataInitialized()) { + return + } + // Force sync by marking dirty and syncing + markWorkflowsDirty() + // Small delay to ensure state has settled + setTimeout(() => { + workflowSync.sync() + }, 100) }, }) @@ -135,7 +136,7 @@ export const useWorkflowStore = create()( set(newState) pushHistory(set, get, newState, `Add ${type} node`) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() return } @@ -185,7 +186,6 @@ export const useWorkflowStore = create()( pushHistory(set, get, newState, `Add ${type} block`) get().updateLastSaved() get().sync.markDirty() - get().sync.forceSync() }, updateBlockPosition: (id: string, position: Position) => { @@ -200,8 +200,7 @@ export const useWorkflowStore = create()( edges: [...state.edges], })) get().updateLastSaved() - - // No sync here as this is a frequent operation during dragging + // No sync for position updates to avoid excessive syncing during drag }, updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { @@ -220,7 +219,7 @@ export const useWorkflowStore = create()( edges: [...state.edges], })) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() }, updateParentId: (id: string, parentId: string, extent: 'parent') => { @@ -291,7 +290,8 @@ export const useWorkflowStore = create()( parentId ? `Set parent for ${block.name}` : `Remove parent for ${block.name}` ) get().updateLastSaved() - workflowSync.sync() + get().sync.markDirty() + get().sync.forceSync() }, removeBlock: (id: string) => { @@ -363,7 +363,6 @@ export const useWorkflowStore = create()( pushHistory(set, get, newState, 'Remove block and children') get().updateLastSaved() get().sync.markDirty() - get().sync.forceSync() }, addEdge: (edge: Edge) => { @@ -391,7 +390,6 @@ export const useWorkflowStore = create()( const newEdges = [...get().edges, newEdge] - // Use the new loop generation approach const newState = { blocks: { ...get().blocks }, edges: newEdges, @@ -403,7 +401,6 @@ export const useWorkflowStore = create()( pushHistory(set, get, newState, 'Add connection') get().updateLastSaved() get().sync.markDirty() - get().sync.forceSync() }, removeEdge: (edgeId: string) => { @@ -416,7 +413,6 @@ export const useWorkflowStore = create()( const newEdges = get().edges.filter((edge) => edge.id !== edgeId) - // Use the new loop generation approach instead of cycle detection const newState = { blocks: { ...get().blocks }, edges: newEdges, @@ -428,7 +424,6 @@ export const useWorkflowStore = create()( pushHistory(set, get, newState, 'Remove connection') get().updateLastSaved() get().sync.markDirty() - get().sync.forceSync() }, clear: () => { @@ -436,6 +431,7 @@ export const useWorkflowStore = create()( blocks: {}, edges: [], loops: {}, + parallels: {}, history: { past: [], present: { @@ -461,38 +457,12 @@ export const useWorkflowStore = create()( } set(newState) get().sync.markDirty() - get().sync.forceSync() - return newState }, updateLastSaved: () => { set({ lastSaved: Date.now() }) - - // Save current state to localStorage - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (activeWorkflowId) { - const currentState = get() - const generatedLoops = currentState.generateLoopBlocks() - const generatedParallels = currentState.generateParallelBlocks() - saveWorkflowState(activeWorkflowId, { - blocks: currentState.blocks, - edges: currentState.edges, - loops: generatedLoops, - parallels: generatedParallels, - history: currentState.history, - // Include both legacy and new deployment status fields - isDeployed: currentState.isDeployed, - deployedAt: currentState.deployedAt, - deploymentStatuses: currentState.deploymentStatuses, - lastSaved: Date.now(), - }) - - // Note: Scheduling changes are automatically handled by the workflowSync - // When the workflow is synced to the database, the sync system checks if - // the starter block has scheduling enabled and updates or cancels the - // schedule accordingly. - } + // Note: Scheduling changes are automatically handled by the workflowSync }, toggleBlockEnabled: (id: string) => { @@ -506,12 +476,12 @@ export const useWorkflowStore = create()( }, edges: [...get().edges], loops: { ...get().loops }, + parallels: { ...get().parallels }, } set(newState) get().updateLastSaved() get().sync.markDirty() - get().sync.forceSync() }, duplicateBlock: (id: string) => {