Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/(landing)/components/nav/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface NavProps {
}

export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('24.4k')
const [githubStars, setGithubStars] = useState('25.1k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const [starCount, setStarCount] = useState('24.4k')
const [starCount, setStarCount] = useState('25.1k')
const [conversationId, setConversationId] = useState('')

const [showScrollButton, setShowScrollButton] = useState(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useChildDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-deployment'
import { getAllBlocks } from '@/blocks'
import {
type CustomTool as CustomToolDefinition,
Expand Down Expand Up @@ -582,6 +583,8 @@ function WorkflowSelectorSyncWrapper({
onChange={onChange}
placeholder={uiComponent.placeholder || 'Select workflow'}
disabled={disabled || isLoading}
searchable
searchPlaceholder='Search workflows...'
/>
)
}
Expand Down Expand Up @@ -752,6 +755,81 @@ function CodeEditorSyncWrapper({
)
}

/**
* Badge component showing deployment status for workflow tools
*/
function WorkflowToolDeployBadge({
workflowId,
onDeploySuccess,
}: {
workflowId: string
onDeploySuccess?: () => void
}) {
const { isDeployed, needsRedeploy, isLoading, refetch } = useChildDeployment(workflowId)
const [isDeploying, setIsDeploying] = useState(false)

const deployWorkflow = useCallback(async () => {
if (isDeploying || !workflowId) return

try {
setIsDeploying(true)
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})

if (response.ok) {
refetch()
onDeploySuccess?.()
} else {
logger.error('Failed to deploy workflow')
}
} catch (error) {
logger.error('Error deploying workflow:', error)
} finally {
setIsDeploying(false)
}
}, [isDeploying, workflowId, refetch, onDeploySuccess])

if (isLoading || (isDeployed && !needsRedeploy)) {
return null
}

if (typeof isDeployed !== 'boolean') {
return null
}

return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant={!isDeployed ? 'red' : 'amber'}
className='cursor-pointer'
size='sm'
dot
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
if (!isDeploying) {
deployWorkflow()
}
}}
>
{isDeploying ? 'Deploying...' : !isDeployed ? 'undeployed' : 'redeploy'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>{!isDeployed ? 'Click to deploy' : 'Click to redeploy'}</span>
</Tooltip.Content>
</Tooltip.Root>
)
}

/**
* Set of built-in tool types that are core platform tools.
*
Expand Down Expand Up @@ -2219,10 +2297,15 @@ export function ToolInput({
{getIssueBadgeLabel(issue)}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>{issue.message}: click to open settings</Tooltip.Content>
<Tooltip.Content>
<span className='text-sm'>{issue.message}: click to open settings</span>
</Tooltip.Content>
</Tooltip.Root>
)
})()}
{tool.type === 'workflow' && tool.params?.workflowId && (
<WorkflowToolDeployBadge workflowId={tool.params.workflowId} />
)}
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) && (
Expand Down
230 changes: 230 additions & 0 deletions apps/sim/tools/workflow/executor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest'
import { workflowExecutorTool } from '@/tools/workflow/executor'

describe('workflowExecutorTool', () => {
describe('request.body', () => {
const buildBody = workflowExecutorTool.request.body!

it.concurrent('should pass through object inputMapping unchanged (LLM-provided args)', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: { firstName: 'John', lastName: 'Doe', age: 30 },
}

const result = buildBody(params)

expect(result).toEqual({
input: { firstName: 'John', lastName: 'Doe', age: 30 },
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should parse JSON string inputMapping (UI-provided via tool-input)', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: '{"firstName": "John", "lastName": "Doe"}',
}

const result = buildBody(params)

expect(result).toEqual({
input: { firstName: 'John', lastName: 'Doe' },
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should handle nested objects in JSON string inputMapping', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: '{"user": {"name": "John", "email": "john@example.com"}, "count": 5}',
}

const result = buildBody(params)

expect(result).toEqual({
input: { user: { name: 'John', email: 'john@example.com' }, count: 5 },
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should handle arrays in JSON string inputMapping', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: '{"tags": ["a", "b", "c"], "ids": [1, 2, 3]}',
}

const result = buildBody(params)

expect(result).toEqual({
input: { tags: ['a', 'b', 'c'], ids: [1, 2, 3] },
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should default to empty object when inputMapping is undefined', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: undefined,
}

const result = buildBody(params)

expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should default to empty object when inputMapping is null', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: null as any,
}

const result = buildBody(params)

expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should fallback to empty object for invalid JSON string', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: 'not valid json {',
}

const result = buildBody(params)

expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should fallback to empty object for empty string', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: '',
}

const result = buildBody(params)

expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should handle empty object inputMapping', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: {},
}

const result = buildBody(params)

expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should handle empty JSON object string', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: '{}',
}

const result = buildBody(params)

expect(result).toEqual({
input: {},
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should preserve special characters in string values', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: '{"message": "Hello\\nWorld", "path": "C:\\\\Users"}',
}

const result = buildBody(params)

expect(result).toEqual({
input: { message: 'Hello\nWorld', path: 'C:\\Users' },
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should handle unicode characters in JSON string', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: '{"greeting": "こんにちは", "emoji": "👋"}',
}

const result = buildBody(params)

expect(result).toEqual({
input: { greeting: 'こんにちは', emoji: '👋' },
triggerType: 'api',
useDraftState: false,
})
})

it.concurrent('should not modify object with string values that look like JSON', () => {
const params = {
workflowId: 'test-workflow-id',
inputMapping: { data: '{"nested": "json"}' },
}

const result = buildBody(params)

expect(result).toEqual({
input: { data: '{"nested": "json"}' },
triggerType: 'api',
useDraftState: false,
})
})
})

describe('request.url', () => {
it.concurrent('should build correct URL with workflowId', () => {
const url = workflowExecutorTool.request.url as (params: any) => string

expect(url({ workflowId: 'abc-123' })).toBe('/api/workflows/abc-123/execute')
expect(url({ workflowId: 'my-workflow' })).toBe('/api/workflows/my-workflow/execute')
})
})

describe('tool metadata', () => {
it.concurrent('should have correct id', () => {
expect(workflowExecutorTool.id).toBe('workflow_executor')
})

it.concurrent('should have required workflowId param', () => {
expect(workflowExecutorTool.params.workflowId.required).toBe(true)
})

it.concurrent('should have optional inputMapping param', () => {
expect(workflowExecutorTool.params.inputMapping.required).toBe(false)
})

it.concurrent('should use POST method', () => {
expect(workflowExecutorTool.request.method).toBe('POST')
})
})
})
20 changes: 15 additions & 5 deletions apps/sim/tools/workflow/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,21 @@ export const workflowExecutorTool: ToolConfig<
url: (params: WorkflowExecutorParams) => `/api/workflows/${params.workflowId}/execute`,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params: WorkflowExecutorParams) => ({
input: params.inputMapping || {},
triggerType: 'api',
useDraftState: false,
}),
body: (params: WorkflowExecutorParams) => {
let inputData = params.inputMapping || {}
if (typeof inputData === 'string') {
try {
inputData = JSON.parse(inputData)
} catch {
inputData = {}
}
}
return {
input: inputData,
triggerType: 'api',
useDraftState: false,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
Expand Down
Loading