diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index c954de07fd..facf63c977 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -33,12 +33,15 @@ "microsoft_planner", "microsoft_teams", "mistral_parse", + "mysql", "notion", "onedrive", "openai", "outlook", + "parallel_ai", "perplexity", "pinecone", + "postgresql", "qdrant", "reddit", "s3", diff --git a/apps/docs/content/docs/tools/mysql.mdx b/apps/docs/content/docs/tools/mysql.mdx new file mode 100644 index 0000000000..14125a962a --- /dev/null +++ b/apps/docs/content/docs/tools/mysql.mdx @@ -0,0 +1,180 @@ +--- +title: MySQL +description: Connect to MySQL database +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +The [MySQL](https://www.mysql.com/) tool enables you to connect to any MySQL database and perform a wide range of database operations directly within your agentic workflows. With secure connection handling and flexible configuration, you can easily manage and interact with your data. + +With the MySQL tool, you can: + +- **Query data**: Execute SELECT queries to retrieve data from your MySQL tables using the `mysql_query` operation. +- **Insert records**: Add new rows to your tables with the `mysql_insert` operation by specifying the table and data to insert. +- **Update records**: Modify existing data in your tables using the `mysql_update` operation, providing the table, new data, and WHERE conditions. +- **Delete records**: Remove rows from your tables with the `mysql_delete` operation, specifying the table and WHERE conditions. +- **Execute raw SQL**: Run any custom SQL command using the `mysql_execute` operation for advanced use cases. + +The MySQL tool is ideal for scenarios where your agents need to interact with structured data—such as automating reporting, syncing data between systems, or powering data-driven workflows. It streamlines database access, making it easy to read, write, and manage your MySQL data programmatically. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Connect to any MySQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling. + + + +## Tools + +### `mysql_query` + +Execute SELECT query on MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | SQL SELECT query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows returned | + +### `mysql_insert` + +Insert new record into MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to insert into | +| `data` | object | Yes | Data to insert as key-value pairs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of inserted rows | +| `rowCount` | number | Number of rows inserted | + +### `mysql_update` + +Update existing records in MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to update | +| `data` | object | Yes | Data to update as key-value pairs | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of updated rows | +| `rowCount` | number | Number of rows updated | + +### `mysql_delete` + +Delete records from MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to delete from | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of deleted rows | +| `rowCount` | number | Number of rows deleted | + +### `mysql_execute` + +Execute raw SQL query on MySQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | MySQL server hostname or IP address | +| `port` | number | Yes | MySQL server port \(default: 3306\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | Raw SQL query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows affected | + + + +## Notes + +- Category: `tools` +- Type: `mysql` diff --git a/apps/docs/content/docs/tools/parallel_ai.mdx b/apps/docs/content/docs/tools/parallel_ai.mdx new file mode 100644 index 0000000000..39b8730dd3 --- /dev/null +++ b/apps/docs/content/docs/tools/parallel_ai.mdx @@ -0,0 +1,106 @@ +--- +title: Parallel AI +description: Search with Parallel AI +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[Parallel AI](https://parallel.ai/) is an advanced web search and content extraction platform designed to deliver comprehensive, high-quality results for any query. By leveraging intelligent processing and large-scale data extraction, Parallel AI enables users and agents to access, analyze, and synthesize information from across the web with speed and accuracy. + +With Parallel AI, you can: + +- **Search the web intelligently**: Retrieve relevant, up-to-date information from a wide range of sources +- **Extract and summarize content**: Get concise, meaningful excerpts from web pages and documents +- **Customize search objectives**: Tailor queries to specific needs or questions for targeted results +- **Process results at scale**: Handle large volumes of search results with advanced processing options +- **Integrate with workflows**: Use Parallel AI within Sim to automate research, content gathering, and knowledge extraction +- **Control output granularity**: Specify the number of results and the amount of content per result +- **Secure API access**: Protect your searches and data with API key authentication + +In Sim, the Parallel AI integration empowers your agents to perform web searches and extract content programmatically. This enables powerful automation scenarios such as real-time research, competitive analysis, content monitoring, and knowledge base creation. By connecting Sim with Parallel AI, you unlock the ability for agents to gather, process, and utilize web data as part of your automated workflows. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Search the web using Parallel AI's advanced search capabilities. Get comprehensive results with intelligent processing and content extraction. + + + +## Tools + +### `parallel_search` + +Search the web using Parallel AI. Provides comprehensive search results with intelligent processing and content extraction. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `objective` | string | Yes | The search objective or question to answer | +| `search_queries` | string | No | Optional comma-separated list of search queries to execute | +| `processor` | string | No | Processing method: base or pro \(default: base\) | +| `max_results` | number | No | Maximum number of results to return \(default: 5\) | +| `max_chars_per_result` | number | No | Maximum characters per result \(default: 1500\) | +| `apiKey` | string | Yes | Parallel AI API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Search results with excerpts from relevant pages | + + + +## Notes + +- Category: `tools` +- Type: `parallel_ai` diff --git a/apps/docs/content/docs/tools/postgresql.mdx b/apps/docs/content/docs/tools/postgresql.mdx new file mode 100644 index 0000000000..e79d5661e9 --- /dev/null +++ b/apps/docs/content/docs/tools/postgresql.mdx @@ -0,0 +1,188 @@ +--- +title: PostgreSQL +description: Connect to PostgreSQL database +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +The [PostgreSQL](https://www.postgresql.org/) tool enables you to connect to any PostgreSQL database and perform a wide range of database operations directly within your agentic workflows. With secure connection handling and flexible configuration, you can easily manage and interact with your data. + +With the PostgreSQL tool, you can: + +- **Query data**: Execute SELECT queries to retrieve data from your PostgreSQL tables using the `postgresql_query` operation. +- **Insert records**: Add new rows to your tables with the `postgresql_insert` operation by specifying the table and data to insert. +- **Update records**: Modify existing data in your tables using the `postgresql_update` operation, providing the table, new data, and WHERE conditions. +- **Delete records**: Remove rows from your tables with the `postgresql_delete` operation, specifying the table and WHERE conditions. +- **Execute raw SQL**: Run any custom SQL command using the `postgresql_execute` operation for advanced use cases. + +The PostgreSQL tool is ideal for scenarios where your agents need to interact with structured data—such as automating reporting, syncing data between systems, or powering data-driven workflows. It streamlines database access, making it easy to read, write, and manage your PostgreSQL data programmatically. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Connect to any PostgreSQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling. + + + +## Tools + +### `postgresql_query` + +Execute a SELECT query on PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | SQL SELECT query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows returned | + +### `postgresql_insert` + +Insert data into PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to insert data into | +| `data` | object | Yes | Data object to insert \(key-value pairs\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Inserted data \(if RETURNING clause used\) | +| `rowCount` | number | Number of rows inserted | + +### `postgresql_update` + +Update data in PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to update data in | +| `data` | object | Yes | Data object with fields to update \(key-value pairs\) | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Updated data \(if RETURNING clause used\) | +| `rowCount` | number | Number of rows updated | + +### `postgresql_delete` + +Delete data from PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `table` | string | Yes | Table name to delete data from | +| `where` | string | Yes | WHERE clause condition \(without WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Deleted data \(if RETURNING clause used\) | +| `rowCount` | number | Number of rows deleted | + +### `postgresql_execute` + +Execute raw SQL query on PostgreSQL database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | PostgreSQL server hostname or IP address | +| `port` | number | Yes | PostgreSQL server port \(default: 5432\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Database username | +| `password` | string | Yes | Database password | +| `ssl` | string | No | SSL connection mode \(disabled, required, preferred\) | +| `query` | string | Yes | Raw SQL query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows affected | + + + +## Notes + +- Category: `tools` +- Type: `postgresql` diff --git a/apps/sim/app/api/billing/daily/route.ts b/apps/sim/app/api/billing/daily/route.ts deleted file mode 100644 index 4ed088e866..0000000000 --- a/apps/sim/app/api/billing/daily/route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { verifyCronAuth } from '@/lib/auth/internal' -import { processDailyBillingCheck } from '@/lib/billing/core/billing' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('DailyBillingCron') - -/** - * Daily billing CRON job endpoint that checks individual billing periods - */ -export async function POST(request: NextRequest) { - try { - const authError = verifyCronAuth(request, 'daily billing check') - if (authError) { - return authError - } - - logger.info('Starting daily billing check cron job') - - const startTime = Date.now() - - // Process overage billing for users and organizations with periods ending today - const result = await processDailyBillingCheck() - - const duration = Date.now() - startTime - - if (result.success) { - logger.info('Daily billing check completed successfully', { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - duration: `${duration}ms`, - }) - - return NextResponse.json({ - success: true, - summary: { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - duration: `${duration}ms`, - }, - }) - } - - logger.error('Daily billing check completed with errors', { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - errorCount: result.errors.length, - errors: result.errors, - duration: `${duration}ms`, - }) - - return NextResponse.json( - { - success: false, - summary: { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - errorCount: result.errors.length, - duration: `${duration}ms`, - }, - errors: result.errors, - }, - { status: 500 } - ) - } catch (error) { - logger.error('Fatal error in daily billing cron job', { error }) - - return NextResponse.json( - { - success: false, - error: 'Internal server error during daily billing check', - details: error instanceof Error ? error.message : 'Unknown error', - }, - { status: 500 } - ) - } -} - -/** - * GET endpoint for manual testing and health checks - */ -export async function GET(request: NextRequest) { - try { - const authError = verifyCronAuth(request, 'daily billing check health check') - if (authError) { - return authError - } - - const startTime = Date.now() - const result = await processDailyBillingCheck() - const duration = Date.now() - startTime - - if (result.success) { - logger.info('Daily billing check (GET) completed successfully', { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - duration: `${duration}ms`, - }) - - return NextResponse.json({ - success: true, - summary: { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - duration: `${duration}ms`, - }, - }) - } - - logger.error('Daily billing check (GET) completed with errors', { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - errorCount: result.errors.length, - errors: result.errors, - duration: `${duration}ms`, - }) - - return NextResponse.json( - { - success: false, - summary: { - processedUsers: result.processedUsers, - processedOrganizations: result.processedOrganizations, - totalChargedAmount: result.totalChargedAmount, - errorCount: result.errors.length, - duration: `${duration}ms`, - }, - errors: result.errors, - }, - { status: 500 } - ) - } catch (error) { - logger.error('Fatal error in daily billing (GET) cron job', { error }) - return NextResponse.json( - { - success: false, - error: 'Internal server error during daily billing check', - details: error instanceof Error ? error.message : 'Unknown error', - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 3c22e25c92..8debe8bade 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -143,7 +143,7 @@ async function generateChatTitleAsync( streamController?: ReadableStreamDefaultController ): Promise { try { - logger.info(`[${requestId}] Starting async title generation for chat ${chatId}`) + // logger.info(`[${requestId}] Starting async title generation for chat ${chatId}`) const title = await generateChatTitle(userMessage) @@ -167,7 +167,7 @@ async function generateChatTitleAsync( logger.debug(`[${requestId}] Sent title_updated event to client: "${title}"`) } - logger.info(`[${requestId}] Generated title for chat ${chatId}: "${title}"`) + // logger.info(`[${requestId}] Generated title for chat ${chatId}: "${title}"`) } catch (error) { logger.error(`[${requestId}] Failed to generate title for chat ${chatId}:`, error) // Don't throw - this is a background operation @@ -229,21 +229,21 @@ export async function POST(req: NextRequest) { } } - logger.info(`[${tracker.requestId}] Processing copilot chat request`, { - userId: authenticatedUserId, - workflowId, - chatId, - mode, - stream, - createNewChat, - messageLength: message.length, - hasImplicitFeedback: !!implicitFeedback, - provider: provider || 'openai', - hasConversationId: !!conversationId, - depth, - prefetch, - origin: requestOrigin, - }) + // logger.info(`[${tracker.requestId}] Processing copilot chat request`, { + // userId: authenticatedUserId, + // workflowId, + // chatId, + // mode, + // stream, + // createNewChat, + // messageLength: message.length, + // hasImplicitFeedback: !!implicitFeedback, + // provider: provider || 'openai', + // hasConversationId: !!conversationId, + // depth, + // prefetch, + // origin: requestOrigin, + // }) // Handle chat context let currentChat: any = null @@ -285,7 +285,7 @@ export async function POST(req: NextRequest) { // Process file attachments if present const processedFileContents: any[] = [] if (fileAttachments && fileAttachments.length > 0) { - logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`) + // logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`) for (const attachment of fileAttachments) { try { @@ -296,7 +296,7 @@ export async function POST(req: NextRequest) { } // Download file from S3 - logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`) + // logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`) let fileBuffer: Buffer if (USE_S3_STORAGE) { fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG) @@ -309,9 +309,9 @@ export async function POST(req: NextRequest) { const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type) if (fileContent) { processedFileContents.push(fileContent) - logger.info( - `[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})` - ) + // logger.info( + // `[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})` + // ) } } catch (error) { logger.error( @@ -424,27 +424,7 @@ export async function POST(req: NextRequest) { ...(requestOrigin ? { origin: requestOrigin } : {}), } - // Log the payload being sent to the streaming endpoint - try { - logger.info(`[${tracker.requestId}] Sending payload to sim agent streaming endpoint`, { - url: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`, - provider: providerToUse, - mode, - stream, - workflowId, - hasConversationId: !!effectiveConversationId, - depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined, - prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined, - messagesCount: requestPayload.messages.length, - ...(requestOrigin ? { origin: requestOrigin } : {}), - }) - // Full payload as JSON string - logger.info( - `[${tracker.requestId}] Full streaming payload: ${JSON.stringify(requestPayload)}` - ) - } catch (e) { - logger.warn(`[${tracker.requestId}] Failed to log payload preview for streaming endpoint`, e) - } + // Log the payload being sent to the streaming endpoint (logs currently disabled) const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, { method: 'POST', @@ -475,7 +455,7 @@ export async function POST(req: NextRequest) { // If streaming is requested, forward the stream and update chat later if (stream && simAgentResponse.body) { - logger.info(`[${tracker.requestId}] Streaming response from sim agent`) + // logger.info(`[${tracker.requestId}] Streaming response from sim agent`) // Create user message to save const userMessage = { @@ -493,7 +473,7 @@ export async function POST(req: NextRequest) { let assistantContent = '' const toolCalls: any[] = [] let buffer = '' - let isFirstDone = true + const isFirstDone = true let responseIdFromStart: string | undefined let responseIdFromDone: string | undefined // Track tool call progress to identify a safe done event @@ -515,30 +495,30 @@ export async function POST(req: NextRequest) { // Start title generation in parallel if needed if (actualChatId && !currentChat?.title && conversationHistory.length === 0) { - logger.info(`[${tracker.requestId}] Starting title generation with stream updates`, { - chatId: actualChatId, - hasTitle: !!currentChat?.title, - conversationLength: conversationHistory.length, - message: message.substring(0, 100) + (message.length > 100 ? '...' : ''), - }) + // logger.info(`[${tracker.requestId}] Starting title generation with stream updates`, { + // chatId: actualChatId, + // hasTitle: !!currentChat?.title, + // conversationLength: conversationHistory.length, + // message: message.substring(0, 100) + (message.length > 100 ? '...' : ''), + // }) generateChatTitleAsync(actualChatId, message, tracker.requestId, controller).catch( (error) => { logger.error(`[${tracker.requestId}] Title generation failed:`, error) } ) } else { - logger.debug(`[${tracker.requestId}] Skipping title generation`, { - chatId: actualChatId, - hasTitle: !!currentChat?.title, - conversationLength: conversationHistory.length, - reason: !actualChatId - ? 'no chatId' - : currentChat?.title - ? 'already has title' - : conversationHistory.length > 0 - ? 'not first message' - : 'unknown', - }) + // logger.debug(`[${tracker.requestId}] Skipping title generation`, { + // chatId: actualChatId, + // hasTitle: !!currentChat?.title, + // conversationLength: conversationHistory.length, + // reason: !actualChatId + // ? 'no chatId' + // : currentChat?.title + // ? 'already has title' + // : conversationHistory.length > 0 + // ? 'not first message' + // : 'unknown', + // }) } // Forward the sim agent stream and capture assistant response @@ -549,7 +529,7 @@ export async function POST(req: NextRequest) { while (true) { const { done, value } = await reader.read() if (done) { - logger.info(`[${tracker.requestId}] Stream reading completed`) + // logger.info(`[${tracker.requestId}] Stream reading completed`) break } @@ -559,9 +539,9 @@ export async function POST(req: NextRequest) { controller.enqueue(value) } catch (error) { // Client disconnected - stop reading from sim agent - logger.info( - `[${tracker.requestId}] Client disconnected, stopping stream processing` - ) + // logger.info( + // `[${tracker.requestId}] Client disconnected, stopping stream processing` + // ) reader.cancel() // Stop reading from sim agent break } @@ -608,15 +588,15 @@ export async function POST(req: NextRequest) { break case 'tool_call': - logger.info( - `[${tracker.requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`, - { - id: event.data?.id, - name: event.data?.name, - arguments: event.data?.arguments, - blockIndex: event.data?._blockIndex, - } - ) + // logger.info( + // `[${tracker.requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`, + // { + // id: event.data?.id, + // name: event.data?.name, + // arguments: event.data?.arguments, + // blockIndex: event.data?._blockIndex, + // } + // ) if (!event.data?.partial) { toolCalls.push(event.data) if (event.data?.id) { @@ -625,30 +605,24 @@ export async function POST(req: NextRequest) { } break - case 'tool_execution': - logger.info(`[${tracker.requestId}] Tool execution started:`, { - toolCallId: event.toolCallId, - toolName: event.toolName, - status: event.status, - }) + case 'tool_generating': + // logger.info(`[${tracker.requestId}] Tool generating:`, { + // toolCallId: event.toolCallId, + // toolName: event.toolName, + // }) if (event.toolCallId) { - if (event.status === 'completed') { - startedToolExecutionIds.add(event.toolCallId) - completedToolExecutionIds.add(event.toolCallId) - } else { - startedToolExecutionIds.add(event.toolCallId) - } + startedToolExecutionIds.add(event.toolCallId) } break case 'tool_result': - logger.info(`[${tracker.requestId}] Tool result received:`, { - toolCallId: event.toolCallId, - toolName: event.toolName, - success: event.success, - result: `${JSON.stringify(event.result).substring(0, 200)}...`, - resultSize: JSON.stringify(event.result).length, - }) + // logger.info(`[${tracker.requestId}] Tool result received:`, { + // toolCallId: event.toolCallId, + // toolName: event.toolName, + // success: event.success, + // result: `${JSON.stringify(event.result).substring(0, 200)}...`, + // resultSize: JSON.stringify(event.result).length, + // }) if (event.toolCallId) { completedToolExecutionIds.add(event.toolCallId) } @@ -669,9 +643,6 @@ export async function POST(req: NextRequest) { case 'start': if (event.data?.responseId) { responseIdFromStart = event.data.responseId - logger.info( - `[${tracker.requestId}] Received start event with responseId: ${responseIdFromStart}` - ) } break @@ -679,9 +650,7 @@ export async function POST(req: NextRequest) { if (event.data?.responseId) { responseIdFromDone = event.data.responseId lastDoneResponseId = responseIdFromDone - logger.info( - `[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}` - ) + // Mark this done as safe only if no tool call is currently in progress or pending const announced = announcedToolCallIds.size const completed = completedToolExecutionIds.size @@ -689,34 +658,14 @@ export async function POST(req: NextRequest) { const hasToolInProgress = announced > completed || started > completed if (!hasToolInProgress) { lastSafeDoneResponseId = responseIdFromDone - logger.info( - `[${tracker.requestId}] Marked done as SAFE (no tools in progress)` - ) - } else { - logger.info( - `[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})` - ) } } - if (isFirstDone) { - logger.info( - `[${tracker.requestId}] Initial AI response complete, tool count: ${toolCalls.length}` - ) - isFirstDone = false - } else { - logger.info(`[${tracker.requestId}] Conversation round complete`) - } break case 'error': - logger.error(`[${tracker.requestId}] Stream error event:`, event.error) break default: - logger.debug( - `[${tracker.requestId}] Unknown event type: ${event.type}`, - event - ) } } catch (e) { // Enhanced error handling for large payloads and parsing issues diff --git a/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts new file mode 100644 index 0000000000..f7ead3e522 --- /dev/null +++ b/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts @@ -0,0 +1,53 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { + authenticateCopilotRequestSessionOnly, + createBadRequestResponse, + createInternalServerErrorResponse, + createRequestTracker, + createUnauthorizedResponse, +} from '@/lib/copilot/auth' +import { routeExecution } from '@/lib/copilot/tools/server/router' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('ExecuteCopilotServerToolAPI') + +const ExecuteSchema = z.object({ + toolName: z.string(), + payload: z.unknown().optional(), +}) + +export async function POST(req: NextRequest) { + const tracker = createRequestTracker() + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const body = await req.json() + try { + const preview = JSON.stringify(body).slice(0, 300) + logger.debug(`[${tracker.requestId}] Incoming request body preview`, { preview }) + } catch {} + + const { toolName, payload } = ExecuteSchema.parse(body) + + logger.info(`[${tracker.requestId}] Executing server tool`, { toolName }) + const result = await routeExecution(toolName, payload) + + try { + const resultPreview = JSON.stringify(result).slice(0, 300) + logger.debug(`[${tracker.requestId}] Server tool result preview`, { toolName, resultPreview }) + } catch {} + + return NextResponse.json({ success: true, result }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.debug(`[${tracker.requestId}] Zod validation error`, { issues: error.issues }) + return createBadRequestResponse('Invalid request body for execute-copilot-server-tool') + } + logger.error(`[${tracker.requestId}] Failed to execute server tool:`, error) + return createInternalServerErrorResponse('Failed to execute server tool') + } +} diff --git a/apps/sim/app/api/copilot/methods/route.test.ts b/apps/sim/app/api/copilot/methods/route.test.ts index 243a9b9c5c..0553ad161b 100644 --- a/apps/sim/app/api/copilot/methods/route.test.ts +++ b/apps/sim/app/api/copilot/methods/route.test.ts @@ -1,761 +1,7 @@ -/** - * Tests for copilot methods API route - * - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createMockRequest, - mockCryptoUuid, - setupCommonApiMocks, -} from '@/app/api/__test-utils__/utils' +import { describe, expect, it } from 'vitest' -describe('Copilot Methods API Route', () => { - const mockRedisGet = vi.fn() - const mockRedisSet = vi.fn() - const mockGetRedisClient = vi.fn() - const mockToolRegistryHas = vi.fn() - const mockToolRegistryGet = vi.fn() - const mockToolRegistryExecute = vi.fn() - const mockToolRegistryGetAvailableIds = vi.fn() - - beforeEach(() => { - vi.resetModules() - setupCommonApiMocks() - mockCryptoUuid() - - // Mock Redis client - const mockRedisClient = { - get: mockRedisGet, - set: mockRedisSet, - } - - mockGetRedisClient.mockReturnValue(mockRedisClient) - mockRedisGet.mockResolvedValue(null) - mockRedisSet.mockResolvedValue('OK') - - vi.doMock('@/lib/redis', () => ({ - getRedisClient: mockGetRedisClient, - })) - - // Mock tool registry - const mockToolRegistry = { - has: mockToolRegistryHas, - get: mockToolRegistryGet, - execute: mockToolRegistryExecute, - getAvailableIds: mockToolRegistryGetAvailableIds, - } - - mockToolRegistryHas.mockReturnValue(true) - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: false }) - mockToolRegistryExecute.mockResolvedValue({ success: true, data: 'Tool executed successfully' }) - mockToolRegistryGetAvailableIds.mockReturnValue(['test-tool', 'another-tool']) - - vi.doMock('@/lib/copilot/tools/server-tools/registry', () => ({ - copilotToolRegistry: mockToolRegistry, - })) - - // Mock environment variables - vi.doMock('@/lib/env', () => ({ - env: { - INTERNAL_API_SECRET: 'test-secret-key', - COPILOT_API_KEY: 'test-copilot-key', - }, - })) - - // Mock setTimeout for polling - vi.spyOn(global, 'setTimeout').mockImplementation((callback, _delay) => { - if (typeof callback === 'function') { - setImmediate(callback) - } - return setTimeout(() => {}, 0) as any - }) - - // Mock Date.now for timeout control - let mockTime = 1640995200000 - vi.spyOn(Date, 'now').mockImplementation(() => { - mockTime += 1000 // Add 1 second each call - return mockTime - }) - - // Mock crypto.randomUUID for request IDs - vi.spyOn(crypto, 'randomUUID').mockReturnValue('test-request-id') - }) - - afterEach(() => { - vi.clearAllMocks() - vi.restoreAllMocks() - }) - - describe('POST', () => { - it('should return 401 when API key is missing', async () => { - const req = createMockRequest('POST', { - methodId: 'test-tool', - params: {}, - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData).toEqual({ - success: false, - error: 'API key required', - }) - }) - - it('should return 401 when API key is invalid', async () => { - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'invalid-key', - }, - body: JSON.stringify({ - methodId: 'test-tool', - params: {}, - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(typeof responseData.error).toBe('string') - }) - - it('should return 401 when internal API key is not configured', async () => { - // Mock environment with no API key - vi.doMock('@/lib/env', () => ({ - env: { - INTERNAL_API_SECRET: undefined, - COPILOT_API_KEY: 'test-copilot-key', - }, - })) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'any-key', - }, - body: JSON.stringify({ - methodId: 'test-tool', - params: {}, - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData.status).toBeUndefined() - expect(responseData.success).toBe(false) - expect(typeof responseData.error).toBe('string') - }) - - it('should return 400 for invalid request body - missing methodId', async () => { - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - params: {}, - // Missing methodId - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toContain('Required') - }) - - it('should return 400 for empty methodId', async () => { - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: '', - params: {}, - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toContain('Method ID is required') - }) - - it('should return 400 when tool is not found in registry', async () => { - mockToolRegistryHas.mockReturnValue(false) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'unknown-tool', - params: {}, - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toContain('Unknown method: unknown-tool') - expect(responseData.error).toContain('Available methods: test-tool, another-tool') - }) - - it('should successfully execute a tool without interruption', async () => { - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'test-tool', - params: { key: 'value' }, - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - data: 'Tool executed successfully', - }) - - expect(mockToolRegistryExecute).toHaveBeenCalledWith('test-tool', { key: 'value' }) - }) - - it('should handle tool execution with default empty params', async () => { - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'test-tool', - // No params provided - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - data: 'Tool executed successfully', - }) - - expect(mockToolRegistryExecute).toHaveBeenCalledWith('test-tool', {}) - }) - - it('should return 400 when tool requires interrupt but no toolCallId provided', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - // No toolCallId provided - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe( - 'This tool requires approval but no tool call ID was provided' - ) - }) - - it('should handle tool execution with interrupt - user approval', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return accepted status immediately (simulate quick approval) - mockRedisGet.mockResolvedValue( - JSON.stringify({ status: 'accepted', message: 'User approved' }) - ) - - // Reset Date.now mock to not trigger timeout - let mockTime = 1640995200000 - vi.spyOn(Date, 'now').mockImplementation(() => { - mockTime += 100 // Small increment to avoid timeout - return mockTime - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: { key: 'value' }, - toolCallId: 'tool-call-123', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - data: 'Tool executed successfully', - }) - - // Verify Redis operations - expect(mockRedisSet).toHaveBeenCalledWith( - 'tool_call:tool-call-123', - expect.stringContaining('"status":"pending"'), - 'EX', - 86400 - ) - expect(mockRedisGet).toHaveBeenCalledWith('tool_call:tool-call-123') - expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', { - key: 'value', - confirmationMessage: 'User approved', - fullData: { - message: 'User approved', - status: 'accepted', - }, - }) - }) - - it('should handle tool execution with interrupt - user rejection', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return rejected status - mockRedisGet.mockResolvedValue( - JSON.stringify({ status: 'rejected', message: 'User rejected' }) - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-456', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) // User rejection returns 200 - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe( - 'The user decided to skip running this tool. This was a user decision.' - ) - - // Tool should not be executed when rejected - expect(mockToolRegistryExecute).not.toHaveBeenCalled() - }) - - it('should handle tool execution with interrupt - error status', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return error status - mockRedisGet.mockResolvedValue( - JSON.stringify({ status: 'error', message: 'Tool execution failed' }) - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-error', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe('Tool execution failed') - }) - - it('should handle tool execution with interrupt - background status', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return background status - mockRedisGet.mockResolvedValue( - JSON.stringify({ status: 'background', message: 'Running in background' }) - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-bg', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - data: 'Tool executed successfully', - }) - - expect(mockToolRegistryExecute).toHaveBeenCalled() - }) - - it('should handle tool execution with interrupt - success status', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return success status - mockRedisGet.mockResolvedValue( - JSON.stringify({ status: 'success', message: 'Completed successfully' }) - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-success', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - data: 'Tool executed successfully', - }) - - expect(mockToolRegistryExecute).toHaveBeenCalled() - }) - - it('should handle tool execution with interrupt - timeout', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to never return a status (timeout scenario) - mockRedisGet.mockResolvedValue(null) - - // Mock Date.now to trigger timeout quickly - let mockTime = 1640995200000 - vi.spyOn(Date, 'now').mockImplementation(() => { - mockTime += 100000 // Add 100 seconds each call to trigger timeout - return mockTime - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-timeout', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(408) // Request Timeout - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe('Tool execution request timed out') - - expect(mockToolRegistryExecute).not.toHaveBeenCalled() - }) - - it('should handle unexpected status in interrupt flow', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return unexpected status - mockRedisGet.mockResolvedValue( - JSON.stringify({ status: 'unknown-status', message: 'Unknown' }) - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-unknown', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe('Unexpected tool call status: unknown-status') - }) - - it('should handle Redis client unavailable for interrupt flow', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - mockGetRedisClient.mockReturnValue(null) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-no-redis', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(408) // Timeout due to Redis unavailable - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe('Tool execution request timed out') - }) - - it('should handle no_op tool with confirmation message', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return accepted status with message - mockRedisGet.mockResolvedValue( - JSON.stringify({ status: 'accepted', message: 'Confirmation message' }) - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'no_op', - params: { existing: 'param' }, - toolCallId: 'tool-call-noop', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) - - // Verify confirmation message was added to params - expect(mockToolRegistryExecute).toHaveBeenCalledWith('no_op', { - existing: 'param', - confirmationMessage: 'Confirmation message', - fullData: { - message: 'Confirmation message', - status: 'accepted', - }, - }) - }) - - it('should handle Redis errors in interrupt flow', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to throw an error - mockRedisGet.mockRejectedValue(new Error('Redis connection failed')) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-redis-error', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(408) // Timeout due to Redis error - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe('Tool execution request timed out') - }) - - it('should handle tool execution failure', async () => { - mockToolRegistryExecute.mockResolvedValue({ - success: false, - error: 'Tool execution failed', - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'failing-tool', - params: {}, - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) // Still returns 200, but with success: false - const responseData = await response.json() - expect(responseData).toEqual({ - success: false, - error: 'Tool execution failed', - }) - }) - - it('should handle JSON parsing errors in request body', async () => { - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: '{invalid-json', - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toContain('JSON') - }) - - it('should handle tool registry execution throwing an error', async () => { - mockToolRegistryExecute.mockRejectedValue(new Error('Registry execution failed')) - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'error-tool', - params: {}, - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.success).toBe(false) - expect(responseData.error).toBe('Registry execution failed') - }) - - it('should handle old format Redis status (string instead of JSON)', async () => { - mockToolRegistryGet.mockReturnValue({ requiresInterrupt: true }) - - // Mock Redis to return old format (direct status string) - mockRedisGet.mockResolvedValue('accepted') - - const req = new NextRequest('http://localhost:3000/api/copilot/methods', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-secret-key', - }, - body: JSON.stringify({ - methodId: 'interrupt-tool', - params: {}, - toolCallId: 'tool-call-old-format', - }), - }) - - const { POST } = await import('@/app/api/copilot/methods/route') - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - data: 'Tool executed successfully', - }) - - expect(mockToolRegistryExecute).toHaveBeenCalled() - }) +describe('copilot methods route placeholder', () => { + it('loads test suite', () => { + expect(true).toBe(true) }) }) diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts deleted file mode 100644 index 4af0bfad1a..0000000000 --- a/apps/sim/app/api/copilot/methods/route.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry' -import type { NotificationStatus } from '@/lib/copilot/types' -import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils' -import { createLogger } from '@/lib/logs/console/logger' -import { getRedisClient } from '@/lib/redis' -import { createErrorResponse } from '@/app/api/copilot/methods/utils' - -const logger = createLogger('CopilotMethodsAPI') - -/** - * Add a tool call to Redis with 'pending' status - */ -async function addToolToRedis(toolCallId: string): Promise { - if (!toolCallId) { - logger.warn('addToolToRedis: No tool call ID provided') - return - } - - const redis = getRedisClient() - if (!redis) { - logger.warn('addToolToRedis: Redis client not available') - return - } - - try { - const key = `tool_call:${toolCallId}` - const status: NotificationStatus = 'pending' - - // Store as JSON object for consistency with confirm API - const toolCallData = { - status, - message: null, - timestamp: new Date().toISOString(), - } - - // Set with 24 hour expiry (86400 seconds) - await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) - - logger.info('Tool call added to Redis', { - toolCallId, - key, - status, - }) - } catch (error) { - logger.error('Failed to add tool call to Redis', { - toolCallId, - error: error instanceof Error ? error.message : 'Unknown error', - }) - } -} - -/** - * Poll Redis for tool call status updates - * Returns when status changes to 'Accepted' or 'Rejected', or times out after 60 seconds - */ -async function pollRedisForTool( - toolCallId: string -): Promise<{ status: NotificationStatus; message?: string; fullData?: any } | null> { - const redis = getRedisClient() - if (!redis) { - logger.warn('pollRedisForTool: Redis client not available') - return null - } - - const key = `tool_call:${toolCallId}` - const timeout = 600000 // 10 minutes for long-running operations - const pollInterval = 1000 // 1 second - const startTime = Date.now() - - while (Date.now() - startTime < timeout) { - try { - const redisValue = await redis.get(key) - if (!redisValue) { - // Wait before next poll - await new Promise((resolve) => setTimeout(resolve, pollInterval)) - continue - } - - let status: NotificationStatus | null = null - let message: string | undefined - let fullData: any = null - - // Try to parse as JSON (new format), fallback to string (old format) - try { - const parsedData = JSON.parse(redisValue) - status = parsedData.status as NotificationStatus - message = parsedData.message || undefined - fullData = parsedData // Store the full parsed data - } catch { - // Fallback to old format (direct status string) - status = redisValue as NotificationStatus - } - - if (status !== 'pending') { - // Log the message found in redis prominently - always log, even if message is null/undefined - logger.info('Redis poller found non-pending status', { - toolCallId, - foundMessage: message, - messageType: typeof message, - messageIsNull: message === null, - messageIsUndefined: message === undefined, - status, - duration: Date.now() - startTime, - rawRedisValue: redisValue, - }) - - // Special logging for set environment variables tool when Redis status is found - if (toolCallId && (status === 'accepted' || status === 'rejected')) { - logger.info('SET_ENV_VARS: Redis polling found status update', { - toolCallId, - foundStatus: status, - redisMessage: message, - pollDuration: Date.now() - startTime, - redisKey: `tool_call:${toolCallId}`, - }) - } - - return { status, message, fullData } - } - - // Wait before next poll - await new Promise((resolve) => setTimeout(resolve, pollInterval)) - } catch (error) { - logger.error('Error polling Redis for tool call status', { - toolCallId, - error: error instanceof Error ? error.message : 'Unknown error', - }) - return null - } - } - - logger.warn('Tool call polling timed out', { - toolCallId, - timeout, - }) - return null -} - -/** - * Handle tool calls that require user interruption/approval - * Returns { approved: boolean, rejected: boolean, error?: boolean, message?: string } to distinguish between rejection, timeout, and error - */ -async function interruptHandler(toolCallId: string): Promise<{ - approved: boolean - rejected: boolean - error?: boolean - message?: string - fullData?: any -}> { - if (!toolCallId) { - logger.error('interruptHandler: No tool call ID provided') - return { approved: false, rejected: false, error: true, message: 'No tool call ID provided' } - } - - logger.info('Starting interrupt handler for tool call', { toolCallId }) - - try { - // Step 1: Add tool to Redis with 'pending' status - await addToolToRedis(toolCallId) - - // Step 2: Poll Redis for status update - const result = await pollRedisForTool(toolCallId) - - if (!result) { - logger.error('Failed to get tool call status or timed out', { toolCallId }) - return { approved: false, rejected: false } - } - - const { status, message, fullData } = result - - if (status === 'rejected') { - logger.info('Tool execution rejected by user', { toolCallId, message }) - return { approved: false, rejected: true, message, fullData } - } - - if (status === 'accepted') { - logger.info('Tool execution approved by user', { toolCallId, message }) - return { approved: true, rejected: false, message, fullData } - } - - if (status === 'error') { - logger.error('Tool execution failed with error', { toolCallId, message }) - return { approved: false, rejected: false, error: true, message, fullData } - } - - if (status === 'background') { - logger.info('Tool execution moved to background', { toolCallId, message }) - return { approved: true, rejected: false, message, fullData } - } - - if (status === 'success') { - logger.info('Tool execution completed successfully', { toolCallId, message }) - return { approved: true, rejected: false, message, fullData } - } - - logger.warn('Unexpected tool call status', { toolCallId, status, message }) - return { - approved: false, - rejected: false, - error: true, - message: `Unexpected tool call status: ${status}`, - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.error('Error in interrupt handler', { - toolCallId, - error: errorMessage, - }) - return { - approved: false, - rejected: false, - error: true, - message: `Interrupt handler error: ${errorMessage}`, - } - } -} - -const MethodExecutionSchema = z.object({ - methodId: z.string().min(1, 'Method ID is required'), - params: z.record(z.any()).optional().default({}), - toolCallId: z.string().nullable().optional().default(null), -}) - -/** - * POST /api/copilot/methods - * Execute a method based on methodId with internal API key auth - */ -export async function POST(req: NextRequest) { - const requestId = crypto.randomUUID() - const startTime = Date.now() - - try { - // Evaluate both auth schemes; pass if either is valid - const internalAuth = checkInternalApiKey(req) - const copilotAuth = checkCopilotApiKey(req) - const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success) - if (!isAuthenticated) { - const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed' - return NextResponse.json(createErrorResponse(errorMessage), { - status: 401, - }) - } - - const body = await req.json() - const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body) - - logger.info(`[${requestId}] Method execution request`, { - methodId, - toolCallId, - hasParams: !!params && Object.keys(params).length > 0, - }) - - // Check if tool exists in registry - if (!copilotToolRegistry.has(methodId)) { - logger.error(`[${requestId}] Tool not found in registry: ${methodId}`, { - methodId, - toolCallId, - availableTools: copilotToolRegistry.getAvailableIds(), - registrySize: copilotToolRegistry.getAvailableIds().length, - }) - return NextResponse.json( - createErrorResponse( - `Unknown method: ${methodId}. Available methods: ${copilotToolRegistry.getAvailableIds().join(', ')}` - ), - { status: 400 } - ) - } - - logger.info(`[${requestId}] Tool found in registry: ${methodId}`, { - toolCallId, - }) - - // Check if the tool requires interrupt/approval - const tool = copilotToolRegistry.get(methodId) - if (tool?.requiresInterrupt) { - if (!toolCallId) { - logger.warn(`[${requestId}] Tool requires interrupt but no toolCallId provided`, { - methodId, - }) - return NextResponse.json( - createErrorResponse('This tool requires approval but no tool call ID was provided'), - { status: 400 } - ) - } - - logger.info(`[${requestId}] Tool requires interrupt, starting approval process`, { - methodId, - toolCallId, - }) - - // Handle interrupt flow - const { approved, rejected, error, message, fullData } = await interruptHandler(toolCallId) - - if (rejected) { - logger.info(`[${requestId}] Tool execution rejected by user`, { - methodId, - toolCallId, - message, - }) - return NextResponse.json( - createErrorResponse( - 'The user decided to skip running this tool. This was a user decision.' - ), - { status: 200 } // Changed to 200 - user rejection is a valid response - ) - } - - if (error) { - logger.error(`[${requestId}] Tool execution failed with error`, { - methodId, - toolCallId, - message, - }) - return NextResponse.json( - createErrorResponse(message || 'Tool execution failed with unknown error'), - { status: 500 } // 500 Internal Server Error - ) - } - - if (!approved) { - logger.warn(`[${requestId}] Tool execution timed out`, { - methodId, - toolCallId, - }) - return NextResponse.json( - createErrorResponse('Tool execution request timed out'), - { status: 408 } // 408 Request Timeout - ) - } - - logger.info(`[${requestId}] Tool execution approved by user`, { - methodId, - toolCallId, - message, - }) - - // For tools that need confirmation data, pass the message and/or fullData as parameters - if (message) { - params.confirmationMessage = message - } - if (fullData) { - params.fullData = fullData - } - } - - // Execute the tool directly via registry - const result = await copilotToolRegistry.execute(methodId, params) - - logger.info(`[${requestId}] Tool execution result:`, { - methodId, - toolCallId, - success: result.success, - hasData: !!result.data, - hasError: !!result.error, - }) - - const duration = Date.now() - startTime - logger.info(`[${requestId}] Method execution completed: ${methodId}`, { - methodId, - toolCallId, - duration, - success: result.success, - }) - - return NextResponse.json(result) - } catch (error) { - const duration = Date.now() - startTime - - if (error instanceof z.ZodError) { - logger.error(`[${requestId}] Request validation error:`, { - duration, - errors: error.errors, - }) - return NextResponse.json( - createErrorResponse( - `Invalid request data: ${error.errors.map((e) => e.message).join(', ')}` - ), - { status: 400 } - ) - } - - logger.error(`[${requestId}] Unexpected error:`, { - duration, - error: error instanceof Error ? error.message : 'Unknown error', - stack: error instanceof Error ? error.stack : undefined, - }) - - return NextResponse.json( - createErrorResponse(error instanceof Error ? error.message : 'Internal server error'), - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/copilot/methods/utils.ts b/apps/sim/app/api/copilot/methods/utils.ts deleted file mode 100644 index 42d87f8f78..0000000000 --- a/apps/sim/app/api/copilot/methods/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { CopilotToolResponse } from '@/lib/copilot/tools/server-tools/base' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('CopilotMethodsUtils') - -/** - * Create a standardized error response - */ -export function createErrorResponse(error: string): CopilotToolResponse { - return { - success: false, - error, - } -} diff --git a/apps/sim/app/api/copilot/tools/mark-complete/route.ts b/apps/sim/app/api/copilot/tools/mark-complete/route.ts new file mode 100644 index 0000000000..12fe4b2dd3 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/mark-complete/route.ts @@ -0,0 +1,125 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { + authenticateCopilotRequestSessionOnly, + createBadRequestResponse, + createInternalServerErrorResponse, + createRequestTracker, + createUnauthorizedResponse, +} from '@/lib/copilot/auth' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent' + +const logger = createLogger('CopilotMarkToolCompleteAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT + +// Schema for mark-complete request +const MarkCompleteSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.number().int(), + message: z.any().optional(), + data: z.any().optional(), +}) + +/** + * POST /api/copilot/tools/mark-complete + * Proxy to Sim Agent: POST /api/tools/mark-complete + */ +export async function POST(req: NextRequest) { + const tracker = createRequestTracker() + + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const body = await req.json() + + // Log raw body shape for diagnostics (avoid dumping huge payloads) + try { + const bodyPreview = JSON.stringify(body).slice(0, 300) + logger.debug(`[${tracker.requestId}] Incoming mark-complete raw body preview`, { + preview: `${bodyPreview}${bodyPreview.length === 300 ? '...' : ''}`, + }) + } catch {} + + const parsed = MarkCompleteSchema.parse(body) + + const messagePreview = (() => { + try { + const s = + typeof parsed.message === 'string' ? parsed.message : JSON.stringify(parsed.message) + return s ? `${s.slice(0, 200)}${s.length > 200 ? '...' : ''}` : undefined + } catch { + return undefined + } + })() + + logger.info(`[${tracker.requestId}] Forwarding tool mark-complete`, { + userId, + toolCallId: parsed.id, + toolName: parsed.name, + status: parsed.status, + hasMessage: parsed.message !== undefined, + hasData: parsed.data !== undefined, + messagePreview, + agentUrl: `${SIM_AGENT_API_URL}/api/tools/mark-complete`, + }) + + const agentRes = await fetch(`${SIM_AGENT_API_URL}/api/tools/mark-complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), + }, + body: JSON.stringify(parsed), + }) + + // Attempt to parse agent response JSON + let agentJson: any = null + let agentText: string | null = null + try { + agentJson = await agentRes.json() + } catch (_) { + try { + agentText = await agentRes.text() + } catch {} + } + + logger.info(`[${tracker.requestId}] Agent responded to mark-complete`, { + status: agentRes.status, + ok: agentRes.ok, + responseJsonPreview: agentJson ? JSON.stringify(agentJson).slice(0, 300) : undefined, + responseTextPreview: agentText ? agentText.slice(0, 300) : undefined, + }) + + if (agentRes.ok) { + return NextResponse.json({ success: true }) + } + + const errorMessage = + agentJson?.error || agentText || `Agent responded with status ${agentRes.status}` + const status = agentRes.status >= 500 ? 500 : 400 + + logger.warn(`[${tracker.requestId}] Mark-complete failed`, { + status, + error: errorMessage, + }) + + return NextResponse.json({ success: false, error: errorMessage }, { status }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${tracker.requestId}] Invalid mark-complete request body`, { + issues: error.issues, + }) + return createBadRequestResponse('Invalid request body for mark-complete') + } + logger.error(`[${tracker.requestId}] Failed to proxy mark-complete:`, error) + return createInternalServerErrorResponse('Failed to mark tool as complete') + } +} diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 96d1d6ed32..08dfae0682 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -213,24 +213,81 @@ function createUserFriendlyErrorMessage( } /** - * Resolves environment variables and tags in code - * @param code - Code with variables - * @param params - Parameters that may contain variable values - * @param envVars - Environment variables from the workflow - * @returns Resolved code + * Resolves workflow variables with syntax */ +function resolveWorkflowVariables( + code: string, + workflowVariables: Record, + contextVariables: Record +): string { + let resolvedCode = code -function resolveCodeVariables( + const variableMatches = resolvedCode.match(/]+)>/g) || [] + for (const match of variableMatches) { + const variableName = match.slice(' (variable.name || '').replace(/\s+/g, '') === variableName + ) + + if (foundVariable) { + const variable = foundVariable[1] + // Get the typed value - handle different variable types + let variableValue = variable.value + + if (variable.value !== undefined && variable.value !== null) { + try { + // Handle 'string' type the same as 'plain' for backward compatibility + const type = variable.type === 'string' ? 'plain' : variable.type + + // For plain text, use exactly what's entered without modifications + if (type === 'plain' && typeof variableValue === 'string') { + // Use as-is for plain text + } else if (type === 'number') { + variableValue = Number(variableValue) + } else if (type === 'boolean') { + variableValue = variableValue === 'true' || variableValue === true + } else if (type === 'json') { + try { + variableValue = + typeof variableValue === 'string' ? JSON.parse(variableValue) : variableValue + } catch { + // Keep original value if JSON parsing fails + } + } + } catch (error) { + // Fallback to original value on error + variableValue = variable.value + } + } + + // Create a safe variable reference + const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}` + contextVariables[safeVarName] = variableValue + + // Replace the variable reference with the safe variable name + resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName) + } else { + // Variable not found - replace with empty string to avoid syntax errors + resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), '') + } + } + + return resolvedCode +} + +/** + * Resolves environment variables with {{var_name}} syntax + */ +function resolveEnvironmentVariables( code: string, params: Record, - envVars: Record = {}, - blockData: Record = {}, - blockNameMapping: Record = {} -): { resolvedCode: string; contextVariables: Record } { + envVars: Record, + contextVariables: Record +): string { let resolvedCode = code - const contextVariables: Record = {} - // Resolve environment variables with {{var_name}} syntax const envVarMatches = resolvedCode.match(/\{\{([^}]+)\}\}/g) || [] for (const match of envVarMatches) { const varName = match.slice(2, -2).trim() @@ -245,7 +302,21 @@ function resolveCodeVariables( resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName) } - // Resolve tags with syntax (including nested paths like ) + return resolvedCode +} + +/** + * Resolves tags with syntax (including nested paths like ) + */ +function resolveTagVariables( + code: string, + params: Record, + blockData: Record, + blockNameMapping: Record, + contextVariables: Record +): string { + let resolvedCode = code + const tagMatches = resolvedCode.match(/<([a-zA-Z_][a-zA-Z0-9_.]*[a-zA-Z0-9_])>/g) || [] for (const match of tagMatches) { @@ -300,6 +371,42 @@ function resolveCodeVariables( resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName) } + return resolvedCode +} + +/** + * Resolves environment variables and tags in code + * @param code - Code with variables + * @param params - Parameters that may contain variable values + * @param envVars - Environment variables from the workflow + * @returns Resolved code + */ +function resolveCodeVariables( + code: string, + params: Record, + envVars: Record = {}, + blockData: Record = {}, + blockNameMapping: Record = {}, + workflowVariables: Record = {} +): { resolvedCode: string; contextVariables: Record } { + let resolvedCode = code + const contextVariables: Record = {} + + // Resolve workflow variables with syntax first + resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables) + + // Resolve environment variables with {{var_name}} syntax + resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables) + + // Resolve tags with syntax (including nested paths like ) + resolvedCode = resolveTagVariables( + resolvedCode, + params, + blockData, + blockNameMapping, + contextVariables + ) + return { resolvedCode, contextVariables } } @@ -338,6 +445,7 @@ export async function POST(req: NextRequest) { envVars = {}, blockData = {}, blockNameMapping = {}, + workflowVariables = {}, workflowId, isCustomTool = false, } = body @@ -360,7 +468,8 @@ export async function POST(req: NextRequest) { executionParams, envVars, blockData, - blockNameMapping + blockNameMapping, + workflowVariables ) resolvedCode = codeResolution.resolvedCode const contextVariables = codeResolution.contextVariables @@ -368,8 +477,8 @@ export async function POST(req: NextRequest) { const executionMethod = 'vm' // Default execution method logger.info(`[${requestId}] Using VM for code execution`, { - resolvedCode, hasEnvVars: Object.keys(envVars).length > 0, + hasWorkflowVariables: Object.keys(workflowVariables).length > 0, }) // Create a secure context with console logging diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 7ef3460a68..a166b713cf 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -73,30 +73,59 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + // Conditionally select columns based on detail level to optimize performance + const selectColumns = + params.details === 'full' + ? { + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, // Large field - only in full mode + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, // Large field - only in full mode + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + } + : { + // Basic mode - exclude large fields for better performance + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: sql`NULL`, // Exclude large execution data in basic mode + cost: workflowExecutionLogs.cost, + files: sql`NULL`, // Exclude files in basic mode + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + } + const baseQuery = db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - }) + .select(selectColumns) .from(workflowExecutionLogs) .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin( @@ -276,18 +305,24 @@ export async function GET(request: NextRequest) { const enhancedLogs = logs.map((log) => { const blockExecutions = blockExecutionsByExecution[log.executionId] || [] - // Use stored trace spans if available, otherwise create from block executions - const storedTraceSpans = (log.executionData as any)?.traceSpans - const traceSpans = - storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0 - ? storedTraceSpans - : createTraceSpans(blockExecutions) - - // Prefer stored cost JSON; otherwise synthesize from blocks - const costSummary = - log.cost && Object.keys(log.cost as any).length > 0 - ? (log.cost as any) - : extractCostSummary(blockExecutions) + // Only process trace spans and detailed cost in full mode + let traceSpans = [] + let costSummary = (log.cost as any) || { total: 0 } + + if (params.details === 'full' && log.executionData) { + // Use stored trace spans if available, otherwise create from block executions + const storedTraceSpans = (log.executionData as any)?.traceSpans + traceSpans = + storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0 + ? storedTraceSpans + : createTraceSpans(blockExecutions) + + // Prefer stored cost JSON; otherwise synthesize from blocks + costSummary = + log.cost && Object.keys(log.cost as any).length > 0 + ? (log.cost as any) + : extractCostSummary(blockExecutions) + } const workflowSummary = { id: log.workflowId, diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index ba82322968..dc324edb9a 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -1,6 +1,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { getUserUsageData } from '@/lib/billing/core/usage' import { createLogger } from '@/lib/logs/console/logger' import { db } from '@/db' import { member, user, userStats } from '@/db/schema' @@ -80,8 +81,6 @@ export async function GET( .select({ currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, - billingPeriodStart: userStats.billingPeriodStart, - billingPeriodEnd: userStats.billingPeriodEnd, usageLimitSetBy: userStats.usageLimitSetBy, usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, lastPeriodCost: userStats.lastPeriodCost, @@ -90,11 +89,22 @@ export async function GET( .where(eq(userStats.userId, memberId)) .limit(1) + const computed = await getUserUsageData(memberId) + if (usageData.length > 0) { memberData = { ...memberData, - usage: usageData[0], - } as typeof memberData & { usage: (typeof usageData)[0] } + usage: { + ...usageData[0], + billingPeriodStart: computed.billingPeriodStart, + billingPeriodEnd: computed.billingPeriodEnd, + }, + } as typeof memberData & { + usage: (typeof usageData)[0] & { + billingPeriodStart: Date | null + billingPeriodEnd: Date | null + } + } } } diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 9a2cdf9952..9ae87b15c6 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -3,6 +3,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email' import { getSession } from '@/lib/auth' +import { getUserUsageData } from '@/lib/billing/core/usage' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { sendEmail } from '@/lib/email/mailer' import { quickValidateEmail } from '@/lib/email/validation' @@ -63,7 +64,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ // Include usage data if requested and user has admin access if (includeUsage && hasAdminAccess) { - const membersWithUsage = await db + const base = await db .select({ id: member.id, userId: member.userId, @@ -74,8 +75,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ userEmail: user.email, currentPeriodCost: userStats.currentPeriodCost, currentUsageLimit: userStats.currentUsageLimit, - billingPeriodStart: userStats.billingPeriodStart, - billingPeriodEnd: userStats.billingPeriodEnd, usageLimitSetBy: userStats.usageLimitSetBy, usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, }) @@ -84,6 +83,17 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .leftJoin(userStats, eq(user.id, userStats.userId)) .where(eq(member.organizationId, organizationId)) + const membersWithUsage = await Promise.all( + base.map(async (row) => { + const usage = await getUserUsageData(row.userId) + return { + ...row, + billingPeriodStart: usage.billingPeriodStart, + billingPeriodEnd: usage.billingPeriodEnd, + } + }) + ) + return NextResponse.json({ success: true, data: membersWithUsage, diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index 17d37a4f38..8aa62f7e71 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -39,6 +39,9 @@ export async function POST(request: NextRequest) { stream, messages, environmentVariables, + workflowVariables, + blockData, + blockNameMapping, reasoningEffort, verbosity, } = body @@ -60,6 +63,7 @@ export async function POST(request: NextRequest) { messageCount: messages?.length || 0, hasEnvironmentVariables: !!environmentVariables && Object.keys(environmentVariables).length > 0, + hasWorkflowVariables: !!workflowVariables && Object.keys(workflowVariables).length > 0, reasoningEffort, verbosity, }) @@ -103,6 +107,9 @@ export async function POST(request: NextRequest) { stream, messages, environmentVariables, + workflowVariables, + blockData, + blockNameMapping, reasoningEffort, verbosity, }) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 38dc7802e1..2835e42d57 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -474,8 +474,10 @@ export async function GET() { }) await loggingSession.safeCompleteWithError({ - message: `Schedule execution failed before workflow started: ${earlyError.message}`, - stackTrace: earlyError.stack, + error: { + message: `Schedule execution failed before workflow started: ${earlyError.message}`, + stackTrace: earlyError.stack, + }, }) } catch (loggingError) { logger.error( @@ -591,8 +593,10 @@ export async function GET() { }) await failureLoggingSession.safeCompleteWithError({ - message: `Schedule execution failed: ${error.message}`, - stackTrace: error.stack, + error: { + message: `Schedule execution failed: ${error.message}`, + stackTrace: error.stack, + }, }) } catch (loggingError) { logger.error( diff --git a/apps/sim/app/api/tools/mysql/delete/route.ts b/apps/sim/app/api/tools/mysql/delete/route.ts new file mode 100644 index 0000000000..d473dae9df --- /dev/null +++ b/apps/sim/app/api/tools/mysql/delete/route.ts @@ -0,0 +1,67 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' + +const logger = createLogger('MySQLDeleteAPI') + +const DeleteSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + table: z.string().min(1, 'Table name is required'), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = DeleteSchema.parse(body) + + logger.info( + `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const connection = await createMySQLConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const { query, values } = buildDeleteQuery(params.table, params.where) + const result = await executeQuery(connection, query, values) + + logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`) + + return NextResponse.json({ + message: `Data deleted successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await connection.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] MySQL delete failed:`, error) + + return NextResponse.json({ error: `MySQL delete failed: ${errorMessage}` }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/mysql/execute/route.ts b/apps/sim/app/api/tools/mysql/execute/route.ts new file mode 100644 index 0000000000..30d59025c9 --- /dev/null +++ b/apps/sim/app/api/tools/mysql/execute/route.ts @@ -0,0 +1,75 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils' + +const logger = createLogger('MySQLExecuteAPI') + +const ExecuteSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + query: z.string().min(1, 'Query is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = ExecuteSchema.parse(body) + + logger.info( + `[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}` + ) + + // Validate query before execution + const validation = validateQuery(params.query) + if (!validation.isValid) { + logger.warn(`[${requestId}] Query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + const connection = await createMySQLConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const result = await executeQuery(connection, params.query) + + logger.info(`[${requestId}] SQL executed successfully, ${result.rowCount} row(s) affected`) + + return NextResponse.json({ + message: `SQL executed successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await connection.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] MySQL execute failed:`, error) + + return NextResponse.json({ error: `MySQL execute failed: ${errorMessage}` }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/mysql/insert/route.ts b/apps/sim/app/api/tools/mysql/insert/route.ts new file mode 100644 index 0000000000..497d8cf5fc --- /dev/null +++ b/apps/sim/app/api/tools/mysql/insert/route.ts @@ -0,0 +1,91 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' + +const logger = createLogger('MySQLInsertAPI') + +const InsertSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + table: z.string().min(1, 'Table name is required'), + data: z.union([ + z + .record(z.unknown()) + .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), + z + .string() + .min(1) + .transform((str) => { + try { + const parsed = JSON.parse(str) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Data must be a JSON object') + } + return parsed + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + throw new Error( + `Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...` + ) + } + }), + ]), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + + logger.info(`[${requestId}] Received data field type: ${typeof body.data}, value:`, body.data) + + const params = InsertSchema.parse(body) + + logger.info( + `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const connection = await createMySQLConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const { query, values } = buildInsertQuery(params.table, params.data) + const result = await executeQuery(connection, query, values) + + logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`) + + return NextResponse.json({ + message: `Data inserted successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await connection.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] MySQL insert failed:`, error) + + return NextResponse.json({ error: `MySQL insert failed: ${errorMessage}` }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/mysql/query/route.ts b/apps/sim/app/api/tools/mysql/query/route.ts new file mode 100644 index 0000000000..56b6f2960d --- /dev/null +++ b/apps/sim/app/api/tools/mysql/query/route.ts @@ -0,0 +1,75 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils' + +const logger = createLogger('MySQLQueryAPI') + +const QuerySchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + query: z.string().min(1, 'Query is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = QuerySchema.parse(body) + + logger.info( + `[${requestId}] Executing MySQL query on ${params.host}:${params.port}/${params.database}` + ) + + // Validate query before execution + const validation = validateQuery(params.query) + if (!validation.isValid) { + logger.warn(`[${requestId}] Query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + const connection = await createMySQLConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const result = await executeQuery(connection, params.query) + + logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`) + + return NextResponse.json({ + message: `Query executed successfully. ${result.rowCount} row(s) returned.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await connection.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] MySQL query failed:`, error) + + return NextResponse.json({ error: `MySQL query failed: ${errorMessage}` }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/mysql/update/route.ts b/apps/sim/app/api/tools/mysql/update/route.ts new file mode 100644 index 0000000000..dcf5fd5075 --- /dev/null +++ b/apps/sim/app/api/tools/mysql/update/route.ts @@ -0,0 +1,86 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' + +const logger = createLogger('MySQLUpdateAPI') + +const UpdateSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + table: z.string().min(1, 'Table name is required'), + data: z.union([ + z + .record(z.unknown()) + .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), + z + .string() + .min(1) + .transform((str) => { + try { + const parsed = JSON.parse(str) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Data must be a JSON object') + } + return parsed + } catch (e) { + throw new Error('Invalid JSON format in data field') + } + }), + ]), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = UpdateSchema.parse(body) + + logger.info( + `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const connection = await createMySQLConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const { query, values } = buildUpdateQuery(params.table, params.data, params.where) + const result = await executeQuery(connection, query, values) + + logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`) + + return NextResponse.json({ + message: `Data updated successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await connection.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] MySQL update failed:`, error) + + return NextResponse.json({ error: `MySQL update failed: ${errorMessage}` }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/mysql/utils.ts b/apps/sim/app/api/tools/mysql/utils.ts new file mode 100644 index 0000000000..29d84339f5 --- /dev/null +++ b/apps/sim/app/api/tools/mysql/utils.ts @@ -0,0 +1,159 @@ +import mysql from 'mysql2/promise' + +export interface MySQLConnectionConfig { + host: string + port: number + database: string + username: string + password: string + ssl?: string +} + +export async function createMySQLConnection(config: MySQLConnectionConfig) { + const connectionConfig: mysql.ConnectionOptions = { + host: config.host, + port: config.port, + database: config.database, + user: config.username, + password: config.password, + } + + // Handle SSL configuration + if (config.ssl === 'required') { + connectionConfig.ssl = { rejectUnauthorized: true } + } else if (config.ssl === 'preferred') { + connectionConfig.ssl = { rejectUnauthorized: false } + } + // For 'disabled', we don't set the ssl property at all + + return mysql.createConnection(connectionConfig) +} + +export async function executeQuery( + connection: mysql.Connection, + query: string, + values?: unknown[] +) { + const [rows, fields] = await connection.execute(query, values) + + if (Array.isArray(rows)) { + return { + rows: rows as unknown[], + rowCount: rows.length, + fields, + } + } + + return { + rows: [], + rowCount: (rows as mysql.ResultSetHeader).affectedRows || 0, + fields, + } +} + +export function validateQuery(query: string): { isValid: boolean; error?: string } { + const trimmedQuery = query.trim().toLowerCase() + + // Block dangerous SQL operations + const dangerousPatterns = [ + /drop\s+database/i, + /drop\s+schema/i, + /drop\s+user/i, + /create\s+user/i, + /grant\s+/i, + /revoke\s+/i, + /alter\s+user/i, + /set\s+global/i, + /set\s+session/i, + /load\s+data/i, + /into\s+outfile/i, + /into\s+dumpfile/i, + /load_file\s*\(/i, + /system\s+/i, + /exec\s+/i, + /execute\s+immediate/i, + /xp_cmdshell/i, + /sp_configure/i, + /information_schema\.tables/i, + /mysql\.user/i, + /mysql\.db/i, + /mysql\.host/i, + /performance_schema/i, + /sys\./i, + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(query)) { + return { + isValid: false, + error: `Query contains potentially dangerous operation: ${pattern.source}`, + } + } + } + + // Only allow specific statement types for execute endpoint + const allowedStatements = /^(select|insert|update|delete|with|show|describe|explain)\s+/i + if (!allowedStatements.test(trimmedQuery)) { + return { + isValid: false, + error: + 'Only SELECT, INSERT, UPDATE, DELETE, WITH, SHOW, DESCRIBE, and EXPLAIN statements are allowed', + } + } + + return { isValid: true } +} + +export function buildInsertQuery(table: string, data: Record) { + const sanitizedTable = sanitizeIdentifier(table) + const columns = Object.keys(data) + const values = Object.values(data) + const placeholders = columns.map(() => '?').join(', ') + + const query = `INSERT INTO ${sanitizedTable} (${columns.map(sanitizeIdentifier).join(', ')}) VALUES (${placeholders})` + + return { query, values } +} + +export function buildUpdateQuery(table: string, data: Record, where: string) { + const sanitizedTable = sanitizeIdentifier(table) + const columns = Object.keys(data) + const values = Object.values(data) + + const setClause = columns.map((col) => `${sanitizeIdentifier(col)} = ?`).join(', ') + const query = `UPDATE ${sanitizedTable} SET ${setClause} WHERE ${where}` + + return { query, values } +} + +export function buildDeleteQuery(table: string, where: string) { + const sanitizedTable = sanitizeIdentifier(table) + const query = `DELETE FROM ${sanitizedTable} WHERE ${where}` + + return { query, values: [] } +} + +export function sanitizeIdentifier(identifier: string): string { + // Handle schema.table format + if (identifier.includes('.')) { + const parts = identifier.split('.') + return parts.map((part) => sanitizeSingleIdentifier(part)).join('.') + } + + return sanitizeSingleIdentifier(identifier) +} + +function sanitizeSingleIdentifier(identifier: string): string { + // Remove any existing backticks to prevent double-escaping + const cleaned = identifier.replace(/`/g, '') + + // Validate identifier contains only safe characters + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error( + `Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.` + ) + } + + // Wrap in backticks for MySQL + return `\`${cleaned}\`` +} diff --git a/apps/sim/app/api/tools/postgresql/delete/route.ts b/apps/sim/app/api/tools/postgresql/delete/route.ts new file mode 100644 index 0000000000..da13eabb5a --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/delete/route.ts @@ -0,0 +1,74 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + buildDeleteQuery, + createPostgresConnection, + executeQuery, +} from '@/app/api/tools/postgresql/utils' + +const logger = createLogger('PostgreSQLDeleteAPI') + +const DeleteSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + table: z.string().min(1, 'Table name is required'), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = DeleteSchema.parse(body) + + logger.info( + `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const client = await createPostgresConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const { query, values } = buildDeleteQuery(params.table, params.where) + const result = await executeQuery(client, query, values) + + logger.info(`[${requestId}] Delete executed successfully, ${result.rowCount} row(s) deleted`) + + return NextResponse.json({ + message: `Data deleted successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await client.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] PostgreSQL delete failed:`, error) + + return NextResponse.json( + { error: `PostgreSQL delete failed: ${errorMessage}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/postgresql/execute/route.ts b/apps/sim/app/api/tools/postgresql/execute/route.ts new file mode 100644 index 0000000000..a1eeb247d5 --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/execute/route.ts @@ -0,0 +1,82 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + createPostgresConnection, + executeQuery, + validateQuery, +} from '@/app/api/tools/postgresql/utils' + +const logger = createLogger('PostgreSQLExecuteAPI') + +const ExecuteSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + query: z.string().min(1, 'Query is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = ExecuteSchema.parse(body) + + logger.info( + `[${requestId}] Executing raw SQL on ${params.host}:${params.port}/${params.database}` + ) + + // Validate query before execution + const validation = validateQuery(params.query) + if (!validation.isValid) { + logger.warn(`[${requestId}] Query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + const client = await createPostgresConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const result = await executeQuery(client, params.query) + + logger.info(`[${requestId}] SQL executed successfully, ${result.rowCount} row(s) affected`) + + return NextResponse.json({ + message: `SQL executed successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await client.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] PostgreSQL execute failed:`, error) + + return NextResponse.json( + { error: `PostgreSQL execute failed: ${errorMessage}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/postgresql/insert/route.ts b/apps/sim/app/api/tools/postgresql/insert/route.ts new file mode 100644 index 0000000000..aa8cffaf60 --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/insert/route.ts @@ -0,0 +1,99 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + buildInsertQuery, + createPostgresConnection, + executeQuery, +} from '@/app/api/tools/postgresql/utils' + +const logger = createLogger('PostgreSQLInsertAPI') + +const InsertSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + table: z.string().min(1, 'Table name is required'), + data: z.union([ + z + .record(z.unknown()) + .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), + z + .string() + .min(1) + .transform((str) => { + try { + const parsed = JSON.parse(str) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Data must be a JSON object') + } + return parsed + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error' + throw new Error( + `Invalid JSON format in data field: ${errorMsg}. Received: ${str.substring(0, 100)}...` + ) + } + }), + ]), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + + // Debug: Log the data field to see what we're getting + logger.info(`[${requestId}] Received data field type: ${typeof body.data}, value:`, body.data) + + const params = InsertSchema.parse(body) + + logger.info( + `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const client = await createPostgresConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const { query, values } = buildInsertQuery(params.table, params.data) + const result = await executeQuery(client, query, values) + + logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`) + + return NextResponse.json({ + message: `Data inserted successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await client.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] PostgreSQL insert failed:`, error) + + return NextResponse.json( + { error: `PostgreSQL insert failed: ${errorMessage}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/postgresql/query/route.ts b/apps/sim/app/api/tools/postgresql/query/route.ts new file mode 100644 index 0000000000..88dc9be1f3 --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/query/route.ts @@ -0,0 +1,65 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils' + +const logger = createLogger('PostgreSQLQueryAPI') + +const QuerySchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + query: z.string().min(1, 'Query is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = QuerySchema.parse(body) + + logger.info( + `[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}` + ) + + const client = await createPostgresConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const result = await executeQuery(client, params.query) + + logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`) + + return NextResponse.json({ + message: `Query executed successfully. ${result.rowCount} row(s) returned.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await client.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] PostgreSQL query failed:`, error) + + return NextResponse.json({ error: `PostgreSQL query failed: ${errorMessage}` }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/postgresql/update/route.ts b/apps/sim/app/api/tools/postgresql/update/route.ts new file mode 100644 index 0000000000..fe66167274 --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/update/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + buildUpdateQuery, + createPostgresConnection, + executeQuery, +} from '@/app/api/tools/postgresql/utils' + +const logger = createLogger('PostgreSQLUpdateAPI') + +const UpdateSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + ssl: z.enum(['disabled', 'required', 'preferred']).default('required'), + table: z.string().min(1, 'Table name is required'), + data: z.union([ + z + .record(z.unknown()) + .refine((obj) => Object.keys(obj).length > 0, 'Data object cannot be empty'), + z + .string() + .min(1) + .transform((str) => { + try { + const parsed = JSON.parse(str) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Data must be a JSON object') + } + return parsed + } catch (e) { + throw new Error('Invalid JSON format in data field') + } + }), + ]), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const params = UpdateSchema.parse(body) + + logger.info( + `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const client = await createPostgresConnection({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + ssl: params.ssl, + }) + + try { + const { query, values } = buildUpdateQuery(params.table, params.data, params.where) + const result = await executeQuery(client, query, values) + + logger.info(`[${requestId}] Update executed successfully, ${result.rowCount} row(s) updated`) + + return NextResponse.json({ + message: `Data updated successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } finally { + await client.end() + } + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] PostgreSQL update failed:`, error) + + return NextResponse.json( + { error: `PostgreSQL update failed: ${errorMessage}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts new file mode 100644 index 0000000000..6d655da026 --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -0,0 +1,173 @@ +import { Client } from 'pg' +import type { PostgresConnectionConfig } from '@/tools/postgresql/types' + +export async function createPostgresConnection(config: PostgresConnectionConfig): Promise { + const client = new Client({ + host: config.host, + port: config.port, + database: config.database, + user: config.username, + password: config.password, + ssl: + config.ssl === 'disabled' + ? false + : config.ssl === 'required' + ? true + : config.ssl === 'preferred' + ? { rejectUnauthorized: false } + : false, + connectionTimeoutMillis: 10000, // 10 seconds + query_timeout: 30000, // 30 seconds + }) + + try { + await client.connect() + return client + } catch (error) { + await client.end() + throw error + } +} + +export async function executeQuery( + client: Client, + query: string, + params: unknown[] = [] +): Promise<{ rows: unknown[]; rowCount: number }> { + const result = await client.query(query, params) + return { + rows: result.rows || [], + rowCount: result.rowCount || 0, + } +} + +export function validateQuery(query: string): { isValid: boolean; error?: string } { + const trimmedQuery = query.trim().toLowerCase() + + // Block dangerous SQL operations + const dangerousPatterns = [ + /drop\s+database/i, + /drop\s+schema/i, + /drop\s+user/i, + /create\s+user/i, + /create\s+role/i, + /grant\s+/i, + /revoke\s+/i, + /alter\s+user/i, + /alter\s+role/i, + /set\s+role/i, + /reset\s+role/i, + /copy\s+.*from/i, + /copy\s+.*to/i, + /lo_import/i, + /lo_export/i, + /pg_read_file/i, + /pg_write_file/i, + /pg_ls_dir/i, + /information_schema\.tables/i, + /pg_catalog/i, + /pg_user/i, + /pg_shadow/i, + /pg_roles/i, + /pg_authid/i, + /pg_stat_activity/i, + /dblink/i, + /\\\\copy/i, + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(query)) { + return { + isValid: false, + error: `Query contains potentially dangerous operation: ${pattern.source}`, + } + } + } + + // Only allow specific statement types for execute endpoint + const allowedStatements = /^(select|insert|update|delete|with|explain|analyze|show)\s+/i + if (!allowedStatements.test(trimmedQuery)) { + return { + isValid: false, + error: + 'Only SELECT, INSERT, UPDATE, DELETE, WITH, EXPLAIN, ANALYZE, and SHOW statements are allowed', + } + } + + return { isValid: true } +} + +export function sanitizeIdentifier(identifier: string): string { + // Handle schema.table format + if (identifier.includes('.')) { + const parts = identifier.split('.') + return parts.map((part) => sanitizeSingleIdentifier(part)).join('.') + } + + return sanitizeSingleIdentifier(identifier) +} + +function sanitizeSingleIdentifier(identifier: string): string { + // Remove any existing double quotes to prevent double-escaping + const cleaned = identifier.replace(/"/g, '') + + // Validate identifier contains only safe characters + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error( + `Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.` + ) + } + + // Wrap in double quotes for PostgreSQL + return `"${cleaned}"` +} + +export function buildInsertQuery( + table: string, + data: Record +): { + query: string + values: unknown[] +} { + const sanitizedTable = sanitizeIdentifier(table) + const columns = Object.keys(data) + const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col)) + const placeholders = columns.map((_, index) => `$${index + 1}`) + const values = columns.map((col) => data[col]) + + const query = `INSERT INTO ${sanitizedTable} (${sanitizedColumns.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *` + + return { query, values } +} + +export function buildUpdateQuery( + table: string, + data: Record, + where: string +): { + query: string + values: unknown[] +} { + const sanitizedTable = sanitizeIdentifier(table) + const columns = Object.keys(data) + const sanitizedColumns = columns.map((col) => sanitizeIdentifier(col)) + const setClause = sanitizedColumns.map((col, index) => `${col} = $${index + 1}`).join(', ') + const values = columns.map((col) => data[col]) + + const query = `UPDATE ${sanitizedTable} SET ${setClause} WHERE ${where} RETURNING *` + + return { query, values } +} + +export function buildDeleteQuery( + table: string, + where: string +): { + query: string + values: unknown[] +} { + const sanitizedTable = sanitizeIdentifier(table) + const query = `DELETE FROM ${sanitizedTable} WHERE ${where} RETURNING *` + + return { query, values: [] } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx index e750496d82..e03f211618 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx @@ -1,4 +1,4 @@ -import { Check, Eye, X } from 'lucide-react' +import { Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { createLogger } from '@/lib/logs/console/logger' import { useCopilotStore } from '@/stores/copilot/store' @@ -201,6 +201,34 @@ export function DiffControls() { logger.warn('Failed to clear preview YAML:', error) }) + // Resolve target toolCallId for build/edit and update to terminal success state in the copilot store + try { + const { toolCallsById, messages } = useCopilotStore.getState() + let id: string | undefined + outer: for (let mi = messages.length - 1; mi >= 0; mi--) { + const m = messages[mi] + if (m.role !== 'assistant' || !m.contentBlocks) continue + const blocks = m.contentBlocks as any[] + for (let bi = blocks.length - 1; bi >= 0; bi--) { + const b = blocks[bi] + if (b?.type === 'tool_call') { + const tn = b.toolCall?.name + if (tn === 'build_workflow' || tn === 'edit_workflow') { + id = b.toolCall?.id + break outer + } + } + } + } + if (!id) { + const candidates = Object.values(toolCallsById).filter( + (t) => t.name === 'build_workflow' || t.name === 'edit_workflow' + ) + id = candidates.length ? candidates[candidates.length - 1].id : undefined + } + if (id) updatePreviewToolCallState('accepted', id) + } catch {} + // Accept changes without blocking the UI; errors will be logged by the store handler acceptChanges().catch((error) => { logger.error('Failed to accept changes (background):', error) @@ -224,6 +252,34 @@ export function DiffControls() { logger.warn('Failed to clear preview YAML:', error) }) + // Resolve target toolCallId for build/edit and update to terminal rejected state in the copilot store + try { + const { toolCallsById, messages } = useCopilotStore.getState() + let id: string | undefined + outer: for (let mi = messages.length - 1; mi >= 0; mi--) { + const m = messages[mi] + if (m.role !== 'assistant' || !m.contentBlocks) continue + const blocks = m.contentBlocks as any[] + for (let bi = blocks.length - 1; bi >= 0; bi--) { + const b = blocks[bi] + if (b?.type === 'tool_call') { + const tn = b.toolCall?.name + if (tn === 'build_workflow' || tn === 'edit_workflow') { + id = b.toolCall?.id + break outer + } + } + } + } + if (!id) { + const candidates = Object.values(toolCallsById).filter( + (t) => t.name === 'build_workflow' || t.name === 'edit_workflow' + ) + id = candidates.length ? candidates[candidates.length - 1].id : undefined + } + if (id) updatePreviewToolCallState('rejected', id) + } catch {} + // Reject changes optimistically rejectChanges().catch((error) => { logger.error('Failed to reject changes (background):', error) @@ -232,58 +288,39 @@ export function DiffControls() { return (
-
-
- {/* Info section */} -
-
- -
-
- - {isShowingDiff ? 'Viewing Proposed Changes' : 'Copilot has proposed changes'} - - {diffMetadata && ( - - Source: {diffMetadata.source} •{' '} - {new Date(diffMetadata.timestamp).toLocaleTimeString()} - - )} -
-
- - {/* Controls */} -
- {/* Toggle View Button */} - - - {/* Accept/Reject buttons - only show when viewing diff */} - {isShowingDiff && ( - <> - - - - )} -
-
+
+ {/* Toggle (left, icon-only, no background) */} + + + {/* Reject (middle, light gray, icon-only) */} + + + {/* Accept (right, brand purple, icon-only) */} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx index 65077809eb..f2b94de3d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx @@ -4,6 +4,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } import { ArrowDown, ArrowUp } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Notice } from '@/components/ui/notice' import { ScrollArea } from '@/components/ui/scroll-area' import { createLogger } from '@/lib/logs/console/logger' import { @@ -32,12 +33,11 @@ interface ChatFile { } interface ChatProps { - panelWidth: number chatMessage: string setChatMessage: (message: string) => void } -export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { +export function Chat({ chatMessage, setChatMessage }: ChatProps) { const { activeWorkflowId } = useWorkflowRegistry() const { @@ -63,6 +63,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { // File upload state const [chatFiles, setChatFiles] = useState([]) const [isUploadingFiles, setIsUploadingFiles] = useState(false) + const [uploadErrors, setUploadErrors] = useState([]) const [dragCounter, setDragCounter] = useState(0) const isDragOver = dragCounter > 0 // Scroll state @@ -280,11 +281,15 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { type: chatFile.type, file: chatFile.file, // Pass the actual File object })) + workflowInput.onUploadError = (message: string) => { + setUploadErrors((prev) => [...prev, message]) + } } // Clear input and files, refocus immediately setChatMessage('') setChatFiles([]) + setUploadErrors([]) focusInput(10) // Execute the workflow to generate a response @@ -560,14 +565,16 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { No messages yet
) : ( - -
- {workflowMessages.map((message) => ( - - ))} -
-
- +
+ +
+ {workflowMessages.map((message) => ( + + ))} +
+
+ +
)} {/* Scroll to bottom button */} @@ -615,26 +622,68 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) { const droppedFiles = Array.from(e.dataTransfer.files) if (droppedFiles.length > 0) { - const newFiles = droppedFiles.slice(0, 5 - chatFiles.length).map((file) => ({ - id: crypto.randomUUID(), - name: file.name, - size: file.size, - type: file.type, - file, - })) - setChatFiles([...chatFiles, ...newFiles]) + const remainingSlots = Math.max(0, 5 - chatFiles.length) + const candidateFiles = droppedFiles.slice(0, remainingSlots) + const errors: string[] = [] + const validNewFiles: ChatFile[] = [] + + for (const file of candidateFiles) { + if (file.size > 10 * 1024 * 1024) { + errors.push(`${file.name} is too large (max 10MB)`) + continue + } + + const isDuplicate = chatFiles.some( + (existingFile) => + existingFile.name === file.name && existingFile.size === file.size + ) + if (isDuplicate) { + errors.push(`${file.name} already added`) + continue + } + + validNewFiles.push({ + id: crypto.randomUUID(), + name: file.name, + size: file.size, + type: file.type, + file, + }) + } + + if (errors.length > 0) { + setUploadErrors(errors) + } + + if (validNewFiles.length > 0) { + setChatFiles([...chatFiles, ...validNewFiles]) + } } } }} > {/* File upload section */}
+ {uploadErrors.length > 0 && ( +
+ +
    + {uploadErrors.map((err, idx) => ( +
  • {err}
  • + ))} +
+
+
+ )} { + setChatFiles(files) + }} maxFiles={5} maxSize={10} disabled={!activeWorkflowId || isExecuting || isUploadingFiles} + onError={(errors) => setUploadErrors(errors)} />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-file-upload.tsx index f5d89319bc..d3e6518c9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-file-upload.tsx @@ -21,6 +21,7 @@ interface ChatFileUploadProps { maxSize?: number // in MB acceptedTypes?: string[] disabled?: boolean + onError?: (errors: string[]) => void } export function ChatFileUpload({ @@ -30,6 +31,7 @@ export function ChatFileUpload({ maxSize = 10, acceptedTypes = ['*'], disabled = false, + onError, }: ChatFileUploadProps) { const [isDragOver, setIsDragOver] = useState(false) const fileInputRef = useRef(null) @@ -91,7 +93,7 @@ export function ChatFileUpload({ if (errors.length > 0) { logger.warn('File upload errors:', errors) - // You could show these errors in a toast or alert + onError?.(errors) } if (newFiles.length > 0) { @@ -168,7 +170,12 @@ export function ChatFileUpload({ ref={fileInputRef} type='file' multiple - onChange={(e) => handleFileSelect(e.target.files)} + onChange={(e) => { + handleFileSelect(e.target.files) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }} className='hidden' accept={acceptedTypes.join(',')} disabled={disabled} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx index 16b41a6442..80564ff6a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/console.tsx @@ -25,7 +25,7 @@ export function Console({ panelWidth }: ConsoleProps) { No console entries
) : ( - +
{filteredEntries.map((entry) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx index 076032898e..6cf689f622 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -78,6 +78,14 @@ if (typeof document !== 'undefined') { overflow-wrap: anywhere !important; word-break: break-word !important; } + + /* Reduce top margin for first heading (e.g., right after thinking block) */ + .copilot-markdown-wrapper > h1:first-child, + .copilot-markdown-wrapper > h2:first-child, + .copilot-markdown-wrapper > h3:first-child, + .copilot-markdown-wrapper > h4:first-child { + margin-top: 0.25rem !important; + } ` document.head.appendChild(style) } @@ -140,17 +148,17 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend // Headings h1: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h2: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), h3: ({ children }: React.HTMLAttributes) => ( -

+

{children}

), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index 9ffc56f508..2fbfa17097 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -19,6 +19,8 @@ export function ThinkingBlock({ }: ThinkingBlockProps) { const [isExpanded, setIsExpanded] = useState(false) const [duration, setDuration] = useState(persistedDuration ?? 0) + // Track if the user explicitly collapsed while streaming; sticky per block instance + const userCollapsedRef = useRef(false) // Keep a stable reference to start time that updates when prop changes const startTimeRef = useRef(persistedStartTime ?? Date.now()) useEffect(() => { @@ -28,13 +30,14 @@ export function ThinkingBlock({ }, [persistedStartTime]) useEffect(() => { - // Auto-collapse when streaming ends + // Auto-collapse when streaming ends and reset userCollapsed flag if (!isStreaming) { setIsExpanded(false) + userCollapsedRef.current = false return } - // Expand once there is visible content while streaming - if (content && content.trim().length > 0) { + // Expand once there is visible content while streaming, unless user collapsed + if (!userCollapsedRef.current && content && content.trim().length > 0) { setIsExpanded(true) } }, [isStreaming, content]) @@ -65,9 +68,16 @@ export function ThinkingBlock({ } return ( -
+
)} -
+
+ {hasCheckpoints && ( +
+ {showRestoreConfirmation ? ( +
+ + +
+ ) : ( + + )} +
+ )}
{/* Message content in purple box */}
= memo(
- - {/* Checkpoints below message */} - {hasCheckpoints && ( -
-
- - Restore{showRestoreConfirmation && ?} - -
- {showRestoreConfirmation ? ( -
- - -
- ) : ( - - )} -
-
-
- )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx index 4b870cebd0..84330d7967 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/todo-list/todo-list.tsx @@ -98,22 +98,22 @@ export const TodoList = memo(function TodoList({ index !== todos.length - 1 && 'border-gray-50 border-b dark:border-gray-800' )} > -
+ +
+ ) : ( +
- {todo.executing ? ( - - ) : todo.completed ? ( - - ) : null} -
+ )} + > + {todo.completed ? : null} +
+ )} - + - + )) CopilotSlider.displayName = 'CopilotSlider' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 611e7e539e..9da8fe935f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -120,12 +120,15 @@ const UserInput = forwardRef( const setMessage = controlledValue !== undefined ? onControlledChange || (() => {}) : setInternalMessage - // Auto-resize textarea + // Auto-resize textarea and toggle vertical scroll when exceeding max height useEffect(() => { const textarea = textareaRef.current if (textarea) { + const maxHeight = 120 textarea.style.height = 'auto' - textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` // Max height of 120px + const nextHeight = Math.min(textarea.scrollHeight, maxHeight) + textarea.style.height = `${nextHeight}px` + textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden' } }, [message]) @@ -431,6 +434,13 @@ const UserInput = forwardRef( // Depth toggle state comes from global store; access via useCopilotStore const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore() + // Ensure MAX mode is off for Fast and Balanced depths + useEffect(() => { + if (agentDepth < 2 && !agentPrefetch) { + setAgentPrefetch(true) + } + }, [agentDepth, agentPrefetch, setAgentPrefetch]) + const cycleDepth = () => { // 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping. const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3) @@ -446,24 +456,27 @@ const UserInput = forwardRef( } const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => { - return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Expert' + return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Behemoth' } // Removed descriptive suffixes; concise labels only const getDepthDescription = (value: 0 | 1 | 2 | 3) => { if (value === 0) - return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks.' - if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks.' + return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks' + if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks' if (value === 2) - return 'More reasoning for larger workflows and complex edits, still balanced for speed.' - return 'Maximum reasoning power. Best for complex workflow building and debugging.' + return 'More reasoning for larger workflows and complex edits, still balanced for speed' + return 'Maximum reasoning power. Best for complex workflow building and debugging' } const getDepthIconFor = (value: 0 | 1 | 2 | 3) => { - if (value === 0) return - if (value === 1) return - if (value === 2) return - return + const colorClass = !agentPrefetch + ? 'text-[var(--brand-primary-hover-hex)]' + : 'text-muted-foreground' + if (value === 0) return + if (value === 1) return + if (value === 2) return + return } const getDepthIcon = () => getDepthIconFor(agentDepth) @@ -550,7 +563,7 @@ const UserInput = forwardRef( placeholder={isDragging ? 'Drop files here...' : placeholder} disabled={disabled} rows={1} - className='mb-2 min-h-[32px] w-full resize-none overflow-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + className='mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-[2px] py-1 text-foreground focus-visible:ring-0 focus-visible:ring-offset-0' style={{ height: 'auto' }} /> @@ -636,7 +649,12 @@ const UserInput = forwardRef(
setAgentPrefetch(!checked)} + disabled={agentDepth < 2} + title={ + agentDepth < 2 + ? 'MAX mode is only available for Advanced or Expert' + : undefined + } + onCheckedChange={(checked) => { + if (agentDepth < 2) return + setAgentPrefetch(!checked) + }} />
@@ -680,9 +711,12 @@ const UserInput = forwardRef(
Mode - - {getDepthLabelFor(agentDepth)} - +
+ {getDepthIconFor(agentDepth)} + + {getDepthLabelFor(agentDepth)} + +
(({ panelWidth }, ref const previewToolCall = lastMessage.toolCalls.find( (tc) => tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW && - tc.state === 'completed' && + tc.state === 'success' && !isToolCallSeen(tc.id) ) - if (previewToolCall?.result) { - logger.info('Preview workflow completed via native SSE - handling result') + if (previewToolCall) { + logger.info('Preview workflow completed via native SSE') // Mark as seen to prevent duplicate processing markToolCallAsSeen(previewToolCall.id) // Tool call handling logic would go here if needed diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx index 66e8282fa2..9714fc45f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/variables/variables.tsx @@ -1,7 +1,16 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { AlertTriangle, ChevronDown, Copy, MoreVertical, Plus, Trash } from 'lucide-react' +import { + AlertTriangle, + ChevronDown, + Copy, + Maximize2, + Minimize2, + MoreVertical, + Plus, + Trash, +} from 'lucide-react' import { highlight, languages } from 'prismjs' import 'prismjs/components/prism-javascript' import 'prismjs/themes/prism.css' @@ -52,6 +61,16 @@ export function Variables() { // Track which variables are currently being edited const [_activeEditors, setActiveEditors] = useState>({}) + // Collapsed state per variable + const [collapsedById, setCollapsedById] = useState>({}) + + const toggleCollapsed = (variableId: string) => { + setCollapsedById((prev) => ({ + ...prev, + [variableId]: !prev[variableId], + })) + } + // Handle variable name change with validation const handleVariableNameChange = (variableId: string, newName: string) => { const validatedName = validateName(newName) @@ -220,7 +239,7 @@ export function Variables() {
) : ( - +
{workflowVariables.map((variable) => (
@@ -298,6 +317,17 @@ export function Variables() { align='end' className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]' > + toggleCollapsed(variable.id)} + className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' + > + {(collapsedById[variable.id] ?? false) ? ( + + ) : ( + + )} + {(collapsedById[variable.id] ?? false) ? 'Expand' : 'Collapse'} + collaborativeDuplicateVariable(variable.id)} className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' @@ -317,71 +347,75 @@ export function Variables() {
{/* Value area */} -
- {/* Validation indicator */} - {variable.value !== '' && getValidationStatus(variable) && ( -
- - -
- -
-
- -

{getValidationStatus(variable)}

-
-
-
- )} - - {/* Editor */} -
-
{ - editorRefs.current[variable.id] = el - }} - style={{ maxWidth: '100%' }} - > - {variable.value === '' && ( -
-
{getPlaceholder(variable.type)}
-
- )} - handleEditorBlur(variable.id)} - onFocus={() => handleEditorFocus(variable.id)} - highlight={(code) => - // Only apply syntax highlighting for non-basic text types - variable.type === 'plain' || variable.type === 'string' - ? code - : highlight( - code, - languages[getEditorLanguage(variable.type)], - getEditorLanguage(variable.type) - ) - } - padding={0} - style={{ - fontFamily: 'inherit', - lineHeight: '20px', - width: '100%', - maxWidth: '100%', - whiteSpace: 'pre-wrap', - wordBreak: 'break-all', - overflowWrap: 'break-word', - minHeight: '20px', - overflow: 'hidden', + {!(collapsedById[variable.id] ?? false) && ( +
+ {/* Validation indicator */} + {variable.value !== '' && getValidationStatus(variable) && ( +
+ + +
+ +
+
+ +

{getValidationStatus(variable)}

+
+
+
+ )} + + {/* Editor */} +
+
{ + editorRefs.current[variable.id] = el }} - className='[&>pre]:!max-w-full [&>pre]:!overflow-hidden [&>pre]:!whitespace-pre-wrap [&>pre]:!break-all [&>pre]:!overflow-wrap-break-word [&>textarea]:!max-w-full [&>textarea]:!overflow-hidden [&>textarea]:!whitespace-pre-wrap [&>textarea]:!break-all [&>textarea]:!overflow-wrap-break-word font-[380] text-foreground text-sm leading-normal focus:outline-none' - textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full max-w-full whitespace-pre-wrap break-all overflow-wrap-break-word overflow-hidden font-[380] text-foreground' - /> + style={{ maxWidth: '100%' }} + > + {variable.value === '' && ( +
+
+ {getPlaceholder(variable.type)} +
+
+ )} + handleEditorBlur(variable.id)} + onFocus={() => handleEditorFocus(variable.id)} + highlight={(code) => + // Only apply syntax highlighting for non-basic text types + variable.type === 'plain' || variable.type === 'string' + ? code + : highlight( + code, + languages[getEditorLanguage(variable.type)], + getEditorLanguage(variable.type) + ) + } + padding={0} + style={{ + fontFamily: 'inherit', + lineHeight: '20px', + width: '100%', + maxWidth: '100%', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + overflowWrap: 'break-word', + minHeight: '20px', + overflow: 'hidden', + }} + className='[&>pre]:!max-w-full [&>pre]:!overflow-hidden [&>pre]:!whitespace-pre-wrap [&>pre]:!break-all [&>pre]:!overflow-wrap-break-word [&>textarea]:!max-w-full [&>textarea]:!overflow-hidden [&>textarea]:!whitespace-pre-wrap [&>textarea]:!break-all [&>textarea]:!overflow-wrap-break-word font-[380] text-foreground text-sm leading-normal focus:outline-none' + textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full max-w-full whitespace-pre-wrap break-all overflow-wrap-break-word overflow-hidden font-[380] text-foreground' + /> +
-
+ )}
))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 6fe1b961fb..90dad4afc2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -492,11 +492,7 @@ export function Panel() {
{/* Keep all tabs mounted but hidden to preserve state and animations */}
- +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx index 9ab634e66a..1ea5279d57 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx @@ -18,6 +18,7 @@ interface CodeEditorProps { highlightVariables?: boolean onKeyDown?: (e: React.KeyboardEvent) => void disabled?: boolean + schemaParameters?: Array<{ name: string; type: string; description: string; required: boolean }> } export function CodeEditor({ @@ -30,6 +31,7 @@ export function CodeEditor({ highlightVariables = true, onKeyDown, disabled = false, + schemaParameters = [], }: CodeEditorProps) { const [code, setCode] = useState(value) const [visualLineHeights, setVisualLineHeights] = useState([]) @@ -120,25 +122,80 @@ export function CodeEditor({ // First, get the default Prism highlighting let highlighted = highlight(code, languages[language], language) - // Then, highlight environment variables with {{var_name}} syntax in blue - if (highlighted.includes('{{')) { - highlighted = highlighted.replace( - /\{\{([^}]+)\}\}/g, - '{{$1}}' - ) + // Collect all syntax highlights to apply in a single pass + type SyntaxHighlight = { + start: number + end: number + replacement: string } + const highlights: SyntaxHighlight[] = [] - // Also highlight tags with syntax in blue - if (highlighted.includes('<') && !language.includes('html')) { - highlighted = highlighted.replace(/<([^>\s/]+)>/g, (match, group) => { - // Avoid replacing HTML tags in comments - if (match.startsWith('