mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 })
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+2
-2
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
+71
-61
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
images: {
|
||||
domains: ['avatars.githubusercontent.com'],
|
||||
},
|
||||
|
||||
+1
-1
@@ -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
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -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' }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user