mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Waleed Latif
parent
7b73dfb462
commit
5ee66252ed
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+249
-66
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+26
-88
@@ -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>
|
||||
|
||||
|
||||
+75
-42
@@ -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
|
||||
|
||||
+156
-33
@@ -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
|
||||
|
||||
+82
-75
@@ -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}
|
||||
|
||||
-1
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+35
-3
@@ -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',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+23
-2
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -14,6 +14,8 @@ export enum BlockType {
|
||||
RESPONSE = 'response',
|
||||
WORKFLOW = 'workflow',
|
||||
STARTER = 'starter',
|
||||
SCHEDULE = 'schedule',
|
||||
WEBHOOK_TRIGGER = 'webhook_trigger',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+90
-53
@@ -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 || {},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user