diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 973c51b0e..8bbe524b1 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -158,10 +158,12 @@ export async function POST(req: NextRequest) { } // Build execution context that will be passed to Go and used for tool execution + // Source is 'ui' since this route is called from the browser UI const executionContext = { userId: authenticatedUserId, workflowId: resolvedWorkflowId, workspaceId: resolvedWorkspaceId, + source: 'ui' as const, } logger.debug(`[${tracker.requestId}] Resolved execution context`, executionContext) @@ -686,6 +688,7 @@ export async function POST(req: NextRequest) { workspaceId: event.data.executionContext?.workspaceId || resolvedWorkspaceId, chatId: actualChatId, + source: 'ui' as const, } handleToolCallEvent( { diff --git a/apps/sim/app/api/copilot/test/route.ts b/apps/sim/app/api/copilot/test/route.ts index ed713e3a3..90dfbac0b 100644 --- a/apps/sim/app/api/copilot/test/route.ts +++ b/apps/sim/app/api/copilot/test/route.ts @@ -44,10 +44,12 @@ export async function POST(req: NextRequest) { logger.info('Test copilot request', { query, userId, workflowId, workspaceId, stream }) // Build execution context + // Source is 'headless' since this is a test/API endpoint without UI const executionContext = { userId, workflowId, workspaceId, + source: 'headless' as const, } // Build request payload for Go copilot @@ -119,6 +121,7 @@ export async function POST(req: NextRequest) { workflowId: event.data.executionContext?.workflowId || workflowId, workspaceId: event.data.executionContext?.workspaceId || workspaceId, chatId: undefined, + source: 'headless' as const, } handleToolCallEvent( diff --git a/apps/sim/lib/copilot/server-executor/registry.ts b/apps/sim/lib/copilot/server-executor/registry.ts index 4d9795010..deaaff90d 100644 --- a/apps/sim/lib/copilot/server-executor/registry.ts +++ b/apps/sim/lib/copilot/server-executor/registry.ts @@ -363,17 +363,38 @@ const TOOL_REGISTRY: Record = { }, } +/** + * Tools that should only be auto-intercepted in headless mode. + * In UI mode, the client tool handles these (e.g., to show diff review). + */ +const HEADLESS_ONLY_TOOLS = new Set(['edit_workflow']) + /** * List of all server-executed tool names. * Export this so clients know which tools NOT to execute locally. + * Note: edit_workflow is excluded because it needs client-side diff review in UI mode. */ -export const SERVER_EXECUTED_TOOLS = Object.keys(TOOL_REGISTRY) +export const SERVER_EXECUTED_TOOLS = Object.keys(TOOL_REGISTRY).filter( + (name) => !HEADLESS_ONLY_TOOLS.has(name) +) /** * Check if a tool is registered for server execution. + * @param toolName - The tool name to check + * @param source - Optional execution source. If 'ui', headless-only tools return false. */ -export function isServerExecutedTool(toolName: string): boolean { - return toolName in TOOL_REGISTRY +export function isServerExecutedTool( + toolName: string, + source?: 'ui' | 'headless' +): boolean { + if (!(toolName in TOOL_REGISTRY)) { + return false + } + // In UI mode, headless-only tools are NOT server-executed (client handles them) + if (source === 'ui' && HEADLESS_ONLY_TOOLS.has(toolName)) { + return false + } + return true } /** diff --git a/apps/sim/lib/copilot/server-executor/stream-handler.ts b/apps/sim/lib/copilot/server-executor/stream-handler.ts index e61d018a9..910d59223 100644 --- a/apps/sim/lib/copilot/server-executor/stream-handler.ts +++ b/apps/sim/lib/copilot/server-executor/stream-handler.ts @@ -182,10 +182,12 @@ export async function handleToolCallEvent( } // Check if this tool should be executed server-side - if (!isServerExecutedTool(event.name)) { + // Pass source to handle headless-only tools (e.g., edit_workflow in UI mode is client-handled) + if (!isServerExecutedTool(event.name, context.source)) { logger.debug('Tool not server-executed, client will handle', { toolCallId: event.id, toolName: event.name, + source: context.source, }) return false } diff --git a/apps/sim/lib/copilot/server-executor/types.ts b/apps/sim/lib/copilot/server-executor/types.ts index 774956dfa..c8df62f3e 100644 --- a/apps/sim/lib/copilot/server-executor/types.ts +++ b/apps/sim/lib/copilot/server-executor/types.ts @@ -21,6 +21,11 @@ export interface ToolResult { } } +/** + * Execution source - whether the request came from UI or headless API. + */ +export type ExecutionSource = 'ui' | 'headless' + /** * Context passed to tool executors. * @@ -33,6 +38,8 @@ export interface ExecutionContext { workflowId?: string workspaceId?: string chatId?: string + /** Whether request came from UI (with diff review) or headless API (direct save) */ + source?: ExecutionSource } /** diff --git a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts index 82be14ec0..b6e371cb5 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts @@ -1,3 +1,4 @@ +import { createLogger } from '@sim/logger' import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, @@ -5,15 +6,108 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' +import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' +import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff' +import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { mergeSubblockState } from '@/stores/workflows/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +interface EditWorkflowOperation { + operation_type: 'add' | 'edit' | 'delete' + block_id: string + params?: Record +} + +interface EditWorkflowArgs { + operations: EditWorkflowOperation[] + workflowId: string + currentUserWorkflow?: string +} export class EditWorkflowClientTool extends BaseClientTool { static readonly id = 'edit_workflow' + private lastResult: any | undefined + private hasExecuted = false + private hasAppliedDiff = false + private workflowId: string | undefined constructor(toolCallId: string) { super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata) } + async markToolComplete(status: number, message?: any, data?: any): Promise { + const logger = createLogger('EditWorkflowClientTool') + logger.info('markToolComplete payload', { + toolCallId: this.toolCallId, + toolName: this.name, + status, + message, + data, + }) + return super.markToolComplete(status, message, data) + } + + /** + * Get sanitized workflow JSON from a workflow state, merge subblocks, and sanitize for copilot + */ + private getSanitizedWorkflowJson(workflowState: any): string | undefined { + const logger = createLogger('EditWorkflowClientTool') + + if (!this.workflowId) { + logger.warn('No workflowId available for getting sanitized workflow JSON') + return undefined + } + + if (!workflowState) { + logger.warn('No workflow state provided') + return undefined + } + + try { + // Normalize required properties + if (!workflowState.loops) workflowState.loops = {} + if (!workflowState.parallels) workflowState.parallels = {} + if (!workflowState.edges) workflowState.edges = [] + if (!workflowState.blocks) workflowState.blocks = {} + + // Merge latest subblock values so edits are reflected + let mergedState = workflowState + if (workflowState.blocks) { + mergedState = { + ...workflowState, + blocks: mergeSubblockState(workflowState.blocks, this.workflowId as any), + } + } + + // Sanitize workflow state for copilot (remove UI-specific data) + const sanitizedState = sanitizeForCopilot(mergedState) + + // Convert to JSON string for transport + const workflowJson = JSON.stringify(sanitizedState, null, 2) + + return workflowJson + } catch (error) { + logger.warn('Failed to get sanitized workflow JSON', { + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } + } + + private getCurrentWorkflowJsonSafe(logger: ReturnType): string | undefined { + try { + const workflowStore = useWorkflowStore.getState() + const currentState = workflowStore.getWorkflowState() + return this.getSanitizedWorkflowJson(currentState) + } catch { + logger.warn('Failed to get current workflow JSON for error response') + return undefined + } + } + static readonly metadata: BaseClientToolMetadata = { displayNames: { [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 }, @@ -56,9 +150,163 @@ export class EditWorkflowClientTool extends BaseClientTool { }, } - // Executed server-side via handleToolCallEvent in stream-handler.ts - // Client tool provides UI metadata only for rendering tool call cards - // The server applies workflow changes directly in headless mode + handleAccept(): void { + const logger = createLogger('EditWorkflowClientTool') + logger.info('handleAccept called', { toolCallId: this.toolCallId, state: this.getState() }) + // The actual accept is handled by useWorkflowDiffStore.acceptChanges() + // This just updates the tool state + this.setState(ClientToolCallState.success) + } + + handleReject(): void { + const logger = createLogger('EditWorkflowClientTool') + logger.info('handleReject called', { toolCallId: this.toolCallId, state: this.getState() }) + // Tool was already marked complete in execute() - this is just for UI state + this.setState(ClientToolCallState.rejected) + } + + async execute(args?: EditWorkflowArgs): Promise { + const logger = createLogger('EditWorkflowClientTool') + + if (this.hasExecuted) { + logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId }) + return + } + + this.hasExecuted = true + this.setState(ClientToolCallState.executing) + + // Get workflow ID from args or active workflow + const workflowId = args?.workflowId || useWorkflowRegistry.getState().activeWorkflowId + if (!workflowId) { + this.setState(ClientToolCallState.error) + await this.markToolComplete(400, 'No workflow ID provided or active') + return + } + this.workflowId = workflowId + + logger.info('execute starting', { + toolCallId: this.toolCallId, + workflowId, + operationCount: args?.operations?.length, + }) + + try { + // Get current workflow state to send to server + const workflowStore = useWorkflowStore.getState() + const fullState = workflowStore.getWorkflowState() + const mergedBlocks = mergeSubblockState(fullState.blocks, workflowId as any) + const payloadState = stripWorkflowDiffMarkers({ + ...fullState, + blocks: mergedBlocks, + edges: fullState.edges || [], + loops: fullState.loops || {}, + parallels: fullState.parallels || {}, + }) + + // Call server to execute the tool (without saving to DB in UI mode) + const response = await fetch('/api/copilot/execute-copilot-server-tool', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + toolName: 'edit_workflow', + toolCallId: this.toolCallId, + args: { + ...args, + workflowId, + currentUserWorkflow: JSON.stringify(payloadState), + }, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Server execution failed', { status: response.status, error: errorText }) + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + response.status, + errorText || 'Server execution failed', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) + return + } + + const result = await response.json() + logger.info('Server execution result', { + success: result.success, + hasWorkflowState: !!result.data?.workflowState, + }) + + // Validate result + const parseResult = ExecuteResponseSuccessSchema.safeParse(result) + if (!parseResult.success || !result.data?.workflowState) { + logger.error('Invalid response from server', { errors: parseResult.error?.errors }) + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + 500, + 'Invalid response from server', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) + return + } + + this.lastResult = result.data + + // Apply the proposed state to the diff store for review + if (!this.hasAppliedDiff) { + const diffStore = useWorkflowDiffStore.getState() + // setProposedChanges applies the state optimistically to the workflow store + // and sets up diff markers for visual feedback + await diffStore.setProposedChanges(result.data.workflowState) + logger.info('Diff proposed changes set for edit_workflow with direct workflow state') + this.hasAppliedDiff = true + } + + // Read back the applied state from the workflow store + const actualDiffWorkflow = workflowStore.getWorkflowState() + + if (!actualDiffWorkflow) { + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + 500, + 'Failed to retrieve workflow state after applying changes', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) + return + } + + // Set state to review so user can accept/reject + this.setState(ClientToolCallState.review) + + // Mark tool complete with success - the workflow state is ready for review + const sanitizedJson = this.getSanitizedWorkflowJson(actualDiffWorkflow) + await this.markToolComplete( + 200, + result.data.inputValidationMessage || result.data.skippedItemsMessage || undefined, + sanitizedJson ? { userWorkflow: sanitizedJson } : undefined + ) + + logger.info('execute completed successfully - awaiting user review', { + toolCallId: this.toolCallId, + state: this.getState(), + }) + } catch (error) { + logger.error('execute failed with exception', { + toolCallId: this.toolCallId, + error: error instanceof Error ? error.message : String(error), + }) + this.setState(ClientToolCallState.error) + const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) + await this.markToolComplete( + 500, + error instanceof Error ? error.message : 'Unknown error', + currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined + ) + } + } } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index c0fe976f1..a71b1aacb 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -2631,7 +2631,7 @@ export const editWorkflowServerTool: BaseServerTool = { name: 'edit_workflow', async execute( params: EditWorkflowParams, - context?: { userId: string; workflowId?: string } + context?: { userId: string; workflowId?: string; workspaceId?: string; source?: 'ui' | 'headless' } ): Promise { const logger = createLogger('EditWorkflowServerTool') const { operations, currentUserWorkflow } = params @@ -2753,68 +2753,83 @@ export const editWorkflowServerTool: BaseServerTool = { const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState // ───────────────────────────────────────────────────────────────────────── - // PERSIST THE CHANGES TO THE DATABASE - // This is critical for headless mode and ensures changes are saved + // PERSIST THE CHANGES TO THE DATABASE (headless mode only) + // In UI mode, we return the proposed state for client-side diff review. + // The client will persist after user accepts the changes. + // In headless mode, we save directly since there's no UI to review. // ───────────────────────────────────────────────────────────────────────── - const workflowStateForPersistence = { - blocks: finalWorkflowState.blocks, - edges: finalWorkflowState.edges, - loops: finalWorkflowState.loops || {}, - parallels: finalWorkflowState.parallels || {}, - lastSaved: Date.now(), - } + const isHeadlessMode = context?.source !== 'ui' - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForPersistence) + if (isHeadlessMode) { + const workflowStateForPersistence = { + blocks: finalWorkflowState.blocks, + edges: finalWorkflowState.edges, + loops: finalWorkflowState.loops || {}, + parallels: finalWorkflowState.parallels || {}, + lastSaved: Date.now(), + } - if (!saveResult.success) { - logger.error('Failed to persist workflow changes to database', { - workflowId, - error: saveResult.error, + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForPersistence) + + if (!saveResult.success) { + logger.error('Failed to persist workflow changes to database', { + workflowId, + error: saveResult.error, + }) + throw new Error(`Failed to save workflow: ${saveResult.error}`) + } + + // Update workflow's lastSynced timestamp + await db + .update(workflowTable) + .set({ + lastSynced: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowTable.id, workflowId)) + + // Notify socket server so connected clients can refresh + // This uses the copilot-specific endpoint to trigger UI refresh + try { + const socketUrl = process.env.SOCKET_SERVER_URL || 'http://localhost:3002' + const operationsSummary = operations + .map((op: any) => `${op.operation_type} ${op.block_id || 'block'}`) + .slice(0, 3) + .join(', ') + const description = `Applied ${operations.length} operation(s): ${operationsSummary}${operations.length > 3 ? '...' : ''}` + + await fetch(`${socketUrl}/api/copilot-workflow-edit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflowId, description }), + }).catch((err) => { + logger.warn('Failed to notify socket server about copilot edit', { error: err.message }) + }) + } catch (notifyError) { + // Non-fatal - log and continue + logger.warn('Error notifying socket server', { error: notifyError }) + } + + logger.info('edit_workflow successfully applied and persisted operations (headless mode)', { + operationCount: operations.length, + blocksCount: Object.keys(finalWorkflowState.blocks).length, + edgesCount: finalWorkflowState.edges.length, + inputValidationErrors: validationErrors.length, + skippedItemsCount: skippedItems.length, + schemaValidationErrors: validation.errors.length, + validationWarnings: validation.warnings.length, }) - throw new Error(`Failed to save workflow: ${saveResult.error}`) - } - - // Update workflow's lastSynced timestamp - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), + } else { + // UI mode - don't persist, let client handle after user accepts + logger.info('edit_workflow returning proposed state for UI review (not persisted)', { + operationCount: operations.length, + blocksCount: Object.keys(finalWorkflowState.blocks).length, + edgesCount: finalWorkflowState.edges.length, + inputValidationErrors: validationErrors.length, + skippedItemsCount: skippedItems.length, }) - .where(eq(workflowTable.id, workflowId)) - - // Notify socket server so connected clients can refresh - // This uses the copilot-specific endpoint to trigger UI refresh - try { - const socketUrl = process.env.SOCKET_SERVER_URL || 'http://localhost:3002' - const operationsSummary = operations - .map((op: any) => `${op.operation_type} ${op.block_id || 'block'}`) - .slice(0, 3) - .join(', ') - const description = `Applied ${operations.length} operation(s): ${operationsSummary}${operations.length > 3 ? '...' : ''}` - - await fetch(`${socketUrl}/api/copilot-workflow-edit`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workflowId, description }), - }).catch((err) => { - logger.warn('Failed to notify socket server about copilot edit', { error: err.message }) - }) - } catch (notifyError) { - // Non-fatal - log and continue - logger.warn('Error notifying socket server', { error: notifyError }) } - logger.info('edit_workflow successfully applied and persisted operations', { - operationCount: operations.length, - blocksCount: Object.keys(finalWorkflowState.blocks).length, - edgesCount: finalWorkflowState.edges.length, - inputValidationErrors: validationErrors.length, - skippedItemsCount: skippedItems.length, - schemaValidationErrors: validation.errors.length, - validationWarnings: validation.warnings.length, - }) - // Format validation errors for LLM feedback const inputErrors = validationErrors.length > 0