Skip to content
38 changes: 29 additions & 9 deletions apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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))
Expand All @@ -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)) {
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/app/api/chat/manage/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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) {
Expand Down
28 changes: 19 additions & 9 deletions apps/sim/app/api/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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' },
}),
}))

Expand Down Expand Up @@ -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' },
}),
}))

Expand Down Expand Up @@ -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' },
}),
}))

Expand All @@ -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',
Expand All @@ -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',
})
})
})
})
16 changes: 13 additions & 3 deletions apps/sim/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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) {
Expand Down
110 changes: 14 additions & 96 deletions apps/sim/app/api/workflows/[id]/deploy/route.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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<string, unknown> = {
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 {
Expand All @@ -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<string, number> = {}
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({
Expand Down
Loading