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) => {