feat(versioning): added api deployment versioning, change detection in workflow, added ci job to run migrations on merge to main (#220)

* added initial code for versioning

* ran migrations & improvements to versioning

* change sensitivity of state diff checker

* debounce the change detection function

* cleaned up file structure

* added ci job to run migrations when merging to main

* ran migrations

* cleanup unused files, remove unused dependencies
This commit is contained in:
Waleed Latif
2025-04-02 22:21:07 -07:00
committed by GitHub
parent c7ad9d5dd8
commit 9d3b27065a
17 changed files with 2348 additions and 2464 deletions
+33 -1
View File
@@ -26,6 +26,12 @@ jobs:
working-directory: ./sim
run: npm ci
- name: Fix Rollup module issue
working-directory: ./sim
run: |
rm -rf node_modules package-lock.json
npm install
- name: Run tests with coverage
working-directory: ./sim
env:
@@ -41,4 +47,30 @@ jobs:
with:
directory: ./sim/coverage
fail_ci_if_error: false
verbose: true
verbose: true
migrations:
name: Apply Database Migrations
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './sim/package-lock.json'
- name: Install dependencies
working-directory: ./sim
run: npm ci
- name: Apply migrations
working-directory: ./sim
env:
POSTGRES_URL: ${{ secrets.DATABASE_URL }}
run: npx drizzle-kit push
+21 -3
View File
@@ -32,6 +32,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
userId: workflow.userId,
state: workflow.state,
deployedState: workflow.deployedState,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -51,6 +53,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isDeployed: false,
deployedAt: null,
apiKey: null,
needsRedeployment: false,
})
}
@@ -63,11 +66,22 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.where(eq(apiKey.userId, workflowData.userId))
.limit(1)
// Check if the workflow has meaningful changes that would require redeployment
let needsRedeployment = false
if (workflowData.deployedState) {
const { hasWorkflowChanged } = await import('@/lib/workflows/utils')
needsRedeployment = hasWorkflowChanged(
workflowData.state as any,
workflowData.deployedState as any
)
}
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
return createSuccessResponse({
apiKey: userApiKey.length > 0 ? userApiKey[0].key : null,
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
needsRedeployment,
})
} catch (error: any) {
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)
@@ -88,10 +102,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
// Get the workflow to find the user
// Get the workflow to find the user and current state
const workflowData = await db
.select({
userId: workflow.userId,
state: workflow.state,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -103,6 +118,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const userId = workflowData[0].userId
const currentState = workflowData[0].state
const deployedAt = new Date()
// Check if the user already has an API key
@@ -132,12 +148,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
userKey = userApiKey[0].key
}
// Update the workflow deployment status
// Update the workflow deployment status and save current state as deployed state
await db
.update(workflow)
.set({
isDeployed: true,
deployedAt,
deployedState: currentState,
})
.where(eq(workflow.id, id))
@@ -165,12 +182,13 @@ export async function DELETE(
return createErrorResponse(validation.error.message, validation.error.status)
}
// Update the workflow to remove deployment status
// Update the workflow to remove deployment status and deployed state
await db
.update(workflow)
.set({
isDeployed: false,
deployedAt: null,
deployedState: null,
})
.where(eq(workflow.id, id))
+12 -2
View File
@@ -61,8 +61,18 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
runningExecutions.add(workflowId)
logger.info(`[${requestId}] Starting workflow execution: ${workflowId}`)
// Get the workflow state
const state = workflow.state as WorkflowState
// Use the deployed state if available, otherwise fall back to current state
const workflowState = workflow.deployedState || workflow.state
if (!workflow.deployedState) {
logger.warn(
`[${requestId}] No deployed state found for workflow: ${workflowId}, using current state`
)
} else {
logger.info(`[${requestId}] Using deployed state for workflow execution: ${workflowId}`)
}
const state = workflowState as WorkflowState
const { blocks, edges, loops } = state
// Use the same execution flow as in scheduled executions
@@ -1,5 +1,6 @@
import { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console-logger'
import { hasWorkflowChanged } from '@/lib/workflows/utils'
import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils'
@@ -17,15 +18,26 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
// Check if the workflow has meaningful changes that would require redeployment
let needsRedeployment = false
if (validation.workflow.isDeployed && validation.workflow.deployedState) {
needsRedeployment = hasWorkflowChanged(
validation.workflow.state as any,
validation.workflow.deployedState as any
)
}
logger.info(`[${requestId}] Retrieved status for workflow: ${id}`, {
isDeployed: validation.workflow.isDeployed,
isPublished: validation.workflow.isPublished,
needsRedeployment,
})
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt,
isPublished: validation.workflow.isPublished,
needsRedeployment,
})
} catch (error) {
logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error)
@@ -0,0 +1,253 @@
'use client'
import { useState } from 'react'
import { Loader2, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useNotificationStore } from '@/stores/notifications/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DeploymentControls')
interface DeploymentControlsProps {
activeWorkflowId: string | null
needsRedeployment: boolean
setNeedsRedeployment: (value: boolean) => void
}
export function DeploymentControls({
activeWorkflowId,
needsRedeployment,
setNeedsRedeployment,
}: DeploymentControlsProps) {
// Store hooks
const { addNotification, showNotification, removeNotification, notifications } =
useNotificationStore()
const { isDeployed, setDeploymentStatus } = useWorkflowStore()
// Local state
const [isDeploying, setIsDeploying] = useState(false)
/**
* Get an example of the input format for the workflow
*/
const getInputFormatExample = () => {
let inputFormatExample = ''
try {
// Find the starter block in the workflow
const blocks = Object.values(useWorkflowStore.getState().blocks)
const starterBlock = blocks.find((block) => block.type === 'starter')
if (starterBlock) {
const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat')
// If input format is defined, create an example
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
const exampleData: Record<string, any> = {}
// Create example values for each field
inputFormat.forEach((field: any) => {
if (field.name) {
switch (field.type) {
case 'string':
exampleData[field.name] = 'example'
break
case 'number':
exampleData[field.name] = 42
break
case 'boolean':
exampleData[field.name] = true
break
case 'object':
exampleData[field.name] = { key: 'value' }
break
case 'array':
exampleData[field.name] = [1, 2, 3]
break
}
}
})
inputFormatExample = ` -d '${JSON.stringify(exampleData)}'`
}
}
} catch (error) {
logger.error('Error generating input format example:', error)
}
return inputFormatExample
}
/**
* Helper to create API notification with consistent format
*/
const createApiNotification = (
message: string,
workflowId: string,
apiKey: string,
needsRedeployment = false
) => {
const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample()
return addNotification('api', message, workflowId, {
isPersistent: true,
sections: [
{
label: 'API Endpoint',
content: endpoint,
},
{
label: 'API Key',
content: apiKey || 'No API key found. Visit your account settings to create one.',
},
{
label: 'Example curl command',
content: apiKey
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`
: `You need an API key to call this endpoint. Visit your account settings to create one.`,
},
],
needsRedeployment,
})
}
/**
* Workflow deployment handler
*/
const handleDeploy = async () => {
if (!activeWorkflowId) return
// If already deployed, show the API info
if (isDeployed) {
// Try to find an existing API notification
const apiNotification = notifications.find(
(n) => n.type === 'api' && n.workflowId === activeWorkflowId
)
if (apiNotification) {
// Before showing existing notification, check if we need to update it with current status
if (apiNotification.options?.needsRedeployment !== needsRedeployment) {
// Remove old notification
removeNotification(apiNotification.id)
// Fetch API key from the existing notification
const apiKey =
apiNotification.options?.sections?.find((s) => s.label === 'API Key')?.content || ''
createApiNotification(
needsRedeployment
? 'Workflow changes detected - Redeploy needed'
: 'Workflow deployment information',
activeWorkflowId,
apiKey,
needsRedeployment
)
} else {
// Show existing notification if status hasn't changed
showNotification(apiNotification.id)
}
return
}
// If notification not found but workflow is deployed, fetch deployment info
try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`)
if (!response.ok) throw new Error('Failed to fetch deployment info')
// Get needsRedeployment info from status endpoint
const statusResponse = await fetch(`/api/workflows/${activeWorkflowId}/status`)
const statusData = await statusResponse.json()
const needsRedeployment = statusData.needsRedeployment || false
const { apiKey } = await response.json()
// Create a new notification with the deployment info
createApiNotification(
needsRedeployment
? 'Workflow changes detected - Redeploy needed'
: 'Workflow deployment information',
activeWorkflowId,
apiKey,
needsRedeployment
)
} catch (error) {
addNotification('error', 'Failed to fetch deployment information', activeWorkflowId)
} finally {
setIsDeploying(false)
}
return
}
// If not deployed, proceed with deployment
try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to deploy workflow')
const { apiKey, isDeployed: newDeployStatus, deployedAt } = await response.json()
// Update the store with the deployment status
setDeploymentStatus(newDeployStatus, deployedAt ? new Date(deployedAt) : undefined)
// Reset the needs redeployment flag since we just deployed
setNeedsRedeployment(false)
createApiNotification('Workflow successfully deployed', activeWorkflowId, apiKey)
} catch (error) {
addNotification('error', 'Failed to deploy workflow. Please try again.', activeWorkflowId)
} finally {
setIsDeploying(false)
}
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="relative">
<Button
variant="ghost"
size="icon"
onClick={handleDeploy}
disabled={isDeploying}
className={cn('hover:text-[#802FFF]', isDeployed && 'text-[#802FFF]')}
>
{isDeploying ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Rocket className="h-5 w-5" />
)}
<span className="sr-only">Deploy API</span>
</Button>
{/* Improved redeploy indicator with animation */}
{isDeployed && needsRedeployment && (
<div className="absolute top-0.5 right-0.5 flex items-center justify-center">
<div className="relative">
<div className="absolute inset-0 w-2 h-2 rounded-full bg-amber-500/50 animate-ping"></div>
<div className="relative w-2 h-2 rounded-full bg-amber-500 ring-1 ring-background animate-in zoom-in fade-in duration-300"></div>
</div>
<span className="sr-only">Needs Redeployment</span>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{isDeploying
? 'Deploying...'
: isDeployed && needsRedeployment
? 'Workflow changes detected'
: 'Deployment Settings'}
</TooltipContent>
</Tooltip>
)
}
@@ -10,7 +10,6 @@ import {
History,
Loader2,
Play,
Rocket,
SkipForward,
StepForward,
Store,
@@ -36,7 +35,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Progress } from '@/components/ui/progress'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { useExecutionStore } from '@/stores/execution/store'
@@ -46,6 +45,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
import { DeploymentControls } from './components/deployment-controls/deployment-controls'
import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item'
import { MarketplaceModal } from './components/marketplace-modal/marketplace-modal'
import { NotificationDropdownItem } from './components/notification-dropdown-item/notification-dropdown-item'
@@ -63,8 +63,13 @@ export function ControlBar() {
const router = useRouter()
// Store hooks
const { notifications, getWorkflowNotifications, addNotification, showNotification } =
useNotificationStore()
const {
notifications,
getWorkflowNotifications,
addNotification,
showNotification,
removeNotification,
} = useNotificationStore()
const { history, revertToHistoryState, lastSaved, isDeployed, setDeploymentStatus } =
useWorkflowStore()
const { workflows, updateWorkflow, activeWorkflowId, removeWorkflow } = useWorkflowRegistry()
@@ -90,6 +95,7 @@ export function ControlBar() {
// Status states
const [isDeploying, setIsDeploying] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const [needsRedeployment, setNeedsRedeployment] = useState(false)
// Marketplace modal state
const [isMarketplaceModalOpen, setIsMarketplaceModalOpen] = useState(false)
@@ -134,6 +140,101 @@ export function ControlBar() {
return () => clearInterval(interval)
}, [])
// Listen for workflow changes and check if redeployment is needed
useEffect(() => {
if (!activeWorkflowId || !isDeployed) return
// Create a debounced function to check for changes
let debounceTimer: NodeJS.Timeout | null = null
let lastCheckTime = 0
let pendingChanges = 0
const DEBOUNCE_DELAY = 1000
const THROTTLE_INTERVAL = 3000
// Function to check if redeployment is needed
const checkForChanges = async () => {
// Skip if we're already showing needsRedeployment
if (needsRedeployment) return
// Reset the pending changes counter
pendingChanges = 0
lastCheckTime = Date.now()
try {
// Get the deployed state from the API
const response = await fetch(`/api/workflows/${activeWorkflowId}/status`)
if (response.ok) {
const data = await response.json()
// If the API says we need redeployment, update our state and the store
if (data.needsRedeployment) {
setNeedsRedeployment(true)
// Also update the store state so other components can access this flag
useWorkflowStore.getState().setNeedsRedeploymentFlag(true)
}
}
} catch (error) {
logger.error('Failed to check workflow change status:', { error })
}
}
// Debounced check function
const debouncedCheck = () => {
// Increment the pending changes counter
pendingChanges++
// Clear any existing timer
if (debounceTimer) {
clearTimeout(debounceTimer)
}
// If we recently checked, and it's within throttle interval, wait longer
const timeElapsed = Date.now() - lastCheckTime
if (timeElapsed < THROTTLE_INTERVAL && lastCheckTime > 0) {
// Wait until the throttle interval has passed
const adjustedDelay = Math.max(THROTTLE_INTERVAL - timeElapsed, DEBOUNCE_DELAY)
debounceTimer = setTimeout(() => {
// Only check if we have pending changes
if (pendingChanges > 0) {
checkForChanges()
}
}, adjustedDelay)
} else {
// Standard debounce delay if we haven't checked recently
debounceTimer = setTimeout(() => {
// Only check if we have pending changes
if (pendingChanges > 0) {
checkForChanges()
}
}, DEBOUNCE_DELAY)
}
}
// Subscribe to workflow store changes
const workflowUnsubscribe = useWorkflowStore.subscribe(debouncedCheck)
// Also subscribe to subblock store changes
const subBlockUnsubscribe = useSubBlockStore.subscribe((state) => {
// Only check for the active workflow
if (!activeWorkflowId || !isDeployed || needsRedeployment) return
// Only trigger when there is an update to the current workflow's subblocks
const workflowSubBlocks = state.workflowValues[activeWorkflowId]
if (workflowSubBlocks && Object.keys(workflowSubBlocks).length > 0) {
debouncedCheck()
}
})
return () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
workflowUnsubscribe()
subBlockUnsubscribe()
}
}, [activeWorkflowId, isDeployed, needsRedeployment])
// Check deployment and publication status on mount or when activeWorkflowId changes
useEffect(() => {
async function checkStatus() {
@@ -160,6 +261,8 @@ export function ControlBar() {
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined
)
setNeedsRedeployment(data.needsRedeployment)
useWorkflowStore.getState().setNeedsRedeploymentFlag(data.needsRedeployment)
}
} catch (error) {
logger.error('Failed to check workflow status:', { error })
@@ -168,6 +271,53 @@ export function ControlBar() {
checkStatus()
}, [activeWorkflowId, setDeploymentStatus])
// Listen for deployment status changes
useEffect(() => {
// When deployment status changes and isDeployed becomes true,
// that means a deployment just occurred, so reset the needsRedeployment flag
if (isDeployed) {
setNeedsRedeployment(false)
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
}
}, [isDeployed])
// Add a listener for the needsRedeployment flag in the workflow store
useEffect(() => {
const unsubscribe = useWorkflowStore.subscribe((state) => {
// Update local state when the store flag changes
if (state.needsRedeployment !== undefined) {
setNeedsRedeployment(state.needsRedeployment)
}
})
return () => unsubscribe()
}, [])
// Add a manual method to update the deployment status and clear the needsRedeployment flag
const updateDeploymentStatusAndClearFlag = (isDeployed: boolean, deployedAt?: Date) => {
setDeploymentStatus(isDeployed, deployedAt)
setNeedsRedeployment(false)
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
}
// Update existing API notifications when needsRedeployment changes
useEffect(() => {
if (!activeWorkflowId) return
const apiNotification = notifications.find(
(n) => n.type === 'api' && n.workflowId === activeWorkflowId && n.options?.isPersistent
)
if (apiNotification && apiNotification.options?.needsRedeployment !== needsRedeployment) {
// If there's an existing API notification and its state doesn't match, update it
if (apiNotification.isVisible) {
// Only update if it's currently showing to the user
removeNotification(apiNotification.id)
// The DeploymentControls component will handle showing the appropriate notification
}
}
}, [needsRedeployment, activeWorkflowId, notifications, removeNotification, addNotification])
/**
* Workflow name handlers
*/
@@ -216,157 +366,6 @@ export function ControlBar() {
removeWorkflow(activeWorkflowId)
}
/**
* Get an example of the input format for the workflow
*/
const getInputFormatExample = () => {
let inputFormatExample = ''
try {
// Find the starter block in the workflow
const blocks = Object.values(useWorkflowStore.getState().blocks)
const starterBlock = blocks.find((block) => block.type === 'starter')
if (starterBlock) {
const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat')
// If input format is defined, create an example
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
const exampleData: Record<string, any> = {}
// Create example values for each field
inputFormat.forEach((field) => {
if (field.name) {
switch (field.type) {
case 'string':
exampleData[field.name] = 'example'
break
case 'number':
exampleData[field.name] = 42
break
case 'boolean':
exampleData[field.name] = true
break
case 'object':
exampleData[field.name] = { key: 'value' }
break
case 'array':
exampleData[field.name] = [1, 2, 3]
break
}
}
})
inputFormatExample = ` -d '${JSON.stringify(exampleData)}'`
}
}
} catch (error) {
console.error('Error generating input format example:', error)
}
return inputFormatExample
}
/**
* Workflow deployment handler
*/
const handleDeploy = async () => {
if (!activeWorkflowId) return
const inputFormatExample = getInputFormatExample()
// If already deployed, show the API info
if (isDeployed) {
// Try to find an existing API notification
const apiNotification = notifications.find(
(n) => n.type === 'api' && n.workflowId === activeWorkflowId
)
if (apiNotification) {
// Show the existing notification
showNotification(apiNotification.id)
return
}
// If notification not found but workflow is deployed, fetch deployment info
try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`)
if (!response.ok) throw new Error('Failed to fetch deployment info')
const { apiKey } = await response.json()
const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${activeWorkflowId}/execute`
// Create a new notification with the deployment info
addNotification('api', 'Workflow deployment information', activeWorkflowId, {
isPersistent: true,
sections: [
{
label: 'API Endpoint',
content: endpoint,
},
{
label: 'API Key',
content: apiKey || 'No API key found. Visit your account settings to create one.',
},
{
label: 'Example curl command',
content: apiKey
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`
: `You need an API key to call this endpoint. Visit your account settings to create one.`,
},
],
})
} catch (error) {
addNotification('error', 'Failed to fetch deployment information', activeWorkflowId)
} finally {
setIsDeploying(false)
}
return
}
// If not deployed, proceed with deployment
try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to deploy workflow')
const { apiKey, isDeployed: newDeployStatus, deployedAt } = await response.json()
const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${activeWorkflowId}/execute`
// Update the store with the deployment status
setDeploymentStatus(newDeployStatus, deployedAt ? new Date(deployedAt) : undefined)
addNotification('api', 'Workflow successfully deployed', activeWorkflowId, {
isPersistent: true,
sections: [
{
label: 'API Endpoint',
content: endpoint,
},
{
label: 'API Key',
content: apiKey || 'No API key found. Visit your account settings to create one.',
},
{
label: 'Example curl command',
content: apiKey
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`
: `You need an API key to call this endpoint. Visit your account settings to create one.`,
},
],
})
} catch (error) {
addNotification('error', 'Failed to deploy workflow. Please try again.', activeWorkflowId)
} finally {
setIsDeploying(false)
}
}
/**
* Handle opening marketplace modal or showing published status
*/
@@ -514,27 +513,11 @@ export function ControlBar() {
* Render deploy button with tooltip
*/
const renderDeployButton = () => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleDeploy}
disabled={isDeploying}
className={cn('hover:text-[#802FFF]', isDeployed && 'text-[#802FFF]')}
>
{isDeploying ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Rocket className="h-5 w-5" />
)}
<span className="sr-only">Deploy API</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{isDeploying ? 'Deploying...' : isDeployed ? 'Deployed' : 'Deploy as API'}
</TooltipContent>
</Tooltip>
<DeploymentControls
activeWorkflowId={activeWorkflowId}
needsRedeployment={needsRedeployment}
setNeedsRedeployment={setNeedsRedeployment}
/>
)
/**
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Copy, Eye, EyeOff, Info, Rocket, Store, Terminal, X } from 'lucide-react'
import { Info, Rocket, Store, Terminal, X } from 'lucide-react'
import { ErrorIcon } from '@/components/icons'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
@@ -16,11 +16,7 @@ import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import {
MAX_VISIBLE_NOTIFICATIONS,
NOTIFICATION_TIMEOUT,
useNotificationStore,
} from '@/stores/notifications/store'
import { MAX_VISIBLE_NOTIFICATIONS, useNotificationStore } from '@/stores/notifications/store'
import { Notification } from '@/stores/notifications/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -306,7 +302,7 @@ interface NotificationAlertProps {
onHide: (id: string) => void
}
function NotificationAlert({ notification, isFading, onHide }: NotificationAlertProps) {
export function NotificationAlert({ notification, isFading, onHide }: NotificationAlertProps) {
const { id, type, message, options, workflowId } = notification
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [showApiKey, setShowApiKey] = useState(false)
@@ -315,6 +311,15 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
isDeployed: state.isDeployed,
}))
// Create a function to clear the redeployment flag and update deployment status
const updateDeploymentStatus = (isDeployed: boolean, deployedAt?: Date) => {
// Update deployment status in workflow store
setDeploymentStatus(isDeployed, deployedAt)
// Manually update the needsRedeployment flag in workflow store
useWorkflowStore.getState().setNeedsRedeploymentFlag(false)
}
const Icon = NotificationIcon[type]
const handleDeleteApi = async () => {
@@ -328,7 +333,7 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
if (!response.ok) throw new Error('Failed to delete API deployment')
// Update deployment status in the store
setDeploymentStatus(false)
updateDeploymentStatus(false)
// Close the notification
onHide(id)
@@ -444,10 +449,17 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
<div className="flex items-center gap-1.5">
<div className="relative flex items-center justify-center">
{isDeployed ? (
<>
<div className="absolute h-3 w-3 rounded-full bg-green-500/20 animate-ping"></div>
<div className="relative h-2 w-2 rounded-full bg-green-500"></div>
</>
options?.needsRedeployment ? (
<>
<div className="absolute h-3 w-3 rounded-full bg-amber-500/20 animate-ping"></div>
<div className="relative h-2 w-2 rounded-full bg-amber-500"></div>
</>
) : (
<>
<div className="absolute h-3 w-3 rounded-full bg-green-500/20 animate-ping"></div>
<div className="relative h-2 w-2 rounded-full bg-green-500"></div>
</>
)
) : (
<>
<div className="absolute h-3 w-3 rounded-full bg-red-500/20 animate-ping"></div>
@@ -458,22 +470,86 @@ function NotificationAlert({ notification, isFading, onHide }: NotificationAlert
<span
className={cn(
'text-xs font-medium',
isDeployed ? ApiStatusStyles.active : ApiStatusStyles.inactive
isDeployed
? options?.needsRedeployment
? 'text-amber-600 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400'
: ApiStatusStyles.active
: ApiStatusStyles.inactive
)}
>
{isDeployed ? 'Active' : 'Inactive'}
{isDeployed
? options?.needsRedeployment
? 'Changes Detected'
: 'Active'
: 'Inactive'}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={!isDeployed}
>
Delete
</Button>
<div className="flex gap-2">
{options?.needsRedeployment && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs font-medium text-muted-foreground hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
onClick={async () => {
if (!workflowId) return
try {
// Call the deploy endpoint to redeploy the workflow
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
})
if (!response.ok) throw new Error('Failed to redeploy workflow')
// Get the response data
const data = await response.json()
// Get the API key from the existing notification sections
const apiKey =
options?.sections?.find((s) => s.label === 'API Key')?.content || ''
const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/api/workflows/${workflowId}/execute`
// Get input format example for curl command
const inputFormatExample = ''
// Update deployment status in the store (resets needsRedeployment flag)
updateDeploymentStatus(
data.isDeployed,
data.deployedAt ? new Date(data.deployedAt) : undefined
)
// First close this notification
onHide(id)
// Show a temporary success notification without creating another API notification
useNotificationStore
.getState()
.addNotification(
'info',
'Workflow successfully redeployed',
workflowId,
{ isPersistent: false }
)
} catch (error) {
logger.error('Error redeploying workflow:', { error })
}
}}
>
Redeploy
</Button>
)}
{isDeployed && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteDialogOpen(true)}
>
Delete
</Button>
)}
</div>
</div>
</AlertDescription>
</div>
@@ -0,0 +1 @@
ALTER TABLE "workflow" ADD COLUMN "deployed_state" json;
+37 -109
View File
@@ -93,12 +93,8 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -170,12 +166,8 @@
"name": "api_key_user_id_user_id_fk",
"tableFrom": "api_key",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -185,9 +177,7 @@
"api_key_key_unique": {
"name": "api_key_key_unique",
"nullsNotDistinct": false,
"columns": [
"key"
]
"columns": ["key"]
}
},
"policies": {},
@@ -230,12 +220,8 @@
"name": "environment_user_id_user_id_fk",
"tableFrom": "environment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -245,9 +231,7 @@
"environment_user_id_unique": {
"name": "environment_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
"columns": ["user_id"]
}
},
"policies": {},
@@ -341,12 +325,8 @@
"name": "marketplace_workflow_id_workflow_id_fk",
"tableFrom": "marketplace",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -354,12 +334,8 @@
"name": "marketplace_author_id_user_id_fk",
"tableFrom": "marketplace",
"tableTo": "user",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["author_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -428,12 +404,8 @@
"name": "marketplace_star_marketplace_id_marketplace_id_fk",
"tableFrom": "marketplace_star",
"tableTo": "marketplace",
"columnsFrom": [
"marketplace_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["marketplace_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -441,12 +413,8 @@
"name": "marketplace_star_user_id_user_id_fk",
"tableFrom": "marketplace_star",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
@@ -516,12 +484,8 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -531,9 +495,7 @@
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@@ -576,12 +538,8 @@
"name": "settings_user_id_user_id_fk",
"tableFrom": "settings",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -591,9 +549,7 @@
"settings_user_id_unique": {
"name": "settings_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
"columns": ["user_id"]
}
},
"policies": {},
@@ -654,9 +610,7 @@
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -735,12 +689,8 @@
"name": "user_stats_user_id_user_id_fk",
"tableFrom": "user_stats",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -750,9 +700,7 @@
"user_stats_user_id_unique": {
"name": "user_stats_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
"columns": ["user_id"]
}
},
"policies": {},
@@ -853,9 +801,7 @@
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@@ -940,12 +886,8 @@
"name": "webhook_workflow_id_workflow_id_fk",
"tableFrom": "webhook",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1075,12 +1017,8 @@
"name": "workflow_user_id_user_id_fk",
"tableFrom": "workflow",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1157,12 +1095,8 @@
"name": "workflow_logs_workflow_id_workflow_id_fk",
"tableFrom": "workflow_logs",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1234,12 +1168,8 @@
"name": "workflow_schedule_workflow_id_workflow_id_fk",
"tableFrom": "workflow_schedule",
"tableTo": "workflow",
"columnsFrom": [
"workflow_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1249,9 +1179,7 @@
"workflow_schedule_workflow_id_unique": {
"name": "workflow_schedule_workflow_id_unique",
"nullsNotDistinct": false,
"columns": [
"workflow_id"
]
"columns": ["workflow_id"]
}
},
"policies": {},
@@ -1270,4 +1198,4 @@
"schemas": {},
"tables": {}
}
}
}
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -183,6 +183,13 @@
"when": 1743643010845,
"tag": "0025_curved_jubilee",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1743654486007,
"tag": "0026_daily_killraven",
"breakpoints": true
}
]
}
}
+2 -1
View File
@@ -72,13 +72,14 @@ export const workflow = pgTable('workflow', {
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
isDeployed: boolean('is_deployed').notNull().default(false),
deployedState: json('deployed_state'),
deployedAt: timestamp('deployed_at'),
collaborators: json('collaborators').notNull().default('[]'),
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'),
marketplaceData: json('marketplace_data'), // Format: { id: string, status: 'owner' | 'temp' | 'star' }
// These columns are kept for backward compatibility during migration
// and should be marked as deprecated
// @deprecated - Use marketplaceData instead
+227
View File
@@ -1,5 +1,6 @@
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { WorkflowState } from '@/stores/workflows/workflow/types'
import { db } from '@/db'
import { userStats, workflow as workflowTable } from '@/db/schema'
@@ -87,3 +88,229 @@ export async function updateWorkflowRunCounts(workflowId: string, runs: number =
throw error
}
}
/**
* Normalize a value for consistent comparison by sorting object keys
* @param value - The value to normalize
* @returns A normalized version of the value
*/
function normalizeValue(value: any): any {
// If not an object or array, return as is
if (value === null || value === undefined || typeof value !== 'object') {
return value
}
// Handle arrays by normalizing each element
if (Array.isArray(value)) {
return value.map(normalizeValue)
}
// For objects, sort keys and normalize each value
const sortedObj: Record<string, any> = {}
// Get all keys and sort them
const sortedKeys = Object.keys(value).sort()
// Reconstruct object with sorted keys and normalized values
for (const key of sortedKeys) {
sortedObj[key] = normalizeValue(value[key])
}
return sortedObj
}
/**
* Generate a normalized JSON string for comparison
* @param value - The value to normalize and stringify
* @returns A normalized JSON string
*/
function normalizedStringify(value: any): string {
return JSON.stringify(normalizeValue(value))
}
/**
* Compare the current workflow state with the deployed state to detect meaningful changes
* @param currentState - The current workflow state
* @param deployedState - The deployed workflow state
* @returns True if there are meaningful changes, false if only position changes or no changes
*/
export function hasWorkflowChanged(
currentState: WorkflowState,
deployedState: WorkflowState | null
): boolean {
// If no deployed state exists, then the workflow has changed
if (!deployedState) return true
// 1. Compare edges (connections between blocks)
// First check length
const currentEdges = currentState.edges || []
const deployedEdges = deployedState.edges || []
// Create sorted, normalized representations of the edges for more reliable comparison
const normalizedCurrentEdges = currentEdges
.map((edge) => ({
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
targetHandle: edge.targetHandle,
}))
.sort((a, b) =>
`${a.source}-${a.sourceHandle}-${a.target}-${a.targetHandle}`.localeCompare(
`${b.source}-${b.sourceHandle}-${b.target}-${b.targetHandle}`
)
)
const normalizedDeployedEdges = deployedEdges
.map((edge) => ({
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
targetHandle: edge.targetHandle,
}))
.sort((a, b) =>
`${a.source}-${a.sourceHandle}-${a.target}-${a.targetHandle}`.localeCompare(
`${b.source}-${b.sourceHandle}-${b.target}-${b.targetHandle}`
)
)
// Compare the normalized edge arrays
if (
normalizedStringify(normalizedCurrentEdges) !== normalizedStringify(normalizedDeployedEdges)
) {
return true
}
// 2. Compare blocks and their configurations
const currentBlockIds = Object.keys(currentState.blocks || {}).sort()
const deployedBlockIds = Object.keys(deployedState.blocks || {}).sort()
// Check if the block IDs are different
if (
currentBlockIds.length !== deployedBlockIds.length ||
normalizedStringify(currentBlockIds) !== normalizedStringify(deployedBlockIds)
) {
return true
}
// 3. Build normalized representations of blocks for comparison
const normalizedCurrentBlocks: Record<string, any> = {}
const normalizedDeployedBlocks: Record<string, any> = {}
for (const blockId of currentBlockIds) {
const currentBlock = currentState.blocks[blockId]
const deployedBlock = deployedState.blocks[blockId]
// Skip position as it doesn't affect functionality
const { position: currentPosition, ...currentBlockProps } = currentBlock
const { position: deployedPosition, ...deployedBlockProps } = deployedBlock
// Extract and normalize subBlocks separately for cleaner comparison
const currentSubBlocks = currentBlockProps.subBlocks || {}
const deployedSubBlocks = deployedBlockProps.subBlocks || {}
// Create normalized block representations without position or subBlocks
normalizedCurrentBlocks[blockId] = {
...currentBlockProps,
subBlocks: undefined,
}
normalizedDeployedBlocks[blockId] = {
...deployedBlockProps,
subBlocks: undefined,
}
// Handle subBlocks separately
const normalizedCurrentSubBlocks: Record<string, any> = {}
const normalizedDeployedSubBlocks: Record<string, any> = {}
// Get all subBlock IDs from both states
const allSubBlockIds = [
...new Set([...Object.keys(currentSubBlocks), ...Object.keys(deployedSubBlocks)]),
].sort()
// Check if any subBlocks are missing in either state
if (Object.keys(currentSubBlocks).length !== Object.keys(deployedSubBlocks).length) {
return true
}
// Normalize and compare each subBlock
for (const subBlockId of allSubBlockIds) {
// If the subBlock doesn't exist in either state, there's a difference
if (!currentSubBlocks[subBlockId] || !deployedSubBlocks[subBlockId]) {
return true
}
// Get values with special handling for null/undefined
const currentValue = currentSubBlocks[subBlockId].value ?? null
const deployedValue = deployedSubBlocks[subBlockId].value ?? null
// For string values, compare directly to catch even small text changes
if (typeof currentValue === 'string' && typeof deployedValue === 'string') {
if (currentValue !== deployedValue) {
return true
}
} else {
// For other types, use normalized comparison
const normalizedCurrentValue = normalizeValue(currentValue)
const normalizedDeployedValue = normalizeValue(deployedValue)
if (
normalizedStringify(normalizedCurrentValue) !==
normalizedStringify(normalizedDeployedValue)
) {
return true
}
}
// Compare type and other properties
const currentSubBlockWithoutValue = { ...currentSubBlocks[subBlockId], value: undefined }
const deployedSubBlockWithoutValue = { ...deployedSubBlocks[subBlockId], value: undefined }
if (
normalizedStringify(currentSubBlockWithoutValue) !==
normalizedStringify(deployedSubBlockWithoutValue)
) {
return true
}
}
// Skip the normalization of subBlocks since we've already done detailed comparison above
const blocksEqual =
normalizedStringify(normalizedCurrentBlocks[blockId]) ===
normalizedStringify(normalizedDeployedBlocks[blockId])
// We've already compared subBlocks in detail
if (!blocksEqual) {
return true
}
}
// 4. Compare loops
const currentLoops = currentState.loops || {}
const deployedLoops = deployedState.loops || {}
const currentLoopIds = Object.keys(currentLoops).sort()
const deployedLoopIds = Object.keys(deployedLoops).sort()
if (
currentLoopIds.length !== deployedLoopIds.length ||
normalizedStringify(currentLoopIds) !== normalizedStringify(deployedLoopIds)
) {
return true
}
// Compare each loop with normalized values
for (const loopId of currentLoopIds) {
const normalizedCurrentLoop = normalizeValue(currentLoops[loopId])
const normalizedDeployedLoop = normalizeValue(deployedLoops[loopId])
if (
normalizedStringify(normalizedCurrentLoop) !== normalizedStringify(normalizedDeployedLoop)
) {
return true
}
}
// No meaningful changes detected
return false
}
+268 -2147
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -21,6 +21,7 @@ export interface NotificationOptions {
copyableContent?: string
isPersistent?: boolean
sections?: NotificationSection[]
needsRedeployment?: boolean
}
export interface NotificationStore {
+6 -1
View File
@@ -19,7 +19,7 @@ const initialState = {
lastSaved: undefined,
isDeployed: false,
deployedAt: undefined,
isPublished: false,
needsRedeployment: false,
history: {
past: [],
present: {
@@ -42,6 +42,10 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
canRedo: () => false,
revertToHistoryState: () => {},
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => {
set({ needsRedeployment })
},
addBlock: (id: string, type: string, name: string, position: Position) => {
const blockConfig = getBlock(type)
if (!blockConfig) return
@@ -614,6 +618,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
...get(),
isDeployed,
deployedAt: deployedAt || (isDeployed ? new Date() : undefined),
needsRedeployment: isDeployed ? false : get().needsRedeployment,
}
set(newState)
+2
View File
@@ -41,6 +41,7 @@ export interface WorkflowState {
lastUpdate?: number
isDeployed?: boolean
deployedAt?: Date
needsRedeployment?: boolean
}
export interface WorkflowActions {
@@ -62,6 +63,7 @@ export interface WorkflowActions {
updateLoopForEachItems: (loopId: string, items: string) => void
triggerUpdate: () => void
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions