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 deleted file mode 100644 index 3d6ab2e3a..000000000 --- a/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { - authenticateCopilotRequestSessionOnly, - createBadRequestResponse, - createInternalServerErrorResponse, - createRequestTracker, - createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' -import { routeExecution } from '@/lib/copilot/tools/server/router' - -const logger = createLogger('ExecuteCopilotServerToolAPI') - -const ExecuteSchema = z.object({ - toolName: z.string(), - payload: z.unknown().optional(), -}) - -/** - * @deprecated Transitional route used by the legacy client-side tool execution path - * (Zustand store → client tool classes → this route). Will be removed once the - * interactive browser path is fully migrated to server-side orchestration. - * New server-side code should use lib/copilot/orchestrator/tool-executor directly. - */ -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, { userId }) - - 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) - const errorMessage = error instanceof Error ? error.message : 'Failed to execute server tool' - return createInternalServerErrorResponse(errorMessage) - } -} diff --git a/apps/sim/lib/copilot/tools/client/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/client/knowledge/knowledge-base.ts index 611caa68f..0245c7dee 100644 --- a/apps/sim/lib/copilot/tools/client/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/client/knowledge/knowledge-base.ts @@ -90,8 +90,8 @@ export class KnowledgeBaseClientTool extends BaseClientTool { this.setState(ClientToolCallState.rejected) } - async handleAccept(args?: KnowledgeBaseArgs): Promise { - await this.execute(args) + async handleAccept(): Promise { + await this.execute() } async execute(): Promise { 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 55ffdaa93..6c56dc140 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,126 +5,14 @@ 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 - * This matches what get_user_workflow returns - */ - 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), - } - logger.info('Merged subblock values into workflow state', { - workflowId: this.workflowId, - blockCount: Object.keys(mergedState.blocks || {}).length, - }) - } - - // 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) - logger.info('Successfully created sanitized workflow JSON', { - workflowId: this.workflowId, - jsonLength: workflowJson.length, - }) - - return workflowJson - } catch (error) { - logger.error('Failed to get sanitized workflow JSON', { - error: error instanceof Error ? error.message : String(error), - }) - return undefined - } - } - - /** - * Safely get the current workflow JSON sanitized for copilot without throwing. - * Used to ensure we always include workflow state in markComplete. - */ - private getCurrentWorkflowJsonSafe(logger: ReturnType): string | undefined { - try { - const currentState = useWorkflowStore.getState().getWorkflowState() - if (!currentState) { - logger.warn('No current workflow state available') - return undefined - } - return this.getSanitizedWorkflowJson(currentState) - } catch (error) { - logger.warn('Failed to get current workflow JSON safely', { - error: error instanceof Error ? error.message : String(error), - }) - return undefined - } - } - static readonly metadata: BaseClientToolMetadata = { displayNames: { [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 }, @@ -141,284 +28,18 @@ export class EditWorkflowClientTool extends BaseClientTool { isSpecial: true, customRenderer: 'edit_summary', }, - getDynamicText: (params, state) => { - const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId - if (workflowId) { - const workflowName = useWorkflowRegistry.getState().workflows[workflowId]?.name - if (workflowName) { - switch (state) { - case ClientToolCallState.success: - return `Edited ${workflowName}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Editing ${workflowName}` - case ClientToolCallState.error: - return `Failed to edit ${workflowName}` - case ClientToolCallState.review: - return `Review changes to ${workflowName}` - case ClientToolCallState.rejected: - return `Rejected changes to ${workflowName}` - case ClientToolCallState.aborted: - return `Aborted editing ${workflowName}` - } - } - } - return undefined - }, } async handleAccept(): Promise { - const logger = createLogger('EditWorkflowClientTool') - logger.info('handleAccept called', { toolCallId: this.toolCallId, state: this.getState() }) - // Tool was already marked complete in execute() - this is just for UI state + // Diff store calls this after review acceptance. this.setState(ClientToolCallState.success) } - async handleReject(): Promise { - 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 - } - - // Use timeout protection to ensure tool always completes - await this.executeWithTimeout(async () => { - this.hasExecuted = true - logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args }) - this.setState(ClientToolCallState.executing) - - // Resolve workflowId - let workflowId = args?.workflowId - if (!workflowId) { - const { activeWorkflowId } = useWorkflowRegistry.getState() - workflowId = activeWorkflowId as any - } - if (!workflowId) { - this.setState(ClientToolCallState.error) - await this.markToolComplete(400, 'No active workflow found') - return - } - - // Store workflowId for later use - this.workflowId = workflowId - - // Validate operations - const operations = args?.operations || [] - if (!operations.length) { - this.setState(ClientToolCallState.error) - const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) - await this.markToolComplete( - 400, - 'No operations provided for edit_workflow', - currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined - ) - return - } - - // Prepare currentUserWorkflow JSON from stores to preserve block IDs - let currentUserWorkflow = args?.currentUserWorkflow - - if (!currentUserWorkflow) { - try { - 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 || {}, - }) - currentUserWorkflow = JSON.stringify(payloadState) - } catch (error) { - logger.warn('Failed to build currentUserWorkflow from stores; proceeding without it', { - error, - }) - } - } - - // Fetch with AbortController for timeout support - const controller = new AbortController() - const fetchTimeout = setTimeout(() => controller.abort(), 60000) // 60s fetch timeout - - try { - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - toolName: 'edit_workflow', - payload: { - operations, - workflowId, - ...(currentUserWorkflow ? { currentUserWorkflow } : {}), - }, - }), - signal: controller.signal, - }) - - clearTimeout(fetchTimeout) - - if (!res.ok) { - const errorText = await res.text().catch(() => '') - let errorMessage: string - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error || errorText || `Server error (${res.status})` - } catch { - errorMessage = errorText || `Server error (${res.status})` - } - // Mark complete with error but include current workflow state - this.setState(ClientToolCallState.error) - const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) - await this.markToolComplete( - res.status, - errorMessage, - currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined - ) - return - } - - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - const result = parsed.result as any - this.lastResult = result - logger.info('server result parsed', { - hasWorkflowState: !!result?.workflowState, - blocksCount: result?.workflowState - ? Object.keys(result.workflowState.blocks || {}).length - : 0, - hasSkippedItems: !!result?.skippedItems, - skippedItemsCount: result?.skippedItems?.length || 0, - hasInputValidationErrors: !!result?.inputValidationErrors, - inputValidationErrorsCount: result?.inputValidationErrors?.length || 0, - }) - - // Log skipped items and validation errors for visibility - if (result?.skippedItems?.length > 0) { - logger.warn('Some operations were skipped during edit_workflow', { - skippedItems: result.skippedItems, - }) - } - if (result?.inputValidationErrors?.length > 0) { - logger.warn('Some inputs were rejected during edit_workflow', { - inputValidationErrors: result.inputValidationErrors, - }) - } - - // Update diff directly with workflow state - no YAML conversion needed! - if (!result.workflowState) { - this.setState(ClientToolCallState.error) - const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) - await this.markToolComplete( - 500, - 'No workflow state returned from server', - currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined - ) - return - } - - let actualDiffWorkflow: WorkflowState | null = null - - if (!this.hasAppliedDiff) { - const diffStore = useWorkflowDiffStore.getState() - // setProposedChanges applies the state optimistically to the workflow store - await diffStore.setProposedChanges(result.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 workflowStore = useWorkflowStore.getState() - 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 - } - - // Get the workflow state that was just applied, merge subblocks, and sanitize - // This matches what get_user_workflow would return (the true state after edits were applied) - let workflowJson = this.getSanitizedWorkflowJson(actualDiffWorkflow) - - // Fallback: try to get current workflow state if sanitization failed - if (!workflowJson) { - workflowJson = this.getCurrentWorkflowJsonSafe(logger) - } - - // userWorkflow must always be present on success - log error if missing - if (!workflowJson) { - logger.error('Failed to get workflow JSON on success path - this should not happen', { - toolCallId: this.toolCallId, - workflowId: this.workflowId, - }) - } - - // Build sanitized data including workflow JSON and any skipped/validation info - // Always include userWorkflow on success paths - const sanitizedData: Record = { - userWorkflow: workflowJson ?? '{}', // Fallback to empty object JSON if all else fails - } - - // Include skipped items and validation errors in the response for LLM feedback - if (result?.skippedItems?.length > 0) { - sanitizedData.skippedItems = result.skippedItems - sanitizedData.skippedItemsMessage = result.skippedItemsMessage - } - if (result?.inputValidationErrors?.length > 0) { - sanitizedData.inputValidationErrors = result.inputValidationErrors - sanitizedData.inputValidationMessage = result.inputValidationMessage - } - - // Build a message that includes info about skipped items - let completeMessage = 'Workflow diff ready for review' - if (result?.skippedItems?.length > 0 || result?.inputValidationErrors?.length > 0) { - const parts: string[] = [] - if (result?.skippedItems?.length > 0) { - parts.push(`${result.skippedItems.length} operation(s) skipped`) - } - if (result?.inputValidationErrors?.length > 0) { - parts.push(`${result.inputValidationErrors.length} input(s) rejected`) - } - completeMessage = `Workflow diff ready for review. Note: ${parts.join(', ')}.` - } - - // Mark complete early to unblock LLM stream - sanitizedData always has userWorkflow - await this.markToolComplete(200, completeMessage, sanitizedData) - - // Move into review state - this.setState(ClientToolCallState.review, { result }) - } catch (fetchError: any) { - clearTimeout(fetchTimeout) - // Handle error with current workflow state - this.setState(ClientToolCallState.error) - const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger) - const errorMessage = - fetchError.name === 'AbortError' - ? 'Server request timed out' - : fetchError.message || String(fetchError) - await this.markToolComplete( - 500, - errorMessage, - currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined - ) - } - }) + async execute(): Promise { + // Tool execution is handled server-side by the orchestrator. + // The store's tool_result SSE handler applies the diff preview + // via diffStore.setProposedChanges() when the result arrives. + this.setState(ClientToolCallState.success) } }