diff --git a/apps/sim/lib/copilot/server-executor/registry.ts b/apps/sim/lib/copilot/server-executor/registry.ts index 2543e6506..79581d5e9 100644 --- a/apps/sim/lib/copilot/server-executor/registry.ts +++ b/apps/sim/lib/copilot/server-executor/registry.ts @@ -67,6 +67,22 @@ import { SetGlobalWorkflowVariablesInput, setGlobalWorkflowVariablesServerTool, } from '../tools/server/workflow/set-global-workflow-variables' +import { + GetBlockUpstreamReferencesInput, + getBlockUpstreamReferencesServerTool, +} from '../tools/server/workflow/get-block-upstream-references' +import { + GetWorkflowDataInput, + getWorkflowDataServerTool, +} from '../tools/server/workflow/get-workflow-data' +import { + ManageCustomToolInput, + manageCustomToolServerTool, +} from '../tools/server/workflow/manage-custom-tool' +import { + ManageMcpToolInput, + manageMcpToolServerTool, +} from '../tools/server/workflow/manage-mcp-tool' // Import schemas import { EditWorkflowInput, @@ -242,6 +258,26 @@ const TOOL_REGISTRY: Record = { requiresAuth: true, execute: createExecutor(getBlockOutputsServerTool), }, + get_block_upstream_references: { + inputSchema: GetBlockUpstreamReferencesInput, + requiresAuth: true, + execute: createExecutor(getBlockUpstreamReferencesServerTool), + }, + get_workflow_data: { + inputSchema: GetWorkflowDataInput, + requiresAuth: true, + execute: createExecutor(getWorkflowDataServerTool), + }, + manage_custom_tool: { + inputSchema: ManageCustomToolInput, + requiresAuth: true, + execute: createExecutor(manageCustomToolServerTool), + }, + manage_mcp_tool: { + inputSchema: ManageMcpToolInput, + requiresAuth: true, + execute: createExecutor(manageMcpToolServerTool), + }, // ───────────────────────────────────────────────────────────────────────── // Search Tools diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts index a76971df0..f946927ea 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts @@ -1,23 +1,11 @@ -import { createLogger } from '@sim/logger' import { FileCode, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - ExecuteResponseSuccessSchema, - GetBlockConfigInput, - GetBlockConfigResult, -} from '@/lib/copilot/tools/shared/schemas' import { getLatestBlock } from '@/blocks/registry' -interface GetBlockConfigArgs { - blockType: string - operation?: string - trigger?: boolean -} - export class GetBlockConfigClientTool extends BaseClientTool { static readonly id = 'get_block_config' @@ -63,38 +51,6 @@ export class GetBlockConfigClientTool extends BaseClientTool { }, } - async execute(args?: GetBlockConfigArgs): Promise { - const logger = createLogger('GetBlockConfigClientTool') - try { - this.setState(ClientToolCallState.executing) - - const { blockType, operation, trigger } = GetBlockConfigInput.parse(args || {}) - - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - toolName: 'get_block_config', - payload: { blockType, operation, trigger }, - }), - }) - if (!res.ok) { - const errorText = await res.text().catch(() => '') - throw new Error(errorText || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - const result = GetBlockConfigResult.parse(parsed.result) - - const inputCount = Object.keys(result.inputs).length - const outputCount = Object.keys(result.outputs).length - await this.markToolComplete(200, { inputs: inputCount, outputs: outputCount }, result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Execute failed', { message }) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts index 06efb6ffc..1f3c903d1 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts @@ -1,21 +1,11 @@ -import { createLogger } from '@sim/logger' import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - ExecuteResponseSuccessSchema, - GetBlockOptionsInput, - GetBlockOptionsResult, -} from '@/lib/copilot/tools/shared/schemas' import { getLatestBlock } from '@/blocks/registry' -interface GetBlockOptionsArgs { - blockId: string -} - export class GetBlockOptionsClientTool extends BaseClientTool { static readonly id = 'get_block_options' @@ -65,46 +55,6 @@ export class GetBlockOptionsClientTool extends BaseClientTool { }, } - async execute(args?: GetBlockOptionsArgs): Promise { - const logger = createLogger('GetBlockOptionsClientTool') - try { - this.setState(ClientToolCallState.executing) - - // Handle both camelCase and snake_case parameter names, plus blockType as an alias - const normalizedArgs = args - ? { - blockId: - args.blockId || - (args as any).block_id || - (args as any).blockType || - (args as any).block_type, - } - : {} - - logger.info('execute called', { originalArgs: args, normalizedArgs }) - - const { blockId } = GetBlockOptionsInput.parse(normalizedArgs) - - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'get_block_options', payload: { blockId } }), - }) - if (!res.ok) { - const errorText = await res.text().catch(() => '') - throw new Error(errorText || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - const result = GetBlockOptionsResult.parse(parsed.result) - - await this.markToolComplete(200, { operations: result.operations.length }, result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Execute failed', { message }) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/client/blocks/get-blocks-and-tools.ts index d57cb1d24..5eec0ba19 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-blocks-and-tools.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-blocks-and-tools.ts @@ -1,14 +1,9 @@ -import { createLogger } from '@sim/logger' import { Blocks, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - ExecuteResponseSuccessSchema, - GetBlocksAndToolsResult, -} from '@/lib/copilot/tools/shared/schemas' export class GetBlocksAndToolsClientTool extends BaseClientTool { static readonly id = 'get_blocks_and_tools' @@ -30,30 +25,6 @@ export class GetBlocksAndToolsClientTool extends BaseClientTool { interrupt: undefined, } - async execute(): Promise { - const logger = createLogger('GetBlocksAndToolsClientTool') - try { - this.setState(ClientToolCallState.executing) - - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'get_blocks_and_tools', payload: {} }), - }) - if (!res.ok) { - const errorText = await res.text().catch(() => '') - throw new Error(errorText || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - const result = GetBlocksAndToolsResult.parse(parsed.result) - - await this.markToolComplete(200, 'Successfully retrieved blocks and tools', result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts b/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts index 8fd88b1a3..9b014bf70 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts @@ -1,19 +1,9 @@ -import { createLogger } from '@sim/logger' import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - ExecuteResponseSuccessSchema, - GetBlocksMetadataInput, - GetBlocksMetadataResult, -} from '@/lib/copilot/tools/shared/schemas' - -interface GetBlocksMetadataArgs { - blockIds: string[] -} export class GetBlocksMetadataClientTool extends BaseClientTool { static readonly id = 'get_blocks_metadata' @@ -63,33 +53,6 @@ export class GetBlocksMetadataClientTool extends BaseClientTool { }, } - async execute(args?: GetBlocksMetadataArgs): Promise { - const logger = createLogger('GetBlocksMetadataClientTool') - try { - this.setState(ClientToolCallState.executing) - - const { blockIds } = GetBlocksMetadataInput.parse(args || {}) - - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'get_blocks_metadata', payload: { blockIds } }), - }) - if (!res.ok) { - const errorText = await res.text().catch(() => '') - throw new Error(errorText || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - const result = GetBlocksMetadataResult.parse(parsed.result) - - await this.markToolComplete(200, { retrieved: Object.keys(result.metadata).length }, result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Execute failed', { message }) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/client/blocks/get-trigger-blocks.ts index c9fa0f78a..4198b990f 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-trigger-blocks.ts @@ -1,14 +1,9 @@ -import { createLogger } from '@sim/logger' import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - ExecuteResponseSuccessSchema, - GetTriggerBlocksResult, -} from '@/lib/copilot/tools/shared/schemas' export class GetTriggerBlocksClientTool extends BaseClientTool { static readonly id = 'get_trigger_blocks' @@ -30,35 +25,6 @@ export class GetTriggerBlocksClientTool extends BaseClientTool { interrupt: undefined, } - async execute(): Promise { - const logger = createLogger('GetTriggerBlocksClientTool') - try { - this.setState(ClientToolCallState.executing) - - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'get_trigger_blocks', payload: {} }), - }) - if (!res.ok) { - const errorText = await res.text().catch(() => '') - try { - const errorJson = JSON.parse(errorText) - throw new Error(errorJson.error || errorText || `Server error (${res.status})`) - } catch { - throw new Error(errorText || `Server error (${res.status})`) - } - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - const result = GetTriggerBlocksResult.parse(parsed.result) - - await this.markToolComplete(200, 'Successfully retrieved trigger blocks', result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } 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 41afc2e85..1f6743ba5 100644 --- a/apps/sim/lib/copilot/tools/client/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/client/knowledge/knowledge-base.ts @@ -1,14 +1,10 @@ -import { createLogger } from '@sim/logger' import { Database, Loader2, MinusCircle, PlusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - ExecuteResponseSuccessSchema, - type KnowledgeBaseArgs, -} from '@/lib/copilot/tools/shared/schemas' +import type { KnowledgeBaseArgs } from '@/lib/copilot/tools/shared/schemas' import { useCopilotStore } from '@/stores/panel/copilot/store' /** @@ -89,42 +85,6 @@ export class KnowledgeBaseClientTool extends BaseClientTool { }, } - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: KnowledgeBaseArgs): Promise { - await this.execute(args) - } - - async execute(args?: KnowledgeBaseArgs): Promise { - const logger = createLogger('KnowledgeBaseClientTool') - try { - this.setState(ClientToolCallState.executing) - const payload: KnowledgeBaseArgs = { ...(args || { operation: 'list' }) } - - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'knowledge_base', payload }), - }) - - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Knowledge base operation completed', parsed.result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to access knowledge base') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts b/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts index 2a925d82d..f3d4974f5 100644 --- a/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts +++ b/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Check, Loader2, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,11 +5,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -interface CheckoffTodoArgs { - id?: string - todoId?: string -} - export class CheckoffTodoClientTool extends BaseClientTool { static readonly id = 'checkoff_todo' @@ -27,35 +21,6 @@ export class CheckoffTodoClientTool extends BaseClientTool { }, } - async execute(args?: CheckoffTodoArgs): Promise { - const logger = createLogger('CheckoffTodoClientTool') - try { - this.setState(ClientToolCallState.executing) - - const todoId = args?.id || args?.todoId - if (!todoId) { - this.setState(ClientToolCallState.error) - await this.markToolComplete(400, 'Missing todo id') - return - } - - try { - const { useCopilotStore } = await import('@/stores/panel/copilot/store') - const store = useCopilotStore.getState() - if (store.updatePlanTodoStatus) { - store.updatePlanTodoStatus(todoId, 'completed') - } - } catch (e) { - logger.warn('Failed to update todo status in store', { message: (e as any)?.message }) - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Todo checked off', { todoId }) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to check off todo') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/other/make-api-request.ts b/apps/sim/lib/copilot/tools/client/other/make-api-request.ts index 051622c05..855779f20 100644 --- a/apps/sim/lib/copilot/tools/client/other/make-api-request.ts +++ b/apps/sim/lib/copilot/tools/client/other/make-api-request.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Globe2, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,15 +5,6 @@ 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' - -interface MakeApiRequestArgs { - url: string - method: 'GET' | 'POST' | 'PUT' - queryParams?: Record - headers?: Record - body?: any -} export class MakeApiRequestClientTool extends BaseClientTool { static readonly id = 'make_api_request' @@ -88,39 +78,8 @@ export class MakeApiRequestClientTool extends BaseClientTool { }, } - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: MakeApiRequestArgs): Promise { - const logger = createLogger('MakeApiRequestClientTool') - try { - this.setState(ClientToolCallState.executing) - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'make_api_request', payload: args || {} }), - }) - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'API request executed', parsed.result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'API request failed') - } - } - - async execute(args?: MakeApiRequestArgs): Promise { - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts b/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts index fbed86ea8..d77aa51d2 100644 --- a/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts +++ b/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,11 +5,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -interface MarkTodoInProgressArgs { - id?: string - todoId?: string -} - export class MarkTodoInProgressClientTool extends BaseClientTool { static readonly id = 'mark_todo_in_progress' @@ -30,35 +24,6 @@ export class MarkTodoInProgressClientTool extends BaseClientTool { }, } - async execute(args?: MarkTodoInProgressArgs): Promise { - const logger = createLogger('MarkTodoInProgressClientTool') - try { - this.setState(ClientToolCallState.executing) - - const todoId = args?.id || args?.todoId - if (!todoId) { - this.setState(ClientToolCallState.error) - await this.markToolComplete(400, 'Missing todo id') - return - } - - try { - const { useCopilotStore } = await import('@/stores/panel/copilot/store') - const store = useCopilotStore.getState() - if (store.updatePlanTodoStatus) { - store.updatePlanTodoStatus(todoId, 'executing') - } - } catch (e) { - logger.warn('Failed to update todo status in store', { message: (e as any)?.message }) - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Todo marked in progress', { todoId }) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to mark todo in progress') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/other/search-documentation.ts b/apps/sim/lib/copilot/tools/client/other/search-documentation.ts index cf784d3f2..9329648e0 100644 --- a/apps/sim/lib/copilot/tools/client/other/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/client/other/search-documentation.ts @@ -1,17 +1,9 @@ -import { createLogger } from '@sim/logger' import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' - -interface SearchDocumentationArgs { - query: string - topK?: number - threshold?: number -} export class SearchDocumentationClientTool extends BaseClientTool { static readonly id = 'search_documentation' @@ -53,28 +45,6 @@ export class SearchDocumentationClientTool extends BaseClientTool { }, } - async execute(args?: SearchDocumentationArgs): Promise { - const logger = createLogger('SearchDocumentationClientTool') - try { - this.setState(ClientToolCallState.executing) - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'search_documentation', payload: args || {} }), - }) - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Documentation search complete', parsed.result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Documentation search failed') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/other/search-online.ts b/apps/sim/lib/copilot/tools/client/other/search-online.ts index 083658468..786fbb065 100644 --- a/apps/sim/lib/copilot/tools/client/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/client/other/search-online.ts @@ -46,7 +46,6 @@ export class SearchOnlineClientTool extends BaseClientTool { }, } - async execute(): Promise { - return - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/other/sleep.ts b/apps/sim/lib/copilot/tools/client/other/sleep.ts index 91949ea81..65332a305 100644 --- a/apps/sim/lib/copilot/tools/client/other/sleep.ts +++ b/apps/sim/lib/copilot/tools/client/other/sleep.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, MinusCircle, Moon, XCircle } from 'lucide-react' import { BaseClientTool, @@ -7,16 +6,6 @@ import { } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' -/** Maximum sleep duration in seconds (3 minutes) */ -const MAX_SLEEP_SECONDS = 180 - -/** Track sleep start times for calculating elapsed time on wake */ -const sleepStartTimes: Record = {} - -interface SleepArgs { - seconds?: number -} - /** * Format seconds into a human-readable duration string */ @@ -87,70 +76,8 @@ export class SleepClientTool extends BaseClientTool { }, } - /** - * Get elapsed seconds since sleep started - */ - getElapsedSeconds(): number { - const startTime = sleepStartTimes[this.toolCallId] - if (!startTime) return 0 - return (Date.now() - startTime) / 1000 - } - - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: SleepArgs): Promise { - const logger = createLogger('SleepClientTool') - - // Use a timeout slightly longer than max sleep (3 minutes + buffer) - const timeoutMs = (MAX_SLEEP_SECONDS + 30) * 1000 - - await this.executeWithTimeout(async () => { - const params = args || {} - logger.debug('handleAccept() called', { - toolCallId: this.toolCallId, - state: this.getState(), - hasArgs: !!args, - seconds: params.seconds, - }) - - // Validate and clamp seconds - let seconds = typeof params.seconds === 'number' ? params.seconds : 0 - if (seconds < 0) seconds = 0 - if (seconds > MAX_SLEEP_SECONDS) seconds = MAX_SLEEP_SECONDS - - logger.debug('Starting sleep', { seconds }) - - // Track start time for elapsed calculation - sleepStartTimes[this.toolCallId] = Date.now() - - this.setState(ClientToolCallState.executing) - - try { - // Sleep for the specified duration - await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) - - logger.debug('Sleep completed successfully') - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Slept for ${seconds} seconds`) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Sleep failed', { error: message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, message) - } finally { - // Clean up start time tracking - delete sleepStartTimes[this.toolCallId] - } - }, timeoutMs) - } - - async execute(args?: SleepArgs): Promise { - // Auto-execute without confirmation - go straight to executing - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/client/user/get-credentials.ts b/apps/sim/lib/copilot/tools/client/user/get-credentials.ts index 8ad821b14..ffe969069 100644 --- a/apps/sim/lib/copilot/tools/client/user/get-credentials.ts +++ b/apps/sim/lib/copilot/tools/client/user/get-credentials.ts @@ -1,17 +1,9 @@ -import { createLogger } from '@sim/logger' import { Key, Loader2, MinusCircle, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface GetCredentialsArgs { - userId?: string - workflowId?: string -} export class GetCredentialsClientTool extends BaseClientTool { static readonly id = 'get_credentials' @@ -41,33 +33,6 @@ export class GetCredentialsClientTool extends BaseClientTool { }, } - async execute(args?: GetCredentialsArgs): Promise { - const logger = createLogger('GetCredentialsClientTool') - try { - this.setState(ClientToolCallState.executing) - const payload: GetCredentialsArgs = { ...(args || {}) } - if (!payload.workflowId && !payload.userId) { - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (activeWorkflowId) payload.workflowId = activeWorkflowId - } - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'get_credentials', payload }), - }) - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Connected integrations fetched', parsed.result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to fetch connected integrations') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts index e4033ca85..54aeb6d6e 100644 --- a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, Settings2, X, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,14 +5,6 @@ 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 { useEnvironmentStore } from '@/stores/settings/environment' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface SetEnvArgs { - variables: Record - workflowId?: string -} export class SetEnvironmentVariablesClientTool extends BaseClientTool { static readonly id = 'set_environment_variables' @@ -102,52 +93,8 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool { }, } - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: SetEnvArgs): Promise { - const logger = createLogger('SetEnvironmentVariablesClientTool') - try { - this.setState(ClientToolCallState.executing) - const payload: SetEnvArgs = { ...(args || { variables: {} }) } - if (!payload.workflowId) { - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (activeWorkflowId) payload.workflowId = activeWorkflowId - } - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'set_environment_variables', payload }), - }) - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Environment variables updated', parsed.result) - this.setState(ClientToolCallState.success) - - // Refresh the environment store so the UI reflects the new variables - try { - await useEnvironmentStore.getState().loadEnvironmentVariables() - logger.info('Environment store refreshed after setting variables') - } catch (error) { - logger.warn('Failed to refresh environment store:', error) - } - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to set environment variables') - } - } - - async execute(args?: SetEnvArgs): Promise { - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts b/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts index a0d3de72e..e0bebad33 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts @@ -1,50 +1,9 @@ -import { createLogger } from '@sim/logger' import { Loader2, Rocket, X, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface CheckDeploymentStatusArgs { - workflowId?: string -} - -interface ApiDeploymentDetails { - isDeployed: boolean - deployedAt: string | null - endpoint: string | null - apiKey: string | null - needsRedeployment: boolean -} - -interface ChatDeploymentDetails { - isDeployed: boolean - chatId: string | null - identifier: string | null - chatUrl: string | null - title: string | null - description: string | null - authType: string | null - allowedEmails: string[] | null - outputConfigs: Array<{ blockId: string; path: string }> | null - welcomeMessage: string | null - primaryColor: string | null - hasPassword: boolean -} - -interface McpDeploymentDetails { - isDeployed: boolean - servers: Array<{ - serverId: string - serverName: string - toolName: string - toolDescription: string | null - parameterSchema?: Record | null - toolId?: string | null - }> -} export class CheckDeploymentStatusClientTool extends BaseClientTool { static readonly id = 'check_deployment_status' @@ -75,141 +34,6 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool { interrupt: undefined, } - async execute(args?: CheckDeploymentStatusArgs): Promise { - const logger = createLogger('CheckDeploymentStatusClientTool') - try { - this.setState(ClientToolCallState.executing) - - const { activeWorkflowId, workflows } = useWorkflowRegistry.getState() - const workflowId = args?.workflowId || activeWorkflowId - - if (!workflowId) { - throw new Error('No workflow ID provided') - } - - const workflow = workflows[workflowId] - const workspaceId = workflow?.workspaceId - - // Fetch deployment status from all sources - const [apiDeployRes, chatDeployRes, mcpServersRes] = await Promise.all([ - fetch(`/api/workflows/${workflowId}/deploy`), - fetch(`/api/workflows/${workflowId}/chat/status`), - workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null, - ]) - - const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null - const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null - const mcpServers = mcpServersRes?.ok ? await mcpServersRes.json() : null - - // API deployment details - const isApiDeployed = apiDeploy?.isDeployed || false - const appUrl = typeof window !== 'undefined' ? window.location.origin : '' - const apiDetails: ApiDeploymentDetails = { - isDeployed: isApiDeployed, - deployedAt: apiDeploy?.deployedAt || null, - endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null, - apiKey: apiDeploy?.apiKey || null, - needsRedeployment: apiDeploy?.needsRedeployment === true, - } - - // Chat deployment details - const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment) - const chatDetails: ChatDeploymentDetails = { - isDeployed: isChatDeployed, - chatId: chatDeploy?.deployment?.id || null, - identifier: chatDeploy?.deployment?.identifier || null, - chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null, - title: chatDeploy?.deployment?.title || null, - description: chatDeploy?.deployment?.description || null, - authType: chatDeploy?.deployment?.authType || null, - allowedEmails: Array.isArray(chatDeploy?.deployment?.allowedEmails) - ? chatDeploy?.deployment?.allowedEmails - : null, - outputConfigs: Array.isArray(chatDeploy?.deployment?.outputConfigs) - ? chatDeploy?.deployment?.outputConfigs - : null, - welcomeMessage: chatDeploy?.deployment?.customizations?.welcomeMessage || null, - primaryColor: chatDeploy?.deployment?.customizations?.primaryColor || null, - hasPassword: chatDeploy?.deployment?.hasPassword === true, - } - - // MCP deployment details - find servers that have this workflow as a tool - const mcpServerList = mcpServers?.data?.servers || [] - const mcpToolDeployments: McpDeploymentDetails['servers'] = [] - - for (const server of mcpServerList) { - // Check if this workflow is deployed as a tool on this server - if (server.toolNames && Array.isArray(server.toolNames)) { - // We need to fetch the actual tools to check if this workflow is there - try { - const toolsRes = await fetch( - `/api/mcp/workflow-servers/${server.id}/tools?workspaceId=${workspaceId}` - ) - if (toolsRes.ok) { - const toolsData = await toolsRes.json() - const tools = toolsData.data?.tools || [] - for (const tool of tools) { - if (tool.workflowId === workflowId) { - mcpToolDeployments.push({ - serverId: server.id, - serverName: server.name, - toolName: tool.toolName, - toolDescription: tool.toolDescription, - parameterSchema: tool.parameterSchema ?? null, - toolId: tool.id ?? null, - }) - } - } - } - } catch { - // Skip this server if we can't fetch tools - } - } - } - - const isMcpDeployed = mcpToolDeployments.length > 0 - const mcpDetails: McpDeploymentDetails = { - isDeployed: isMcpDeployed, - servers: mcpToolDeployments, - } - - // Build deployment types list - const deploymentTypes: string[] = [] - if (isApiDeployed) deploymentTypes.push('api') - if (isChatDeployed) deploymentTypes.push('chat') - if (isMcpDeployed) deploymentTypes.push('mcp') - - const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed - - // Build summary message - let message = '' - if (!isDeployed) { - message = 'Workflow is not deployed' - } else { - const parts: string[] = [] - if (isApiDeployed) parts.push('API') - if (isChatDeployed) parts.push(`Chat (${chatDetails.identifier})`) - if (isMcpDeployed) { - const serverNames = mcpToolDeployments.map((d) => d.serverName).join(', ') - parts.push(`MCP (${serverNames})`) - } - message = `Workflow is deployed as: ${parts.join(', ')}` - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, message, { - isDeployed, - deploymentTypes, - api: apiDetails, - chat: chatDetails, - mcp: mcpDetails, - }) - - logger.info('Checked deployment status', { isDeployed, deploymentTypes }) - } catch (e: any) { - logger.error('Check deployment status failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to check deployment status') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts b/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts index f50832184..7db01b8fc 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, Plus, Server, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,7 +5,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { useCopilotStore } from '@/stores/panel/copilot/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export interface CreateWorkspaceMcpServerArgs { /** Name of the MCP server */ @@ -79,77 +77,6 @@ export class CreateWorkspaceMcpServerClientTool extends BaseClientTool { }, } - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: CreateWorkspaceMcpServerArgs): Promise { - const logger = createLogger('CreateWorkspaceMcpServerClientTool') - try { - if (!args?.name) { - throw new Error('Server name is required') - } - - // Get workspace ID from active workflow if not provided - const { activeWorkflowId, workflows } = useWorkflowRegistry.getState() - let workspaceId = args?.workspaceId - - if (!workspaceId && activeWorkflowId) { - workspaceId = workflows[activeWorkflowId]?.workspaceId - } - - if (!workspaceId) { - throw new Error('No workspace ID available') - } - - this.setState(ClientToolCallState.executing) - - const res = await fetch('/api/mcp/workflow-servers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workspaceId, - name: args.name.trim(), - description: args.description?.trim() || null, - }), - }) - - const data = await res.json() - - if (!res.ok) { - throw new Error(data.error || `Failed to create MCP server (${res.status})`) - } - - const server = data.data?.server - if (!server) { - throw new Error('Server creation response missing server data') - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete( - 200, - `MCP server "${args.name}" created successfully. You can now deploy workflows to it using deploy_mcp.`, - { - success: true, - serverId: server.id, - serverName: server.name, - description: server.description, - } - ) - - logger.info(`Created MCP server: ${server.name} (${server.id})`) - } catch (e: any) { - logger.error('Failed to create MCP server', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to create MCP server', { - success: false, - error: e?.message, - }) - } - } - - async execute(args?: CreateWorkspaceMcpServerArgs): Promise { - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts index c850dd493..bdbfc7861 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, Rocket, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,8 +5,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils' import { useCopilotStore } from '@/stores/panel/copilot/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -125,161 +122,8 @@ export class DeployApiClientTool extends BaseClientTool { }, } - /** - * Checks if the user has any API keys (workspace or personal) - */ - private async hasApiKeys(workspaceId: string): Promise { - try { - const [workspaceRes, personalRes] = await Promise.all([ - fetch(`/api/workspaces/${workspaceId}/api-keys`), - fetch('/api/users/me/api-keys'), - ]) - - if (!workspaceRes.ok || !personalRes.ok) { - return false - } - - const workspaceData = await workspaceRes.json() - const personalData = await personalRes.json() - - const workspaceKeys = (workspaceData?.keys || []) as Array - const personalKeys = (personalData?.keys || []) as Array - - return workspaceKeys.length > 0 || personalKeys.length > 0 - } catch (error) { - const logger = createLogger('DeployApiClientTool') - logger.warn('Failed to check API keys:', error) - return false - } - } - - /** - * Opens the settings modal to the API keys tab - */ - private openApiKeysModal(): void { - window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } })) - } - - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: DeployApiArgs): Promise { - const logger = createLogger('DeployApiClientTool') - try { - const action = args?.action || 'deploy' - const { activeWorkflowId, workflows } = useWorkflowRegistry.getState() - const workflowId = args?.workflowId || activeWorkflowId - - if (!workflowId) { - throw new Error('No workflow ID provided') - } - - const workflow = workflows[workflowId] - const workspaceId = workflow?.workspaceId - - // For deploy action, check if user has API keys first - if (action === 'deploy') { - if (!workspaceId) { - throw new Error('Workflow workspace not found') - } - - const hasKeys = await this.hasApiKeys(workspaceId) - - if (!hasKeys) { - this.setState(ClientToolCallState.rejected) - this.openApiKeysModal() - - await this.markToolComplete( - 200, - 'Cannot deploy without an API key. Opened API key settings so you can create one. Once you have an API key, try deploying again.', - { - needsApiKey: true, - message: - 'You need to create an API key before you can deploy your workflow. The API key settings have been opened for you. After creating an API key, you can deploy your workflow.', - } - ) - return - } - } - - this.setState(ClientToolCallState.executing) - - const endpoint = `/api/workflows/${workflowId}/deploy` - const method = action === 'deploy' ? 'POST' : 'DELETE' - - const res = await fetch(endpoint, { - method, - headers: { 'Content-Type': 'application/json' }, - body: action === 'deploy' ? JSON.stringify({ deployChatEnabled: false }) : undefined, - }) - - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Server error (${res.status})`) - } - - const json = await res.json() - - let successMessage = '' - let resultData: any = { - action, - isDeployed: action === 'deploy', - deployedAt: json.deployedAt, - } - - if (action === 'deploy') { - const appUrl = getBaseUrl() - const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute` - const apiKeyPlaceholder = '$SIM_API_KEY' - - const inputExample = getInputFormatExample(false) - const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${apiEndpoint}` - - successMessage = 'Workflow deployed successfully as API. You can now call it via REST.' - - resultData = { - ...resultData, - endpoint: apiEndpoint, - curlCommand, - apiKeyPlaceholder, - } - } else { - successMessage = 'Workflow undeployed successfully.' - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, successMessage, resultData) - - // Refresh the workflow registry to update deployment status - try { - const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus - if (action === 'deploy') { - setDeploymentStatus( - workflowId, - true, - json.deployedAt ? new Date(json.deployedAt) : undefined, - json.apiKey || '' - ) - } else { - setDeploymentStatus(workflowId, false, undefined, '') - } - const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed' - logger.info(`Workflow ${actionPast} as API and registry updated`) - } catch (error) { - logger.warn('Failed to update workflow registry:', error) - } - } catch (e: any) { - logger.error('Deploy API failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to deploy API') - } - } - - async execute(args?: DeployApiArgs): Promise { - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts index 24ad19a53..c4db8c4b6 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, MessageSquare, XCircle } from 'lucide-react' import { BaseClientTool, @@ -7,7 +6,6 @@ import { } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' import { useCopilotStore } from '@/stores/panel/copilot/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type ChatAuthType = 'public' | 'password' | 'email' | 'sso' @@ -118,263 +116,8 @@ export class DeployChatClientTool extends BaseClientTool { }, } - /** - * Generates a default identifier from the workflow name - */ - private generateIdentifier(workflowName: string): string { - return workflowName - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .substring(0, 50) - } - - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: DeployChatArgs): Promise { - const logger = createLogger('DeployChatClientTool') - try { - const action = args?.action || 'deploy' - const { activeWorkflowId, workflows } = useWorkflowRegistry.getState() - const workflowId = args?.workflowId || activeWorkflowId - - if (!workflowId) { - throw new Error('No workflow ID provided') - } - - const workflow = workflows[workflowId] - - // Handle undeploy action - if (action === 'undeploy') { - this.setState(ClientToolCallState.executing) - - // First get the chat deployment ID - const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`) - if (!statusRes.ok) { - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, 'Failed to check chat deployment status', { - success: false, - action: 'undeploy', - isDeployed: false, - error: 'Failed to check chat deployment status', - errorCode: 'SERVER_ERROR', - }) - return - } - - const statusJson = await statusRes.json() - if (!statusJson.isDeployed || !statusJson.deployment?.id) { - this.setState(ClientToolCallState.error) - await this.markToolComplete(400, 'No active chat deployment found for this workflow', { - success: false, - action: 'undeploy', - isDeployed: false, - error: 'No active chat deployment found for this workflow', - errorCode: 'VALIDATION_ERROR', - }) - return - } - - const chatId = statusJson.deployment.id - - // Delete the chat deployment - const res = await fetch(`/api/chat/manage/${chatId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - }) - - if (!res.ok) { - const txt = await res.text().catch(() => '') - this.setState(ClientToolCallState.error) - await this.markToolComplete(res.status, txt || `Server error (${res.status})`, { - success: false, - action: 'undeploy', - isDeployed: true, - error: txt || 'Failed to undeploy chat', - errorCode: 'SERVER_ERROR', - }) - return - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Chat deployment removed successfully.', { - success: true, - action: 'undeploy', - isDeployed: false, - }) - return - } - - this.setState(ClientToolCallState.executing) - - const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`) - const statusJson = statusRes.ok ? await statusRes.json() : null - const existingDeployment = statusJson?.deployment || null - - const baseIdentifier = - existingDeployment?.identifier || this.generateIdentifier(workflow?.name || 'chat') - const baseTitle = existingDeployment?.title || workflow?.name || 'Chat' - const baseDescription = existingDeployment?.description || '' - const baseAuthType = existingDeployment?.authType || 'public' - const baseWelcomeMessage = - existingDeployment?.customizations?.welcomeMessage || 'Hi there! How can I help you today?' - const basePrimaryColor = - existingDeployment?.customizations?.primaryColor || 'var(--brand-primary-hover-hex)' - const baseAllowedEmails = Array.isArray(existingDeployment?.allowedEmails) - ? existingDeployment.allowedEmails - : [] - const baseOutputConfigs = Array.isArray(existingDeployment?.outputConfigs) - ? existingDeployment.outputConfigs - : [] - - const identifier = args?.identifier || baseIdentifier - const title = args?.title || baseTitle - const description = args?.description ?? baseDescription - const authType = args?.authType || baseAuthType - const welcomeMessage = args?.welcomeMessage || baseWelcomeMessage - const outputConfigs = args?.outputConfigs || baseOutputConfigs - const allowedEmails = args?.allowedEmails || baseAllowedEmails - const primaryColor = basePrimaryColor - - if (!identifier || !title) { - throw new Error('Chat identifier and title are required') - } - - if (authType === 'password' && !args?.password && !existingDeployment?.hasPassword) { - throw new Error('Password is required when using password protection') - } - - if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) { - throw new Error(`At least one email or domain is required when using ${authType} access`) - } - - const payload = { - workflowId, - identifier: identifier.trim(), - title: title.trim(), - description: description.trim(), - customizations: { - primaryColor, - welcomeMessage: welcomeMessage.trim(), - }, - authType, - password: authType === 'password' ? args?.password : undefined, - allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], - outputConfigs, - } - - const isUpdating = Boolean(existingDeployment?.id) - const endpoint = isUpdating ? `/api/chat/manage/${existingDeployment.id}` : '/api/chat' - const method = isUpdating ? 'PATCH' : 'POST' - - const res = await fetch(endpoint, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - - const json = await res.json() - - if (!res.ok) { - if (json.error === 'Identifier already in use') { - this.setState(ClientToolCallState.error) - await this.markToolComplete( - 400, - `The identifier "${identifier}" is already in use. Please choose a different one.`, - { - success: false, - action: 'deploy', - isDeployed: false, - identifier, - error: `Identifier "${identifier}" is already taken`, - errorCode: 'IDENTIFIER_TAKEN', - } - ) - return - } - - // Handle validation errors - if (json.code === 'VALIDATION_ERROR') { - this.setState(ClientToolCallState.error) - await this.markToolComplete(400, json.error || 'Validation error', { - success: false, - action: 'deploy', - isDeployed: false, - error: json.error, - errorCode: 'VALIDATION_ERROR', - }) - return - } - - this.setState(ClientToolCallState.error) - await this.markToolComplete(res.status, json.error || 'Failed to deploy chat', { - success: false, - action: 'deploy', - isDeployed: false, - error: json.error || 'Server error', - errorCode: 'SERVER_ERROR', - }) - return - } - - if (!json.chatUrl) { - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, 'Response missing chat URL', { - success: false, - action: 'deploy', - isDeployed: false, - error: 'Response missing chat URL', - errorCode: 'SERVER_ERROR', - }) - return - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete( - 200, - `Chat deployed successfully! Available at: ${json.chatUrl}`, - { - success: true, - action: 'deploy', - isDeployed: true, - chatId: json.id, - chatUrl: json.chatUrl, - identifier, - title, - authType, - } - ) - - // Update the workflow registry to reflect deployment status - // Chat deployment also deploys the API, so we update the registry - try { - const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus - setDeploymentStatus(workflowId, true, new Date(), '') - logger.info('Workflow deployment status updated in registry') - } catch (error) { - logger.warn('Failed to update workflow registry:', error) - } - - logger.info('Chat deployed successfully:', json.chatUrl) - } catch (e: any) { - logger.error('Deploy chat failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to deploy chat', { - success: false, - action: 'deploy', - isDeployed: false, - error: e?.message || 'Failed to deploy chat', - errorCode: 'SERVER_ERROR', - }) - } - } - - async execute(args?: DeployChatArgs): Promise { - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts index bcd87fc25..a51c3d569 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, Server, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,7 +5,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export interface ParameterDescription { name: string @@ -88,162 +86,8 @@ export class DeployMcpClientTool extends BaseClientTool { }, } - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: DeployMcpArgs): Promise { - const logger = createLogger('DeployMcpClientTool') - try { - if (!args?.serverId) { - throw new Error( - 'Server ID is required. Use list_workspace_mcp_servers to get available servers.' - ) - } - - const { activeWorkflowId, workflows } = useWorkflowRegistry.getState() - const workflowId = args?.workflowId || activeWorkflowId - - if (!workflowId) { - throw new Error('No workflow ID available') - } - - const workflow = workflows[workflowId] - const workspaceId = workflow?.workspaceId - - if (!workspaceId) { - throw new Error('Workflow workspace not found') - } - - // Check if workflow is deployed - const deploymentStatus = useWorkflowRegistry - .getState() - .getWorkflowDeploymentStatus(workflowId) - if (!deploymentStatus?.isDeployed) { - throw new Error( - 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.' - ) - } - - this.setState(ClientToolCallState.executing) - - let parameterSchema: Record | undefined - if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) { - const properties: Record = {} - for (const param of args.parameterDescriptions) { - properties[param.name] = { description: param.description } - } - parameterSchema = { properties } - } - - const res = await fetch( - `/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - toolName: args.toolName?.trim(), - toolDescription: args.toolDescription?.trim(), - parameterSchema, - }), - } - ) - - const data = await res.json() - - if (!res.ok) { - if (data.error?.includes('already added')) { - const toolsRes = await fetch( - `/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}` - ) - const toolsJson = toolsRes.ok ? await toolsRes.json() : null - const tools = toolsJson?.data?.tools || [] - const existingTool = tools.find((tool: any) => tool.workflowId === workflowId) - if (!existingTool?.id) { - throw new Error('This workflow is already deployed to this MCP server') - } - const patchRes = await fetch( - `/api/mcp/workflow-servers/${args.serverId}/tools/${existingTool.id}?workspaceId=${workspaceId}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - toolName: args.toolName?.trim(), - toolDescription: args.toolDescription?.trim(), - parameterSchema, - }), - } - ) - const patchJson = patchRes.ok ? await patchRes.json() : null - if (!patchRes.ok) { - const patchError = patchJson?.error || `Failed to update MCP tool (${patchRes.status})` - throw new Error(patchError) - } - const updatedTool = patchJson?.data?.tool - this.setState(ClientToolCallState.success) - await this.markToolComplete( - 200, - `Workflow MCP tool updated to "${updatedTool?.toolName || existingTool.toolName}".`, - { - success: true, - toolId: updatedTool?.id || existingTool.id, - toolName: updatedTool?.toolName || existingTool.toolName, - toolDescription: updatedTool?.toolDescription || existingTool.toolDescription, - serverId: args.serverId, - updated: true, - } - ) - logger.info('Updated workflow MCP tool', { toolId: existingTool.id }) - return - } - if (data.error?.includes('not deployed')) { - throw new Error('Workflow must be deployed before adding as an MCP tool') - } - if (data.error?.includes('Start block')) { - throw new Error('Workflow must have a Start block to be used as an MCP tool') - } - if (data.error?.includes('Server not found')) { - throw new Error( - 'MCP server not found. Use list_workspace_mcp_servers to see available servers.' - ) - } - throw new Error(data.error || `Failed to deploy to MCP (${res.status})`) - } - - const tool = data.data?.tool - if (!tool) { - throw new Error('Response missing tool data') - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete( - 200, - `Workflow deployed as MCP tool "${tool.toolName}" to server.`, - { - success: true, - toolId: tool.id, - toolName: tool.toolName, - toolDescription: tool.toolDescription, - serverId: args.serverId, - } - ) - - logger.info(`Deployed workflow as MCP tool: ${tool.toolName}`) - } catch (e: any) { - logger.error('Failed to deploy to MCP', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to deploy to MCP', { - success: false, - error: e?.message, - }) - } - } - - async execute(args?: DeployMcpArgs): Promise { - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } // Register UI config at module load 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..82be14ec0 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,15 @@ 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 }, @@ -168,258 +56,9 @@ export class EditWorkflowClientTool extends BaseClientTool { }, } - 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 - 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 - ) - } - }) - } + // 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 } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts index d835678d3..a6557a0c1 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts @@ -1,29 +1,9 @@ -import { createLogger } from '@sim/logger' import { Loader2, Tag, X, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - computeBlockOutputPaths, - formatOutputsWithPrefix, - getSubflowInsidePaths, - getWorkflowSubBlockValues, - getWorkflowVariables, -} from '@/lib/copilot/tools/client/workflow/block-output-utils' -import { - GetBlockOutputsResult, - type GetBlockOutputsResultType, -} from '@/lib/copilot/tools/shared/schemas' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -const logger = createLogger('GetBlockOutputsClientTool') - -interface GetBlockOutputsArgs { - blockIds?: string[] -} export class GetBlockOutputsClientTool extends BaseClientTool { static readonly id = 'get_block_outputs' @@ -61,84 +41,6 @@ export class GetBlockOutputsClientTool extends BaseClientTool { }, } - async execute(args?: GetBlockOutputsArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (!activeWorkflowId) { - await this.markToolComplete(400, 'No active workflow found') - this.setState(ClientToolCallState.error) - return - } - - const workflowStore = useWorkflowStore.getState() - const blocks = workflowStore.blocks || {} - const loops = workflowStore.loops || {} - const parallels = workflowStore.parallels || {} - const subBlockValues = getWorkflowSubBlockValues(activeWorkflowId) - - const ctx = { workflowId: activeWorkflowId, blocks, loops, parallels, subBlockValues } - const targetBlockIds = - args?.blockIds && args.blockIds.length > 0 ? args.blockIds : Object.keys(blocks) - - const blockOutputs: GetBlockOutputsResultType['blocks'] = [] - - for (const blockId of targetBlockIds) { - const block = blocks[blockId] - if (!block?.type) continue - - const blockName = block.name || block.type - - const blockOutput: GetBlockOutputsResultType['blocks'][0] = { - blockId, - blockName, - blockType: block.type, - outputs: [], - } - - // Include triggerMode if the block is in trigger mode - if (block.triggerMode) { - blockOutput.triggerMode = true - } - - if (block.type === 'loop' || block.type === 'parallel') { - const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels) - blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName) - blockOutput.outsideSubflowOutputs = formatOutputsWithPrefix(['results'], blockName) - } else { - const outputPaths = computeBlockOutputPaths(block, ctx) - blockOutput.outputs = formatOutputsWithPrefix(outputPaths, blockName) - } - - blockOutputs.push(blockOutput) - } - - const includeVariables = !args?.blockIds || args.blockIds.length === 0 - const resultData: { - blocks: typeof blockOutputs - variables?: ReturnType - } = { - blocks: blockOutputs, - } - if (includeVariables) { - resultData.variables = getWorkflowVariables(activeWorkflowId) - } - - const result = GetBlockOutputsResult.parse(resultData) - - logger.info('Retrieved block outputs', { - blockCount: blockOutputs.length, - variableCount: resultData.variables?.length ?? 0, - }) - - await this.markToolComplete(200, 'Retrieved block outputs', result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message }) - await this.markToolComplete(500, message || 'Failed to get block outputs') - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts b/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts index f02c9958c..317afa4e6 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts @@ -1,32 +1,9 @@ -import { createLogger } from '@sim/logger' import { GitBranch, Loader2, X, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { - computeBlockOutputPaths, - formatOutputsWithPrefix, - getSubflowInsidePaths, - getWorkflowSubBlockValues, - getWorkflowVariables, -} from '@/lib/copilot/tools/client/workflow/block-output-utils' -import { - GetBlockUpstreamReferencesResult, - type GetBlockUpstreamReferencesResultType, -} from '@/lib/copilot/tools/shared/schemas' -import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' -import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import type { Loop, Parallel } from '@/stores/workflows/workflow/types' - -const logger = createLogger('GetBlockUpstreamReferencesClientTool') - -interface GetBlockUpstreamReferencesArgs { - blockIds: string[] -} export class GetBlockUpstreamReferencesClientTool extends BaseClientTool { static readonly id = 'get_block_upstream_references' @@ -68,164 +45,6 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool { }, } - async execute(args?: GetBlockUpstreamReferencesArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - if (!args?.blockIds || args.blockIds.length === 0) { - await this.markToolComplete(400, 'blockIds array is required') - this.setState(ClientToolCallState.error) - return - } - - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (!activeWorkflowId) { - await this.markToolComplete(400, 'No active workflow found') - this.setState(ClientToolCallState.error) - return - } - - const workflowStore = useWorkflowStore.getState() - const blocks = workflowStore.blocks || {} - const edges = workflowStore.edges || [] - const loops = workflowStore.loops || {} - const parallels = workflowStore.parallels || {} - const subBlockValues = getWorkflowSubBlockValues(activeWorkflowId) - - const ctx = { workflowId: activeWorkflowId, blocks, loops, parallels, subBlockValues } - const variableOutputs = getWorkflowVariables(activeWorkflowId) - const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) - - const results: GetBlockUpstreamReferencesResultType['results'] = [] - - for (const blockId of args.blockIds) { - const targetBlock = blocks[blockId] - if (!targetBlock) { - logger.warn(`Block ${blockId} not found`) - continue - } - - const insideSubflows: { blockId: string; blockName: string; blockType: string }[] = [] - const containingLoopIds = new Set() - const containingParallelIds = new Set() - - Object.values(loops as Record).forEach((loop) => { - if (loop?.nodes?.includes(blockId)) { - containingLoopIds.add(loop.id) - const loopBlock = blocks[loop.id] - if (loopBlock) { - insideSubflows.push({ - blockId: loop.id, - blockName: loopBlock.name || loopBlock.type, - blockType: 'loop', - }) - } - } - }) - - Object.values(parallels as Record).forEach((parallel) => { - if (parallel?.nodes?.includes(blockId)) { - containingParallelIds.add(parallel.id) - const parallelBlock = blocks[parallel.id] - if (parallelBlock) { - insideSubflows.push({ - blockId: parallel.id, - blockName: parallelBlock.name || parallelBlock.type, - blockType: 'parallel', - }) - } - } - }) - - const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId) - const accessibleIds = new Set(ancestorIds) - accessibleIds.add(blockId) - - const starterBlock = Object.values(blocks).find((b) => isInputDefinitionTrigger(b.type)) - if (starterBlock && ancestorIds.includes(starterBlock.id)) { - accessibleIds.add(starterBlock.id) - } - - containingLoopIds.forEach((loopId) => { - accessibleIds.add(loopId) - loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) - }) - - containingParallelIds.forEach((parallelId) => { - accessibleIds.add(parallelId) - parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) - }) - - const accessibleBlocks: GetBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'] = - [] - - for (const accessibleBlockId of accessibleIds) { - const block = blocks[accessibleBlockId] - if (!block?.type) continue - - const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop' - if (accessibleBlockId === blockId && !canSelfReference) continue - - const blockName = block.name || block.type - let accessContext: 'inside' | 'outside' | undefined - let outputPaths: string[] - - if (block.type === 'loop' || block.type === 'parallel') { - const isInside = - (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) || - (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId)) - - accessContext = isInside ? 'inside' : 'outside' - outputPaths = isInside - ? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels) - : ['results'] - } else { - outputPaths = computeBlockOutputPaths(block, ctx) - } - - const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName) - - const entry: GetBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0] = { - blockId: accessibleBlockId, - blockName, - blockType: block.type, - outputs: formattedOutputs, - } - - // Include triggerMode if the block is in trigger mode - if (block.triggerMode) { - entry.triggerMode = true - } - - if (accessContext) entry.accessContext = accessContext - accessibleBlocks.push(entry) - } - - const resultEntry: GetBlockUpstreamReferencesResultType['results'][0] = { - blockId, - blockName: targetBlock.name || targetBlock.type, - accessibleBlocks, - variables: variableOutputs, - } - - if (insideSubflows.length > 0) resultEntry.insideSubflows = insideSubflows - results.push(resultEntry) - } - - const result = GetBlockUpstreamReferencesResult.parse({ results }) - - logger.info('Retrieved upstream references', { - blockIds: args.blockIds, - resultCount: results.length, - }) - - await this.markToolComplete(200, 'Retrieved upstream references', result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message }) - await this.markToolComplete(500, message || 'Failed to get upstream references') - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-user-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/get-user-workflow.ts index c67f92a9e..849983c47 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-user-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-user-workflow.ts @@ -1,22 +1,10 @@ -import { createLogger } from '@sim/logger' import { Loader2, Workflow as WorkflowIcon, X, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { mergeSubblockState } from '@/stores/workflows/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -interface GetUserWorkflowArgs { - workflowId?: string - includeMetadata?: boolean -} - -const logger = createLogger('GetUserWorkflowClientTool') export class GetUserWorkflowClientTool extends BaseClientTool { static readonly id = 'get_user_workflow' @@ -60,128 +48,6 @@ export class GetUserWorkflowClientTool extends BaseClientTool { }, } - async execute(args?: GetUserWorkflowArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - // Determine workflow ID (explicit or active) - let workflowId = args?.workflowId - if (!workflowId) { - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (!activeWorkflowId) { - await this.markToolComplete(400, 'No active workflow found') - this.setState(ClientToolCallState.error) - return - } - workflowId = activeWorkflowId as any - } - - logger.info('Fetching user workflow from stores', { - workflowId, - includeMetadata: args?.includeMetadata, - }) - - // Always use main workflow store as the source of truth - const workflowStore = useWorkflowStore.getState() - const fullWorkflowState = workflowStore.getWorkflowState() - - let workflowState: any = null - - if (!fullWorkflowState || !fullWorkflowState.blocks) { - const workflowRegistry = useWorkflowRegistry.getState() - const wfKey = String(workflowId) - const workflow = (workflowRegistry as any).workflows?.[wfKey] - - if (!workflow) { - await this.markToolComplete(404, `Workflow ${workflowId} not found in any store`) - this.setState(ClientToolCallState.error) - return - } - - logger.warn('No workflow state found, using workflow metadata only', { workflowId }) - workflowState = workflow - } else { - workflowState = stripWorkflowDiffMarkers(fullWorkflowState) - logger.info('Using workflow state from workflow store', { - workflowId, - blockCount: Object.keys(fullWorkflowState.blocks || {}).length, - }) - } - - // Normalize required properties - if (workflowState) { - 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 - try { - if (workflowState?.blocks) { - workflowState = { - ...workflowState, - blocks: mergeSubblockState(workflowState.blocks, workflowId as any), - } - logger.info('Merged subblock values into workflow state', { - workflowId, - blockCount: Object.keys(workflowState.blocks || {}).length, - }) - } - } catch (mergeError) { - logger.warn('Failed to merge subblock values; proceeding with raw workflow state', { - workflowId, - error: mergeError instanceof Error ? mergeError.message : String(mergeError), - }) - } - - logger.info('Validating workflow state', { - workflowId, - hasWorkflowState: !!workflowState, - hasBlocks: !!workflowState?.blocks, - workflowStateType: typeof workflowState, - }) - - if (!workflowState || !workflowState.blocks) { - await this.markToolComplete(422, 'Workflow state is empty or invalid') - this.setState(ClientToolCallState.error) - return - } - - // Sanitize workflow state for copilot (remove UI-specific data) - const sanitizedState = sanitizeForCopilot(workflowState) - - // Convert to JSON string for transport - let workflowJson = '' - try { - workflowJson = JSON.stringify(sanitizedState, null, 2) - logger.info('Successfully stringified sanitized workflow state', { - workflowId, - jsonLength: workflowJson.length, - }) - } catch (stringifyError) { - await this.markToolComplete( - 500, - `Failed to convert workflow to JSON: ${ - stringifyError instanceof Error ? stringifyError.message : 'Unknown error' - }` - ) - this.setState(ClientToolCallState.error) - return - } - - // Mark complete with data; keep state success for store render - await this.markToolComplete(200, 'Workflow analyzed', { userWorkflow: workflowJson }) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Error in tool execution', { - toolCallId: this.toolCallId, - error, - message, - }) - await this.markToolComplete(500, message || 'Failed to fetch workflow') - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-console.ts b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-console.ts index 328ae5aad..ac4eac0fd 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-console.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-console.ts @@ -1,18 +1,9 @@ -import { createLogger } from '@sim/logger' import { Loader2, MinusCircle, TerminalSquare, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface GetWorkflowConsoleArgs { - workflowId?: string - limit?: number - includeDetails?: boolean -} export class GetWorkflowConsoleClientTool extends BaseClientTool { static readonly id = 'get_workflow_console' @@ -61,52 +52,6 @@ export class GetWorkflowConsoleClientTool extends BaseClientTool { }, } - async execute(args?: GetWorkflowConsoleArgs): Promise { - const logger = createLogger('GetWorkflowConsoleClientTool') - try { - this.setState(ClientToolCallState.executing) - - const params = args || {} - let workflowId = params.workflowId - if (!workflowId) { - const { activeWorkflowId } = useWorkflowRegistry.getState() - workflowId = activeWorkflowId || undefined - } - if (!workflowId) { - logger.error('No active workflow found for console fetch') - this.setState(ClientToolCallState.error) - await this.markToolComplete(400, 'No active workflow found') - return - } - - const payload = { - workflowId, - limit: params.limit ?? 3, - includeDetails: params.includeDetails ?? true, - } - - const res = await fetch('/api/copilot/execute-copilot-server-tool', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ toolName: 'get_workflow_console', payload }), - }) - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(text || `Server error (${res.status})`) - } - - const json = await res.json() - const parsed = ExecuteResponseSuccessSchema.parse(json) - - // Mark success and include result data for UI rendering - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Workflow console fetched', parsed.result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - const message = e instanceof Error ? e.message : String(e) - createLogger('GetWorkflowConsoleClientTool').error('execute failed', { message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, message) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts index 657daa0a0..873b9ac9d 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts @@ -1,21 +1,13 @@ -import { createLogger } from '@sim/logger' import { Database, Loader2, X, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -const logger = createLogger('GetWorkflowDataClientTool') /** Data type enum for the get_workflow_data tool */ export type WorkflowDataType = 'global_variables' | 'custom_tools' | 'mcp_tools' | 'files' -interface GetWorkflowDataArgs { - data_type: WorkflowDataType -} - export class GetWorkflowDataClientTool extends BaseClientTool { static readonly id = 'get_workflow_data' @@ -65,205 +57,6 @@ export class GetWorkflowDataClientTool extends BaseClientTool { }, } - async execute(args?: GetWorkflowDataArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - const dataType = args?.data_type - if (!dataType) { - await this.markToolComplete(400, 'Missing data_type parameter') - this.setState(ClientToolCallState.error) - return - } - - const { activeWorkflowId, hydration } = useWorkflowRegistry.getState() - const activeWorkspaceId = hydration.workspaceId - - switch (dataType) { - case 'global_variables': - await this.fetchGlobalVariables(activeWorkflowId) - break - case 'custom_tools': - await this.fetchCustomTools(activeWorkspaceId) - break - case 'mcp_tools': - await this.fetchMcpTools(activeWorkspaceId) - break - case 'files': - await this.fetchFiles(activeWorkspaceId) - break - default: - await this.markToolComplete(400, `Unknown data_type: ${dataType}`) - this.setState(ClientToolCallState.error) - return - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message || 'Failed to fetch workflow data') - this.setState(ClientToolCallState.error) - } - } - - /** - * Fetch global workflow variables - */ - private async fetchGlobalVariables(workflowId: string | null): Promise { - if (!workflowId) { - await this.markToolComplete(400, 'No active workflow found') - this.setState(ClientToolCallState.error) - return - } - - const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - await this.markToolComplete(res.status, text || 'Failed to fetch workflow variables') - this.setState(ClientToolCallState.error) - return - } - - const json = await res.json() - const varsRecord = (json?.data as Record) || {} - const variables = Object.values(varsRecord).map((v: unknown) => { - const variable = v as { id?: string; name?: string; value?: unknown } - return { - id: String(variable?.id || ''), - name: String(variable?.name || ''), - value: variable?.value, - } - }) - - logger.info('Fetched workflow variables', { count: variables.length }) - await this.markToolComplete(200, `Found ${variables.length} variable(s)`, { variables }) - this.setState(ClientToolCallState.success) - } - - /** - * Fetch custom tools for the workspace - */ - private async fetchCustomTools(workspaceId: string | null): Promise { - if (!workspaceId) { - await this.markToolComplete(400, 'No active workspace found') - this.setState(ClientToolCallState.error) - return - } - - const res = await fetch(`/api/tools/custom?workspaceId=${workspaceId}`, { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - await this.markToolComplete(res.status, text || 'Failed to fetch custom tools') - this.setState(ClientToolCallState.error) - return - } - - const json = await res.json() - const toolsData = (json?.data as unknown[]) || [] - const customTools = toolsData.map((tool: unknown) => { - const t = tool as { - id?: string - title?: string - schema?: { function?: { name?: string; description?: string; parameters?: unknown } } - code?: string - } - return { - id: String(t?.id || ''), - title: String(t?.title || ''), - functionName: String(t?.schema?.function?.name || ''), - description: String(t?.schema?.function?.description || ''), - parameters: t?.schema?.function?.parameters, - } - }) - - logger.info('Fetched custom tools', { count: customTools.length }) - await this.markToolComplete(200, `Found ${customTools.length} custom tool(s)`, { customTools }) - this.setState(ClientToolCallState.success) - } - - /** - * Fetch MCP tools for the workspace - */ - private async fetchMcpTools(workspaceId: string | null): Promise { - if (!workspaceId) { - await this.markToolComplete(400, 'No active workspace found') - this.setState(ClientToolCallState.error) - return - } - - const res = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`, { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - await this.markToolComplete(res.status, text || 'Failed to fetch MCP tools') - this.setState(ClientToolCallState.error) - return - } - - const json = await res.json() - const toolsData = (json?.data?.tools as unknown[]) || [] - const mcpTools = toolsData.map((tool: unknown) => { - const t = tool as { - name?: string - serverId?: string - serverName?: string - description?: string - inputSchema?: unknown - } - return { - name: String(t?.name || ''), - serverId: String(t?.serverId || ''), - serverName: String(t?.serverName || ''), - description: String(t?.description || ''), - inputSchema: t?.inputSchema, - } - }) - - logger.info('Fetched MCP tools', { count: mcpTools.length }) - await this.markToolComplete(200, `Found ${mcpTools.length} MCP tool(s)`, { mcpTools }) - this.setState(ClientToolCallState.success) - } - - /** - * Fetch workspace files metadata - */ - private async fetchFiles(workspaceId: string | null): Promise { - if (!workspaceId) { - await this.markToolComplete(400, 'No active workspace found') - this.setState(ClientToolCallState.error) - return - } - - const res = await fetch(`/api/workspaces/${workspaceId}/files`, { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - await this.markToolComplete(res.status, text || 'Failed to fetch files') - this.setState(ClientToolCallState.error) - return - } - - const json = await res.json() - const filesData = (json?.files as unknown[]) || [] - const files = filesData.map((file: unknown) => { - const f = file as { - id?: string - name?: string - key?: string - path?: string - size?: number - type?: string - uploadedAt?: string - } - return { - id: String(f?.id || ''), - name: String(f?.name || ''), - key: String(f?.key || ''), - path: String(f?.path || ''), - size: Number(f?.size || 0), - type: String(f?.type || ''), - uploadedAt: String(f?.uploadedAt || ''), - } - }) - - logger.info('Fetched workspace files', { count: files.length }) - await this.markToolComplete(200, `Found ${files.length} file(s)`, { files }) - this.setState(ClientToolCallState.success) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-from-name.ts b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-from-name.ts index 18aeb335f..d79df3632 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-from-name.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-from-name.ts @@ -1,18 +1,9 @@ -import { createLogger } from '@sim/logger' import { FileText, Loader2, X, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -const logger = createLogger('GetWorkflowFromNameClientTool') - -interface GetWorkflowFromNameArgs { - workflow_name: string -} export class GetWorkflowFromNameClientTool extends BaseClientTool { static readonly id = 'get_workflow_from_name' @@ -54,66 +45,6 @@ export class GetWorkflowFromNameClientTool extends BaseClientTool { }, } - async execute(args?: GetWorkflowFromNameArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - const workflowName = args?.workflow_name?.trim() - if (!workflowName) { - await this.markToolComplete(400, 'workflow_name is required') - this.setState(ClientToolCallState.error) - return - } - - // Try to find by name from registry first to get ID - const registry = useWorkflowRegistry.getState() - const match = Object.values((registry as any).workflows || {}).find( - (w: any) => - String(w?.name || '') - .trim() - .toLowerCase() === workflowName.toLowerCase() - ) as any - - if (!match?.id) { - await this.markToolComplete(404, `Workflow not found: ${workflowName}`) - this.setState(ClientToolCallState.error) - return - } - - // Fetch full workflow from API route (normalized tables) - const res = await fetch(`/api/workflows/${encodeURIComponent(match.id)}`, { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - await this.markToolComplete(res.status, text || 'Failed to fetch workflow by name') - this.setState(ClientToolCallState.error) - return - } - - const json = await res.json() - const wf = json?.data - if (!wf?.state?.blocks) { - await this.markToolComplete(422, 'Workflow state is empty or invalid') - this.setState(ClientToolCallState.error) - return - } - - // Convert state to the same string format as get_user_workflow - const workflowState = { - blocks: wf.state.blocks || {}, - edges: wf.state.edges || [], - loops: wf.state.loops || {}, - parallels: wf.state.parallels || {}, - } - // Sanitize workflow state for copilot (remove UI-specific data) - const sanitizedState = sanitizeForCopilot(workflowState) - const userWorkflow = JSON.stringify(sanitizedState, null, 2) - - await this.markToolComplete(200, `Retrieved workflow ${workflowName}`, { userWorkflow }) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message || 'Failed to retrieve workflow by name') - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts b/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts index 551982029..f9c2b323a 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/list-user-workflows.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { ListChecks, Loader2, X, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,8 +5,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -const logger = createLogger('ListUserWorkflowsClientTool') - export class ListUserWorkflowsClientTool extends BaseClientTool { static readonly id = 'list_user_workflows' @@ -27,34 +24,6 @@ export class ListUserWorkflowsClientTool extends BaseClientTool { }, } - async execute(): Promise { - try { - this.setState(ClientToolCallState.executing) - - const res = await fetch('/api/workflows', { method: 'GET' }) - if (!res.ok) { - const text = await res.text().catch(() => '') - await this.markToolComplete(res.status, text || 'Failed to fetch workflows') - this.setState(ClientToolCallState.error) - return - } - - const json = await res.json() - const workflows = Array.isArray(json?.data) ? json.data : [] - const names = workflows - .map((w: any) => (typeof w?.name === 'string' ? w.name : null)) - .filter((n: string | null) => !!n) - - logger.info('Found workflows', { count: names.length }) - - await this.markToolComplete(200, `Found ${names.length} workflow(s)`, { - workflow_names: names, - }) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message || 'Failed to list workflows') - this.setState(ClientToolCallState.error) - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts b/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts index 1dad9fbf7..84885e6a6 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts @@ -1,23 +1,9 @@ -import { createLogger } from '@sim/logger' import { Loader2, Server, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface ListWorkspaceMcpServersArgs { - workspaceId?: string -} - -export interface WorkspaceMcpServer { - id: string - name: string - description: string | null - toolCount: number - toolNames: string[] -} /** * List workspace MCP servers tool. @@ -50,63 +36,6 @@ export class ListWorkspaceMcpServersClientTool extends BaseClientTool { interrupt: undefined, } - async execute(args?: ListWorkspaceMcpServersArgs): Promise { - const logger = createLogger('ListWorkspaceMcpServersClientTool') - try { - this.setState(ClientToolCallState.executing) - - // Get workspace ID from active workflow if not provided - const { activeWorkflowId, workflows } = useWorkflowRegistry.getState() - let workspaceId = args?.workspaceId - - if (!workspaceId && activeWorkflowId) { - workspaceId = workflows[activeWorkflowId]?.workspaceId - } - - if (!workspaceId) { - throw new Error('No workspace ID available') - } - - const res = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) - - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.error || `Failed to fetch MCP servers (${res.status})`) - } - - const data = await res.json() - const servers: WorkspaceMcpServer[] = (data.data?.servers || []).map((s: any) => ({ - id: s.id, - name: s.name, - description: s.description, - toolCount: s.toolCount || 0, - toolNames: s.toolNames || [], - })) - - this.setState(ClientToolCallState.success) - - if (servers.length === 0) { - await this.markToolComplete( - 200, - 'No MCP servers found in this workspace. Use create_workspace_mcp_server to create one.', - { servers: [], count: 0 } - ) - } else { - await this.markToolComplete( - 200, - `Found ${servers.length} MCP server(s) in the workspace.`, - { - servers, - count: servers.length, - } - ) - } - - logger.info(`Listed ${servers.length} MCP servers`) - } catch (e: any) { - logger.error('Failed to list MCP servers', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to list MCP servers') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts index 58a823637..27bde25ff 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts @@ -1,57 +1,16 @@ -import { createLogger } from '@sim/logger' import { Check, Loader2, Plus, X, XCircle } from 'lucide-react' -import { client } from '@/lib/auth/auth-client' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { getCustomTool } from '@/hooks/queries/custom-tools' -import { useCopilotStore } from '@/stores/panel/copilot/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface CustomToolSchema { - type: 'function' - function: { - name: string - description?: string - parameters: { - type: string - properties: Record - required?: string[] - } - } -} - -interface ManageCustomToolArgs { - operation: 'add' | 'edit' | 'delete' | 'list' - toolId?: string - schema?: CustomToolSchema - code?: string -} - -const API_ENDPOINT = '/api/tools/custom' - -async function checkCustomToolsPermission(): Promise { - const activeOrgResponse = await client.organization.getFullOrganization() - const organizationId = activeOrgResponse.data?.id - if (!organizationId) return - - const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`) - if (!response.ok) return - - const data = await response.json() - if (data?.config?.disableCustomTools) { - throw new Error('Custom tools are not allowed based on your permission group settings') - } -} /** * Client tool for creating, editing, and deleting custom tools via the copilot. */ export class ManageCustomToolClientTool extends BaseClientTool { static readonly id = 'manage_custom_tool' - private currentArgs?: ManageCustomToolArgs constructor(toolCallId: string) { super(toolCallId, ManageCustomToolClientTool.id, ManageCustomToolClientTool.metadata) @@ -148,261 +107,7 @@ export class ManageCustomToolClientTool extends BaseClientTool { }, } - /** - * Gets the tool call args from the copilot store (needed before execute() is called) - */ - private getArgsFromStore(): ManageCustomToolArgs | undefined { - try { - const { toolCallsById } = useCopilotStore.getState() - const toolCall = toolCallsById[this.toolCallId] - return (toolCall as any)?.params as ManageCustomToolArgs | undefined - } catch { - return undefined - } - } - - /** - * Override getInterruptDisplays to only show confirmation for edit and delete operations. - * Add operations execute directly without confirmation. - */ - getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined { - const args = this.currentArgs || this.getArgsFromStore() - const operation = args?.operation - if (operation === 'edit' || operation === 'delete') { - return this.metadata.interrupt - } - return undefined - } - - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: ManageCustomToolArgs): Promise { - const logger = createLogger('ManageCustomToolClientTool') - try { - this.setState(ClientToolCallState.executing) - await this.executeOperation(args, logger) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to manage custom tool', { - success: false, - error: e?.message || 'Failed to manage custom tool', - }) - } - } - - async execute(args?: ManageCustomToolArgs): Promise { - this.currentArgs = args - if (args?.operation === 'add' || args?.operation === 'list') { - await this.handleAccept(args) - } - } - - /** - * Executes the custom tool operation (add, edit, delete, or list) - */ - private async executeOperation( - args: ManageCustomToolArgs | undefined, - logger: ReturnType - ): Promise { - if (!args?.operation) { - throw new Error('Operation is required') - } - - await checkCustomToolsPermission() - - const { operation, toolId, schema, code } = args - - const { hydration } = useWorkflowRegistry.getState() - const workspaceId = hydration.workspaceId - if (!workspaceId) { - throw new Error('No active workspace found') - } - - logger.info(`Executing custom tool operation: ${operation}`, { - operation, - toolId, - functionName: schema?.function?.name, - workspaceId, - }) - - switch (operation) { - case 'add': - await this.addCustomTool({ schema, code, workspaceId }, logger) - break - case 'edit': - await this.editCustomTool({ toolId, schema, code, workspaceId }, logger) - break - case 'delete': - await this.deleteCustomTool({ toolId, workspaceId }, logger) - break - case 'list': - await this.markToolComplete(200, 'Listed custom tools') - break - default: - throw new Error(`Unknown operation: ${operation}`) - } - } - - /** - * Creates a new custom tool - */ - private async addCustomTool( - params: { - schema?: CustomToolSchema - code?: string - workspaceId: string - }, - logger: ReturnType - ): Promise { - const { schema, code, workspaceId } = params - - if (!schema) { - throw new Error('Schema is required for adding a custom tool') - } - if (!code) { - throw new Error('Code is required for adding a custom tool') - } - - const functionName = schema.function.name - - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tools: [{ title: functionName, schema, code }], - workspaceId, - }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to create custom tool') - } - - if (!data.data || !Array.isArray(data.data) || data.data.length === 0) { - throw new Error('Invalid API response: missing tool data') - } - - const createdTool = data.data[0] - logger.info(`Created custom tool: ${functionName}`, { toolId: createdTool.id }) - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Created custom tool "${functionName}"`, { - success: true, - operation: 'add', - toolId: createdTool.id, - functionName, - }) - } - - /** - * Updates an existing custom tool - */ - private async editCustomTool( - params: { - toolId?: string - schema?: CustomToolSchema - code?: string - workspaceId: string - }, - logger: ReturnType - ): Promise { - const { toolId, schema, code, workspaceId } = params - - if (!toolId) { - throw new Error('Tool ID is required for editing a custom tool') - } - - if (!schema && !code) { - throw new Error('At least one of schema or code must be provided for editing') - } - - const existingResponse = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`) - const existingData = await existingResponse.json() - - if (!existingResponse.ok) { - throw new Error(existingData.error || 'Failed to fetch existing tools') - } - - const existingTool = existingData.data?.find((t: any) => t.id === toolId) - if (!existingTool) { - throw new Error(`Tool with ID ${toolId} not found`) - } - - const mergedSchema = schema ?? existingTool.schema - const updatedTool = { - id: toolId, - title: mergedSchema.function.name, - schema: mergedSchema, - code: code ?? existingTool.code, - } - - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tools: [updatedTool], - workspaceId, - }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to update custom tool') - } - - const functionName = updatedTool.schema.function.name - logger.info(`Updated custom tool: ${functionName}`, { toolId }) - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Updated custom tool "${functionName}"`, { - success: true, - operation: 'edit', - toolId, - functionName, - }) - } - - /** - * Deletes a custom tool - */ - private async deleteCustomTool( - params: { - toolId?: string - workspaceId: string - }, - logger: ReturnType - ): Promise { - const { toolId, workspaceId } = params - - if (!toolId) { - throw new Error('Tool ID is required for deleting a custom tool') - } - - const url = `${API_ENDPOINT}?id=${toolId}&workspaceId=${workspaceId}` - const response = await fetch(url, { - method: 'DELETE', - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete custom tool') - } - - logger.info(`Deleted custom tool: ${toolId}`) - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Deleted custom tool`, { - success: true, - operation: 'delete', - toolId, - }) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only for rendering tool call cards + // Interrupts (edit/delete operations) are auto-executed in headless mode } diff --git a/apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts index 796574dc1..3e0b20074 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/manage-mcp-tool.ts @@ -1,51 +1,15 @@ -import { createLogger } from '@sim/logger' import { Check, Loader2, Server, X, XCircle } from 'lucide-react' -import { client } from '@/lib/auth/auth-client' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { useCopilotStore } from '@/stores/panel/copilot/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface McpServerConfig { - name: string - transport: 'streamable-http' - url?: string - headers?: Record - timeout?: number - enabled?: boolean -} - -interface ManageMcpToolArgs { - operation: 'add' | 'edit' | 'delete' - serverId?: string - config?: McpServerConfig -} - -const API_ENDPOINT = '/api/mcp/servers' - -async function checkMcpToolsPermission(): Promise { - const activeOrgResponse = await client.organization.getFullOrganization() - const organizationId = activeOrgResponse.data?.id - if (!organizationId) return - - const response = await fetch(`/api/permission-groups/user?organizationId=${organizationId}`) - if (!response.ok) return - - const data = await response.json() - if (data?.config?.disableMcpTools) { - throw new Error('MCP tools are not allowed based on your permission group settings') - } -} /** * Client tool for creating, editing, and deleting MCP tool servers via the copilot. */ export class ManageMcpToolClientTool extends BaseClientTool { static readonly id = 'manage_mcp_tool' - private currentArgs?: ManageMcpToolArgs constructor(toolCallId: string) { super(toolCallId, ManageMcpToolClientTool.id, ManageMcpToolClientTool.metadata) @@ -121,240 +85,7 @@ export class ManageMcpToolClientTool extends BaseClientTool { }, } - /** - * Gets the tool call args from the copilot store (needed before execute() is called) - */ - private getArgsFromStore(): ManageMcpToolArgs | undefined { - try { - const { toolCallsById } = useCopilotStore.getState() - const toolCall = toolCallsById[this.toolCallId] - return (toolCall as any)?.params as ManageMcpToolArgs | undefined - } catch { - return undefined - } - } - - /** - * Override getInterruptDisplays to only show confirmation for edit and delete operations. - * Add operations execute directly without confirmation. - */ - getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined { - const args = this.currentArgs || this.getArgsFromStore() - const operation = args?.operation - if (operation === 'edit' || operation === 'delete') { - return this.metadata.interrupt - } - return undefined - } - - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: ManageMcpToolArgs): Promise { - const logger = createLogger('ManageMcpToolClientTool') - try { - this.setState(ClientToolCallState.executing) - await this.executeOperation(args, logger) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to manage MCP tool', { - success: false, - error: e?.message || 'Failed to manage MCP tool', - }) - } - } - - async execute(args?: ManageMcpToolArgs): Promise { - this.currentArgs = args - if (args?.operation === 'add') { - await this.handleAccept(args) - } - } - - /** - * Executes the MCP tool operation (add, edit, or delete) - */ - private async executeOperation( - args: ManageMcpToolArgs | undefined, - logger: ReturnType - ): Promise { - if (!args?.operation) { - throw new Error('Operation is required') - } - - await checkMcpToolsPermission() - - const { operation, serverId, config } = args - - const { hydration } = useWorkflowRegistry.getState() - const workspaceId = hydration.workspaceId - if (!workspaceId) { - throw new Error('No active workspace found') - } - - logger.info(`Executing MCP tool operation: ${operation}`, { - operation, - serverId, - serverName: config?.name, - workspaceId, - }) - - switch (operation) { - case 'add': - await this.addMcpServer({ config, workspaceId }, logger) - break - case 'edit': - await this.editMcpServer({ serverId, config, workspaceId }, logger) - break - case 'delete': - await this.deleteMcpServer({ serverId, workspaceId }, logger) - break - default: - throw new Error(`Unknown operation: ${operation}`) - } - } - - /** - * Creates a new MCP server - */ - private async addMcpServer( - params: { - config?: McpServerConfig - workspaceId: string - }, - logger: ReturnType - ): Promise { - const { config, workspaceId } = params - - if (!config) { - throw new Error('Config is required for adding an MCP tool') - } - if (!config.name) { - throw new Error('Server name is required') - } - if (!config.url) { - throw new Error('Server URL is required for streamable-http transport') - } - - const serverData = { - ...config, - workspaceId, - transport: config.transport || 'streamable-http', - timeout: config.timeout || 30000, - enabled: config.enabled !== false, - } - - const response = await fetch(API_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(serverData), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to create MCP tool') - } - - const serverId = data.data?.serverId - logger.info(`Created MCP tool: ${config.name}`, { serverId }) - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Created MCP tool "${config.name}"`, { - success: true, - operation: 'add', - serverId, - serverName: config.name, - }) - } - - /** - * Updates an existing MCP server - */ - private async editMcpServer( - params: { - serverId?: string - config?: McpServerConfig - workspaceId: string - }, - logger: ReturnType - ): Promise { - const { serverId, config, workspaceId } = params - - if (!serverId) { - throw new Error('Server ID is required for editing an MCP tool') - } - - if (!config) { - throw new Error('Config is required for editing an MCP tool') - } - - const updateData = { - ...config, - workspaceId, - } - - const response = await fetch(`${API_ENDPOINT}/${serverId}?workspaceId=${workspaceId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updateData), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to update MCP tool') - } - - const serverName = config.name || data.data?.server?.name || serverId - logger.info(`Updated MCP tool: ${serverName}`, { serverId }) - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Updated MCP tool "${serverName}"`, { - success: true, - operation: 'edit', - serverId, - serverName, - }) - } - - /** - * Deletes an MCP server - */ - private async deleteMcpServer( - params: { - serverId?: string - workspaceId: string - }, - logger: ReturnType - ): Promise { - const { serverId, workspaceId } = params - - if (!serverId) { - throw new Error('Server ID is required for deleting an MCP tool') - } - - const url = `${API_ENDPOINT}?serverId=${serverId}&workspaceId=${workspaceId}` - const response = await fetch(url, { - method: 'DELETE', - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete MCP tool') - } - - logger.info(`Deleted MCP tool: ${serverId}`) - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, `Deleted MCP tool`, { - success: true, - operation: 'delete', - serverId, - }) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only for rendering tool call cards + // Interrupts (edit/delete operations) are auto-executed in headless mode } diff --git a/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts index 2fef023fb..43b72fa77 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/redeploy.ts @@ -1,15 +1,12 @@ -import { createLogger } from '@sim/logger' import { Loader2, Rocket, XCircle } from 'lucide-react' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export class RedeployClientTool extends BaseClientTool { static readonly id = 'redeploy' - private hasExecuted = false constructor(toolCallId: string) { super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata) @@ -28,44 +25,6 @@ export class RedeployClientTool extends BaseClientTool { interrupt: undefined, } - async execute(): Promise { - const logger = createLogger('RedeployClientTool') - try { - if (this.hasExecuted) { - logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId }) - return - } - this.hasExecuted = true - - this.setState(ClientToolCallState.executing) - - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (!activeWorkflowId) { - throw new Error('No workflow ID provided') - } - - const res = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deployChatEnabled: false }), - }) - - const json = await res.json().catch(() => ({})) - if (!res.ok) { - const errorText = json?.error || `Server error (${res.status})` - throw new Error(errorText) - } - - this.setState(ClientToolCallState.success) - await this.markToolComplete(200, 'Workflow redeployed', { - workflowId: activeWorkflowId, - deployedAt: json?.deployedAt || null, - schedule: json?.schedule, - }) - } catch (error: any) { - logger.error('Redeploy failed', { message: error?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, error?.message || 'Failed to redeploy workflow') - } - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } diff --git a/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts index 3b2c89df6..4c2f44880 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts @@ -1,23 +1,12 @@ -import { createLogger } from '@sim/logger' import { Loader2, MinusCircle, Play, XCircle } from 'lucide-react' -import { v4 as uuidv4 } from 'uuid' import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState, - WORKFLOW_EXECUTION_TIMEOUT_MS, } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' -import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' -import { useExecutionStore } from '@/stores/execution' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -interface RunWorkflowArgs { - workflowId?: string - description?: string - workflow_input?: Record -} - export class RunWorkflowClientTool extends BaseClientTool { static readonly id = 'run_workflow' @@ -112,119 +101,9 @@ export class RunWorkflowClientTool extends BaseClientTool { }, } - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: RunWorkflowArgs): Promise { - const logger = createLogger('RunWorkflowClientTool') - - // Use longer timeout for workflow execution (10 minutes) - await this.executeWithTimeout(async () => { - const params = args || {} - logger.debug('handleAccept() called', { - toolCallId: this.toolCallId, - state: this.getState(), - hasArgs: !!args, - argKeys: args ? Object.keys(args) : [], - }) - - // prevent concurrent execution - const { isExecuting, setIsExecuting } = useExecutionStore.getState() - if (isExecuting) { - logger.debug('Execution prevented: already executing') - this.setState(ClientToolCallState.error) - await this.markToolComplete( - 409, - 'The workflow is already in the middle of an execution. Try again later' - ) - return - } - - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (!activeWorkflowId) { - logger.debug('Execution prevented: no active workflow') - this.setState(ClientToolCallState.error) - await this.markToolComplete(400, 'No active workflow found') - return - } - logger.debug('Using active workflow', { activeWorkflowId }) - - const workflowInput = params.workflow_input || undefined - if (workflowInput) { - logger.debug('Workflow input provided', { - inputFields: Object.keys(workflowInput), - inputPreview: JSON.stringify(workflowInput).slice(0, 120), - }) - } - - setIsExecuting(true) - logger.debug('Set isExecuting(true) and switching state to executing') - this.setState(ClientToolCallState.executing) - - const executionId = uuidv4() - const executionStartTime = new Date().toISOString() - logger.debug('Starting workflow execution', { - executionStartTime, - executionId, - toolCallId: this.toolCallId, - }) - - try { - const result = await executeWorkflowWithFullLogging({ - workflowInput, - executionId, - }) - - // Determine success for both non-streaming and streaming executions - let succeeded = true - let errorMessage: string | undefined - try { - if (result && typeof result === 'object' && 'success' in (result as any)) { - succeeded = Boolean((result as any).success) - if (!succeeded) { - errorMessage = (result as any)?.error || (result as any)?.output?.error - } - } else if ( - result && - typeof result === 'object' && - 'execution' in (result as any) && - (result as any).execution && - typeof (result as any).execution === 'object' - ) { - succeeded = Boolean((result as any).execution.success) - if (!succeeded) { - errorMessage = - (result as any).execution?.error || (result as any).execution?.output?.error - } - } - } catch {} - - if (succeeded) { - logger.debug('Workflow execution finished with success') - this.setState(ClientToolCallState.success) - await this.markToolComplete( - 200, - `Workflow execution completed. Started at: ${executionStartTime}` - ) - } else { - const msg = errorMessage || 'Workflow execution failed' - logger.error('Workflow execution finished with failure', { message: msg }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, msg) - } - } finally { - // Always clean up execution state - setIsExecuting(false) - } - }, WORKFLOW_EXECUTION_TIMEOUT_MS) - } - - async execute(args?: RunWorkflowArgs): Promise { - // For compatibility if execute() is explicitly invoked, route to handleAccept - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only for rendering tool call cards + // Workflow execution happens entirely on the server } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts b/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts index 63f4c6c6f..bebbe6016 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { Loader2, Settings2, X, XCircle } from 'lucide-react' import { BaseClientTool, @@ -6,20 +5,6 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config' -import { useVariablesStore } from '@/stores/panel/variables/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface OperationItem { - operation: 'add' | 'edit' | 'delete' - name: string - type?: 'plain' | 'number' | 'boolean' | 'array' | 'object' - value?: string -} - -interface SetGlobalVarsArgs { - operations: OperationItem[] - workflowId?: string -} export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool { static readonly id = 'set_global_workflow_variables' @@ -105,170 +90,8 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool { }, } - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: SetGlobalVarsArgs): Promise { - const logger = createLogger('SetGlobalWorkflowVariablesClientTool') - try { - this.setState(ClientToolCallState.executing) - const payload: SetGlobalVarsArgs = { ...(args || { operations: [] }) } - if (!payload.workflowId) { - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (activeWorkflowId) payload.workflowId = activeWorkflowId - } - if (!payload.workflowId) { - throw new Error('No active workflow found') - } - - // Fetch current variables so we can construct full array payload - const getRes = await fetch(`/api/workflows/${payload.workflowId}/variables`, { - method: 'GET', - }) - if (!getRes.ok) { - const txt = await getRes.text().catch(() => '') - throw new Error(txt || 'Failed to load current variables') - } - const currentJson = await getRes.json() - const currentVarsRecord = (currentJson?.data as Record) || {} - - // Helper to convert string -> typed value - function coerceValue( - value: string | undefined, - type?: 'plain' | 'number' | 'boolean' | 'array' | 'object' - ) { - if (value === undefined) return value - const t = type || 'plain' - try { - if (t === 'number') { - const n = Number(value) - if (Number.isNaN(n)) return value - return n - } - if (t === 'boolean') { - const v = String(value).trim().toLowerCase() - if (v === 'true') return true - if (v === 'false') return false - return value - } - if (t === 'array' || t === 'object') { - const parsed = JSON.parse(value) - if (t === 'array' && Array.isArray(parsed)) return parsed - if (t === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) - return parsed - return value - } - } catch {} - return value - } - - // Build mutable map by variable name - const byName: Record = {} - Object.values(currentVarsRecord).forEach((v: any) => { - if (v && typeof v === 'object' && v.id && v.name) byName[String(v.name)] = v - }) - - // Apply operations in order - for (const op of payload.operations || []) { - const key = String(op.name) - const nextType = (op.type as any) || byName[key]?.type || 'plain' - if (op.operation === 'delete') { - delete byName[key] - continue - } - const typedValue = coerceValue(op.value, nextType) - if (op.operation === 'add') { - byName[key] = { - id: crypto.randomUUID(), - workflowId: payload.workflowId, - name: key, - type: nextType, - value: typedValue, - } - continue - } - if (op.operation === 'edit') { - if (!byName[key]) { - // If editing a non-existent variable, create it - byName[key] = { - id: crypto.randomUUID(), - workflowId: payload.workflowId, - name: key, - type: nextType, - value: typedValue, - } - } else { - byName[key] = { - ...byName[key], - type: nextType, - ...(op.value !== undefined ? { value: typedValue } : {}), - } - } - } - } - - // Convert byName (keyed by name) to record keyed by ID for the API - const variablesRecord: Record = {} - for (const v of Object.values(byName)) { - variablesRecord[v.id] = v - } - - // POST full variables record to persist - const res = await fetch(`/api/workflows/${payload.workflowId}/variables`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables: variablesRecord }), - }) - if (!res.ok) { - const txt = await res.text().catch(() => '') - throw new Error(txt || `Failed to update variables (${res.status})`) - } - - try { - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (activeWorkflowId) { - // Fetch the updated variables from the API - const refreshRes = await fetch(`/api/workflows/${activeWorkflowId}/variables`, { - method: 'GET', - }) - - if (refreshRes.ok) { - const refreshJson = await refreshRes.json() - const updatedVarsRecord = (refreshJson?.data as Record) || {} - - // Update the variables store with the fresh data - useVariablesStore.setState((state) => { - // Remove old variables for this workflow - const withoutWorkflow = Object.fromEntries( - Object.entries(state.variables).filter(([, v]) => v.workflowId !== activeWorkflowId) - ) - // Add the updated variables - return { - variables: { ...withoutWorkflow, ...updatedVarsRecord }, - } - }) - - logger.info('Refreshed variables in store', { workflowId: activeWorkflowId }) - } - } - } catch (refreshError) { - logger.warn('Failed to refresh variables in store', { error: refreshError }) - } - - await this.markToolComplete(200, 'Workflow variables updated', { variables: byName }) - this.setState(ClientToolCallState.success) - } catch (e: any) { - const message = e instanceof Error ? e.message : String(e) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, message || 'Failed to set workflow variables') - } - } - - async execute(args?: SetGlobalVarsArgs): Promise { - await this.handleAccept(args) - } + // Executed server-side via handleToolCallEvent in stream-handler.ts + // Client tool provides UI metadata only } // Register UI config at module load diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-block-upstream-references.ts b/apps/sim/lib/copilot/tools/server/workflow/get-block-upstream-references.ts new file mode 100644 index 000000000..5b3ee1edb --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/get-block-upstream-references.ts @@ -0,0 +1,310 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { normalizeName } from '@/executor/constants' +import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' +import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' +import type { Loop, Parallel } from '@/stores/workflows/workflow/types' +import type { BaseServerTool } from '../base-tool' + +const logger = createLogger('GetBlockUpstreamReferencesServerTool') + +export const GetBlockUpstreamReferencesInput = z.object({ + workflowId: z.string().min(1), + blockIds: z.array(z.string()).min(1), +}) + +interface Variable { + id: string + name: string + type?: string +} + +interface BlockOutput { + blockId: string + blockName: string + blockType: string + outputs: string[] + triggerMode?: boolean + accessContext?: 'inside' | 'outside' +} + +interface UpstreamResult { + blockId: string + blockName: string + accessibleBlocks: BlockOutput[] + variables: Array<{ id: string; name: string; type: string; tag: string }> + insideSubflows?: Array<{ blockId: string; blockName: string; blockType: string }> +} + +const GetBlockUpstreamReferencesResult = z.object({ + results: z.array( + z.object({ + blockId: z.string(), + blockName: z.string(), + accessibleBlocks: z.array( + z.object({ + blockId: z.string(), + blockName: z.string(), + blockType: z.string(), + outputs: z.array(z.string()), + triggerMode: z.boolean().optional(), + accessContext: z.enum(['inside', 'outside']).optional(), + }) + ), + variables: z.array( + z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + tag: z.string(), + }) + ), + insideSubflows: z + .array( + z.object({ + blockId: z.string(), + blockName: z.string(), + blockType: z.string(), + }) + ) + .optional(), + }) + ), +}) + +type GetBlockUpstreamReferencesResultType = z.infer + +/** + * Format output paths with block name prefix + */ +function formatOutputsWithPrefix(outputPaths: string[], blockName: string): string[] { + const normalized = normalizeName(blockName) + return outputPaths.map((path) => `${normalized}.${path}`) +} + +/** + * Get outputs for subflow from inside (loop item, parallel item, etc.) + */ +function getSubflowInsidePaths( + blockType: string, + blockId: string, + loops: Record, + parallels: Record +): string[] { + if (blockType === 'loop') { + const loop = loops[blockId] + if (loop?.loopType === 'forEach') { + return ['item', 'index'] + } + return ['index'] + } + if (blockType === 'parallel') { + return ['item', 'index'] + } + return [] +} + +export const getBlockUpstreamReferencesServerTool: BaseServerTool< + typeof GetBlockUpstreamReferencesInput, + GetBlockUpstreamReferencesResultType +> = { + name: 'get_block_upstream_references', + + async execute(args: unknown, context?: { userId: string }) { + const parsed = GetBlockUpstreamReferencesInput.parse(args) + const { workflowId, blockIds } = parsed + + logger.info('Getting block upstream references', { + workflowId, + blockIds, + }) + + // Load workflow from normalized tables + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + + if (!normalizedData?.blocks) { + throw new Error('Workflow state is empty or invalid') + } + + const blocks = normalizedData.blocks + const edges = normalizedData.edges || [] + const loops = (normalizedData.loops || {}) as Record + const parallels = (normalizedData.parallels || {}) as Record + + // Get workflow variables + const [wf] = await db + .select({ variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + const workflowVariables = wf?.variables as Record | null + let variables: Array<{ id: string; name: string; type: string; tag: string }> = [] + + if (workflowVariables && typeof workflowVariables === 'object') { + variables = Object.values(workflowVariables) + .filter( + (v): v is Variable => + typeof v === 'object' && + v !== null && + 'name' in v && + typeof v.name === 'string' && + v.name.trim() !== '' + ) + .map((variable) => ({ + id: variable.id, + name: variable.name, + type: variable.type || 'string', + tag: `variable.${normalizeName(variable.name)}`, + })) + } + + // Build graph edges for path calculation + const graphEdges = edges.map((edge: { source: string; target: string }) => ({ + source: edge.source, + target: edge.target, + })) + + const results: UpstreamResult[] = [] + + for (const blockId of blockIds) { + const targetBlock = blocks[blockId] + if (!targetBlock) { + logger.warn(`Block ${blockId} not found`) + continue + } + + const insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> = [] + const containingLoopIds = new Set() + const containingParallelIds = new Set() + + // Find containing loops + Object.values(loops).forEach((loop) => { + if (loop?.nodes?.includes(blockId)) { + containingLoopIds.add(loop.id) + const loopBlock = blocks[loop.id] + if (loopBlock) { + insideSubflows.push({ + blockId: loop.id, + blockName: loopBlock.name || loopBlock.type, + blockType: 'loop', + }) + } + } + }) + + // Find containing parallels + Object.values(parallels).forEach((parallel) => { + if (parallel?.nodes?.includes(blockId)) { + containingParallelIds.add(parallel.id) + const parallelBlock = blocks[parallel.id] + if (parallelBlock) { + insideSubflows.push({ + blockId: parallel.id, + blockName: parallelBlock.name || parallelBlock.type, + blockType: 'parallel', + }) + } + } + }) + + // Find all ancestor blocks using path calculator + const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId) + const accessibleIds = new Set(ancestorIds) + accessibleIds.add(blockId) + + // Include starter block if it's an ancestor + const starterBlock = Object.values(blocks).find((b: any) => + isInputDefinitionTrigger(b.type) + ) + if (starterBlock && ancestorIds.includes((starterBlock as any).id)) { + accessibleIds.add((starterBlock as any).id) + } + + // Add all nodes in containing loops/parallels + containingLoopIds.forEach((loopId) => { + accessibleIds.add(loopId) + loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) + }) + + containingParallelIds.forEach((parallelId) => { + accessibleIds.add(parallelId) + parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) + }) + + const accessibleBlocks: BlockOutput[] = [] + + for (const accessibleBlockId of accessibleIds) { + const block = blocks[accessibleBlockId] as any + if (!block?.type) continue + + // Skip self-reference unless it's a special block type + const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop' + if (accessibleBlockId === blockId && !canSelfReference) continue + + const blockName = block.name || block.type + let accessContext: 'inside' | 'outside' | undefined + let outputPaths: string[] + + if (block.type === 'loop' || block.type === 'parallel') { + const isInside = + (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) || + (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId)) + + accessContext = isInside ? 'inside' : 'outside' + outputPaths = isInside + ? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels) + : ['results'] + } else { + outputPaths = getBlockOutputPaths(block.type, block.subBlocks, block.triggerMode) + } + + const formattedOutputs = formatOutputsWithPrefix(outputPaths, blockName) + + const entry: BlockOutput = { + blockId: accessibleBlockId, + blockName, + blockType: block.type, + outputs: formattedOutputs, + } + + if (block.triggerMode) { + entry.triggerMode = true + } + + if (accessContext) { + entry.accessContext = accessContext + } + + accessibleBlocks.push(entry) + } + + const resultEntry: UpstreamResult = { + blockId, + blockName: targetBlock.name || targetBlock.type, + accessibleBlocks, + variables, + } + + if (insideSubflows.length > 0) { + resultEntry.insideSubflows = insideSubflows + } + + results.push(resultEntry) + } + + const result = GetBlockUpstreamReferencesResult.parse({ results }) + + logger.info('Retrieved upstream references', { + blockIds, + resultCount: results.length, + }) + + return result + }, +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-data.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-data.ts new file mode 100644 index 000000000..834871b43 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-data.ts @@ -0,0 +1,142 @@ +import { db } from '@sim/db' +import { customTools, mcpServers as mcpServersTable, workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' +import { normalizeName } from '@/executor/constants' +import type { BaseServerTool } from '../base-tool' + +const logger = createLogger('GetWorkflowDataServerTool') + +export const GetWorkflowDataInput = z.object({ + workflowId: z.string().min(1), + workspaceId: z.string().optional(), + data_type: z.enum(['global_variables', 'custom_tools', 'mcp_tools', 'files']), +}) + +interface Variable { + id: string + name: string + value?: unknown + type?: string +} + +export const getWorkflowDataServerTool: BaseServerTool = { + name: 'get_workflow_data', + + async execute(args: unknown, context?: { userId: string }) { + const parsed = GetWorkflowDataInput.parse(args) + const { workflowId, data_type } = parsed + + logger.info('Getting workflow data', { + workflowId, + dataType: data_type, + }) + + // Get workspace ID from workflow + const [wf] = await db + .select({ workspaceId: workflow.workspaceId, variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!wf?.workspaceId) { + throw new Error('Workflow not found or has no workspace') + } + + const workspaceId = wf.workspaceId + + switch (data_type) { + case 'global_variables': + return fetchGlobalVariables(wf.variables as Record | null) + case 'custom_tools': + return await fetchCustomTools(workspaceId) + case 'mcp_tools': + return await fetchMcpTools(workspaceId) + case 'files': + // Files require workspace ID - we'd need to call an API or access storage + // For now, return empty array as files are typically accessed via API + return { files: [], message: 'File listing not yet implemented server-side' } + default: + throw new Error(`Unknown data type: ${data_type}`) + } + }, +} + +function fetchGlobalVariables(workflowVariables: Record | null) { + const variables: Array<{ id: string; name: string; value: unknown; tag: string }> = [] + + if (workflowVariables && typeof workflowVariables === 'object') { + for (const variable of Object.values(workflowVariables)) { + if ( + typeof variable === 'object' && + variable !== null && + 'name' in variable && + typeof variable.name === 'string' && + variable.name.trim() !== '' + ) { + variables.push({ + id: variable.id, + name: variable.name, + value: variable.value, + tag: `variable.${normalizeName(variable.name)}`, + }) + } + } + } + + logger.info('Fetched workflow variables', { count: variables.length }) + return { variables } +} + +async function fetchCustomTools(workspaceId: string) { + const tools = await db + .select({ + id: customTools.id, + title: customTools.title, + schema: customTools.schema, + }) + .from(customTools) + .where(eq(customTools.workspaceId, workspaceId)) + + const formattedTools = tools.map((tool) => { + const schema = tool.schema as { + function?: { name?: string; description?: string; parameters?: unknown } + } | null + + return { + id: tool.id, + title: tool.title, + functionName: schema?.function?.name || '', + description: schema?.function?.description || '', + parameters: schema?.function?.parameters, + } + }) + + logger.info('Fetched custom tools', { count: formattedTools.length }) + return { customTools: formattedTools } +} + +async function fetchMcpTools(workspaceId: string) { + const servers = await db + .select({ + id: mcpServersTable.id, + name: mcpServersTable.name, + url: mcpServersTable.url, + enabled: mcpServersTable.enabled, + }) + .from(mcpServersTable) + .where(and(eq(mcpServersTable.workspaceId, workspaceId), eq(mcpServersTable.enabled, true))) + + // For MCP tools, we return the server list + // Full tool discovery would require connecting to each server + const mcpServers = servers.map((server) => ({ + serverId: server.id, + serverName: server.name, + url: server.url, + enabled: server.enabled, + })) + + logger.info('Fetched MCP servers', { count: mcpServers.length }) + return { mcpServers, message: 'MCP servers listed. Full tool discovery requires server connection.' } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/server/workflow/manage-custom-tool.ts new file mode 100644 index 000000000..6a200282a --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/manage-custom-tool.ts @@ -0,0 +1,232 @@ +import { db } from '@sim/db' +import { customTools, workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { z } from 'zod' +import type { BaseServerTool } from '../base-tool' + +const logger = createLogger('ManageCustomToolServerTool') + +const CustomToolSchemaZ = z.object({ + type: z.literal('function'), + function: z.object({ + name: z.string(), + description: z.string().optional(), + parameters: z.object({ + type: z.string(), + properties: z.record(z.any()), + required: z.array(z.string()).optional(), + }), + }), +}) + +export const ManageCustomToolInput = z.object({ + workflowId: z.string().min(1), + workspaceId: z.string().optional(), + operation: z.enum(['add', 'edit', 'delete', 'list']), + toolId: z.string().optional(), + schema: CustomToolSchemaZ.optional(), + code: z.string().optional(), +}) + +type ManageCustomToolResult = { + success: boolean + operation: string + toolId?: string + functionName?: string + customTools?: Array<{ + id: string + title: string + functionName: string + description: string + }> +} + +export const manageCustomToolServerTool: BaseServerTool< + typeof ManageCustomToolInput, + ManageCustomToolResult +> = { + name: 'manage_custom_tool', + + async execute(args: unknown, context?: { userId: string }) { + const parsed = ManageCustomToolInput.parse(args) + const { workflowId, operation, toolId, schema, code } = parsed + + // Get workspace ID from workflow if not provided + let workspaceId = parsed.workspaceId + if (!workspaceId) { + const [wf] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!wf?.workspaceId) { + throw new Error('Workflow not found or has no workspace') + } + workspaceId = wf.workspaceId + } + + logger.info('Managing custom tool', { + operation, + toolId, + functionName: schema?.function?.name, + workspaceId, + }) + + switch (operation) { + case 'add': + return await addCustomTool(workspaceId, schema, code, context?.userId) + case 'edit': + return await editCustomTool(workspaceId, toolId, schema, code) + case 'delete': + return await deleteCustomTool(workspaceId, toolId) + case 'list': + return await listCustomTools(workspaceId) + default: + throw new Error(`Unknown operation: ${operation}`) + } + }, +} + +async function addCustomTool( + workspaceId: string, + schema: z.infer | undefined, + code: string | undefined, + userId: string | undefined +): Promise { + if (!schema) { + throw new Error('Schema is required for adding a custom tool') + } + if (!code) { + throw new Error('Code is required for adding a custom tool') + } + if (!userId) { + throw new Error('User ID is required for adding a custom tool') + } + + const functionName = schema.function.name + + const [created] = await db + .insert(customTools) + .values({ + id: nanoid(), + workspaceId, + userId, + title: functionName, + schema: schema as any, + code, + }) + .returning({ id: customTools.id }) + + logger.info(`Created custom tool: ${functionName}`, { toolId: created.id }) + + return { + success: true, + operation: 'add', + toolId: created.id, + functionName, + } +} + +async function editCustomTool( + workspaceId: string, + toolId: string | undefined, + schema: z.infer | undefined, + code: string | undefined +): Promise { + if (!toolId) { + throw new Error('Tool ID is required for editing a custom tool') + } + if (!schema && !code) { + throw new Error('At least one of schema or code must be provided for editing') + } + + // Get existing tool + const [existing] = await db + .select() + .from(customTools) + .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) + .limit(1) + + if (!existing) { + throw new Error(`Tool with ID ${toolId} not found`) + } + + const mergedSchema = schema ?? (existing.schema as z.infer) + const mergedCode = code ?? existing.code + + await db + .update(customTools) + .set({ + title: mergedSchema.function.name, + schema: mergedSchema as any, + code: mergedCode, + updatedAt: new Date(), + }) + .where(eq(customTools.id, toolId)) + + const functionName = mergedSchema.function.name + logger.info(`Updated custom tool: ${functionName}`, { toolId }) + + return { + success: true, + operation: 'edit', + toolId, + functionName, + } +} + +async function deleteCustomTool( + workspaceId: string, + toolId: string | undefined +): Promise { + if (!toolId) { + throw new Error('Tool ID is required for deleting a custom tool') + } + + await db + .delete(customTools) + .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) + + logger.info(`Deleted custom tool: ${toolId}`) + + return { + success: true, + operation: 'delete', + toolId, + } +} + +async function listCustomTools(workspaceId: string): Promise { + const tools = await db + .select({ + id: customTools.id, + title: customTools.title, + schema: customTools.schema, + }) + .from(customTools) + .where(eq(customTools.workspaceId, workspaceId)) + + const formattedTools = tools.map((tool) => { + const schema = tool.schema as { + function?: { name?: string; description?: string } + } | null + + return { + id: tool.id, + title: tool.title || '', + functionName: schema?.function?.name || '', + description: schema?.function?.description || '', + } + }) + + logger.info('Listed custom tools', { count: formattedTools.length }) + + return { + success: true, + operation: 'list', + customTools: formattedTools, + } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/server/workflow/manage-mcp-tool.ts new file mode 100644 index 000000000..ca6e75754 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/manage-mcp-tool.ts @@ -0,0 +1,189 @@ +import { db } from '@sim/db' +import { mcpServers, workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { z } from 'zod' +import type { BaseServerTool } from '../base-tool' + +const logger = createLogger('ManageMcpToolServerTool') + +const McpServerConfigZ = z.object({ + name: z.string(), + transport: z.literal('streamable-http').optional().default('streamable-http'), + url: z.string().optional(), + headers: z.record(z.string()).optional(), + timeout: z.number().optional().default(30000), + enabled: z.boolean().optional().default(true), +}) + +export const ManageMcpToolInput = z.object({ + workflowId: z.string().min(1), + workspaceId: z.string().optional(), + operation: z.enum(['add', 'edit', 'delete']), + serverId: z.string().optional(), + config: McpServerConfigZ.optional(), +}) + +type ManageMcpToolResult = { + success: boolean + operation: string + serverId?: string + serverName?: string +} + +export const manageMcpToolServerTool: BaseServerTool< + typeof ManageMcpToolInput, + ManageMcpToolResult +> = { + name: 'manage_mcp_tool', + + async execute(args: unknown, context?: { userId: string }) { + const parsed = ManageMcpToolInput.parse(args) + const { workflowId, operation, serverId, config } = parsed + + // Get workspace ID from workflow if not provided + let workspaceId = parsed.workspaceId + if (!workspaceId) { + const [wf] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!wf?.workspaceId) { + throw new Error('Workflow not found or has no workspace') + } + workspaceId = wf.workspaceId + } + + logger.info('Managing MCP tool', { + operation, + serverId, + serverName: config?.name, + workspaceId, + }) + + switch (operation) { + case 'add': + return await addMcpServer(workspaceId, config, context?.userId) + case 'edit': + return await editMcpServer(workspaceId, serverId, config) + case 'delete': + return await deleteMcpServer(workspaceId, serverId) + default: + throw new Error(`Unknown operation: ${operation}`) + } + }, +} + +async function addMcpServer( + workspaceId: string, + config: z.infer | undefined, + userId: string | undefined +): Promise { + if (!config) { + throw new Error('Config is required for adding an MCP tool') + } + if (!config.name) { + throw new Error('Server name is required') + } + if (!config.url) { + throw new Error('Server URL is required for streamable-http transport') + } + if (!userId) { + throw new Error('User ID is required for adding an MCP tool') + } + + const [created] = await db + .insert(mcpServers) + .values({ + id: nanoid(), + workspaceId, + createdBy: userId, + name: config.name, + url: config.url, + transport: config.transport || 'streamable-http', + headers: config.headers || {}, + timeout: config.timeout || 30000, + enabled: config.enabled !== false, + }) + .returning({ id: mcpServers.id }) + + logger.info(`Created MCP server: ${config.name}`, { serverId: created.id }) + + return { + success: true, + operation: 'add', + serverId: created.id, + serverName: config.name, + } +} + +async function editMcpServer( + workspaceId: string, + serverId: string | undefined, + config: z.infer | undefined +): Promise { + if (!serverId) { + throw new Error('Server ID is required for editing an MCP tool') + } + if (!config) { + throw new Error('Config is required for editing an MCP tool') + } + + // Verify server exists + const [existing] = await db + .select({ id: mcpServers.id, name: mcpServers.name }) + .from(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + .limit(1) + + if (!existing) { + throw new Error(`MCP server with ID ${serverId} not found`) + } + + const updateData: Record = { + updatedAt: new Date(), + } + + if (config.name) updateData.name = config.name + if (config.url) updateData.url = config.url + if (config.transport) updateData.transport = config.transport + if (config.headers) updateData.headers = config.headers + if (config.timeout !== undefined) updateData.timeout = config.timeout + if (config.enabled !== undefined) updateData.enabled = config.enabled + + await db.update(mcpServers).set(updateData).where(eq(mcpServers.id, serverId)) + + const serverName = config.name || existing.name + logger.info(`Updated MCP server: ${serverName}`, { serverId }) + + return { + success: true, + operation: 'edit', + serverId, + serverName, + } +} + +async function deleteMcpServer( + workspaceId: string, + serverId: string | undefined +): Promise { + if (!serverId) { + throw new Error('Server ID is required for deleting an MCP tool') + } + + await db + .delete(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + + logger.info(`Deleted MCP server: ${serverId}`) + + return { + success: true, + operation: 'delete', + serverId, + } +}