feat(webhook-triggers): multiple webhook trigger blocks (#725)

* checkpoint

* correctly clear status

* works

* improvements

* fix build issue

* add docs

* remove comments, logs

* fix migration to have foreign ref key

* change filename to snake case

* modified dropdown to match combobox styling

* added block type for triggers, split out schedule block into a separate trigger

* added chat trigger to start block, removed startAt from schedule modal, added chat fields into tag dropdown for start block

* removed startedAt for schedules, separated schedules into a separate block, removed unique constraint on scheule workflows and added combo constraint on workflowid/blockid and schedule

* icons fix

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
Vikhyath Mondreti
2025-07-19 21:22:51 -07:00
committed by GitHub
co-authored by Waleed Latif
parent 7b73dfb462
commit 5ee66252ed
45 changed files with 7028 additions and 627 deletions
+6 -5
View File
@@ -4,12 +4,13 @@
"agent",
"api",
"condition",
"function",
"evaluator",
"router",
"response",
"workflow",
"function",
"loop",
"parallel"
"parallel",
"response",
"router",
"webhook_trigger",
"workflow"
]
}
@@ -0,0 +1,113 @@
---
title: Webhook Trigger
description: Trigger workflow execution from external webhooks
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { Card, Cards } from 'fumadocs-ui/components/card'
import { ThemeImage } from '@/components/ui/theme-image'
The Webhook Trigger block allows external services to trigger your workflow execution through HTTP webhooks. Unlike starter blocks, webhook triggers are pure input sources that start workflows without requiring manual intervention.
<ThemeImage
lightSrc="/static/light/webhooktrigger-light.png"
darkSrc="/static/dark/webhooktrigger-dark.png"
alt="Webhook Trigger Block"
width={350}
height={175}
/>
<Callout>
Webhook triggers cannot receive incoming connections and do not expose webhook data to the workflow. They serve as pure execution triggers.
</Callout>
## Overview
The Webhook Trigger block enables you to:
<Steps>
<Step>
<strong>Receive external triggers</strong>: Accept HTTP requests from external services
</Step>
<Step>
<strong>Support multiple providers</strong>: Handle webhooks from Slack, Gmail, GitHub, and more
</Step>
<Step>
<strong>Start workflows automatically</strong>: Execute workflows without manual intervention
</Step>
<Step>
<strong>Provide secure endpoints</strong>: Generate unique webhook URLs for each trigger
</Step>
</Steps>
## How It Works
The Webhook Trigger block operates as a pure input source:
1. **Generate Endpoint** - Creates a unique webhook URL when configured
2. **Receive Request** - Accepts HTTP POST requests from external services
3. **Trigger Execution** - Starts the workflow when a valid request is received
## Configuration Options
### Webhook Provider
Choose from supported service providers:
<Cards>
<Card title="Slack" href="#">
Receive events from Slack apps and bots
</Card>
<Card title="Gmail" href="#">
Handle email-based triggers and notifications
</Card>
<Card title="Airtable" href="#">
Respond to database changes
</Card>
<Card title="Telegram" href="#">
Process bot messages and updates
</Card>
<Card title="WhatsApp" href="#">
Handle messaging events
</Card>
<Card title="GitHub" href="#">
Process repository events and pull requests
</Card>
<Card title="Discord" href="#">
Respond to Discord server events
</Card>
<Card title="Stripe" href="#">
Handle payment and subscription events
</Card>
</Cards>
### Generic Webhooks
For custom integrations or services not listed above, use the **Generic** provider. This option accepts HTTP POST requests from any client and provides flexible authentication options:
- **Optional Authentication** - Configure Bearer token or custom header authentication
- **IP Restrictions** - Limit access to specific IP addresses
- **Request Deduplication** - Automatic duplicate request detection using content hashing
- **Flexible Headers** - Support for custom authentication header names
The Generic provider is ideal for internal services, custom applications, or third-party tools that need to trigger workflows via standard HTTP requests.
### Webhook Configuration
Configure provider-specific settings:
- **Webhook URL** - Automatically generated unique endpoint
- **Provider Settings** - Authentication and validation options
- **Security** - Built-in rate limiting and provider-specific authentication
## Best Practices
- **Use unique webhook URLs** for each integration to maintain security
- **Configure proper authentication** when supported by the provider
- **Keep workflows independent** of webhook payload structure
- **Test webhook endpoints** before deploying to production
- **Monitor webhook delivery** through provider dashboards
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+23
View File
@@ -141,6 +141,29 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
})
}
if (action === 'disable' || (body.status && body.status === 'disabled')) {
if (schedule.status === 'disabled') {
return NextResponse.json({ message: 'Schedule is already disabled' }, { status: 200 })
}
const now = new Date()
await db
.update(workflowSchedule)
.set({
status: 'disabled',
updatedAt: now,
nextRunAt: null, // Clear next run time when disabled
})
.where(eq(workflowSchedule.id, scheduleId))
logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`)
return NextResponse.json({
message: 'Schedule disabled successfully',
})
}
logger.warn(`[${requestId}] Unsupported update action for schedule: ${scheduleId}`)
return NextResponse.json({ error: 'Unsupported update action' }, { status: 400 })
} catch (error) {
+11 -5
View File
@@ -46,10 +46,13 @@ function calculateNextRunTime(
schedule: typeof workflowSchedule.$inferSelect,
blocks: Record<string, BlockState>
): Date {
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
if (!starterBlock) throw new Error('No starter block found')
const scheduleType = getSubBlockValue(starterBlock, 'scheduleType')
const scheduleValues = getScheduleTimeValues(starterBlock)
// Look for either starter block or schedule trigger block
const scheduleBlock = Object.values(blocks).find(
(block) => block.type === 'starter' || block.type === 'schedule'
)
if (!scheduleBlock) throw new Error('No starter or schedule block found')
const scheduleType = getSubBlockValue(scheduleBlock, 'scheduleType')
const scheduleValues = getScheduleTimeValues(scheduleBlock)
if (schedule.cronExpression) {
const cron = new Cron(schedule.cronExpression)
@@ -401,7 +404,10 @@ export async function GET() {
// Set up enhanced logging on the executor
loggingSession.setupExecutor(executor)
const result = await executor.execute(schedule.workflowId)
const result = await executor.execute(
schedule.workflowId,
schedule.blockId || undefined
)
const executionResult =
'stream' in result && 'execution' in result ? result.execution : result
+76 -21
View File
@@ -1,5 +1,5 @@
import crypto from 'crypto'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -19,6 +19,7 @@ const logger = createLogger('ScheduledAPI')
const ScheduleRequestSchema = z.object({
workflowId: z.string(),
blockId: z.string().optional(),
state: z.object({
blocks: z.record(z.any()),
edges: z.array(z.any()),
@@ -66,6 +67,7 @@ export async function GET(req: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
const url = new URL(req.url)
const workflowId = url.searchParams.get('workflowId')
const blockId = url.searchParams.get('blockId')
const mode = url.searchParams.get('mode')
if (mode && mode !== 'schedule') {
@@ -92,10 +94,16 @@ export async function GET(req: NextRequest) {
recentRequests.set(workflowId, now)
}
// Build query conditions
const conditions = [eq(workflowSchedule.workflowId, workflowId)]
if (blockId) {
conditions.push(eq(workflowSchedule.blockId, blockId))
}
const schedule = await db
.select()
.from(workflowSchedule)
.where(eq(workflowSchedule.workflowId, workflowId))
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
.limit(1)
const headers = new Headers()
@@ -138,36 +146,81 @@ export async function POST(req: NextRequest) {
}
const body = await req.json()
const { workflowId, state } = ScheduleRequestSchema.parse(body)
const { workflowId, blockId, state } = ScheduleRequestSchema.parse(body)
logger.info(`[${requestId}] Processing schedule update for workflow ${workflowId}`)
const starterBlock = Object.values(state.blocks).find(
(block: any) => block.type === 'starter'
) as BlockState | undefined
if (!starterBlock) {
logger.warn(`[${requestId}] No starter block found in workflow ${workflowId}`)
return NextResponse.json({ error: 'No starter block found in workflow' }, { status: 400 })
// Find the target block - prioritize the specific blockId if provided
let targetBlock: BlockState | undefined
if (blockId) {
// If blockId is provided, find that specific block
targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as
| BlockState
| undefined
} else {
// Fallback: find either starter block or schedule trigger block
targetBlock = Object.values(state.blocks).find(
(block: any) => block.type === 'starter' || block.type === 'schedule'
) as BlockState | undefined
}
const startWorkflow = getSubBlockValue(starterBlock, 'startWorkflow')
const scheduleType = getSubBlockValue(starterBlock, 'scheduleType')
if (!targetBlock) {
logger.warn(`[${requestId}] No starter or schedule block found in workflow ${workflowId}`)
return NextResponse.json(
{ error: 'No starter or schedule block found in workflow' },
{ status: 400 }
)
}
const scheduleValues = getScheduleTimeValues(starterBlock)
const startWorkflow = getSubBlockValue(targetBlock, 'startWorkflow')
const scheduleType = getSubBlockValue(targetBlock, 'scheduleType')
const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, starterBlock)
const scheduleValues = getScheduleTimeValues(targetBlock)
if (startWorkflow !== 'schedule' && !hasScheduleConfig) {
const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock)
// For schedule trigger blocks, we always have valid configuration
// For starter blocks, check if schedule is selected and has valid config
const isScheduleBlock = targetBlock.type === 'schedule'
const hasValidConfig = isScheduleBlock || (startWorkflow === 'schedule' && hasScheduleConfig)
// Debug logging to understand why validation fails
logger.info(`[${requestId}] Schedule validation debug:`, {
workflowId,
blockId,
blockType: targetBlock.type,
isScheduleBlock,
startWorkflow,
scheduleType,
hasScheduleConfig,
hasValidConfig,
scheduleValues: {
minutesInterval: scheduleValues.minutesInterval,
dailyTime: scheduleValues.dailyTime,
cronExpression: scheduleValues.cronExpression,
},
})
if (!hasValidConfig) {
logger.info(
`[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found`
)
await db.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId))
// Build delete conditions
const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)]
if (blockId) {
deleteConditions.push(eq(workflowSchedule.blockId, blockId))
}
await db
.delete(workflowSchedule)
.where(deleteConditions.length > 1 ? and(...deleteConditions) : deleteConditions[0])
return NextResponse.json({ message: 'Schedule removed' })
}
if (startWorkflow !== 'schedule') {
if (isScheduleBlock) {
logger.info(`[${requestId}] Processing schedule trigger block for workflow ${workflowId}`)
} else if (startWorkflow !== 'schedule') {
logger.info(
`[${requestId}] Setting workflow to scheduled mode based on schedule configuration`
)
@@ -177,12 +230,12 @@ export async function POST(req: NextRequest) {
let cronExpression: string | null = null
let nextRunAt: Date | undefined
const timezone = getSubBlockValue(starterBlock, 'timezone') || 'UTC'
const timezone = getSubBlockValue(targetBlock, 'timezone') || 'UTC'
try {
const defaultScheduleType = scheduleType || 'daily'
const scheduleStartAt = getSubBlockValue(starterBlock, 'scheduleStartAt')
const scheduleTime = getSubBlockValue(starterBlock, 'scheduleTime')
const scheduleStartAt = getSubBlockValue(targetBlock, 'scheduleStartAt')
const scheduleTime = getSubBlockValue(targetBlock, 'scheduleTime')
logger.debug(`[${requestId}] Schedule configuration:`, {
type: defaultScheduleType,
@@ -218,6 +271,7 @@ export async function POST(req: NextRequest) {
const values = {
id: crypto.randomUUID(),
workflowId,
blockId,
cronExpression,
triggerType: 'schedule',
createdAt: new Date(),
@@ -229,6 +283,7 @@ export async function POST(req: NextRequest) {
}
const setValues = {
blockId,
cronExpression,
updatedAt: new Date(),
nextRunAt,
@@ -241,7 +296,7 @@ export async function POST(req: NextRequest) {
.insert(workflowSchedule)
.values(values)
.onConflictDoUpdate({
target: [workflowSchedule.workflowId],
target: [workflowSchedule.workflowId, workflowSchedule.blockId],
set: setValues,
})
+21 -4
View File
@@ -26,15 +26,30 @@ export async function GET(request: NextRequest) {
// Get query parameters
const { searchParams } = new URL(request.url)
const workflowId = searchParams.get('workflowId')
const blockId = searchParams.get('blockId')
if (workflowId && !blockId) {
// For now, allow the call but return empty results to avoid breaking the UI
return NextResponse.json({ webhooks: [] }, { status: 200 })
}
logger.debug(`[${requestId}] Fetching webhooks for user ${session.user.id}`, {
filteredByWorkflow: !!workflowId,
filteredByBlock: !!blockId,
})
// Create where condition
const whereCondition = workflowId
? and(eq(workflow.userId, session.user.id), eq(webhook.workflowId, workflowId))
: eq(workflow.userId, session.user.id)
const conditions = [eq(workflow.userId, session.user.id)]
if (workflowId) {
conditions.push(eq(webhook.workflowId, workflowId))
}
if (blockId) {
conditions.push(eq(webhook.blockId, blockId))
}
const whereCondition = conditions.length > 1 ? and(...conditions) : conditions[0]
const webhooks = await db
.select({
@@ -68,7 +83,7 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { workflowId, path, provider, providerConfig } = body
const { workflowId, path, provider, providerConfig, blockId } = body
// Validate input
if (!workflowId || !path) {
@@ -115,6 +130,7 @@ export async function POST(request: NextRequest) {
const updatedResult = await db
.update(webhook)
.set({
blockId,
provider,
providerConfig,
isActive: true,
@@ -132,6 +148,7 @@ export async function POST(request: NextRequest) {
.values({
id: webhookId,
workflowId,
blockId,
path,
provider,
providerConfig,
@@ -64,7 +64,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
deploymentStatuses: deployedState.deploymentStatuses || {},
hasActiveSchedule: deployedState.hasActiveSchedule || false,
hasActiveWebhook: deployedState.hasActiveWebhook || false,
})
-1
View File
@@ -119,7 +119,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
finalWorkflowData.state = {
// Default values for expected properties
deploymentStatuses: {},
hasActiveSchedule: false,
hasActiveWebhook: false,
// Preserve any existing state properties
...existingState,
@@ -103,7 +103,6 @@ const WorkflowStateSchema = z.object({
isDeployed: z.boolean().optional(),
deployedAt: z.date().optional(),
deploymentStatuses: z.record(DeploymentStatusSchema).optional(),
hasActiveSchedule: z.boolean().optional(),
hasActiveWebhook: z.boolean().optional(),
})
@@ -180,7 +179,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
isDeployed: state.isDeployed || false,
deployedAt: state.deployedAt,
deploymentStatuses: state.deploymentStatuses || {},
hasActiveSchedule: state.hasActiveSchedule || false,
hasActiveWebhook: state.hasActiveWebhook || false,
}
@@ -1,18 +1,19 @@
import { useEffect, useMemo, useState } from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
interface DropdownProps {
options:
| Array<string | { label: string; id: string }>
| (() => Array<string | { label: string; id: string }>)
| Array<
string | { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }
>
| (() => Array<
string | { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }
>)
defaultValue?: string
blockId: string
subBlockId: string
@@ -20,6 +21,7 @@ interface DropdownProps {
isPreview?: boolean
previewValue?: string | null
disabled?: boolean
placeholder?: string
}
export function Dropdown({
@@ -31,9 +33,15 @@ export function Dropdown({
isPreview = false,
previewValue,
disabled,
placeholder = 'Select an option...',
}: DropdownProps) {
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
const [storeInitialized, setStoreInitialized] = useState(false)
const [open, setOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// For response dataMode conversion - get builderData and data sub-blocks
const [builderData] = useSubBlockValue<any[]>(blockId, 'builderData')
@@ -47,11 +55,19 @@ export function Dropdown({
return typeof options === 'function' ? options() : options
}, [options])
const getOptionValue = (option: string | { label: string; id: string }) => {
const getOptionValue = (
option:
| string
| { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }
) => {
return typeof option === 'string' ? option : option.id
}
const getOptionLabel = (option: string | { label: string; id: string }) => {
const getOptionLabel = (
option:
| string
| { label: string; id: string; icon?: React.ComponentType<{ className?: string }> }
) => {
return typeof option === 'string' ? option : option.label
}
@@ -85,67 +101,234 @@ export function Dropdown({
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
// Calculate the effective value to use in the dropdown
const effectiveValue = useMemo(() => {
// If we have a value from the store, use that
if (value !== null && value !== undefined) {
return value
// Event handlers
const handleSelect = (selectedValue: string) => {
if (!isPreview && !disabled) {
// Handle conversion when switching from Builder to Editor mode in response blocks
if (
subBlockId === 'dataMode' &&
storeValue === 'structured' &&
selectedValue === 'json' &&
builderData &&
Array.isArray(builderData) &&
builderData.length > 0
) {
// Convert builderData to JSON string for editor mode
const jsonString = ResponseBlockHandler.convertBuilderDataToJsonString(builderData)
setData(jsonString)
}
setStoreValue(selectedValue)
}
setOpen(false)
setHighlightedIndex(-1)
inputRef.current?.blur()
}
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setOpen(!open)
if (!open) {
inputRef.current?.focus()
}
}
}
const handleFocus = () => {
setOpen(true)
setHighlightedIndex(-1)
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => {
const activeElement = document.activeElement
if (!activeElement || !activeElement.closest('.absolute.top-full')) {
setOpen(false)
setHighlightedIndex(-1)
}
}, 150)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setOpen(false)
setHighlightedIndex(-1)
return
}
// Only return defaultOptionValue if store is initialized
if (storeInitialized) {
return defaultOptionValue
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!open) {
setOpen(true)
setHighlightedIndex(0)
} else {
setHighlightedIndex((prev) => (prev < evaluatedOptions.length - 1 ? prev + 1 : 0))
}
}
// While store is loading, don't use any value
return undefined
}, [value, defaultOptionValue, storeInitialized])
if (e.key === 'ArrowUp') {
e.preventDefault()
if (open) {
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : evaluatedOptions.length - 1))
}
}
// Handle the case where evaluatedOptions changes and the current selection is no longer valid
const isValueInOptions = useMemo(() => {
if (!effectiveValue || evaluatedOptions.length === 0) return false
return evaluatedOptions.some((opt) => getOptionValue(opt) === effectiveValue)
}, [effectiveValue, evaluatedOptions, getOptionValue])
if (e.key === 'Enter' && open && highlightedIndex >= 0) {
e.preventDefault()
const selectedOption = evaluatedOptions[highlightedIndex]
if (selectedOption) {
handleSelect(getOptionValue(selectedOption))
}
}
}
// Effects
useEffect(() => {
setHighlightedIndex((prev) => {
if (prev >= 0 && prev < evaluatedOptions.length) {
return prev
}
return -1
})
}, [evaluatedOptions])
// Scroll highlighted option into view
useEffect(() => {
if (highlightedIndex >= 0 && dropdownRef.current) {
const highlightedElement = dropdownRef.current.querySelector(
`[data-option-index="${highlightedIndex}"]`
)
if (highlightedElement) {
highlightedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
}
}, [highlightedIndex])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element
if (
inputRef.current &&
!inputRef.current.contains(target) &&
!target.closest('.absolute.top-full')
) {
setOpen(false)
setHighlightedIndex(-1)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [open])
// Display value
const displayValue = value?.toString() ?? ''
const selectedOption = evaluatedOptions.find((opt) => getOptionValue(opt) === value)
const selectedLabel = selectedOption ? getOptionLabel(selectedOption) : displayValue
const SelectedIcon =
selectedOption && typeof selectedOption === 'object' && 'icon' in selectedOption
? (selectedOption.icon as React.ComponentType<{ className?: string }>)
: null
// Render component
return (
<Select
value={isValueInOptions ? effectiveValue : undefined}
onValueChange={(newValue) => {
// Only update store when not in preview mode and not disabled
if (!isPreview && !disabled) {
// Handle conversion when switching from Builder to Editor mode in response blocks
if (
subBlockId === 'dataMode' &&
storeValue === 'structured' &&
newValue === 'json' &&
builderData &&
Array.isArray(builderData) &&
builderData.length > 0
) {
// Convert builderData to JSON string for editor mode
const jsonString = ResponseBlockHandler.convertBuilderDataToJsonString(builderData)
setData(jsonString)
}
<div className='relative w-full'>
<div className='relative'>
<Input
ref={inputRef}
className={cn(
'w-full cursor-pointer overflow-hidden pr-10 text-foreground',
SelectedIcon ? 'pl-8' : ''
)}
placeholder={placeholder}
value={selectedLabel || ''}
readOnly
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
disabled={disabled}
autoComplete='off'
/>
{/* Icon overlay */}
{SelectedIcon && (
<div className='pointer-events-none absolute top-0 bottom-0 left-0 flex items-center bg-transparent pl-3 text-sm'>
<SelectedIcon className='h-3 w-3' />
</div>
)}
{/* Chevron button */}
<Button
variant='ghost'
size='sm'
className='-translate-y-1/2 absolute top-1/2 right-1 z-10 h-6 w-6 p-0 hover:bg-transparent'
disabled={disabled}
onMouseDown={handleDropdownClick}
>
<ChevronDown
className={cn('h-4 w-4 opacity-50 transition-transform', open && 'rotate-180')}
/>
</Button>
</div>
setStoreValue(newValue)
}
}}
disabled={isPreview || disabled}
>
<SelectTrigger className='min-w-0 text-left'>
<SelectValue placeholder='Select an option' className='truncate' />
</SelectTrigger>
<SelectContent className='max-h-48'>
{evaluatedOptions.map((option) => (
<SelectItem
key={getOptionValue(option)}
value={getOptionValue(option)}
className='text-sm'
>
{getOptionLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Dropdown */}
{open && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full min-w-[286px]'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
ref={dropdownRef}
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{evaluatedOptions.length === 0 ? (
<div className='py-6 text-center text-muted-foreground text-sm'>
No options available.
</div>
) : (
evaluatedOptions.map((option, index) => {
const optionValue = getOptionValue(option)
const optionLabel = getOptionLabel(option)
const OptionIcon =
typeof option === 'object' && 'icon' in option
? (option.icon as React.ComponentType<{ className?: string }>)
: null
const isSelected = value === optionValue
const isHighlighted = index === highlightedIndex
return (
<div
key={optionValue}
data-option-index={index}
onClick={() => handleSelect(optionValue)}
onMouseDown={(e) => {
e.preventDefault()
handleSelect(optionValue)
}}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
isHighlighted && 'bg-accent text-accent-foreground'
)}
>
{OptionIcon && <OptionIcon className='mr-2 h-3 w-3' />}
<span className='flex-1 truncate'>{optionLabel}</span>
{isSelected && <Check className='ml-2 h-4 w-4 flex-shrink-0' />}
</div>
)
})
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { format } from 'date-fns'
import { Trash2, X } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
@@ -13,10 +12,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Calendar as CalendarComponent } from '@/components/ui/calendar'
import { DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
@@ -54,8 +51,6 @@ export function ScheduleModal({
}: ScheduleModalProps) {
// States for schedule configuration
const [scheduleType, setScheduleType] = useSubBlockValue(blockId, 'scheduleType')
const [scheduleStartAt, setScheduleStartAt] = useSubBlockValue(blockId, 'scheduleStartAt')
const [scheduleTime, setScheduleTime] = useSubBlockValue(blockId, 'scheduleTime')
const [minutesInterval, setMinutesInterval] = useSubBlockValue(blockId, 'minutesInterval')
const [hourlyMinute, setHourlyMinute] = useSubBlockValue(blockId, 'hourlyMinute')
const [dailyTime, setDailyTime] = useSubBlockValue(blockId, 'dailyTime')
@@ -86,8 +81,6 @@ export function ScheduleModal({
// Capture all current values when modal opens
const currentValues = {
scheduleType: scheduleType || 'daily',
scheduleStartAt: scheduleStartAt || '',
scheduleTime: scheduleTime || '',
minutesInterval: minutesInterval || '',
hourlyMinute: hourlyMinute || '',
dailyTime: dailyTime || '',
@@ -111,8 +104,6 @@ export function ScheduleModal({
const currentValues = {
scheduleType: scheduleType || 'daily',
scheduleStartAt: scheduleStartAt || '',
scheduleTime: scheduleTime || '',
minutesInterval: minutesInterval || '',
hourlyMinute: hourlyMinute || '',
dailyTime: dailyTime || '',
@@ -160,8 +151,6 @@ export function ScheduleModal({
isOpen,
scheduleId,
scheduleType,
scheduleStartAt,
scheduleTime,
minutesInterval,
hourlyMinute,
dailyTime,
@@ -188,8 +177,6 @@ export function ScheduleModal({
// Revert form values to initial values
if (hasChanges) {
setScheduleType(initialValues.scheduleType)
setScheduleStartAt(initialValues.scheduleStartAt)
setScheduleTime(initialValues.scheduleTime)
setMinutesInterval(initialValues.minutesInterval)
setHourlyMinute(initialValues.hourlyMinute)
setDailyTime(initialValues.dailyTime)
@@ -279,8 +266,6 @@ export function ScheduleModal({
// Update initial values to match current state
const updatedValues = {
scheduleType: scheduleType || 'daily',
scheduleStartAt: scheduleStartAt || '',
scheduleTime: scheduleTime || '',
minutesInterval: minutesInterval || '',
hourlyMinute: hourlyMinute || '',
dailyTime: dailyTime || '',
@@ -329,15 +314,6 @@ export function ScheduleModal({
setShowDeleteConfirm(true)
}
// Helper to format a date for display
const formatDate = (date: string) => {
try {
return date ? format(new Date(date), 'PPP') : 'Select date'
} catch (_e) {
return 'Select date'
}
}
return (
<>
<DialogContent className='flex flex-col gap-0 p-0 sm:max-w-[600px]' hideCloseButton>
@@ -359,46 +335,6 @@ export function ScheduleModal({
)}
<div className='space-y-6'>
{/* Common date and time fields */}
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1'>
<label htmlFor='scheduleStartAt' className='font-medium text-sm'>
Start At
</label>
<Popover>
<PopoverTrigger asChild>
<Button
id='scheduleStartAt'
variant='outline'
className='h-10 w-full justify-start text-left font-normal'
>
{formatDate(scheduleStartAt || '')}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<CalendarComponent
mode='single'
selected={scheduleStartAt ? new Date(scheduleStartAt) : undefined}
onSelect={(date) => setScheduleStartAt(date ? date.toISOString() : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className='space-y-1'>
<label htmlFor='scheduleTime' className='font-medium text-sm'>
Time
</label>
<TimeInput
blockId={blockId}
subBlockId='scheduleTime'
placeholder='Select time'
className='h-10'
/>
</div>
</div>
{/* Frequency selector */}
<div className='space-y-1'>
<label htmlFor='scheduleType' className='font-medium text-sm'>
@@ -469,7 +405,7 @@ export function ScheduleModal({
)}
{/* Daily schedule options */}
{scheduleType === 'daily' && (
{(scheduleType === 'daily' || !scheduleType) && (
<div className='space-y-1'>
<label htmlFor='dailyTime' className='font-medium text-sm'>
Time of Day
@@ -578,29 +514,31 @@ export function ScheduleModal({
</div>
)}
{/* Timezone configuration */}
<div className='space-y-1'>
<label htmlFor='timezone' className='font-medium text-sm'>
Timezone
</label>
<Select value={timezone || 'UTC'} onValueChange={(value) => setTimezone(value)}>
<SelectTrigger className='h-10'>
<SelectValue placeholder='Select timezone' />
</SelectTrigger>
<SelectContent>
<SelectItem value='UTC'>UTC</SelectItem>
<SelectItem value='America/New_York'>US Eastern (UTC-4)</SelectItem>
<SelectItem value='America/Chicago'>US Central (UTC-5)</SelectItem>
<SelectItem value='America/Denver'>US Mountain (UTC-6)</SelectItem>
<SelectItem value='America/Los_Angeles'>US Pacific (UTC-7)</SelectItem>
<SelectItem value='Europe/London'>London (UTC+1)</SelectItem>
<SelectItem value='Europe/Paris'>Paris (UTC+2)</SelectItem>
<SelectItem value='Asia/Singapore'>Singapore (UTC+8)</SelectItem>
<SelectItem value='Asia/Tokyo'>Tokyo (UTC+9)</SelectItem>
<SelectItem value='Australia/Sydney'>Sydney (UTC+10)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Timezone configuration - only show for time-specific schedules */}
{scheduleType !== 'minutes' && scheduleType !== 'hourly' && (
<div className='space-y-1'>
<label htmlFor='timezone' className='font-medium text-sm'>
Timezone
</label>
<Select value={timezone || 'UTC'} onValueChange={(value) => setTimezone(value)}>
<SelectTrigger className='h-10'>
<SelectValue placeholder='Select timezone' />
</SelectTrigger>
<SelectContent>
<SelectItem value='UTC'>UTC</SelectItem>
<SelectItem value='America/New_York'>US Eastern (UTC-4)</SelectItem>
<SelectItem value='America/Chicago'>US Central (UTC-5)</SelectItem>
<SelectItem value='America/Denver'>US Mountain (UTC-6)</SelectItem>
<SelectItem value='America/Los_Angeles'>US Pacific (UTC-7)</SelectItem>
<SelectItem value='Europe/London'>London (UTC+1)</SelectItem>
<SelectItem value='Europe/Paris'>Paris (UTC+2)</SelectItem>
<SelectItem value='Asia/Singapore'>Singapore (UTC+8)</SelectItem>
<SelectItem value='Asia/Tokyo'>Tokyo (UTC+9)</SelectItem>
<SelectItem value='Australia/Sydney'>Sydney (UTC+10)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</div>
@@ -6,7 +6,7 @@ import { Dialog } from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console-logger'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { formatDateTime } from '@/lib/utils'
import { getWorkflowWithValues } from '@/stores/workflows'
import { getBlockWithValues, getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -49,7 +49,6 @@ export function ScheduleConfig({
const workflowId = params.workflowId as string
// Get workflow state from store
const setScheduleStatus = useWorkflowStore((state) => state.setScheduleStatus)
// Get the schedule type from the block state
const [scheduleType] = useSubBlockValue(blockId, 'scheduleType')
@@ -58,12 +57,25 @@ export function ScheduleConfig({
// and expose the setter so we can update it
const [_startWorkflow, setStartWorkflow] = useSubBlockValue(blockId, 'startWorkflow')
// Determine if this is a schedule trigger block vs starter block
const blockWithValues = getBlockWithValues(blockId)
const isScheduleTriggerBlock = blockWithValues?.type === 'schedule'
// Function to check if schedule exists in the database
const checkSchedule = async () => {
setIsLoading(true)
try {
// Check if there's a schedule for this workflow, passing the mode parameter
const response = await fetch(`/api/schedules?workflowId=${workflowId}&mode=schedule`, {
// For schedule trigger blocks, include blockId to get the specific schedule
const url = new URL('/api/schedules', window.location.origin)
url.searchParams.set('workflowId', workflowId)
url.searchParams.set('mode', 'schedule')
if (isScheduleTriggerBlock) {
url.searchParams.set('blockId', blockId)
}
const response = await fetch(url.toString(), {
// Add cache: 'no-store' to prevent caching of this request
cache: 'no-store',
headers: {
@@ -82,16 +94,15 @@ export function ScheduleConfig({
setCronExpression(data.schedule.cronExpression)
setTimezone(data.schedule.timezone || 'UTC')
// Set active schedule flag to true since we found an active schedule
setScheduleStatus(true)
// Note: We no longer set global schedule status from individual components
// The global schedule status should be managed by a higher-level component
} else {
setScheduleId(null)
setNextRunAt(null)
setLastRanAt(null)
setCronExpression(null)
// Set active schedule flag to false since no schedule was found
setScheduleStatus(false)
// Note: We no longer set global schedule status from individual components
}
}
} catch (error) {
@@ -104,9 +115,8 @@ export function ScheduleConfig({
// Check for schedule on mount and when relevant dependencies change
useEffect(() => {
// Only check for schedules when workflowId changes or modal opens
// Avoid checking on every scheduleType change to prevent excessive API calls
if (workflowId && (isModalOpen || refreshCounter > 0)) {
// Check for schedules when workflowId changes, modal opens, or on initial mount
if (workflowId) {
checkSchedule()
}
@@ -160,23 +170,33 @@ export function ScheduleConfig({
setError(null)
try {
// 1. First, update the startWorkflow value in SubBlock store to 'schedule'
setStartWorkflow('schedule')
// For starter blocks, update the startWorkflow value to 'schedule'
// For schedule trigger blocks, skip this step as startWorkflow is not needed
if (!isScheduleTriggerBlock) {
// 1. First, update the startWorkflow value in SubBlock store to 'schedule'
setStartWorkflow('schedule')
// 2. Directly access and modify the SubBlock store to guarantee the value is set
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
setError('No active workflow found')
return false
}
// Update the SubBlock store directly to ensure the value is set correctly
const subBlockStore = useSubBlockStore.getState()
subBlockStore.setValue(blockId, 'startWorkflow', 'schedule')
// Give React time to process the state update
await new Promise((resolve) => setTimeout(resolve, 200))
}
// 2. Directly access and modify the SubBlock store to guarantee the value is set
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
setError('No active workflow found')
return false
}
// Update the SubBlock store directly to ensure the value is set correctly
const subBlockStore = useSubBlockStore.getState()
subBlockStore.setValue(blockId, 'startWorkflow', 'schedule')
// Give React time to process the state update
await new Promise((resolve) => setTimeout(resolve, 200))
// 3. Get the fully merged current state with updated values
// This ensures we send the complete, correct workflow state to the backend
const currentWorkflowWithValues = getWorkflowWithValues(activeWorkflowId)
@@ -188,15 +208,24 @@ export function ScheduleConfig({
// 4. Make a direct API call instead of relying on sync
// This gives us more control and better error handling
logger.debug('Making direct API call to save schedule with complete state')
// Prepare the request body
const requestBody: any = {
workflowId,
state: currentWorkflowWithValues.state,
}
// For schedule trigger blocks, include the blockId
if (isScheduleTriggerBlock) {
requestBody.blockId = blockId
}
const response = await fetch('/api/schedules', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workflowId,
state: currentWorkflowWithValues.state,
}),
body: JSON.stringify(requestBody),
})
// Parse the response
@@ -230,7 +259,7 @@ export function ScheduleConfig({
}
// 6. Update the schedule status and trigger a workflow update
setScheduleStatus(true)
// Note: Global schedule status is managed at a higher level
// 7. Tell the workflow store that the state has been saved
const workflowStore = useWorkflowStore.getState()
@@ -262,25 +291,29 @@ export function ScheduleConfig({
setIsDeleting(true)
try {
// 1. First update the workflow state to disable scheduling
setStartWorkflow('manual')
// For starter blocks, update the startWorkflow value to 'manual'
// For schedule trigger blocks, skip this step as startWorkflow is not relevant
if (!isScheduleTriggerBlock) {
// 1. First update the workflow state to disable scheduling
setStartWorkflow('manual')
// 2. Directly update the SubBlock store to ensure the value is set
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
setError('No active workflow found')
return false
// 2. Directly update the SubBlock store to ensure the value is set
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) {
setError('No active workflow found')
return false
}
// Update the store directly
const subBlockStore = useSubBlockStore.getState()
subBlockStore.setValue(blockId, 'startWorkflow', 'manual')
// 3. Update the workflow store
const workflowStore = useWorkflowStore.getState()
workflowStore.triggerUpdate()
workflowStore.updateLastSaved()
}
// Update the store directly
const subBlockStore = useSubBlockStore.getState()
subBlockStore.setValue(blockId, 'startWorkflow', 'manual')
// 3. Update the workflow store
const workflowStore = useWorkflowStore.getState()
workflowStore.triggerUpdate()
workflowStore.updateLastSaved()
// 4. Make the DELETE API call to remove the schedule
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'DELETE',
@@ -299,7 +332,7 @@ export function ScheduleConfig({
setCronExpression(null)
// 6. Update schedule status and refresh UI
setScheduleStatus(false)
// Note: Global schedule status is managed at a higher level
setRefreshCounter((prev) => prev + 1)
return true
@@ -14,7 +14,6 @@ import {
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console-logger'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { ToolCredentialSelector } from '../tool-input/components/tool-credential-selector'
import { WebhookModal } from './components/webhook-modal'
@@ -314,8 +313,7 @@ export function WebhookConfig({
const [isLoading, setIsLoading] = useState(false)
const [gmailCredentialId, setGmailCredentialId] = useState<string>('')
// Get workflow store function to update webhook status
const setWebhookStatus = useWorkflowStore((state) => state.setWebhookStatus)
// No need to manage webhook status separately - it's determined by having provider + path
// Get the webhook provider from the block state
const [storeWebhookProvider, setWebhookProvider] = useSubBlockValue(blockId, 'webhookProvider')
@@ -323,6 +321,9 @@ export function WebhookConfig({
// Store the webhook path
const [storeWebhookPath, setWebhookPath] = useSubBlockValue(blockId, 'webhookPath')
// Don't auto-generate webhook paths - only create them when user actually configures a webhook
// This prevents the "Active Webhook" badge from showing on unconfigured blocks
// Store provider-specific configuration
const [storeProviderConfig, setProviderConfig] = useSubBlockValue(blockId, 'providerConfig')
@@ -331,16 +332,132 @@ export function WebhookConfig({
const webhookPath = propValue?.webhookPath ?? storeWebhookPath
const providerConfig = propValue?.providerConfig ?? storeProviderConfig
// Reset provider config when provider changes
// Store the actual provider from the database
const [actualProvider, setActualProvider] = useState<string | null>(null)
// Track the previous provider to detect changes
const [previousProvider, setPreviousProvider] = useState<string | null>(null)
// Handle provider changes - clear webhook data when switching providers
useEffect(() => {
// Skip on initial load or if no provider is set
if (!webhookProvider || !previousProvider) {
setPreviousProvider(webhookProvider)
return
}
// If the provider has changed, clear all webhook-related data
if (webhookProvider !== previousProvider) {
// IMPORTANT: Store the current webhook ID BEFORE clearing it
const currentWebhookId = webhookId
logger.info('Webhook provider changed, clearing webhook data', {
from: previousProvider,
to: webhookProvider,
blockId,
webhookId: currentWebhookId,
})
// If there's an existing webhook, delete it from the database
const deleteExistingWebhook = async () => {
if (currentWebhookId && !isPreview) {
try {
logger.info('Deleting existing webhook due to provider change', {
webhookId: currentWebhookId,
oldProvider: previousProvider,
newProvider: webhookProvider,
})
const response = await fetch(`/api/webhooks/${currentWebhookId}`, {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json()
logger.error('Failed to delete existing webhook', {
webhookId: currentWebhookId,
error: errorData.error,
})
} else {
logger.info('Successfully deleted existing webhook', { webhookId: currentWebhookId })
const store = useSubBlockStore.getState()
const workflowValues = store.workflowValues[workflowId] || {}
const blockValues = { ...workflowValues[blockId] }
// Clear webhook-related fields
blockValues.webhookPath = undefined
blockValues.providerConfig = undefined
// Update the store with the cleaned block values
useSubBlockStore.setState({
workflowValues: {
...workflowValues,
[workflowId]: {
...workflowValues,
[blockId]: blockValues,
},
},
})
logger.info('Cleared webhook data from store after successful deletion', { blockId })
}
} catch (error: any) {
logger.error('Error deleting existing webhook', {
webhookId: currentWebhookId,
error: error.message,
})
}
}
}
// Clear webhook fields FIRST to make badge disappear immediately
// Then delete from database to prevent the webhook check useEffect from restoring the path
// IMPORTANT: Clear webhook connection data FIRST
// This prevents the webhook check useEffect from finding and restoring the webhook
setWebhookId(null)
setActualProvider(null)
// Clear provider config
setProviderConfig({})
// Clear component state
setError(null)
setGmailCredentialId('')
// Note: Store will be cleared AFTER successful database deletion
// This ensures store and database stay perfectly in sync
// Update previous provider to the new provider
setPreviousProvider(webhookProvider)
// Delete existing webhook AFTER clearing the path to prevent race condition
// The webhook check useEffect won't restore the path if we clear it first
// Execute deletion asynchronously but don't block the UI
;(async () => {
await deleteExistingWebhook()
})()
}
}, [webhookProvider, previousProvider, blockId, webhookId, isPreview])
// Reset provider config when provider changes (legacy effect - keeping for safety)
useEffect(() => {
if (webhookProvider) {
// Reset the provider config when the provider changes
setProviderConfig({})
}
}, [webhookProvider, setProviderConfig])
// Store the actual provider from the database
const [actualProvider, setActualProvider] = useState<string | null>(null)
// Clear webhook ID and actual provider when switching providers
// This ensures the webhook status is properly reset
if (webhookProvider !== actualProvider) {
setWebhookId(null)
setActualProvider(null)
}
// Provider config is reset - webhook status will be determined by provider + path existence
}
}, [webhookProvider, webhookId, actualProvider])
// Check if webhook exists in the database
useEffect(() => {
@@ -353,18 +470,17 @@ export function WebhookConfig({
const checkWebhook = async () => {
setIsLoading(true)
try {
// Check if there's a webhook for this workflow
const response = await fetch(`/api/webhooks?workflowId=${workflowId}`)
// Check if there's a webhook for this specific block
// Always include blockId - every webhook should be associated with a specific block
const response = await fetch(`/api/webhooks?workflowId=${workflowId}&blockId=${blockId}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks && data.webhooks.length > 0) {
const webhook = data.webhooks[0].webhook
setWebhookId(webhook.id)
// Update the provider in the block state if it's different
if (webhook.provider && webhook.provider !== webhookProvider) {
setWebhookProvider(webhook.provider)
}
// Don't automatically update the provider - let user control it
// The user should be able to change providers even when a webhook exists
// Store the actual provider from the database
setActualProvider(webhook.provider)
@@ -374,14 +490,22 @@ export function WebhookConfig({
setWebhookPath(webhook.path)
}
// Set active webhook flag to true since we found an active webhook
setWebhookStatus(true)
// Webhook found - status will be determined by provider + path existence
} else {
setWebhookId(null)
setActualProvider(null)
// Set active webhook flag to false since no webhook was found
setWebhookStatus(false)
// IMPORTANT: Clear stale webhook data from store when no webhook found in database
// This ensures the reactive badge status updates correctly on page refresh
if (webhookPath) {
setWebhookPath('')
logger.info('Cleared stale webhook path on page refresh - no webhook in database', {
blockId,
clearedPath: webhookPath,
})
}
// No webhook found - reactive blockWebhookStatus will now be false
}
}
} catch (error) {
@@ -392,15 +516,7 @@ export function WebhookConfig({
}
checkWebhook()
}, [
webhookPath,
webhookProvider,
workflowId,
setWebhookPath,
setWebhookProvider,
setWebhookStatus,
isPreview,
])
}, [workflowId, blockId, isPreview]) // Removed webhookPath dependency to prevent race condition with provider changes
const handleOpenModal = () => {
if (isPreview || disabled) return
@@ -443,6 +559,7 @@ export function WebhookConfig({
},
body: JSON.stringify({
workflowId,
blockId,
path,
provider: webhookProvider || 'generic',
providerConfig: finalConfig,
@@ -459,13 +576,20 @@ export function WebhookConfig({
}
const data = await response.json()
setWebhookId(data.webhook.id)
const savedWebhookId = data.webhook.id
setWebhookId(savedWebhookId)
logger.info('Webhook saved successfully', {
webhookId: savedWebhookId,
provider: webhookProvider,
path,
blockId,
})
// Update the actual provider after saving
setActualProvider(webhookProvider || 'generic')
// Set active webhook flag to true after successfully saving
setWebhookStatus(true)
// Webhook saved successfully - status will be determined by provider + path existence
return true
} catch (error: any) {
@@ -504,7 +628,7 @@ export function WebhookConfig({
// Remove webhook-related fields
blockValues.webhookProvider = undefined
blockValues.providerConfig = undefined
blockValues.webhookPath = ''
blockValues.webhookPath = undefined
// Update the store with the cleaned block values
store.setValue(blockId, 'startWorkflow', 'manual')
@@ -522,8 +646,7 @@ export function WebhookConfig({
setWebhookId(null)
setActualProvider(null)
// Set active webhook flag to false
setWebhookStatus(false)
// Webhook deleted - status will be determined by provider + path existence
handleCloseModal()
return true
@@ -7,12 +7,13 @@ import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { parseCronToHumanReadable } from '@/lib/schedules/utils'
import { cn, formatDateTime, validateName } from '@/lib/utils'
import { cn, validateName } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useExecutionStore } from '@/stores/execution/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { ActionBar } from './components/action-bar/action-bar'
@@ -67,7 +68,17 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
)
const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false)
const blockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0)
const hasActiveWebhook = useWorkflowStore((state) => state.hasActiveWebhook ?? false)
// Get per-block webhook status by checking if webhook is configured
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const hasWebhookProvider = useSubBlockStore(
(state) => state.workflowValues[activeWorkflowId || '']?.[id]?.webhookProvider
)
const hasWebhookPath = useSubBlockStore(
(state) => state.workflowValues[activeWorkflowId || '']?.[id]?.webhookPath
)
const blockWebhookStatus = !!(hasWebhookProvider && hasWebhookPath)
const blockAdvancedMode = useWorkflowStore((state) => state.blocks[id]?.advancedMode ?? false)
// Collaborative workflow actions
@@ -89,6 +100,11 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
const params = useParams()
const currentWorkflowId = params.workflowId as string
// Check if this is a starter block or trigger block
const isStarterBlock = type === 'starter'
const isTriggerBlock = config.category === 'triggers'
const isWebhookTriggerBlock = type === 'webhook'
const reactivateSchedule = async (scheduleId: string) => {
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
@@ -112,13 +128,42 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}
const disableSchedule = async (scheduleId: string) => {
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'disable' }),
})
if (response.ok) {
// Refresh schedule info to show updated status
if (currentWorkflowId) {
fetchScheduleInfo(currentWorkflowId)
}
} else {
console.error('Failed to disable schedule')
}
} catch (error) {
console.error('Error disabling schedule:', error)
}
}
const fetchScheduleInfo = async (workflowId: string) => {
if (!workflowId) return
try {
setIsLoadingScheduleInfo(true)
const response = await fetch(`/api/schedules?workflowId=${workflowId}&mode=schedule`, {
// For schedule trigger blocks, always include the blockId parameter
const url = new URL('/api/schedules', window.location.origin)
url.searchParams.set('workflowId', workflowId)
url.searchParams.set('mode', 'schedule')
url.searchParams.set('blockId', id) // Always include blockId for schedule blocks
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
@@ -185,48 +230,25 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
useEffect(() => {
if (type === 'starter' && currentWorkflowId) {
if (type === 'schedule' && currentWorkflowId) {
fetchScheduleInfo(currentWorkflowId)
} else {
setScheduleInfo(null)
setIsLoadingScheduleInfo(false) // Reset loading state when not a starter block
setIsLoadingScheduleInfo(false) // Reset loading state when not a schedule block
}
// Cleanup function to reset loading state when component unmounts or workflow changes
return () => {
setIsLoadingScheduleInfo(false)
}
}, [type, currentWorkflowId])
}, [isStarterBlock, isTriggerBlock, type, currentWorkflowId, lastUpdate])
// Get webhook information for the tooltip
useEffect(() => {
if (type === 'starter' && hasActiveWebhook) {
const fetchWebhookInfo = async () => {
try {
const workflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!workflowId) return
const response = await fetch(`/api/webhooks?workflowId=${workflowId}`)
if (response.ok) {
const data = await response.json()
if (data.webhooks?.[0]?.webhook) {
const webhook = data.webhooks[0].webhook
setWebhookInfo({
webhookPath: webhook.path || '',
provider: webhook.provider || 'generic',
})
}
}
} catch (error) {
console.error('Error fetching webhook info:', error)
}
}
fetchWebhookInfo()
} else if (!hasActiveWebhook) {
if (!blockWebhookStatus) {
setWebhookInfo(null)
}
}, [type, hasActiveWebhook])
}, [blockWebhookStatus])
// Update node internals when handles change
useEffect(() => {
@@ -404,9 +426,8 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
}
}
// Check if this is a starter block and has active schedule or webhook
const isStarterBlock = type === 'starter'
const showWebhookIndicator = isStarterBlock && hasActiveWebhook
// Check webhook indicator
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && blockWebhookStatus
const getProviderName = (providerId: string): string => {
const providers: Record<string, string> = {
@@ -422,7 +443,8 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
return providers[providerId] || 'Webhook'
}
const shouldShowScheduleBadge = isStarterBlock && !isLoadingScheduleInfo && scheduleInfo !== null
const shouldShowScheduleBadge =
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
const userPermissions = useUserPermissionsContext()
return (
@@ -447,15 +469,18 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
)}
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
<ConnectionBlocks
blockId={id}
setIsConnecting={setIsConnecting}
isDisabled={!userPermissions.canEdit}
horizontalHandles={horizontalHandles}
/>
{/* Connection Blocks - Don't show for trigger blocks or starter blocks */}
{config.category !== 'triggers' && type !== 'starter' && (
<ConnectionBlocks
blockId={id}
setIsConnecting={setIsConnecting}
isDisabled={!userPermissions.canEdit}
horizontalHandles={horizontalHandles}
/>
)}
{/* Input Handle - Don't show for starter blocks */}
{type !== 'starter' && (
{/* Input Handle - Don't show for trigger blocks or starter blocks */}
{config.category !== 'triggers' && type !== 'starter' && (
<Handle
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
@@ -541,14 +566,16 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
<Badge
variant='outline'
className={cn(
'flex items-center gap-1 font-normal text-xs',
'flex cursor-pointer items-center gap-1 font-normal text-xs',
scheduleInfo?.isDisabled
? 'cursor-pointer border-amber-200 bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-900/20 dark:text-amber-400'
: 'border-green-200 bg-green-50 text-green-600 hover:bg-green-50 dark:bg-green-900/20 dark:text-green-400'
? 'border-amber-200 bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-900/20 dark:text-amber-400'
: 'border-green-200 bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-400'
)}
onClick={
scheduleInfo?.isDisabled && scheduleInfo?.id
? () => reactivateSchedule(scheduleInfo.id!)
scheduleInfo?.id
? scheduleInfo.isDisabled
? () => reactivateSchedule(scheduleInfo.id!)
: () => disableSchedule(scheduleInfo.id!)
: undefined
}
>
@@ -570,32 +597,12 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
</Badge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-4'>
{scheduleInfo ? (
<>
<p className='text-sm'>{scheduleInfo.scheduleTiming}</p>
{scheduleInfo.isDisabled && (
<p className='mt-1 font-medium text-amber-600 text-sm'>
This schedule is currently disabled due to consecutive failures. Click the
badge to reactivate it.
</p>
)}
{scheduleInfo.nextRunAt && !scheduleInfo.isDisabled && (
<p className='mt-1 text-muted-foreground text-xs'>
Next run:{' '}
{formatDateTime(new Date(scheduleInfo.nextRunAt), scheduleInfo.timezone)}
</p>
)}
{scheduleInfo.lastRanAt && (
<p className='text-muted-foreground text-xs'>
Last run:{' '}
{formatDateTime(new Date(scheduleInfo.lastRanAt), scheduleInfo.timezone)}
</p>
)}
</>
) : (
<p className='text-muted-foreground text-sm'>
This workflow is running on a schedule.
{scheduleInfo?.isDisabled ? (
<p className='text-sm'>
This schedule is currently disabled. Click the badge to reactivate it.
</p>
) : (
<p className='text-sm'>Click the badge to disable this schedule.</p>
)}
</TooltipContent>
</Tooltip>
@@ -825,8 +832,8 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
isValidConnection={(connection) => connection.target !== id}
/>
{/* Error Handle - Don't show for starter blocks */}
{type !== 'starter' && (
{/* Error Handle - Don't show for trigger blocks or starter blocks */}
{config.category !== 'triggers' && type !== 'starter' && (
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
@@ -89,7 +89,6 @@ export async function applyWorkflowDiff(
isDeployed: parsedData.state.isDeployed || false,
deployedAt: parsedData.state.deployedAt,
deploymentStatuses: parsedData.state.deploymentStatuses || {},
hasActiveSchedule: parsedData.state.hasActiveSchedule || false,
hasActiveWebhook: parsedData.state.hasActiveWebhook || false,
}
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import { getBlock } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import { Executor } from '@/executor'
import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types'
@@ -419,7 +420,23 @@ export function useWorkflowExecution() {
): Promise<ExecutionResult | StreamingExecution> => {
// Use the mergeSubblockState utility to get all block states
const mergedStates = mergeSubblockState(blocks)
const currentBlockStates = Object.entries(mergedStates).reduce(
// Filter out trigger blocks for manual execution
const filteredStates = Object.entries(mergedStates).reduce(
(acc, [id, block]) => {
const blockConfig = getBlock(block.type)
const isTriggerBlock = blockConfig?.category === 'triggers'
// Skip trigger blocks during manual execution
if (!isTriggerBlock) {
acc[id] = block
}
return acc
},
{} as typeof mergedStates
)
const currentBlockStates = Object.entries(filteredStates).reduce(
(acc, [id, block]) => {
acc[id] = Object.entries(block.subBlocks).reduce(
(subAcc, [key, subBlock]) => {
@@ -453,8 +470,23 @@ export function useWorkflowExecution() {
{} as Record<string, any>
)
// Create serialized workflow
const workflow = new Serializer().serializeWorkflow(mergedStates, edges, loops, parallels)
// Filter edges to exclude connections to/from trigger blocks
const triggerBlockIds = Object.keys(mergedStates).filter((id) => {
const blockConfig = getBlock(mergedStates[id].type)
return blockConfig?.category === 'triggers'
})
const filteredEdges = edges.filter(
(edge) => !triggerBlockIds.includes(edge.source) && !triggerBlockIds.includes(edge.target)
)
// Create serialized workflow with filtered blocks and edges
const workflow = new Serializer().serializeWorkflow(
filteredStates,
filteredEdges,
loops,
parallels
)
// Determine if this is a chat execution
const isChatExecution =
@@ -1,4 +1,5 @@
import { createLogger } from '@/lib/logs/console-logger'
import { getBlock } from '@/blocks'
const logger = createLogger('WorkflowUtils')
@@ -549,14 +550,25 @@ export const analyzeWorkflowGraph = (
const outDegreeValue = (adjacencyList.get(blockId) || []).length
const block = blocks[blockId]
if (inDegreeValue === 0 && outDegreeValue === 0 && block.type !== 'starter') {
const blockConfig = getBlock(block.type)
const isTriggerBlock = blockConfig?.category === 'triggers'
if (
inDegreeValue === 0 &&
outDegreeValue === 0 &&
block.type !== 'starter' &&
!isTriggerBlock
) {
orphanedBlocks.add(blockId)
}
})
const queue: string[] = []
inDegree.forEach((degree, blockId) => {
if (degree === 0 || blocks[blockId].type === 'starter') {
const blockConfig = getBlock(blocks[blockId].type)
const isTriggerBlock = blockConfig?.category === 'triggers'
if (degree === 0 || blocks[blockId].type === 'starter' || isTriggerBlock) {
queue.push(blockId)
blockLayers.set(blockId, 0)
}
@@ -1080,6 +1080,16 @@ const WorkflowContent = React.memo(() => {
if (!sourceNode || !targetNode) return
// Prevent incoming connections to trigger blocks (webhook, schedule, etc.)
if (targetNode.data?.config?.category === 'triggers') {
return
}
// Prevent incoming connections to starter blocks (still keep separate for backward compatibility)
if (targetNode.data?.type === 'starter') {
return
}
// Get parent information (handle container start node case)
const sourceParentId =
sourceNode.parentId ||
@@ -143,7 +143,10 @@ export function SearchModal({
const allBlocks = getAllBlocks()
return allBlocks
.filter(
(block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks'
(block) =>
block.type !== 'starter' &&
!block.hideFromToolbar &&
(block.category === 'blocks' || block.category === 'triggers')
)
.map(
(block): BlockItem => ({
@@ -222,7 +225,7 @@ export function SearchModal({
name: block.name,
icon: block.icon,
href: block.docsLink,
type: block.category === 'blocks' ? 'block' : 'tool',
type: block.category === 'blocks' || block.category === 'triggers' ? 'block' : 'tool',
})
}
})
@@ -25,7 +25,7 @@ interface BlockItem {
export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: ToolbarProps) {
const [searchQuery, setSearchQuery] = useState('')
const { regularBlocks, specialBlocks, tools } = useMemo(() => {
const { regularBlocks, specialBlocks, tools, triggers } = useMemo(() => {
const allBlocks = getAllBlocks()
// Filter blocks based on search query
@@ -39,9 +39,10 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
)
})
// Separate regular blocks (category: 'blocks') and tools (category: 'tools')
// Separate blocks by category: 'blocks', 'tools', and 'triggers'
const regularBlockConfigs = filteredBlocks.filter((block) => block.category === 'blocks')
const toolConfigs = filteredBlocks.filter((block) => block.category === 'tools')
const triggerConfigs = filteredBlocks.filter((block) => block.category === 'triggers')
// Create regular block items and sort alphabetically
const regularBlockItems: BlockItem[] = regularBlockConfigs
@@ -75,6 +76,16 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
// Sort special blocks alphabetically
specialBlockItems.sort((a, b) => a.name.localeCompare(b.name))
// Create trigger block items and sort alphabetically
const triggerBlockItems: BlockItem[] = triggerConfigs
.map((block) => ({
name: block.name,
type: block.type,
config: block,
isCustom: false,
}))
.sort((a, b) => a.name.localeCompare(b.name))
// Sort tools alphabetically
toolConfigs.sort((a, b) => a.name.localeCompare(b.name))
@@ -82,6 +93,7 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
regularBlocks: regularBlockItems,
specialBlocks: specialBlockItems,
tools: toolConfigs,
triggers: triggerBlockItems,
}
}, [searchQuery])
@@ -127,6 +139,15 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }:
return null
})}
{/* Triggers Section */}
{triggers.map((trigger) => (
<ToolbarBlock
key={trigger.type}
config={trigger.config}
disabled={!userPermissions.canEdit}
/>
))}
{/* Tools Section */}
{tools.map((tool) => (
<ToolbarBlock key={tool.type} config={tool} disabled={!userPermissions.canEdit} />
+116
View File
@@ -0,0 +1,116 @@
import { ScheduleIcon } from '@/components/icons'
import type { BlockConfig } from '../types'
export const ScheduleBlock: BlockConfig = {
type: 'schedule',
name: 'Schedule',
description: 'Trigger workflow execution on a schedule',
longDescription:
'Configure automated workflow execution with flexible timing options. Set up recurring workflows that run at specific intervals or times.',
category: 'triggers',
bgColor: '#7B68EE',
icon: ScheduleIcon,
subBlocks: [
// Schedule configuration status display
{
id: 'scheduleConfig',
title: 'Schedule Status',
type: 'schedule-config',
layout: 'full',
},
// Hidden fields for schedule configuration (used by the modal only)
{
id: 'scheduleType',
title: 'Frequency',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Every X Minutes', id: 'minutes' },
{ label: 'Hourly', id: 'hourly' },
{ label: 'Daily', id: 'daily' },
{ label: 'Weekly', id: 'weekly' },
{ label: 'Monthly', id: 'monthly' },
{ label: 'Custom Cron', id: 'custom' },
],
value: () => 'daily',
hidden: true,
},
{
id: 'minutesInterval',
type: 'short-input',
hidden: true,
},
{
id: 'hourlyMinute',
type: 'short-input',
hidden: true,
},
{
id: 'dailyTime',
type: 'short-input',
hidden: true,
},
{
id: 'weeklyDay',
type: 'dropdown',
hidden: true,
options: [
{ label: 'Monday', id: 'MON' },
{ label: 'Tuesday', id: 'TUE' },
{ label: 'Wednesday', id: 'WED' },
{ label: 'Thursday', id: 'THU' },
{ label: 'Friday', id: 'FRI' },
{ label: 'Saturday', id: 'SAT' },
{ label: 'Sunday', id: 'SUN' },
],
value: () => 'MON',
},
{
id: 'weeklyDayTime',
type: 'short-input',
hidden: true,
},
{
id: 'monthlyDay',
type: 'short-input',
hidden: true,
},
{
id: 'monthlyTime',
type: 'short-input',
hidden: true,
},
{
id: 'cronExpression',
type: 'short-input',
hidden: true,
},
{
id: 'timezone',
type: 'dropdown',
hidden: true,
options: [
{ label: 'UTC', id: 'UTC' },
{ label: 'US Eastern (UTC-4)', id: 'America/New_York' },
{ label: 'US Central (UTC-5)', id: 'America/Chicago' },
{ label: 'US Mountain (UTC-6)', id: 'America/Denver' },
{ label: 'US Pacific (UTC-7)', id: 'America/Los_Angeles' },
{ label: 'London (UTC+1)', id: 'Europe/London' },
{ label: 'Paris (UTC+2)', id: 'Europe/Paris' },
{ label: 'Singapore (UTC+8)', id: 'Asia/Singapore' },
{ label: 'Tokyo (UTC+9)', id: 'Asia/Tokyo' },
{ label: 'Sydney (UTC+10)', id: 'Australia/Sydney' },
],
value: () => 'UTC',
},
],
tools: {
access: [], // No external tools needed
},
inputs: {}, // No inputs - schedule triggers initiate workflows
outputs: {}, // No outputs - schedule triggers initiate workflow execution
}
+2 -146
View File
@@ -5,8 +5,7 @@ export const StarterBlock: BlockConfig = {
type: 'starter',
name: 'Starter',
description: 'Start workflow',
longDescription:
'Initiate your workflow manually, on a schedule, or via webhook triggers. Configure flexible execution patterns with customizable timing options and webhook security.',
longDescription: 'Initiate your workflow manually with optional structured input for API calls.',
category: 'blocks',
bgColor: '#2FB3FF',
icon: StartIcon,
@@ -19,8 +18,7 @@ export const StarterBlock: BlockConfig = {
layout: 'full',
options: [
{ label: 'Run manually', id: 'manual' },
{ label: 'On webhook call', id: 'webhook' },
{ label: 'On schedule', id: 'schedule' },
{ label: 'Chat', id: 'chat' },
],
value: () => 'manual',
},
@@ -33,148 +31,6 @@ export const StarterBlock: BlockConfig = {
mode: 'advanced',
condition: { field: 'startWorkflow', value: 'manual' },
},
// Webhook configuration
{
id: 'webhookProvider',
title: 'Webhook Provider',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Slack', id: 'slack' },
{ label: 'Gmail', id: 'gmail' },
{ label: 'Airtable', id: 'airtable' },
{ label: 'Telegram', id: 'telegram' },
{ label: 'Generic', id: 'generic' },
// { label: 'WhatsApp', id: 'whatsapp' },
// { label: 'GitHub', id: 'github' },
// { label: 'Discord', id: 'discord' },
// { label: 'Stripe', id: 'stripe' },
],
value: () => 'generic',
condition: { field: 'startWorkflow', value: 'webhook' },
},
{
id: 'webhookConfig',
title: 'Webhook Configuration',
type: 'webhook-config',
layout: 'full',
condition: { field: 'startWorkflow', value: 'webhook' },
},
// Schedule configuration status display
{
id: 'scheduleConfig',
title: 'Schedule Status',
type: 'schedule-config',
layout: 'full',
condition: { field: 'startWorkflow', value: 'schedule' },
},
// Hidden fields for schedule configuration (used by the modal only)
{
id: 'scheduleType',
title: 'Frequency',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Every X Minutes', id: 'minutes' },
{ label: 'Hourly', id: 'hourly' },
{ label: 'Daily', id: 'daily' },
{ label: 'Weekly', id: 'weekly' },
{ label: 'Monthly', id: 'monthly' },
{ label: 'Custom Cron', id: 'custom' },
],
value: () => 'daily',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'scheduleStartAt',
type: 'date-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'scheduleTime',
type: 'time-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'minutesInterval',
type: 'short-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'hourlyMinute',
type: 'short-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'dailyTime',
type: 'short-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'weeklyDay',
type: 'dropdown',
hidden: true,
options: [
{ label: 'Monday', id: 'MON' },
{ label: 'Tuesday', id: 'TUE' },
{ label: 'Wednesday', id: 'WED' },
{ label: 'Thursday', id: 'THU' },
{ label: 'Friday', id: 'FRI' },
{ label: 'Saturday', id: 'SAT' },
{ label: 'Sunday', id: 'SUN' },
],
value: () => 'MON',
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'weeklyDayTime',
type: 'short-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'monthlyDay',
type: 'short-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'monthlyTime',
type: 'short-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'cronExpression',
type: 'short-input',
hidden: true,
condition: { field: 'startWorkflow', value: 'schedule' },
},
{
id: 'timezone',
type: 'dropdown',
hidden: true,
options: [
{ label: 'UTC', id: 'UTC' },
{ label: 'US Eastern (UTC-4)', id: 'America/New_York' },
{ label: 'US Central (UTC-5)', id: 'America/Chicago' },
{ label: 'US Mountain (UTC-6)', id: 'America/Denver' },
{ label: 'US Pacific (UTC-7)', id: 'America/Los_Angeles' },
{ label: 'London (UTC+1)', id: 'Europe/London' },
{ label: 'Paris (UTC+2)', id: 'Europe/Paris' },
{ label: 'Singapore (UTC+8)', id: 'Asia/Singapore' },
{ label: 'Tokyo (UTC+9)', id: 'Asia/Tokyo' },
{ label: 'Sydney (UTC+10)', id: 'Australia/Sydney' },
],
value: () => 'UTC',
condition: { field: 'startWorkflow', value: 'schedule' },
},
],
tools: {
access: [],
+92
View File
@@ -0,0 +1,92 @@
import {
AirtableIcon,
DiscordIcon,
GithubIcon,
GmailIcon,
SignalIcon,
SlackIcon,
StripeIcon,
TelegramIcon,
WebhookIcon,
WhatsAppIcon,
} from '@/components/icons'
import type { BlockConfig } from '../types'
const getWebhookProviderIcon = (provider: string) => {
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
slack: SlackIcon,
gmail: GmailIcon,
airtable: AirtableIcon,
telegram: TelegramIcon,
generic: SignalIcon,
whatsapp: WhatsAppIcon,
github: GithubIcon,
discord: DiscordIcon,
stripe: StripeIcon,
}
return iconMap[provider.toLowerCase()]
}
export const WebhookBlock: BlockConfig = {
type: 'webhook',
name: 'Webhook',
description: 'Trigger workflow execution from external webhooks',
category: 'triggers',
icon: WebhookIcon,
bgColor: '#10B981', // Green color for triggers
subBlocks: [
{
id: 'webhookProvider',
title: 'Webhook Provider',
type: 'dropdown',
layout: 'full',
options: [
'slack',
'gmail',
'airtable',
'telegram',
'generic',
'whatsapp',
'github',
'discord',
'stripe',
].map((provider) => {
const providerLabels = {
slack: 'Slack',
gmail: 'Gmail',
airtable: 'Airtable',
telegram: 'Telegram',
generic: 'Generic',
whatsapp: 'WhatsApp',
github: 'GitHub',
discord: 'Discord',
stripe: 'Stripe',
}
const icon = getWebhookProviderIcon(provider)
return {
label: providerLabels[provider as keyof typeof providerLabels],
id: provider,
...(icon && { icon }),
}
}),
value: () => 'generic',
},
{
id: 'webhookConfig',
title: 'Webhook Configuration',
type: 'webhook-config',
layout: 'full',
},
],
tools: {
access: [], // No external tools needed
},
inputs: {}, // No inputs - webhook triggers are pure input sources
outputs: {}, // No outputs - webhook data is injected directly into workflow context
}
+5 -1
View File
@@ -45,6 +45,7 @@ import { RedditBlock } from '@/blocks/blocks/reddit'
import { ResponseBlock } from '@/blocks/blocks/response'
import { RouterBlock } from '@/blocks/blocks/router'
import { S3Block } from '@/blocks/blocks/s3'
import { ScheduleBlock } from '@/blocks/blocks/schedule'
import { SerperBlock } from '@/blocks/blocks/serper'
import { SlackBlock } from '@/blocks/blocks/slack'
import { StagehandBlock } from '@/blocks/blocks/stagehand'
@@ -59,6 +60,7 @@ import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
import { TypeformBlock } from '@/blocks/blocks/typeform'
import { VisionBlock } from '@/blocks/blocks/vision'
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
import { WebhookBlock } from '@/blocks/blocks/webhook'
import { WhatsAppBlock } from '@/blocks/blocks/whatsapp'
import { WorkflowBlock } from '@/blocks/blocks/workflow'
import { XBlock } from '@/blocks/blocks/x'
@@ -108,6 +110,7 @@ export const registry: Record<string, BlockConfig> = {
reddit: RedditBlock,
response: ResponseBlock,
router: RouterBlock,
schedule: ScheduleBlock,
s3: S3Block,
serper: SerperBlock,
stagehand: StagehandBlock,
@@ -123,6 +126,7 @@ export const registry: Record<string, BlockConfig> = {
typeform: TypeformBlock,
vision: VisionBlock,
wealthbox: WealthboxBlock,
webhook: WebhookBlock,
whatsapp: WhatsAppBlock,
workflow: WorkflowBlock,
x: XBlock,
@@ -132,7 +136,7 @@ export const registry: Record<string, BlockConfig> = {
// Helper functions to access the registry
export const getBlock = (type: string): BlockConfig | undefined => registry[type]
export const getBlocksByCategory = (category: 'blocks' | 'tools'): BlockConfig[] =>
export const getBlocksByCategory = (category: 'blocks' | 'tools' | 'triggers'): BlockConfig[] =>
Object.values(registry).filter((block) => block.category === category)
export const getAllBlockTypes = (): string[] => Object.keys(registry)
+1 -1
View File
@@ -7,7 +7,7 @@ export type ParamType = 'string' | 'number' | 'boolean' | 'json'
export type PrimitiveValueType = 'string' | 'number' | 'boolean' | 'json' | 'any'
// Block classification
export type BlockCategory = 'blocks' | 'tools'
export type BlockCategory = 'blocks' | 'tools' | 'triggers'
// SubBlock types
export type SubBlockType =
+38
View File
@@ -2988,3 +2988,41 @@ export function WealthboxIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
fill='currentColor'
width='800px'
height='800px'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M17.974 7A4.967 4.967 0 0 0 18 6.5a5.5 5.5 0 1 0-8.672 4.491L7.18 15.114A2.428 2.428 0 0 0 6.496 15 2.5 2.5 0 1 0 9 17.496a2.36 2.36 0 0 0-.93-1.925l2.576-4.943-.41-.241A4.5 4.5 0 1 1 17 6.5a4.8 4.8 0 0 1-.022.452zM6.503 18.999a1.5 1.5 0 1 1 1.496-1.503A1.518 1.518 0 0 1 6.503 19zM18.5 12a5.735 5.735 0 0 0-1.453.157l-2.744-3.941A2.414 2.414 0 0 0 15 6.5a2.544 2.544 0 1 0-1.518 2.284l3.17 4.557.36-.13A4.267 4.267 0 0 1 18.5 13a4.5 4.5 0 1 1-.008 9h-.006a4.684 4.684 0 0 1-3.12-1.355l-.703.71A5.653 5.653 0 0 0 18.49 23h.011a5.5 5.5 0 0 0 0-11zM11 6.5A1.5 1.5 0 1 1 12.5 8 1.509 1.509 0 0 1 11 6.5zM18.5 20a2.5 2.5 0 1 0-2.447-3h-5.05l-.003.497A4.546 4.546 0 0 1 6.5 22 4.526 4.526 0 0 1 2 17.5a4.596 4.596 0 0 1 3.148-4.37l-.296-.954A5.606 5.606 0 0 0 1 17.5 5.532 5.532 0 0 0 6.5 23a5.573 5.573 0 0 0 5.478-5h4.08a2.487 2.487 0 0 0 2.442 2zm0-4a1.5 1.5 0 1 1-1.5 1.5 1.509 1.509 0 0 1 1.5-1.5z' />
<path fill='none' d='M0 0h24v24H0z' />
</svg>
)
}
export function ScheduleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M8 2v4' />
<path d='M16 2v4' />
<rect width='18' height='18' x='3' y='4' rx='2' />
<path d='M3 10h18' />
</svg>
)
}
+48 -20
View File
@@ -184,19 +184,33 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
} else if (Object.keys(blockConfig.outputs).length === 0) {
// Handle blocks with no outputs (like starter) - check for custom input fields
if (sourceBlock.type === 'starter') {
// Check for custom input format fields
const inputFormatValue = useSubBlockStore
// Check what start workflow mode is selected
const startWorkflowValue = useSubBlockStore
.getState()
.getValue(activeSourceBlockId, 'inputFormat')
.getValue(activeSourceBlockId, 'startWorkflow')
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
// Use custom input fields if they exist
blockTags = inputFormatValue
.filter((field: any) => field.name && field.name.trim() !== '')
.map((field: any) => `${normalizedBlockName}.${field.name}`)
if (startWorkflowValue === 'chat') {
// For chat mode, provide input and conversationId
blockTags = [`${normalizedBlockName}.input`, `${normalizedBlockName}.conversationId`]
} else {
// Fallback to just the block name
blockTags = [normalizedBlockName]
// Check for custom input format fields (for manual mode)
const inputFormatValue = useSubBlockStore
.getState()
.getValue(activeSourceBlockId, 'inputFormat')
if (
inputFormatValue &&
Array.isArray(inputFormatValue) &&
inputFormatValue.length > 0
) {
// Use custom input fields if they exist
blockTags = inputFormatValue
.filter((field: any) => field.name && field.name.trim() !== '')
.map((field: any) => `${normalizedBlockName}.${field.name}`)
} else {
// Fallback to just the block name
blockTags = [normalizedBlockName]
}
}
} else {
// Other blocks with no outputs - show as just <blockname>
@@ -429,19 +443,33 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
} else if (Object.keys(blockConfig.outputs).length === 0) {
// Handle blocks with no outputs (like starter) - check for custom input fields
if (accessibleBlock.type === 'starter') {
// Check for custom input format fields
const inputFormatValue = useSubBlockStore
// Check what start workflow mode is selected
const startWorkflowValue = useSubBlockStore
.getState()
.getValue(accessibleBlockId, 'inputFormat')
.getValue(accessibleBlockId, 'startWorkflow')
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
// Use custom input fields if they exist
blockTags = inputFormatValue
.filter((field: any) => field.name && field.name.trim() !== '')
.map((field: any) => `${normalizedBlockName}.${field.name}`)
if (startWorkflowValue === 'chat') {
// For chat mode, provide input and conversationId
blockTags = [`${normalizedBlockName}.input`, `${normalizedBlockName}.conversationId`]
} else {
// Fallback to just the block name
blockTags = [normalizedBlockName]
// Check for custom input format fields (for manual mode)
const inputFormatValue = useSubBlockStore
.getState()
.getValue(accessibleBlockId, 'inputFormat')
if (
inputFormatValue &&
Array.isArray(inputFormatValue) &&
inputFormatValue.length > 0
) {
// Use custom input fields if they exist
blockTags = inputFormatValue
.filter((field: any) => field.name && field.name.trim() !== '')
.map((field: any) => `${normalizedBlockName}.${field.name}`)
} else {
// Fallback to just the block name
blockTags = [normalizedBlockName]
}
}
} else {
// Other blocks with no outputs - show as just <blockname>
+1 -1
View File
@@ -357,7 +357,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
})
socketInstance.on('workflow-state', (state) => {
logger.info('Received workflow state from server:', state)
// logger.info('Received workflow state from server:', state)
// This will be used to sync initial state when joining a workflow
})
@@ -0,0 +1,6 @@
ALTER TABLE "workflow_schedule" DROP CONSTRAINT "workflow_schedule_workflow_id_unique";--> statement-breakpoint
ALTER TABLE "webhook" ADD COLUMN "block_id" text;--> statement-breakpoint
ALTER TABLE "workflow_schedule" ADD COLUMN "block_id" text;--> statement-breakpoint
ALTER TABLE "webhook" ADD CONSTRAINT "webhook_block_id_workflow_blocks_id_fk" FOREIGN KEY ("block_id") REFERENCES "public"."workflow_blocks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workflow_schedule" ADD CONSTRAINT "workflow_schedule_block_id_workflow_blocks_id_fk" FOREIGN KEY ("block_id") REFERENCES "public"."workflow_blocks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "workflow_schedule_workflow_block_unique" ON "workflow_schedule" USING btree ("workflow_id","block_id");
File diff suppressed because it is too large Load Diff
@@ -393,6 +393,13 @@
"when": 1752789061522,
"tag": "0056_adorable_franklin_richards",
"breakpoints": true
},
{
"idx": 57,
"version": "7",
"when": 1752980338632,
"tag": "0057_charming_star_brand",
"breakpoints": true
}
]
}
+29 -17
View File
@@ -410,23 +410,34 @@ export const settings = pgTable('settings', {
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workflowSchedule = pgTable('workflow_schedule', {
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' })
.unique(),
cronExpression: text('cron_expression'),
nextRunAt: timestamp('next_run_at'),
lastRanAt: timestamp('last_ran_at'),
triggerType: text('trigger_type').notNull(), // "manual", "webhook", "schedule"
timezone: text('timezone').notNull().default('UTC'),
failedCount: integer('failed_count').notNull().default(0), // Track consecutive failures
status: text('status').notNull().default('active'), // 'active' or 'disabled'
lastFailedAt: timestamp('last_failed_at'), // When the schedule last failed
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workflowSchedule = pgTable(
'workflow_schedule',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
blockId: text('block_id').references(() => workflowBlocks.id, { onDelete: 'cascade' }),
cronExpression: text('cron_expression'),
nextRunAt: timestamp('next_run_at'),
lastRanAt: timestamp('last_ran_at'),
triggerType: text('trigger_type').notNull(), // "manual", "webhook", "schedule"
timezone: text('timezone').notNull().default('UTC'),
failedCount: integer('failed_count').notNull().default(0), // Track consecutive failures
status: text('status').notNull().default('active'), // 'active' or 'disabled'
lastFailedAt: timestamp('last_failed_at'), // When the schedule last failed
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
workflowBlockUnique: uniqueIndex('workflow_schedule_workflow_block_unique').on(
table.workflowId,
table.blockId
),
}
}
)
export const webhook = pgTable(
'webhook',
@@ -435,6 +446,7 @@ export const webhook = pgTable(
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
blockId: text('block_id').references(() => workflowBlocks.id, { onDelete: 'cascade' }), // ID of the webhook trigger block (nullable for legacy starter block webhooks)
path: text('path').notNull(),
provider: text('provider'), // e.g., "whatsapp", "github", etc.
providerConfig: json('provider_config'), // Store provider-specific configuration
+2
View File
@@ -14,6 +14,8 @@ export enum BlockType {
RESPONSE = 'response',
WORKFLOW = 'workflow',
STARTER = 'starter',
SCHEDULE = 'schedule',
WEBHOOK_TRIGGER = 'webhook_trigger',
}
/**
+90 -53
View File
@@ -167,9 +167,13 @@ export class Executor {
* Executes the workflow and returns the result.
*
* @param workflowId - Unique identifier for the workflow execution
* @param startBlockId - Optional block ID to start execution from (for webhook or schedule triggers)
* @returns Execution result containing output, logs, and metadata, or a stream, or combined execution and stream
*/
async execute(workflowId: string): Promise<ExecutionResult | StreamingExecution> {
async execute(
workflowId: string,
startBlockId?: string
): Promise<ExecutionResult | StreamingExecution> {
const { setIsExecuting, setIsDebugging, setPendingBlocks, reset } = useExecutionStore.getState()
const startTime = new Date()
let finalOutput: NormalizedBlockOutput = {}
@@ -182,9 +186,9 @@ export class Executor {
startTime: startTime.toISOString(),
})
this.validateWorkflow()
this.validateWorkflow(startBlockId)
const context = this.createExecutionContext(workflowId, startTime)
const context = this.createExecutionContext(workflowId, startTime, startBlockId)
try {
setIsExecuting(true)
@@ -543,30 +547,46 @@ export class Executor {
/**
* Validates that the workflow meets requirements for execution.
* Checks for starter block, connections, and loop configurations.
* Checks for starter block, webhook trigger block, or schedule trigger block, connections, and loop configurations.
*
* @param startBlockId - Optional specific block to start from
* @throws Error if workflow validation fails
*/
private validateWorkflow(): void {
const starterBlock = this.actualWorkflow.blocks.find(
(block) => block.metadata?.id === BlockType.STARTER
)
if (!starterBlock || !starterBlock.enabled) {
throw new Error('Workflow must have an enabled starter block')
}
private validateWorkflow(startBlockId?: string): void {
let validationBlock: SerializedBlock | undefined
const incomingToStarter = this.actualWorkflow.connections.filter(
(conn) => conn.target === starterBlock.id
)
if (incomingToStarter.length > 0) {
throw new Error('Starter block cannot have incoming connections')
}
if (startBlockId) {
// If starting from a specific block (webhook trigger or schedule trigger), validate that block exists
const startBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId)
if (!startBlock || !startBlock.enabled) {
throw new Error(`Start block ${startBlockId} not found or disabled`)
}
validationBlock = startBlock
// Trigger blocks (webhook and schedule) can have incoming connections, so no need to check that
} else {
// Default validation for starter block
const starterBlock = this.actualWorkflow.blocks.find(
(block) => block.metadata?.id === BlockType.STARTER
)
if (!starterBlock || !starterBlock.enabled) {
throw new Error('Workflow must have an enabled starter block')
}
validationBlock = starterBlock
const outgoingFromStarter = this.actualWorkflow.connections.filter(
(conn) => conn.source === starterBlock.id
)
if (outgoingFromStarter.length === 0) {
throw new Error('Starter block must have at least one outgoing connection')
const incomingToStarter = this.actualWorkflow.connections.filter(
(conn) => conn.target === starterBlock.id
)
if (incomingToStarter.length > 0) {
throw new Error('Starter block cannot have incoming connections')
}
// Only check outgoing connections for starter blocks, not trigger blocks
const outgoingFromStarter = this.actualWorkflow.connections.filter(
(conn) => conn.source === starterBlock.id
)
if (outgoingFromStarter.length === 0) {
throw new Error('Starter block must have at least one outgoing connection')
}
}
const blockIds = new Set(this.actualWorkflow.blocks.map((block) => block.id))
@@ -603,13 +623,18 @@ export class Executor {
/**
* Creates the initial execution context with predefined states.
* Sets up the starter block and its connections in the active execution path.
* Sets up the starter block, webhook trigger block, or schedule trigger block and its connections in the active execution path.
*
* @param workflowId - Unique identifier for the workflow execution
* @param startTime - Execution start time
* @param startBlockId - Optional specific block to start from
* @returns Initialized execution context
*/
private createExecutionContext(workflowId: string, startTime: Date): ExecutionContext {
private createExecutionContext(
workflowId: string,
startTime: Date,
startBlockId?: string
): ExecutionContext {
const context: ExecutionContext = {
workflowId,
blockStates: new Map(),
@@ -652,13 +677,22 @@ export class Executor {
}
}
const starterBlock = this.actualWorkflow.blocks.find(
(block) => block.metadata?.id === BlockType.STARTER
)
if (starterBlock) {
// Initialize the starter block with the workflow input
// Determine which block to initialize as the starting point
let initBlock: SerializedBlock | undefined
if (startBlockId) {
// Starting from a specific block (webhook trigger or schedule trigger)
initBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId)
} else {
// Default to starter block
initBlock = this.actualWorkflow.blocks.find(
(block) => block.metadata?.id === BlockType.STARTER
)
}
if (initBlock) {
// Initialize the starting block with the workflow input
try {
const blockParams = starterBlock.config.params
const blockParams = initBlock.config.params
const inputFormat = blockParams?.inputFormat
// If input format is defined, structure the input according to the schema
@@ -718,17 +752,17 @@ export class Executor {
// Use the structured input if we processed fields, otherwise use raw input
const finalInput = hasProcessedFields ? structuredInput : rawInputData
// Initialize the starter block with structured input (flattened)
const starterOutput = {
// Initialize the starting block with structured input (flattened)
const blockOutput = {
input: finalInput,
conversationId: this.workflowInput?.conversationId, // Add conversationId to root
...finalInput, // Add input fields directly at top level
}
logger.info(`[Executor] Starter output:`, JSON.stringify(starterOutput, null, 2))
logger.info(`[Executor] Starting block output:`, JSON.stringify(blockOutput, null, 2))
context.blockStates.set(starterBlock.id, {
output: starterOutput,
context.blockStates.set(initBlock.id, {
output: blockOutput,
executed: true,
executionTime: 0,
})
@@ -746,7 +780,7 @@ export class Executor {
conversationId: this.workflowInput.conversationId,
}
context.blockStates.set(starterBlock.id, {
context.blockStates.set(initBlock.id, {
output: starterOutput,
executed: true,
executionTime: 0,
@@ -755,7 +789,7 @@ export class Executor {
// API workflow: spread the raw data directly (no wrapping)
const starterOutput = { ...this.workflowInput }
context.blockStates.set(starterBlock.id, {
context.blockStates.set(initBlock.id, {
output: starterOutput,
executed: true,
executionTime: 0,
@@ -767,7 +801,7 @@ export class Executor {
input: this.workflowInput,
}
context.blockStates.set(starterBlock.id, {
context.blockStates.set(initBlock.id, {
output: starterOutput,
executed: true,
executionTime: 0,
@@ -778,7 +812,7 @@ export class Executor {
logger.warn('Error processing starter block input format:', e)
// Error handler fallback - use appropriate structure
let starterOutput: any
let blockOutput: any
if (this.workflowInput && typeof this.workflowInput === 'object') {
// Check if this is a chat workflow input (has both input and conversationId)
if (
@@ -786,40 +820,43 @@ export class Executor {
Object.hasOwn(this.workflowInput, 'conversationId')
) {
// Chat workflow: extract input and conversationId to root level
starterOutput = {
blockOutput = {
input: this.workflowInput.input,
conversationId: this.workflowInput.conversationId,
}
} else {
// API workflow: spread the raw data directly (no wrapping)
starterOutput = { ...this.workflowInput }
blockOutput = { ...this.workflowInput }
}
} else {
// Primitive input
starterOutput = {
blockOutput = {
input: this.workflowInput,
}
}
logger.info('[Executor] Fallback starter output:', JSON.stringify(starterOutput, null, 2))
logger.info(
'[Executor] Fallback starting block output:',
JSON.stringify(blockOutput, null, 2)
)
context.blockStates.set(starterBlock.id, {
output: starterOutput,
context.blockStates.set(initBlock.id, {
output: blockOutput,
executed: true,
executionTime: 0,
})
}
// Ensure the starter block is in the active execution path
context.activeExecutionPath.add(starterBlock.id)
// Mark the starter block as executed
context.executedBlocks.add(starterBlock.id)
// Ensure the starting block is in the active execution path
context.activeExecutionPath.add(initBlock.id)
// Mark the starting block as executed
context.executedBlocks.add(initBlock.id)
// Add all blocks connected to the starter to the active execution path
const connectedToStarter = this.actualWorkflow.connections
.filter((conn) => conn.source === starterBlock.id)
// Add all blocks connected to the starting block to the active execution path
const connectedToStartBlock = this.actualWorkflow.connections
.filter((conn) => conn.source === initBlock.id)
.map((conn) => conn.target)
connectedToStarter.forEach((blockId) => {
connectedToStartBlock.forEach((blockId) => {
context.activeExecutionPath.add(blockId)
})
}
@@ -289,7 +289,6 @@ export function useCollaborativeWorkflow() {
isDeployed: workflowData.state.isDeployed || false,
deployedAt: workflowData.state.deployedAt,
lastSaved: workflowData.state.lastSaved || Date.now(),
hasActiveSchedule: workflowData.state.hasActiveSchedule || false,
hasActiveWebhook: workflowData.state.hasActiveWebhook || false,
deploymentStatuses: workflowData.state.deploymentStatuses || {},
})
+11 -4
View File
@@ -423,7 +423,8 @@ export async function executeWorkflowFromPayload(
foundWorkflow: any,
input: any,
executionId: string,
requestId: string
requestId: string,
startBlockId?: string | null
): Promise<void> {
// Add log at the beginning of this function for clarity
logger.info(`[${requestId}] Preparing to execute workflow`, {
@@ -668,7 +669,7 @@ export async function executeWorkflowFromPayload(
)
// This is THE critical line where the workflow actually executes
const result = await executor.execute(foundWorkflow.id)
const result = await executor.execute(foundWorkflow.id, startBlockId || undefined)
// Check if we got a StreamingExecution result (with stream + execution properties)
// For webhook executions, we only care about the ExecutionResult part, not the stream
@@ -1275,7 +1276,7 @@ export async function fetchAndProcessAirtablePayloads(
}
)
await executeWorkflowFromPayload(workflowData, input, requestId, requestId)
await executeWorkflowFromPayload(workflowData, input, requestId, requestId, null)
// COMPLETION LOG - This will only appear if execution succeeds
logger.info(`[${requestId}] CRITICAL_TRACE: Workflow execution completed successfully`, {
@@ -1372,7 +1373,13 @@ export async function processWebhook(
`[${requestId}] Executing workflow ${foundWorkflow.id} for webhook ${foundWebhook.id} (Execution: ${executionId})`
)
await executeWorkflowFromPayload(foundWorkflow, input, executionId, requestId)
await executeWorkflowFromPayload(
foundWorkflow,
input,
executionId,
requestId,
foundWebhook.blockId
)
// Since executeWorkflowFromPayload handles logging and errors internally,
// we just need to return a standard success response for synchronous webhooks.
@@ -207,7 +207,6 @@ const mockWorkflowState: WorkflowState = {
lastSaved: Date.now(),
isDeployed: false,
deploymentStatuses: {},
hasActiveSchedule: false,
hasActiveWebhook: false,
}
@@ -463,7 +462,6 @@ describe('Database Helpers', () => {
lastSaved: Date.now(),
isDeployed: false,
deploymentStatuses: {},
hasActiveSchedule: false,
hasActiveWebhook: false,
}
@@ -643,7 +641,6 @@ describe('Database Helpers', () => {
lastSaved: Date.now(),
isDeployed: false,
deploymentStatuses: {},
hasActiveSchedule: false,
hasActiveWebhook: false,
}
@@ -731,7 +728,6 @@ describe('Database Helpers', () => {
lastSaved: Date.now(),
isDeployed: false,
deploymentStatuses: {},
hasActiveSchedule: false,
hasActiveWebhook: false,
}
-2
View File
@@ -211,7 +211,6 @@ export async function saveWorkflowToNormalizedTables(
isDeployed: state.isDeployed,
deployedAt: state.deployedAt,
deploymentStatuses: state.deploymentStatuses,
hasActiveSchedule: state.hasActiveSchedule,
hasActiveWebhook: state.hasActiveWebhook,
}
@@ -264,7 +263,6 @@ export async function migrateWorkflowToNormalizedTables(
isDeployed: jsonState.isDeployed,
deployedAt: jsonState.deployedAt,
deploymentStatuses: jsonState.deploymentStatuses || {},
hasActiveSchedule: jsonState.hasActiveSchedule,
hasActiveWebhook: jsonState.hasActiveWebhook,
}
@@ -130,7 +130,6 @@ export async function getWorkflowState(workflowId: string) {
const finalState = {
// Default values for expected properties
deploymentStatuses: {},
hasActiveSchedule: false,
hasActiveWebhook: false,
// Preserve any existing state properties
...existingState,
@@ -188,7 +188,6 @@ function resetWorkflowStores() {
isDeployed: false,
deployedAt: undefined,
deploymentStatuses: {}, // Reset deployment statuses map
hasActiveSchedule: false,
history: {
past: [],
present: {
@@ -441,7 +440,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
lastSaved: Date.now(),
marketplaceData: workflowData.marketplaceData || null,
deploymentStatuses: {},
hasActiveSchedule: false,
history: {
past: [],
present: {
@@ -490,7 +488,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
isDeployed: false,
deployedAt: undefined,
deploymentStatuses: {},
hasActiveSchedule: false,
history: {
past: [],
present: {
@@ -1253,7 +1250,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
parallels: {},
isDeployed: false,
deployedAt: undefined,
hasActiveSchedule: false,
history: {
past: [],
present: {
@@ -23,7 +23,6 @@ const initialState = {
// New field for per-workflow deployment tracking
deploymentStatuses: {},
needsRedeployment: false,
hasActiveSchedule: false,
hasActiveWebhook: false,
history: {
past: [],
@@ -436,7 +435,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
lastSaved: Date.now(),
isDeployed: false,
isPublished: false,
hasActiveSchedule: false,
hasActiveWebhook: false,
}
set(newState)
@@ -799,23 +797,9 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
}))
},
setScheduleStatus: (hasActiveSchedule: boolean) => {
// Only update if the status has changed to avoid unnecessary rerenders
if (get().hasActiveSchedule !== hasActiveSchedule) {
set({ hasActiveSchedule })
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
}
},
setWebhookStatus: (hasActiveWebhook: boolean) => {
// Only update if the status has changed to avoid unnecessary rerenders
if (get().hasActiveWebhook !== hasActiveWebhook) {
// If the workflow has an active schedule, disable it
if (get().hasActiveSchedule) {
get().setScheduleStatus(false)
}
set({ hasActiveWebhook })
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
@@ -140,7 +140,6 @@ export interface WorkflowState {
// New field for per-workflow deployment status
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
hasActiveSchedule?: boolean
hasActiveWebhook?: boolean
}
@@ -189,7 +188,6 @@ export interface WorkflowActions {
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
setScheduleStatus: (hasActiveSchedule: boolean) => void
setWebhookStatus: (hasActiveWebhook: boolean) => void
revertToDeployedState: (deployedState: WorkflowState) => void
toggleBlockAdvancedMode: (id: string) => void
@@ -696,7 +696,6 @@ export async function importWorkflowFromYaml(
isDeployed: false,
deployedAt: undefined,
deploymentStatuses: {},
hasActiveSchedule: false,
hasActiveWebhook: false,
}