diff --git a/apps/docs/content/docs/en/execution/meta.json b/apps/docs/content/docs/en/execution/meta.json index 02f2c537db..37cac68f5a 100644 --- a/apps/docs/content/docs/en/execution/meta.json +++ b/apps/docs/content/docs/en/execution/meta.json @@ -1,3 +1,3 @@ { - "pages": ["index", "basics", "api", "form", "logging", "costs"] + "pages": ["index", "basics", "api", "logging", "costs"] } diff --git a/apps/sim/app/api/copilot/execute-tool/route.ts b/apps/sim/app/api/copilot/execute-tool/route.ts index b737b196de..c8205821fb 100644 --- a/apps/sim/app/api/copilot/execute-tool/route.ts +++ b/apps/sim/app/api/copilot/execute-tool/route.ts @@ -14,8 +14,7 @@ import { import { generateRequestId } from '@/lib/core/utils/request' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' -import { REFERENCE } from '@/executor/constants' -import { createEnvVarPattern } from '@/executor/utils/reference-validation' +import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import { executeTool } from '@/tools' import { getTool, resolveToolId } from '@/tools/utils' @@ -28,45 +27,6 @@ const ExecuteToolSchema = z.object({ workflowId: z.string().optional(), }) -/** - * Resolves all {{ENV_VAR}} references in a value recursively - * Works with strings, arrays, and objects - */ -function resolveEnvVarReferences(value: any, envVars: Record): any { - if (typeof value === 'string') { - // Check for exact match: entire string is "{{VAR_NAME}}" - const exactMatchPattern = new RegExp( - `^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$` - ) - const exactMatch = exactMatchPattern.exec(value) - if (exactMatch) { - const envVarName = exactMatch[1].trim() - return envVars[envVarName] ?? value - } - - // Check for embedded references: "prefix {{VAR}} suffix" - const envVarPattern = createEnvVarPattern() - return value.replace(envVarPattern, (match, varName) => { - const trimmedName = varName.trim() - return envVars[trimmedName] ?? match - }) - } - - if (Array.isArray(value)) { - return value.map((item) => resolveEnvVarReferences(item, envVars)) - } - - if (value !== null && typeof value === 'object') { - const resolved: Record = {} - for (const [key, val] of Object.entries(value)) { - resolved[key] = resolveEnvVarReferences(val, envVars) - } - return resolved - } - - return value -} - export async function POST(req: NextRequest) { const tracker = createRequestTracker() @@ -145,7 +105,17 @@ export async function POST(req: NextRequest) { // Build execution params starting with LLM-provided arguments // Resolve all {{ENV_VAR}} references in the arguments - const executionParams: Record = resolveEnvVarReferences(toolArgs, decryptedEnvVars) + const executionParams: Record = resolveEnvVarReferences( + toolArgs, + decryptedEnvVars, + { + resolveExactMatch: true, + allowEmbedded: true, + trimKeys: true, + onMissing: 'keep', + deep: true, + } + ) as Record logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, { toolName, diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index cb1da555af..4412cf9667 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -9,6 +9,7 @@ import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { createEnvVarPattern, createWorkflowVariablePattern, + resolveEnvVarReferences, } from '@/executor/utils/reference-validation' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -479,9 +480,29 @@ function resolveEnvironmentVariables( const replacements: Array<{ match: string; index: number; varName: string; varValue: string }> = [] + const resolverVars: Record = {} + Object.entries(params).forEach(([key, value]) => { + if (value) { + resolverVars[key] = String(value) + } + }) + Object.entries(envVars).forEach(([key, value]) => { + if (value) { + resolverVars[key] = value + } + }) + while ((match = regex.exec(code)) !== null) { const varName = match[1].trim() - const varValue = envVars[varName] || params[varName] || '' + const resolved = resolveEnvVarReferences(match[0], resolverVars, { + allowEmbedded: true, + resolveExactMatch: true, + trimKeys: true, + onMissing: 'empty', + deep: false, + }) + const varValue = + typeof resolved === 'string' ? resolved : resolved == null ? '' : String(resolved) replacements.push({ match: match[0], index: match.index, diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 3332397535..d91691d2f6 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -5,8 +5,7 @@ import { McpClient } from '@/lib/mcp/client' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import type { McpServerConfig, McpTransport } from '@/lib/mcp/types' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { REFERENCE } from '@/executor/constants' -import { createEnvVarPattern } from '@/executor/utils/reference-validation' +import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' const logger = createLogger('McpServerTestAPI') @@ -24,22 +23,23 @@ function isUrlBasedTransport(transport: McpTransport): boolean { * Resolve environment variables in strings */ function resolveEnvVars(value: string, envVars: Record): string { - const envVarPattern = createEnvVarPattern() - const envMatches = value.match(envVarPattern) - if (!envMatches) return value - - let resolvedValue = value - for (const match of envMatches) { - const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim() - const envValue = envVars[envKey] - - if (envValue === undefined) { + const missingVars: string[] = [] + const resolvedValue = resolveEnvVarReferences(value, envVars, { + allowEmbedded: true, + resolveExactMatch: true, + trimKeys: true, + onMissing: 'keep', + deep: false, + missingKeys: missingVars, + }) as string + + if (missingVars.length > 0) { + const uniqueMissing = Array.from(new Set(missingVars)) + uniqueMissing.forEach((envKey) => { logger.warn(`Environment variable "${envKey}" not found in MCP server test`) - continue - } - - resolvedValue = resolvedValue.replace(match, envValue) + }) } + return resolvedValue } diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index 6feddfe7a2..0d44e1ccd5 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -57,6 +57,7 @@ describe('Scheduled Workflow Execution API Route', () => { not: vi.fn((condition) => ({ type: 'not', condition })), isNull: vi.fn((field) => ({ type: 'isNull', field })), or: vi.fn((...conditions) => ({ type: 'or', conditions })), + sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), })) vi.doMock('@sim/db', () => { @@ -92,6 +93,17 @@ describe('Scheduled Workflow Execution API Route', () => { status: 'status', nextRunAt: 'nextRunAt', lastQueuedAt: 'lastQueuedAt', + deploymentVersionId: 'deploymentVersionId', + }, + workflowDeploymentVersion: { + id: 'id', + workflowId: 'workflowId', + isActive: 'isActive', + }, + workflow: { + id: 'id', + userId: 'userId', + workspaceId: 'workspaceId', }, } }) @@ -134,6 +146,7 @@ describe('Scheduled Workflow Execution API Route', () => { not: vi.fn((condition) => ({ type: 'not', condition })), isNull: vi.fn((field) => ({ type: 'isNull', field })), or: vi.fn((...conditions) => ({ type: 'or', conditions })), + sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), })) vi.doMock('@sim/db', () => { @@ -169,6 +182,17 @@ describe('Scheduled Workflow Execution API Route', () => { status: 'status', nextRunAt: 'nextRunAt', lastQueuedAt: 'lastQueuedAt', + deploymentVersionId: 'deploymentVersionId', + }, + workflowDeploymentVersion: { + id: 'id', + workflowId: 'workflowId', + isActive: 'isActive', + }, + workflow: { + id: 'id', + userId: 'userId', + workspaceId: 'workspaceId', }, } }) @@ -206,6 +230,7 @@ describe('Scheduled Workflow Execution API Route', () => { not: vi.fn((condition) => ({ type: 'not', condition })), isNull: vi.fn((field) => ({ type: 'isNull', field })), or: vi.fn((...conditions) => ({ type: 'or', conditions })), + sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), })) vi.doMock('@sim/db', () => { @@ -228,6 +253,17 @@ describe('Scheduled Workflow Execution API Route', () => { status: 'status', nextRunAt: 'nextRunAt', lastQueuedAt: 'lastQueuedAt', + deploymentVersionId: 'deploymentVersionId', + }, + workflowDeploymentVersion: { + id: 'id', + workflowId: 'workflowId', + isActive: 'isActive', + }, + workflow: { + id: 'id', + userId: 'userId', + workspaceId: 'workspaceId', }, } }) @@ -265,6 +301,7 @@ describe('Scheduled Workflow Execution API Route', () => { not: vi.fn((condition) => ({ type: 'not', condition })), isNull: vi.fn((field) => ({ type: 'isNull', field })), or: vi.fn((...conditions) => ({ type: 'or', conditions })), + sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), })) vi.doMock('@sim/db', () => { @@ -310,6 +347,17 @@ describe('Scheduled Workflow Execution API Route', () => { status: 'status', nextRunAt: 'nextRunAt', lastQueuedAt: 'lastQueuedAt', + deploymentVersionId: 'deploymentVersionId', + }, + workflowDeploymentVersion: { + id: 'id', + workflowId: 'workflowId', + isActive: 'isActive', + }, + workflow: { + id: 'id', + userId: 'userId', + workspaceId: 'workspaceId', }, } }) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index cadad529f5..d401b085a3 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -1,7 +1,7 @@ -import { db, workflowSchedule } from '@sim/db' +import { db, workflowDeploymentVersion, workflowSchedule } from '@sim/db' import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' -import { and, eq, isNull, lt, lte, not, or } from 'drizzle-orm' +import { and, eq, isNull, lt, lte, not, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' @@ -37,7 +37,8 @@ export async function GET(request: NextRequest) { or( isNull(workflowSchedule.lastQueuedAt), lt(workflowSchedule.lastQueuedAt, workflowSchedule.nextRunAt) - ) + ), + sql`${workflowSchedule.deploymentVersionId} = (select ${workflowDeploymentVersion.id} from ${workflowDeploymentVersion} where ${workflowDeploymentVersion.workflowId} = ${workflowSchedule.workflowId} and ${workflowDeploymentVersion.isActive} = true)` ) ) .returning({ diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index 608a1eb068..a7df3c9529 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -29,12 +29,23 @@ vi.mock('@sim/db', () => ({ vi.mock('@sim/db/schema', () => ({ workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, - workflowSchedule: { workflowId: 'workflowId', blockId: 'blockId' }, + workflowSchedule: { + workflowId: 'workflowId', + blockId: 'blockId', + deploymentVersionId: 'deploymentVersionId', + }, + workflowDeploymentVersion: { + id: 'id', + workflowId: 'workflowId', + isActive: 'isActive', + }, })) vi.mock('drizzle-orm', () => ({ eq: vi.fn(), and: vi.fn(), + or: vi.fn(), + isNull: vi.fn(), })) vi.mock('@/lib/core/utils/request', () => ({ @@ -56,6 +67,11 @@ function mockDbChain(results: any[]) { where: () => ({ limit: () => results[callIndex++] || [], }), + leftJoin: () => ({ + where: () => ({ + limit: () => results[callIndex++] || [], + }), + }), }), })) } @@ -74,7 +90,16 @@ describe('Schedule GET API', () => { it('returns schedule data for authorized user', async () => { mockDbChain([ [{ userId: 'user-1', workspaceId: null }], - [{ id: 'sched-1', cronExpression: '0 9 * * *', status: 'active', failedCount: 0 }], + [ + { + schedule: { + id: 'sched-1', + cronExpression: '0 9 * * *', + status: 'active', + failedCount: 0, + }, + }, + ], ]) const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) @@ -128,7 +153,7 @@ describe('Schedule GET API', () => { it('allows workspace members to view', async () => { mockDbChain([ [{ userId: 'other-user', workspaceId: 'ws-1' }], - [{ id: 'sched-1', status: 'active', failedCount: 0 }], + [{ schedule: { id: 'sched-1', status: 'active', failedCount: 0 } }], ]) const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) @@ -139,7 +164,7 @@ describe('Schedule GET API', () => { it('indicates disabled schedule with failures', async () => { mockDbChain([ [{ userId: 'user-1', workspaceId: null }], - [{ id: 'sched-1', status: 'disabled', failedCount: 100 }], + [{ schedule: { id: 'sched-1', status: 'disabled', failedCount: 100 } }], ]) const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1')) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 3b6ba81864..50cf346065 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { workflow, workflowSchedule } from '@sim/db/schema' +import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' @@ -62,9 +62,24 @@ export async function GET(req: NextRequest) { } const schedule = await db - .select() + .select({ schedule: workflowSchedule }) .from(workflowSchedule) - .where(conditions.length > 1 ? and(...conditions) : conditions[0]) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflowSchedule.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + ...conditions, + or( + eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId)) + ) + ) + ) .limit(1) const headers = new Headers() @@ -74,7 +89,7 @@ export async function GET(req: NextRequest) { return NextResponse.json({ schedule: null }, { headers }) } - const scheduleData = schedule[0] + const scheduleData = schedule[0].schedule const isDisabled = scheduleData.status === 'disabled' const hasFailures = scheduleData.failedCount > 0 diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index d76d765ab9..4f9f517aeb 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -60,7 +60,17 @@ export const POST = withAdminAuthParams(async (request, context) => return internalErrorResponse(deployResult.error || 'Failed to deploy workflow') } - const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db) + if (!deployResult.deploymentVersionId) { + await undeployWorkflow({ workflowId }) + return internalErrorResponse('Failed to resolve deployment version') + } + + const scheduleResult = await createSchedulesForDeploy( + workflowId, + normalizedData.blocks, + db, + deployResult.deploymentVersionId + ) if (!scheduleResult.success) { logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`) } diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 0ae8b65d91..da1412acf5 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { webhook, workflow } from '@sim/db/schema' +import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, isNull, or } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' @@ -71,7 +71,23 @@ export async function GET(request: NextRequest) { }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId))) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.workflowId, workflowId), + eq(webhook.blockId, blockId), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) .orderBy(desc(webhook.updatedAt)) logger.info( @@ -149,7 +165,23 @@ export async function POST(request: NextRequest) { const existingForBlock = await db .select({ id: webhook.id, path: webhook.path }) .from(webhook) - .where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId))) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.workflowId, workflowId), + eq(webhook.blockId, blockId), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) .limit(1) if (existingForBlock.length > 0) { @@ -225,7 +257,23 @@ export async function POST(request: NextRequest) { const existingForBlock = await db .select({ id: webhook.id }) .from(webhook) - .where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId))) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.workflowId, workflowId), + eq(webhook.blockId, blockId), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) .limit(1) if (existingForBlock.length > 0) { targetWebhookId = existingForBlock[0].id diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index ae11e476cf..ba08df3907 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -152,7 +152,6 @@ export async function POST( const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { requestId, path, - executionTarget: 'deployed', }) responses.push(response) } diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 21afa25177..6e1172c049 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -10,7 +10,11 @@ import { loadWorkflowFromNormalizedTables, undeployWorkflow, } from '@/lib/workflows/persistence/utils' -import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules' +import { + cleanupDeploymentVersion, + createSchedulesForDeploy, + validateWorkflowSchedules, +} from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -131,6 +135,24 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400) } + const deployResult = await deployWorkflow({ + workflowId: id, + deployedBy: actorUserId, + workflowName: workflowData!.name, + }) + + if (!deployResult.success) { + return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500) + } + + const deployedAt = deployResult.deployedAt! + const deploymentVersionId = deployResult.deploymentVersionId + + if (!deploymentVersionId) { + await undeployWorkflow({ workflowId: id }) + return createErrorResponse('Failed to resolve deployment version', 500) + } + const triggerSaveResult = await saveTriggerWebhooksForDeploy({ request, workflowId: id, @@ -138,34 +160,44 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId: actorUserId, blocks: normalizedData.blocks, requestId, + deploymentVersionId, }) if (!triggerSaveResult.success) { + await cleanupDeploymentVersion({ + workflowId: id, + workflow: workflowData as Record, + requestId, + deploymentVersionId, + }) + await undeployWorkflow({ workflowId: id }) return createErrorResponse( triggerSaveResult.error?.message || 'Failed to save trigger configuration', triggerSaveResult.error?.status || 500 ) } - const deployResult = await deployWorkflow({ - workflowId: id, - deployedBy: actorUserId, - workflowName: workflowData!.name, - }) - - if (!deployResult.success) { - return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500) - } - - const deployedAt = deployResult.deployedAt! - let scheduleInfo: { scheduleId?: string; cronExpression?: string; nextRunAt?: Date } = {} - const scheduleResult = await createSchedulesForDeploy(id, normalizedData.blocks, db) + const scheduleResult = await createSchedulesForDeploy( + id, + normalizedData.blocks, + db, + deploymentVersionId + ) if (!scheduleResult.success) { logger.error( `[${requestId}] Failed to create schedule for workflow ${id}: ${scheduleResult.error}` ) - } else if (scheduleResult.scheduleId) { + await cleanupDeploymentVersion({ + workflowId: id, + workflow: workflowData as Record, + requestId, + deploymentVersionId, + }) + await undeployWorkflow({ workflowId: id }) + return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500) + } + if (scheduleResult.scheduleId) { scheduleInfo = { scheduleId: scheduleResult.scheduleId, cronExpression: scheduleResult.cronExpression, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts index 76126ee86c..d3e5abb555 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts @@ -1,10 +1,19 @@ +import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' +import { saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy' import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils' +import { + cleanupDeploymentVersion, + createSchedulesForDeploy, + validateWorkflowSchedules, +} from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowActivateDeploymentAPI') @@ -19,30 +28,135 @@ export async function POST( const { id, version } = await params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'admin') + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') if (error) { return createErrorResponse(error.message, error.status) } + const actorUserId = session?.user?.id + if (!actorUserId) { + logger.warn(`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`) + return createErrorResponse('Unable to determine activating user', 400) + } + const versionNum = Number(version) if (!Number.isFinite(versionNum)) { return createErrorResponse('Invalid version number', 400) } - const result = await activateWorkflowVersion({ workflowId: id, version: versionNum }) - if (!result.success) { - return createErrorResponse(result.error || 'Failed to activate deployment', 400) + const [versionRow] = await db + .select({ + id: workflowDeploymentVersion.id, + state: workflowDeploymentVersion.state, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.version, versionNum) + ) + ) + .limit(1) + + if (!versionRow?.state) { + return createErrorResponse('Deployment version not found', 404) + } + + const [currentActiveVersion] = await db + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .limit(1) + + const previousVersionId = currentActiveVersion?.id + + const deployedState = versionRow.state as { blocks?: Record } + const blocks = deployedState.blocks + if (!blocks || typeof blocks !== 'object') { + return createErrorResponse('Invalid deployed state structure', 500) + } + + const triggerSaveResult = await saveTriggerWebhooksForDeploy({ + request, + workflowId: id, + workflow: workflowData as Record, + userId: actorUserId, + blocks, + requestId, + deploymentVersionId: versionRow.id, + }) + + if (!triggerSaveResult.success) { + return createErrorResponse( + triggerSaveResult.error?.message || 'Failed to sync trigger configuration', + triggerSaveResult.error?.status || 500 + ) } - if (result.state) { - await syncMcpToolsForWorkflow({ + const scheduleValidation = validateWorkflowSchedules(blocks) + if (!scheduleValidation.isValid) { + return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400) + } + + const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id) + + if (!scheduleResult.success) { + await cleanupDeploymentVersion({ + workflowId: id, + workflow: workflowData as Record, + requestId, + deploymentVersionId: versionRow.id, + }) + return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500) + } + + const result = await activateWorkflowVersion({ workflowId: id, version: versionNum }) + if (!result.success) { + await cleanupDeploymentVersion({ workflowId: id, + workflow: workflowData as Record, requestId, - state: result.state, - context: 'activate', + deploymentVersionId: versionRow.id, }) + return createErrorResponse(result.error || 'Failed to activate deployment', 400) } + if (previousVersionId && previousVersionId !== versionRow.id) { + try { + logger.info( + `[${requestId}] Cleaning up previous version ${previousVersionId} webhooks/schedules` + ) + await cleanupDeploymentVersion({ + workflowId: id, + workflow: workflowData as Record, + requestId, + deploymentVersionId: previousVersionId, + }) + logger.info(`[${requestId}] Previous version cleanup completed`) + } catch (cleanupError) { + logger.error( + `[${requestId}] Failed to clean up previous version ${previousVersionId}`, + cleanupError + ) + } + } + + await syncMcpToolsForWorkflow({ + workflowId: id, + requestId, + state: versionRow.state, + context: 'activate', + }) + return createSuccessResponse({ success: true, deployedAt: result.deployedAt }) } catch (error: any) { logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 3a9b04dfba..df988f26a7 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -110,6 +110,7 @@ type AsyncExecutionParams = { userId: string input: any triggerType: CoreTriggerType + preflighted?: boolean } /** @@ -132,6 +133,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise getDependsOnFields(dependsOn), [dependsOn]) const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + const canonicalModeOverrides = blockState?.data?.canonicalModes const dependencyValues = useSubBlockStore( useCallback( (state) => { if (dependsOnFields.length === 0 || !activeWorkflowId) return [] const workflowValues = state.workflowValues[activeWorkflowId] || {} const blockValues = workflowValues[blockId] || {} - return dependsOnFields.map((depKey) => blockValues[depKey] ?? null) + return dependsOnFields.map((depKey) => + resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides) + ) }, - [dependsOnFields, activeWorkflowId, blockId] + [dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides] ) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 8edd3f3800..3dd1aca9a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -1,12 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge } from '@/components/emcn' import { Combobox, type ComboboxOption } from '@/components/emcn/components' +import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** * Dropdown option type - can be a simple string or an object with label, id, and optional icon @@ -89,15 +92,24 @@ export function Dropdown({ const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn]) const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + const canonicalModeOverrides = blockState?.data?.canonicalModes const dependencyValues = useSubBlockStore( useCallback( (state) => { if (dependsOnFields.length === 0 || !activeWorkflowId) return [] const workflowValues = state.workflowValues[activeWorkflowId] || {} const blockValues = workflowValues[blockId] || {} - return dependsOnFields.map((depKey) => blockValues[depKey] ?? null) + return dependsOnFields.map((depKey) => + resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides) + ) }, - [dependsOnFields, activeWorkflowId, blockId] + [dependsOnFields, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides] ) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx index 27415b31a4..6805e2ec4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-selector/file-selector-input.tsx @@ -4,15 +4,19 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' import { Tooltip } from '@/components/emcn' import { getProviderIdFromServiceId } from '@/lib/oauth' +import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { isDependency } from '@/blocks/utils' import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface FileSelectorInputProps { blockId: string @@ -42,21 +46,59 @@ export function FileSelectorInput({ previewContextValues, }) - const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential') + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + const canonicalModeOverrides = blockState?.data?.canonicalModes + + const blockValues = useSubBlockStore((state) => { + if (!activeWorkflowId) return {} + const workflowValues = state.workflowValues[activeWorkflowId] || {} + return (workflowValues as Record>)[blockId] || {} + }) + const [domainValueFromStore] = useSubBlockValue(blockId, 'domain') - const [projectIdValueFromStore] = useSubBlockValue(blockId, 'projectId') - const [planIdValueFromStore] = useSubBlockValue(blockId, 'planId') - const [teamIdValueFromStore] = useSubBlockValue(blockId, 'teamId') - const [siteIdValueFromStore] = useSubBlockValue(blockId, 'siteId') - const [collectionIdValueFromStore] = useSubBlockValue(blockId, 'collectionId') - const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore + const connectedCredential = previewContextValues?.credential ?? blockValues.credential const domainValue = previewContextValues?.domain ?? domainValueFromStore - const projectIdValue = previewContextValues?.projectId ?? projectIdValueFromStore - const planIdValue = previewContextValues?.planId ?? planIdValueFromStore - const teamIdValue = previewContextValues?.teamId ?? teamIdValueFromStore - const siteIdValue = previewContextValues?.siteId ?? siteIdValueFromStore - const collectionIdValue = previewContextValues?.collectionId ?? collectionIdValueFromStore + + const teamIdValue = useMemo( + () => + previewContextValues?.teamId ?? + resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), + [previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides] + ) + + const siteIdValue = useMemo( + () => + previewContextValues?.siteId ?? + resolveDependencyValue('siteId', blockValues, canonicalIndex, canonicalModeOverrides), + [previewContextValues?.siteId, blockValues, canonicalIndex, canonicalModeOverrides] + ) + + const collectionIdValue = useMemo( + () => + previewContextValues?.collectionId ?? + resolveDependencyValue('collectionId', blockValues, canonicalIndex, canonicalModeOverrides), + [previewContextValues?.collectionId, blockValues, canonicalIndex, canonicalModeOverrides] + ) + + const projectIdValue = useMemo( + () => + previewContextValues?.projectId ?? + resolveDependencyValue('projectId', blockValues, canonicalIndex, canonicalModeOverrides), + [previewContextValues?.projectId, blockValues, canonicalIndex, canonicalModeOverrides] + ) + + const planIdValue = useMemo( + () => + previewContextValues?.planId ?? + resolveDependencyValue('planId', blockValues, canonicalIndex, canonicalModeOverrides), + [previewContextValues?.planId, blockValues, canonicalIndex, canonicalModeOverrides] + ) const normalizedCredentialId = typeof connectedCredential === 'string' @@ -65,7 +107,6 @@ export function FileSelectorInput({ ? ((connectedCredential as Record).id ?? '') : '' - // Derive provider from serviceId using OAuth config (same pattern as credential-selector) const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx index d189aa92f2..9d5e353202 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/project-selector/project-selector-input.tsx @@ -4,14 +4,17 @@ import { useEffect, useMemo, useState } from 'react' import { useParams } from 'next/navigation' import { Tooltip } from '@/components/emcn' import { getProviderIdFromServiceId } from '@/lib/oauth' +import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface ProjectSelectorInputProps { blockId: string @@ -32,21 +35,36 @@ export function ProjectSelectorInput({ previewValue, previewContextValues, }: ProjectSelectorInputProps) { - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() const params = useParams() + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null const [selectedProjectId, setSelectedProjectId] = useState('') - // Use the proper hook to get the current value and setter const [storeValue] = useSubBlockValue(blockId, subBlock.id) - const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential') - const [linearTeamIdFromStore] = useSubBlockValue(blockId, 'teamId') const [jiraDomainFromStore] = useSubBlockValue(blockId, 'domain') - // Use previewContextValues if provided (for tools inside agent blocks), otherwise use store values - const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore - const linearTeamId = previewContextValues?.teamId ?? linearTeamIdFromStore + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + const canonicalModeOverrides = blockState?.data?.canonicalModes + + const blockValues = useSubBlockStore((state) => { + if (!activeWorkflowId) return {} + const workflowValues = state.workflowValues[activeWorkflowId] || {} + return (workflowValues as Record>)[blockId] || {} + }) + + const connectedCredential = previewContextValues?.credential ?? blockValues.credential const jiraDomain = previewContextValues?.domain ?? jiraDomainFromStore - // Derive provider from serviceId using OAuth config + const linearTeamId = useMemo( + () => + previewContextValues?.teamId ?? + resolveDependencyValue('teamId', blockValues, canonicalIndex, canonicalModeOverrides), + [previewContextValues?.teamId, blockValues, canonicalIndex, canonicalModeOverrides] + ) + const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) @@ -54,7 +72,6 @@ export function ProjectSelectorInput({ effectiveProviderId, (connectedCredential as string) || '' ) - const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) as string | null const workflowIdFromUrl = (params?.workflowId as string) || activeWorkflowId || '' const { finalDisabled } = useDependsOnGate(blockId, subBlock, { disabled, @@ -62,12 +79,8 @@ export function ProjectSelectorInput({ previewContextValues, }) - // Jira/Discord upstream fields - use values from previewContextValues or store const domain = (jiraDomain as string) || '' - // Verify Jira credential belongs to current user; if not, treat as absent - - // Get the current value from the store or prop value if in preview mode useEffect(() => { if (isPreview && previewValue !== undefined) { setSelectedProjectId(previewValue) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx index 0e3bcf2e2b..cd2a5adf5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sheet-selector/sheet-selector-input.tsx @@ -4,14 +4,17 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' import { Tooltip } from '@/components/emcn' import { getProviderIdFromServiceId } from '@/lib/oauth' +import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' -import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { resolveSelectorForSubBlock, type SelectorResolution } from '@/hooks/selectors/resolution' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface SheetSelectorInputProps { blockId: string @@ -41,16 +44,32 @@ export function SheetSelectorInput({ previewContextValues, }) - const [connectedCredentialFromStore] = useSubBlockValue(blockId, 'credential') - const [spreadsheetIdFromStore] = useSubBlockValue(blockId, 'spreadsheetId') - const [manualSpreadsheetIdFromStore] = useSubBlockValue(blockId, 'manualSpreadsheetId') + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + const canonicalModeOverrides = blockState?.data?.canonicalModes + + const blockValues = useSubBlockStore((state) => { + if (!activeWorkflowId) return {} + const workflowValues = state.workflowValues[activeWorkflowId] || {} + return (workflowValues as Record>)[blockId] || {} + }) + + const connectedCredentialFromStore = blockValues.credential + + const spreadsheetIdFromStore = useMemo( + () => + resolveDependencyValue('spreadsheetId', blockValues, canonicalIndex, canonicalModeOverrides), + [blockValues, canonicalIndex, canonicalModeOverrides] + ) const connectedCredential = previewContextValues?.credential ?? connectedCredentialFromStore - const spreadsheetId = - previewContextValues?.spreadsheetId ?? - spreadsheetIdFromStore ?? - previewContextValues?.manualSpreadsheetId ?? - manualSpreadsheetIdFromStore + const spreadsheetId = previewContextValues + ? (previewContextValues.spreadsheetId ?? previewContextValues.manualSpreadsheetId) + : spreadsheetIdFromStore const normalizedCredentialId = typeof connectedCredential === 'string' @@ -61,7 +80,6 @@ export function SheetSelectorInput({ const normalizedSpreadsheetId = typeof spreadsheetId === 'string' ? spreadsheetId.trim() : '' - // Derive provider from serviceId using OAuth config const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts index 3c145f52fc..e333338f93 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts @@ -1,9 +1,16 @@ 'use client' import { useMemo } from 'react' +import { + buildCanonicalIndex, + isNonEmptyValue, + resolveDependencyValue, +} from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' type DependsOnConfig = string[] | { all?: string[]; any?: string[] } @@ -50,6 +57,13 @@ export function useDependsOnGate( const previewContextValues = opts?.previewContextValues const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const blockState = useWorkflowStore((state) => state.blocks[blockId]) + const blockConfig = blockState?.type ? getBlock(blockState.type) : null + const canonicalIndex = useMemo( + () => buildCanonicalIndex(blockConfig?.subBlocks || []), + [blockConfig?.subBlocks] + ) + const canonicalModeOverrides = blockState?.data?.canonicalModes // Parse dependsOn config to get all/any field lists const { allFields, anyFields, allDependsOnFields } = useMemo( @@ -91,7 +105,13 @@ export function useDependsOnGate( if (previewContextValues) { const map: Record = {} for (const key of allDependsOnFields) { - map[key] = normalizeDependencyValue(previewContextValues[key]) + const resolvedValue = resolveDependencyValue( + key, + previewContextValues, + canonicalIndex, + canonicalModeOverrides + ) + map[key] = normalizeDependencyValue(resolvedValue) } return map } @@ -108,32 +128,25 @@ export function useDependsOnGate( const blockValues = (workflowValues as any)[blockId] || {} const map: Record = {} for (const key of allDependsOnFields) { - map[key] = normalizeDependencyValue((blockValues as any)[key]) + const resolvedValue = resolveDependencyValue( + key, + blockValues, + canonicalIndex, + canonicalModeOverrides + ) + map[key] = normalizeDependencyValue(resolvedValue) } return map }) - // For backward compatibility, also provide array of values - const dependencyValues = useMemo( - () => allDependsOnFields.map((key) => dependencyValuesMap[key]), - [allDependsOnFields, dependencyValuesMap] - ) as any[] - - const isValueSatisfied = (value: unknown): boolean => { - if (value === null || value === undefined) return false - if (typeof value === 'string') return value.trim().length > 0 - if (Array.isArray(value)) return value.length > 0 - return value !== '' - } - const depsSatisfied = useMemo(() => { // Check all fields (AND logic) - all must be satisfied const allSatisfied = - allFields.length === 0 || allFields.every((key) => isValueSatisfied(dependencyValuesMap[key])) + allFields.length === 0 || allFields.every((key) => isNonEmptyValue(dependencyValuesMap[key])) // Check any fields (OR logic) - at least one must be satisfied const anySatisfied = - anyFields.length === 0 || anyFields.some((key) => isValueSatisfied(dependencyValuesMap[key])) + anyFields.length === 0 || anyFields.some((key) => isNonEmptyValue(dependencyValuesMap[key])) return allSatisfied && anySatisfied }, [allFields, anyFields, dependencyValuesMap]) @@ -146,7 +159,6 @@ export function useDependsOnGate( return { dependsOn, - dependencyValues, depsSatisfied, blocked, finalDisabled, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index aaa0bd077a..9379ed8c20 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -1,5 +1,5 @@ import { type JSX, type MouseEvent, memo, useRef, useState } from 'react' -import { AlertTriangle, ArrowUp } from 'lucide-react' +import { AlertTriangle, ArrowLeftRight, ArrowUp } from 'lucide-react' import { Button, Input, Label, Tooltip } from '@/components/emcn/components' import { cn } from '@/lib/core/utils/cn' import type { FieldDiffStatus } from '@/lib/workflows/diff/types' @@ -67,6 +67,11 @@ interface SubBlockProps { disabled?: boolean fieldDiffStatus?: FieldDiffStatus allowExpandInPreview?: boolean + canonicalToggle?: { + mode: 'basic' | 'advanced' + disabled?: boolean + onToggle?: () => void + } } /** @@ -182,6 +187,11 @@ const renderLabel = ( onSearchSubmit: () => void onSearchCancel: () => void searchInputRef: React.RefObject + }, + canonicalToggle?: { + mode: 'basic' | 'advanced' + disabled?: boolean + onToggle?: () => void } ): JSX.Element | null => { if (config.type === 'switch') return null @@ -189,13 +199,12 @@ const renderLabel = ( const required = isFieldRequired(config, subBlockValues) const showWand = wandState?.isWandEnabled && !wandState.isPreview && !wandState.disabled + const showCanonicalToggle = !!canonicalToggle && !wandState?.isPreview + const canonicalToggleDisabled = wandState?.disabled || canonicalToggle?.disabled return ( -