diff --git a/app/api/workflows/sync/route.ts b/app/api/workflows/sync/route.ts
new file mode 100644
index 000000000..323afca10
--- /dev/null
+++ b/app/api/workflows/sync/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from 'next/server'
+import { eq } from 'drizzle-orm'
+import { z } from 'zod'
+import { getSession } from '@/lib/auth'
+import { db } from '@/db'
+import { workflow } from '@/db/schema'
+
+// Define the schema for incoming data
+const WorkflowSyncSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string().optional(),
+ state: z.string(), // JSON stringified workflow state
+})
+
+export async function POST(request: Request) {
+ try {
+ // Get the authenticated user
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // Parse and validate the request body
+ const body = await request.json()
+ const { id, name, description, state } = WorkflowSyncSchema.parse(body)
+
+ // Get the current timestamp
+ const now = new Date()
+
+ // Upsert the workflow
+ await db
+ .insert(workflow)
+ .values({
+ id,
+ userId: session.user.id,
+ name,
+ description,
+ state,
+ lastSynced: now,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .onConflictDoUpdate({
+ target: [workflow.id],
+ set: {
+ name,
+ description,
+ state,
+ lastSynced: now,
+ updatedAt: now,
+ },
+ where: eq(workflow.userId, session.user.id), // Only update if the workflow belongs to the user
+ })
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Workflow sync error:', error)
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid request data', details: error.errors },
+ { status: 400 }
+ )
+ }
+ return NextResponse.json({ error: 'Sync failed' }, { status: 500 })
+ }
+}
diff --git a/app/w/[id]/workflow.tsx b/app/w/[id]/workflow.tsx
index 224a2534e..5944c2ad0 100644
--- a/app/w/[id]/workflow.tsx
+++ b/app/w/[id]/workflow.tsx
@@ -19,6 +19,7 @@ import { initializeStateLogger } from '@/stores/workflow/logger'
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
import { useWorkflowStore } from '@/stores/workflow/store'
import { NotificationList } from '@/app/w/components/notifications/notifications'
+import { WorkflowSyncWrapper } from '@/app/w/components/workflows/sync-wrapper'
import { getBlock } from '../../../blocks'
import { ErrorBoundary } from '../components/error-boundary/error-boundary'
import { CustomEdge } from './components/custom-edge/custom-edge'
@@ -378,7 +379,9 @@ export default function Workflow() {
return (
-
+
+
+
)
diff --git a/app/w/components/workflows/sync-wrapper.tsx b/app/w/components/workflows/sync-wrapper.tsx
new file mode 100644
index 000000000..0428922e5
--- /dev/null
+++ b/app/w/components/workflows/sync-wrapper.tsx
@@ -0,0 +1,18 @@
+import { ReactNode } from 'react'
+import {
+ useDebouncedWorkflowSync,
+ usePeriodicWorkflowSync,
+ useSyncOnUnload,
+} from '@/stores/workflow/sync/hooks'
+
+interface WorkflowSyncWrapperProps {
+ children: ReactNode
+}
+
+export function WorkflowSyncWrapper({ children }: WorkflowSyncWrapperProps) {
+ useDebouncedWorkflowSync()
+ usePeriodicWorkflowSync()
+ useSyncOnUnload()
+
+ return <>{children}>
+}
diff --git a/package-lock.json b/package-lock.json
index df967315c..0e1a21d1a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,12 +21,14 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.6",
+ "@types/lodash.debounce": "^4.0.9",
"better-auth": "^1.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.39.3",
+ "lodash.debounce": "^4.0.8",
"lucide-react": "^0.469.0",
"next": "15.1.3",
"openai": "^4.83.0",
@@ -4512,6 +4514,21 @@
"pretty-format": "^29.0.0"
}
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.15",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz",
+ "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash.debounce": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz",
+ "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.17.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.11.tgz",
@@ -8513,6 +8530,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
diff --git a/package.json b/package.json
index 291f34c79..4b3f5ef1a 100644
--- a/package.json
+++ b/package.json
@@ -29,12 +29,14 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.6",
+ "@types/lodash.debounce": "^4.0.9",
"better-auth": "^1.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.39.3",
+ "lodash.debounce": "^4.0.8",
"lucide-react": "^0.469.0",
"next": "15.1.3",
"openai": "^4.83.0",
diff --git a/stores/workflow/sync/hooks.ts b/stores/workflow/sync/hooks.ts
new file mode 100644
index 000000000..101cf06b9
--- /dev/null
+++ b/stores/workflow/sync/hooks.ts
@@ -0,0 +1,143 @@
+import { useCallback, useEffect, useRef } from 'react'
+import debounce from 'lodash.debounce'
+import { useWorkflowRegistry } from '../registry/store'
+import { useWorkflowStore } from '../store'
+
+const SYNC_DEBOUNCE_MS = 2000 // 2 seconds
+const PERIODIC_SYNC_MS = 30000 // 30 seconds
+
+interface SyncPayload {
+ id: string
+ name: string
+ description?: string
+ state: string
+}
+
+async function syncWorkflowToServer(payload: SyncPayload): Promise {
+ try {
+ const response = await fetch('/api/workflows/sync', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Sync failed: ${response.statusText}`)
+ }
+
+ return true
+ } catch (error) {
+ console.error('Error syncing workflow:', error)
+ return false
+ }
+}
+
+export function useDebouncedWorkflowSync() {
+ const workflowState = useWorkflowStore((state) => ({
+ blocks: state.blocks,
+ edges: state.edges,
+ loops: state.loops,
+ lastSaved: state.lastSaved,
+ }))
+ const { activeWorkflowId, workflows } = useWorkflowRegistry()
+
+ const debouncedSyncRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (!activeWorkflowId || !workflows[activeWorkflowId]) return
+
+ const syncWorkflow = async () => {
+ const activeWorkflow = workflows[activeWorkflowId]
+ const payload: SyncPayload = {
+ id: activeWorkflowId,
+ name: activeWorkflow.name,
+ description: activeWorkflow.description,
+ state: JSON.stringify(workflowState),
+ }
+
+ await syncWorkflowToServer(payload)
+ }
+
+ // Create a debounced version of syncWorkflow
+ if (!debouncedSyncRef.current) {
+ debouncedSyncRef.current = debounce(syncWorkflow, SYNC_DEBOUNCE_MS)
+ }
+
+ // Call the debounced sync
+ debouncedSyncRef.current()
+
+ // Cleanup
+ return () => {
+ debouncedSyncRef.current?.cancel()
+ }
+ }, [activeWorkflowId, workflows, workflowState])
+}
+
+export function usePeriodicWorkflowSync() {
+ const workflowState = useWorkflowStore((state) => ({
+ blocks: state.blocks,
+ edges: state.edges,
+ loops: state.loops,
+ lastSaved: state.lastSaved,
+ }))
+ const { activeWorkflowId, workflows } = useWorkflowRegistry()
+
+ useEffect(() => {
+ if (!activeWorkflowId || !workflows[activeWorkflowId]) return
+
+ const syncWorkflow = async () => {
+ const activeWorkflow = workflows[activeWorkflowId]
+ const payload: SyncPayload = {
+ id: activeWorkflowId,
+ name: activeWorkflow.name,
+ description: activeWorkflow.description,
+ state: JSON.stringify(workflowState),
+ }
+
+ await syncWorkflowToServer(payload)
+ }
+
+ const intervalId = setInterval(syncWorkflow, PERIODIC_SYNC_MS)
+
+ return () => clearInterval(intervalId)
+ }, [activeWorkflowId, workflows, workflowState])
+}
+
+export function useSyncOnUnload() {
+ const workflowState = useWorkflowStore((state) => ({
+ blocks: state.blocks,
+ edges: state.edges,
+ loops: state.loops,
+ lastSaved: state.lastSaved,
+ }))
+ const { activeWorkflowId, workflows } = useWorkflowRegistry()
+
+ useEffect(() => {
+ if (!activeWorkflowId || !workflows[activeWorkflowId]) return
+
+ const handleBeforeUnload = async (event: BeforeUnloadEvent) => {
+ const activeWorkflow = workflows[activeWorkflowId]
+ const payload: SyncPayload = {
+ id: activeWorkflowId,
+ name: activeWorkflow.name,
+ description: activeWorkflow.description,
+ state: JSON.stringify(workflowState),
+ }
+
+ // Use the keepalive option to try to complete the request even during unload
+ await fetch('/api/workflows/sync', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ keepalive: true,
+ })
+
+ // Show a confirmation dialog
+ event.preventDefault()
+ event.returnValue = ''
+ }
+
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [activeWorkflowId, workflows, workflowState])
+}