diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index e939fc0f09..2e335e3de4 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -1,9 +1,8 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { verifyInternalToken } from '@/lib/auth/internal' import { generateRequestId } from '@/lib/core/utils/request' +import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -43,21 +42,21 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ logger.debug(`[${requestId}] Internal API call for deployed workflow: ${id}`) } - const [active] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1) + let deployedState = null + try { + const data = await loadDeployedWorkflowState(id) + deployedState = { + blocks: data.blocks, + edges: data.edges, + loops: data.loops, + parallels: data.parallels, + variables: data.variables, + } + } catch { + deployedState = null + } - const response = createSuccessResponse({ - deployedState: active?.state || null, - }) + const response = createSuccessResponse({ deployedState }) return addNoCacheHeaders(response) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 76b7c4de2e..99d6779aa8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -24,7 +24,6 @@ import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' -import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -55,7 +54,6 @@ export function CredentialSelector({ const [isEditing, setIsEditing] = useState(false) const { activeWorkflowId } = useWorkflowRegistry() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) - const { collaborativeSetSubblockValue } = useCollaborativeWorkflow() const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' @@ -136,11 +134,6 @@ export function CredentialSelector({ if (!response.ok || cancelled) return const data = await response.json() if (!cancelled && data.credential?.displayName) { - if (data.credential.id !== selectedId) { - collaborativeSetSubblockValue(blockId, subBlock.id, data.credential.id, { - skipDependsOn: true, - }) - } setInaccessibleCredentialName(data.credential.displayName) } } catch { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index ad7f41c1cf..7c2c77a840 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -114,9 +114,6 @@ export function ToolCredentialSelector({ if (!response.ok || cancelled) return const data = await response.json() if (!cancelled && data.credential?.displayName) { - if (data.credential.id !== selectedId) { - onChangeRef.current(data.credential.id) - } setInaccessibleCredentialName(data.credential.displayName) } } catch { diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 8a6c8d09b1..83fdd5ed17 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -1232,12 +1232,7 @@ export function useCollaborativeWorkflow() { ) const collaborativeSetSubblockValue = useCallback( - ( - blockId: string, - subblockId: string, - value: any, - options?: { _visited?: Set; skipDependsOn?: boolean } - ) => { + (blockId: string, subblockId: string, value: any, options?: { _visited?: Set }) => { if (isApplyingRemoteChange.current) return if (isBaselineDiffView) { @@ -1263,8 +1258,6 @@ export function useCollaborativeWorkflow() { }) } - if (options?.skipDependsOn) return - // Handle dependent subblock clearing (recursive calls) try { const visited = options?._visited || new Set() diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index d95cd633a9..ef4c00fab8 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -329,6 +329,9 @@ describe('Database Helpers', () => { if (callCount === 3) { return Promise.resolve(mockSubflowsFromDb) } + if (callCount === 4) { + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } + } return Promise.resolve([]) }), }), @@ -431,6 +434,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve(mockBlocksFromDb) if (callCount === 2) return Promise.resolve(mockEdgesFromDb) if (callCount === 3) return Promise.resolve(subflowsWithUnknownType) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -469,6 +474,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve(malformedBlocks) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -851,6 +858,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve(testBlocks) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -888,6 +897,8 @@ describe('Database Helpers', () => { where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve(blocksWithDefaultValues) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -957,6 +968,8 @@ describe('Database Helpers', () => { if (callCount === 1) return Promise.resolve([originalBlock, duplicatedBlock]) if (callCount === 2) return Promise.resolve([]) if (callCount === 3) return Promise.resolve([]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -1053,6 +1066,8 @@ describe('Database Helpers', () => { where: vi.fn().mockImplementation(() => { callCount++ if (callCount === 1) return Promise.resolve([basicBlock, advancedBlock]) + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), @@ -1137,6 +1152,8 @@ describe('Database Helpers', () => { }, ]) } + if (callCount === 4) + return { limit: vi.fn().mockResolvedValue([{ workspaceId: 'test-workspace-id' }]) } return Promise.resolve([]) }), }), diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index b747177e3e..3e2a16c5fa 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -7,9 +7,10 @@ import { workflowEdges, workflowSubflows, } from '@sim/db' +import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' -import { and, desc, eq, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' import type { DbOrTx } from '@/lib/db/types' @@ -99,8 +100,19 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise } + const [wfRow] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + const resolvedBlocks = state.blocks || {} + const { blocks: migratedBlocks } = wfRow?.workspaceId + ? await migrateCredentialIds(resolvedBlocks, wfRow.workspaceId) + : { blocks: resolvedBlocks } + return { - blocks: state.blocks || {}, + blocks: migratedBlocks, edges: state.edges || [], loops: state.loops || {}, parallels: state.parallels || {}, @@ -185,6 +197,107 @@ export function migrateAgentBlocksToMessagesFormat( ) } +const CREDENTIAL_SUBBLOCK_IDS = new Set(['credential', 'triggerCredentials']) + +/** + * Migrates legacy `account.id` values to `credential.id` in OAuth subblocks. + * Collects all potential legacy IDs in a single batch query for efficiency. + * Also migrates `tool.params.credential` in agent block tool arrays. + */ +async function migrateCredentialIds( + blocks: Record, + workspaceId: string +): Promise<{ blocks: Record; migrated: boolean }> { + const potentialLegacyIds = new Set() + + for (const block of Object.values(blocks)) { + for (const [subBlockId, subBlock] of Object.entries(block.subBlocks || {})) { + const value = (subBlock as { value?: unknown }).value + if ( + CREDENTIAL_SUBBLOCK_IDS.has(subBlockId) && + typeof value === 'string' && + value && + !value.startsWith('cred_') + ) { + potentialLegacyIds.add(value) + } + + if (subBlockId === 'tools' && Array.isArray(value)) { + for (const tool of value) { + const credParam = tool?.params?.credential + if (typeof credParam === 'string' && credParam && !credParam.startsWith('cred_')) { + potentialLegacyIds.add(credParam) + } + } + } + } + } + + if (potentialLegacyIds.size === 0) { + return { blocks, migrated: false } + } + + const rows = await db + .select({ id: credential.id, accountId: credential.accountId }) + .from(credential) + .where( + and( + inArray(credential.accountId, [...potentialLegacyIds]), + eq(credential.workspaceId, workspaceId) + ) + ) + + if (rows.length === 0) { + return { blocks, migrated: false } + } + + const accountToCredential = new Map(rows.map((r) => [r.accountId!, r.id])) + + const migratedBlocks = Object.fromEntries( + Object.entries(blocks).map(([blockId, block]) => { + let blockChanged = false + const newSubBlocks = { ...block.subBlocks } + + for (const [subBlockId, subBlock] of Object.entries(newSubBlocks)) { + if (CREDENTIAL_SUBBLOCK_IDS.has(subBlockId) && typeof subBlock.value === 'string') { + const newId = accountToCredential.get(subBlock.value) + if (newId) { + newSubBlocks[subBlockId] = { ...subBlock, value: newId } + blockChanged = true + } + } + + if (subBlockId === 'tools' && Array.isArray(subBlock.value)) { + let toolsChanged = false + const newTools = (subBlock.value as any[]).map((tool: any) => { + const credParam = tool?.params?.credential + if (typeof credParam === 'string') { + const newId = accountToCredential.get(credParam) + if (newId) { + toolsChanged = true + return { ...tool, params: { ...tool.params, credential: newId } } + } + } + return tool + }) + if (toolsChanged) { + newSubBlocks[subBlockId] = { ...subBlock, value: newTools as any } + blockChanged = true + } + } + } + + return [blockId, blockChanged ? { ...block, subBlocks: newSubBlocks } : block] + }) + ) + + const anyBlockChanged = Object.keys(migratedBlocks).some( + (id) => migratedBlocks[id] !== blocks[id] + ) + + return { blocks: migratedBlocks, migrated: anyBlockChanged } +} + /** * Load workflow state from normalized tables * Returns null if no data found (fallback to JSON blob) @@ -193,11 +306,15 @@ export async function loadWorkflowFromNormalizedTables( workflowId: string ): Promise { try { - // Load all components in parallel - const [blocks, edges, subflows] = await Promise.all([ + const [blocks, edges, subflows, [workflowRow]] = await Promise.all([ db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), db.select().from(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), + db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), ]) // If no blocks found, assume this workflow hasn't been migrated yet @@ -236,9 +353,32 @@ export async function loadWorkflowFromNormalizedTables( const { blocks: sanitizedBlocks } = sanitizeAgentToolsInBlocks(blocksMap) // Migrate old agent block format (systemPrompt/userPrompt) to new messages array format - // This ensures backward compatibility for workflows created before the messages-input refactor const migratedBlocks = migrateAgentBlocksToMessagesFormat(sanitizedBlocks) + // Migrate legacy account.id → credential.id in OAuth subblocks + const { blocks: credMigratedBlocks, migrated: credentialsMigrated } = workflowRow?.workspaceId + ? await migrateCredentialIds(migratedBlocks, workflowRow.workspaceId) + : { blocks: migratedBlocks, migrated: false } + + if (credentialsMigrated) { + Promise.resolve().then(async () => { + try { + for (const [blockId, block] of Object.entries(credMigratedBlocks)) { + if (block.subBlocks !== migratedBlocks[blockId]?.subBlocks) { + await db + .update(workflowBlocks) + .set({ subBlocks: block.subBlocks, updatedAt: new Date() }) + .where( + and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)) + ) + } + } + } catch (err) { + logger.warn('Failed to persist credential ID migration', { workflowId, error: err }) + } + }) + } + // Convert edges to the expected format const edgesArray: Edge[] = edges.map((edge) => ({ id: edge.id, @@ -275,15 +415,13 @@ export async function loadWorkflowFromNormalizedTables( forEachItems: (config as Loop).forEachItems ?? '', whileCondition: (config as Loop).whileCondition ?? '', doWhileCondition: (config as Loop).doWhileCondition ?? '', - enabled: migratedBlocks[subflow.id]?.enabled ?? true, + enabled: credMigratedBlocks[subflow.id]?.enabled ?? true, } loops[subflow.id] = loop - // Sync block.data with loop config to ensure all fields are present - // This allows switching between loop types without losing data - if (migratedBlocks[subflow.id]) { - const block = migratedBlocks[subflow.id] - migratedBlocks[subflow.id] = { + if (credMigratedBlocks[subflow.id]) { + const block = credMigratedBlocks[subflow.id] + credMigratedBlocks[subflow.id] = { ...block, data: { ...block.data, @@ -304,7 +442,7 @@ export async function loadWorkflowFromNormalizedTables( (config as Parallel).parallelType === 'collection' ? (config as Parallel).parallelType : 'count', - enabled: migratedBlocks[subflow.id]?.enabled ?? true, + enabled: credMigratedBlocks[subflow.id]?.enabled ?? true, } parallels[subflow.id] = parallel } else { @@ -313,7 +451,7 @@ export async function loadWorkflowFromNormalizedTables( }) return { - blocks: migratedBlocks, + blocks: credMigratedBlocks, edges: edgesArray, loops, parallels,