From 40a649727724b8ceb0808d15444331ec6dc478b0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 6 Aug 2025 15:35:42 -0700 Subject: [PATCH 1/6] v1 --- apps/sim/app/api/copilot/methods/route.ts | 29 +- .../tools/client-tools/get-user-workflow.ts | 265 ++++++++++++++++++ apps/sim/lib/copilot/tools/registry.ts | 2 + .../workflow/get-user-workflow.ts | 146 +++++----- apps/sim/lib/environment.ts | 2 +- apps/sim/stores/copilot/store.ts | 37 +++ 6 files changed, 399 insertions(+), 82 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index ced17d38ac3..20682ee80fa 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -57,7 +57,7 @@ async function addToolToRedis(toolCallId: string): Promise { */ async function pollRedisForTool( toolCallId: string -): Promise<{ status: NotificationStatus; message?: string } | null> { +): Promise<{ status: NotificationStatus; message?: string; fullData?: any } | null> { const redis = getRedisClient() if (!redis) { logger.warn('pollRedisForTool: Redis client not available') @@ -86,12 +86,14 @@ async function pollRedisForTool( 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 @@ -138,7 +140,7 @@ async function pollRedisForTool( }) } - return { status, message } + return { status, message, fullData } } // Wait before next poll @@ -165,7 +167,7 @@ async function pollRedisForTool( */ async function interruptHandler( toolCallId: string -): Promise<{ approved: boolean; rejected: boolean; error?: boolean; message?: 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' } @@ -185,31 +187,31 @@ async function interruptHandler( return { approved: false, rejected: false } } - const { status, message } = result + const { status, message, fullData } = result if (status === 'rejected') { logger.info('Tool execution rejected by user', { toolCallId, message }) - return { approved: false, rejected: true, 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 } + 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 } + 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 } + return { approved: true, rejected: false, message, fullData } } if (status === 'success') { logger.info('Tool execution completed successfully', { toolCallId, message }) - return { approved: true, rejected: false, message } + return { approved: true, rejected: false, message, fullData } } logger.warn('Unexpected tool call status', { toolCallId, status, message }) @@ -326,7 +328,7 @@ export async function POST(req: NextRequest) { }) // Handle interrupt flow - const { approved, rejected, error, message } = await interruptHandler(toolCallId) + const { approved, rejected, error, message, fullData } = await interruptHandler(toolCallId) if (rejected) { logger.info(`[${requestId}] Tool execution rejected by user`, { @@ -371,10 +373,13 @@ export async function POST(req: NextRequest) { message, }) - // For noop tool, pass the confirmation message as a parameter - if (methodId === 'no_op' && 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 diff --git a/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts b/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts new file mode 100644 index 00000000000..62131c19f68 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts @@ -0,0 +1,265 @@ +/** + * Get User Workflow Tool - Client-side implementation + */ + +import { BaseTool } from '@/lib/copilot/tools/base-tool' +import type { + CopilotToolCall, + ToolExecuteResult, + ToolExecutionOptions, + ToolMetadata, +} from '@/lib/copilot/tools/types' +import { createLogger } from '@/lib/logs/console/logger' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +interface GetUserWorkflowParams { + workflowId?: string + includeMetadata?: boolean +} + +export class GetUserWorkflowTool extends BaseTool { + static readonly id = 'get_user_workflow' + + metadata: ToolMetadata = { + id: GetUserWorkflowTool.id, + displayConfig: { + states: { + executing: { + displayName: 'Analyzing your workflow', + icon: 'spinner', + }, + accepted: { + displayName: 'Analyzing your workflow', + icon: 'spinner', + }, + success: { + displayName: 'Workflow analyzed', + icon: 'workflow', + }, + rejected: { + displayName: 'Skipped workflow analysis', + icon: 'skip', + }, + errored: { + displayName: 'Failed to analyze workflow', + icon: 'error', + }, + aborted: { + displayName: 'Aborted workflow analysis', + icon: 'abort', + }, + }, + }, + schema: { + name: GetUserWorkflowTool.id, + description: 'Get the current workflow state as JSON', + parameters: { + type: 'object', + properties: { + workflowId: { + type: 'string', + description: 'The ID of the workflow to fetch (optional, uses active workflow if not provided)', + }, + includeMetadata: { + type: 'boolean', + description: 'Whether to include workflow metadata', + }, + }, + required: [], + }, + }, + requiresInterrupt: false, // Client tools handle their own interrupts + stateMessages: { + success: 'Successfully retrieved workflow', + error: 'Failed to retrieve workflow', + rejected: 'User chose to skip workflow retrieval', + }, + } + + /** + * Execute the tool - fetch the workflow from stores and write to Redis + */ + async execute( + toolCall: CopilotToolCall, + options?: ToolExecutionOptions + ): Promise { + const logger = createLogger('GetUserWorkflowTool') + + logger.info('Starting client tool execution', { + toolCallId: toolCall.id, + toolName: toolCall.name, + }) + + try { + // Parse parameters + const rawParams = toolCall.parameters || toolCall.input || {} + const params = rawParams as GetUserWorkflowParams + + // Get workflow ID - use provided or active workflow + let workflowId = params.workflowId + if (!workflowId) { + const { activeWorkflowId } = useWorkflowRegistry.getState() + if (!activeWorkflowId) { + options?.onStateChange?.('errored') + return { + success: false, + error: 'No active workflow found', + } + } + workflowId = activeWorkflowId + } + + logger.info('Fetching user workflow from stores', { workflowId, includeMetadata: params.includeMetadata }) + + // Try to get workflow from diff/preview store first, then main store + let workflowState: any = null + + // Check diff store first + const diffStore = useWorkflowDiffStore.getState() + if (diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0) { + workflowState = diffStore.diffWorkflow + logger.info('Using workflow from diff/preview store', { workflowId }) + } else { + // Get the actual workflow state from the workflow store + const workflowStore = useWorkflowStore.getState() + const fullWorkflowState = workflowStore.getWorkflowState() + + if (!fullWorkflowState || !fullWorkflowState.blocks) { + // Fallback to workflow registry metadata if no workflow state + const workflowRegistry = useWorkflowRegistry.getState() + const workflow = workflowRegistry.workflows[workflowId] + + if (!workflow) { + options?.onStateChange?.('errored') + return { + success: false, + error: `Workflow ${workflowId} not found in any store`, + } + } + + logger.warn('No workflow state found, using workflow metadata only', { workflowId }) + workflowState = workflow + } else { + workflowState = fullWorkflowState + logger.info('Using workflow state from workflow store', { + workflowId, + blockCount: Object.keys(fullWorkflowState.blocks || {}).length + }) + } + } + + logger.info('Validating workflow state', { + workflowId, + hasWorkflowState: !!workflowState, + hasBlocks: !!(workflowState && workflowState.blocks), + workflowStateType: typeof workflowState, + }) + + if (!workflowState || !workflowState.blocks) { + logger.error('Workflow state validation failed', { + workflowId, + workflowState: workflowState, + hasBlocks: !!(workflowState && workflowState.blocks), + }) + options?.onStateChange?.('errored') + return { + success: false, + error: 'Workflow state is empty or invalid', + } + } + + // Include metadata if requested and available + if (params.includeMetadata && workflowState.metadata) { + // Metadata is already included in the workflow state + } + + logger.info('Successfully fetched user workflow from stores', { + workflowId, + blockCount: Object.keys(workflowState.blocks || {}).length, + fromDiffStore: !!diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0, + }) + + logger.info('About to stringify workflow state', { + workflowId, + workflowStateKeys: Object.keys(workflowState), + }) + + // Convert workflow state to JSON string + let workflowJson: string + try { + workflowJson = JSON.stringify(workflowState, null, 2) + logger.info('Successfully stringified workflow state', { + workflowId, + jsonLength: workflowJson.length, + }) + } catch (stringifyError) { + logger.error('Error stringifying workflow state', { + workflowId, + error: stringifyError, + }) + options?.onStateChange?.('errored') + return { + success: false, + error: `Failed to convert workflow to JSON: ${stringifyError instanceof Error ? stringifyError.message : 'Unknown error'}`, + } + } + logger.info('About to notify server with workflow data', { + workflowId, + toolCallId: toolCall.id, + dataLength: workflowJson.length, + }) + + // Notify server of success with structured data containing userWorkflow + const structuredData = JSON.stringify({ + userWorkflow: workflowJson, + }) + + logger.info('Calling notify with structured data', { + toolCallId: toolCall.id, + structuredDataLength: structuredData.length, + }) + + await this.notify(toolCall.id, 'success', structuredData) + + logger.info('Successfully notified server of success', { + toolCallId: toolCall.id, + }) + + options?.onStateChange?.('success') + + return { + success: true, + data: workflowJson, // Return the same data that goes to Redis + } + } catch (error: any) { + logger.error('Error in client tool execution:', { + toolCallId: toolCall.id, + error: error, + stack: error instanceof Error ? error.stack : undefined, + message: error instanceof Error ? error.message : String(error), + }) + + try { + // Notify server of error + await this.notify(toolCall.id, 'errored', error.message || 'Failed to fetch workflow') + logger.info('Successfully notified server of error', { + toolCallId: toolCall.id, + }) + } catch (notifyError) { + logger.error('Failed to notify server of error:', { + toolCallId: toolCall.id, + notifyError: notifyError, + }) + } + + options?.onStateChange?.('errored') + + return { + success: false, + error: error.message || 'Failed to fetch workflow', + } + } + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/tools/registry.ts b/apps/sim/lib/copilot/tools/registry.ts index 7df3617ae6a..3af189444e6 100644 --- a/apps/sim/lib/copilot/tools/registry.ts +++ b/apps/sim/lib/copilot/tools/registry.ts @@ -9,6 +9,7 @@ */ import { RunWorkflowTool } from '@/lib/copilot/tools/client-tools/run-workflow' +import { GetUserWorkflowTool } from '@/lib/copilot/tools/client-tools/get-user-workflow' import { SERVER_TOOL_METADATA } from '@/lib/copilot/tools/server-tools/definitions' import type { Tool, ToolMetadata } from '@/lib/copilot/tools/types' @@ -112,6 +113,7 @@ export class ToolRegistry { private registerDefaultTools(): void { // Register actual client tool implementations this.register(new RunWorkflowTool()) + this.register(new GetUserWorkflowTool()) } } diff --git a/apps/sim/lib/copilot/tools/server-tools/workflow/get-user-workflow.ts b/apps/sim/lib/copilot/tools/server-tools/workflow/get-user-workflow.ts index 9892c54f939..5531364f2d1 100644 --- a/apps/sim/lib/copilot/tools/server-tools/workflow/get-user-workflow.ts +++ b/apps/sim/lib/copilot/tools/server-tools/workflow/get-user-workflow.ts @@ -1,90 +1,98 @@ -import { eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console/logger' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' -import { db } from '@/db' -import { workflow as workflowTable } from '@/db/schema' import { BaseCopilotTool } from '../base' interface GetUserWorkflowParams { - workflowId: string + workflowId?: string includeMetadata?: boolean + confirmationMessage?: string + fullData?: any } class GetUserWorkflowTool extends BaseCopilotTool { readonly id = 'get_user_workflow' readonly displayName = 'Analyzing your workflow' + readonly requiresInterrupt = true // This triggers automatic Redis polling protected async executeImpl(params: GetUserWorkflowParams): Promise { - return getUserWorkflow(params) - } -} - -// Export the tool instance -export const getUserWorkflowTool = new GetUserWorkflowTool() - -// Implementation function -async function getUserWorkflow(params: GetUserWorkflowParams): Promise { - const logger = createLogger('GetUserWorkflow') - const { workflowId, includeMetadata = false } = params - - logger.info('Fetching user workflow', { workflowId }) + const logger = createLogger('GetUserWorkflow') + + logger.info('Server tool received params', { + hasFullData: !!params.fullData, + hasConfirmationMessage: !!params.confirmationMessage, + fullDataType: typeof params.fullData, + fullDataKeys: params.fullData ? Object.keys(params.fullData) : null, + confirmationMessageLength: params.confirmationMessage?.length || 0, + }) + + // Extract the workflow data from fullData or confirmationMessage + let workflowData: string | null = null + + if (params.fullData && params.fullData.userWorkflow) { + // New format: fullData contains structured data with userWorkflow field + workflowData = params.fullData.userWorkflow + logger.info('Using workflow data from fullData.userWorkflow', { + dataLength: workflowData?.length || 0, + }) + } else if (params.confirmationMessage) { + // The confirmationMessage might contain the structured JSON data + logger.info('Attempting to parse confirmationMessage as structured data', { + messageLength: params.confirmationMessage.length, + messagePreview: params.confirmationMessage.substring(0, 100), + }) + + try { + // Try to parse the confirmation message as structured data + const parsedMessage = JSON.parse(params.confirmationMessage) + if (parsedMessage && parsedMessage.userWorkflow) { + workflowData = parsedMessage.userWorkflow + logger.info('Successfully extracted userWorkflow from confirmationMessage', { + dataLength: workflowData?.length || 0, + }) + } else { + // Fallback: treat the entire message as workflow data + workflowData = params.confirmationMessage + logger.info('Using confirmationMessage directly as workflow data', { + dataLength: workflowData.length, + }) + } + } catch (parseError) { + // If parsing fails, use the message directly + workflowData = params.confirmationMessage + logger.info('Failed to parse confirmationMessage, using directly', { + dataLength: workflowData.length, + parseError: parseError instanceof Error ? parseError.message : 'Unknown error', + }) + } + } else { + throw new Error('No workflow data received from client tool') + } - // Fetch workflow from database - const [workflowRecord] = await db - .select() - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) + if (!workflowData) { + throw new Error('No workflow data available') + } - if (!workflowRecord) { - throw new Error(`Workflow ${workflowId} not found`) - } + try { + // Parse the workflow data to validate it's valid JSON + const workflowState = JSON.parse(workflowData) + + if (!workflowState || !workflowState.blocks) { + throw new Error('Invalid workflow state received from client tool') + } - // Try to load from normalized tables first, fallback to JSON blob - let workflowState: any = null - const subBlockValues: Record> = {} + logger.info('Successfully parsed and validated workflow data', { + blockCount: Object.keys(workflowState.blocks).length, + }) - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) - if (normalizedData) { - workflowState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, + // Return the workflow data as properly formatted JSON string + return JSON.stringify(workflowState, null, 2) + } catch (error) { + logger.error('Failed to parse workflow data from client tool', { error }) + throw new Error('Invalid workflow data format received from client tool') } - - // Extract subblock values from normalized data - Object.entries(normalizedData.blocks).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { - if ((subBlock as any).value !== undefined) { - subBlockValues[blockId][subBlockId] = (subBlock as any).value - } - }) - }) - } else if (workflowRecord.state) { - // Fallback to JSON blob - workflowState = workflowRecord.state as any - // For JSON blob, subblock values are embedded in the block state - Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { - if ((subBlock as any).value !== undefined) { - subBlockValues[blockId][subBlockId] = (subBlock as any).value - } - }) - }) } +} - if (!workflowState || !workflowState.blocks) { - throw new Error('Workflow state is empty or invalid') - } +// Export the tool instance +export const getUserWorkflowTool = new GetUserWorkflowTool() - logger.info('Successfully fetched user workflow as JSON', { - workflowId, - blockCount: Object.keys(workflowState.blocks).length, - }) - // Return the raw JSON workflow state - return JSON.stringify(workflowState, null, 2) -} diff --git a/apps/sim/lib/environment.ts b/apps/sim/lib/environment.ts index d6da01ec546..cfffb33186d 100644 --- a/apps/sim/lib/environment.ts +++ b/apps/sim/lib/environment.ts @@ -11,7 +11,7 @@ export const isProd = env.NODE_ENV === 'production' /** * Is the application running in development mode */ -export const isDev = env.NODE_ENV === 'development' +export const isDev = false // env.NODE_ENV === 'development' /** * Is the application running in test mode diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index e2a937a149a..e03e62bb55c 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -531,6 +531,43 @@ function createToolCall(id: string, name: string, input: any = {}): any { setToolCallState(toolCall, initialState, { preserveTerminalStates: false }) + // Auto-execute client tools that don't require interrupt + if (!requiresInterrupt && toolRegistry.getTool(name)) { + logger.info('Auto-executing client tool:', name, toolCall.id) + // Execute client tool asynchronously + setTimeout(async () => { + try { + const tool = toolRegistry.getTool(name) + if (tool && toolCall.state === 'executing') { + await tool.execute(toolCall as any, { + onStateChange: (state: any) => { + // Update the tool call state in the store + const currentState = useCopilotStore.getState() + const updatedMessages = currentState.messages.map(msg => ({ + ...msg, + toolCalls: msg.toolCalls?.map(tc => + tc.id === toolCall.id ? { ...tc, state } : tc + ), + contentBlocks: msg.contentBlocks?.map(block => + block.type === 'tool_call' && block.toolCall?.id === toolCall.id + ? { ...block, toolCall: { ...block.toolCall, state } } + : block + ) + })) + + useCopilotStore.setState({ messages: updatedMessages }) + }, + }) + } + } catch (error) { + logger.error('Error auto-executing client tool:', name, toolCall.id, error) + setToolCallState(toolCall, 'errored', { + error: error instanceof Error ? error.message : 'Auto-execution failed' + }) + } + }, 0) + } + return toolCall } From f91d3d13aae46e42894938d2ce96a6318155340f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 6 Aug 2025 16:09:07 -0700 Subject: [PATCH 2/6] Incremental edits --- apps/sim/app/api/yaml/diff/create/route.ts | 10 ++ apps/sim/app/api/yaml/diff/merge/route.ts | 12 +- .../tools/client-tools/get-user-workflow.ts | 16 +++ .../server-tools/workflow/edit-workflow.ts | 125 ++++++++++++++++-- apps/sim/stores/copilot/store.ts | 25 +--- apps/sim/stores/workflow-diff/store.ts | 3 + 6 files changed, 157 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/api/yaml/diff/create/route.ts b/apps/sim/app/api/yaml/diff/create/route.ts index e92792c27ea..5fb24abf51c 100644 --- a/apps/sim/app/api/yaml/diff/create/route.ts +++ b/apps/sim/app/api/yaml/diff/create/route.ts @@ -70,6 +70,16 @@ export async function POST(request: NextRequest) { // Note: This endpoint is stateless, so we need to get this from the request const currentWorkflowState = (body as any).currentWorkflowState + // Ensure currentWorkflowState has all required properties with proper defaults if provided + if (currentWorkflowState) { + if (!currentWorkflowState.loops) { + currentWorkflowState.loops = {} + } + if (!currentWorkflowState.parallels) { + currentWorkflowState.parallels = {} + } + } + logger.info(`[${requestId}] Creating diff from YAML`, { contentLength: yamlContent.length, hasDiffAnalysis: !!diffAnalysis, diff --git a/apps/sim/app/api/yaml/diff/merge/route.ts b/apps/sim/app/api/yaml/diff/merge/route.ts index 8ad9b24bea8..c1ec661c3dd 100644 --- a/apps/sim/app/api/yaml/diff/merge/route.ts +++ b/apps/sim/app/api/yaml/diff/merge/route.ts @@ -24,8 +24,8 @@ const MergeDiffRequestSchema = z.object({ proposedState: z.object({ blocks: z.record(z.any()), edges: z.array(z.any()), - loops: z.record(z.any()), - parallels: z.record(z.any()), + loops: z.record(z.any()).optional(), + parallels: z.record(z.any()).optional(), }), diffAnalysis: z.any().optional(), metadata: z.object({ @@ -50,6 +50,14 @@ export async function POST(request: NextRequest) { const body = await request.json() const { existingDiff, yamlContent, diffAnalysis, options } = MergeDiffRequestSchema.parse(body) + // Ensure existingDiff.proposedState has all required properties with proper defaults + if (!existingDiff.proposedState.loops) { + existingDiff.proposedState.loops = {} + } + if (!existingDiff.proposedState.parallels) { + existingDiff.proposedState.parallels = {} + } + logger.info(`[${requestId}] Merging diff from YAML`, { contentLength: yamlContent.length, existingBlockCount: Object.keys(existingDiff.proposedState.blocks).length, diff --git a/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts b/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts index 62131c19f68..c1577a779c7 100644 --- a/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts +++ b/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts @@ -149,6 +149,22 @@ export class GetUserWorkflowTool extends BaseTool { }) } } + + // Ensure workflow state has all required properties with proper defaults + if (workflowState) { + if (!workflowState.loops) { + workflowState.loops = {} + } + if (!workflowState.parallels) { + workflowState.parallels = {} + } + if (!workflowState.edges) { + workflowState.edges = [] + } + if (!workflowState.blocks) { + workflowState.blocks = {} + } + } logger.info('Validating workflow state', { workflowId, diff --git a/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts index e5ad4394df5..0b93b334bbd 100644 --- a/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts @@ -274,10 +274,15 @@ async function applyOperationsToYaml( } import { BaseCopilotTool } from '../base' +import { eq } from 'drizzle-orm' +import { db } from '@/db' +import { workflow as workflowTable } from '@/db/schema' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' interface EditWorkflowParams { operations: EditWorkflowOperation[] workflowId: string + currentUserWorkflow?: string // Optional current workflow JSON - if not provided, will fetch from DB } interface EditWorkflowResult { @@ -297,28 +302,110 @@ class EditWorkflowTool extends BaseCopilotTool { + logger.info('Fetching workflow from database', { workflowId }) + + // Fetch workflow from database + const [workflowRecord] = await db + .select() + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!workflowRecord) { + throw new Error(`Workflow ${workflowId} not found in database`) + } + + // Try to load from normalized tables first, fallback to JSON blob + let workflowState: any = null + const subBlockValues: Record> = {} + + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (normalizedData) { + workflowState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + } + + // Extract subblock values from normalized data + Object.entries(normalizedData.blocks).forEach(([blockId, block]) => { + subBlockValues[blockId] = {} + Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { + if ((subBlock as any).value !== undefined) { + subBlockValues[blockId][subBlockId] = (subBlock as any).value + } + }) + }) + } else if (workflowRecord.state) { + // Fallback to JSON blob + const jsonState = workflowRecord.state as any + workflowState = { + blocks: jsonState.blocks || {}, + edges: jsonState.edges || [], + loops: jsonState.loops || {}, + parallels: jsonState.parallels || {}, + } + // For JSON blob, subblock values are embedded in the block state + Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => { + subBlockValues[blockId] = {} + Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { + if ((subBlock as any).value !== undefined) { + subBlockValues[blockId][subBlockId] = (subBlock as any).value + } + }) + }) + } + + if (!workflowState || !workflowState.blocks) { + throw new Error('Workflow state is empty or invalid') + } + + logger.info('Successfully fetched workflow from database', { + workflowId, + blockCount: Object.keys(workflowState.blocks).length, + }) + + // Return the raw JSON workflow state + return JSON.stringify(workflowState, null, 2) +} + // Implementation function async function editWorkflow(params: EditWorkflowParams): Promise { - const { operations, workflowId } = params + const { operations, workflowId, currentUserWorkflow } = params logger.info('Processing targeted update request', { workflowId, operationCount: operations.length, + hasCurrentUserWorkflow: !!currentUserWorkflow, }) - // Get current workflow state as JSON - const { getUserWorkflowTool } = await import('./get-user-workflow') - - const getUserWorkflowResult = await getUserWorkflowTool.execute({ - workflowId: workflowId, - includeMetadata: false, - }) - - if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) { - throw new Error('Failed to get current workflow state') + // Get current workflow state - use provided currentUserWorkflow or fetch from DB + let workflowStateJson: string + + if (currentUserWorkflow) { + logger.info('Using provided currentUserWorkflow for edits', { + workflowId, + jsonLength: currentUserWorkflow.length, + }) + workflowStateJson = currentUserWorkflow + } else { + logger.info('No currentUserWorkflow provided, fetching from database', { + workflowId, + }) + workflowStateJson = await getUserWorkflow(workflowId) } - const workflowStateJson = getUserWorkflowResult.data + // Also get the DB version for diff calculation if we're using a different current workflow + let dbWorkflowStateJson: string = workflowStateJson + if (currentUserWorkflow) { + logger.info('Fetching DB workflow for diff calculation', { workflowId }) + dbWorkflowStateJson = await getUserWorkflow(workflowId) + } logger.info('Retrieved current workflow state', { jsonLength: workflowStateJson.length, @@ -328,6 +415,20 @@ async function editWorkflow(params: EditWorkflowParams): Promise> = {} Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index e03e62bb55c..2ccb6ce7208 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -2784,17 +2784,8 @@ export const useCopilotStore = create()( hasDiffWorkflow: !!diffStoreBefore.diffWorkflow, }) - // Determine if we should clear or merge based on tool type and message context - const { messages } = get() - const currentMessage = messages[messages.length - 1] - const messageHasExistingEdits = - currentMessage?.toolCalls?.some( - (tc) => - (tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || - tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) && - tc.state !== 'executing' - ) || false - + // Determine diff merge strategy based on tool type and existing edits + const messageHasExistingEdits = !!diffStoreBefore.diffWorkflow const shouldClearDiff = toolName === COPILOT_TOOL_IDS.BUILD_WORKFLOW || // build_workflow always clears (toolName === COPILOT_TOOL_IDS.EDIT_WORKFLOW && !messageHasExistingEdits) // first edit_workflow in message clears @@ -2824,15 +2815,9 @@ export const useCopilotStore = create()( hasDiffWorkflow: !!diffStoreBefore.diffWorkflow, }) - if (shouldClearDiff || !diffStoreBefore.diffWorkflow) { - // Use setProposedChanges which will create a new diff - // Pass undefined to let sim-agent generate the diff analysis - await diffStore.setProposedChanges(yamlContent, undefined) - } else { - // Use mergeProposedChanges which will merge into existing diff - // Pass undefined to let sim-agent generate the diff analysis - await diffStore.mergeProposedChanges(yamlContent, undefined) - } + // Always use setProposedChanges to ensure the diff view fully overwrites with new changes + // This provides better UX as users expect to see the latest changes, not merged/cumulative changes + await diffStore.setProposedChanges(yamlContent, undefined) // Check diff store state after update const diffStoreAfter = useWorkflowDiffStore.getState() diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 3d6ce9e2e41..151242a29df 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -114,6 +114,9 @@ export const useWorkflowDiffStore = create Date: Wed, 6 Aug 2025 16:58:26 -0700 Subject: [PATCH 3/6] Lint --- apps/sim/app/api/copilot/methods/route.ts | 8 +- .../tools/client-tools/get-user-workflow.ts | 73 ++++++++++--------- apps/sim/lib/copilot/tools/registry.ts | 2 +- .../server-tools/workflow/edit-workflow.ts | 6 +- .../workflow/get-user-workflow.ts | 24 +++--- apps/sim/stores/copilot/store.ts | 16 ++-- 6 files changed, 69 insertions(+), 60 deletions(-) diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index 20682ee80fa..70fd852f48b 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -167,7 +167,13 @@ async function pollRedisForTool( */ async function interruptHandler( toolCallId: string -): Promise<{ approved: boolean; rejected: boolean; error?: boolean; message?: string; fullData?: any }> { +): 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' } diff --git a/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts b/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts index c1577a779c7..1cb20e83a7b 100644 --- a/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts +++ b/apps/sim/lib/copilot/tools/client-tools/get-user-workflow.ts @@ -10,8 +10,8 @@ import type { ToolMetadata, } from '@/lib/copilot/tools/types' import { createLogger } from '@/lib/logs/console/logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' interface GetUserWorkflowParams { @@ -21,7 +21,7 @@ interface GetUserWorkflowParams { export class GetUserWorkflowTool extends BaseTool { static readonly id = 'get_user_workflow' - + metadata: ToolMetadata = { id: GetUserWorkflowTool.id, displayConfig: { @@ -60,7 +60,8 @@ export class GetUserWorkflowTool extends BaseTool { properties: { workflowId: { type: 'string', - description: 'The ID of the workflow to fetch (optional, uses active workflow if not provided)', + description: + 'The ID of the workflow to fetch (optional, uses active workflow if not provided)', }, includeMetadata: { type: 'boolean', @@ -86,17 +87,17 @@ export class GetUserWorkflowTool extends BaseTool { options?: ToolExecutionOptions ): Promise { const logger = createLogger('GetUserWorkflowTool') - + logger.info('Starting client tool execution', { toolCallId: toolCall.id, toolName: toolCall.name, }) - + try { // Parse parameters const rawParams = toolCall.parameters || toolCall.input || {} const params = rawParams as GetUserWorkflowParams - + // Get workflow ID - use provided or active workflow let workflowId = params.workflowId if (!workflowId) { @@ -110,12 +111,15 @@ export class GetUserWorkflowTool extends BaseTool { } workflowId = activeWorkflowId } - - logger.info('Fetching user workflow from stores', { workflowId, includeMetadata: params.includeMetadata }) - + + logger.info('Fetching user workflow from stores', { + workflowId, + includeMetadata: params.includeMetadata, + }) + // Try to get workflow from diff/preview store first, then main store let workflowState: any = null - + // Check diff store first const diffStore = useWorkflowDiffStore.getState() if (diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0) { @@ -125,12 +129,12 @@ export class GetUserWorkflowTool extends BaseTool { // Get the actual workflow state from the workflow store const workflowStore = useWorkflowStore.getState() const fullWorkflowState = workflowStore.getWorkflowState() - + if (!fullWorkflowState || !fullWorkflowState.blocks) { // Fallback to workflow registry metadata if no workflow state const workflowRegistry = useWorkflowRegistry.getState() const workflow = workflowRegistry.workflows[workflowId] - + if (!workflow) { options?.onStateChange?.('errored') return { @@ -138,14 +142,14 @@ export class GetUserWorkflowTool extends BaseTool { error: `Workflow ${workflowId} not found in any store`, } } - + logger.warn('No workflow state found, using workflow metadata only', { workflowId }) workflowState = workflow } else { workflowState = fullWorkflowState - logger.info('Using workflow state from workflow store', { + logger.info('Using workflow state from workflow store', { workflowId, - blockCount: Object.keys(fullWorkflowState.blocks || {}).length + blockCount: Object.keys(fullWorkflowState.blocks || {}).length, }) } } @@ -165,11 +169,11 @@ export class GetUserWorkflowTool extends BaseTool { workflowState.blocks = {} } } - - logger.info('Validating workflow state', { + + logger.info('Validating workflow state', { workflowId, hasWorkflowState: !!workflowState, - hasBlocks: !!(workflowState && workflowState.blocks), + hasBlocks: !!workflowState?.blocks, workflowStateType: typeof workflowState, }) @@ -177,7 +181,7 @@ export class GetUserWorkflowTool extends BaseTool { logger.error('Workflow state validation failed', { workflowId, workflowState: workflowState, - hasBlocks: !!(workflowState && workflowState.blocks), + hasBlocks: !!workflowState?.blocks, }) options?.onStateChange?.('errored') return { @@ -185,23 +189,24 @@ export class GetUserWorkflowTool extends BaseTool { error: 'Workflow state is empty or invalid', } } - + // Include metadata if requested and available if (params.includeMetadata && workflowState.metadata) { // Metadata is already included in the workflow state } - + logger.info('Successfully fetched user workflow from stores', { workflowId, blockCount: Object.keys(workflowState.blocks || {}).length, - fromDiffStore: !!diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0, + fromDiffStore: + !!diffStore.diffWorkflow && Object.keys(diffStore.diffWorkflow.blocks || {}).length > 0, }) - + logger.info('About to stringify workflow state', { workflowId, workflowStateKeys: Object.keys(workflowState), }) - + // Convert workflow state to JSON string let workflowJson: string try { @@ -226,25 +231,25 @@ export class GetUserWorkflowTool extends BaseTool { toolCallId: toolCall.id, dataLength: workflowJson.length, }) - + // Notify server of success with structured data containing userWorkflow const structuredData = JSON.stringify({ userWorkflow: workflowJson, }) - + logger.info('Calling notify with structured data', { toolCallId: toolCall.id, structuredDataLength: structuredData.length, }) - + await this.notify(toolCall.id, 'success', structuredData) - + logger.info('Successfully notified server of success', { toolCallId: toolCall.id, }) - + options?.onStateChange?.('success') - + return { success: true, data: workflowJson, // Return the same data that goes to Redis @@ -256,7 +261,7 @@ export class GetUserWorkflowTool extends BaseTool { stack: error instanceof Error ? error.stack : undefined, message: error instanceof Error ? error.message : String(error), }) - + try { // Notify server of error await this.notify(toolCall.id, 'errored', error.message || 'Failed to fetch workflow') @@ -269,13 +274,13 @@ export class GetUserWorkflowTool extends BaseTool { notifyError: notifyError, }) } - + options?.onStateChange?.('errored') - + return { success: false, error: error.message || 'Failed to fetch workflow', } } } -} \ No newline at end of file +} diff --git a/apps/sim/lib/copilot/tools/registry.ts b/apps/sim/lib/copilot/tools/registry.ts index 3af189444e6..81038c5e9d9 100644 --- a/apps/sim/lib/copilot/tools/registry.ts +++ b/apps/sim/lib/copilot/tools/registry.ts @@ -8,8 +8,8 @@ * It also provides metadata for server-side tools for display purposes */ -import { RunWorkflowTool } from '@/lib/copilot/tools/client-tools/run-workflow' import { GetUserWorkflowTool } from '@/lib/copilot/tools/client-tools/get-user-workflow' +import { RunWorkflowTool } from '@/lib/copilot/tools/client-tools/run-workflow' import { SERVER_TOOL_METADATA } from '@/lib/copilot/tools/server-tools/definitions' import type { Tool, ToolMetadata } from '@/lib/copilot/tools/types' diff --git a/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts index 0b93b334bbd..2159fdd5138 100644 --- a/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server-tools/workflow/edit-workflow.ts @@ -273,11 +273,11 @@ async function applyOperationsToYaml( return yamlDump(workflowData) } -import { BaseCopilotTool } from '../base' import { eq } from 'drizzle-orm' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { db } from '@/db' import { workflow as workflowTable } from '@/db/schema' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' +import { BaseCopilotTool } from '../base' interface EditWorkflowParams { operations: EditWorkflowOperation[] @@ -386,7 +386,7 @@ async function editWorkflow(params: EditWorkflowParams): Promise protected async executeImpl(params: GetUserWorkflowParams): Promise { const logger = createLogger('GetUserWorkflow') - + logger.info('Server tool received params', { hasFullData: !!params.fullData, hasConfirmationMessage: !!params.confirmationMessage, @@ -23,11 +23,11 @@ class GetUserWorkflowTool extends BaseCopilotTool fullDataKeys: params.fullData ? Object.keys(params.fullData) : null, confirmationMessageLength: params.confirmationMessage?.length || 0, }) - + // Extract the workflow data from fullData or confirmationMessage let workflowData: string | null = null - - if (params.fullData && params.fullData.userWorkflow) { + + if (params.fullData?.userWorkflow) { // New format: fullData contains structured data with userWorkflow field workflowData = params.fullData.userWorkflow logger.info('Using workflow data from fullData.userWorkflow', { @@ -39,15 +39,15 @@ class GetUserWorkflowTool extends BaseCopilotTool messageLength: params.confirmationMessage.length, messagePreview: params.confirmationMessage.substring(0, 100), }) - + try { // Try to parse the confirmation message as structured data const parsedMessage = JSON.parse(params.confirmationMessage) - if (parsedMessage && parsedMessage.userWorkflow) { - workflowData = parsedMessage.userWorkflow - logger.info('Successfully extracted userWorkflow from confirmationMessage', { - dataLength: workflowData?.length || 0, - }) + if (parsedMessage?.userWorkflow) { + workflowData = parsedMessage.userWorkflow + logger.info('Successfully extracted userWorkflow from confirmationMessage', { + dataLength: workflowData?.length || 0, + }) } else { // Fallback: treat the entire message as workflow data workflowData = params.confirmationMessage @@ -74,7 +74,7 @@ class GetUserWorkflowTool extends BaseCopilotTool try { // Parse the workflow data to validate it's valid JSON const workflowState = JSON.parse(workflowData) - + if (!workflowState || !workflowState.blocks) { throw new Error('Invalid workflow state received from client tool') } @@ -94,5 +94,3 @@ class GetUserWorkflowTool extends BaseCopilotTool // Export the tool instance export const getUserWorkflowTool = new GetUserWorkflowTool() - - diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 2ccb6ce7208..0ec13c0119a 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -543,26 +543,26 @@ function createToolCall(id: string, name: string, input: any = {}): any { onStateChange: (state: any) => { // Update the tool call state in the store const currentState = useCopilotStore.getState() - const updatedMessages = currentState.messages.map(msg => ({ + const updatedMessages = currentState.messages.map((msg) => ({ ...msg, - toolCalls: msg.toolCalls?.map(tc => + toolCalls: msg.toolCalls?.map((tc) => tc.id === toolCall.id ? { ...tc, state } : tc ), - contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && block.toolCall?.id === toolCall.id + contentBlocks: msg.contentBlocks?.map((block) => + block.type === 'tool_call' && block.toolCall?.id === toolCall.id ? { ...block, toolCall: { ...block.toolCall, state } } : block - ) + ), })) - + useCopilotStore.setState({ messages: updatedMessages }) }, }) } } catch (error) { logger.error('Error auto-executing client tool:', name, toolCall.id, error) - setToolCallState(toolCall, 'errored', { - error: error instanceof Error ? error.message : 'Auto-execution failed' + setToolCallState(toolCall, 'errored', { + error: error instanceof Error ? error.message : 'Auto-execution failed', }) } }, 0) From 142b2fef4f17227ca41edf427a73185e23732ad5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 6 Aug 2025 17:01:31 -0700 Subject: [PATCH 4/6] Remove dev env --- apps/sim/lib/environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/environment.ts b/apps/sim/lib/environment.ts index cfffb33186d..d6da01ec546 100644 --- a/apps/sim/lib/environment.ts +++ b/apps/sim/lib/environment.ts @@ -11,7 +11,7 @@ export const isProd = env.NODE_ENV === 'production' /** * Is the application running in development mode */ -export const isDev = false // env.NODE_ENV === 'development' +export const isDev = env.NODE_ENV === 'development' /** * Is the application running in test mode From 4daacc798e2e488ddac257b46efbe0e19347689a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 6 Aug 2025 17:07:46 -0700 Subject: [PATCH 5/6] Fix tests --- apps/sim/app/api/copilot/methods/route.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/copilot/methods/route.test.ts b/apps/sim/app/api/copilot/methods/route.test.ts index 206aa7bceda..a8540976f25 100644 --- a/apps/sim/app/api/copilot/methods/route.test.ts +++ b/apps/sim/app/api/copilot/methods/route.test.ts @@ -354,7 +354,14 @@ describe('Copilot Methods API Route', () => { 86400 ) expect(mockRedisGet).toHaveBeenCalledWith('tool_call:tool-call-123') - expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', { key: 'value' }) + 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 () => { @@ -613,6 +620,10 @@ describe('Copilot Methods API Route', () => { expect(mockToolRegistryExecute).toHaveBeenCalledWith('no_op', { existing: 'param', confirmationMessage: 'Confirmation message', + fullData: { + message: 'Confirmation message', + status: 'accepted', + } }) }) From 7e8259b249ecbc89e54aed83f00150d3dd71254b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 6 Aug 2025 17:09:38 -0700 Subject: [PATCH 6/6] Lint --- apps/sim/app/api/copilot/methods/route.test.ts | 6 +++--- apps/sim/app/api/copilot/methods/route.ts | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/copilot/methods/route.test.ts b/apps/sim/app/api/copilot/methods/route.test.ts index a8540976f25..02cae1bc637 100644 --- a/apps/sim/app/api/copilot/methods/route.test.ts +++ b/apps/sim/app/api/copilot/methods/route.test.ts @@ -354,13 +354,13 @@ describe('Copilot Methods API Route', () => { 86400 ) expect(mockRedisGet).toHaveBeenCalledWith('tool_call:tool-call-123') - expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', { + expect(mockToolRegistryExecute).toHaveBeenCalledWith('interrupt-tool', { key: 'value', confirmationMessage: 'User approved', fullData: { message: 'User approved', status: 'accepted', - } + }, }) }) @@ -623,7 +623,7 @@ describe('Copilot Methods API Route', () => { fullData: { message: 'Confirmation message', status: 'accepted', - } + }, }) }) diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index 70fd852f48b..7a21f8a875a 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -165,9 +165,7 @@ async function pollRedisForTool( * 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<{ +async function interruptHandler(toolCallId: string): Promise<{ approved: boolean rejected: boolean error?: boolean