Compare commits

...

7 Commits

Author SHA1 Message Date
Vikhyath Mondreti
f4e627a9f7 v0.2.3: fix (#592)
* fix(variable resolution): use variable references to not have escaping issues (#587)

* fix(variable-resolution): don't inject stringified json, use var refs

* fix lint

* remove unused var

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>

* fix(subblock updates): special selectors persistence (#591)

* fix(knowledge-base-selector): should trigger sockets event for persistence

* fix subblock value updates for non useSubblockValue components

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>

* fix(race-cond): for auto-connect rare race condition between adding edge + block (#582)

* auto connect race condition

* fix lint

* Update apps/sim/hooks/use-collaborative-workflow.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-30 16:57:48 -07:00
Vikhyath Mondreti
b0c1547198 fix test failure 2025-06-30 15:52:26 -07:00
Vikhyath Mondreti
d19632aec3 fix typing issue 2025-06-30 14:01:21 -07:00
Vikhyath Mondreti
35ac68f579 fix(func var resolution): variable ref codepath triggered - lint fixed 2025-06-30 13:55:12 -07:00
Vikhyath Mondreti
9c14f5f8fc fix(func var resolution): variable ref codepath triggered 2025-06-30 13:54:53 -07:00
Vikhyath Mondreti
d50db1d3fb add dot check 2025-06-30 12:45:36 -07:00
Vikhyath Mondreti
b3960ad77a v0.2.2: fix (#588)
* fix(loop-foreach): forEach loop iterations (#581)

* fix: working on fixing foreach loop max

* fix: removed default of 5 iterations

* fix: bun run lint

* fix: greptile comments (#581)

* fix: removed safety max (#581)

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>

* improvement(sockets conn): sockets connection refresh warning (#580)

* working conn status

* improvement(sockets conn): sockets connection refresh warning

* fix styling

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>

* fix(variable resolution): use variable references to not have escaping issues (#587)

* fix(variable-resolution): don't inject stringified json, use var refs

* fix lint

* remove unused var

---------

Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>

---------

Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-Air.attlocal.net>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@Vikhyaths-MacBook-Air.local>
Co-authored-by: Vikhyath Mondreti <vikhyathmondreti@vikhyaths-air.lan>
2025-06-30 11:50:59 -07:00
22 changed files with 423 additions and 136 deletions

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 60
const logger = createLogger('FunctionExecuteAPI')
@@ -14,45 +15,16 @@ const logger = createLogger('FunctionExecuteAPI')
* @param envVars - Environment variables from the workflow
* @returns Resolved code
*/
/**
* Safely serialize a value to JSON string with proper escaping
* This prevents JavaScript syntax errors when the serialized data is injected into code
*/
function safeJSONStringify(value: any): string {
try {
// Use JSON.stringify with proper escaping
// The key is to let JSON.stringify handle the escaping properly
return JSON.stringify(value)
} catch (error) {
// If JSON.stringify fails (e.g., circular references), return a safe fallback
try {
// Try to create a safe representation by removing circular references
const seen = new WeakSet()
const cleanValue = JSON.parse(
JSON.stringify(value, (key, val) => {
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) {
return '[Circular Reference]'
}
seen.add(val)
}
return val
})
)
return JSON.stringify(cleanValue)
} catch {
// If that also fails, return a safe string representation
return JSON.stringify(String(value))
}
}
}
function resolveCodeVariables(
code: string,
params: Record<string, any>,
envVars: Record<string, string> = {}
): string {
envVars: Record<string, string> = {},
blockData: Record<string, any> = {},
blockNameMapping: Record<string, string> = {}
): { resolvedCode: string; contextVariables: Record<string, any> } {
let resolvedCode = code
const contextVariables: Record<string, any> = {}
// Resolve environment variables with {{var_name}} syntax
const envVarMatches = resolvedCode.match(/\{\{([^}]+)\}\}/g) || []
@@ -60,25 +32,82 @@ function resolveCodeVariables(
const varName = match.slice(2, -2).trim()
// Priority: 1. Environment variables from workflow, 2. Params
const varValue = envVars[varName] || params[varName] || ''
// Use safe JSON stringify to prevent syntax errors
resolvedCode = resolvedCode.replace(
new RegExp(escapeRegExp(match), 'g'),
safeJSONStringify(varValue)
)
// Instead of injecting large JSON directly, create a variable reference
const safeVarName = `__var_${varName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = varValue
// Replace the template with a variable reference
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
// Resolve tags with <tag_name> syntax
const tagMatches = resolvedCode.match(/<([a-zA-Z_][a-zA-Z0-9_]*)>/g) || []
// Resolve tags with <tag_name> syntax (including nested paths like <block.response.data>)
const tagMatches = resolvedCode.match(/<([a-zA-Z_][a-zA-Z0-9_.]*[a-zA-Z0-9_])>/g) || []
for (const match of tagMatches) {
const tagName = match.slice(1, -1).trim()
const tagValue = params[tagName] || ''
resolvedCode = resolvedCode.replace(
new RegExp(escapeRegExp(match), 'g'),
safeJSONStringify(tagValue)
)
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
// First try params, then blockData directly, then try with block name mapping
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
// If not found and the path starts with a block name, try mapping the block name to ID
if (!tagValue && tagName.includes('.')) {
const pathParts = tagName.split('.')
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
// Find the block ID by looking for a block name that normalizes to this value
let blockId = null
for (const [blockName, id] of Object.entries(blockNameMapping)) {
// Apply the same normalization logic as the UI: remove spaces and lowercase
const normalizedName = blockName.replace(/\s+/g, '').toLowerCase()
if (normalizedName === normalizedBlockName) {
blockId = id
break
}
}
if (blockId) {
const remainingPath = pathParts.slice(1).join('.')
const fullPath = `${blockId}.${remainingPath}`
tagValue = getNestedValue(blockData, fullPath) || ''
}
}
// If the value is a stringified JSON, parse it back to object
if (
typeof tagValue === 'string' &&
tagValue.length > 100 &&
(tagValue.startsWith('{') || tagValue.startsWith('['))
) {
try {
tagValue = JSON.parse(tagValue)
} catch (e) {
// Keep as string if parsing fails
}
}
// Instead of injecting large JSON directly, create a variable reference
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = tagValue
// Replace the template with a variable reference
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
return resolvedCode
return { resolvedCode, contextVariables }
}
/**
* Get nested value from object using dot notation path
*/
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined
return path.split('.').reduce((current, key) => {
return current && typeof current === 'object' ? current[key] : undefined
}, obj)
}
/**
@@ -101,6 +130,8 @@ export async function POST(req: NextRequest) {
params = {},
timeout = 5000,
envVars = {},
blockData = {},
blockNameMapping = {},
workflowId,
isCustomTool = false,
} = body
@@ -118,7 +149,13 @@ export async function POST(req: NextRequest) {
})
// Resolve variables in the code with workflow environment variables
const resolvedCode = resolveCodeVariables(code, executionParams, envVars)
const { resolvedCode, contextVariables } = resolveCodeVariables(
code,
executionParams,
envVars,
blockData,
blockNameMapping
)
const executionMethod = 'vm' // Default execution method
@@ -280,6 +317,7 @@ export async function POST(req: NextRequest) {
const context = createContext({
params: executionParams,
environmentVariables: envVars,
...contextVariables, // Add resolved variables directly to context
fetch: globalThis.fetch || require('node-fetch').default,
console: {
log: (...args: any[]) => {

View File

@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
interface ConnectionStatusProps {
isConnected: boolean
}
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
const [showOfflineNotice, setShowOfflineNotice] = useState(false)
useEffect(() => {
let timeoutId: NodeJS.Timeout
if (!isConnected) {
// Show offline notice after 6 seconds of being disconnected
timeoutId = setTimeout(() => {
setShowOfflineNotice(true)
}, 6000) // 6 seconds
} else {
// Hide notice immediately when reconnected
setShowOfflineNotice(false)
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}, [isConnected])
// Don't render anything if connected or if we haven't been disconnected long enough
if (!showOfflineNotice) {
return null
}
return (
<div className='flex items-center gap-1.5'>
<div className='flex items-center gap-1.5 text-red-600'>
<div className='relative flex items-center justify-center'>
<div className='absolute h-3 w-3 animate-ping rounded-full bg-red-500/20' />
<div className='relative h-2 w-2 rounded-full bg-red-500' />
</div>
<div className='flex flex-col'>
<span className='font-medium text-xs leading-tight'>Connection lost</span>
<span className='text-xs leading-tight opacity-90'>
Changes not saved - please refresh
</span>
</div>
</div>
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useMemo } from 'react'
import { usePresence } from '../../../../hooks/use-presence'
import { ConnectionStatus } from './components/connection-status/connection-status'
import { UserAvatar } from './components/user-avatar/user-avatar'
interface User {
@@ -25,7 +26,7 @@ export function UserAvatarStack({
className = '',
}: UserAvatarStackProps) {
// Use presence data if no users are provided via props
const { users: presenceUsers } = usePresence()
const { users: presenceUsers, isConnected } = usePresence()
const users = propUsers || presenceUsers
// Memoize the processed users to avoid unnecessary re-renders
@@ -43,10 +44,14 @@ export function UserAvatarStack({
}
}, [users, maxVisible])
// Show connection status component regardless of user count
// This will handle the offline notice when disconnected for 15 seconds
const connectionStatusElement = <ConnectionStatus isConnected={isConnected} />
// Only show presence when there are multiple users (>1)
// Don't render anything if there are no users or only 1 user
// But always show connection status
if (users.length <= 1) {
return null
return connectionStatusElement
}
// Determine spacing based on size
@@ -58,6 +63,9 @@ export function UserAvatarStack({
return (
<div className={`flex items-center ${spacingClass} ${className}`}>
{/* Connection status - always present */}
{connectionStatusElement}
{/* Render visible user avatars */}
{visibleUsers.map((user, index) => (
<UserAvatar

View File

@@ -683,7 +683,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
</p>
)}
</div>
<UserAvatarStack className='ml-3' />
</div>
)
}
@@ -1275,8 +1274,10 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{/* Left Section - Workflow Info */}
<div className='pl-4'>{renderWorkflowName()}</div>
{/* Middle Section - Reserved for future use */}
<div className='flex-1' />
{/* Middle Section - Connection Status */}
<div className='flex flex-1 justify-center'>
<UserAvatarStack />
</div>
{/* Right Section - Actions */}
<div className='flex items-center gap-1 pr-4'>

View File

@@ -13,6 +13,7 @@ import {
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
interface DocumentData {
@@ -50,7 +51,8 @@ export function DocumentSelector({
isPreview = false,
previewValue,
}: DocumentSelectorProps) {
const { getValue, setValue } = useSubBlockStore()
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [documents, setDocuments] = useState<DocumentData[]>([])
const [error, setError] = useState<string | null>(null)
@@ -117,7 +119,7 @@ export function DocumentSelector({
if (selectedId && !fetchedDocuments.some((doc: DocumentData) => doc.id === selectedId)) {
setSelectedId('')
if (!isPreview) {
setValue(blockId, subBlock.id, '')
collaborativeSetSubblockValue(blockId, subBlock.id, '')
}
}
@@ -131,7 +133,7 @@ export function DocumentSelector({
setSelectedId(singleDoc.id)
setSelectedDocument(singleDoc)
if (!isPreview) {
setValue(blockId, subBlock.id, singleDoc.id)
collaborativeSetSubblockValue(blockId, subBlock.id, singleDoc.id)
}
onDocumentSelect?.(singleDoc.id)
}
@@ -141,7 +143,15 @@ export function DocumentSelector({
setError((err as Error).message)
setDocuments([])
}
}, [knowledgeBaseId, selectedId, setValue, blockId, subBlock.id, isPreview, onDocumentSelect])
}, [
knowledgeBaseId,
selectedId,
collaborativeSetSubblockValue,
blockId,
subBlock.id,
isPreview,
onDocumentSelect,
])
// Handle dropdown open/close - fetch documents when opening
const handleOpenChange = (isOpen: boolean) => {
@@ -163,7 +173,7 @@ export function DocumentSelector({
setSelectedId(document.id)
if (!isPreview) {
setValue(blockId, subBlock.id, document.id)
collaborativeSetSubblockValue(blockId, subBlock.id, document.id)
}
onDocumentSelect?.(document.id)
@@ -193,10 +203,10 @@ export function DocumentSelector({
setInitialFetchDone(false)
setError(null)
if (!isPreview) {
setValue(blockId, subBlock.id, '')
collaborativeSetSubblockValue(blockId, subBlock.id, '')
}
}
}, [knowledgeBaseId, blockId, subBlock.id, setValue, isPreview])
}, [knowledgeBaseId, blockId, subBlock.id, collaborativeSetSubblockValue, isPreview])
// Fetch documents when knowledge base is available and we haven't fetched yet
useEffect(() => {

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { env } from '@/lib/env'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { ConfluenceFileInfo } from './components/confluence-file-selector'
@@ -36,7 +37,8 @@ export function FileSelectorInput({
isPreview = false,
previewValue,
}: FileSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const [selectedFileId, setSelectedFileId] = useState<string>('')
const [_fileInfo, setFileInfo] = useState<FileInfo | ConfluenceFileInfo | null>(null)
@@ -115,19 +117,19 @@ export function FileSelectorInput({
const handleFileChange = (fileId: string, info?: any) => {
setSelectedFileId(fileId)
setFileInfo(info || null)
setValue(blockId, subBlock.id, fileId)
collaborativeSetSubblockValue(blockId, subBlock.id, fileId)
}
// Handle issue selection
const handleIssueChange = (issueKey: string, info?: JiraIssueInfo) => {
setSelectedIssueId(issueKey)
setIssueInfo(info || null)
setValue(blockId, subBlock.id, issueKey)
collaborativeSetSubblockValue(blockId, subBlock.id, issueKey)
// Clear the fields when a new issue is selected
if (isJira) {
setValue(blockId, 'summary', '')
setValue(blockId, 'description', '')
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
}
}
@@ -135,14 +137,14 @@ export function FileSelectorInput({
const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => {
setSelectedChannelId(channelId)
setChannelInfo(info || null)
setValue(blockId, subBlock.id, channelId)
collaborativeSetSubblockValue(blockId, subBlock.id, channelId)
}
// Handle calendar selection
const handleCalendarChange = (calendarId: string, info?: GoogleCalendarInfo) => {
setSelectedCalendarId(calendarId)
setCalendarInfo(info || null)
setValue(blockId, subBlock.id, calendarId)
collaborativeSetSubblockValue(blockId, subBlock.id, calendarId)
}
// For Google Drive
@@ -337,7 +339,7 @@ export function FileSelectorInput({
onChange={(value, info) => {
setSelectedMessageId(value)
setMessageInfo(info || null)
setValue(blockId, subBlock.id, value)
collaborativeSetSubblockValue(blockId, subBlock.id, value)
}}
provider='microsoft-teams'
requiredScopes={subBlock.requiredScopes || []}

View File

@@ -4,9 +4,9 @@ import { useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useNotificationStore } from '@/stores/notifications/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
@@ -58,6 +58,7 @@ export function FileUpload({
// Stores
const { addNotification } = useNotificationStore()
const { activeWorkflowId } = useWorkflowRegistry()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
// Use preview value when in preview mode, otherwise use store value
const value = isPreview ? previewValue : storeValue
@@ -298,15 +299,15 @@ export function FileUpload({
setStoreValue(newFiles)
// Make sure to update the subblock store value for the workflow execution
useSubBlockStore.getState().setValue(blockId, subBlockId, newFiles)
// Use collaborative update for persistence
collaborativeSetSubblockValue(blockId, subBlockId, newFiles)
useWorkflowStore.getState().triggerUpdate()
} else {
// For single file: Replace with last uploaded file
setStoreValue(uploadedFiles[0] || null)
// Make sure to update the subblock store value for the workflow execution
useSubBlockStore.getState().setValue(blockId, subBlockId, uploadedFiles[0] || null)
// Use collaborative update for persistence
collaborativeSetSubblockValue(blockId, subBlockId, uploadedFiles[0] || null)
useWorkflowStore.getState().triggerUpdate()
}
} catch (error) {
@@ -363,16 +364,18 @@ export function FileUpload({
const updatedFiles = filesArray.filter((f) => f.path !== file.path)
setStoreValue(updatedFiles.length > 0 ? updatedFiles : null)
// Make sure to update the subblock store value for the workflow execution
useSubBlockStore
.getState()
.setValue(blockId, subBlockId, updatedFiles.length > 0 ? updatedFiles : null)
// Use collaborative update for persistence
collaborativeSetSubblockValue(
blockId,
subBlockId,
updatedFiles.length > 0 ? updatedFiles : null
)
} else {
// For single file: Clear the value
setStoreValue(null)
// Make sure to update the subblock store
useSubBlockStore.getState().setValue(blockId, subBlockId, null)
// Use collaborative update for persistence
collaborativeSetSubblockValue(blockId, subBlockId, null)
}
useWorkflowStore.getState().triggerUpdate()
@@ -413,7 +416,7 @@ export function FileUpload({
// Clear input state immediately for better UX
setStoreValue(null)
useSubBlockStore.getState().setValue(blockId, subBlockId, null)
collaborativeSetSubblockValue(blockId, subBlockId, null)
useWorkflowStore.getState().triggerUpdate()
if (fileInputRef.current) {

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { type KnowledgeBaseData, useKnowledgeStore } from '@/stores/knowledge/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -36,7 +37,8 @@ export function KnowledgeBaseSelector({
}: KnowledgeBaseSelectorProps) {
const { getKnowledgeBasesList, knowledgeBasesList, loadingKnowledgeBasesList } =
useKnowledgeStore()
const { getValue, setValue } = useSubBlockStore()
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseData[]>([])
const [loading, setLoading] = useState(false)
@@ -90,7 +92,8 @@ export function KnowledgeBaseSelector({
setSelectedKnowledgeBases([knowledgeBase])
if (!isPreview) {
setValue(blockId, subBlock.id, knowledgeBase.id)
// Use collaborative update for both local store and persistence
collaborativeSetSubblockValue(blockId, subBlock.id, knowledgeBase.id)
}
onKnowledgeBaseSelect?.(knowledgeBase.id)
@@ -117,7 +120,8 @@ export function KnowledgeBaseSelector({
if (!isPreview) {
const selectedIds = newSelected.map((kb) => kb.id)
const valueToStore = selectedIds.length === 1 ? selectedIds[0] : selectedIds.join(',')
setValue(blockId, subBlock.id, valueToStore)
// Use collaborative update for both local store and persistence
collaborativeSetSubblockValue(blockId, subBlock.id, valueToStore)
}
onKnowledgeBaseSelect?.(newSelected.map((kb) => kb.id))
@@ -133,7 +137,8 @@ export function KnowledgeBaseSelector({
if (!isPreview) {
const selectedIds = newSelected.map((kb) => kb.id)
const valueToStore = selectedIds.length === 1 ? selectedIds[0] : selectedIds.join(',')
setValue(blockId, subBlock.id, valueToStore)
// Use collaborative update for both local store and persistence
collaborativeSetSubblockValue(blockId, subBlock.id, valueToStore)
}
onKnowledgeBaseSelect?.(newSelected.map((kb) => kb.id))

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { type DiscordServerInfo, DiscordServerSelector } from './components/discord-server-selector'
import { type JiraProjectInfo, JiraProjectSelector } from './components/jira-project-selector'
@@ -26,7 +27,8 @@ export function ProjectSelectorInput({
isPreview = false,
previewValue,
}: ProjectSelectorInputProps) {
const { getValue, setValue } = useSubBlockStore()
const { getValue } = useSubBlockStore()
const { collaborativeSetSubblockValue } = useCollaborativeWorkflow()
const [selectedProjectId, setSelectedProjectId] = useState<string>('')
const [_projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(null)
@@ -58,21 +60,21 @@ export function ProjectSelectorInput({
) => {
setSelectedProjectId(projectId)
setProjectInfo(info || null)
setValue(blockId, subBlock.id, projectId)
collaborativeSetSubblockValue(blockId, subBlock.id, projectId)
// Clear the issue-related fields when a new project is selected
if (provider === 'jira') {
setValue(blockId, 'summary', '')
setValue(blockId, 'description', '')
setValue(blockId, 'issueKey', '')
collaborativeSetSubblockValue(blockId, 'summary', '')
collaborativeSetSubblockValue(blockId, 'description', '')
collaborativeSetSubblockValue(blockId, 'issueKey', '')
} else if (provider === 'discord') {
setValue(blockId, 'channelId', '')
collaborativeSetSubblockValue(blockId, 'channelId', '')
} else if (provider === 'linear') {
if (subBlock.id === 'teamId') {
setValue(blockId, 'teamId', projectId)
setValue(blockId, 'projectId', '')
collaborativeSetSubblockValue(blockId, 'teamId', projectId)
collaborativeSetSubblockValue(blockId, 'projectId', '')
} else if (subBlock.id === 'projectId') {
setValue(blockId, 'projectId', projectId)
collaborativeSetSubblockValue(blockId, 'projectId', projectId)
}
}

View File

@@ -419,6 +419,7 @@ const WorkflowContent = React.memo(() => {
}
const { type } = event.detail
console.log('🛠️ Adding block from toolbar:', type)
if (!type) return
if (type === 'connectionBlock') return
@@ -439,32 +440,42 @@ const WorkflowContent = React.memo(() => {
y: window.innerHeight / 2,
})
// Add the container node directly to canvas with default dimensions
addBlock(id, type, name, centerPosition, {
width: 500,
height: 300,
type: type === 'loop' ? 'loopNode' : 'parallelNode',
})
// Auto-connect logic for container nodes
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(centerPosition)
if (closestBlock) {
// Get appropriate source handle
const sourceHandle = determineSourceHandle(closestBlock)
addEdge({
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
}
// Add the container node directly to canvas with default dimensions and auto-connect edge
addBlock(
id,
type,
name,
centerPosition,
{
width: 500,
height: 300,
type: type === 'loop' ? 'loopNode' : 'parallelNode',
},
undefined,
undefined,
autoConnectEdge
)
return
}
@@ -486,27 +497,30 @@ const WorkflowContent = React.memo(() => {
Object.values(blocks).filter((b) => b.type === type).length + 1
}`
// Add the block to the workflow
addBlock(id, type, name, centerPosition)
// Auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled && type !== 'starter') {
const closestBlock = findClosestOutput(centerPosition)
console.log('🎯 Closest block found:', closestBlock)
if (closestBlock) {
// Get appropriate source handle
const sourceHandle = determineSourceHandle(closestBlock)
addEdge({
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
console.log('✅ Auto-connect edge created:', autoConnectEdge)
}
}
// Add the block to the workflow with auto-connect edge
addBlock(id, type, name, centerPosition, undefined, undefined, undefined, autoConnectEdge)
}
window.addEventListener('add-block-from-toolbar', handleAddBlockFromToolbar as EventListener)
@@ -583,30 +597,40 @@ const WorkflowContent = React.memo(() => {
// Resize the parent container to fit the new child container
resizeLoopNodesWrapper()
} else {
// Add the container node directly to canvas with default dimensions
addBlock(id, data.type, name, position, {
width: 500,
height: 300,
type: data.type === 'loop' ? 'loopNode' : 'parallelNode',
})
// Auto-connect the container to the closest node on the canvas
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled) {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
const sourceHandle = determineSourceHandle(closestBlock)
addEdge({
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
}
// Add the container node directly to canvas with default dimensions and auto-connect edge
addBlock(
id,
data.type,
name,
position,
{
width: 500,
height: 300,
type: data.type === 'loop' ? 'loopNode' : 'parallelNode',
},
undefined,
undefined,
autoConnectEdge
)
}
return
@@ -706,26 +730,27 @@ const WorkflowContent = React.memo(() => {
}
}
} else {
// Regular canvas drop
addBlock(id, data.type, name, position)
// Regular auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled && data.type !== 'starter') {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
const sourceHandle = determineSourceHandle(closestBlock)
addEdge({
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
}
// Regular canvas drop with auto-connect edge
addBlock(id, data.type, name, position, undefined, undefined, undefined, autoConnectEdge)
}
} catch (err) {
logger.error('Error dropping block:', { err })

View File

@@ -77,6 +77,8 @@ describe('FunctionBlockHandler', () => {
code: inputs.code,
timeout: inputs.timeout,
envVars: {},
blockData: {},
blockNameMapping: {},
_context: { workflowId: mockContext.workflowId },
}
const expectedOutput: BlockOutput = { response: { result: 'Success' } }
@@ -100,6 +102,8 @@ describe('FunctionBlockHandler', () => {
code: expectedCode,
timeout: inputs.timeout,
envVars: {},
blockData: {},
blockNameMapping: {},
_context: { workflowId: mockContext.workflowId },
}
const expectedOutput: BlockOutput = { response: { result: 'Success' } }
@@ -116,6 +120,8 @@ describe('FunctionBlockHandler', () => {
code: inputs.code,
timeout: 5000, // Default timeout
envVars: {},
blockData: {},
blockNameMapping: {},
_context: { workflowId: mockContext.workflowId },
}

View File

@@ -23,12 +23,29 @@ export class FunctionBlockHandler implements BlockHandler {
? inputs.code.map((c: { content: string }) => c.content).join('\n')
: inputs.code
// Extract block data for variable resolution
const blockData: Record<string, any> = {}
const blockNameMapping: Record<string, string> = {}
for (const [blockId, blockState] of context.blockStates.entries()) {
if (blockState.output) {
blockData[blockId] = blockState.output
// Try to find the block name from the workflow
const workflowBlock = context.workflow?.blocks?.find((b) => b.id === blockId)
if (workflowBlock?.metadata?.name) {
blockNameMapping[workflowBlock.metadata.name] = blockId
}
}
}
// Directly use the function_execute tool which calls the API route
logger.info(`Executing function block via API route: ${block.id}`)
const result = await executeTool('function_execute', {
code: codeContent,
timeout: inputs.timeout || 5000,
envVars: context.environmentVariables || {},
blockData: blockData, // Pass block data for variable resolution
blockNameMapping: blockNameMapping, // Pass block name to ID mapping
_context: { workflowId: context.workflowId },
})

View File

@@ -44,9 +44,7 @@ export class LoopBlockHandler implements BlockHandler {
}
const currentIteration = context.loopIterations.get(block.id) || 0
let maxIterations = loop.iterations || DEFAULT_MAX_ITERATIONS
// For forEach loops, we need to check the actual items length
let maxIterations: number
let forEachItems: any[] | Record<string, any> | null = null
if (loop.loopType === 'forEach') {
if (
@@ -71,14 +69,19 @@ export class LoopBlockHandler implements BlockHandler {
)
}
// Adjust max iterations based on actual items
// For forEach, max iterations = items length
const itemsLength = Array.isArray(forEachItems)
? forEachItems.length
: Object.keys(forEachItems).length
maxIterations = Math.min(maxIterations, itemsLength)
maxIterations = itemsLength
logger.info(
`Loop ${block.id} max iterations set to ${maxIterations} based on ${itemsLength} items`
`forEach loop ${block.id} - Items: ${itemsLength}, Max iterations: ${maxIterations}`
)
} else {
maxIterations = loop.iterations || DEFAULT_MAX_ITERATIONS
logger.info(`For loop ${block.id} - Max iterations: ${maxIterations}`)
}
logger.info(

View File

@@ -81,7 +81,7 @@ export class LoopManager {
// Determine the maximum iterations
let maxIterations = loop.iterations || this.defaultIterations
// For forEach loops, check the actual items length
// For forEach loops, use the actual items length
if (loop.loopType === 'forEach' && loop.forEachItems) {
// First check if the items have already been evaluated and stored by the loop handler
const storedItems = context.loopItems.get(`${loopId}_items`)
@@ -89,15 +89,18 @@ export class LoopManager {
const itemsLength = Array.isArray(storedItems)
? storedItems.length
: Object.keys(storedItems).length
maxIterations = Math.min(maxIterations, itemsLength)
maxIterations = itemsLength
logger.info(
`Loop ${loopId} using stored items length: ${itemsLength} (max iterations: ${maxIterations})`
`forEach loop ${loopId} - Items: ${itemsLength}, Max iterations: ${maxIterations}`
)
} else {
// Fallback to parsing the forEachItems string if it's not a reference
const itemsLength = this.getItemsLength(loop.forEachItems)
if (itemsLength > 0) {
maxIterations = Math.min(maxIterations, itemsLength)
maxIterations = itemsLength
logger.info(
`forEach loop ${loopId} - Parsed items: ${itemsLength}, Max iterations: ${maxIterations}`
)
}
}
}

View File

@@ -593,6 +593,7 @@ export class InputResolver {
isInTemplateLiteral
)
} else {
// The function execution API will handle variable resolution within code strings
formattedValue =
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)

View File

@@ -91,6 +91,10 @@ export function useCollaborativeWorkflow() {
payload.parentId,
payload.extent
)
// Handle auto-connect edge if present
if (payload.autoConnectEdge) {
workflowStore.addEdge(payload.autoConnectEdge)
}
break
case 'update-position': {
// Apply position update only if it's newer than the last applied timestamp
@@ -164,6 +168,10 @@ export function useCollaborativeWorkflow() {
payload.parentId,
payload.extent
)
// Handle auto-connect edge if present
if (payload.autoConnectEdge) {
workflowStore.addEdge(payload.autoConnectEdge)
}
break
}
} else if (target === 'edge') {
@@ -284,7 +292,8 @@ export function useCollaborativeWorkflow() {
position: Position,
data?: Record<string, any>,
parentId?: string,
extent?: 'parent'
extent?: 'parent',
autoConnectEdge?: Edge
) => {
// Create complete block data upfront using the same logic as the store
const blockConfig = getBlock(type)
@@ -306,10 +315,14 @@ export function useCollaborativeWorkflow() {
height: 0,
parentId,
extent,
autoConnectEdge, // Include edge data for atomic operation
}
// Apply locally first
workflowStore.addBlock(id, type, name, position, data, parentId, extent)
if (autoConnectEdge) {
workflowStore.addEdge(autoConnectEdge)
}
// Then broadcast to other clients with complete block data
if (!isApplyingRemoteChange.current) {
@@ -354,10 +367,14 @@ export function useCollaborativeWorkflow() {
height: 0, // Default height, will be set by the UI
parentId,
extent,
autoConnectEdge, // Include edge data for atomic operation
}
// Apply locally first
workflowStore.addBlock(id, type, name, position, data, parentId, extent)
if (autoConnectEdge) {
workflowStore.addEdge(autoConnectEdge)
}
// Then broadcast to other clients with complete block data
if (!isApplyingRemoteChange.current) {

View File

@@ -29,6 +29,34 @@ const db = socketDb
// Constants
const DEFAULT_LOOP_ITERATIONS = 5
/**
* Shared function to handle auto-connect edge insertion
* @param tx - Database transaction
* @param workflowId - The workflow ID
* @param autoConnectEdge - The auto-connect edge data
* @param logger - Logger instance
*/
async function insertAutoConnectEdge(
tx: any,
workflowId: string,
autoConnectEdge: any,
logger: any
) {
if (!autoConnectEdge) return
await tx.insert(workflowEdges).values({
id: autoConnectEdge.id,
workflowId,
sourceBlockId: autoConnectEdge.source,
targetBlockId: autoConnectEdge.target,
sourceHandle: autoConnectEdge.sourceHandle || null,
targetHandle: autoConnectEdge.targetHandle || null,
})
logger.debug(
`Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}`
)
}
// Enum for subflow types
enum SubflowType {
LOOP = 'loop',
@@ -246,6 +274,9 @@ async function handleBlockOperationTx(
}
await tx.insert(workflowBlocks).values(insertData)
// Handle auto-connect edge if present
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
} catch (insertError) {
logger.error(`[SERVER] ❌ Failed to insert block ${payload.id}:`, insertError)
throw insertError
@@ -592,6 +623,9 @@ async function handleBlockOperationTx(
}
await tx.insert(workflowBlocks).values(insertData)
// Handle auto-connect edge if present
await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger)
} catch (insertError) {
logger.error(`[SERVER] ❌ Failed to insert duplicated block ${payload.id}:`, insertError)
throw insertError

View File

@@ -279,6 +279,32 @@ describe('Socket Server Index Integration', () => {
expect(() => WorkflowOperationSchema.parse(validOperation)).not.toThrow()
})
it.concurrent('should validate block operations with autoConnectEdge', async () => {
const { WorkflowOperationSchema } = await import('./validation/schemas')
const validOperationWithAutoEdge = {
operation: 'add',
target: 'block',
payload: {
id: 'test-block',
type: 'action',
name: 'Test Block',
position: { x: 100, y: 200 },
autoConnectEdge: {
id: 'auto-edge-123',
source: 'source-block',
target: 'test-block',
sourceHandle: 'output',
targetHandle: 'target',
type: 'workflowEdge',
},
},
timestamp: Date.now(),
}
expect(() => WorkflowOperationSchema.parse(validOperationWithAutoEdge)).not.toThrow()
})
it.concurrent('should validate edge operations', async () => {
const { WorkflowOperationSchema } = await import('./validation/schemas')

View File

@@ -5,6 +5,16 @@ const PositionSchema = z.object({
y: z.number(),
})
// Schema for auto-connect edge data
const AutoConnectEdgeSchema = z.object({
id: z.string(),
source: z.string(),
target: z.string(),
sourceHandle: z.string().nullable().optional(),
targetHandle: z.string().nullable().optional(),
type: z.string().optional(),
})
export const BlockOperationSchema = z.object({
operation: z.enum([
'add',
@@ -35,6 +45,7 @@ export const BlockOperationSchema = z.object({
isWide: z.boolean().optional(),
advancedMode: z.boolean().optional(),
height: z.number().optional(),
autoConnectEdge: AutoConnectEdgeSchema.optional(), // Add support for auto-connect edges
}),
timestamp: z.number(),
})
@@ -69,4 +80,4 @@ export const WorkflowOperationSchema = z.union([
SubflowOperationSchema,
])
export { PositionSchema }
export { PositionSchema, AutoConnectEdgeSchema }

View File

@@ -50,6 +50,8 @@ describe('Function Execute Tool', () => {
expect(body).toEqual({
code: 'return 42',
envVars: {},
blockData: {},
blockNameMapping: {},
isCustomTool: false,
timeout: 5000,
workflowId: undefined,
@@ -73,6 +75,8 @@ describe('Function Execute Tool', () => {
code: 'const x = 40;\nconst y = 2;\nreturn x + y;',
timeout: 10000,
envVars: {},
blockData: {},
blockNameMapping: {},
isCustomTool: false,
workflowId: undefined,
})
@@ -87,6 +91,8 @@ describe('Function Execute Tool', () => {
code: 'return 42',
timeout: 10000,
envVars: {},
blockData: {},
blockNameMapping: {},
isCustomTool: false,
workflowId: undefined,
})

View File

@@ -28,6 +28,18 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
description: 'Environment variables to make available during execution',
default: {},
},
blockData: {
type: 'object',
required: false,
description: 'Block output data for variable resolution',
default: {},
},
blockNameMapping: {
type: 'object',
required: false,
description: 'Mapping of block names to block IDs',
default: {},
},
},
request: {
@@ -45,6 +57,8 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
code: codeContent,
timeout: params.timeout || DEFAULT_TIMEOUT,
envVars: params.envVars || {},
blockData: params.blockData || {},
blockNameMapping: params.blockNameMapping || {},
workflowId: params._context?.workflowId,
isCustomTool: params.isCustomTool || false,
}

View File

@@ -5,6 +5,8 @@ export interface CodeExecutionInput {
timeout?: number
memoryLimit?: number
envVars?: Record<string, string>
blockData?: Record<string, any>
blockNameMapping?: Record<string, string>
_context?: {
workflowId?: string
}