From 8b885ad5ce8760db8fb3df60c436646c5875fc8b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 21 Nov 2025 16:23:28 -0800 Subject: [PATCH 1/2] Fix copilot trigger unsave --- .../sim/app/api/workflows/[id]/state/route.ts | 98 +++++++++- apps/sim/hooks/use-webhook-management.ts | 182 ++++++++++-------- apps/sim/stores/workflows/utils.ts | 9 +- 3 files changed, 202 insertions(+), 87 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index cbd07cf417..654199bdd3 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' +import { webhook, workflow } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -13,6 +13,7 @@ import { getWorkflowAccessContext } from '@/lib/workflows/utils' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import type { BlockState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { getTrigger } from '@/triggers' const logger = createLogger('WorkflowStateAPI') @@ -202,6 +203,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ) } + await syncWorkflowWebhooks(workflowId, workflowState.blocks) + // Extract and persist custom tools to database try { const workspaceId = workflowData.workspaceId @@ -287,3 +290,96 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } + +function getSubBlockValue(block: BlockState, subBlockId: string): T | undefined { + const value = block.subBlocks?.[subBlockId]?.value + if (value === undefined || value === null) { + return undefined + } + return value as T +} + +async function syncWorkflowWebhooks( + workflowId: string, + blocks: Record +): Promise { + const blocksEntries = Object.values(blocks || {}).filter(Boolean) as BlockState[] + if (blocksEntries.length === 0) { + return + } + + for (const block of blocksEntries) { + const webhookId = getSubBlockValue(block, 'webhookId') + if (!webhookId) continue + + const triggerId = + getSubBlockValue(block, 'triggerId') || + getSubBlockValue(block, 'selectedTriggerId') + const triggerConfig = getSubBlockValue>(block, 'triggerConfig') || {} + const triggerCredentials = getSubBlockValue(block, 'triggerCredentials') + const triggerPath = getSubBlockValue(block, 'triggerPath') || block.id + + const triggerDef = triggerId ? getTrigger(triggerId) : undefined + const provider = triggerDef?.provider + + const providerConfig = { + ...(typeof triggerConfig === 'object' ? triggerConfig : {}), + ...(triggerCredentials ? { credentialId: triggerCredentials } : {}), + ...(triggerId ? { triggerId } : {}), + } + + const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1) + + if (existing) { + const needsUpdate = + existing.blockId !== block.id || + existing.workflowId !== workflowId || + existing.path !== triggerPath + + if (needsUpdate) { + await db + .update(webhook) + .set({ + workflowId, + blockId: block.id, + path: triggerPath, + provider: provider || existing.provider, + providerConfig: Object.keys(providerConfig).length + ? providerConfig + : existing.providerConfig, + isActive: true, + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookId)) + } + continue + } + + try { + await db.insert(webhook).values({ + id: webhookId, + workflowId, + blockId: block.id, + path: triggerPath, + provider: provider || null, + providerConfig: providerConfig, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + + logger.info('Recreated missing webhook after workflow save', { + workflowId, + blockId: block.id, + webhookId, + }) + } catch (error) { + logger.error('Failed to recreate webhook during workflow sync', { + workflowId, + blockId: block.id, + webhookId, + error, + }) + } + } +} diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index 2c1ed0f889..8c0ea53fd4 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -208,105 +208,129 @@ export function useWebhookManagement({ loadWebhookOrGenerateUrl() }, [isPreview, triggerId, workflowId, blockId]) - const saveConfig = async (): Promise => { - if (isPreview || !triggerDef) { + const createWebhook = async ( + effectiveTriggerId: string | undefined, + selectedCredentialId: string | null + ): Promise => { + if (!triggerDef || !effectiveTriggerId) { return false } - const effectiveTriggerId = resolveEffectiveTriggerId(blockId, triggerId) + const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + const webhookConfig = { + ...(triggerConfig || {}), + ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), + triggerId: effectiveTriggerId, + } - try { - setIsSaving(true) + const path = blockId + + const response = await fetch('/api/webhooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowId, + blockId, + path, + provider: triggerDef.provider, + providerConfig: webhookConfig, + }), + }) + + if (!response.ok) { + let errorMessage = 'Failed to create webhook' + try { + const errorData = await response.json() + errorMessage = errorData.details || errorData.error || errorMessage + } catch { + // If response is not JSON, use default message + } + logger.error('Failed to create webhook', { errorMessage }) + throw new Error(errorMessage) + } - if (!webhookId) { - const path = blockId + const data = await response.json() + const savedWebhookId = data.webhook.id + + useSubBlockStore.getState().setValue(blockId, 'triggerPath', path) + useSubBlockStore.getState().setValue(blockId, 'triggerId', effectiveTriggerId) + useSubBlockStore.getState().setValue(blockId, 'webhookId', savedWebhookId) + useSubBlockStore.setState((state) => ({ + checkedWebhooks: new Set([...state.checkedWebhooks, blockId]), + })) - const selectedCredentialId = - (useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as string | null) || - null + logger.info('Trigger webhook created successfully', { + webhookId: savedWebhookId, + triggerId: effectiveTriggerId, + provider: triggerDef.provider, + blockId, + }) - const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + return true + } - const webhookConfig = { - ...(triggerConfig || {}), + const updateWebhook = async ( + webhookIdToUpdate: string, + effectiveTriggerId: string | undefined, + selectedCredentialId: string | null + ): Promise => { + const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + + const response = await fetch(`/api/webhooks/${webhookIdToUpdate}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerConfig: { + ...triggerConfig, ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), triggerId: effectiveTriggerId, - } - - const response = await fetch('/api/webhooks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - blockId, - path, - provider: triggerDef.provider, - providerConfig: webhookConfig, - }), - }) + }, + }), + }) + + if (response.status === 404) { + logger.warn('Webhook not found while updating, recreating', { + blockId, + lostWebhookId: webhookIdToUpdate, + }) + useSubBlockStore.getState().setValue(blockId, 'webhookId', null) + return createWebhook(effectiveTriggerId, selectedCredentialId) + } - if (!response.ok) { - let errorMessage = 'Failed to create webhook' - try { - const errorData = await response.json() - errorMessage = errorData.details || errorData.error || errorMessage - } catch { - // If response is not JSON, use default message - } - logger.error('Failed to create webhook', { errorMessage }) - throw new Error(errorMessage) - } + if (!response.ok) { + let errorMessage = 'Failed to save trigger configuration' + try { + const errorData = await response.json() + errorMessage = errorData.details || errorData.error || errorMessage + } catch { + // If response is not JSON, use default message + } + logger.error('Failed to save trigger config', { errorMessage }) + throw new Error(errorMessage) + } - const data = await response.json() - const savedWebhookId = data.webhook.id + logger.info('Trigger config saved successfully', { blockId, webhookId: webhookIdToUpdate }) + return true + } - useSubBlockStore.getState().setValue(blockId, 'triggerPath', path) - useSubBlockStore.getState().setValue(blockId, 'triggerId', effectiveTriggerId) - useSubBlockStore.getState().setValue(blockId, 'webhookId', savedWebhookId) - useSubBlockStore.setState((state) => ({ - checkedWebhooks: new Set([...state.checkedWebhooks, blockId]), - })) + const saveConfig = async (): Promise => { + if (isPreview || !triggerDef) { + return false + } - logger.info('Trigger webhook created successfully', { - webhookId: savedWebhookId, - triggerId: effectiveTriggerId, - provider: triggerDef.provider, - blockId, - }) + const effectiveTriggerId = resolveEffectiveTriggerId(blockId, triggerId) - return true - } + try { + setIsSaving(true) - const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') const triggerCredentials = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') - const selectedCredentialId = triggerCredentials as string | null - - const response = await fetch(`/api/webhooks/${webhookId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - providerConfig: { - ...triggerConfig, - ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), - triggerId: effectiveTriggerId, - }, - }), - }) + const selectedCredentialId = (triggerCredentials as string | null) || null - if (!response.ok) { - let errorMessage = 'Failed to save trigger configuration' - try { - const errorData = await response.json() - errorMessage = errorData.details || errorData.error || errorMessage - } catch { - // If response is not JSON, use default message - } - logger.error('Failed to save trigger config', { errorMessage }) - throw new Error(errorMessage) + if (!webhookId) { + return createWebhook(effectiveTriggerId, selectedCredentialId) } - logger.info('Trigger config saved successfully') - return true + return updateWebhook(webhookId, effectiveTriggerId, selectedCredentialId) } catch (error) { logger.error('Error saving trigger config:', error) throw error diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 8b0000c37b..09b7361be0 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -114,14 +114,9 @@ export function mergeSubblockState( {} as Record ) - // Return the full block state with updated subBlocks - acc[id] = { - ...block, - subBlocks: mergedSubBlocks, - } - // Add any values that exist in the store but aren't in the block structure // This handles cases where block config has been updated but values still exist + // IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc. Object.entries(blockValues).forEach(([subBlockId, value]) => { if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) { // Create a minimal subblock structure @@ -133,7 +128,7 @@ export function mergeSubblockState( } }) - // Update the block with the final merged subBlocks (including orphaned values) + // Return the full block state with updated subBlocks (including orphaned values) acc[id] = { ...block, subBlocks: mergedSubBlocks, From c3efed1e03c22a42e7a94d2469ae88c79b8c81b6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 21 Nov 2025 16:59:44 -0800 Subject: [PATCH 2/2] Fix for schedules --- .../sim/app/api/workflows/[id]/state/route.ts | 330 +++++++++++++++--- 1 file changed, 272 insertions(+), 58 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 654199bdd3..ad1342b354 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { webhook, workflow } from '@sim/db/schema' +import { webhook, workflow, workflowSchedule } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -10,10 +10,16 @@ import { generateRequestId } from '@/lib/utils' import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { getWorkflowAccessContext } from '@/lib/workflows/utils' +import { getTrigger } from '@/triggers' +import { + calculateNextRunTime, + generateCronExpression, + getScheduleTimeValues, + validateCronExpression, +} from '@/lib/schedules/utils' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import type { BlockState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { getTrigger } from '@/triggers' const logger = createLogger('WorkflowStateAPI') @@ -204,6 +210,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } await syncWorkflowWebhooks(workflowId, workflowState.blocks) + await syncWorkflowSchedules(workflowId, workflowState.blocks) // Extract and persist custom tools to database try { @@ -301,85 +308,292 @@ function getSubBlockValue(block: BlockState, subBlockId: string): T async function syncWorkflowWebhooks( workflowId: string, - blocks: Record + blocks: Record ): Promise { - const blocksEntries = Object.values(blocks || {}).filter(Boolean) as BlockState[] - if (blocksEntries.length === 0) { - return - } + await syncBlockResources(workflowId, blocks, { + resourceName: 'webhook', + subBlockId: 'webhookId', + buildMetadata: buildWebhookMetadata, + applyMetadata: upsertWebhookRecord, + }) +} - for (const block of blocksEntries) { - const webhookId = getSubBlockValue(block, 'webhookId') - if (!webhookId) continue +type ScheduleBlockInput = Parameters[0] - const triggerId = - getSubBlockValue(block, 'triggerId') || - getSubBlockValue(block, 'selectedTriggerId') - const triggerConfig = getSubBlockValue>(block, 'triggerConfig') || {} - const triggerCredentials = getSubBlockValue(block, 'triggerCredentials') - const triggerPath = getSubBlockValue(block, 'triggerPath') || block.id +async function syncWorkflowSchedules( + workflowId: string, + blocks: Record +): Promise { + await syncBlockResources(workflowId, blocks, { + resourceName: 'schedule', + subBlockId: 'scheduleId', + buildMetadata: buildScheduleMetadata, + applyMetadata: upsertScheduleRecord, + }) +} - const triggerDef = triggerId ? getTrigger(triggerId) : undefined - const provider = triggerDef?.provider +interface ScheduleMetadata { + cronExpression: string | null + nextRunAt: Date | null + timezone: string +} - const providerConfig = { - ...(typeof triggerConfig === 'object' ? triggerConfig : {}), - ...(triggerCredentials ? { credentialId: triggerCredentials } : {}), - ...(triggerId ? { triggerId } : {}), +function buildScheduleMetadata(block: BlockState): ScheduleMetadata | null { + const scheduleType = getSubBlockValue(block, 'scheduleType') || 'daily' + const scheduleBlock = convertToScheduleBlock(block) + + const scheduleValues = getScheduleTimeValues(scheduleBlock) + const sanitizedValues = + scheduleType !== 'custom' ? { ...scheduleValues, cronExpression: null } : scheduleValues + + try { + const cronExpression = generateCronExpression(scheduleType, sanitizedValues) + const timezone = scheduleValues.timezone || 'UTC' + + if (cronExpression) { + const validation = validateCronExpression(cronExpression, timezone) + if (!validation.isValid) { + logger.warn('Invalid cron expression while syncing schedule', { + blockId: block.id, + cronExpression, + error: validation.error, + }) + return null + } } - const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1) + const nextRunAt = calculateNextRunTime(scheduleType, sanitizedValues) - if (existing) { - const needsUpdate = - existing.blockId !== block.id || - existing.workflowId !== workflowId || - existing.path !== triggerPath + return { + cronExpression, + timezone, + nextRunAt, + } + } catch (error) { + logger.error('Failed to build schedule metadata during sync', { + blockId: block.id, + error, + }) + return null + } +} - if (needsUpdate) { - await db - .update(webhook) - .set({ - workflowId, - blockId: block.id, - path: triggerPath, - provider: provider || existing.provider, - providerConfig: Object.keys(providerConfig).length - ? providerConfig - : existing.providerConfig, - isActive: true, - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookId)) - } - continue +function convertToScheduleBlock(block: BlockState): ScheduleBlockInput { + const subBlocks: ScheduleBlockInput['subBlocks'] = {} + + Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => { + subBlocks[id] = { value: stringifySubBlockValue(subBlock?.value) } + }) + + return { + type: block.type, + subBlocks, + } +} + +interface WebhookMetadata { + triggerPath: string + provider: string | null + providerConfig: Record +} + +function buildWebhookMetadata(block: BlockState): WebhookMetadata | null { + const triggerId = + getSubBlockValue(block, 'triggerId') || + getSubBlockValue(block, 'selectedTriggerId') + const triggerConfig = getSubBlockValue>(block, 'triggerConfig') || {} + const triggerCredentials = getSubBlockValue(block, 'triggerCredentials') + const triggerPath = getSubBlockValue(block, 'triggerPath') || block.id + + const triggerDef = triggerId ? getTrigger(triggerId) : undefined + const provider = triggerDef?.provider || null + + const providerConfig = { + ...(typeof triggerConfig === 'object' ? triggerConfig : {}), + ...(triggerCredentials ? { credentialId: triggerCredentials } : {}), + ...(triggerId ? { triggerId } : {}), + } + + return { + triggerPath, + provider, + providerConfig, + } +} + +async function upsertWebhookRecord( + workflowId: string, + block: BlockState, + webhookId: string, + metadata: WebhookMetadata +): Promise { + const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1) + + if (existing) { + const needsUpdate = + existing.blockId !== block.id || + existing.workflowId !== workflowId || + existing.path !== metadata.triggerPath + + if (needsUpdate) { + await db + .update(webhook) + .set({ + workflowId, + blockId: block.id, + path: metadata.triggerPath, + provider: metadata.provider || existing.provider, + providerConfig: Object.keys(metadata.providerConfig).length + ? metadata.providerConfig + : existing.providerConfig, + isActive: true, + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookId)) } + return + } - try { - await db.insert(webhook).values({ - id: webhookId, + await db.insert(webhook).values({ + id: webhookId, + workflowId, + blockId: block.id, + path: metadata.triggerPath, + provider: metadata.provider, + providerConfig: metadata.providerConfig, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + + logger.info('Recreated missing webhook after workflow save', { + workflowId, + blockId: block.id, + webhookId, + }) +} + +async function upsertScheduleRecord( + workflowId: string, + block: BlockState, + scheduleId: string, + metadata: ScheduleMetadata +): Promise { + const now = new Date() + const [existing] = await db + .select({ + id: workflowSchedule.id, + nextRunAt: workflowSchedule.nextRunAt, + }) + .from(workflowSchedule) + .where(eq(workflowSchedule.id, scheduleId)) + .limit(1) + + if (existing) { + await db + .update(workflowSchedule) + .set({ workflowId, blockId: block.id, - path: triggerPath, - provider: provider || null, - providerConfig: providerConfig, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), + cronExpression: metadata.cronExpression, + nextRunAt: metadata.nextRunAt ?? existing.nextRunAt, + timezone: metadata.timezone, + updatedAt: now, }) + .where(eq(workflowSchedule.id, scheduleId)) + return + } + + await db.insert(workflowSchedule).values({ + id: scheduleId, + workflowId, + blockId: block.id, + cronExpression: metadata.cronExpression, + nextRunAt: metadata.nextRunAt ?? null, + triggerType: 'schedule', + timezone: metadata.timezone, + status: 'active', + failedCount: 0, + createdAt: now, + updatedAt: now, + }) + + logger.info('Recreated missing schedule after workflow save', { + workflowId, + blockId: block.id, + scheduleId, + }) +} + +interface BlockResourceSyncConfig { + resourceName: string + subBlockId: string + buildMetadata: (block: BlockState, resourceId: string) => T | null + applyMetadata: ( + workflowId: string, + block: BlockState, + resourceId: string, + metadata: T + ) => Promise +} + +async function syncBlockResources( + workflowId: string, + blocks: Record, + config: BlockResourceSyncConfig +): Promise { + const blockEntries = Object.values(blocks || {}).filter(Boolean) as BlockState[] + if (blockEntries.length === 0) return + + for (const block of blockEntries) { + const resourceId = getSubBlockValue(block, config.subBlockId) + if (!resourceId) continue - logger.info('Recreated missing webhook after workflow save', { + const metadata = config.buildMetadata(block, resourceId) + if (!metadata) { + logger.warn(`Skipping ${config.resourceName} sync due to invalid configuration`, { workflowId, blockId: block.id, - webhookId, + resourceId, + resourceName: config.resourceName, }) + continue + } + + try { + await config.applyMetadata(workflowId, block, resourceId, metadata) } catch (error) { - logger.error('Failed to recreate webhook during workflow sync', { + logger.error(`Failed to sync ${config.resourceName}`, { workflowId, blockId: block.id, - webhookId, + resourceId, + resourceName: config.resourceName, error, }) } } } + +function stringifySubBlockValue(value: unknown): string { + if (value === undefined || value === null) { + return '' + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + + if (value instanceof Date) { + return value.toISOString() + } + + try { + return JSON.stringify(value) + } catch { + return String(value) + } +}