mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+253
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+268
-2147
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ export interface NotificationOptions {
|
||||
copyableContent?: string
|
||||
isPersistent?: boolean
|
||||
sections?: NotificationSection[]
|
||||
needsRedeployment?: boolean
|
||||
}
|
||||
|
||||
export interface NotificationStore {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user