diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index d8cf0e570f..bab28e66ff 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { chat, workflow } from '@sim/db/schema' +import { chat, workflow, workspace } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' @@ -94,11 +94,12 @@ export async function POST( return addCorsHeaders(createErrorResponse('No input provided', 400), request) } - // Get the workflow for this chat + // Get the workflow and workspace owner for this chat const workflowResult = await db .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId, + variables: workflow.variables, }) .from(workflow) .where(eq(workflow.id, deployment.workflowId)) @@ -109,6 +110,22 @@ export async function POST( return addCorsHeaders(createErrorResponse('Chat workflow is not available', 503), request) } + let workspaceOwnerId = deployment.userId + if (workflowResult[0].workspaceId) { + const workspaceData = await db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, workflowResult[0].workspaceId)) + .limit(1) + + if (workspaceData.length === 0) { + logger.error(`[${requestId}] Workspace not found for workflow ${deployment.workflowId}`) + return addCorsHeaders(createErrorResponse('Workspace not found', 500), request) + } + + workspaceOwnerId = workspaceData[0].ownerId + } + try { const selectedOutputs: string[] = [] if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) { @@ -145,16 +162,19 @@ export async function POST( } } + const workflowForExecution = { + id: deployment.workflowId, + userId: deployment.userId, + workspaceId: workflowResult[0].workspaceId, + isDeployed: true, + variables: workflowResult[0].variables || {}, + } + const stream = await createStreamingResponse({ requestId, - workflow: { - id: deployment.workflowId, - userId: deployment.userId, - workspaceId: workflowResult[0].workspaceId, - isDeployed: true, - }, + workflow: workflowForExecution, input: workflowInput, - executingUserId: deployment.userId, + executingUserId: workspaceOwnerId, streamConfig: { selectedOutputs, isSecureMode: true, diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 65d0583ac0..4c930521ef 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -8,6 +8,7 @@ import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { getEmailDomain } from '@/lib/urls/utils' import { encryptSecret } from '@/lib/utils' +import { deployWorkflow } from '@/lib/workflows/db-helpers' import { checkChatAccess } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -134,6 +135,22 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } + // Redeploy the workflow to ensure latest version is active + const deployResult = await deployWorkflow({ + workflowId: existingChat[0].workflowId, + deployedBy: session.user.id, + }) + + if (!deployResult.success) { + logger.warn( + `Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update` + ) + } else { + logger.info( + `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` + ) + } + let encryptedPassword if (password) { diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 7661f7b576..567d02c2f1 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -19,6 +19,7 @@ describe('Chat API Route', () => { const mockCreateErrorResponse = vi.fn() const mockEncryptSecret = vi.fn() const mockCheckWorkflowAccessForChatCreation = vi.fn() + const mockDeployWorkflow = vi.fn() beforeEach(() => { vi.resetModules() @@ -76,6 +77,14 @@ describe('Chat API Route', () => { vi.doMock('@/app/api/chat/utils', () => ({ checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation, })) + + vi.doMock('@/lib/workflows/db-helpers', () => ({ + deployWorkflow: mockDeployWorkflow.mockResolvedValue({ + success: true, + version: 1, + deployedAt: new Date(), + }), + })) }) afterEach(() => { @@ -236,7 +245,7 @@ describe('Chat API Route', () => { it('should allow chat deployment when user owns workflow directly', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, + user: { id: 'user-id', email: 'user@example.com' }, }), })) @@ -283,7 +292,7 @@ describe('Chat API Route', () => { it('should allow chat deployment when user has workspace admin permission', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, + user: { id: 'user-id', email: 'user@example.com' }, }), })) @@ -393,10 +402,10 @@ describe('Chat API Route', () => { expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id') }) - it('should reject if workflow is not deployed', async () => { + it('should auto-deploy workflow if not already deployed', async () => { vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, + user: { id: 'user-id', email: 'user@example.com' }, }), })) @@ -415,6 +424,7 @@ describe('Chat API Route', () => { hasAccess: true, workflow: { userId: 'user-id', workspaceId: null, isDeployed: false }, }) + mockReturning.mockResolvedValue([{ id: 'test-uuid' }]) const req = new NextRequest('http://localhost:3000/api/chat', { method: 'POST', @@ -423,11 +433,11 @@ describe('Chat API Route', () => { const { POST } = await import('@/app/api/chat/route') const response = await POST(req) - expect(response.status).toBe(400) - expect(mockCreateErrorResponse).toHaveBeenCalledWith( - 'Workflow must be deployed before creating a chat', - 400 - ) + expect(response.status).toBe(200) + expect(mockDeployWorkflow).toHaveBeenCalledWith({ + workflowId: 'workflow-123', + deployedBy: 'user-id', + }) }) }) }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index fb51159bb2..35013f363b 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -9,6 +9,7 @@ import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' import { encryptSecret } from '@/lib/utils' +import { deployWorkflow } from '@/lib/workflows/db-helpers' import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -119,11 +120,20 @@ export async function POST(request: NextRequest) { return createErrorResponse('Workflow not found or access denied', 404) } - // Verify the workflow is deployed (required for chat deployment) - if (!workflowRecord.isDeployed) { - return createErrorResponse('Workflow must be deployed before creating a chat', 400) + // Always deploy/redeploy the workflow to ensure latest version + const result = await deployWorkflow({ + workflowId, + deployedBy: session.user.id, + }) + + if (!result.success) { + return createErrorResponse(result.error || 'Failed to deploy workflow', 500) } + logger.info( + `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for chat (v${result.version})` + ) + // Encrypt password if provided let encryptedPassword = null if (authType === 'password' && password) { diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 3607b58ef8..e93f9f6dd3 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,10 +1,9 @@ import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db' -import { and, desc, eq, sql } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' +import { deployWorkflow } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -138,37 +137,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } catch (_err) {} - logger.debug(`[${requestId}] Getting current workflow state for deployment`) - - const normalizedData = await loadWorkflowFromNormalizedTables(id) - - if (!normalizedData) { - logger.error(`[${requestId}] Failed to load workflow from normalized tables`) - return createErrorResponse('Failed to load workflow state', 500) - } - - const currentState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - lastSaved: Date.now(), - } - - logger.debug(`[${requestId}] Current state retrieved from normalized tables:`, { - blocksCount: Object.keys(currentState.blocks).length, - edgesCount: currentState.edges.length, - loopsCount: Object.keys(currentState.loops).length, - parallelsCount: Object.keys(currentState.parallels).length, - }) - - if (!currentState || !currentState.blocks) { - logger.error(`[${requestId}] Invalid workflow state retrieved`, { currentState }) - throw new Error('Invalid workflow state: missing blocks') - } - - const deployedAt = new Date() - logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`) + logger.debug(`[${requestId}] Validating API key for deployment`) let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null let matchedKey: { @@ -260,45 +229,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse('Unable to determine deploying user', 400) } - await db.transaction(async (tx) => { - const [{ maxVersion }] = await tx - .select({ maxVersion: sql`COALESCE(MAX("version"), 0)` }) - .from(workflowDeploymentVersion) - .where(eq(workflowDeploymentVersion.workflowId, id)) - - const nextVersion = Number(maxVersion) + 1 - - await tx - .update(workflowDeploymentVersion) - .set({ isActive: false }) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - - await tx.insert(workflowDeploymentVersion).values({ - id: uuidv4(), - workflowId: id, - version: nextVersion, - state: currentState, - isActive: true, - createdAt: deployedAt, - createdBy: actorUserId, - }) + const deployResult = await deployWorkflow({ + workflowId: id, + deployedBy: actorUserId, + pinnedApiKeyId: matchedKey?.id, + includeDeployedState: true, + workflowName: workflowData!.name, + }) - const updateData: Record = { - isDeployed: true, - deployedAt, - deployedState: currentState, - } - if (providedApiKey && matchedKey) { - updateData.pinnedApiKeyId = matchedKey.id - } + if (!deployResult.success) { + return createErrorResponse(deployResult.error || 'Failed to deploy workflow', 500) + } - await tx.update(workflow).set(updateData).where(eq(workflow.id, id)) - }) + const deployedAt = deployResult.deployedAt! if (matchedKey) { try { @@ -313,31 +256,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - // Track workflow deployment - try { - const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') - - // Aggregate block types to understand which blocks are being used - const blockTypeCounts: Record = {} - for (const block of Object.values(currentState.blocks)) { - const blockType = (block as any).type || 'unknown' - blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 - } - - trackPlatformEvent('platform.workflow.deployed', { - 'workflow.id': id, - 'workflow.name': workflowData!.name, - 'workflow.blocks_count': Object.keys(currentState.blocks).length, - 'workflow.edges_count': currentState.edges.length, - 'workflow.has_loops': Object.keys(currentState.loops).length > 0, - 'workflow.has_parallels': Object.keys(currentState.parallels).length > 0, - 'workflow.api_key_type': keyInfo?.type || 'default', - 'workflow.block_types': JSON.stringify(blockTypeCounts), - }) - } catch (_e) { - // Silently fail - } - const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key' return createSuccessResponse({ 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 1a4209f51b..c9b636a877 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,4 +1,4 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' @@ -19,7 +19,11 @@ 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) } @@ -29,6 +33,52 @@ export async function POST( return createErrorResponse('Invalid version', 400) } + let providedApiKey: string | null = null + try { + const parsed = await request.json() + if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) { + providedApiKey = parsed.apiKey.trim() + } + } catch (_err) {} + + let pinnedApiKeyId: string | null = null + if (providedApiKey) { + const currentUserId = session?.user?.id + if (currentUserId) { + const [personalKey] = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.id, providedApiKey), + eq(apiKey.userId, currentUserId), + eq(apiKey.type, 'personal') + ) + ) + .limit(1) + + if (personalKey) { + pinnedApiKeyId = personalKey.id + } else if (workflowData!.workspaceId) { + const [workspaceKey] = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where( + and( + eq(apiKey.id, providedApiKey), + eq(apiKey.workspaceId, workflowData!.workspaceId), + eq(apiKey.type, 'workspace') + ) + ) + .limit(1) + + if (workspaceKey) { + pinnedApiKeyId = workspaceKey.id + } + } + } + } + const now = new Date() await db.transaction(async (tx) => { @@ -57,10 +107,16 @@ export async function POST( throw new Error('Deployment version not found') } - await tx - .update(workflow) - .set({ isDeployed: true, deployedAt: now }) - .where(eq(workflow.id, id)) + const updateData: Record = { + isDeployed: true, + deployedAt: now, + } + + if (pinnedApiKeyId) { + updateData.pinnedApiKeyId = pinnedApiKeyId + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, id)) }) return createSuccessResponse({ success: true, deployedAt: now }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx new file mode 100644 index 0000000000..fb18b657e9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector.tsx @@ -0,0 +1,469 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Check, Copy, Info, Loader2, Plus } from 'lucide-react' +import { useParams } from 'next/navigation' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Input, + Label, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui' +import { createLogger } from '@/lib/logs/console/logger' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' + +const logger = createLogger('ApiKeySelector') + +export interface ApiKey { + id: string + name: string + key: string + displayKey?: string + lastUsed?: string + createdAt: string + expiresAt?: string + createdBy?: string +} + +interface ApiKeysData { + workspace: ApiKey[] + personal: ApiKey[] +} + +interface ApiKeySelectorProps { + value: string + onChange: (keyId: string) => void + disabled?: boolean + apiKeys?: ApiKey[] + onApiKeyCreated?: () => void + showLabel?: boolean + label?: string + isDeployed?: boolean + deployedApiKeyDisplay?: string +} + +export function ApiKeySelector({ + value, + onChange, + disabled = false, + apiKeys = [], + onApiKeyCreated, + showLabel = true, + label = 'API Key', + isDeployed = false, + deployedApiKeyDisplay, +}: ApiKeySelectorProps) { + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' + const userPermissions = useUserPermissionsContext() + const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin + + const [apiKeysData, setApiKeysData] = useState(null) + const [isCreatingKey, setIsCreatingKey] = useState(false) + const [newKeyName, setNewKeyName] = useState('') + const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal') + const [newKey, setNewKey] = useState(null) + const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) + const [copySuccess, setCopySuccess] = useState(false) + const [isSubmittingCreate, setIsSubmittingCreate] = useState(false) + const [keysLoaded, setKeysLoaded] = useState(false) + const [createError, setCreateError] = useState(null) + const [justCreatedKeyId, setJustCreatedKeyId] = useState(null) + + useEffect(() => { + fetchApiKeys() + }, [workspaceId]) + + const fetchApiKeys = async () => { + try { + setKeysLoaded(false) + const [workspaceRes, personalRes] = await Promise.all([ + fetch(`/api/workspaces/${workspaceId}/api-keys`), + fetch('/api/users/me/api-keys'), + ]) + + const workspaceData = workspaceRes.ok ? await workspaceRes.json() : { keys: [] } + const personalData = personalRes.ok ? await personalRes.json() : { keys: [] } + + setApiKeysData({ + workspace: workspaceData.keys || [], + personal: personalData.keys || [], + }) + setKeysLoaded(true) + } catch (error) { + logger.error('Error fetching API keys:', { error }) + setKeysLoaded(true) + } + } + + const handleCreateKey = async () => { + if (!newKeyName.trim()) { + setCreateError('Please enter a name for the API key') + return + } + + try { + setIsSubmittingCreate(true) + setCreateError(null) + + const endpoint = + keyType === 'workspace' + ? `/api/workspaces/${workspaceId}/api-keys` + : '/api/users/me/api-keys' + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newKeyName }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to create API key') + } + + const data = await response.json() + setNewKey(data.key) + setJustCreatedKeyId(data.key.id) + setShowNewKeyDialog(true) + setIsCreatingKey(false) + setNewKeyName('') + + // Refresh API keys + await fetchApiKeys() + onApiKeyCreated?.() + } catch (error: any) { + setCreateError(error.message || 'Failed to create API key') + } finally { + setIsSubmittingCreate(false) + } + } + + const handleCopyKey = async () => { + if (newKey?.key) { + await navigator.clipboard.writeText(newKey.key) + setCopySuccess(true) + setTimeout(() => setCopySuccess(false), 2000) + } + } + + if (isDeployed && deployedApiKeyDisplay) { + return ( +
+ {showLabel && ( +
+ + + + + + + +

Owner is billed for usage

+
+
+
+
+ )} +
+
+
+              {(() => {
+                const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
+                if (match) {
+                  return match[1].trim()
+                }
+                return deployedApiKeyDisplay
+              })()}
+            
+ {(() => { + const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/) + if (match) { + const type = match[2] + return ( +
+ + {type} + +
+ ) + } + return null + })()} +
+
+
+ ) + } + + return ( + <> +
+ {showLabel && ( +
+
+ + + + + + + +

Key Owner is Billed

+
+
+
+
+ {!disabled && ( + + )} +
+ )} + +
+ + {/* Create Key Dialog */} + + + + Create new API key + + {keyType === 'workspace' + ? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again." + : "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."} + + + +
+ {canCreateWorkspaceKeys && ( +
+

API Key Type

+
+ + +
+
+ )} + +
+ + { + setNewKeyName(e.target.value) + if (createError) setCreateError(null) + }} + disabled={isSubmittingCreate} + /> + {createError &&

{createError}

} +
+
+ + + { + setNewKeyName('') + setCreateError(null) + }} + > + Cancel + + { + e.preventDefault() + handleCreateKey() + }} + > + {isSubmittingCreate ? ( + <> + + Creating... + + ) : ( + 'Create' + )} + + +
+
+ + {/* New Key Dialog */} + + + + API Key Created Successfully + + Your new API key has been created. Make sure to copy it now as you won't be able to + see it again. + + + +
+ +
+ + +
+
+ + + { + setShowNewKeyDialog(false) + setNewKey(null) + setCopySuccess(false) + // Auto-select the newly created key + if (justCreatedKeyId) { + onChange(justCreatedKeyId) + setJustCreatedKeyId(null) + } + }} + > + Done + + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 0a70a2f149..fef71b45d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -41,11 +41,12 @@ interface ChatDeployProps { chatSubmitting: boolean setChatSubmitting: (submitting: boolean) => void onValidationChange?: (isValid: boolean) => void - onPreDeployWorkflow?: () => Promise showDeleteConfirmation?: boolean setShowDeleteConfirmation?: (show: boolean) => void onDeploymentComplete?: () => void onDeployed?: () => void + onUndeploy?: () => Promise + onVersionActivated?: () => void } interface ExistingChat { @@ -69,11 +70,12 @@ export function ChatDeploy({ chatSubmitting, setChatSubmitting, onValidationChange, - onPreDeployWorkflow, showDeleteConfirmation: externalShowDeleteConfirmation, setShowDeleteConfirmation: externalSetShowDeleteConfirmation, onDeploymentComplete, onDeployed, + onUndeploy, + onVersionActivated, }: ChatDeployProps) { const [isLoading, setIsLoading] = useState(false) const [existingChat, setExistingChat] = useState(null) @@ -97,6 +99,7 @@ export function ChatDeploy({ const { deployedUrl, deployChat } = useChatDeployment() const formRef = useRef(null) const [isIdentifierValid, setIsIdentifierValid] = useState(false) + const isFormValid = isIdentifierValid && Boolean(formData.title.trim()) && @@ -148,7 +151,6 @@ export function ChatDeploy({ : [], }) - // Set image URL if it exists if (chatDetail.customizations?.imageUrl) { setImageUrl(chatDetail.customizations.imageUrl) } @@ -178,8 +180,6 @@ export function ChatDeploy({ setChatSubmitting(true) try { - await onPreDeployWorkflow?.() - if (!validateForm()) { setChatSubmitting(false) return @@ -191,14 +191,13 @@ export function ChatDeploy({ return } - await deployChat(workflowId, formData, deploymentInfo, existingChat?.id, imageUrl) + await deployChat(workflowId, formData, null, existingChat?.id, imageUrl) onChatExistsChange?.(true) setShowSuccessView(true) onDeployed?.() + onVersionActivated?.() - // Fetch the updated chat data immediately after deployment - // This ensures existingChat is available when switching back to edit mode await fetchExistingChat() } catch (error: any) { if (error.message?.includes('identifier')) { @@ -226,13 +225,15 @@ export function ChatDeploy({ throw new Error(error.error || 'Failed to delete chat') } - // Update state + if (onUndeploy) { + await onUndeploy() + } + setExistingChat(null) setImageUrl(null) setImageUploadError(null) onChatExistsChange?.(false) - // Notify parent of successful deletion onDeploymentComplete?.() } catch (error: any) { logger.error('Failed to delete chat:', error) @@ -268,8 +269,8 @@ export function ChatDeploy({ This will permanently delete your chat deployment at{' '} {getEmailDomain()}/chat/{existingChat?.identifier} - - . + {' '} + and undeploy the workflow. All users will lose access immediately, and this action cannot be undone. @@ -324,6 +325,7 @@ export function ChatDeploy({ onValidationChange={setIsIdentifierValid} isEditingExisting={!!existingChat} /> +
- {/* Image Upload Section */}
{ setImageUrl(url) - setImageUploadError(null) // Clear error on successful upload + setImageUploadError(null) }} onError={setImageUploadError} onUploadStart={setIsImageUploading} @@ -427,7 +428,6 @@ export function ChatDeploy({ )}
- {/* Hidden delete trigger button for modal footer */} - - + { + field.onChange(keyId) + onApiKeyChange(keyId) + }} + apiKeys={apiKeys} + onApiKeyCreated={onApiKeyCreated} + showLabel={true} + label='Select API Key' + isDeployed={isDeployed} + deployedApiKeyDisplay={deployedApiKeyDisplay} + /> )} /> - - {/* Create API Key Dialog */} - - - - Create new API key - - {keyType === 'workspace' - ? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again." - : "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."} - - - -
- {canCreateWorkspaceKeys && ( -
-

API Key Type

-
- - -
-
- )} -
-

- Enter a name for your API key to help you identify it later. -

- { - setNewKeyName(e.target.value) - if (createError) setCreateError(null) // Clear error when user types - }} - placeholder='e.g., Development, Production' - className='h-9 rounded-[8px]' - autoFocus - /> - {createError &&
{createError}
} -
-
- - - { - setNewKeyName('') - setKeyType('personal') - setCreateError(null) - }} - > - Cancel - - - -
-
- - {/* New API Key Dialog */} - { - setShowNewKeyDialog(open) - if (!open) { - setNewKey(null) - setCopySuccess(false) - } - }} - > - - - Your API key has been created - - This is the only time you will see your API key.{' '} - Copy it now and store it securely. - - - - {newKey && ( -
-
- - {newKey.key} - -
- -
- )} -
-
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index e13d4df43e..601a6f5f16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -86,8 +86,8 @@ export function DeployModal({ const [deploymentInfo, setDeploymentInfo] = useState(null) const [isLoading, setIsLoading] = useState(false) const [apiKeys, setApiKeys] = useState([]) - const [keysLoaded, setKeysLoaded] = useState(false) const [activeTab, setActiveTab] = useState('general') + const [selectedApiKeyId, setSelectedApiKeyId] = useState('') const [chatSubmitting, setChatSubmitting] = useState(false) const [apiDeployError, setApiDeployError] = useState(null) const [chatExists, setChatExists] = useState(false) @@ -106,6 +106,7 @@ export function DeployModal({ const [editValue, setEditValue] = useState('') const [isRenaming, setIsRenaming] = useState(false) const [openDropdown, setOpenDropdown] = useState(null) + const [versionToActivate, setVersionToActivate] = useState(null) const inputRef = useRef(null) useEffect(() => { @@ -212,17 +213,14 @@ export function DeployModal({ if (!open) return try { - setKeysLoaded(false) const response = await fetch('/api/users/me/api-keys') if (response.ok) { const data = await response.json() setApiKeys(data.keys || []) - setKeysLoaded(true) } } catch (error) { logger.error('Error fetching API keys:', { error }) - setKeysLoaded(true) } } @@ -257,12 +255,31 @@ export function DeployModal({ fetchApiKeys() fetchChatDeploymentInfo() setActiveTab('api') + setVersionToActivate(null) + } else { + setSelectedApiKeyId('') + setVersionToActivate(null) } }, [open, workflowId]) + useEffect(() => { + if (apiKeys.length === 0) return + + if (deploymentInfo?.apiKey) { + const matchingKey = apiKeys.find((k) => k.key === deploymentInfo.apiKey) + if (matchingKey) { + setSelectedApiKeyId(matchingKey.id) + return + } + } + + if (!selectedApiKeyId) { + setSelectedApiKeyId(apiKeys[0].id) + } + }, [deploymentInfo, apiKeys]) + useEffect(() => { async function fetchDeploymentInfo() { - // If not open or not deployed, clear info and stop if (!open || !workflowId || !isDeployed) { setDeploymentInfo(null) if (!open) { @@ -271,7 +288,6 @@ export function DeployModal({ return } - // If we already have deploymentInfo (e.g., just deployed and set locally), avoid overriding it if (deploymentInfo?.isDeployed && !needsRedeployment) { setIsLoading(false) return @@ -288,7 +304,7 @@ export function DeployModal({ const data = await response.json() const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) // Include streaming params only if outputs selected + const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) setDeploymentInfo({ isDeployed: data.isDeployed, @@ -314,13 +330,20 @@ export function DeployModal({ try { setIsSubmitting(true) - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { + const apiKeyToUse = data.apiKey || selectedApiKeyId + + let deployEndpoint = `/api/workflows/${workflowId}/deploy` + if (versionToActivate !== null) { + deployEndpoint = `/api/workflows/${workflowId}/deployments/${versionToActivate}/activate` + } + + const response = await fetch(deployEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - apiKey: data.apiKey, + apiKey: apiKeyToUse, deployChatEnabled: false, }), }) @@ -330,35 +353,47 @@ export function DeployModal({ throw new Error(errorData.error || 'Failed to deploy workflow') } - const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json() + const responseData = await response.json() - setDeploymentStatus( - workflowId, - newDeployStatus, - deployedAt ? new Date(deployedAt) : undefined, - apiKey || data.apiKey - ) + const isActivating = versionToActivate !== null + const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false) + const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined + const apiKeyFromResponse = responseData.apiKey || apiKeyToUse - setNeedsRedeployment(false) - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) // Include streaming params only if outputs selected - - const newDeploymentInfo = { - isDeployed: true, - deployedAt: deployedAt, - apiKey: apiKey || data.apiKey, - endpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${apiKey || data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, - needsRedeployment: false, + setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyFromResponse) + + const matchingKey = apiKeys.find((k) => k.key === apiKeyFromResponse || k.id === apiKeyToUse) + if (matchingKey) { + setSelectedApiKeyId(matchingKey.id) } - setDeploymentInfo(newDeploymentInfo) + const isActivatingVersion = versionToActivate !== null + setNeedsRedeployment(isActivatingVersion) + if (workflowId) { + useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, isActivatingVersion) + } await refetchDeployedState() await fetchVersions() + + const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) + if (deploymentInfoResponse.ok) { + const deploymentData = await deploymentInfoResponse.json() + const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` + const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) + + setDeploymentInfo({ + isDeployed: deploymentData.isDeployed, + deployedAt: deploymentData.deployedAt, + apiKey: deploymentData.apiKey, + endpoint: apiEndpoint, + exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, + needsRedeployment: isActivatingVersion, + }) + } + + setVersionToActivate(null) + setApiDeployError(null) } catch (error: unknown) { logger.error('Error deploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' @@ -392,28 +427,9 @@ export function DeployModal({ } }, [open, workflowId]) - const activateVersion = async (version: number) => { - if (!workflowId) return - try { - setActivatingVersion(version) - const res = await fetch(`/api/workflows/${workflowId}/deployments/${version}/activate`, { - method: 'POST', - }) - if (res.ok) { - await refetchDeployedState() - await fetchVersions() - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - if (previewVersion !== null) { - setPreviewVersion(null) - setPreviewDeployedState(null) - setPreviewing(false) - } - } - } finally { - setActivatingVersion(null) - } + const handleActivateVersion = (version: number) => { + setVersionToActivate(version) + setActiveTab('api') } const openVersionPreview = async (version: number) => { @@ -538,7 +554,6 @@ export function DeployModal({ await refetchDeployedState() await fetchVersions() - // Ensure modal status updates immediately setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev)) } catch (error: unknown) { logger.error('Error redeploying workflow:', { error }) @@ -553,34 +568,32 @@ export function DeployModal({ onOpenChange(false) } - const handleWorkflowPreDeploy = async () => { - // Always deploy to ensure a new deployment version exists - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployApiEnabled: true, - deployChatEnabled: false, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to deploy workflow') - } + const handlePostDeploymentUpdate = async () => { + if (!workflowId) return - const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json() + const isActivating = versionToActivate !== null - setDeploymentStatus( - workflowId, - newDeployStatus, - deployedAt ? new Date(deployedAt) : undefined, - apiKey - ) + setDeploymentStatus(workflowId, true, new Date()) + + const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) + if (deploymentInfoResponse.ok) { + const deploymentData = await deploymentInfoResponse.json() + const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` + const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) + + setDeploymentInfo({ + isDeployed: deploymentData.isDeployed, + deployedAt: deploymentData.deployedAt, + apiKey: deploymentData.apiKey, + endpoint: apiEndpoint, + exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, + needsRedeployment: isActivating, + }) + } - setDeploymentInfo((prev) => (prev ? { ...prev, apiKey } : null)) + await refetchDeployedState() + await fetchVersions() + useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, isActivating) } const handleChatFormSubmit = () => { @@ -597,365 +610,340 @@ export function DeployModal({ } return ( - - - -
- Deploy Workflow - -
-
- -
-
-
- - - + + Close + +
+ + +
+
+
+ + + +
-
-
-
- {activeTab === 'api' && ( - <> - {isDeployed ? ( - - ) : ( - <> - {apiDeployError && ( -
-
API Deployment Error
-
{apiDeployError}
+
+
+ {activeTab === 'api' && ( + <> + {versionToActivate !== null ? ( + <> + {apiDeployError && ( +
+
API Deployment Error
+
{apiDeployError}
+
+ )} + +
+
- )} - -
- + ) : isDeployed ? ( + <> + + + ) : ( + <> + {apiDeployError && ( +
+
API Deployment Error
+
{apiDeployError}
+
+ )} + +
+ +
+ + )} + + )} + + {activeTab === 'versions' && ( + <> +
Deployment Versions
+ {versionsLoading ? ( +
+ Loading deployments...
- - )} - - )} - - {activeTab === 'versions' && ( - <> -
Deployment Versions
- {versionsLoading ? ( -
- Loading deployments... -
- ) : versions.length === 0 ? ( -
- No deployments yet -
- ) : ( - <> -
- - - - - - - - - - {versions - .slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) - .map((v) => ( - { - if (editingVersion !== v.version) { - openVersionPreview(v.version) - } - }} - > - - + + ))} + +
- - Version - - Deployed By - - Created - -
-
-
- {editingVersion === v.version ? ( - setEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSaveRename(v.version) - } else if (e.key === 'Escape') { - e.preventDefault() - handleCancelRename() - } - }} - onBlur={() => handleSaveRename(v.version)} - className='w-full border-0 bg-transparent p-0 font-medium text-sm leading-5 outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' - maxLength={100} - disabled={isRenaming} - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' + ) : versions.length === 0 ? ( +
+ No deployments yet +
+ ) : ( + <> +
+ + + + + + + + + + {versions + .slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage) + .map((v) => ( + { + if (editingVersion !== v.version) { + openVersionPreview(v.version) + } + }} + > + + + - - - + + - - ))} - -
+ + Version + + Deployed By + + Created + +
+
- ) : ( - - {v.name || `v${v.version}`} +
+ {editingVersion === v.version ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveRename(v.version) + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelRename() + } + }} + onBlur={() => handleSaveRename(v.version)} + className='w-full border-0 bg-transparent p-0 font-medium text-sm leading-5 outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' + maxLength={100} + disabled={isRenaming} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> + ) : ( + + {v.name || `v${v.version}`} + + )} + + + {v.deployedBy || 'Unknown'} - )} - - - {v.deployedBy || 'Unknown'} - - - - {new Date(v.createdAt).toLocaleDateString()}{' '} - {new Date(v.createdAt).toLocaleTimeString()} - - e.stopPropagation()}> - - setOpenDropdown(open ? v.version : null) - } + + + {new Date(v.createdAt).toLocaleDateString()}{' '} + {new Date(v.createdAt).toLocaleTimeString()} + + e.stopPropagation()} > - - - - event.preventDefault()} + + setOpenDropdown(open ? v.version : null) + } > - activateVersion(v.version)} - disabled={v.isActive || activatingVersion === v.version} - > - {v.isActive - ? 'Active' - : activatingVersion === v.version - ? 'Activating...' - : 'Activate'} - - openVersionPreview(v.version)} + + + + event.preventDefault()} > - Inspect - - handleStartRename(v.version, v.name)} - > - Rename - - - -
-
- {versions.length > itemsPerPage && ( -
- - Showing{' '} - {Math.min((currentPage - 1) * itemsPerPage + 1, versions.length)} -{' '} - {Math.min(currentPage * itemsPerPage, versions.length)} of{' '} - {versions.length} - -
- - -
+ openVersionPreview(v.version)} + > + {v.isActive ? 'View Active' : 'Inspect'} + + handleStartRename(v.version, v.name)} + > + Rename + + + +
- )} - - )} - - )} - - {activeTab === 'chat' && ( - { - await refetchDeployedState() - await fetchVersions() - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - }} - /> - )} + {versions.length > itemsPerPage && ( +
+ + Showing{' '} + {Math.min((currentPage - 1) * itemsPerPage + 1, versions.length)} -{' '} + {Math.min(currentPage * itemsPerPage, versions.length)} of{' '} + {versions.length} + +
+ + +
+
+ )} + + )} + + )} + + {activeTab === 'chat' && ( + <> + setVersionToActivate(null)} + /> + + )} +
-
- - {activeTab === 'api' && !isDeployed && ( -
- - - -
- )} - {activeTab === 'chat' && ( -
- + {activeTab === 'api' && (versionToActivate !== null || !isDeployed) && ( +
+ -
- {chatExists && ( - - )}
-
+ )} + + {activeTab === 'chat' && ( +
+ + +
+ {chatExists && ( + + )} + +
+
+ )} + + {previewVersion !== null && previewDeployedState && workflowId && ( + { + setPreviewVersion(null) + setPreviewDeployedState(null) + setPreviewing(false) + }} + needsRedeployment={true} + activeDeployedState={deployedState} + selectedDeployedState={previewDeployedState as WorkflowState} + selectedVersion={previewVersion} + onActivateVersion={() => { + handleActivateVersion(previewVersion) + setPreviewVersion(null) + setPreviewDeployedState(null) + setPreviewing(false) + }} + isActivating={activatingVersion === previewVersion} + selectedVersionLabel={ + versions.find((v) => v.version === previewVersion)?.name || `v${previewVersion}` + } + workflowId={workflowId} + isSelectedVersionActive={versions.find((v) => v.version === previewVersion)?.isActive} + /> )} - - {previewVersion !== null && previewDeployedState && workflowId && ( - { - setPreviewVersion(null) - setPreviewDeployedState(null) - setPreviewing(false) - }} - needsRedeployment={true} - activeDeployedState={deployedState} - selectedDeployedState={previewDeployedState as WorkflowState} - selectedVersion={previewVersion} - onActivateVersion={() => activateVersion(previewVersion)} - isActivating={activatingVersion === previewVersion} - selectedVersionLabel={ - versions.find((v) => v.version === previewVersion)?.name || `v${previewVersion}` - } - workflowId={workflowId} - isSelectedVersionActive={versions.find((v) => v.version === previewVersion)?.isActive} - /> - )} -
+ + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx index e42bbf4ea6..01339cf8f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx @@ -118,9 +118,15 @@ export function DeployedWorkflowModal({ Active ) : ( - +
+ +
))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx index bb5eff712c..627e33b597 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -35,6 +35,7 @@ export function DeploymentControls({ const isDeployed = deploymentStatus?.isDeployed || false const workflowNeedsRedeployment = needsRedeployment + const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment const [isDeploying, _setIsDeploying] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) @@ -93,7 +94,9 @@ export function DeploymentControls({ 'h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs', 'hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white', 'transition-all duration-200', - isDeployed && 'text-[var(--brand-primary-hover-hex)]', + isDeployed && !isPreviousVersionActive && 'text-[var(--brand-primary-hover-hex)]', + isPreviousVersionActive && + 'border-purple-500 bg-purple-500/10 text-purple-600 dark:text-purple-400', isDisabled && 'cursor-not-allowed opacity-50 hover:border hover:bg-card hover:text-card-foreground hover:shadow-xs' )} diff --git a/apps/sim/blocks/blocks/qdrant.ts b/apps/sim/blocks/blocks/qdrant.ts index a06743764e..7d43f4e848 100644 --- a/apps/sim/blocks/blocks/qdrant.ts +++ b/apps/sim/blocks/blocks/qdrant.ts @@ -100,17 +100,17 @@ export const QdrantBlock: BlockConfig = { condition: { field: 'operation', value: 'search' }, }, { - id: 'with_payload', - title: 'With Payload', - type: 'switch', - layout: 'full', - condition: { field: 'operation', value: 'search' }, - }, - { - id: 'with_vector', - title: 'With Vector', - type: 'switch', + id: 'search_return_data', + title: 'Return Data', + type: 'dropdown', layout: 'full', + options: [ + { label: 'Payload Only', id: 'payload_only' }, + { label: 'Vector Only', id: 'vector_only' }, + { label: 'Both Payload and Vector', id: 'both' }, + { label: 'None (IDs and scores only)', id: 'none' }, + ], + value: () => 'payload_only', condition: { field: 'operation', value: 'search' }, }, // Fetch fields @@ -142,17 +142,17 @@ export const QdrantBlock: BlockConfig = { required: true, }, { - id: 'with_payload', - title: 'With Payload', - type: 'switch', - layout: 'full', - condition: { field: 'operation', value: 'fetch' }, - }, - { - id: 'with_vector', - title: 'With Vector', - type: 'switch', + id: 'fetch_return_data', + title: 'Return Data', + type: 'dropdown', layout: 'full', + options: [ + { label: 'Payload Only', id: 'payload_only' }, + { label: 'Vector Only', id: 'vector_only' }, + { label: 'Both Payload and Vector', id: 'both' }, + { label: 'None (IDs only)', id: 'none' }, + ], + value: () => 'payload_only', condition: { field: 'operation', value: 'fetch' }, }, { @@ -194,6 +194,8 @@ export const QdrantBlock: BlockConfig = { limit: { type: 'number', description: 'Result limit' }, filter: { type: 'json', description: 'Search filter' }, ids: { type: 'json', description: 'Point identifiers' }, + search_return_data: { type: 'string', description: 'Data to return from search' }, + fetch_return_data: { type: 'string', description: 'Data to return from fetch' }, with_payload: { type: 'boolean', description: 'Include payload' }, with_vector: { type: 'boolean', description: 'Include vectors' }, }, diff --git a/apps/sim/lib/workflows/db-helpers.ts b/apps/sim/lib/workflows/db-helpers.ts index 5b636f5d41..6d68c42537 100644 --- a/apps/sim/lib/workflows/db-helpers.ts +++ b/apps/sim/lib/workflows/db-helpers.ts @@ -1,13 +1,15 @@ import { db, + workflow, workflowBlocks, workflowDeploymentVersion, workflowEdges, workflowSubflows, } from '@sim/db' import type { InferSelectModel } from 'drizzle-orm' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' +import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' @@ -356,3 +358,131 @@ export async function migrateWorkflowToNormalizedTables( } } } + +/** + * Deploy a workflow by creating a new deployment version + */ +export async function deployWorkflow(params: { + workflowId: string + deployedBy: string // User ID of the person deploying + pinnedApiKeyId?: string + includeDeployedState?: boolean + workflowName?: string +}): Promise<{ + success: boolean + version?: number + deployedAt?: Date + currentState?: any + error?: string +}> { + const { + workflowId, + deployedBy, + pinnedApiKeyId, + includeDeployedState = false, + workflowName, + } = params + + try { + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalizedData) { + return { success: false, error: 'Failed to load workflow state' } + } + + const currentState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + lastSaved: Date.now(), + } + + const now = new Date() + + const deployedVersion = await db.transaction(async (tx) => { + // Get next version number + const [{ maxVersion }] = await tx + .select({ maxVersion: sql`COALESCE(MAX("version"), 0)` }) + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + + const nextVersion = Number(maxVersion) + 1 + + // Deactivate all existing versions + await tx + .update(workflowDeploymentVersion) + .set({ isActive: false }) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + + // Create new deployment version + await tx.insert(workflowDeploymentVersion).values({ + id: uuidv4(), + workflowId, + version: nextVersion, + state: currentState, + isActive: true, + createdBy: deployedBy, + createdAt: now, + }) + + // Update workflow to deployed + const updateData: Record = { + isDeployed: true, + deployedAt: now, + } + + if (includeDeployedState) { + updateData.deployedState = currentState + } + + if (pinnedApiKeyId) { + updateData.pinnedApiKeyId = pinnedApiKeyId + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + + return nextVersion + }) + + logger.info(`Deployed workflow ${workflowId} as v${deployedVersion}`) + + // Track deployment telemetry if workflow name is provided + if (workflowName) { + try { + const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') + + const blockTypeCounts: Record = {} + for (const block of Object.values(currentState.blocks)) { + const blockType = (block as any).type || 'unknown' + blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 + } + + trackPlatformEvent('platform.workflow.deployed', { + 'workflow.id': workflowId, + 'workflow.name': workflowName, + 'workflow.blocks_count': Object.keys(currentState.blocks).length, + 'workflow.edges_count': currentState.edges.length, + 'workflow.loops_count': Object.keys(currentState.loops).length, + 'workflow.parallels_count': Object.keys(currentState.parallels).length, + 'workflow.block_types': JSON.stringify(blockTypeCounts), + 'deployment.version': deployedVersion, + }) + } catch (telemetryError) { + logger.warn(`Failed to track deployment telemetry for ${workflowId}`, telemetryError) + } + } + + return { + success: true, + version: deployedVersion, + deployedAt: now, + currentState, + } + } catch (error) { + logger.error(`Error deploying workflow ${workflowId}:`, error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } +} diff --git a/apps/sim/lib/workflows/streaming.ts b/apps/sim/lib/workflows/streaming.ts index e7b3bb0c74..27f4342875 100644 --- a/apps/sim/lib/workflows/streaming.ts +++ b/apps/sim/lib/workflows/streaming.ts @@ -16,7 +16,13 @@ export interface StreamingConfig { export interface StreamingResponseOptions { requestId: string - workflow: { id: string; userId: string; workspaceId?: string | null; isDeployed?: boolean } + workflow: { + id: string + userId: string + workspaceId?: string | null + isDeployed?: boolean + variables?: Record + } input: any executingUserId: string streamConfig: StreamingConfig diff --git a/apps/sim/tools/qdrant/fetch_points.ts b/apps/sim/tools/qdrant/fetch_points.ts index 1c32c3b114..548ad175e8 100644 --- a/apps/sim/tools/qdrant/fetch_points.ts +++ b/apps/sim/tools/qdrant/fetch_points.ts @@ -32,6 +32,12 @@ export const fetchPointsTool: ToolConfig = { visibility: 'user-only', description: 'Array of point IDs to fetch', }, + fetch_return_data: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Data to return from fetch', + }, with_payload: { type: 'boolean', required: false, @@ -53,11 +59,38 @@ export const fetchPointsTool: ToolConfig = { 'Content-Type': 'application/json', ...(params.apiKey ? { 'api-key': params.apiKey } : {}), }), - body: (params) => ({ - ids: params.ids, - with_payload: params.with_payload, - with_vector: params.with_vector, - }), + body: (params) => { + // Calculate with_payload and with_vector from fetch_return_data if provided + let withPayload = params.with_payload ?? false + let withVector = params.with_vector ?? false + + if (params.fetch_return_data) { + switch (params.fetch_return_data) { + case 'payload_only': + withPayload = true + withVector = false + break + case 'vector_only': + withPayload = false + withVector = true + break + case 'both': + withPayload = true + withVector = true + break + case 'none': + withPayload = false + withVector = false + break + } + } + + return { + ids: params.ids, + with_payload: withPayload, + with_vector: withVector, + } + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/qdrant/search_vector.ts b/apps/sim/tools/qdrant/search_vector.ts index 126ab2b5c3..f68001913f 100644 --- a/apps/sim/tools/qdrant/search_vector.ts +++ b/apps/sim/tools/qdrant/search_vector.ts @@ -44,6 +44,12 @@ export const searchVectorTool: ToolConfig = visibility: 'user-only', description: 'Filter to apply to the search', }, + search_return_data: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Data to return from search', + }, with_payload: { type: 'boolean', required: false, @@ -66,13 +72,40 @@ export const searchVectorTool: ToolConfig = 'Content-Type': 'application/json', ...(params.apiKey ? { 'api-key': params.apiKey } : {}), }), - body: (params) => ({ - query: params.vector, - limit: params.limit ? Number.parseInt(params.limit.toString()) : 10, - filter: params.filter, - with_payload: params.with_payload, - with_vector: params.with_vector, - }), + body: (params) => { + // Calculate with_payload and with_vector from search_return_data if provided + let withPayload = params.with_payload ?? false + let withVector = params.with_vector ?? false + + if (params.search_return_data) { + switch (params.search_return_data) { + case 'payload_only': + withPayload = true + withVector = false + break + case 'vector_only': + withPayload = false + withVector = true + break + case 'both': + withPayload = true + withVector = true + break + case 'none': + withPayload = false + withVector = false + break + } + } + + return { + query: params.vector, + limit: params.limit ? Number.parseInt(params.limit.toString()) : 10, + filter: params.filter, + with_payload: withPayload, + with_vector: withVector, + } + }, }, transformResponse: async (response) => { diff --git a/apps/sim/tools/qdrant/types.ts b/apps/sim/tools/qdrant/types.ts index 0d85466ce5..fc0c10257e 100644 --- a/apps/sim/tools/qdrant/types.ts +++ b/apps/sim/tools/qdrant/types.ts @@ -20,12 +20,14 @@ export interface QdrantSearchParams extends QdrantBaseParams { vector: number[] limit?: number filter?: Record + search_return_data?: string with_payload?: boolean with_vector?: boolean } export interface QdrantFetchParams extends QdrantBaseParams { ids: string[] + fetch_return_data?: string with_payload?: boolean with_vector?: boolean }