Feat/db sync (#94)

* feat(db-sync): added general sync file and implemented environment sync

* improvement(workflows-store): structured workflows store system better and added getter for values across stores

* fix(stores): deleted workflows/types since unused

* improvement(db-sync): added workflow event syncs and debounce

* improvement(db-sync): clean and upgraded db-sync system; environment sync implemented

* improvement(db-sync): added batch sync with registry; init bug needs fixing

* improvement(db-sync): finalized sync system and implemented for workflow

* fix(db-sync): fixed client-side rendering

* improvement(db-sync): created backwards sync system; environment implemented

* improvement(db-sync): added colors to db

* fix(db-sync): color sync with db

* improvement(db-sync): added workflow backwards sync; fixing color bug and race condition

* fix(db-stores): color sync

* feature(db-sync): db-sync complete; need to sync history

* improvement(db-sync): added scheduling

* fix(db-sync): environment sync to db
This commit is contained in:
Emir Karabeg
2025-03-03 19:43:39 -08:00
committed by GitHub
parent 7b5168fdd6
commit 76dbc4a52f
56 changed files with 2448 additions and 667 deletions
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/utils'
import { decryptSecret, encryptSecret } from '@/lib/utils'
import { EnvironmentVariable } from '@/stores/settings/environment/types'
import { db } from '@/db'
import { environment } from '@/db/schema'
@@ -22,14 +22,17 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { variables } = EnvVarSchema.parse(body)
// Encrypt each environment variable value
const encryptedVariables: Record<string, string> = {}
for (const [key, value] of Object.entries(variables)) {
const { encrypted } = await encryptSecret(value)
encryptedVariables[key] = encrypted
}
// Encrypt all variables
const encryptedVariables = await Object.entries(variables).reduce(
async (accPromise, [key, value]) => {
const acc = await accPromise
const { encrypted } = await encryptSecret(value)
return { ...acc, [key]: encrypted }
},
Promise.resolve({})
)
// Upsert the environment variables
// Replace all environment variables for user
await db
.insert(environment)
.values({
@@ -61,13 +64,14 @@ export async function POST(req: NextRequest) {
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
// Get the session directly in the API route
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const result = await db
.select()
.from(environment)
@@ -78,13 +82,23 @@ export async function GET(request: Request) {
return NextResponse.json({ data: {} }, { status: 200 })
}
// Update the type handling for variables
const variables = result[0].variables as Record<string, EnvironmentVariable>
const sanitizedVariables = Object.fromEntries(
Object.entries(variables).map(([key, value]) => [key, { key, value: '••••••••' }])
)
// Decrypt the variables for client-side use
const encryptedVariables = result[0].variables as Record<string, string>
const decryptedVariables: Record<string, EnvironmentVariable> = {}
return NextResponse.json({ data: sanitizedVariables }, { status: 200 })
// Decrypt each variable
for (const [key, encryptedValue] of Object.entries(encryptedVariables)) {
try {
const { decrypted } = await decryptSecret(encryptedValue)
decryptedVariables[key] = { key, value: decrypted }
} catch (error) {
console.error(`Error decrypting variable ${key}:`, error)
// If decryption fails, provide a placeholder
decryptedVariables[key] = { key, value: '' }
}
}
return NextResponse.json({ data: decryptedVariables }, { status: 200 })
} catch (error: any) {
console.error('Environment fetch error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
-83
View File
@@ -1,83 +0,0 @@
import { NextResponse } from 'next/server'
import { eq, sql } 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 a single workflow
const WorkflowSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
state: z.record(z.any()),
})
// Define the schema for batch sync
const BatchSyncSchema = z.object({
workflows: z.array(WorkflowSchema),
deletedWorkflowIds: z.array(z.string()).optional(),
})
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { workflows, deletedWorkflowIds } = BatchSyncSchema.parse(body)
const now = new Date()
// Process all operations in a single transaction
await db.transaction(async (tx) => {
// Handle deletions first
if (deletedWorkflowIds?.length) {
await tx
.delete(workflow)
.where(
sql`${workflow.id} IN ${deletedWorkflowIds} AND ${workflow.userId} = ${session.user.id}`
)
}
// Handle updates/inserts
for (const workflowData of workflows) {
await tx
.insert(workflow)
.values({
id: workflowData.id,
userId: session.user.id,
name: workflowData.name,
description: workflowData.description,
state: workflowData.state,
lastSynced: now,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [workflow.id],
set: {
name: workflowData.name,
description: workflowData.description,
state: workflowData.state,
lastSynced: now,
updatedAt: now,
},
where: eq(workflow.userId, session.user.id),
})
}
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Batch sync error:', error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
return NextResponse.json({ error: 'Batch sync failed' }, { status: 500 })
}
}
+138
View File
@@ -0,0 +1,138 @@
import { NextRequest, 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'
// Schema for workflow data
const WorkflowStateSchema = z.object({
blocks: z.record(z.any()),
edges: z.array(z.any()),
loops: z.record(z.any()),
lastSaved: z.number().optional(),
isDeployed: z.boolean().optional(),
deployedAt: z.date().optional(),
})
const WorkflowSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
color: z.string().optional(),
state: WorkflowStateSchema,
})
const SyncPayloadSchema = z.object({
workflows: z.record(z.string(), WorkflowSchema),
})
export async function GET(request: Request) {
try {
// Get the session directly in the API route
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Fetch all workflows for the user
const workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
// Return the workflows
return NextResponse.json({ data: workflows }, { status: 200 })
} catch (error: any) {
console.error('Workflow fetch error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { workflows: clientWorkflows } = SyncPayloadSchema.parse(body)
// Get all workflows for the user from the database
const dbWorkflows = await db.select().from(workflow).where(eq(workflow.userId, session.user.id))
const now = new Date()
const operations: Promise<any>[] = []
// Create a map of DB workflows for easier lookup
const dbWorkflowMap = new Map(dbWorkflows.map((w) => [w.id, w]))
const processedIds = new Set<string>()
// Process client workflows
for (const [id, clientWorkflow] of Object.entries(clientWorkflows)) {
processedIds.add(id)
const dbWorkflow = dbWorkflowMap.get(id)
if (!dbWorkflow) {
// New workflow - create
operations.push(
db.insert(workflow).values({
id: clientWorkflow.id,
userId: session.user.id,
name: clientWorkflow.name,
description: clientWorkflow.description,
color: clientWorkflow.color,
state: clientWorkflow.state,
lastSynced: now,
createdAt: now,
updatedAt: now,
})
)
} else {
// 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
if (needsUpdate) {
operations.push(
db
.update(workflow)
.set({
name: clientWorkflow.name,
description: clientWorkflow.description,
color: clientWorkflow.color,
state: clientWorkflow.state,
lastSynced: now,
updatedAt: now,
})
.where(eq(workflow.id, id))
)
}
}
}
// Handle deletions - workflows in DB but not in client
for (const dbWorkflow of dbWorkflows) {
if (!processedIds.has(dbWorkflow.id)) {
operations.push(db.delete(workflow).where(eq(workflow.id, dbWorkflow.id)))
}
}
// Execute all operations in parallel
await Promise.all(operations)
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: 'Workflow sync failed' }, { status: 500 })
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { persistLog } from '@/lib/logging'
import { decryptSecret } from '@/lib/utils'
import { BlockState, WorkflowState } from '@/stores/workflow/types'
import { mergeSubblockState } from '@/stores/workflow/utils'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { db } from '@/db'
import { environment, workflow, workflowSchedule } from '@/db/schema'
import { Executor } from '@/executor'
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { BlockState } from '@/stores/workflow/types'
import { BlockState } from '@/stores/workflows/workflow/types'
import { db } from '@/db'
import { workflow, workflowSchedule } from '@/db/schema'
+2 -2
View File
@@ -4,8 +4,8 @@ import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { persistLog } from '@/lib/logging'
import { decryptSecret } from '@/lib/utils'
import { WorkflowState } from '@/stores/workflow/types'
import { mergeSubblockState } from '@/stores/workflow/utils'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { WorkflowState } from '@/stores/workflows/workflow/types'
import { db } from '@/db'
import { environment } from '@/db/schema'
import { Executor } from '@/executor'
@@ -2,7 +2,7 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, Play, Trash2 } fr
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface ActionBarProps {
blockId: string
@@ -11,7 +11,7 @@ import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-drop
import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
interface ConditionalBlock {
@@ -4,7 +4,7 @@ import { formatDisplayText } from '@/components/ui/formatted-text'
import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { SubBlockConfig } from '@/blocks/types'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
@@ -20,7 +20,7 @@ import {
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { getAllBlocks } from '@/blocks'
import { getTool } from '@/tools'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useSubBlockStore } from '@/stores/workflow/subblock/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
export function useSubBlockValue<T = any>(
blockId: string,
@@ -7,9 +7,9 @@ import { Card } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
import { useWorkflowStore } from '@/stores/workflow/store'
import { mergeSubblockState } from '@/stores/workflow/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { ActionBar } from './components/action-bar/action-bar'
import { ConnectionBlocks } from './components/connection-blocks/connection-blocks'
@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
export function LoopInput({ id }: NodeProps) {
// Extract the loop ID from the node ID
@@ -1,5 +1,5 @@
import { useWorkflowStore } from '@/stores/workflow/store'
import { Loop } from '@/stores/workflow/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { Loop } from '@/stores/workflows/workflow/types'
interface WorkflowLoopProps {
loopId: string
+15 -12
View File
@@ -14,9 +14,9 @@ import ReactFlow, {
import 'reactflow/dist/style.css'
import { useNotificationStore } from '@/stores/notifications/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { initializeStateLogger } from '@/stores/workflow/logger'
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
import { useWorkflowStore } from '@/stores/workflow/store'
import { getSyncManagers, initializeSyncManagers, isSyncInitialized } from '@/stores/sync-registry'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { NotificationList } from '@/app/w/components/notifications/notifications'
import { getBlock } from '../../../blocks'
import { ErrorBoundary } from '../components/error-boundary/error-boundary'
@@ -53,11 +53,19 @@ function WorkflowContent() {
// Initialize workflow
useEffect(() => {
if (typeof window !== 'undefined') {
const savedRegistry = localStorage.getItem('workflow-registry')
if (savedRegistry) {
useWorkflowRegistry.setState({ workflows: JSON.parse(savedRegistry) })
// Ensure sync system is initialized before proceeding
const initSync = async () => {
// Initialize sync system if not already initialized
await initializeSyncManagers()
setIsInitialized(true)
}
// Check if already initialized
if (isSyncInitialized()) {
setIsInitialized(true)
} else {
initSync()
}
setIsInitialized(true)
}
}, [])
@@ -299,11 +307,6 @@ function WorkflowContent() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedEdgeId, removeEdge])
// Initialize state logging
// useEffect(() => {
// initializeStateLogger()
// }, [])
if (!isInitialized) return null
return (
+1 -1
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useConsoleStore } from '@/stores/console/store'
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { ConsoleEntry } from './components/console-entry/console-entry'
export function Console() {
+2 -5
View File
@@ -25,9 +25,8 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import { performSync } from '@/stores/sync-manager'
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
import { HistoryDropdownItem } from './components/history-dropdown-item'
import { NotificationDropdownItem } from './components/notification-dropdown-item'
@@ -207,8 +206,6 @@ export function ControlBar() {
try {
setIsDeploying(true)
await performSync()
const response = await fetch(`/api/workflow/${activeWorkflowId}/deploy`, {
method: 'POST',
})
@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import { Notification, NotificationType } from '@/stores/notifications/types'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
// Constants
const NOTIFICATION_TIMEOUT = 4000
@@ -17,7 +17,10 @@ import { Label } from '@/components/ui/label'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types'
// Extend the store type with our UI-specific fields
// Constants
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4'
const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' }
interface UIEnvironmentVariable extends StoreEnvironmentVariable {
id?: number
}
@@ -26,19 +29,21 @@ interface EnvironmentVariablesProps {
onOpenChange: (open: boolean) => void
}
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4'
const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' }
export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps) {
const { variables, setVariable, removeVariable } = useEnvironmentStore()
// Store access
const { variables } = useEnvironmentStore()
// State
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
// Refs
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingClose = useRef(false)
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
// Check if there are unsaved changes by comparing with initial state
// Derived state
const hasChanges = useMemo(() => {
const initialVars = initialVarsRef.current.filter((v) => v.key || v.value)
const currentVars = envVars.filter((v) => v.key || v.value)
@@ -60,7 +65,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
return false
}, [envVars])
// Initialize environment variables
// Initialization effect
useEffect(() => {
const existingVars = Object.values(variables)
const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR]
@@ -69,33 +74,34 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
pendingClose.current = false
}, [variables])
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(false)
}
}
const handleCancel = () => {
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
setShowUnsavedChanges(false)
if (pendingClose.current) {
onOpenChange(false)
}
}
// Scroll effect
useEffect(() => {
if (scrollContainerRef.current) {
// Smooth scroll to bottom when new variables are added
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
}
}, [envVars.length]) // Only trigger on length changes
}, [envVars.length])
// Variable management functions
const addEnvVar = () => {
const newVar = { key: '', value: '', id: Date.now() }
setEnvVars([...envVars, newVar])
}
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
const removeEnvVar = (index: number) => {
const newEnvVars = envVars.filter((_, i) => i !== index)
setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR])
}
// Input event handlers
const handleValueFocus = (index: number, e: React.FocusEvent<HTMLInputElement>) => {
setFocusedValueIndex(index)
e.target.scrollLeft = 0
@@ -153,45 +159,49 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
}
}
const addEnvVar = () => {
const newVar = { key: '', value: '', id: Date.now() }
setEnvVars([...envVars, newVar])
}
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
const removeEnvVar = (index: number) => {
const newEnvVars = envVars.filter((_, i) => i !== index)
setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR])
}
const handleSave = async () => {
try {
const validVars = envVars.filter((v) => v.key && v.value)
validVars.forEach((v) => setVariable(v.key, v.value))
const currentKeys = new Set(validVars.map((v) => v.key))
Object.keys(variables).forEach((key) => {
if (!currentKeys.has(key)) {
removeVariable(key)
}
})
// Sync with database
await useEnvironmentStore.getState().syncWithDatabase()
setShowUnsavedChanges(false)
// Dialog management
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(false)
} catch (error) {
console.error('Failed to save environment variables:', error)
// You might want to show an error notification here
}
}
const handleCancel = () => {
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
setShowUnsavedChanges(false)
if (pendingClose.current) {
onOpenChange(false)
}
}
const handleSave = () => {
try {
// Close modal immediately for optimistic updates
setShowUnsavedChanges(false)
onOpenChange(false)
// Convert valid env vars to Record<string, string>
const validVariables = envVars
.filter((v) => v.key && v.value)
.reduce(
(acc, { key, value }) => ({
...acc,
[key]: value,
}),
{}
)
// Single store update that triggers sync
useEnvironmentStore.getState().setVariables(validVariables)
} catch (error) {
console.error('Failed to save environment variables:', error)
}
}
// UI rendering
const renderEnvVarRow = (envVar: UIEnvironmentVariable, index: number) => (
<div key={envVar.id || index} className={`${GRID_COLS} items-center`}>
<Input
+20 -3
View File
@@ -1,13 +1,13 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Plus, Settings } from 'lucide-react'
import { AgentIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { NavItem } from './components/nav-item/nav-item'
import { SettingsModal } from './components/settings-modal/settings-modal'
@@ -16,6 +16,23 @@ export function Sidebar() {
const router = useRouter()
const [showSettings, setShowSettings] = useState(false)
// Sort workflows by lastModified date (which corresponds to createdAt for new workflows)
// Newest workflows at the bottom (ascending order by date)
const sortedWorkflows = useMemo(() => {
return Object.values(workflows).sort((a, b) => {
// Ensure we're comparing dates properly by converting to timestamps
const dateA =
a.lastModified instanceof Date
? a.lastModified.getTime()
: new Date(a.lastModified).getTime()
const dateB =
b.lastModified instanceof Date
? b.lastModified.getTime()
: new Date(b.lastModified).getTime()
return dateA - dateB // Ascending order (oldest first, newest last)
})
}, [workflows])
const handleCreateWorkflow = () => {
const id = createWorkflow()
router.push(`/w/${id}`)
@@ -50,7 +67,7 @@ export function Sidebar() {
{/* Scrollable workflows section */}
<nav className="flex-1 overflow-y-auto px-2 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<div className="flex flex-col items-center gap-4">
{Object.values(workflows).map((workflow) => (
{sortedWorkflows.map((workflow) => (
<NavItem key={workflow.id} href={`/w/${workflow.id}`} label={workflow.name}>
<div
className="h-4 w-4 rounded-full"
+2 -2
View File
@@ -1,6 +1,6 @@
import { shallow } from 'zustand/shallow'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useSubBlockStore } from '@/stores/workflow/subblock/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface Field {
name: string
+4 -4
View File
@@ -4,10 +4,10 @@ import { useConsoleStore } from '@/stores/console/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useNotificationStore } from '@/stores/notifications/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useSubBlockStore } from '@/stores/workflow/subblock/store'
import { mergeSubblockState } from '@/stores/workflow/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { Executor } from '@/executor'
import { ExecutionResult } from '@/executor/types'
import { Serializer } from '@/serializer'
+1 -1
View File
@@ -1,4 +1,4 @@
import { SubBlockState } from '@/stores/workflow/types'
import { SubBlockState } from '@/stores/workflows/workflow/types'
import { BlockOutput, OutputConfig } from '@/blocks/types'
interface CodeLine {
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflow/store'
import { useSubBlockStore } from '@/stores/workflow/subblock/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface Field {
name: string
@@ -0,0 +1 @@
ALTER TABLE "workflow" ADD COLUMN "color" text DEFAULT '#3972F6' NOT NULL;
+715
View File
@@ -0,0 +1,715 @@
{
"id": "00b2ec4a-e695-4ad1-8ca6-38ff473f869a",
"prevId": "87a9f389-6dcc-441d-8000-a447c9c25522",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.environment": {
"name": "environment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"variables": {
"name": "variables",
"type": "json",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"environment_user_id_user_id_fk": {
"name": "environment_user_id_user_id_fk",
"tableFrom": "environment",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"environment_user_id_unique": {
"name": "environment_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"general": {
"name": "general",
"type": "json",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"settings_user_id_user_id_fk": {
"name": "settings_user_id_user_id_fk",
"tableFrom": "settings",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"settings_user_id_unique": {
"name": "settings_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow": {
"name": "workflow",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'#3972F6'"
},
"last_synced": {
"name": "last_synced",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"is_deployed": {
"name": "is_deployed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"deployed_at": {
"name": "deployed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"workflow_user_id_user_id_fk": {
"name": "workflow_user_id_user_id_fk",
"tableFrom": "workflow",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow_logs": {
"name": "workflow_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"execution_id": {
"name": "execution_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"level": {
"name": "level",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "text",
"primaryKey": false,
"notNull": false
},
"trigger": {
"name": "trigger",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"workflow_logs_workflow_id_workflow_id_fk": {
"name": "workflow_logs_workflow_id_workflow_id_fk",
"tableFrom": "workflow_logs",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow_schedule": {
"name": "workflow_schedule",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": false
},
"next_run_at": {
"name": "next_run_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_ran_at": {
"name": "last_ran_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"trigger_type": {
"name": "trigger_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"workflow_schedule_workflow_id_workflow_id_fk": {
"name": "workflow_schedule_workflow_id_workflow_id_fk",
"tableFrom": "workflow_schedule",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"workflow_schedule_workflow_id_unique": {
"name": "workflow_schedule_workflow_id_unique",
"nullsNotDistinct": false,
"columns": ["workflow_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -85,6 +85,13 @@
"when": 1740340299261,
"tag": "0011_youthful_iron_lad",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1741040211301,
"tag": "0012_minor_dexter_bennett",
"breakpoints": true
}
]
}
+1
View File
@@ -58,6 +58,7 @@ export const workflow = pgTable('workflow', {
name: text('name').notNull(),
description: text('description'),
state: json('state').notNull(),
color: text('color').notNull().default('#3972F6'),
lastSynced: timestamp('last_synced').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
devIndicators: false,
images: {
domains: ['avatars.githubusercontent.com'],
},
+1 -1
View File
@@ -1,5 +1,5 @@
import { Edge } from 'reactflow'
import { BlockState, Loop, SubBlockState } from '@/stores/workflow/types'
import { BlockState, Loop, SubBlockState } from '@/stores/workflows/workflow/types'
import { getBlock } from '@/blocks'
import { SerializedBlock, SerializedConnection, SerializedWorkflow } from './types'
+1 -1
View File
@@ -1,4 +1,4 @@
import { Position } from '@/stores/workflow/types'
import { Position } from '@/stores/workflows/workflow/types'
import { BlockOutput, ParamType } from '@/blocks/types'
export interface SerializedWorkflow {
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { useEnvironmentStore } from '../settings/environment/store'
import { useWorkflowStore } from '../workflow/store'
import { useWorkflowStore } from '../workflows/workflow/store'
import { ChatMessage, ChatStore } from './types'
import { calculateBlockPosition, getNextBlockNumber } from './utils'
+15
View File
@@ -0,0 +1,15 @@
export const STORAGE_KEYS = {
REGISTRY: 'workflow-registry',
WORKFLOW: (id: string) => `workflow-${id}`,
SUBBLOCK: (id: string) => `subblock-values-${id}`,
}
export const API_ENDPOINTS = {
WORKFLOW: '/api/db/workflow',
ENVIRONMENT: '/api/db/environment',
SCHEDULE: '/api/scheduled/schedule',
}
export const SYNC_INTERVALS = {
DEFAULT: 30000, // 30 seconds
}
+177 -30
View File
@@ -1,53 +1,208 @@
import { useEffect } from 'react'
import { useChatStore } from './chat/store'
import { useConsoleStore } from './console/store'
import { useCustomToolsStore } from './custom-tools/store'
import { useExecutionStore } from './execution/store'
import { useNotificationStore } from './notifications/store'
import { useEnvironmentStore } from './settings/environment/store'
import { useGeneralStore } from './settings/general/store'
import { addDeletedWorkflow, initializeSyncManager } from './sync-manager'
import { useWorkflowRegistry } from './workflow/registry/store'
import { useWorkflowStore } from './workflow/store'
import { getSyncManagers, initializeSyncManagers } from './sync-registry'
import {
loadRegistry,
loadSubblockValues,
loadWorkflowState,
saveSubblockValues,
saveWorkflowState,
} from './workflows/persistence'
import { useWorkflowRegistry } from './workflows/registry/store'
import { useSubBlockStore } from './workflows/subblock/store'
import { useWorkflowStore } from './workflows/workflow/store'
// Initialize sync manager when the store is first imported
if (typeof window !== 'undefined') {
initializeSyncManager()
// Track initialization state
let isInitializing = false
/**
* 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.
*/
async function initializeApplication(): Promise<void> {
if (typeof window === 'undefined' || isInitializing) return
isInitializing = true
try {
// Initialize sync system first and fetch data from DB
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()
if (Object.keys(registryState.workflows).length === 0) {
// No workflows loaded from DB, try localStorage as fallback
const workflows = loadRegistry()
if (workflows && Object.keys(workflows).length > 0) {
console.log('Loading workflows from localStorage as fallback')
useWorkflowRegistry.setState({ workflows })
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
initializeWorkflowState(activeWorkflowId)
}
}
} else {
console.log('Using workflows loaded from DB, ignoring localStorage')
}
// 2. Register cleanup
window.addEventListener('beforeunload', handleBeforeUnload)
} catch (error) {
console.error('Error during application initialization:', error)
} finally {
isInitializing = false
}
}
// Reset all application stores to their initial state
export const resetAllStores = () => {
// Track all workflow IDs for deletion before clearing
if (typeof window !== 'undefined') {
const workflowRegistry = useWorkflowRegistry.getState().workflows
Object.keys(workflowRegistry).forEach((id) => {
addDeletedWorkflow(id)
function initializeWorkflowState(workflowId: string): void {
// Load the specific workflow state from localStorage
const workflowState = loadWorkflowState(workflowId)
if (!workflowState) {
console.warn(`No saved state found for workflow ${workflowId}`)
return
}
// 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)
}
console.log(`Initialized workflow state for ${workflowId}`)
}
/**
* Handle application cleanup before unload
*/
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,
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)
}
}
// 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 = ''
}
/**
* Clean up sync system
*/
function cleanupApplication(): void {
window.removeEventListener('beforeunload', handleBeforeUnload)
getSyncManagers().forEach((manager) => manager.dispose())
}
/**
* Hook to manage application lifecycle
*/
export function useAppInitialization() {
useEffect(() => {
// Use Promise to handle async initialization
initializeApplication()
return () => {
cleanupApplication()
}
}, [])
}
// Initialize immediately when imported on client
if (typeof window !== 'undefined') {
initializeApplication()
}
// Export all stores
export {
useWorkflowStore,
useWorkflowRegistry,
useNotificationStore,
useEnvironmentStore,
useExecutionStore,
useConsoleStore,
useChatStore,
useCustomToolsStore,
}
// Helper function to reset all stores
export const resetAllStores = () => {
if (typeof window !== 'undefined') {
// Selectively clear localStorage items
const keysToKeep = ['next-favicon']
const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key))
keysToRemove.forEach((key) => localStorage.removeItem(key))
}
// Force immediate state reset for all stores
// This ensures in-memory state is also cleared
useWorkflowStore.getState().clear()
// Reset all stores to initial state
useWorkflowRegistry.setState({
workflows: {},
activeWorkflowId: null,
isLoading: false,
error: null,
})
useWorkflowStore.getState().clear()
useSubBlockStore.getState().clear()
useNotificationStore.setState({ notifications: [] })
useEnvironmentStore.setState({ variables: {} })
useExecutionStore.getState().reset()
useConsoleStore.setState({ entries: [], isOpen: false })
useGeneralStore.setState({ isAutoConnectEnabled: true, isDebugModeEnabled: false })
useChatStore.setState({ messages: [], isProcessing: false, error: null })
useCustomToolsStore.setState({ tools: {} })
}
// Log the current state of all stores
// Helper function to log all store states
export const logAllStores = () => {
const state = {
workflow: useWorkflowStore.getState(),
@@ -58,6 +213,7 @@ export const logAllStores = () => {
console: useConsoleStore.getState(),
chat: useChatStore.getState(),
customTools: useCustomToolsStore.getState(),
subBlock: useSubBlockStore.getState(),
}
console.group('Application State')
@@ -71,14 +227,5 @@ export const logAllStores = () => {
return state
}
// Export all stores for convenience
export {
useWorkflowStore,
useWorkflowRegistry,
useNotificationStore,
useEnvironmentStore,
useExecutionStore,
useConsoleStore,
useChatStore,
useCustomToolsStore,
}
// Re-export sync managers
export { workflowSync, environmentSync } from './sync-registry'
+13 -45
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { environmentSync } from './sync'
import { EnvironmentStore, EnvironmentVariable } from './types'
export const useEnvironmentStore = create<EnvironmentStore>()(
@@ -7,59 +8,26 @@ export const useEnvironmentStore = create<EnvironmentStore>()(
(set, get) => ({
variables: {},
setVariable: (key: string, value: string) => {
set((state: EnvironmentStore) => ({
variables: {
...state.variables,
[key]: { key, value },
},
}))
},
removeVariable: (key: string) => {
set((state: EnvironmentStore) => {
const { [key]: _, ...rest } = state.variables
return { variables: rest }
setVariables: (variables: Record<string, string>) => {
set({
variables: Object.entries(variables).reduce(
(acc, [key, value]) => ({
...acc,
[key]: { key, value },
}),
{}
),
})
environmentSync.sync()
},
clearVariables: () => {
set({ variables: {} })
},
getVariable: (key: string) => {
getVariable: (key: string): string | undefined => {
return get().variables[key]?.value
},
getAllVariables: () => {
getAllVariables: (): Record<string, EnvironmentVariable> => {
return get().variables
},
syncWithDatabase: async () => {
const variables = get().variables
const variableValues = Object.entries(variables).reduce(
(acc, [key, value]) => ({
...acc,
[key]: value.value,
}),
{}
)
try {
const response = await fetch('/api/settings/environment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variableValues }),
})
if (!response.ok) {
throw new Error('Failed to sync environment variables')
}
} catch (error) {
console.error('Error syncing environment variables:', error)
throw error
}
},
}),
{
name: 'environment-store',
+87
View File
@@ -0,0 +1,87 @@
'use client'
import { API_ENDPOINTS } from '../../constants'
import { createSingletonSyncManager } from '../../sync'
import { useEnvironmentStore } from './store'
import { EnvironmentVariable } from './types'
// Flag to prevent immediate sync back to DB after loading from DB
let isLoadingFromDB = false
// Function to fetch environment variables from the DB and update the store
export async function fetchEnvironmentVariables(): Promise<void> {
if (typeof window === 'undefined') return
try {
// Set flag to prevent sync back to DB during loading
isLoadingFromDB = true
// Call the API endpoint directly - session handling is now done in the API route
const response = await fetch(API_ENDPOINTS.ENVIRONMENT)
if (!response.ok) {
// Handle unauthorized or other errors
if (response.status === 401) {
return
}
return
}
const { data } = await response.json()
if (data && Object.keys(data).length > 0) {
// Convert the DB format to the format expected by setVariables
const formattedVariables = Object.entries(data).reduce((acc, [key, envVar]) => {
const variable = envVar as EnvironmentVariable
return {
...acc,
[key]: variable.value,
}
}, {})
// Update the local store with the fetched variables
useEnvironmentStore.getState().setVariables(formattedVariables)
}
} catch (error) {
// Error handling is silent
} finally {
// Reset the flag after a short delay to allow state to settle
setTimeout(() => {
isLoadingFromDB = false
}, 500)
}
}
export const environmentSync = createSingletonSyncManager('environment-sync', () => ({
endpoint: API_ENDPOINTS.ENVIRONMENT,
preparePayload: async () => {
if (typeof window === 'undefined') return { skipSync: true }
// Skip sync if we're currently loading from DB to prevent overwriting DB data
if (isLoadingFromDB) {
return { skipSync: true }
}
// Get all environment variables
const variables = useEnvironmentStore.getState().variables
// Skip sync if there are no variables to sync
if (Object.keys(variables).length === 0) {
return { skipSync: true }
}
// Transform variables to the format expected by the API
return {
variables: Object.entries(variables).reduce(
(acc, [key, value]) => ({
...acc,
[key]: value.value,
}),
{}
),
}
},
method: 'POST',
syncOnInterval: true,
syncOnExit: true,
}))
+1 -4
View File
@@ -8,10 +8,7 @@ export interface EnvironmentState {
}
export interface EnvironmentStore extends EnvironmentState {
setVariable: (key: string, value: string) => void
removeVariable: (key: string) => void
clearVariables: () => void
setVariables: (variables: Record<string, string>) => void
getVariable: (key: string) => string | undefined
getAllVariables: () => Record<string, EnvironmentVariable>
syncWithDatabase: () => Promise<void>
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Core sync types and utilities for optimistic state synchronization
*/
// Configuration for a sync operation
export interface SyncConfig {
// Required configuration
endpoint: string
preparePayload: () => Promise<any> | any
method?: 'GET' | 'POST' | 'DELETE' | 'PUT'
// Sync triggers
syncOnInterval?: boolean
syncOnExit?: boolean
// Optional configuration
syncInterval?: number
onSyncSuccess?: (response: any) => void
onSyncError?: (error: any) => void
}
export const DEFAULT_SYNC_CONFIG: Partial<SyncConfig> = {
syncOnInterval: true,
syncOnExit: true,
syncInterval: 30000, // 30 seconds
}
// Core sync operations interface
export interface SyncOperations {
sync: () => void
startIntervalSync: () => void
stopIntervalSync: () => void
}
// Performs sync operation with automatic retry
export async function performSync(config: SyncConfig): Promise<boolean> {
try {
const payload = await Promise.resolve(config.preparePayload())
// Skip sync if the payload indicates it should be skipped
if (payload && payload.skipSync === true) {
return true
}
return await sendWithRetry(config.endpoint, payload, config)
} catch (error) {
if (config.onSyncError) {
config.onSyncError(error)
}
return false
}
}
// Sends data to endpoint with one retry on failure
async function sendWithRetry(endpoint: string, payload: any, config: SyncConfig): Promise<boolean> {
try {
const result = await sendRequest(endpoint, payload, config)
return result
} catch (error) {
try {
const retryResult = await sendRequest(endpoint, payload, config)
return retryResult
} catch (retryError) {
if (config.onSyncError) {
config.onSyncError(retryError)
}
return false
}
}
}
// Sends a single request to the endpoint
async function sendRequest(endpoint: string, payload: any, config: SyncConfig): Promise<boolean> {
const response = await fetch(endpoint, {
method: config.method || 'POST',
headers: { 'Content-Type': 'application/json' },
body: config.method !== 'GET' ? JSON.stringify(payload) : undefined,
})
if (!response.ok) {
throw new Error(`Sync failed: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (config.onSyncSuccess) {
config.onSyncSuccess(data)
}
return true
}
-181
View File
@@ -1,181 +0,0 @@
import { useWorkflowRegistry } from './workflow/registry/store'
import { BlockState } from './workflow/types'
import { mergeSubblockState } from './workflow/utils'
// Type definitions
interface WorkflowSyncPayload {
id: string
name: string
description?: string
state: {
blocks: Record<string, BlockState>
edges: any
loops: any
lastSaved: any
}
}
// API configuration
const SYNC_INTERVAL_MS = 30000
const API_ENDPOINTS = {
SYNC: '/api/db/sync',
SCHEDULE: '/api/scheduled/schedule',
LOGIN: '/login',
} as const
// Global state
const deletedWorkflowIds = new Set<string>()
let syncInterval: NodeJS.Timeout | null = null
// Workflow deletion tracking
export function addDeletedWorkflow(id: string): void {
deletedWorkflowIds.add(id)
}
// Prepare workflow data
async function prepareSyncPayload(
id: string,
metadata: { name: string; description?: string }
): Promise<WorkflowSyncPayload | null> {
const savedState = localStorage.getItem(`workflow-${id}`)
if (!savedState) return null
const state = JSON.parse(savedState)
const mergedBlocks = mergeSubblockState(state.blocks, id)
return {
id,
name: metadata.name,
description: metadata.description,
state: {
blocks: mergedBlocks,
edges: state.edges,
loops: state.loops,
lastSaved: state.lastSaved,
},
}
}
// Check if workflow has scheduling enabled
function hasSchedulingEnabled(state: WorkflowSyncPayload['state']): boolean {
const starterBlock = Object.values(state.blocks).find((block) => block.type === 'starter')
if (!starterBlock) return false
const startWorkflow = starterBlock.subBlocks.startWorkflow?.value
return startWorkflow === 'schedule'
}
// Server sync logic
async function syncWorkflowsToServer(payloads: WorkflowSyncPayload[]): Promise<boolean> {
try {
// First sync workflows to the database
const response = await fetch(API_ENDPOINTS.SYNC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflows: payloads,
deletedWorkflowIds: Array.from(deletedWorkflowIds),
}),
keepalive: true,
})
if (!response.ok) {
if (response.status === 401) {
window.location.href = API_ENDPOINTS.LOGIN
return false
}
throw new Error(`Batch sync failed: ${response.statusText}`)
}
// Then update schedules for workflows that have scheduling enabled
const scheduleResults = await Promise.allSettled(
payloads.map(async (payload) => {
// Update schedule if workflow has scheduling enabled
if (hasSchedulingEnabled(payload.state)) {
const response = await fetch(API_ENDPOINTS.SCHEDULE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: payload.id,
state: payload.state,
}),
})
if (!response.ok) {
throw new Error(
`Failed to update schedule for workflow ${payload.id}: ${response.statusText}`
)
}
const result = await response.json()
console.log(`Schedule updated for workflow ${payload.id}:`, result)
}
})
)
// Log any schedule sync failures but don't fail the overall sync
scheduleResults.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(`Failed to sync schedule for workflow ${payloads[index].id}:`, result.reason)
}
})
deletedWorkflowIds.clear()
console.log('Workflows synced successfully')
return true
} catch (error) {
console.error('Error syncing workflows:', error)
return false
}
}
// Periodic sync execution
export async function performSync(): Promise<void> {
const { workflows } = useWorkflowRegistry.getState()
const syncPayloads = await Promise.all(
Object.entries(workflows).map(([id, metadata]) => prepareSyncPayload(id, metadata))
)
const validPayloads = syncPayloads.filter(
(payload): payload is WorkflowSyncPayload => payload !== null
)
if (validPayloads.length > 0) {
await syncWorkflowsToServer(validPayloads)
}
}
// Sync manager initialization
export function initializeSyncManager(): (() => void) | undefined {
if (typeof window === 'undefined') return
syncInterval = setInterval(performSync, SYNC_INTERVAL_MS)
const handleBeforeUnload = async (event: BeforeUnloadEvent) => {
const { workflows } = useWorkflowRegistry.getState()
const syncPayloads = await Promise.all(
Object.entries(workflows).map(([id, metadata]) => prepareSyncPayload(id, metadata))
)
const validPayloads = syncPayloads.filter(
(payload): payload is WorkflowSyncPayload => payload !== null
)
if (validPayloads.length > 0) {
event.preventDefault()
event.returnValue = ''
await syncWorkflowsToServer(validPayloads)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (syncInterval) {
clearInterval(syncInterval)
syncInterval = null
}
}
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { environmentSync, fetchEnvironmentVariables } from './settings/environment/sync'
import { SyncManager } from './sync'
import { fetchWorkflowsFromDB, workflowSync } from './workflows/sync'
// Initialize managers lazily
let initialized = false
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.
*/
export async function initializeSyncManagers(): Promise<boolean> {
if (typeof window === 'undefined') return false
// If already initialized, return immediately
if (initialized) return true
// If currently initializing, wait for it to complete
if (initializing) {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (initialized) {
clearInterval(checkInterval)
resolve(true)
}
}, 100)
})
}
initializing = true
managers = [workflowSync, environmentSync]
try {
// Fetch data from DB on initialization to replace local storage
await Promise.all([
fetchEnvironmentVariables(),
fetchWorkflowsFromDB(),
// Add other fetch functions here as needed for other stores
])
initialized = true
return true
} catch (error) {
return false
} finally {
initializing = false
}
}
/**
* Check if the sync system is initialized
*/
export function isSyncInitialized(): boolean {
return initialized
}
export function getSyncManagers(): SyncManager[] {
// Return the current managers regardless of initialization state
// This ensures we don't block the UI while fetching data
return managers
}
// Export individual sync managers for direct use
export { workflowSync, environmentSync }
+189
View File
@@ -0,0 +1,189 @@
'use client'
import { useEffect } from 'react'
import { SYNC_INTERVALS } from './constants'
import { DEFAULT_SYNC_CONFIG, SyncConfig, SyncOperations, performSync } from './sync-core'
// Client-side sync manager with lifecycle and registry management
export interface SyncManager extends SyncOperations {
id: string
config: SyncConfig
dispose: () => void // Cleanup function
}
// Registry of sync managers for system-wide operations
const syncManagerRegistry = new Map<string, SyncManager>()
// Creates a sync manager with optimistic updates
export function createSyncManager(config: SyncConfig): SyncManager {
const id = `sync-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
// Merge with defaults
const fullConfig: SyncConfig = {
...DEFAULT_SYNC_CONFIG,
...config,
}
// Optimistic sync - fire and forget
const sync = (): void => {
performSync(fullConfig).catch((err) => {
console.error('Sync failed:', err)
})
}
// Interval management
let intervalId: NodeJS.Timeout | null = null
const startIntervalSync = () => {
if (intervalId !== null || !fullConfig.syncInterval || !fullConfig.syncOnInterval) return
intervalId = setInterval(() => {
sync()
}, fullConfig.syncInterval)
}
const stopIntervalSync = () => {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
}
// Create the manager
const manager: SyncManager = {
id,
config: fullConfig,
sync,
startIntervalSync,
stopIntervalSync,
dispose: () => {
stopIntervalSync()
syncManagerRegistry.delete(id)
},
}
// Register in global registry
syncManagerRegistry.set(id, manager)
// Start interval if configured
if (fullConfig.syncOnInterval && fullConfig.syncInterval) {
startIntervalSync()
}
return manager
}
// Initializes the sync system with exit handlers
export function initializeSyncSystem(): () => void {
if (typeof window === 'undefined') {
return () => {}
}
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
// Find managers that need exit sync
const exitSyncManagers = Array.from(syncManagerRegistry.values()).filter(
(manager) => manager.config.syncOnExit
)
if (exitSyncManagers.length === 0) return
// Trigger all exit syncs
exitSyncManagers.forEach((manager) => {
manager.sync()
})
// Standard beforeunload pattern
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}
// React hook for using a sync manager in components
export function useSyncManager(config: SyncConfig): SyncManager {
const manager = createSyncManager(config)
useEffect(() => {
return () => {
manager.dispose()
}
}, [manager])
return manager
}
// Creates a singleton sync manager for a specific store
export function createSingletonSyncManager(
key: string,
configFactory: () => SyncConfig
): SyncManager {
if (typeof window === 'undefined') {
// Return a no-op manager for server-side rendering
return {
id: key,
config: configFactory(),
sync: () => {},
startIntervalSync: () => {},
stopIntervalSync: () => {},
dispose: () => {},
}
}
const existing = syncManagerRegistry.get(key)
if (existing) {
return existing
}
const config = {
...configFactory(),
syncInterval: SYNC_INTERVALS.DEFAULT,
}
const manager = {
id: key,
config,
sync: () => {
performSync(config).catch((err) => {
console.error(`Sync failed for ${key}:`, err)
})
},
startIntervalSync: () => {
if (!config.syncInterval || !config.syncOnInterval) return
manager.intervalId = setInterval(manager.sync, config.syncInterval)
},
stopIntervalSync: () => {
if (manager.intervalId) {
clearInterval(manager.intervalId)
manager.intervalId = null
}
},
dispose: () => {
manager.stopIntervalSync()
syncManagerRegistry.delete(key)
},
intervalId: null as NodeJS.Timeout | null,
}
syncManagerRegistry.set(key, manager)
if (config.syncOnInterval && config.syncInterval) {
manager.startIntervalSync()
}
return manager
}
// Creates a factory function for store-specific sync managers
export function createSyncManagerFactory(baseConfig: Partial<SyncConfig>) {
return (config: Partial<SyncConfig>): SyncManager => {
return createSyncManager({
...baseConfig,
...config,
} as SyncConfig)
}
}
-22
View File
@@ -1,22 +0,0 @@
import { WorkflowState } from './types'
export interface HistoryEntry {
state: WorkflowState
timestamp: number
action: string
subblockValues: Record<string, Record<string, any>>
}
export interface WorkflowHistory {
past: HistoryEntry[]
present: HistoryEntry
future: HistoryEntry[]
}
export interface HistoryActions {
undo: () => void
redo: () => void
canUndo: () => boolean
canRedo: () => boolean
revertToHistoryState: (index: number) => void
}
-18
View File
@@ -1,18 +0,0 @@
import { useWorkflowStore } from './store'
export function initializeStateLogger() {
useWorkflowStore.subscribe((state) => {
console.log('Workflow State Updated:', {
current: {
blocks: state.blocks,
edges: state.edges,
loops: state.loops,
},
history: {
past: state.history.past,
present: state.history.present,
future: state.history.future,
},
})
})
}
+73
View File
@@ -0,0 +1,73 @@
import { loadWorkflowState } from './persistence'
import { useWorkflowRegistry } from './registry/store'
import { useSubBlockStore } from './subblock/store'
import { mergeSubblockState } from './utils'
import { useWorkflowStore } from './workflow/store'
import { BlockState, WorkflowState } from './workflow/types'
// Get a specific block with its subblock values merged in
export function getBlockWithValues(blockId: string): BlockState | null {
const workflowState = useWorkflowStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId || !workflowState.blocks[blockId]) return null
const mergedBlocks = mergeSubblockState(workflowState.blocks, activeWorkflowId, blockId)
return mergedBlocks[blockId] || null
}
// Get all workflows with their values merged
export function getAllWorkflowsWithValues() {
const { workflows } = useWorkflowRegistry.getState()
const result: Record<string, any> = {}
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const currentState = useWorkflowStore.getState()
for (const [id, metadata] of Object.entries(workflows)) {
// 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,
isDeployed: currentState.isDeployed,
deployedAt: currentState.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
console.warn(`No saved state found for workflow ${id}`)
continue
}
workflowState = savedState
}
// Merge the subblock values for this specific workflow
const mergedBlocks = mergeSubblockState(workflowState.blocks, id)
result[id] = {
id,
name: metadata.name,
description: metadata.description,
color: metadata.color || '#3972F6',
state: {
blocks: mergedBlocks,
edges: workflowState.edges,
loops: workflowState.loops,
lastSaved: workflowState.lastSaved,
isDeployed: workflowState.isDeployed,
deployedAt: workflowState.deployedAt,
},
}
}
return result
}
export { useWorkflowRegistry, useWorkflowStore, useSubBlockStore }
@@ -1,9 +1,31 @@
import { StateCreator } from 'zustand'
import { HistoryActions, HistoryEntry, WorkflowHistory } from './history-types'
import { saveSubblockValues, saveWorkflowState } from './persistence'
import { useWorkflowRegistry } from './registry/store'
import { useSubBlockStore } from './subblock/store'
import { WorkflowState, WorkflowStore } from './types'
import { mergeSubblockState } from './utils'
import { WorkflowState, WorkflowStore } from './workflow/types'
// Types
interface HistoryEntry {
state: WorkflowState
timestamp: number
action: string
subblockValues: Record<string, Record<string, any>>
}
interface WorkflowHistory {
past: HistoryEntry[]
present: HistoryEntry
future: HistoryEntry[]
}
interface HistoryActions {
undo: () => void
redo: () => void
canUndo: () => boolean
canRedo: () => boolean
revertToHistoryState: (index: number) => void
}
// MAX for each individual workflow
const MAX_HISTORY_LENGTH = 20
@@ -66,6 +88,7 @@ export const withHistory = (
present: previous,
future: [history.present, ...history.future],
},
lastSaved: Date.now(),
})
// Restore subblock values from the previous state's snapshot
@@ -78,12 +101,21 @@ export const withHistory = (
},
})
// Also update localStorage for backup
localStorage.setItem(
`subblock-values-${activeWorkflowId}`,
JSON.stringify(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,
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
lastSaved: Date.now(),
})
},
// Restore next state from history
@@ -107,6 +139,7 @@ export const withHistory = (
present: next,
future: newFuture,
},
lastSaved: Date.now(),
})
// Restore subblock values from the next state's snapshot
@@ -119,12 +152,21 @@ export const withHistory = (
},
})
// Also update localStorage for backup
localStorage.setItem(
`subblock-values-${activeWorkflowId}`,
JSON.stringify(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,
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
lastSaved: Date.now(),
})
},
// Reset workflow to empty state
@@ -143,6 +185,7 @@ export const withHistory = (
},
future: [],
},
lastSaved: Date.now(),
}
set(newState)
return newState
@@ -171,6 +214,7 @@ export const withHistory = (
present: targetState,
future: newFuture,
},
lastSaved: Date.now(),
})
// Restore subblock values from the target state's snapshot
@@ -183,12 +227,21 @@ export const withHistory = (
},
})
// Also update localStorage for backup
localStorage.setItem(
`subblock-values-${activeWorkflowId}`,
JSON.stringify(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,
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
lastSaved: Date.now(),
})
},
}
}
@@ -246,5 +299,6 @@ export const pushHistory = (
present: newEntry,
future: [],
},
lastSaved: Date.now(),
})
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Centralized persistence layer for workflow stores
* Handles localStorage interactions and synchronization
*/
import { STORAGE_KEYS } from '../constants'
import { useWorkflowRegistry } from './registry/store'
import { useSubBlockStore } from './subblock/store'
import { useWorkflowStore } from './workflow/store'
/**
* Save data to localStorage with error handling
*/
export function saveToStorage<T>(key: string, data: T): boolean {
try {
localStorage.setItem(key, JSON.stringify(data))
return true
} catch (error) {
console.error(`Failed to save data to ${key}:`, error)
return false
}
}
/**
* Load data from localStorage with error handling
*/
export function loadFromStorage<T>(key: string): T | null {
try {
const data = localStorage.getItem(key)
return data ? JSON.parse(data) : null
} catch (error) {
console.error(`Failed to load data from ${key}:`, error)
return null
}
}
/**
* Remove data from localStorage with error handling
*/
export function removeFromStorage(key: string): boolean {
try {
localStorage.removeItem(key)
return true
} catch (error) {
console.error(`Failed to remove data from ${key}:`, error)
return false
}
}
/**
* Save workflow state to localStorage
*/
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)
}
/**
* Load workflow state from localStorage
*/
export function loadWorkflowState(workflowId: string): any {
return loadFromStorage(STORAGE_KEYS.WORKFLOW(workflowId))
}
/**
* Save subblock values to localStorage
*/
export function saveSubblockValues(workflowId: string, values: any): boolean {
return saveToStorage(STORAGE_KEYS.SUBBLOCK(workflowId), values)
}
/**
* Load subblock values from localStorage
*/
export function loadSubblockValues(workflowId: string): any {
return loadFromStorage(STORAGE_KEYS.SUBBLOCK(workflowId))
}
/**
* 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)
}
/**
* Initialize all stores from localStorage
* This is the main initialization function that should be called once at app startup
*/
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)
}
}
}
}
// Setup unload persistence
setupUnloadPersistence()
}
/**
* Setup persistence for page unload events
*/
export function setupUnloadPersistence(): void {
if (typeof window === 'undefined') return
window.addEventListener('beforeunload', () => {
const currentId = useWorkflowRegistry.getState().activeWorkflowId
if (currentId) {
// Save workflow state
const currentState = useWorkflowStore.getState()
// Save the complete state including history which is added by middleware
saveWorkflowState(currentId, {
blocks: currentState.blocks,
edges: currentState.edges,
loops: currentState.loops,
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)
}
}
// Save registry
saveRegistry(useWorkflowRegistry.getState().workflows)
})
}
@@ -1,8 +1,17 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { addDeletedWorkflow } from '../../sync-manager'
import { useWorkflowStore } from '../store'
import { API_ENDPOINTS, STORAGE_KEYS } from '../../constants'
import {
loadRegistry,
loadWorkflowState,
removeFromStorage,
saveRegistry,
saveSubblockValues,
saveWorkflowState,
} from '../persistence'
import { useSubBlockStore } from '../subblock/store'
import { workflowSync } from '../sync'
import { useWorkflowStore } from '../workflow/store'
import { WorkflowMetadata, WorkflowRegistry } from './types'
import { generateUniqueName, getNextWorkflowColor } from './utils'
@@ -27,34 +36,40 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
const currentId = get().activeWorkflowId
if (currentId) {
const currentState = useWorkflowStore.getState()
localStorage.setItem(
`workflow-${currentId}`,
JSON.stringify({
blocks: currentState.blocks,
edges: currentState.edges,
loops: currentState.loops,
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
})
)
// Save the complete state for the current workflow
saveWorkflowState(currentId, {
blocks: currentState.blocks,
edges: currentState.edges,
loops: currentState.loops,
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
lastSaved: Date.now(),
})
// Also save current subblock values
const currentSubblockValues = useSubBlockStore.getState().workflowValues[currentId]
if (currentSubblockValues) {
saveSubblockValues(currentId, currentSubblockValues)
}
}
// Load workflow state
const savedState = localStorage.getItem(`workflow-${id}`)
if (savedState) {
const parsedState = JSON.parse(savedState)
const { blocks, edges, history, loops } = parsedState
// Load workflow state for the new active workflow
const parsedState = loadWorkflowState(id)
if (parsedState) {
const { blocks, edges, history, loops, isDeployed, deployedAt } = parsedState
// 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,
isDeployed: parsedState.isDeployed !== undefined ? parsedState.isDeployed : false,
deployedAt: parsedState.deployedAt ? new Date(parsedState.deployedAt) : undefined,
isDeployed: isDeployed !== undefined ? isDeployed : false,
deployedAt: deployedAt ? new Date(deployedAt) : undefined,
history: history || {
past: [],
present: {
@@ -62,8 +77,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
blocks,
edges,
loops: {},
isDeployed: parsedState.isDeployed !== undefined ? parsedState.isDeployed : false,
deployedAt: parsedState.deployedAt,
isDeployed: isDeployed !== undefined ? isDeployed : false,
deployedAt: deployedAt,
},
timestamp: Date.now(),
action: 'Initial state',
@@ -71,8 +86,12 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
},
future: [],
},
lastSaved: parsedState.lastSaved || Date.now(),
})
console.log(`Switched to workflow ${id}`)
} else {
// If no saved state, initialize with empty state
useWorkflowStore.setState({
blocks: {},
edges: [],
@@ -97,8 +116,11 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
},
lastSaved: Date.now(),
})
console.warn(`No saved state found for workflow ${id}, initialized with empty state`)
}
// Update the active workflow ID
set({ activeWorkflowId: id, error: null })
},
@@ -252,10 +274,10 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
// Save workflow list to localStorage
const updatedWorkflows = get().workflows
localStorage.setItem('workflow-registry', JSON.stringify(updatedWorkflows))
saveRegistry(updatedWorkflows)
// Save initial workflow state to localStorage
localStorage.setItem(`workflow-${id}`, JSON.stringify(initialState))
saveWorkflowState(id, initialState)
// If this is the first workflow or it's an initial workflow, set it as active
if (options.isInitial || Object.keys(updatedWorkflows).length === 1) {
@@ -263,6 +285,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
useWorkflowStore.setState(initialState)
}
// Trigger sync
workflowSync.sync()
return id
},
@@ -272,14 +297,26 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
const newWorkflows = { ...state.workflows }
delete newWorkflows[id]
// Track deletion for next sync
addDeletedWorkflow(id)
// Clean up localStorage
removeFromStorage(STORAGE_KEYS.WORKFLOW(id))
removeFromStorage(STORAGE_KEYS.SUBBLOCK(id))
saveRegistry(newWorkflows)
// Remove workflow state from localStorage
localStorage.removeItem(`workflow-${id}`)
// Ensure any schedule for this workflow is cancelled
// The API will handle the deletion of the schedule
fetch(API_ENDPOINTS.SCHEDULE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: id,
state: { blocks: {} }, // Empty blocks will signal to cancel the schedule
}),
}).catch((error) => {
console.error(`Error cancelling schedule for deleted workflow ${id}:`, error)
})
// Update registry in localStorage
localStorage.setItem('workflow-registry', JSON.stringify(newWorkflows))
// Sync deletion with database
workflowSync.sync()
// If deleting active workflow, switch to another one
let newActiveWorkflowId = state.activeWorkflowId
@@ -287,10 +324,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
const remainingIds = Object.keys(newWorkflows)
// Switch to first available workflow
newActiveWorkflowId = remainingIds[0]
const savedState = localStorage.getItem(`workflow-${newActiveWorkflowId}`)
const savedState = loadWorkflowState(newActiveWorkflowId)
if (savedState) {
const { blocks, edges, history, loops, isDeployed, deployedAt } =
JSON.parse(savedState)
const { blocks, edges, history, loops, isDeployed, deployedAt } = savedState
useWorkflowStore.setState({
blocks,
edges,
@@ -359,8 +395,11 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
},
}
// Update registry in localStorage
localStorage.setItem('workflow-registry', JSON.stringify(updatedWorkflows))
// Update localStorage
saveRegistry(updatedWorkflows)
// Use PUT for workflow updates
workflowSync.sync()
return {
workflows: updatedWorkflows,
@@ -372,35 +411,3 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
{ name: 'workflow-registry' }
)
)
// Initialize registry from localStorage and set up persistence
const initializeRegistry = () => {
const savedRegistry = localStorage.getItem('workflow-registry')
if (savedRegistry) {
const workflows = JSON.parse(savedRegistry)
useWorkflowRegistry.setState({ workflows })
}
// Add event listeners for page unload
window.addEventListener('beforeunload', () => {
const currentId = useWorkflowRegistry.getState().activeWorkflowId
if (currentId) {
const currentState = useWorkflowStore.getState()
localStorage.setItem(
`workflow-${currentId}`,
JSON.stringify({
blocks: currentState.blocks,
edges: currentState.edges,
loops: currentState.loops,
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
})
)
}
})
}
if (typeof window !== 'undefined') {
initializeRegistry()
}
@@ -3,7 +3,7 @@ export interface WorkflowMetadata {
name: string
lastModified: Date
description?: string
color?: string
color: string
}
export interface WorkflowRegistryState {
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { SubBlockConfig } from '@/blocks/types'
import { loadSubblockValues, saveSubblockValues } from '../persistence'
import { useWorkflowRegistry } from '../registry/store'
interface SubBlockState {
@@ -38,9 +39,8 @@ export const useSubBlockStore = create<SubBlockStore>()(
}))
// Persist to localStorage for backup
const storageKey = `subblock-values-${activeWorkflowId}`
const currentValues = get().workflowValues[activeWorkflowId] || {}
localStorage.setItem(storageKey, JSON.stringify(currentValues))
saveSubblockValues(activeWorkflowId, currentValues)
},
getValue: (blockId: string, subBlockId: string) => {
@@ -61,20 +61,18 @@ export const useSubBlockStore = create<SubBlockStore>()(
},
}))
localStorage.removeItem(`subblock-values-${activeWorkflowId}`)
saveSubblockValues(activeWorkflowId, {})
},
initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => {
// First, try to load from localStorage
const storageKey = `subblock-values-${workflowId}`
const savedValues = localStorage.getItem(storageKey)
const savedValues = loadSubblockValues(workflowId)
if (savedValues) {
const parsedValues = JSON.parse(savedValues)
set((state) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: parsedValues,
[workflowId]: savedValues,
},
}))
return
@@ -97,7 +95,7 @@ export const useSubBlockStore = create<SubBlockStore>()(
}))
// Save to localStorage
localStorage.setItem(storageKey, JSON.stringify(values))
saveSubblockValues(workflowId, values)
},
}),
{
+279
View File
@@ -0,0 +1,279 @@
'use client'
import { getAllWorkflowsWithValues } from '.'
import { API_ENDPOINTS } from '../constants'
import { createSingletonSyncManager } from '../sync'
import { useWorkflowRegistry } from './registry/store'
import { WorkflowMetadata } from './registry/types'
import { useSubBlockStore } from './subblock/store'
import { useWorkflowStore } from './workflow/store'
import { BlockState, WorkflowState } from './workflow/types'
// Flag to prevent immediate sync back to DB after loading from DB
let isLoadingFromDB = false
// Track workflows that had scheduling enabled in previous syncs
const scheduledWorkflows = new Set<string>()
/**
* Checks if a workflow has scheduling enabled
* @param blocks The workflow blocks
* @returns true if scheduling is enabled, false otherwise
*/
function hasSchedulingEnabled(blocks: Record<string, BlockState>): boolean {
// Find the starter block
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
if (!starterBlock) return false
// Check if the startWorkflow value is 'schedule'
const startWorkflow = starterBlock.subBlocks.startWorkflow?.value
return startWorkflow === 'schedule'
}
/**
* Updates or cancels the schedule for a workflow based on its current configuration
* @param workflowId The workflow ID
* @param state The workflow state
* @returns A promise that resolves when the schedule update is complete
*/
async function updateWorkflowSchedule(workflowId: string, state: any): Promise<void> {
try {
const isScheduleEnabled = hasSchedulingEnabled(state.blocks)
// Always call the schedule API to either update or cancel the schedule
// The API will handle the logic to create, update, or delete the schedule
const response = await fetch(API_ENDPOINTS.SCHEDULE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
state,
}),
})
if (!response.ok) {
console.error(
`Failed to ${isScheduleEnabled ? 'update' : 'cancel'} schedule for workflow ${workflowId}:`,
response.statusText
)
return
}
const result = await response.json()
// Update our tracking of scheduled workflows
if (isScheduleEnabled) {
scheduledWorkflows.add(workflowId)
console.log(`Schedule updated for workflow ${workflowId}:`, result)
} else {
scheduledWorkflows.delete(workflowId)
console.log(`Schedule cancelled for workflow ${workflowId}:`, result)
}
} catch (error) {
console.error(`Error managing schedule for workflow ${workflowId}:`, error)
}
}
/**
* Fetches workflows from the database and updates the local stores
* This function handles backwards syncing on initialization
*/
export async function fetchWorkflowsFromDB(): Promise<void> {
if (typeof window === 'undefined') return
try {
// Set flag to prevent sync back to DB during loading
isLoadingFromDB = true
// Call the API endpoint to get workflows from DB
const response = await fetch(API_ENDPOINTS.WORKFLOW, {
method: 'GET',
})
if (!response.ok) {
if (response.status === 401) {
console.warn('User not authenticated for workflow fetch')
return
}
console.error('Failed to fetch workflows:', response.statusText)
return
}
const { data } = await response.json()
if (!data || !Array.isArray(data) || data.length === 0) {
console.log('No workflows found in database')
return
}
// Get the current active workflow ID before processing
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
// Process workflows and update stores
const registryWorkflows: Record<string, WorkflowMetadata> = {}
// Process each workflow from the database
data.forEach((workflow) => {
const {
id,
name,
description,
color,
state,
lastSynced,
isDeployed,
deployedAt,
apiKey,
createdAt,
} = workflow
// 1. Update registry store with workflow metadata
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),
}
// 2. Prepare workflow state data
const workflowState = {
blocks: state.blocks || {},
edges: state.edges || [],
loops: state.loops || {},
isDeployed: isDeployed || false,
deployedAt: deployedAt ? new Date(deployedAt) : undefined,
apiKey,
lastSaved: Date.now(),
}
// 3. Initialize subblock values from the workflow state
const subblockValues: Record<string, Record<string, any>> = {}
// 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
})
})
// 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))
// 5. Update subblock store for this workflow
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[id]: subblockValues,
},
}))
// 6. If this is the active workflow, update the workflow store
if (id === activeWorkflowId) {
useWorkflowStore.setState(workflowState)
}
// 7. Track if this workflow has scheduling enabled
if (hasSchedulingEnabled(workflowState.blocks)) {
scheduledWorkflows.add(id)
}
})
// 8. Update registry store with all workflows
useWorkflowRegistry.setState({ workflows: registryWorkflows })
// 9. If there's an active workflow that wasn't in the DB data, set a new active workflow
if (activeWorkflowId && !registryWorkflows[activeWorkflowId]) {
const firstWorkflowId = Object.keys(registryWorkflows)[0]
if (firstWorkflowId) {
// 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 })
}
}
}
console.log('Workflows loaded from DB:', Object.keys(registryWorkflows).length)
} catch (error) {
console.error('Error fetching workflows from DB:', error)
} finally {
// Reset the flag after a short delay to allow state to settle
setTimeout(() => {
isLoadingFromDB = false
}, 500)
}
}
// Syncs workflows to the database
export const workflowSync = createSingletonSyncManager('workflow-sync', () => ({
endpoint: API_ENDPOINTS.WORKFLOW,
preparePayload: () => {
if (typeof window === 'undefined') return {}
// Skip sync if we're currently loading from DB to prevent overwriting DB data
if (isLoadingFromDB) {
console.log('Skipping workflow sync while loading from DB')
return { skipSync: true }
}
// Get all workflows with values
const workflowsData = getAllWorkflowsWithValues()
// Skip sync if there are no workflows to sync
if (Object.keys(workflowsData).length === 0) {
console.log('Skipping workflow sync - no workflows to sync')
return { skipSync: true }
}
return {
workflows: workflowsData,
}
},
method: 'POST',
syncOnInterval: true,
syncOnExit: true,
onSyncSuccess: async (data) => {
console.log('Workflows synced to DB successfully')
// After successful sync to DB, update schedules for all workflows
try {
const workflowsData = getAllWorkflowsWithValues()
const currentWorkflowIds = new Set(Object.keys(workflowsData))
// Process each workflow to update its schedule if needed
const schedulePromises = Object.entries(workflowsData).map(async ([id, workflow]) => {
const isCurrentlyScheduled = hasSchedulingEnabled(workflow.state.blocks)
const wasScheduledBefore = scheduledWorkflows.has(id)
// Only update schedule if the scheduling status has changed or it's currently scheduled
// This ensures we update schedules when they change and cancel them when they're disabled
if (isCurrentlyScheduled || wasScheduledBefore) {
await updateWorkflowSchedule(id, workflow.state)
}
})
// Wait for all schedule updates to complete
await Promise.all(schedulePromises)
// Clean up tracking for workflows that no longer exist
for (const id of scheduledWorkflows) {
if (!currentWorkflowIds.has(id)) {
scheduledWorkflows.delete(id)
}
}
} catch (error) {
console.error('Error updating workflow schedules:', error)
}
},
}))
@@ -1,7 +1,5 @@
import { Edge } from 'reactflow'
import { useWorkflowRegistry } from './registry/store'
import { useSubBlockStore } from './subblock/store'
import { BlockState, SubBlockState } from './types'
import { BlockState, SubBlockState } from './workflow/types'
/**
* Merges workflow block states with subblock values while maintaining block structure
@@ -70,56 +68,3 @@ export function mergeSubblockState(
{} as Record<string, BlockState>
)
}
/**
* Performs a depth-first search to detect all cycles in the graph
* @param edges - List of all edges in the graph
* @param startNode - Starting node for cycle detection
* @returns Array of all unique cycles found in the graph
*/
export function detectCycle(
edges: Edge[],
startNode: string
): { hasCycle: boolean; paths: string[][] } {
const visited = new Set<string>()
const recursionStack = new Set<string>()
const allCycles: string[][] = []
const currentPath: string[] = []
function dfs(node: string) {
visited.add(node)
recursionStack.add(node)
currentPath.push(node)
// Get all neighbors of current node
const neighbors = edges.filter((edge) => edge.source === node).map((edge) => edge.target)
for (const neighbor of neighbors) {
if (!recursionStack.has(neighbor)) {
if (!visited.has(neighbor)) {
dfs(neighbor)
}
} else {
// Found a cycle
const cycleStartIndex = currentPath.indexOf(neighbor)
if (cycleStartIndex !== -1) {
const cycle = currentPath.slice(cycleStartIndex)
// Only add cycles with length > 1
if (cycle.length > 1) {
allCycles.push([...cycle])
}
}
}
}
currentPath.pop()
recursionStack.delete(node)
}
dfs(startNode)
return {
hasCycle: allCycles.length > 0,
paths: allCycles,
}
}
@@ -3,12 +3,14 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { getBlock } from '@/blocks'
import { resolveOutputType } from '@/blocks/utils'
import { WorkflowStoreWithHistory, pushHistory, withHistory } from './middleware'
import { useWorkflowRegistry } from './registry/store'
import { useSubBlockStore } from './subblock/store'
import { WorkflowStoreWithHistory, pushHistory, withHistory } from '../middleware'
import { saveWorkflowState } from '../persistence'
import { useWorkflowRegistry } from '../registry/store'
import { useSubBlockStore } from '../subblock/store'
import { workflowSync } from '../sync'
import { mergeSubblockState } from '../utils'
import { Loop, Position, SubBlockState } from './types'
import { detectCycle } from './utils'
import { mergeSubblockState } from './utils'
const initialState = {
blocks: {},
@@ -78,6 +80,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, `Add ${type} block`)
get().updateLastSaved()
workflowSync.sync()
},
updateBlockPosition: (id: string, position: Position) => {
@@ -92,6 +95,8 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
edges: [...state.edges],
}))
get().updateLastSaved()
// No sync here as this is a frequent operation during dragging
},
removeBlock: (id: string) => {
@@ -141,6 +146,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, 'Remove block')
get().updateLastSaved()
workflowSync.sync()
},
addEdge: (edge: Edge) => {
@@ -200,6 +206,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, 'Add connection')
get().updateLastSaved()
workflowSync.sync()
},
removeEdge: (edgeId: string) => {
@@ -237,6 +244,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, 'Remove connection')
get().updateLastSaved()
workflowSync.sync()
},
clear: () => {
@@ -261,11 +269,33 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
lastSaved: Date.now(),
}
set(newState)
workflowSync.sync()
return newState
},
updateLastSaved: () => {
set({ lastSaved: Date.now() })
// Save current state to localStorage
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const currentState = get()
saveWorkflowState(activeWorkflowId, {
blocks: currentState.blocks,
edges: currentState.edges,
loops: currentState.loops,
history: currentState.history,
isDeployed: currentState.isDeployed,
deployedAt: currentState.deployedAt,
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.
}
},
toggleBlockEnabled: (id: string) => {
@@ -282,6 +312,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
get().updateLastSaved()
workflowSync.sync()
},
duplicateBlock: (id: string) => {
@@ -348,6 +379,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, `Duplicate ${block.type} block`)
get().updateLastSaved()
workflowSync.sync()
},
toggleBlockHandles: (id: string) => {
@@ -364,6 +396,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
get().updateLastSaved()
workflowSync.sync()
},
updateBlockName: (id: string, name: string) => {
@@ -382,6 +415,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, `${name} block name updated`)
get().updateLastSaved()
workflowSync.sync()
},
toggleBlockWide: (id: string) => {
@@ -397,6 +431,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
loops: { ...get().loops },
}))
get().updateLastSaved()
workflowSync.sync()
},
updateBlockHeight: (id: string, height: number) => {
@@ -429,6 +464,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
pushHistory(set, get, newState, 'Update loop max iterations')
get().updateLastSaved()
workflowSync.sync()
},
triggerUpdate: () => {
@@ -447,6 +483,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
set(newState)
get().updateLastSaved()
workflowSync.sync()
},
})),
{ name: 'workflow-store' }
+54
View File
@@ -0,0 +1,54 @@
import { Edge } from 'reactflow'
/**
* Performs a depth-first search to detect all cycles in the graph
* @param edges - List of all edges in the graph
* @param startNode - Starting node for cycle detection
* @returns Array of all unique cycles found in the graph
*/
export function detectCycle(
edges: Edge[],
startNode: string
): { hasCycle: boolean; paths: string[][] } {
const visited = new Set<string>()
const recursionStack = new Set<string>()
const allCycles: string[][] = []
const currentPath: string[] = []
function dfs(node: string) {
visited.add(node)
recursionStack.add(node)
currentPath.push(node)
// Get all neighbors of current node
const neighbors = edges.filter((edge) => edge.source === node).map((edge) => edge.target)
for (const neighbor of neighbors) {
if (!recursionStack.has(neighbor)) {
if (!visited.has(neighbor)) {
dfs(neighbor)
}
} else {
// Found a cycle
const cycleStartIndex = currentPath.indexOf(neighbor)
if (cycleStartIndex !== -1) {
const cycle = currentPath.slice(cycleStartIndex)
// Only add cycles with length > 1
if (cycle.length > 1) {
allCycles.push([...cycle])
}
}
}
}
currentPath.pop()
recursionStack.delete(node)
}
dfs(startNode)
return {
hasCycle: allCycles.length > 0,
paths: allCycles,
}
}