diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index a2fb4fe4ba4..ff2c348c58e 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,12 +1,7 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' -import { captureServerEvent } from '@/lib/posthog/server' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { performRevertToVersion } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -37,105 +32,26 @@ export async function POST( return createErrorResponse('Invalid version', 400) } - let stateRow: { state: any } | null = null - if (version === 'active') { - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .limit(1) - stateRow = row || null - } else { - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionSelector as number) - ) - ) - .limit(1) - stateRow = row || null - } - - if (!stateRow?.state) { - return createErrorResponse('Deployment version not found', 404) - } - - const deployedState = stateRow.state - if (!deployedState.blocks || !deployedState.edges) { - return createErrorResponse('Invalid deployed state structure', 500) - } - - const saveResult = await saveWorkflowToNormalizedTables(id, { - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops || {}, - parallels: deployedState.parallels || {}, - lastSaved: Date.now(), - }) - - if (!saveResult.success) { - return createErrorResponse(saveResult.error || 'Failed to save deployed state', 500) - } - - await db - .update(workflow) - .set({ lastSynced: new Date(), updatedAt: new Date() }) - .where(eq(workflow.id, id)) - - try { - const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' - await fetch(`${socketServerUrl}/api/workflow-reverted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId: id, timestamp: Date.now() }), - }) - } catch (e) { - logger.error('Error sending workflow reverted event to socket server', e) - } - - captureServerEvent( - session!.user.id, - 'workflow_deployment_reverted', - { - workflow_id: id, - workspace_id: workflowRecord?.workspaceId ?? '', - version, - }, - workflowRecord?.workspaceId - ? { groups: { workspace: workflowRecord.workspaceId } } - : undefined - ) - - recordAudit({ - workspaceId: workflowRecord?.workspaceId ?? null, - actorId: session!.user.id, - action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: id, + const result = await performRevertToVersion({ + workflowId: id, + version: version === 'active' ? 'active' : (versionSelector as number), + userId: session!.user.id, + workflow: (workflowRecord ?? {}) as Record, + request, actorName: session!.user.name ?? undefined, actorEmail: session!.user.email ?? undefined, - resourceName: workflowRecord?.name ?? undefined, - description: `Reverted workflow to deployment version ${version}`, - metadata: { - targetVersion: version, - }, - request, }) + if (!result.success) { + return createErrorResponse( + result.error || 'Failed to revert', + result.errorCode === 'not_found' ? 404 : 500 + ) + } + return createSuccessResponse({ message: 'Reverted to deployment version', - lastSaved: Date.now(), + lastSaved: result.lastSaved, }) } catch (error: any) { logger.error('Error reverting to deployment version', error) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index e28f4f51d4f..9d8a928eab4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { PillsRing } from '@/components/emcn' -import { FunctionExecute, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import type { ToolCallStatus } from '../../../../types' import { getToolIcon } from '../../utils' @@ -56,42 +56,6 @@ function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: st return } -const LANG_ALIASES: Record = { - javascript: 'javascript', - python: 'python', - shell: 'bash', - bash: 'bash', -} - -function extractFunctionExecutePreview(raw: string): { code: string; lang: string } | null { - if (!raw) return null - const langMatch = raw.match(/"language"\s*:\s*"(\w+)"/) - const lang = langMatch ? (LANG_ALIASES[langMatch[1]] ?? langMatch[1]) : 'javascript' - - const codeStart = raw.indexOf('"code"') - if (codeStart === -1) return null - const colonIdx = raw.indexOf(':', codeStart + 6) - if (colonIdx === -1) return null - const quoteIdx = raw.indexOf('"', colonIdx + 1) - if (quoteIdx === -1) return null - - let value = raw.slice(quoteIdx + 1) - if (value.endsWith('"}') || value.endsWith('"\n}')) { - value = value.replace(/"\s*\}?\s*$/, '') - } - if (value.endsWith('"')) { - value = value.slice(0, -1) - } - - const code = value - .replace(/\\n/g, '\n') - .replace(/\\t/g, '\t') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\') - - return code.length > 0 ? { code, lang } : null -} - interface ToolCallItemProps { toolName: string displayTitle: string @@ -128,14 +92,6 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: .replace(/\\\\/g, '\\') return `${verb} ${unescaped}` }, [toolName, streamingArgs]) - const extracted = useMemo(() => { - if (toolName !== FunctionExecute.id || !streamingArgs) return null - return extractFunctionExecutePreview(streamingArgs) - }, [toolName, streamingArgs]) - const markdown = useMemo( - () => (extracted ? `\`\`\`${extracted.lang}\n${extracted.code}\n\`\`\`` : null), - [extracted] - ) return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 7f3d13f7dc1..60624d43130 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -6,7 +6,7 @@ import { WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' -import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' +import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types' import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' import type { AgentGroupItem } from './components' @@ -94,8 +94,7 @@ function mapToolStatusToClientState( function getOverrideDisplayTitle(tc: NonNullable): string | undefined { if (tc.name === ReadTool.id || tc.name === 'respond' || tc.name.endsWith('_respond')) { - return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.id, tc.params) - ?.text + return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.params)?.text } return undefined } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 57201306855..6176170c32d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -494,7 +494,6 @@ function resolveStreamingToolDisplayTitle(name: string, streamingArgs: string): type StreamToolUI = { hidden?: boolean title?: string - phaseLabel?: string clientExecutable?: boolean } @@ -597,10 +596,16 @@ function getToolUI(ui?: MothershipStreamV1ToolUI): StreamToolUI | undefined { return undefined } + const title = + typeof ui.title === 'string' + ? ui.title + : typeof ui.phaseLabel === 'string' + ? ui.phaseLabel + : undefined + return { ...(typeof ui.hidden === 'boolean' ? { hidden: ui.hidden } : {}), - ...(typeof ui.title === 'string' ? { title: ui.title } : {}), - ...(typeof ui.phaseLabel === 'string' ? { phaseLabel: ui.phaseLabel } : {}), + ...(title ? { title } : {}), ...(typeof ui.clientExecutable === 'boolean' ? { clientExecutable: ui.clientExecutable } : {}), } } @@ -1934,8 +1939,7 @@ export function useChat( } const ui = getToolUI(payload.ui) if (ui?.hidden) break - let displayTitle = ui?.title || ui?.phaseLabel - const phaseLabel = ui?.phaseLabel + let displayTitle = ui?.title const args = payload.arguments as Record | undefined displayTitle = resolveToolDisplayTitle(name, args) ?? displayTitle @@ -1966,7 +1970,6 @@ export function useChat( name, status: 'executing', displayTitle, - phaseLabel, params: args, calledBy: activeSubagent, }, @@ -1980,7 +1983,6 @@ export function useChat( if (tc) { tc.name = name if (displayTitle) tc.displayTitle = displayTitle - if (phaseLabel) tc.phaseLabel = phaseLabel if (args) tc.params = args } } @@ -2525,13 +2527,7 @@ export function useChat( const isCancelled = block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled' const displayTitle = isCancelled ? 'Stopped by user' : block.toolCall.displayTitle - const display = - displayTitle || block.toolCall.phaseLabel - ? { - ...(displayTitle ? { title: displayTitle } : {}), - ...(block.toolCall.phaseLabel ? { phaseLabel: block.toolCall.phaseLabel } : {}), - } - : undefined + const display = displayTitle ? { title: displayTitle } : undefined return { type: block.type, content: block.content, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 5977f5e4720..5ef7747969e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -54,27 +54,6 @@ export interface QueuedMessage { contexts?: ChatContext[] } -/** - * All tool names observed in the mothership SSE stream, grouped by phase. - * - * @example - * ```json - * { "type": "tool", "phase": "call", "toolName": "glob" } - * { "type": "tool", "phase": "call", "toolName": "function_execute", "ui": { "title": "Running code", "icon": "code" } } - * ``` - * Stream `type` is `MothershipStreamV1EventType.tool` (`mothership-stream-v1`) with `phase: 'call'`. - */ - -export const ToolPhase = { - workspace: 'workspace', - search: 'search', - management: 'management', - execution: 'execution', - resource: 'resource', - subagent: 'subagent', -} as const -export type ToolPhase = (typeof ToolPhase)[keyof typeof ToolPhase] - export const ToolCallStatus = { executing: 'executing', success: 'success', @@ -118,7 +97,6 @@ export interface ToolCallInfo { name: string status: ToolCallStatus displayTitle?: string - phaseLabel?: string params?: Record calledBy?: string result?: ToolCallResult @@ -194,134 +172,42 @@ export const SUBAGENT_LABELS: Record = { file: 'File Agent', } as const -export interface ToolUIMetadata { +interface ToolTitleMetadata { title: string - phaseLabel: string - phase: ToolPhase } /** - * Default UI metadata for tools observed in the SSE stream. - * The backend may send `ui` on some `MothershipStreamV1EventType.tool` payloads (`phase: 'call'`); - * this map provides fallback metadata when `ui` is absent. + * Fallback titles for tool calls when the stream did not provide one. */ -export const TOOL_UI_METADATA: Record = { - [Glob.id]: { - title: 'Finding files', - phaseLabel: 'Workspace', - phase: 'workspace', - }, - [Grep.id]: { - title: 'Searching', - phaseLabel: 'Workspace', - phase: 'workspace', - }, - [ReadTool.id]: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' }, - [SearchOnline.id]: { - title: 'Searching online', - phaseLabel: 'Search', - phase: 'search', - }, - [ScrapePage.id]: { - title: 'Scraping page', - phaseLabel: 'Search', - phase: 'search', - }, - [GetPageContents.id]: { - title: 'Getting page contents', - phaseLabel: 'Search', - phase: 'search', - }, - [SearchLibraryDocs.id]: { - title: 'Searching library docs', - phaseLabel: 'Search', - phase: 'search', - }, - [ManageMcpTool.id]: { - title: 'MCP server action', - phaseLabel: 'Management', - phase: 'management', - }, - [ManageSkill.id]: { - title: 'Skill action', - phaseLabel: 'Management', - phase: 'management', - }, - [UserMemory.id]: { - title: 'Accessing memory', - phaseLabel: 'Management', - phase: 'management', - }, - [FunctionExecute.id]: { - title: 'Running code', - phaseLabel: 'Code', - phase: 'execution', - }, - [Superagent.id]: { - title: 'Executing action', - phaseLabel: 'Action', - phase: 'execution', - }, - [UserTable.id]: { - title: 'Managing table', - phaseLabel: 'Resource', - phase: 'resource', - }, - [WorkspaceFile.id]: { - title: 'Editing file', - phaseLabel: 'Resource', - phase: 'resource', - }, - [EDIT_CONTENT_TOOL_ID]: { - title: 'Applying file content', - phaseLabel: 'Resource', - phase: 'resource', - }, - [CreateWorkflow.id]: { - title: 'Creating workflow', - phaseLabel: 'Resource', - phase: 'resource', - }, - [EditWorkflow.id]: { - title: 'Editing workflow', - phaseLabel: 'Resource', - phase: 'resource', - }, - [Workflow.id]: { title: 'Workflow Agent', phaseLabel: 'Workflow', phase: 'subagent' }, - [RUN_SUBAGENT_ID]: { title: 'Run Agent', phaseLabel: 'Run', phase: 'subagent' }, - [Deploy.id]: { title: 'Deploy Agent', phaseLabel: 'Deploy', phase: 'subagent' }, - [Auth.id]: { - title: 'Auth Agent', - phaseLabel: 'Auth', - phase: 'subagent', - }, - [Knowledge.id]: { - title: 'Knowledge Agent', - phaseLabel: 'Knowledge', - phase: 'subagent', - }, - [KnowledgeBase.id]: { - title: 'Managing knowledge base', - phaseLabel: 'Resource', - phase: 'resource', - }, - [Table.id]: { title: 'Table Agent', phaseLabel: 'Table', phase: 'subagent' }, - [Job.id]: { title: 'Job Agent', phaseLabel: 'Job', phase: 'subagent' }, - [Agent.id]: { title: 'Tools Agent', phaseLabel: 'Agent', phase: 'subagent' }, - custom_tool: { - title: 'Creating tool', - phaseLabel: 'Tool', - phase: 'subagent', - }, - [Research.id]: { title: 'Research Agent', phaseLabel: 'Research', phase: 'subagent' }, - [OpenResource.id]: { - title: 'Opening resource', - phaseLabel: 'Resource', - phase: 'resource', - }, - context_compaction: { - title: 'Compacted context', - phaseLabel: 'Context', - phase: 'management', - }, +export const TOOL_UI_METADATA: Record = { + [Glob.id]: { title: 'Finding files' }, + [Grep.id]: { title: 'Searching' }, + [ReadTool.id]: { title: 'Reading file' }, + [SearchOnline.id]: { title: 'Searching online' }, + [ScrapePage.id]: { title: 'Scraping page' }, + [GetPageContents.id]: { title: 'Getting page contents' }, + [SearchLibraryDocs.id]: { title: 'Searching library docs' }, + [ManageMcpTool.id]: { title: 'MCP server action' }, + [ManageSkill.id]: { title: 'Skill action' }, + [UserMemory.id]: { title: 'Accessing memory' }, + [FunctionExecute.id]: { title: 'Running code' }, + [Superagent.id]: { title: 'Executing action' }, + [UserTable.id]: { title: 'Managing table' }, + [WorkspaceFile.id]: { title: 'Editing file' }, + [EDIT_CONTENT_TOOL_ID]: { title: 'Applying file content' }, + [CreateWorkflow.id]: { title: 'Creating workflow' }, + [EditWorkflow.id]: { title: 'Editing workflow' }, + [Workflow.id]: { title: 'Workflow Agent' }, + [RUN_SUBAGENT_ID]: { title: 'Run Agent' }, + [Deploy.id]: { title: 'Deploy Agent' }, + [Auth.id]: { title: 'Auth Agent' }, + [Knowledge.id]: { title: 'Knowledge Agent' }, + [KnowledgeBase.id]: { title: 'Managing knowledge base' }, + [Table.id]: { title: 'Table Agent' }, + [Job.id]: { title: 'Job Agent' }, + [Agent.id]: { title: 'Tools Agent' }, + custom_tool: { title: 'Creating tool' }, + [Research.id]: { title: 'Research Agent' }, + [OpenResource.id]: { title: 'Opening resource' }, + context_compaction: { title: 'Compacted context' }, } diff --git a/apps/sim/lib/copilot/chat/display-message.test.ts b/apps/sim/lib/copilot/chat/display-message.test.ts index c389cd6fadf..b7e99b4804c 100644 --- a/apps/sim/lib/copilot/chat/display-message.test.ts +++ b/apps/sim/lib/copilot/chat/display-message.test.ts @@ -45,7 +45,6 @@ describe('display-message', () => { name: 'read', status: 'cancelled', displayTitle: 'Stopped by user', - phaseLabel: undefined, params: undefined, calledBy: undefined, result: undefined, diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 0d44097eb3d..87efb7800f2 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -37,7 +37,6 @@ function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined name: tc.name, status, displayTitle: status === ToolCallStatus.cancelled ? 'Stopped by user' : tc.display?.title, - phaseLabel: tc.display?.phaseLabel, params: tc.params, calledBy: tc.calledBy, result: tc.result, diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index b5839351d3d..b6e3193efce 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -26,7 +26,6 @@ describe('persisted-message', () => { name: 'read', status: 'success', displayTitle: 'Reading foo.txt', - phaseLabel: 'Workspace', params: { path: 'foo.txt' }, result: { success: true, output: { ok: true } }, }, @@ -46,7 +45,7 @@ describe('persisted-message', () => { id: 'tool-1', name: 'read', state: 'success', - display: { title: 'Reading foo.txt', phaseLabel: 'Workspace' }, + display: { title: 'Reading foo.txt' }, params: { path: 'foo.txt' }, result: { success: true, output: { ok: true } }, calledBy: 'workflow', @@ -87,7 +86,7 @@ describe('persisted-message', () => { id: 'tool-1', name: 'read', state: 'cancelled', - display: { title: 'Stopped by user', phaseLabel: 'Workspace' }, + display: { phaseLabel: 'Workspace' }, }, }, ], @@ -109,7 +108,7 @@ describe('persisted-message', () => { id: 'tool-1', name: 'read', state: 'cancelled', - display: { title: 'Stopped by user', phaseLabel: 'Workspace' }, + display: { title: 'Workspace' }, }, }, { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index b178c2854cb..cb0518700d2 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -25,7 +25,7 @@ export interface PersistedToolCall { error?: string calledBy?: string durationMs?: number - display?: { title?: string; phaseLabel?: string } + display?: { title?: string } } export interface PersistedContentBlock { @@ -146,11 +146,10 @@ function mapContentBlock(block: ContentBlock): PersistedContentBlock { ? { params: block.toolCall.params } : {}), ...(block.calledBy ? { calledBy: block.calledBy } : {}), - ...(block.toolCall.displayTitle || block.toolCall.phaseLabel + ...(block.toolCall.displayTitle ? { display: { - ...(block.toolCall.displayTitle ? { title: block.toolCall.displayTitle } : {}), - ...(block.toolCall.phaseLabel ? { phaseLabel: block.toolCall.phaseLabel } : {}), + title: block.toolCall.displayTitle, }, } : {}), @@ -312,8 +311,10 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { ...(block.toolCall.display ? { display: { - title: block.toolCall.display.title ?? block.toolCall.display.text, - phaseLabel: block.toolCall.display.phaseLabel, + title: + block.toolCall.display.title ?? + block.toolCall.display.text ?? + block.toolCall.display.phaseLabel, }, } : {}), @@ -337,8 +338,10 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { ...(block.toolCall.display ? { display: { - title: block.toolCall.display.title ?? block.toolCall.display.text, - phaseLabel: block.toolCall.display.phaseLabel, + title: + block.toolCall.display.title ?? + block.toolCall.display.text ?? + block.toolCall.display.phaseLabel, }, } : {}), diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 44986aa8b04..3f9136e3aa6 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -2529,6 +2529,13 @@ export const SetEnvironmentVariables: ToolCatalogEntry = { parameters: { type: 'object', properties: { + scope: { + type: 'string', + description: + 'Whether to set workspace or personal environment variables. Defaults to workspace.', + enum: ['personal', 'workspace'], + default: 'workspace', + }, variables: { type: 'array', description: 'List of env vars to set', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 7305d830f2f..ac4b51df2c2 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2342,6 +2342,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + scope: { + type: 'string', + description: + 'Whether to set workspace or personal environment variables. Defaults to workspace.', + enum: ['personal', 'workspace'], + default: 'workspace', + }, variables: { type: 'array', description: 'List of env vars to set', diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 47c8ada2a91..fd0892ab930 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -132,7 +132,6 @@ describe('sse-handlers tool lifecycle', () => { const updated = context.toolCalls.get('tool-1') expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.success) expect(updated?.displayTitle).toBe('Reading foo.txt') - expect(updated?.phaseLabel).toBe('Workspace') expect(updated?.result?.output).toEqual({ ok: true }) expect(context.contentBlocks.at(0)).toEqual( expect.objectContaining({ @@ -140,7 +139,45 @@ describe('sse-handlers tool lifecycle', () => { toolCall: expect.objectContaining({ id: 'tool-1', displayTitle: 'Reading foo.txt', - phaseLabel: 'Workspace', + }), + }) + ) + }) + + it('uses phaseLabel as a display title fallback when no title is provided', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-phase-label', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + ui: { + phaseLabel: 'Workspace', + }, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + const updated = context.toolCalls.get('tool-phase-label') + expect(updated?.displayTitle).toBe('Workspace') + expect(context.contentBlocks.at(0)).toEqual( + expect.objectContaining({ + type: 'tool_call', + toolCall: expect.objectContaining({ + id: 'tool-phase-label', + displayTitle: 'Workspace', }), }) ) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index a269d7b54bc..17da4ecbccf 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -47,8 +47,8 @@ function applyToolDisplay( ui: { title?: string; phaseLabel?: string } ): void { if (!toolCall) return - if (ui.title) toolCall.displayTitle = ui.title - if (ui.phaseLabel) toolCall.phaseLabel = ui.phaseLabel + const displayTitle = ui.title || ui.phaseLabel + if (displayTitle) toolCall.displayTitle = displayTitle } /** diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index f4ef77ba7d3..87416c5e4f4 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -22,7 +22,6 @@ export interface ToolCallState { name: string status: ToolCallStatus displayTitle?: string - phaseLabel?: string params?: Record result?: ToolCallStateResult error?: string diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 5aca48a629a..860dc7f0184 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -7,7 +7,6 @@ import { RunFromBlock, RunWorkflowUntilBlock, } from '@/lib/copilot/generated/tool-catalog-v1' -import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' import { generateId } from '@/lib/core/utils/uuid' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { useExecutionStore } from '@/stores/execution/store' @@ -156,7 +155,6 @@ export function markRunToolManuallyStopped(workflowId: string): string | null { const toolCallId = activeRunToolByWorkflowId.get(workflowId) if (!toolCallId) return null manuallyStoppedToolCallIds.add(toolCallId) - setToolState(toolCallId, ClientToolCallState.cancelled) return toolCallId } @@ -188,7 +186,6 @@ export async function reportManualRunToolStop( if (!manuallyStoppedToolCallIds.has(toolCallId)) { manuallyStoppedToolCallIds.add(toolCallId) - setToolState(toolCallId, ClientToolCallState.cancelled) } await reportCompletion( @@ -216,7 +213,6 @@ async function doExecuteRunTool( if (!targetWorkflowId) { logger.warn('[RunTool] Execution prevented: no active workflow', { toolCallId, toolName }) - setToolState(toolCallId, ClientToolCallState.error) await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.error, @@ -249,7 +245,6 @@ async function doExecuteRunTool( if (isExecuting) { logger.warn('[RunTool] Execution prevented: already executing', { toolCallId, toolName }) activeRunToolByWorkflowId.delete(targetWorkflowId) - setToolState(toolCallId, ClientToolCallState.error) await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.error, @@ -371,7 +366,6 @@ async function doExecuteRunTool( }) } else if (succeeded) { logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName }) - setToolState(toolCallId, ClientToolCallState.success) await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.success, @@ -381,7 +375,6 @@ async function doExecuteRunTool( } else { const msg = errorMessage || 'Workflow execution failed' logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg }) - setToolState(toolCallId, ClientToolCallState.error) await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.error, @@ -398,7 +391,6 @@ async function doExecuteRunTool( } else { const msg = err instanceof Error ? err.message : String(err) logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) - setToolState(toolCallId, ClientToolCallState.error) await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, msg) } } finally { @@ -422,10 +414,6 @@ async function doExecuteRunTool( } } -function setToolState(_toolCallId: string, _state: ClientToolCallState): void { - // no-op: tool state is tracked by the mothership SSE stream -} - /** * Extract a structured result payload from the raw execution result * for the LLM to see the actual workflow output. diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts new file mode 100644 index 00000000000..c15bce90159 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { resolveToolDisplay } from './store-utils' +import { ClientToolCallState } from './tool-call-state' + +describe('resolveToolDisplay', () => { + it('uses a friendly label for internal respond tools', () => { + expect(resolveToolDisplay('respond', ClientToolCallState.executing)?.text).toBe( + 'Gathering thoughts' + ) + expect(resolveToolDisplay('workflow_respond', ClientToolCallState.success)?.text).toBe( + 'Gathering thoughts' + ) + }) + + it('formats read targets from workspace paths', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'files/report.pdf', + })?.text + ).toBe('Reading report.pdf') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/My Workflow/meta.json', + })?.text + ).toBe('Read My Workflow') + }) + + it('falls back to a humanized tool label for generic tools', () => { + expect(resolveToolDisplay('deploy_api', ClientToolCallState.success)?.text).toBe( + 'Executed Deploy Api' + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 299764201a6..69de9f28bff 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -1,87 +1,23 @@ -import { createLogger } from '@sim/logger' import type { LucideIcon } from 'lucide-react' -import { - BookOpen, - Bug, - Cloud, - Code, - FileText, - Folder, - Globe, - HelpCircle, - Key, - Loader2, - Lock, - Pencil, - Play, - Plus, - Rocket, - Search, - Server, - Settings, - Terminal, - Wrench, - Zap, -} from 'lucide-react' +import { FileText, Loader2 } from 'lucide-react' import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { - ClientToolCallState, - type ClientToolDisplay, - TOOL_DISPLAY_REGISTRY, -} from '@/lib/copilot/tools/client/tool-display-registry' - -const logger = createLogger('CopilotStoreUtils') +import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' const INTERNAL_RESPOND_TOOL = 'respond' -/** UI metadata sent by the copilot on SSE tool_call events. */ -export interface ServerToolUI { - title?: string - phaseLabel?: string - icon?: string -} - -/** Maps copilot icon name strings to Lucide icon components. */ -const ICON_MAP: Record = { - search: Search, - globe: Globe, - hammer: Wrench, - rocket: Rocket, - lock: Lock, - book: BookOpen, - wrench: Wrench, - zap: Zap, - play: Play, - cloud: Cloud, - key: Key, - pencil: Pencil, - terminal: Terminal, - workflow: Settings, - settings: Settings, - server: Server, - bug: Bug, - brain: BookOpen, - code: Code, - help: HelpCircle, - plus: Plus, - file: FileText, - folder: Folder, -} -function resolveIcon(iconName: string | undefined): LucideIcon { - if (!iconName) return Loader2 - return ICON_MAP[iconName] || Loader2 +interface ClientToolDisplay { + text: string + icon: LucideIcon } export function resolveToolDisplay( toolName: string | undefined, state: ClientToolCallState, - _toolCallId?: string, - params?: Record, - serverUI?: ServerToolUI + params?: Record ): ClientToolDisplay | undefined { if (!toolName) return undefined if (isToolHiddenInUi(toolName)) return undefined @@ -89,36 +25,6 @@ export function resolveToolDisplay( const specialDisplay = specialToolDisplay(toolName, state, params) if (specialDisplay) return specialDisplay - const entry = TOOL_DISPLAY_REGISTRY[toolName] - if (!entry) { - // Use copilot-provided UI as a better fallback than humanized name - if (serverUI?.title) { - return serverUIFallback(serverUI, state) - } - return humanizedFallback(toolName, state) - } - - if (entry.uiConfig?.dynamicText && params) { - const dynamicText = entry.uiConfig.dynamicText(params, state) - const stateDisplay = entry.displayNames[state] - if (dynamicText && stateDisplay?.icon) { - return { text: dynamicText, icon: stateDisplay.icon } - } - } - - const display = entry.displayNames[state] - if (display?.text || display?.icon) return display - - const fallbackOrder = [ - ClientToolCallState.generating, - ClientToolCallState.executing, - ClientToolCallState.success, - ] - for (const fallbackState of fallbackOrder) { - const fallback = entry.displayNames[fallbackState] - if (fallback?.text || fallback?.icon) return fallback - } - return humanizedFallback(toolName, state) } @@ -200,25 +106,6 @@ function stripExtension(value: string): string { return value.replace(/\.[^/.]+$/, '') } -/** Generates display from copilot-provided UI metadata. */ -function serverUIFallback(serverUI: ServerToolUI, state: ClientToolCallState): ClientToolDisplay { - const icon = resolveIcon(serverUI.icon) - const title = serverUI.title! - - switch (state) { - case ClientToolCallState.success: - return { text: `Completed ${title.toLowerCase()}`, icon } - case ClientToolCallState.error: - return { text: `Failed ${title.toLowerCase()}`, icon } - case ClientToolCallState.rejected: - return { text: `Skipped ${title.toLowerCase()}`, icon } - case ClientToolCallState.aborted: - return { text: `Aborted ${title.toLowerCase()}`, icon } - default: - return { text: title, icon: Loader2 } - } -} - function humanizedFallback( toolName: string, state: ClientToolCallState @@ -234,15 +121,3 @@ function humanizedFallback( : 'Executing' return { text: `${stateVerb} ${formattedName}`, icon: Loader2 } } - -export function isRejectedState(state: string): boolean { - return state === ClientToolCallState.rejected -} - -export function isReviewState(state: string): boolean { - return state === 'review' -} - -export function isBackgroundState(state: string): boolean { - return state === 'background' -} diff --git a/apps/sim/lib/copilot/tools/client/tool-call-state.ts b/apps/sim/lib/copilot/tools/client/tool-call-state.ts new file mode 100644 index 00000000000..47f65cde114 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/tool-call-state.ts @@ -0,0 +1,12 @@ +export enum ClientToolCallState { + generating = 'generating', + pending = 'pending', + executing = 'executing', + aborted = 'aborted', + rejected = 'rejected', + success = 'success', + error = 'error', + cancelled = 'cancelled', + review = 'review', + background = 'background', +} diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts deleted file mode 100644 index e5fcaec3729..00000000000 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ /dev/null @@ -1,2390 +0,0 @@ -import type { LucideIcon } from 'lucide-react' -import { - BookOpen, - Check, - CheckCircle, - Database, - Eye, - FileSearch, - FileText, - FolderPlus, - GitBranch, - Globe, - Grid2x2, - Grid2x2Check, - Grid2x2X, - KeyRound, - Link, - Loader2, - MessageSquare, - MinusCircle, - Moon, - Navigation, - PencilLine, - Play, - PlugZap, - Plus, - Rocket, - Search, - Server, - Settings2, - Sparkles, - Table, - Tag, - TerminalSquare, - Wrench, - X, - XCircle, - Zap, -} from 'lucide-react' -import { - ManageCustomToolOperation, - ManageMcpToolOperation, - ManageSkillOperation, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import type { CustomToolDefinition } from '@/hooks/queries/custom-tools' -import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments' -import { deploymentKeys } from '@/hooks/queries/deployments' -import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' -import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -/** Resolve a block ID to its human-readable name from the workflow store. */ -function resolveBlockName(blockId: string | undefined): string | undefined { - if (!blockId) return undefined - try { - const blocks = useWorkflowStore.getState().blocks - return blocks[blockId]?.name || undefined - } catch { - return undefined - } -} - -export enum ClientToolCallState { - generating = 'generating', - pending = 'pending', - executing = 'executing', - aborted = 'aborted', - rejected = 'rejected', - success = 'success', - error = 'error', - cancelled = 'cancelled', - review = 'review', - background = 'background', -} - -export interface ClientToolDisplay { - text: string - icon: LucideIcon -} - -export type DynamicTextFormatter = ( - params: Record, - state: ClientToolCallState -) => string | undefined - -export interface ToolUIConfig { - isSpecial?: boolean - subagent?: boolean - interrupt?: boolean - customRenderer?: string - paramsTable?: any - dynamicText?: DynamicTextFormatter - secondaryAction?: any - alwaysExpanded?: boolean - subagentLabels?: { - streaming: string - completed: string - } -} - -interface ToolMetadata { - displayNames: Partial> - interrupt?: { - accept: ClientToolDisplay - reject: ClientToolDisplay - } - getDynamicText?: DynamicTextFormatter - uiConfig?: { - isSpecial?: boolean - subagent?: { - streamingLabel?: string - completedLabel?: string - shouldCollapse?: boolean - outputArtifacts?: string[] - hideThinkingText?: boolean - } - interrupt?: any - customRenderer?: string - paramsTable?: any - secondaryAction?: any - alwaysExpanded?: boolean - } -} - -interface ToolDisplayEntry { - displayNames: Partial> - uiConfig?: ToolUIConfig -} - -type WorkflowDataType = 'global_variables' | 'custom_tools' | 'mcp_tools' | 'files' - -type NavigationDestination = 'workflow' | 'logs' | 'templates' | 'vector_db' | 'settings' - -function formatDuration(seconds: number): string { - if (seconds < 60) return `${Math.round(seconds)}s` - const mins = Math.floor(seconds / 60) - const secs = Math.round(seconds % 60) - if (mins < 60) return secs > 0 ? `${mins}m ${secs}s` : `${mins}m` - const hours = Math.floor(mins / 60) - const remMins = mins % 60 - if (remMins > 0) return `${hours}h ${remMins}m` - return `${hours}h` -} - -function getScopedWorkspaceId(params: Record): string | undefined { - const paramWorkspaceId = params?.workspaceId - if (typeof paramWorkspaceId === 'string' && paramWorkspaceId.length > 0) { - return paramWorkspaceId - } - - return useWorkflowRegistry.getState().hydration.workspaceId ?? undefined -} - -function toUiConfig(metadata?: ToolMetadata): ToolUIConfig | undefined { - const legacy = metadata?.uiConfig - const subagent = legacy?.subagent - const dynamicText = metadata?.getDynamicText - // Check both nested uiConfig.interrupt AND top-level interrupt - const hasInterrupt = !!legacy?.interrupt || !!metadata?.interrupt - if (!legacy && !dynamicText && !hasInterrupt) return undefined - - const config: ToolUIConfig = { - isSpecial: legacy?.isSpecial === true, - subagent: !!legacy?.subagent, - interrupt: hasInterrupt, - customRenderer: legacy?.customRenderer, - paramsTable: legacy?.paramsTable, - dynamicText, - secondaryAction: legacy?.secondaryAction, - alwaysExpanded: legacy?.alwaysExpanded, - } - - if (subagent?.streamingLabel || subagent?.completedLabel) { - config.subagentLabels = { - streaming: subagent.streamingLabel || '', - completed: subagent.completedLabel || '', - } - } - - return config -} - -function toToolDisplayEntry(metadata?: ToolMetadata): ToolDisplayEntry { - return { - displayNames: metadata?.displayNames || {}, - uiConfig: toUiConfig(metadata), - } -} - -const META_auth: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Authenticating', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Authenticating', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Authenticating', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Authenticated', icon: KeyRound }, - [ClientToolCallState.error]: { text: 'Failed to authenticate', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped auth', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted auth', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Authenticating', - completedLabel: 'Authenticated', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_check_deployment_status: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Checking deployment status', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Checking deployment status', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Checking deployment status', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Checked deployment status', icon: Rocket }, - [ClientToolCallState.error]: { text: 'Failed to check deployment status', icon: X }, - [ClientToolCallState.aborted]: { - text: 'Aborted checking deployment status', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped checking deployment status', - icon: XCircle, - }, - }, - interrupt: undefined, -} - -const META_complete_job: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Completing job', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Completing job', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Completed job', icon: CheckCircle }, - [ClientToolCallState.error]: { text: 'Failed to complete job', icon: XCircle }, - }, -} - -const META_checkoff_todo: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Marking todo', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Marked todo complete', icon: Check }, - [ClientToolCallState.error]: { text: 'Failed to mark todo', icon: XCircle }, - }, -} - -const META_crawl_website: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Crawling website', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Crawling website', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Crawling website', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Crawled website', icon: Globe }, - [ClientToolCallState.error]: { text: 'Failed to crawl website', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted crawling website', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped crawling website', icon: MinusCircle }, - }, - interrupt: undefined, - getDynamicText: (params, state) => { - if (params?.url && typeof params.url === 'string') { - const url = params.url - - switch (state) { - case ClientToolCallState.success: - return `Crawled ${url}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Crawling ${url}` - case ClientToolCallState.error: - return `Failed to crawl ${url}` - case ClientToolCallState.aborted: - return `Aborted crawling ${url}` - case ClientToolCallState.rejected: - return `Skipped crawling ${url}` - } - } - return undefined - }, -} - -const META_create_file: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Creating file', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Creating file', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Created file', icon: FileText }, - [ClientToolCallState.error]: { text: 'Failed to create file', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const fileName = params?.fileName - if (typeof fileName !== 'string' || !fileName.trim()) return undefined - - switch (state) { - case ClientToolCallState.success: - return `Created ${fileName}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - return `Creating ${fileName}` - case ClientToolCallState.error: - return `Failed to create ${fileName}` - } - return undefined - }, -} - -const META_create_workspace_mcp_server: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to create MCP server', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Create MCP server?', icon: Server }, - [ClientToolCallState.executing]: { text: 'Creating MCP server', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Created MCP server', icon: Server }, - [ClientToolCallState.error]: { text: 'Failed to create MCP server', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted creating MCP server', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped creating MCP server', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Create', icon: Plus }, - reject: { text: 'Skip', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const name = params?.name || 'MCP server' - switch (state) { - case ClientToolCallState.success: - return `Created MCP server "${name}"` - case ClientToolCallState.executing: - return `Creating MCP server "${name}"` - case ClientToolCallState.generating: - return `Preparing to create "${name}"` - case ClientToolCallState.pending: - return `Create MCP server "${name}"?` - case ClientToolCallState.error: - return `Failed to create "${name}"` - } - return undefined - }, -} - -const META_custom_tool: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Managing custom tool', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Managing custom tool', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Managing custom tool', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Managed custom tool', icon: Wrench }, - [ClientToolCallState.error]: { text: 'Failed custom tool', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped custom tool', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted custom tool', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Managing custom tool', - completedLabel: 'Custom tool managed', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_agent: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Managing tools & skills', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Managing tools & skills', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Managing tools & skills', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Managed tools & skills', icon: Wrench }, - [ClientToolCallState.error]: { text: 'Failed managing tools', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped managing tools', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted managing tools', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Managing tools & skills', - completedLabel: 'Tools & skills managed', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_manage_skill: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Skill action', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Skill action?', icon: BookOpen }, - [ClientToolCallState.executing]: { text: 'Skill action', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Updated skill', icon: Check }, - [ClientToolCallState.error]: { text: 'Failed skill action', icon: X }, - [ClientToolCallState.aborted]: { - text: 'Aborted skill action', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped skill action', - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Allow', icon: Check }, - reject: { text: 'Deny', icon: X }, - }, - getDynamicText: (params, state) => { - const operation = params?.operation as ManageSkillOperation | undefined - if (!operation) return undefined - - const skillName = typeof params?.name === 'string' ? params.name : 'skill' - - switch (state) { - case ClientToolCallState.success: - switch (operation) { - case ManageSkillOperation.add: - return `Created ${skillName}` - case ManageSkillOperation.edit: - return `Updated ${skillName}` - case ManageSkillOperation.delete: - return `Deleted ${skillName}` - case ManageSkillOperation.list: - return 'Listed skills' - } - break - case ClientToolCallState.executing: - case ClientToolCallState.generating: - switch (operation) { - case ManageSkillOperation.add: - return `Creating ${skillName}` - case ManageSkillOperation.edit: - return `Updating ${skillName}` - case ManageSkillOperation.delete: - return `Deleting ${skillName}` - case ManageSkillOperation.list: - return 'Listing skills' - } - break - case ClientToolCallState.pending: - switch (operation) { - case ManageSkillOperation.add: - return `Create ${skillName}?` - case ManageSkillOperation.edit: - return `Update ${skillName}?` - case ManageSkillOperation.delete: - return `Delete ${skillName}?` - case ManageSkillOperation.list: - return 'List skills?' - } - break - case ClientToolCallState.error: - switch (operation) { - case ManageSkillOperation.add: - return `Failed to create ${skillName}` - case ManageSkillOperation.edit: - return `Failed to update ${skillName}` - case ManageSkillOperation.delete: - return `Failed to delete ${skillName}` - case ManageSkillOperation.list: - return 'Failed to list skills' - } - break - } - - return undefined - }, -} - -const META_workflow: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Managing workflow', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Managing workflow', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Managing workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Done', icon: Wrench }, - [ClientToolCallState.error]: { text: 'Failed', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Managing workflow', - completedLabel: 'Done', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_deploy: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Deploying', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Deploying', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Deploying', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Deployed', icon: Rocket }, - [ClientToolCallState.error]: { text: 'Failed to deploy', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped deploy', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted deploy', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Deploying', - completedLabel: 'Deployed', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_deploy_api: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to deploy API', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Deploy as API?', icon: Rocket }, - [ClientToolCallState.executing]: { text: 'Deploying API', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Deployed API', icon: Rocket }, - [ClientToolCallState.error]: { text: 'Failed to deploy API', icon: XCircle }, - [ClientToolCallState.aborted]: { - text: 'Aborted deploying API', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped deploying API', - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Deploy', icon: Rocket }, - reject: { text: 'Skip', icon: XCircle }, - }, - uiConfig: { - isSpecial: true, - interrupt: { - accept: { text: 'Deploy', icon: Rocket }, - reject: { text: 'Skip', icon: XCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - }, - getDynamicText: (params, state) => { - const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy' - - const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId - const isAlreadyDeployed = workflowId - ? (getQueryClient().getQueryData(deploymentKeys.info(workflowId)) - ?.isDeployed ?? false) - : false - - let actionText = action - let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying' - const actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed' - - if (action === 'deploy' && isAlreadyDeployed) { - actionText = 'redeploy' - actionTextIng = 'redeploying' - } - - const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1) - - switch (state) { - case ClientToolCallState.success: - return `API ${actionTextPast}` - case ClientToolCallState.executing: - return `${actionCapitalized}ing API` - case ClientToolCallState.generating: - return `Preparing to ${actionText} API` - case ClientToolCallState.pending: - return `${actionCapitalized} API?` - case ClientToolCallState.error: - return `Failed to ${actionText} API` - case ClientToolCallState.aborted: - return `Aborted ${actionTextIng} API` - case ClientToolCallState.rejected: - return `Skipped ${actionTextIng} API` - } - return undefined - }, -} - -const META_deploy_chat: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to deploy chat', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Deploy as chat?', icon: MessageSquare }, - [ClientToolCallState.executing]: { text: 'Deploying chat', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Deployed chat', icon: MessageSquare }, - [ClientToolCallState.error]: { text: 'Failed to deploy chat', icon: XCircle }, - [ClientToolCallState.aborted]: { - text: 'Aborted deploying chat', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped deploying chat', - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Deploy Chat', icon: MessageSquare }, - reject: { text: 'Skip', icon: XCircle }, - }, - uiConfig: { - isSpecial: true, - interrupt: { - accept: { text: 'Deploy Chat', icon: MessageSquare }, - reject: { text: 'Skip', icon: XCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - }, - getDynamicText: (params, state) => { - const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy' - - switch (state) { - case ClientToolCallState.success: - return action === 'undeploy' ? 'Chat undeployed' : 'Chat deployed' - case ClientToolCallState.executing: - return action === 'undeploy' ? 'Undeploying chat' : 'Deploying chat' - case ClientToolCallState.generating: - return `Preparing to ${action} chat` - case ClientToolCallState.pending: - return action === 'undeploy' ? 'Undeploy chat?' : 'Deploy as chat?' - case ClientToolCallState.error: - return `Failed to ${action} chat` - case ClientToolCallState.aborted: - return action === 'undeploy' ? 'Aborted undeploying chat' : 'Aborted deploying chat' - case ClientToolCallState.rejected: - return action === 'undeploy' ? 'Skipped undeploying chat' : 'Skipped deploying chat' - } - return undefined - }, -} - -const META_deploy_mcp: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to deploy to MCP', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Deploy to MCP server?', icon: Server }, - [ClientToolCallState.executing]: { text: 'Deploying to MCP', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Deployed to MCP', icon: Server }, - [ClientToolCallState.error]: { text: 'Failed to deploy to MCP', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted MCP deployment', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped MCP deployment', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Deploy', icon: Server }, - reject: { text: 'Skip', icon: XCircle }, - }, - uiConfig: { - isSpecial: true, - interrupt: { - accept: { text: 'Deploy', icon: Server }, - reject: { text: 'Skip', icon: XCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - }, - getDynamicText: (params, state) => { - const toolName = params?.toolName || 'workflow' - switch (state) { - case ClientToolCallState.success: - return `Deployed "${toolName}" to MCP` - case ClientToolCallState.executing: - return `Deploying "${toolName}" to MCP` - case ClientToolCallState.generating: - return `Preparing to deploy to MCP` - case ClientToolCallState.pending: - return `Deploy "${toolName}" to MCP?` - case ClientToolCallState.error: - return `Failed to deploy to MCP` - } - return undefined - }, -} - -const META_edit_workflow: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Editing your workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Edited your workflow', icon: Grid2x2Check }, - [ClientToolCallState.error]: { text: 'Failed to edit your workflow', icon: XCircle }, - [ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 }, - [ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: Grid2x2X }, - [ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle }, - [ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 }, - }, - uiConfig: { - isSpecial: true, - customRenderer: 'edit_summary', - }, -} - -const META_get_block_outputs: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Getting block outputs', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Getting block outputs', icon: Tag }, - [ClientToolCallState.executing]: { text: 'Getting block outputs', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted getting outputs', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Retrieved block outputs', icon: Tag }, - [ClientToolCallState.error]: { text: 'Failed to get outputs', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped getting outputs', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const blockIds = params?.blockIds - if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) { - const count = blockIds.length - switch (state) { - case ClientToolCallState.success: - return `Retrieved outputs for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Getting outputs for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.error: - return `Failed to get outputs for ${count} block${count > 1 ? 's' : ''}` - } - } - return undefined - }, -} - -const META_get_block_upstream_references: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Getting upstream references', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Getting upstream references', icon: GitBranch }, - [ClientToolCallState.executing]: { text: 'Getting upstream references', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted getting references', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Retrieved upstream references', icon: GitBranch }, - [ClientToolCallState.error]: { text: 'Failed to get references', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped getting references', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const blockIds = params?.blockIds - if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) { - const count = blockIds.length - switch (state) { - case ClientToolCallState.success: - return `Retrieved references for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Getting references for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.error: - return `Failed to get references for ${count} block${count > 1 ? 's' : ''}` - } - } - return undefined - }, -} - -const META_get_examples_rag: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Fetching examples', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Fetching examples', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Fetching examples', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Fetched examples', icon: Search }, - [ClientToolCallState.error]: { text: 'Failed to fetch examples', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted getting examples', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped getting examples', icon: MinusCircle }, - }, - interrupt: undefined, - getDynamicText: (params, state) => { - if (params?.query && typeof params.query === 'string') { - const query = params.query - - switch (state) { - case ClientToolCallState.success: - return `Found examples for ${query}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Searching examples for ${query}` - case ClientToolCallState.error: - return `Failed to find examples for ${query}` - case ClientToolCallState.aborted: - return `Aborted searching examples for ${query}` - case ClientToolCallState.rejected: - return `Skipped searching examples for ${query}` - } - } - return undefined - }, -} - -const META_get_operations_examples: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Designing workflow component', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Designing workflow component', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Designing workflow component', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Designed workflow component', icon: Zap }, - [ClientToolCallState.error]: { text: 'Failed to design workflow component', icon: XCircle }, - [ClientToolCallState.aborted]: { - text: 'Aborted designing workflow component', - icon: MinusCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped designing workflow component', - icon: MinusCircle, - }, - }, - interrupt: undefined, - getDynamicText: (params, state) => { - if (params?.query && typeof params.query === 'string') { - const query = params.query - - switch (state) { - case ClientToolCallState.success: - return `Designed ${query}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Designing ${query}` - case ClientToolCallState.error: - return `Failed to design ${query}` - case ClientToolCallState.aborted: - return `Aborted designing ${query}` - case ClientToolCallState.rejected: - return `Skipped designing ${query}` - } - } - return undefined - }, -} - -const META_get_platform_actions: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Viewing platform actions', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Viewing platform actions', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Viewing platform actions', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Viewed platform actions', icon: Navigation }, - [ClientToolCallState.error]: { text: 'Failed to view platform actions', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped platform actions', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted platform actions', icon: MinusCircle }, - }, -} - -const META_get_page_contents: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Getting page contents', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Getting page contents', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Getting page contents', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Retrieved page contents', icon: FileText }, - [ClientToolCallState.error]: { text: 'Failed to get page contents', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted getting page contents', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped getting page contents', icon: MinusCircle }, - }, - interrupt: undefined, - getDynamicText: (params, state) => { - if (params?.urls && Array.isArray(params.urls) && params.urls.length > 0) { - const firstUrl = String(params.urls[0]) - const count = params.urls.length - - switch (state) { - case ClientToolCallState.success: - return count > 1 ? `Retrieved ${count} pages` : `Retrieved ${firstUrl}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return count > 1 ? `Getting ${count} pages` : `Getting ${firstUrl}` - case ClientToolCallState.error: - return count > 1 ? `Failed to get ${count} pages` : `Failed to get ${firstUrl}` - case ClientToolCallState.aborted: - return count > 1 ? `Aborted getting ${count} pages` : `Aborted getting ${firstUrl}` - case ClientToolCallState.rejected: - return count > 1 ? `Skipped getting ${count} pages` : `Skipped getting ${firstUrl}` - } - } - return undefined - }, -} - -const META_get_trigger_examples: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Selecting a trigger', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Selecting a trigger', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Selecting a trigger', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Selected a trigger', icon: Zap }, - [ClientToolCallState.error]: { text: 'Failed to select a trigger', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted selecting a trigger', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped selecting a trigger', icon: MinusCircle }, - }, - interrupt: undefined, -} - -const META_get_workflow_logs: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Fetching execution logs', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Fetching execution logs', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Fetched execution logs', icon: TerminalSquare }, - [ClientToolCallState.error]: { text: 'Failed to fetch execution logs', icon: XCircle }, - [ClientToolCallState.rejected]: { - text: 'Skipped fetching execution logs', - icon: MinusCircle, - }, - [ClientToolCallState.aborted]: { - text: 'Aborted fetching execution logs', - icon: MinusCircle, - }, - [ClientToolCallState.pending]: { text: 'Fetching execution logs', icon: Loader2 }, - }, - getDynamicText: (params, state) => { - const limit = params?.limit - if (limit && typeof limit === 'number') { - const logText = limit === 1 ? 'execution log' : 'execution logs' - - switch (state) { - case ClientToolCallState.success: - return `Fetched last ${limit} ${logText}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Fetching last ${limit} ${logText}` - case ClientToolCallState.error: - return `Failed to fetch last ${limit} ${logText}` - case ClientToolCallState.rejected: - return `Skipped fetching last ${limit} ${logText}` - case ClientToolCallState.aborted: - return `Aborted fetching last ${limit} ${logText}` - } - } - return undefined - }, -} - -const META_get_workflow_data: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Fetching workflow data', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database }, - [ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted fetching data', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Retrieved workflow data', icon: Database }, - [ClientToolCallState.error]: { text: 'Failed to fetch data', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const dataType = params?.data_type as WorkflowDataType | undefined - if (!dataType) return undefined - - const typeLabels: Record = { - global_variables: 'variables', - custom_tools: 'custom tools', - mcp_tools: 'MCP tools', - files: 'files', - } - - const label = typeLabels[dataType] || dataType - - switch (state) { - case ClientToolCallState.success: - return `Retrieved ${label}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - return `Fetching ${label}` - case ClientToolCallState.pending: - return `Fetch ${label}?` - case ClientToolCallState.error: - return `Failed to fetch ${label}` - case ClientToolCallState.aborted: - return `Aborted fetching ${label}` - case ClientToolCallState.rejected: - return `Skipped fetching ${label}` - } - return undefined - }, -} - -const META_knowledge: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Managing knowledge', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Managing knowledge', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Managing knowledge', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Managed knowledge', icon: BookOpen }, - [ClientToolCallState.error]: { text: 'Failed to manage knowledge', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped knowledge', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted knowledge', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Managing knowledge', - completedLabel: 'Knowledge managed', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_knowledge_base: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Accessing knowledge base', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Access knowledge base?', icon: Database }, - [ClientToolCallState.executing]: { text: 'Accessing knowledge base', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Accessed knowledge base', icon: Database }, - [ClientToolCallState.error]: { text: 'Failed to access knowledge base', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted knowledge base access', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped knowledge base access', icon: MinusCircle }, - }, - interrupt: { - accept: { text: 'Allow', icon: Database }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - getDynamicText: (params: Record, state: ClientToolCallState) => { - const operation = params?.operation as string | undefined - const name = params?.args?.name as string | undefined - - const opVerbs: Record = { - create: { - active: 'Creating knowledge base', - past: 'Created knowledge base', - pending: name ? `Create knowledge base "${name}"?` : 'Create knowledge base?', - }, - list: { active: 'Listing knowledge bases', past: 'Listed knowledge bases' }, - get: { active: 'Getting knowledge base', past: 'Retrieved knowledge base' }, - query: { active: 'Querying knowledge base', past: 'Queried knowledge base' }, - } - const defaultVerb: { active: string; past: string; pending?: string } = { - active: 'Accessing knowledge base', - past: 'Accessed knowledge base', - } - const verb = operation ? opVerbs[operation] || defaultVerb : defaultVerb - - if (state === ClientToolCallState.success) { - return verb.past - } - if (state === ClientToolCallState.pending && verb.pending) { - return verb.pending - } - if ( - state === ClientToolCallState.generating || - state === ClientToolCallState.pending || - state === ClientToolCallState.executing - ) { - return verb.active - } - return undefined - }, -} - -const META_list_workspace_mcp_servers: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Getting MCP servers', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Getting MCP servers', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Getting MCP servers', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Retrieved MCP servers', icon: Server }, - [ClientToolCallState.error]: { text: 'Failed to get MCP servers', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted getting MCP servers', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped getting MCP servers', icon: XCircle }, - }, - interrupt: undefined, -} - -const META_manage_custom_tool: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Custom tool action', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Custom tool action?', icon: Plus }, - [ClientToolCallState.executing]: { text: 'Custom tool action', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Updated custom tool', icon: Check }, - [ClientToolCallState.error]: { text: 'Failed custom tool action', icon: X }, - [ClientToolCallState.aborted]: { - text: 'Aborted custom tool action', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped custom tool action', - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Allow', icon: Check }, - reject: { text: 'Skip', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const operation = params?.operation as ManageCustomToolOperation | undefined - const workspaceId = getScopedWorkspaceId(params) - - if (!operation) return undefined - - let toolName = params?.schema?.function?.name - if (!toolName && params?.toolId && workspaceId) { - try { - const tools = - getQueryClient().getQueryData( - customToolsKeys.list(workspaceId) - ) ?? [] - const tool = tools.find((t) => t.id === params.toolId || t.title === params.toolId) - toolName = tool?.schema?.function?.name - } catch { - // Ignore errors accessing cache - } - } - - const getActionText = (verb: 'present' | 'past' | 'gerund') => { - switch (operation) { - case ManageCustomToolOperation.add: - return verb === 'present' ? 'Create' : verb === 'past' ? 'Created' : 'Creating' - case ManageCustomToolOperation.edit: - return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing' - case ManageCustomToolOperation.delete: - return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting' - case ManageCustomToolOperation.list: - return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing' - default: - return verb === 'present' ? 'Manage' : verb === 'past' ? 'Managed' : 'Managing' - } - } - - // For add: only show tool name in past tense (success) - // For edit/delete: always show tool name - // For list: never show individual tool name, use plural - const shouldShowToolName = (currentState: ClientToolCallState) => { - if (operation === ManageCustomToolOperation.list) return false - if (operation === ManageCustomToolOperation.add) { - return currentState === ClientToolCallState.success - } - return true // edit and delete always show tool name - } - - const nameText = - operation === ManageCustomToolOperation.list - ? ' custom tools' - : shouldShowToolName(state) && toolName - ? ` ${toolName}` - : ' custom tool' - - switch (state) { - case ClientToolCallState.success: - return `${getActionText('past')}${nameText}` - case ClientToolCallState.executing: - return `${getActionText('gerund')}${nameText}` - case ClientToolCallState.generating: - return `${getActionText('gerund')}${nameText}` - case ClientToolCallState.pending: - return `${getActionText('present')}${nameText}?` - case ClientToolCallState.error: - return `Failed to ${getActionText('present')?.toLowerCase()}${nameText}` - case ClientToolCallState.aborted: - return `Aborted ${getActionText('gerund')?.toLowerCase()}${nameText}` - case ClientToolCallState.rejected: - return `Skipped ${getActionText('gerund')?.toLowerCase()}${nameText}` - } - return undefined - }, -} - -const META_manage_mcp_tool: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'MCP server action', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'MCP server action?', icon: Server }, - [ClientToolCallState.executing]: { text: 'MCP server action', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Updated MCP server', icon: Check }, - [ClientToolCallState.error]: { text: 'Failed MCP server action', icon: X }, - [ClientToolCallState.aborted]: { - text: 'Aborted MCP server action', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped MCP server action', - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Allow', icon: Check }, - reject: { text: 'Skip', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const operation = params?.operation as ManageMcpToolOperation | undefined - - if (!operation) return undefined - - const serverName = params?.config?.name || params?.serverName - - const getActionText = (verb: 'present' | 'past' | 'gerund') => { - switch (operation) { - case ManageMcpToolOperation.add: - return verb === 'present' ? 'Add' : verb === 'past' ? 'Added' : 'Adding' - case ManageMcpToolOperation.edit: - return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing' - case ManageMcpToolOperation.delete: - return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting' - case ManageMcpToolOperation.list: - return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing' - } - } - - const shouldShowServerName = (currentState: ClientToolCallState) => { - if (operation === ManageMcpToolOperation.list) return false - if (operation === ManageMcpToolOperation.add) { - return currentState === ClientToolCallState.success - } - return true - } - - const nameText = - operation === ManageMcpToolOperation.list - ? ' MCP servers' - : shouldShowServerName(state) && serverName - ? ` ${serverName}` - : ' MCP server' - - switch (state) { - case ClientToolCallState.success: - return `${getActionText('past')}${nameText}` - case ClientToolCallState.executing: - return `${getActionText('gerund')}${nameText}` - case ClientToolCallState.generating: - return `${getActionText('gerund')}${nameText}` - case ClientToolCallState.pending: - return `${getActionText('present')}${nameText}?` - case ClientToolCallState.error: - return `Failed to ${getActionText('present')?.toLowerCase()}${nameText}` - case ClientToolCallState.aborted: - return `Aborted ${getActionText('gerund')?.toLowerCase()}${nameText}` - case ClientToolCallState.rejected: - return `Skipped ${getActionText('gerund')?.toLowerCase()}${nameText}` - } - return undefined - }, -} - -const META_mark_todo_in_progress: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Marking todo in progress', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Marking todo in progress', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Marking todo in progress', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Marked todo in progress', icon: Loader2 }, - [ClientToolCallState.error]: { text: 'Failed to mark in progress', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped marking in progress', icon: MinusCircle }, - }, -} - -const META_open_resource: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Opening resource', icon: Eye }, - [ClientToolCallState.pending]: { text: 'Opening resource', icon: Eye }, - [ClientToolCallState.executing]: { text: 'Opening resource', icon: Eye }, - [ClientToolCallState.success]: { text: 'Opened resource', icon: Eye }, - [ClientToolCallState.error]: { text: 'Failed to open resource', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped opening resource', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted opening resource', icon: XCircle }, - }, -} - -const META_navigate_ui: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to open', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Open?', icon: Navigation }, - [ClientToolCallState.executing]: { text: 'Opening', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Opened', icon: Navigation }, - [ClientToolCallState.error]: { text: 'Failed to open', icon: X }, - [ClientToolCallState.aborted]: { - text: 'Aborted opening', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped opening', - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Open', icon: Navigation }, - reject: { text: 'Skip', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const destination = params?.destination as NavigationDestination | undefined - const workflowName = params?.workflowName - - const action = 'open' - const actionCapitalized = 'Open' - const actionPast = 'opened' - const actionIng = 'opening' - let target = '' - - if (destination === 'workflow' && workflowName) { - target = ` workflow "${workflowName}"` - } else if (destination === 'workflow') { - target = ' workflows' - } else if (destination === 'logs') { - target = ' logs' - } else if (destination === 'templates') { - target = ' templates' - } else if (destination === 'vector_db') { - target = ' vector database' - } else if (destination === 'settings') { - target = ' settings' - } - - const fullAction = `${action}${target}` - const fullActionCapitalized = `${actionCapitalized}${target}` - const fullActionPast = `${actionPast}${target}` - const fullActionIng = `${actionIng}${target}` - - switch (state) { - case ClientToolCallState.success: - return fullActionPast.charAt(0).toUpperCase() + fullActionPast.slice(1) - case ClientToolCallState.executing: - return fullActionIng.charAt(0).toUpperCase() + fullActionIng.slice(1) - case ClientToolCallState.generating: - return `Preparing to ${fullAction}` - case ClientToolCallState.pending: - return `${fullActionCapitalized}?` - case ClientToolCallState.error: - return `Failed to ${fullAction}` - case ClientToolCallState.aborted: - return `Aborted ${fullAction}` - case ClientToolCallState.rejected: - return `Skipped ${fullAction}` - } - return undefined - }, -} - -const META_oauth_request_access: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Requesting integration access', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Requesting integration access', icon: Loader2 }, - [ClientToolCallState.rejected]: { text: 'Skipped integration access', icon: MinusCircle }, - [ClientToolCallState.success]: { text: 'Requested integration access', icon: CheckCircle }, - [ClientToolCallState.error]: { text: 'Failed to request integration access', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Connect', icon: PlugZap }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - getDynamicText: (params, state) => { - if (params.providerName) { - const name = params.providerName - switch (state) { - case ClientToolCallState.generating: - case ClientToolCallState.pending: - case ClientToolCallState.executing: - return `Requesting ${name} access` - case ClientToolCallState.rejected: - return `Skipped ${name} access` - case ClientToolCallState.success: - return `Requested ${name} access` - case ClientToolCallState.error: - return `Failed to request ${name} access` - case ClientToolCallState.aborted: - return `Aborted ${name} access request` - } - } - return undefined - }, -} - -const META_redeploy: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Redeploying workflow', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Redeploy workflow', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Redeploying workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Redeployed workflow', icon: Rocket }, - [ClientToolCallState.error]: { text: 'Failed to redeploy workflow', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted redeploy', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped redeploy', icon: XCircle }, - }, - interrupt: undefined, -} - -const META_research: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Researching', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Researching', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Researching', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Researched', icon: Search }, - [ClientToolCallState.error]: { text: 'Failed to research', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped research', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted research', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Researching', - completedLabel: 'Researched', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_generate_api_key: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Preparing to generate API key', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Generate API key?', icon: KeyRound }, - [ClientToolCallState.executing]: { text: 'Generating API key', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Generated API key', icon: KeyRound }, - [ClientToolCallState.error]: { text: 'Failed to generate API key', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped generating API key', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted generating API key', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Generate', icon: KeyRound }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - uiConfig: { - interrupt: { - accept: { text: 'Generate', icon: KeyRound }, - reject: { text: 'Skip', icon: MinusCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - }, - getDynamicText: (params, state) => { - const name = params?.name - if (name && typeof name === 'string') { - switch (state) { - case ClientToolCallState.success: - return `Generated API key "${name}"` - case ClientToolCallState.executing: - return `Generating API key "${name}"` - case ClientToolCallState.generating: - return `Preparing to generate "${name}"` - case ClientToolCallState.pending: - return `Generate API key "${name}"?` - case ClientToolCallState.error: - return `Failed to generate "${name}"` - } - } - return undefined - }, -} - -const META_run_block: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Preparing to run block', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Run block?', icon: Play }, - [ClientToolCallState.executing]: { text: 'Running block', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Ran block', icon: Play }, - [ClientToolCallState.error]: { text: 'Failed to run block', icon: XCircle }, - [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped running block', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted running block', icon: MinusCircle }, - [ClientToolCallState.background]: { text: 'Running block in background', icon: Play }, - }, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - uiConfig: { - isSpecial: true, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - secondaryAction: { - text: 'Move to Background', - title: 'Move to Background', - variant: 'tertiary', - showInStates: [ClientToolCallState.executing], - completionMessage: - 'The user has chosen to move the block execution to the background. Check back with them later to know when the block execution is complete', - targetState: ClientToolCallState.background, - }, - }, - getDynamicText: (params, state) => { - const blockId = params?.blockId || params?.block_id - if (blockId && typeof blockId === 'string') { - const name = resolveBlockName(blockId) || blockId - switch (state) { - case ClientToolCallState.success: - return `Ran ${name}` - case ClientToolCallState.executing: - return `Running ${name}` - case ClientToolCallState.generating: - return `Preparing to run ${name}` - case ClientToolCallState.pending: - return `Run ${name}?` - case ClientToolCallState.error: - return `Failed to run ${name}` - case ClientToolCallState.cancelled: - return `Stopped running ${name}` - case ClientToolCallState.rejected: - return `Skipped running ${name}` - case ClientToolCallState.aborted: - return `Aborted running ${name}` - case ClientToolCallState.background: - return `Running ${name} in background` - } - } - return undefined - }, -} - -const META_run_from_block: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Preparing to run from block', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Run from block?', icon: Play }, - [ClientToolCallState.executing]: { text: 'Running from block', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Ran from block', icon: Play }, - [ClientToolCallState.error]: { text: 'Failed to run from block', icon: XCircle }, - [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped running from block', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted running from block', icon: MinusCircle }, - [ClientToolCallState.background]: { text: 'Running from block in background', icon: Play }, - }, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - uiConfig: { - isSpecial: true, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - secondaryAction: { - text: 'Move to Background', - title: 'Move to Background', - variant: 'tertiary', - showInStates: [ClientToolCallState.executing], - completionMessage: - 'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete', - targetState: ClientToolCallState.background, - }, - }, - getDynamicText: (params, state) => { - const blockId = params?.startBlockId || params?.start_block_id - if (blockId && typeof blockId === 'string') { - const name = resolveBlockName(blockId) || blockId - switch (state) { - case ClientToolCallState.success: - return `Ran from ${name}` - case ClientToolCallState.executing: - return `Running from ${name}` - case ClientToolCallState.generating: - return `Preparing to run from ${name}` - case ClientToolCallState.pending: - return `Run from ${name}?` - case ClientToolCallState.error: - return `Failed to run from ${name}` - case ClientToolCallState.cancelled: - return `Stopped running from ${name}` - case ClientToolCallState.rejected: - return `Skipped running from ${name}` - case ClientToolCallState.aborted: - return `Aborted running from ${name}` - case ClientToolCallState.background: - return `Running from ${name} in background` - } - } - return undefined - }, -} - -const META_run_workflow_until_block: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Preparing to run until block', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Run until block?', icon: Play }, - [ClientToolCallState.executing]: { text: 'Running until block', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Ran until block', icon: Play }, - [ClientToolCallState.error]: { text: 'Failed to run until block', icon: XCircle }, - [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped running until block', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted running until block', icon: MinusCircle }, - [ClientToolCallState.background]: { text: 'Running until block in background', icon: Play }, - }, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - uiConfig: { - isSpecial: true, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - secondaryAction: { - text: 'Move to Background', - title: 'Move to Background', - variant: 'tertiary', - showInStates: [ClientToolCallState.executing], - completionMessage: - 'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete', - targetState: ClientToolCallState.background, - }, - }, - getDynamicText: (params, state) => { - const blockId = params?.stopAfterBlockId || params?.stop_after_block_id - if (blockId && typeof blockId === 'string') { - const name = resolveBlockName(blockId) || blockId - switch (state) { - case ClientToolCallState.success: - return `Ran until ${name}` - case ClientToolCallState.executing: - return `Running until ${name}` - case ClientToolCallState.generating: - return `Preparing to run until ${name}` - case ClientToolCallState.pending: - return `Run until ${name}?` - case ClientToolCallState.error: - return `Failed to run until ${name}` - case ClientToolCallState.cancelled: - return `Stopped running until ${name}` - case ClientToolCallState.rejected: - return `Skipped running until ${name}` - case ClientToolCallState.aborted: - return `Aborted running until ${name}` - case ClientToolCallState.background: - return `Running until ${name} in background` - } - } - return undefined - }, -} - -const META_run_workflow: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play }, - [ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Executed workflow', icon: Play }, - [ClientToolCallState.error]: { text: 'Errored running workflow', icon: XCircle }, - [ClientToolCallState.cancelled]: { text: 'Stopped by user', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped workflow execution', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle }, - [ClientToolCallState.background]: { text: 'Running in background', icon: Play }, - }, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - }, - uiConfig: { - isSpecial: true, - interrupt: { - accept: { text: 'Run', icon: Play }, - reject: { text: 'Skip', icon: MinusCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - secondaryAction: { - text: 'Move to Background', - title: 'Move to Background', - variant: 'tertiary', - showInStates: [ClientToolCallState.executing], - completionMessage: - 'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete', - targetState: ClientToolCallState.background, - }, - paramsTable: { - columns: [ - { key: 'input', label: 'Input', width: '36%' }, - { key: 'value', label: 'Value', width: '64%', editable: true, mono: true }, - ], - extractRows: (params: Record): Array<[string, ...any[]]> => { - let inputs = params.input || params.inputs || params.workflow_input - if (typeof inputs === 'string') { - try { - inputs = JSON.parse(inputs) - } catch { - inputs = {} - } - } - if (params.workflow_input && typeof params.workflow_input === 'object') { - inputs = params.workflow_input - } - if (!inputs || typeof inputs !== 'object') { - const { workflowId, workflow_input, ...rest } = params - inputs = rest - } - const safeInputs = inputs && typeof inputs === 'object' ? inputs : {} - return Object.entries(safeInputs).map(([key, value]) => [key, key, String(value)]) - }, - }, - }, - getDynamicText: (params, state) => { - const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId - const workspaceId = getScopedWorkspaceId(params) - if (workflowId && workspaceId) { - const workflowName = getWorkflowById(workspaceId, workflowId)?.name - if (workflowName) { - switch (state) { - case ClientToolCallState.success: - return `Ran ${workflowName}` - case ClientToolCallState.executing: - return `Running ${workflowName}` - case ClientToolCallState.generating: - return `Preparing to run ${workflowName}` - case ClientToolCallState.pending: - return `Run ${workflowName}?` - case ClientToolCallState.error: - return `Failed to run ${workflowName}` - case ClientToolCallState.cancelled: - return `Stopped ${workflowName}` - case ClientToolCallState.rejected: - return `Skipped running ${workflowName}` - case ClientToolCallState.aborted: - return `Aborted running ${workflowName}` - case ClientToolCallState.background: - return `Running ${workflowName} in background` - } - } - } - return undefined - }, -} - -const META_scrape_page: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Scraping page', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Scraping page', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Scraping page', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Scraped page', icon: Globe }, - [ClientToolCallState.error]: { text: 'Failed to scrape page', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted scraping page', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped scraping page', icon: MinusCircle }, - }, - interrupt: undefined, - getDynamicText: (params, state) => { - if (params?.url && typeof params.url === 'string') { - const url = params.url - - switch (state) { - case ClientToolCallState.success: - return `Scraped ${url}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Scraping ${url}` - case ClientToolCallState.error: - return `Failed to scrape ${url}` - case ClientToolCallState.aborted: - return `Aborted scraping ${url}` - case ClientToolCallState.rejected: - return `Skipped scraping ${url}` - } - } - return undefined - }, -} - -const META_search_documentation: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Searching documentation', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Searching documentation', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Completed documentation search', icon: BookOpen }, - [ClientToolCallState.error]: { text: 'Failed to search docs', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle }, - }, - getDynamicText: (params, state) => { - if (params?.query && typeof params.query === 'string') { - const query = params.query - - switch (state) { - case ClientToolCallState.success: - return `Searched docs for ${query}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Searching docs for ${query}` - case ClientToolCallState.error: - return `Failed to search docs for ${query}` - case ClientToolCallState.aborted: - return `Aborted searching docs for ${query}` - case ClientToolCallState.rejected: - return `Skipped searching docs for ${query}` - } - } - return undefined - }, -} - -const META_search_library_docs: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Reading docs', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Reading docs', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Reading docs', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Read docs', icon: BookOpen }, - [ClientToolCallState.error]: { text: 'Failed to read docs', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted reading docs', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped reading docs', icon: MinusCircle }, - }, - getDynamicText: (params, state) => { - const libraryName = params?.library_name - if (libraryName && typeof libraryName === 'string') { - switch (state) { - case ClientToolCallState.success: - return `Read ${libraryName} docs` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Reading ${libraryName} docs` - case ClientToolCallState.error: - return `Failed to read ${libraryName} docs` - case ClientToolCallState.aborted: - return `Aborted reading ${libraryName} docs` - case ClientToolCallState.rejected: - return `Skipped reading ${libraryName} docs` - } - } - return undefined - }, -} - -const META_search_online: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Searching online', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Searching online', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Completed online search', icon: Globe }, - [ClientToolCallState.error]: { text: 'Failed to search online', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle }, - }, - interrupt: undefined, - getDynamicText: (params, state) => { - if (params?.query && typeof params.query === 'string') { - const query = params.query - - switch (state) { - case ClientToolCallState.success: - return `Searched online for ${query}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Searching online for ${query}` - case ClientToolCallState.error: - return `Failed to search online for ${query}` - case ClientToolCallState.aborted: - return `Aborted searching online for ${query}` - case ClientToolCallState.rejected: - return `Skipped searching online for ${query}` - } - } - return undefined - }, -} - -const META_search_patterns: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Searching workflow patterns', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Searching workflow patterns', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Searching workflow patterns', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Found workflow patterns', icon: Search }, - [ClientToolCallState.error]: { text: 'Failed to search patterns', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted pattern search', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped pattern search', icon: MinusCircle }, - }, - interrupt: undefined, - getDynamicText: (params, state) => { - if (params?.queries && Array.isArray(params.queries) && params.queries.length > 0) { - const firstQuery = String(params.queries[0]) - - switch (state) { - case ClientToolCallState.success: - return `Searched ${firstQuery}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Searching ${firstQuery}` - case ClientToolCallState.error: - return `Failed to search ${firstQuery}` - case ClientToolCallState.aborted: - return `Aborted searching ${firstQuery}` - case ClientToolCallState.rejected: - return `Skipped searching ${firstQuery}` - } - } - return undefined - }, -} - -const META_set_environment_variables: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to set environment variables', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Set environment variables?', icon: Settings2 }, - [ClientToolCallState.executing]: { text: 'Setting environment variables', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Set environment variables', icon: Settings2 }, - [ClientToolCallState.error]: { text: 'Failed to set environment variables', icon: X }, - [ClientToolCallState.aborted]: { - text: 'Aborted setting environment variables', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped setting environment variables', - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Apply', icon: Settings2 }, - reject: { text: 'Skip', icon: XCircle }, - }, - uiConfig: { - alwaysExpanded: true, - interrupt: { - accept: { text: 'Apply', icon: Settings2 }, - reject: { text: 'Skip', icon: XCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - paramsTable: { - columns: [ - { key: 'name', label: 'Variable', width: '36%', editable: true }, - { key: 'value', label: 'Value', width: '64%', editable: true, mono: true }, - ], - extractRows: (params: Record): Array<[string, ...any[]]> => { - const variables = params.variables || {} - const entries = Array.isArray(variables) - ? variables.map((v: any, i: number) => [String(i), v.name || `var_${i}`, v.value || '']) - : Object.entries(variables).map(([key, val]) => { - if (typeof val === 'object' && val !== null && 'value' in (val as any)) { - return [key, key, (val as any).value] - } - return [key, key, val] - }) - return entries as Array<[string, ...any[]]> - }, - }, - }, - getDynamicText: (params, state) => { - if (params?.variables && typeof params.variables === 'object') { - const count = Object.keys(params.variables).length - const varText = count === 1 ? 'variable' : 'variables' - - switch (state) { - case ClientToolCallState.success: - return `Set ${count} ${varText}` - case ClientToolCallState.executing: - return `Setting ${count} ${varText}` - case ClientToolCallState.generating: - return `Preparing to set ${count} ${varText}` - case ClientToolCallState.pending: - return `Set ${count} ${varText}?` - case ClientToolCallState.error: - return `Failed to set ${count} ${varText}` - case ClientToolCallState.aborted: - return `Aborted setting ${count} ${varText}` - case ClientToolCallState.rejected: - return `Skipped setting ${count} ${varText}` - } - } - return undefined - }, -} - -const META_set_global_workflow_variables: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to set workflow variables', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 }, - [ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Updated workflow variables', icon: Settings2 }, - [ClientToolCallState.error]: { text: 'Failed to set workflow variables', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped setting variables', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Apply', icon: Settings2 }, - reject: { text: 'Skip', icon: XCircle }, - }, - uiConfig: { - interrupt: { - accept: { text: 'Apply', icon: Settings2 }, - reject: { text: 'Skip', icon: XCircle }, - showAllowOnce: true, - showAllowAlways: true, - }, - paramsTable: { - columns: [ - { key: 'name', label: 'Name', width: '40%', editable: true, mono: true }, - { key: 'value', label: 'Value', width: '60%', editable: true, mono: true }, - ], - extractRows: (params: Record): Array<[string, ...any[]]> => { - const operations = params.operations || [] - return operations.map((op: any, idx: number) => [ - String(idx), - op.name || '', - String(op.value ?? ''), - ]) - }, - }, - }, - getDynamicText: (params, state) => { - if (params?.operations && Array.isArray(params.operations)) { - const varNames = params.operations - .slice(0, 2) - .map((op: any) => op.name) - .filter(Boolean) - - if (varNames.length > 0) { - const varList = varNames.join(', ') - const more = params.operations.length > 2 ? '...' : '' - const displayText = `${varList}${more}` - - switch (state) { - case ClientToolCallState.success: - return `Set ${displayText}` - case ClientToolCallState.executing: - return `Setting ${displayText}` - case ClientToolCallState.generating: - return `Preparing to set ${displayText}` - case ClientToolCallState.pending: - return `Set ${displayText}?` - case ClientToolCallState.error: - return `Failed to set ${displayText}` - case ClientToolCallState.aborted: - return `Aborted setting ${displayText}` - case ClientToolCallState.rejected: - return `Skipped setting ${displayText}` - } - } - } - return undefined - }, -} - -const META_sleep: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Preparing to sleep', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon }, - [ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted sleep', icon: MinusCircle }, - [ClientToolCallState.background]: { text: 'Resumed', icon: Moon }, - }, - uiConfig: { - secondaryAction: { - text: 'Wake', - title: 'Wake', - variant: 'tertiary', - showInStates: [ClientToolCallState.executing], - targetState: ClientToolCallState.background, - }, - }, - // No interrupt - auto-execute immediately - getDynamicText: (params, state) => { - const seconds = params?.seconds - if (typeof seconds === 'number' && seconds > 0) { - const displayTime = formatDuration(seconds) - switch (state) { - case ClientToolCallState.success: - return `Slept for ${displayTime}` - case ClientToolCallState.executing: - case ClientToolCallState.pending: - return `Sleeping for ${displayTime}` - case ClientToolCallState.generating: - return `Preparing to sleep for ${displayTime}` - case ClientToolCallState.error: - return `Failed to sleep for ${displayTime}` - case ClientToolCallState.rejected: - return `Skipped sleeping for ${displayTime}` - case ClientToolCallState.aborted: - return `Aborted sleeping for ${displayTime}` - case ClientToolCallState.background: { - // Calculate elapsed time from when sleep started - const elapsedSeconds = params?._elapsedSeconds - if (typeof elapsedSeconds === 'number' && elapsedSeconds > 0) { - return `Resumed after ${formatDuration(Math.round(elapsedSeconds))}` - } - return 'Resumed early' - } - } - } - return undefined - }, -} - -const META_summarize_conversation: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Summarizing conversation', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Summarizing conversation', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Summarizing conversation', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Summarized conversation', icon: PencilLine }, - [ClientToolCallState.error]: { text: 'Failed to summarize conversation', icon: XCircle }, - [ClientToolCallState.aborted]: { - text: 'Aborted summarizing conversation', - icon: MinusCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped summarizing conversation', - icon: MinusCircle, - }, - }, - interrupt: undefined, -} - -const META_superagent: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Superagent working', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Superagent working', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Superagent working', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Superagent completed', icon: Sparkles }, - [ClientToolCallState.error]: { text: 'Superagent failed', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Superagent skipped', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Superagent aborted', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Superagent working', - completedLabel: 'Superagent completed', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_table: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Managing tables', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Managing tables', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Managing tables', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Tables updated', icon: Table }, - [ClientToolCallState.error]: { text: 'Failed to manage tables', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped table operation', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted table operation', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Managing tables', - completedLabel: 'Tables updated', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_run: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Running', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Running', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Running', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Ran', icon: Play }, - [ClientToolCallState.error]: { text: 'Failed to run', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped run', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted run', icon: XCircle }, - }, - uiConfig: { - subagent: { - streamingLabel: 'Running', - completedLabel: 'Ran', - shouldCollapse: true, - outputArtifacts: [], - }, - }, -} - -const META_get_deployed_workflow_state: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Checking deployed state', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Checking deployed state', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Retrieved deployed state', icon: Eye }, - [ClientToolCallState.error]: { text: 'Failed to get deployed state', icon: XCircle }, - }, -} - -const META_list_user_workspaces: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Listing workspaces', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Listing workspaces', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Listed workspaces', icon: Grid2x2 }, - [ClientToolCallState.error]: { text: 'Failed to list workspaces', icon: XCircle }, - }, -} - -const META_list_folders: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Listing folders', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Listing folders', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Listed folders', icon: FolderPlus }, - [ClientToolCallState.error]: { text: 'Failed to list folders', icon: XCircle }, - }, -} - -const META_create_workflow: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Creating workflow', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Creating workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Created workflow', icon: Plus }, - [ClientToolCallState.error]: { text: 'Failed to create workflow', icon: XCircle }, - }, -} - -const META_create_folder: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Creating folder', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Creating folder', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Created folder', icon: FolderPlus }, - [ClientToolCallState.error]: { text: 'Failed to create folder', icon: XCircle }, - }, -} - -const META_rename_workflow: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Renaming workflow', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Renaming workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Renamed workflow', icon: PencilLine }, - [ClientToolCallState.error]: { text: 'Failed to rename workflow', icon: XCircle }, - }, -} - -const META_move_workflow: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Moving workflow', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Moving workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Moved workflow', icon: Navigation }, - [ClientToolCallState.error]: { text: 'Failed to move workflow', icon: XCircle }, - }, -} - -const META_move_folder: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Moving folder', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Moving folder', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Moved folder', icon: Navigation }, - [ClientToolCallState.error]: { text: 'Failed to move folder', icon: XCircle }, - }, -} - -const META_oauth_get_auth_link: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Getting auth link', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Getting auth link', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Got auth link', icon: Link }, - [ClientToolCallState.error]: { text: 'Failed to get auth link', icon: XCircle }, - }, -} - -const META_user_memory: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Accessing memory', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Accessing memory', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Accessed memory', icon: BookOpen }, - [ClientToolCallState.error]: { text: 'Failed to access memory', icon: XCircle }, - }, -} - -const META_user_table: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Querying table', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Querying table', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Table operation complete', icon: Database }, - [ClientToolCallState.error]: { text: 'Table operation failed', icon: XCircle }, - }, -} - -const META_tool_search_tool_regex: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Searching tools', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Searching tools', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Found tools', icon: Search }, - [ClientToolCallState.error]: { text: 'Tool search failed', icon: XCircle }, - }, -} - -const META_grep: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Searching', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Searching', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Search complete', icon: FileSearch }, - [ClientToolCallState.error]: { text: 'Search failed', icon: XCircle }, - }, -} - -const META_glob: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Finding files', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Finding files', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Files found', icon: FileSearch }, - [ClientToolCallState.error]: { text: 'File search failed', icon: XCircle }, - }, -} - -const META_read: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Reading', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Reading', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Read complete', icon: FileText }, - [ClientToolCallState.error]: { text: 'Read failed', icon: XCircle }, - }, -} - -const META_context_compaction: ToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Compacting context', icon: Sparkles }, - [ClientToolCallState.pending]: { text: 'Compacting context', icon: Sparkles }, - [ClientToolCallState.executing]: { text: 'Compacting context', icon: Sparkles }, - [ClientToolCallState.success]: { text: 'Compacted context', icon: Sparkles }, - [ClientToolCallState.error]: { text: 'Failed to compact context', icon: XCircle }, - }, -} - -const TOOL_METADATA_BY_ID: Record = { - auth: META_auth, - context_compaction: META_context_compaction, - check_deployment_status: META_check_deployment_status, - complete_job: META_complete_job, - checkoff_todo: META_checkoff_todo, - crawl_website: META_crawl_website, - create_file: META_create_file, - create_workspace_mcp_server: META_create_workspace_mcp_server, - workflow: META_workflow, - create_folder: META_create_folder, - create_workflow: META_create_workflow, - agent: META_agent, - custom_tool: META_custom_tool, - deploy: META_deploy, - deploy_api: META_deploy_api, - deploy_chat: META_deploy_chat, - deploy_mcp: META_deploy_mcp, - edit_workflow: META_edit_workflow, - get_block_outputs: META_get_block_outputs, - get_block_upstream_references: META_get_block_upstream_references, - generate_api_key: META_generate_api_key, - get_deployed_workflow_state: META_get_deployed_workflow_state, - get_examples_rag: META_get_examples_rag, - get_operations_examples: META_get_operations_examples, - get_page_contents: META_get_page_contents, - get_platform_actions: META_get_platform_actions, - get_trigger_examples: META_get_trigger_examples, - get_workflow_logs: META_get_workflow_logs, - get_workflow_data: META_get_workflow_data, - glob: META_glob, - grep: META_grep, - knowledge: META_knowledge, - knowledge_base: META_knowledge_base, - list_folders: META_list_folders, - list_user_workspaces: META_list_user_workspaces, - list_workspace_mcp_servers: META_list_workspace_mcp_servers, - manage_custom_tool: META_manage_custom_tool, - manage_mcp_tool: META_manage_mcp_tool, - manage_skill: META_manage_skill, - mark_todo_in_progress: META_mark_todo_in_progress, - move_folder: META_move_folder, - move_workflow: META_move_workflow, - navigate_ui: META_navigate_ui, - oauth_get_auth_link: META_oauth_get_auth_link, - oauth_request_access: META_oauth_request_access, - open_resource: META_open_resource, - read: META_read, - redeploy: META_redeploy, - rename_workflow: META_rename_workflow, - research: META_research, - run: META_run, - run_block: META_run_block, - run_from_block: META_run_from_block, - run_workflow: META_run_workflow, - run_workflow_until_block: META_run_workflow_until_block, - scrape_page: META_scrape_page, - search_documentation: META_search_documentation, - search_library_docs: META_search_library_docs, - search_online: META_search_online, - search_patterns: META_search_patterns, - set_environment_variables: META_set_environment_variables, - set_global_workflow_variables: META_set_global_workflow_variables, - sleep: META_sleep, - summarize_conversation: META_summarize_conversation, - superagent: META_superagent, - table: META_table, - tool_search_tool_regex: META_tool_search_tool_regex, - user_memory: META_user_memory, - user_table: META_user_table, -} - -export const TOOL_DISPLAY_REGISTRY: Record = Object.fromEntries( - Object.entries(TOOL_METADATA_BY_ID).map(([toolName, metadata]) => [ - toolName, - toToolDisplayEntry(metadata), - ]) -) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts new file mode 100644 index 00000000000..eb72568f780 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/lib/copilot/request/types' + +const { ensureWorkflowAccessMock, performRevertToVersionMock } = vi.hoisted(() => ({ + ensureWorkflowAccessMock: vi.fn(), + performRevertToVersionMock: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + chat: {}, + workflow: {}, + workflowDeploymentVersion: {}, + workflowMcpServer: {}, + workflowMcpTool: {}, +})) + +vi.mock('@/lib/audit/log', () => ({ + AuditAction: {}, + AuditResourceType: {}, + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/mcp/pubsub', () => ({ + mcpPubSub: { + publishWorkflowToolsChanged: vi.fn(), + }, +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + generateParameterSchemaForWorkflow: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ + sanitizeToolName: vi.fn((value: string) => value), +})) + +vi.mock('@/lib/workflows/triggers/trigger-utils.server', () => ({ + hasValidStartBlock: vi.fn(), +})) + +vi.mock('../access', () => ({ + ensureWorkflowAccess: ensureWorkflowAccessMock, + ensureWorkspaceAccess: vi.fn(), +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + performRevertToVersion: performRevertToVersionMock, +})) + +import { executeRevertToVersion } from './manage' + +describe('executeRevertToVersion', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, + }) + }) + + it('uses the shared revert helper instead of the HTTP route', async () => { + performRevertToVersionMock.mockResolvedValue({ + success: true, + lastSaved: 12345, + }) + + const result = await executeRevertToVersion({ workflowId: 'wf-1', version: 7 }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(performRevertToVersionMock).toHaveBeenCalledWith({ + workflowId: 'wf-1', + version: 7, + userId: 'user-1', + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, + }) + expect(global.fetch).not.toHaveBeenCalled() + expect(result).toEqual({ + success: true, + output: { + message: 'Reverted workflow to deployment version 7', + lastSaved: 12345, + }, + }) + }) + + it('returns shared helper failures directly', async () => { + performRevertToVersionMock.mockResolvedValue({ + success: false, + error: 'Deployment version not found', + }) + + const result = await executeRevertToVersion({ workflowId: 'wf-1', version: 7 }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(result).toEqual({ + success: false, + error: 'Deployment version not found', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 211646139a7..771089f8cae 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -13,6 +13,7 @@ import { generateId } from '@/lib/core/utils/uuid' import { mcpPubSub } from '@/lib/mcp/pubsub' import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' +import { performRevertToVersion } from '@/lib/workflows/orchestration' import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access' import type { @@ -428,27 +429,22 @@ export async function executeRevertToVersion( return { success: false, error: 'version is required' } } - await ensureWorkflowAccess(workflowId, context.userId, 'admin') - - const baseUrl = - process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || 'http://localhost:3000' - const response = await fetch( - `${baseUrl}/api/workflows/${workflowId}/deployments/${version}/revert`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': process.env.INTERNAL_API_SECRET || '', - }, - } + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'admin' ) + const result = await performRevertToVersion({ + workflowId, + version, + userId: context.userId, + workflow: workflowRecord as Record, + }) - if (!response.ok) { - const body = await response.json().catch(() => ({})) - return { success: false, error: body.error || `Failed to revert (HTTP ${response.status})` } + if (!result.success) { + return { success: false, error: result.error || 'Failed to revert' } } - const result = await response.json() return { success: true, output: { diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts new file mode 100644 index 00000000000..ffd6317e76d --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + ensureWorkflowAccessMock, + ensureWorkspaceAccessMock, + getDefaultWorkspaceIdMock, + upsertPersonalEnvVarsMock, + upsertWorkspaceEnvVarsMock, +} = vi.hoisted(() => ({ + ensureWorkflowAccessMock: vi.fn(), + ensureWorkspaceAccessMock: vi.fn(), + getDefaultWorkspaceIdMock: vi.fn(), + upsertPersonalEnvVarsMock: vi.fn(), + upsertWorkspaceEnvVarsMock: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + }), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkflowAccess: ensureWorkflowAccessMock, + ensureWorkspaceAccess: ensureWorkspaceAccessMock, + getDefaultWorkspaceId: getDefaultWorkspaceIdMock, +})) + +vi.mock('@/lib/environment/utils', () => ({ + upsertPersonalEnvVars: upsertPersonalEnvVarsMock, + upsertWorkspaceEnvVars: upsertWorkspaceEnvVarsMock, +})) + +import { setEnvironmentVariablesServerTool } from './set-environment-variables' + +describe('setEnvironmentVariablesServerTool', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-from-workflow' }, + }) + ensureWorkspaceAccessMock.mockResolvedValue(undefined) + getDefaultWorkspaceIdMock.mockResolvedValue('ws-default') + upsertPersonalEnvVarsMock.mockResolvedValue({ added: ['API_KEY'], updated: [] }) + upsertWorkspaceEnvVarsMock.mockResolvedValue(['API_KEY']) + }) + + it('defaults to workspace scope and uses the current workspace context', async () => { + const result = await setEnvironmentVariablesServerTool.execute( + { + variables: [{ name: 'API_KEY', value: 'secret' }], + }, + { + userId: 'user-1', + workspaceId: 'ws-1', + } + ) + + expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'write') + expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') + expect(upsertPersonalEnvVarsMock).not.toHaveBeenCalled() + expect(result.scope).toBe('workspace') + expect(result.workspaceId).toBe('ws-1') + }) + + it('supports explicit personal scope', async () => { + const result = await setEnvironmentVariablesServerTool.execute( + { + scope: 'personal', + variables: [{ name: 'API_KEY', value: 'secret' }], + }, + { + userId: 'user-1', + workspaceId: 'ws-1', + } + ) + + expect(upsertPersonalEnvVarsMock).toHaveBeenCalledWith('user-1', { API_KEY: 'secret' }) + expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() + expect(ensureWorkspaceAccessMock).not.toHaveBeenCalled() + expect(result.scope).toBe('personal') + }) + + it('falls back to the default workspace when none is in context', async () => { + await setEnvironmentVariablesServerTool.execute( + { + variables: [{ name: 'API_KEY', value: 'secret' }], + }, + { + userId: 'user-1', + } + ) + + expect(getDefaultWorkspaceIdMock).toHaveBeenCalledWith('user-1') + expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith( + 'ws-default', + { API_KEY: 'secret' }, + 'user-1' + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index 56f1de2043f..2e130b60e88 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -1,22 +1,43 @@ -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' import { z } from 'zod' import { SetEnvironmentVariables } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { + ensureWorkflowAccess, + ensureWorkspaceAccess, + getDefaultWorkspaceId, +} from '@/lib/copilot/tools/handlers/access' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import { upsertPersonalEnvVars, upsertWorkspaceEnvVars } from '@/lib/environment/utils' -import { getWorkflowById } from '@/lib/workflows/utils' + +type EnvironmentVariableInputValue = string | number | boolean | null | undefined + +interface EnvironmentVariableInput { + name: string + value: EnvironmentVariableInputValue +} interface SetEnvironmentVariablesParams { - variables: Record | Array<{ name: string; value: string }> + variables: Record | EnvironmentVariableInput[] + scope?: 'personal' | 'workspace' workflowId?: string + workspaceId?: string +} + +interface SetEnvironmentVariablesResult { + message: string + scope: 'personal' | 'workspace' + workspaceId?: string + variableCount: number + variableNames: string[] + addedVariables: string[] + updatedVariables: string[] + workspaceUpdatedVariables: string[] } const EnvVarSchema = z.object({ variables: z.record(z.string()) }) function normalizeVariables( - input: Record | Array<{ name: string; value: string }> + input: Record | EnvironmentVariableInput[] ): Record { if (Array.isArray(input)) { return input.reduce( @@ -34,100 +55,97 @@ function normalizeVariables( ) as Record } -export const setEnvironmentVariablesServerTool: BaseServerTool = - { - name: SetEnvironmentVariables.id, - async execute( - params: SetEnvironmentVariablesParams, - context?: { userId: string } - ): Promise { - const logger = createLogger('SetEnvironmentVariablesServerTool') - - if (!context?.userId) { - logger.error( - 'Unauthorized attempt to set environment variables - no authenticated user context' - ) - throw new Error('Authentication required') - } - - const authenticatedUserId = context.userId - const { variables } = params || ({} as SetEnvironmentVariablesParams) - - const normalized = normalizeVariables(variables || {}) - const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized }) - - const requestedKeys = Object.keys(validatedVariables) - const workflowId = params.workflowId - - const workspaceKeySet = new Set() - let resolvedWorkspaceId: string | null = null - - if (requestedKeys.length > 0 && workflowId) { - const wf = await getWorkflowById(workflowId) - - if (wf?.workspaceId) { - resolvedWorkspaceId = wf.workspaceId - const existingWorkspaceCredentials = await db - .select({ envKey: credential.envKey }) - .from(credential) - .where( - and( - eq(credential.workspaceId, wf.workspaceId), - eq(credential.type, 'env_workspace'), - inArray(credential.envKey, requestedKeys) - ) - ) - - for (const row of existingWorkspaceCredentials) { - if (row.envKey) workspaceKeySet.add(row.envKey) - } - } - } - - const personalVars: Record = {} - const workspaceVars: Record = {} +async function resolveWorkspaceId( + params: SetEnvironmentVariablesParams, + context: ServerToolContext | undefined, + userId: string +): Promise { + if (params.workflowId) { + const { workflow } = await ensureWorkflowAccess(params.workflowId, userId, 'write') + if (!workflow.workspaceId) { + throw new Error(`Workflow ${params.workflowId} is not associated with a workspace`) + } + return workflow.workspaceId + } - for (const [key, value] of Object.entries(validatedVariables)) { - if (workspaceKeySet.has(key)) { - workspaceVars[key] = value - } else { - personalVars[key] = value - } - } - - const { added, updated } = await upsertPersonalEnvVars(authenticatedUserId, personalVars) - - let workspaceUpdated: string[] = [] - if (Object.keys(workspaceVars).length > 0 && resolvedWorkspaceId) { - workspaceUpdated = await upsertWorkspaceEnvVars( - resolvedWorkspaceId, - workspaceVars, - authenticatedUserId - ) - } - - const totalProcessed = added.length + updated.length + workspaceUpdated.length - - logger.info('Saved environment variables', { - userId: authenticatedUserId, - addedCount: added.length, - updatedCount: updated.length, - workspaceUpdatedCount: workspaceUpdated.length, - }) - - const parts: string[] = [] - if (added.length > 0) parts.push(`${added.length} personal secret(s) added`) - if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`) - if (workspaceUpdated.length > 0) - parts.push(`${workspaceUpdated.length} workspace secret(s) updated`) - - return { - message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`, - variableCount: Object.keys(validatedVariables).length, - variableNames: Object.keys(validatedVariables), - addedVariables: added, - updatedVariables: updated, - workspaceUpdatedVariables: workspaceUpdated, - } - }, + const workspaceId = params.workspaceId ?? context?.workspaceId + if (workspaceId) { + await ensureWorkspaceAccess(workspaceId, userId, 'write') + return workspaceId } + + return getDefaultWorkspaceId(userId) +} + +export const setEnvironmentVariablesServerTool: BaseServerTool< + SetEnvironmentVariablesParams, + SetEnvironmentVariablesResult +> = { + name: SetEnvironmentVariables.id, + async execute( + params: SetEnvironmentVariablesParams, + context?: ServerToolContext + ): Promise { + const logger = createLogger('SetEnvironmentVariablesServerTool') + + if (!context?.userId) { + logger.error( + 'Unauthorized attempt to set environment variables - no authenticated user context' + ) + throw new Error('Authentication required') + } + + const authenticatedUserId = context.userId + const { variables } = params || ({} as SetEnvironmentVariablesParams) + const scope = params.scope === 'personal' ? 'personal' : 'workspace' + + const normalized = normalizeVariables(variables || {}) + const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized }) + const variableNames = Object.keys(validatedVariables) + const added: string[] = [] + const updated: string[] = [] + let workspaceUpdated: string[] = [] + + let resolvedWorkspaceId: string | undefined + if (scope === 'workspace') { + resolvedWorkspaceId = await resolveWorkspaceId(params, context, authenticatedUserId) + workspaceUpdated = await upsertWorkspaceEnvVars( + resolvedWorkspaceId, + validatedVariables, + authenticatedUserId + ) + } else { + const result = await upsertPersonalEnvVars(authenticatedUserId, validatedVariables) + added.push(...result.added) + updated.push(...result.updated) + } + + const totalProcessed = added.length + updated.length + workspaceUpdated.length + + logger.info('Saved environment variables', { + userId: authenticatedUserId, + scope, + addedCount: added.length, + updatedCount: updated.length, + workspaceUpdatedCount: workspaceUpdated.length, + workspaceId: resolvedWorkspaceId, + }) + + const parts: string[] = [] + if (added.length > 0) parts.push(`${added.length} personal secret(s) added`) + if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`) + if (workspaceUpdated.length > 0) + parts.push(`${workspaceUpdated.length} workspace secret(s) updated`) + + return { + message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`, + scope, + workspaceId: resolvedWorkspaceId, + variableCount: variableNames.length, + variableNames, + addedVariables: added, + updatedVariables: updated, + workspaceUpdatedVariables: workspaceUpdated, + } + }, +} diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 21216d8b069..1ab6f5da100 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -305,7 +305,7 @@ export async function upsertWorkspaceEnvVars( set: { variables: merged, updatedAt: new Date() }, }) - await syncWorkspaceEnvCredentials({ workspaceId, envKeys: Object.keys(newVars), actingUserId }) + await syncWorkspaceEnvCredentials({ workspaceId, envKeys: Object.keys(merged), actingUserId }) return updatedKeys } diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index d8709e47d50..56717aa877d 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -3,9 +3,11 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' +import { captureServerEvent } from '@/lib/posthog/server' import { cleanupWebhooksForWorkflow, restorePreviousVersionWebhooks, @@ -17,6 +19,7 @@ import { activateWorkflowVersionById, deployWorkflow, loadWorkflowFromNormalizedTables, + saveWorkflowToNormalizedTables, undeployWorkflow, } from '@/lib/workflows/persistence/utils' import { @@ -24,6 +27,7 @@ import { createSchedulesForDeploy, validateWorkflowSchedules, } from '@/lib/workflows/schedules' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('DeployOrchestration') @@ -314,6 +318,25 @@ export interface PerformActivateVersionResult { warnings?: string[] } +export interface PerformRevertToVersionParams { + workflowId: string + version: number | 'active' + userId: string + workflow: Record + request?: NextRequest + /** Override the actor ID used in audit logs. Defaults to `userId`. */ + actorId?: string + actorName?: string + actorEmail?: string +} + +export interface PerformRevertToVersionResult { + success: boolean + lastSaved?: number + error?: string + errorCode?: OrchestrationErrorCode +} + /** * Activates an existing deployment version: validates schedules, syncs trigger * webhooks (with forced subscription recreation), creates schedules, activates @@ -492,3 +515,130 @@ export async function performActivateVersion( warnings: triggerSaveResult.warnings, } } + +/** + * Reverts the current workflow draft to match a saved deployment version. + * This matches the deployment modal's "load deployment" behavior and is used + * by both the HTTP route and the mothership tool handler. + */ +export async function performRevertToVersion( + params: PerformRevertToVersionParams +): Promise { + const { workflowId, version, userId, workflow } = params + const actorId = params.actorId ?? userId + const versionLabel = String(version) + + let stateRow: { state: unknown } | null = null + if (version === 'active') { + const [row] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .limit(1) + stateRow = row || null + } else { + const [row] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.version, version) + ) + ) + .limit(1) + stateRow = row || null + } + + if (!stateRow?.state) { + return { success: false, error: 'Deployment version not found', errorCode: 'not_found' } + } + + const deployedState = stateRow.state as { + blocks?: Record + edges?: unknown[] + loops?: Record + parallels?: Record + } + if (!deployedState.blocks || !deployedState.edges) { + return { + success: false, + error: 'Invalid deployed state structure', + errorCode: 'internal', + } + } + + const lastSaved = Date.now() + const saveResult = await saveWorkflowToNormalizedTables(workflowId, { + blocks: deployedState.blocks, + edges: deployedState.edges, + loops: deployedState.loops || {}, + parallels: deployedState.parallels || {}, + lastSaved, + } as WorkflowState) + + if (!saveResult.success) { + return { + success: false, + error: saveResult.error || 'Failed to save deployed state', + errorCode: 'internal', + } + } + + await db + .update(workflowTable) + .set({ lastSynced: new Date(), updatedAt: new Date() }) + .where(eq(workflowTable.id, workflowId)) + + try { + const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' + await fetch(`${socketServerUrl}/api/workflow-reverted`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId, timestamp: lastSaved }), + }) + } catch (error) { + logger.error('Error sending workflow reverted event to socket server', error) + } + + const workspaceId = (workflow.workspaceId as string) || '' + captureServerEvent( + userId, + 'workflow_deployment_reverted', + { + workflow_id: workflowId, + workspace_id: workspaceId, + version: versionLabel, + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + + recordAudit({ + workspaceId: workspaceId || null, + actorId, + actorName: params.actorName, + actorEmail: params.actorEmail, + action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: (workflow.name as string) || undefined, + description: `Reverted workflow to deployment version ${versionLabel}`, + metadata: { + targetVersion: versionLabel, + }, + request: params.request, + }) + + return { + success: true, + lastSaved, + } +} diff --git a/apps/sim/lib/workflows/orchestration/index.ts b/apps/sim/lib/workflows/orchestration/index.ts index 76dbc07dc27..a4aeeec42e2 100644 --- a/apps/sim/lib/workflows/orchestration/index.ts +++ b/apps/sim/lib/workflows/orchestration/index.ts @@ -13,9 +13,12 @@ export { type PerformFullDeployResult, type PerformFullUndeployParams, type PerformFullUndeployResult, + type PerformRevertToVersionParams, + type PerformRevertToVersionResult, performActivateVersion, performFullDeploy, performFullUndeploy, + performRevertToVersion, } from './deploy' export { type PerformDeleteFolderParams, diff --git a/apps/sim/stores/panel/index.ts b/apps/sim/stores/panel/index.ts index 5ce6320df74..7281279fc37 100644 --- a/apps/sim/stores/panel/index.ts +++ b/apps/sim/stores/panel/index.ts @@ -1,6 +1,6 @@ // Main panel store -export { ClientToolCallState as ToolState } from '@/lib/copilot/tools/client/tool-display-registry' +export { ClientToolCallState as ToolState } from '@/lib/copilot/tools/client/tool-call-state' // Editor export { usePanelEditorStore } from './editor' export { usePanelStore } from './store'