Compare commits

..

1 Commits

Author SHA1 Message Date
waleed
7c9dc7568a feat(mcp): added ability to connect an mcp server and allow agents to do discovery 2026-02-02 14:39:03 -08:00
102 changed files with 894 additions and 12520 deletions

View File

@@ -180,11 +180,6 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
<td>Right-click → **Enable/Disable**</td> <td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td> <td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr> </tr>
<tr>
<td>Lock/Unlock a block</td>
<td>Hover block → Click lock icon (Admin only)</td>
<td><ActionImage src="/static/quick-reference/lock-block.png" alt="Lock block" /></td>
</tr>
<tr> <tr>
<td>Toggle handle orientation</td> <td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td> <td>Right-click → **Toggle Handles**</td>

View File

@@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/> />
{/* MANUAL-CONTENT-START:intro */} {/* MANUAL-CONTENT-START:intro */}
The [Pulse](https://www.runpulse.com) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow. The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
With Pulse, you can: With Pulse, you can:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import SSOForm from '@/ee/sso/components/sso-form' import SSOForm from '@/app/(auth)/sso/sso-form'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'

View File

@@ -29,7 +29,7 @@ import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import { import {
InvitationsNotAllowedError, InvitationsNotAllowedError,
validateInvitationsAllowed, validateInvitationsAllowed,
} from '@/ee/access-control/utils/permission-check' } from '@/executor/utils/permission-check'
const logger = createLogger('OrganizationInvitations') const logger = createLogger('OrganizationInvitations')

View File

@@ -102,7 +102,7 @@ describe('Workspace Invitations API Route', () => {
inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })), inArray: vi.fn().mockImplementation((field, values) => ({ type: 'inArray', field, values })),
})) }))
vi.doMock('@/ee/access-control/utils/permission-check', () => ({ vi.doMock('@/executor/utils/permission-check', () => ({
validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), validateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error { InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {
constructor() { constructor() {

View File

@@ -21,7 +21,7 @@ import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { import {
InvitationsNotAllowedError, InvitationsNotAllowedError,
validateInvitationsAllowed, validateInvitationsAllowed,
} from '@/ee/access-control/utils/permission-check' } from '@/executor/utils/permission-check'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -38,6 +38,7 @@ export async function GET(req: NextRequest) {
} }
try { try {
// Get all workspaces where the user has permissions
const userWorkspaces = await db const userWorkspaces = await db
.select({ id: workspace.id }) .select({ id: workspace.id })
.from(workspace) .from(workspace)
@@ -54,8 +55,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ invitations: [] }) return NextResponse.json({ invitations: [] })
} }
// Get all workspaceIds where the user is a member
const workspaceIds = userWorkspaces.map((w) => w.id) const workspaceIds = userWorkspaces.map((w) => w.id)
// Find all invitations for those workspaces
const invitations = await db const invitations = await db
.select() .select()
.from(workspaceInvitation) .from(workspaceInvitation)

View File

@@ -14,11 +14,11 @@ import {
ChatMessageContainer, ChatMessageContainer,
EmailAuth, EmailAuth,
PasswordAuth, PasswordAuth,
SSOAuth,
VoiceInterface, VoiceInterface,
} from '@/app/chat/components' } from '@/app/chat/components'
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants' import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks' import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
import SSOAuth from '@/ee/sso/components/sso-auth'
const logger = createLogger('ChatClient') const logger = createLogger('ChatClient')

View File

@@ -1,5 +1,6 @@
export { default as EmailAuth } from './auth/email/email-auth' export { default as EmailAuth } from './auth/email/email-auth'
export { default as PasswordAuth } from './auth/password/password-auth' export { default as PasswordAuth } from './auth/password/password-auth'
export { default as SSOAuth } from './auth/sso/sso-auth'
export { ChatErrorState } from './error-state/error-state' export { ChatErrorState } from './error-state/error-state'
export { ChatHeader } from './header/header' export { ChatHeader } from './header/header'
export { ChatInput } from './input/input' export { ChatInput } from './input/input'

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { Knowledge } from './knowledge' import { Knowledge } from './knowledge'
interface KnowledgePageProps { interface KnowledgePageProps {
@@ -23,6 +23,7 @@ export default async function KnowledgePage({ params }: KnowledgePageProps) {
redirect('/') redirect('/')
} }
// Check permission group restrictions
const permissionConfig = await getUserPermissionConfig(session.user.id) const permissionConfig = await getUserPermissionConfig(session.user.id)
if (permissionConfig?.hideKnowledgeBaseTab) { if (permissionConfig?.hideKnowledgeBaseTab) {
redirect(`/workspace/${workspaceId}`) redirect(`/workspace/${workspaceId}`)

View File

@@ -6,11 +6,11 @@ import Link from 'next/link'
import { List, type RowComponentProps, useListRef } from 'react-window' import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn' import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { import {
DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL, DELETED_WORKFLOW_LABEL,
formatDate, formatDate,
formatDuration,
getDisplayStatus, getDisplayStatus,
LOG_COLUMNS, LOG_COLUMNS,
StatusBadge, StatusBadge,
@@ -113,7 +113,7 @@ const LogRow = memo(
<div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}> <div className={`${LOG_COLUMNS.duration.width} ${LOG_COLUMNS.duration.minWidth}`}>
<Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'> <Badge variant='default' className='rounded-[6px] px-[9px] py-[2px] text-[12px]'>
{formatDuration(log.duration, { precision: 2 }) || '—'} {formatDuration(log.duration) || '—'}
</Badge> </Badge>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Badge } from '@/components/emcn' import { Badge } from '@/components/emcn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
import { getBlock } from '@/blocks/registry' import { getBlock } from '@/blocks/registry'
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
@@ -363,14 +362,47 @@ export function mapToExecutionLogAlt(log: RawLogResponse): ExecutionLog {
} }
} }
/**
* Format duration for display in logs UI
* If duration is under 1 second, displays as milliseconds (e.g., "500ms")
* If duration is 1 second or more, displays as seconds (e.g., "1.23s")
* @param duration - Duration string (e.g., "500ms") or null
* @returns Formatted duration string or null
*/
export function formatDuration(duration: string | null): string | null {
if (!duration) return null
// Extract numeric value from duration string (e.g., "500ms" -> 500)
const ms = Number.parseInt(duration.replace(/[^0-9]/g, ''), 10)
if (!Number.isFinite(ms)) return duration
if (ms < 1000) {
return `${ms}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
}
/** /**
* Format latency value for display in dashboard UI * Format latency value for display in dashboard UI
* If latency is under 1 second, displays as milliseconds (e.g., "500ms")
* If latency is 1 second or more, displays as seconds (e.g., "1.23s")
* @param ms - Latency in milliseconds (number) * @param ms - Latency in milliseconds (number)
* @returns Formatted latency string * @returns Formatted latency string
*/ */
export function formatLatency(ms: number): string { export function formatLatency(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—' if (!Number.isFinite(ms) || ms <= 0) return '—'
return formatDuration(ms, { precision: 2 }) ?? '—'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
// Convert to seconds with up to 2 decimal places
const seconds = ms / 1000
return `${seconds.toFixed(2).replace(/\.?0+$/, '')}s`
} }
export const formatDate = (dateString: string) => { export const formatDate = (dateString: string) => {

View File

@@ -6,7 +6,7 @@ import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates' import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates' import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
interface TemplatesPageProps { interface TemplatesPageProps {
params: Promise<{ params: Promise<{

View File

@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react' import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn' import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
@@ -49,7 +49,6 @@ export const ActionBar = memo(
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry() const { setPendingSelection } = useWorkflowRegistry()
const { handleRunFromBlock } = useWorkflowExecution() const { handleRunFromBlock } = useWorkflowExecution()
@@ -85,28 +84,16 @@ export const ActionBar = memo(
) )
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection]) }, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
const { const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
isEnabled,
horizontalHandles,
parentId,
parentType,
isLocked,
isParentLocked,
isParentDisabled,
} = useWorkflowStore(
useCallback( useCallback(
(state) => { (state) => {
const block = state.blocks[blockId] const block = state.blocks[blockId]
const parentId = block?.data?.parentId const parentId = block?.data?.parentId
const parentBlock = parentId ? state.blocks[parentId] : undefined
return { return {
isEnabled: block?.enabled ?? true, isEnabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false, horizontalHandles: block?.horizontalHandles ?? false,
parentId, parentId,
parentType: parentBlock?.type, parentType: parentId ? state.blocks[parentId]?.type : undefined,
isLocked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
} }
}, },
[blockId] [blockId]
@@ -174,27 +161,25 @@ export const ActionBar = memo(
{!isNoteBlock && !isInsideSubflow && ( {!isNoteBlock && !isInsideSubflow && (
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<span className='inline-flex'> <Button
<Button variant='ghost'
variant='ghost' onClick={(e) => {
onClick={(e) => { e.stopPropagation()
e.stopPropagation() if (canRunFromBlock && !disabled) {
if (canRunFromBlock && !disabled) { handleRunFromBlockClick()
handleRunFromBlockClick() }
} }}
}} className={ACTION_BUTTON_STYLES}
className={ACTION_BUTTON_STYLES} disabled={disabled || !canRunFromBlock}
disabled={disabled || !canRunFromBlock} >
> <PlayOutline className={ICON_SIZE} />
<PlayOutline className={ICON_SIZE} /> </Button>
</Button>
</span>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>
{(() => { {(() => {
if (disabled) return getTooltipMessage('Run from block') if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Execution in progress' if (isExecuting) return 'Execution in progress'
if (!dependenciesSatisfied) return 'Run previous blocks first' if (!dependenciesSatisfied) return 'Run upstream blocks first'
return 'Run from block' return 'Run from block'
})()} })()}
</Tooltip.Content> </Tooltip.Content>
@@ -208,54 +193,18 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
// Can't enable if parent is disabled (must enable parent first) if (!disabled) {
const cantEnable = !isEnabled && isParentDisabled
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
collaborativeBatchToggleBlockEnabled([blockId]) collaborativeBatchToggleBlockEnabled([blockId])
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={ disabled={disabled}
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
}
> >
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />} {isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>
{isLocked || isParentLocked {getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
? 'Block is locked'
: !isEnabled && isParentDisabled
? 'Parent container is disabled'
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}
{userPermissions.canAdmin && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
// Can't unlock a block if its parent container is locked
if (!disabled && !(isLocked && isParentLocked)) {
collaborativeBatchToggleLocked([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || (isLocked && isParentLocked)}
>
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{isLocked && isParentLocked
? 'Parent container is locked'
: isLocked
? 'Unlock Block'
: 'Lock Block'}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
@@ -288,12 +237,12 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) { if (!disabled) {
collaborativeBatchToggleBlockHandles([blockId]) collaborativeBatchToggleBlockHandles([blockId])
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked} disabled={disabled}
> >
{horizontalHandles ? ( {horizontalHandles ? (
<ArrowLeftRight className={ICON_SIZE} /> <ArrowLeftRight className={ICON_SIZE} />
@@ -303,9 +252,7 @@ export const ActionBar = memo(
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>
{isLocked || isParentLocked {getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
? 'Block is locked'
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
@@ -317,23 +264,19 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) { if (!disabled && userPermissions.canEdit) {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } }) new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
) )
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked} disabled={disabled || !userPermissions.canEdit}
> >
<LogOut className={ICON_SIZE} /> <LogOut className={ICON_SIZE} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Remove from Subflow')}
</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
)} )}
@@ -343,19 +286,17 @@ export const ActionBar = memo(
variant='ghost' variant='ghost'
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!disabled && !isLocked && !isParentLocked) { if (!disabled) {
collaborativeBatchRemoveBlocks([blockId]) collaborativeBatchRemoveBlocks([blockId])
} }
}} }}
className={ACTION_BUTTON_STYLES} className={ACTION_BUTTON_STYLES}
disabled={disabled || isLocked || isParentLocked} disabled={disabled}
> >
<Trash2 className={ICON_SIZE} /> <Trash2 className={ICON_SIZE} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top'> <Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
) )

View File

@@ -20,9 +20,6 @@ export interface BlockInfo {
horizontalHandles: boolean horizontalHandles: boolean
parentId?: string parentId?: string
parentType?: string parentType?: string
locked?: boolean
isParentLocked?: boolean
isParentDisabled?: boolean
} }
/** /**
@@ -49,17 +46,10 @@ export interface BlockMenuProps {
showRemoveFromSubflow?: boolean showRemoveFromSubflow?: boolean
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */ /** Whether run from block is available (has snapshot, was executed, not inside subflow) */
canRunFromBlock?: boolean canRunFromBlock?: boolean
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
disableEdit?: boolean disableEdit?: boolean
/** Whether the user has edit permission (ignoring locked state) */
userCanEdit?: boolean
isExecuting?: boolean isExecuting?: boolean
/** Whether the selected block is a trigger (has no incoming edges) */ /** Whether the selected block is a trigger (has no incoming edges) */
isPositionalTrigger?: boolean isPositionalTrigger?: boolean
/** Callback to toggle locked state of selected blocks */
onToggleLocked?: () => void
/** Whether the user has admin permissions */
canAdmin?: boolean
} }
/** /**
@@ -88,22 +78,13 @@ export function BlockMenu({
showRemoveFromSubflow = false, showRemoveFromSubflow = false,
canRunFromBlock = false, canRunFromBlock = false,
disableEdit = false, disableEdit = false,
userCanEdit = true,
isExecuting = false, isExecuting = false,
isPositionalTrigger = false, isPositionalTrigger = false,
onToggleLocked,
canAdmin = false,
}: BlockMenuProps) { }: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1 const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled) const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled) const allDisabled = selectedBlocks.every((b) => !b.enabled)
const allLocked = selectedBlocks.every((b) => b.locked)
const allUnlocked = selectedBlocks.every((b) => !b.locked)
// Can't unlock blocks that have locked parents
const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked)
// Can't enable blocks that have disabled parents
const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled)
const hasSingletonBlock = selectedBlocks.some( const hasSingletonBlock = selectedBlocks.some(
(b) => (b) =>
@@ -127,12 +108,6 @@ export function BlockMenu({
return 'Toggle Enabled' return 'Toggle Enabled'
} }
const getToggleLockedLabel = () => {
if (allLocked) return 'Unlock'
if (allUnlocked) return 'Lock'
return 'Toggle Lock'
}
return ( return (
<Popover <Popover
open={isOpen} open={isOpen}
@@ -164,7 +139,7 @@ export function BlockMenu({
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
className='group' className='group'
disabled={!userCanEdit || !hasClipboard} disabled={disableEdit || !hasClipboard}
onClick={() => { onClick={() => {
onPaste() onPaste()
onClose() onClose()
@@ -175,7 +150,7 @@ export function BlockMenu({
</PopoverItem> </PopoverItem>
{!hasSingletonBlock && ( {!hasSingletonBlock && (
<PopoverItem <PopoverItem
disabled={!userCanEdit} disabled={disableEdit}
onClick={() => { onClick={() => {
onDuplicate() onDuplicate()
onClose() onClose()
@@ -189,15 +164,13 @@ export function BlockMenu({
{!allNoteBlocks && <PopoverDivider />} {!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && ( {!allNoteBlocks && (
<PopoverItem <PopoverItem
disabled={disableEdit || hasBlockWithDisabledParent} disabled={disableEdit}
onClick={() => { onClick={() => {
if (!disableEdit && !hasBlockWithDisabledParent) { onToggleEnabled()
onToggleEnabled() onClose()
onClose()
}
}} }}
> >
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()} {getToggleEnabledLabel()}
</PopoverItem> </PopoverItem>
)} )}
{!allNoteBlocks && !isSubflow && ( {!allNoteBlocks && !isSubflow && (
@@ -222,19 +195,6 @@ export function BlockMenu({
Remove from Subflow Remove from Subflow
</PopoverItem> </PopoverItem>
)} )}
{canAdmin && onToggleLocked && (
<PopoverItem
disabled={hasBlockWithLockedParent}
onClick={() => {
if (!hasBlockWithLockedParent) {
onToggleLocked()
onClose()
}
}}
>
{hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()}
</PopoverItem>
)}
{/* Single block actions */} {/* Single block actions */}
{isSingleBlock && <PopoverDivider />} {isSingleBlock && <PopoverDivider />}

View File

@@ -34,8 +34,6 @@ export interface CanvasMenuProps {
canUndo?: boolean canUndo?: boolean
canRedo?: boolean canRedo?: boolean
isInvitationsDisabled?: boolean isInvitationsDisabled?: boolean
/** Whether the workflow has locked blocks (disables auto-layout) */
hasLockedBlocks?: boolean
} }
/** /**
@@ -62,7 +60,6 @@ export function CanvasMenu({
disableEdit = false, disableEdit = false,
canUndo = false, canUndo = false,
canRedo = false, canRedo = false,
hasLockedBlocks = false,
}: CanvasMenuProps) { }: CanvasMenuProps) {
return ( return (
<Popover <Popover
@@ -132,12 +129,11 @@ export function CanvasMenu({
</PopoverItem> </PopoverItem>
<PopoverItem <PopoverItem
className='group' className='group'
disabled={disableEdit || hasLockedBlocks} disabled={disableEdit}
onClick={() => { onClick={() => {
onAutoLayout() onAutoLayout()
onClose() onClose()
}} }}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
> >
<span>Auto-layout</span> <span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span> <span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>

View File

@@ -3,7 +3,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronUp } from 'lucide-react' import { ChevronUp } from 'lucide-react'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '../markdown-renderer' import { CopilotMarkdownRenderer } from '../markdown-renderer'
/** Removes thinking tags (raw or escaped) and special tags from streamed content */ /** Removes thinking tags (raw or escaped) and special tags from streamed content */
@@ -242,11 +241,15 @@ export function ThinkingBlock({
return () => window.clearInterval(intervalId) return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway]) }, [isStreaming, isExpanded, userHasScrolledAway])
/** Formats duration in milliseconds to seconds (minimum 1s) */
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const hasContent = cleanContent.length > 0 const hasContent = cleanContent.length > 0
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
// Round to nearest second (minimum 1s) to match original behavior const durationText = `${label} for ${formatDuration(duration)}`
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${label} for ${formatDuration(roundedMs)}`
const getStreamingLabel = (lbl: string) => { const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking' if (lbl === 'Thought') return 'Thinking'

View File

@@ -15,7 +15,6 @@ import {
hasInterrupt as hasInterruptFromConfig, hasInterrupt as hasInterruptFromConfig,
isSpecialTool as isSpecialToolFromConfig, isSpecialTool as isSpecialToolFromConfig,
} from '@/lib/copilot/tools/client/ui-config' } from '@/lib/copilot/tools/client/ui-config'
import { formatDuration } from '@/lib/core/utils/formatting'
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming' import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block' import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
@@ -849,10 +848,13 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
(allParsed.options && Object.keys(allParsed.options).length > 0) (allParsed.options && Object.keys(allParsed.options).length > 0)
) )
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
return `${seconds}s`
}
const outerLabel = getSubagentCompletionLabel(toolCall.name) const outerLabel = getSubagentCompletionLabel(toolCall.name)
// Round to nearest second (minimum 1s) to match original behavior const durationText = `${outerLabel} for ${formatDuration(duration)}`
const roundedMs = Math.max(1000, Math.round(duration / 1000) * 1000)
const durationText = `${outerLabel} for ${formatDuration(roundedMs)}`
const renderCollapsibleContent = () => ( const renderCollapsibleContent = () => (
<> <>

View File

@@ -45,7 +45,7 @@ export function CredentialSelector({
previewValue, previewValue,
}: CredentialSelectorProps) { }: CredentialSelectorProps) {
const [showOAuthModal, setShowOAuthModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false)
const [editingValue, setEditingValue] = useState('') const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const { activeWorkflowId } = useWorkflowRegistry() const { activeWorkflowId } = useWorkflowRegistry()
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id) const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
@@ -128,7 +128,11 @@ export function CredentialSelector({
return '' return ''
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign]) }, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
const displayValue = isEditing ? editingValue : resolvedLabel useEffect(() => {
if (!isEditing) {
setInputValue(resolvedLabel)
}
}, [resolvedLabel, isEditing])
const invalidSelection = const invalidSelection =
!isPreview && !isPreview &&
@@ -291,7 +295,7 @@ export function CredentialSelector({
const selectedCredentialProvider = selectedCredential?.provider ?? provider const selectedCredentialProvider = selectedCredential?.provider ?? provider
const overlayContent = useMemo(() => { const overlayContent = useMemo(() => {
if (!displayValue) return null if (!inputValue) return null
if (isCredentialSetSelected && selectedCredentialSet) { if (isCredentialSetSelected && selectedCredentialSet) {
return ( return (
@@ -299,7 +303,7 @@ export function CredentialSelector({
<div className='mr-2 flex-shrink-0 opacity-90'> <div className='mr-2 flex-shrink-0 opacity-90'>
<Users className='h-3 w-3' /> <Users className='h-3 w-3' />
</div> </div>
<span className='truncate'>{displayValue}</span> <span className='truncate'>{inputValue}</span>
</div> </div>
) )
} }
@@ -309,12 +313,12 @@ export function CredentialSelector({
<div className='mr-2 flex-shrink-0 opacity-90'> <div className='mr-2 flex-shrink-0 opacity-90'>
{getProviderIcon(selectedCredentialProvider)} {getProviderIcon(selectedCredentialProvider)}
</div> </div>
<span className='truncate'>{displayValue}</span> <span className='truncate'>{inputValue}</span>
</div> </div>
) )
}, [ }, [
getProviderIcon, getProviderIcon,
displayValue, inputValue,
selectedCredentialProvider, selectedCredentialProvider,
isCredentialSetSelected, isCredentialSetSelected,
selectedCredentialSet, selectedCredentialSet,
@@ -331,6 +335,7 @@ export function CredentialSelector({
const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length) const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId) const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
if (matchedSet) { if (matchedSet) {
setInputValue(matchedSet.name)
handleCredentialSetSelect(credentialSetId) handleCredentialSetSelect(credentialSetId)
return return
} }
@@ -338,12 +343,13 @@ export function CredentialSelector({
const matchedCred = credentials.find((c) => c.id === value) const matchedCred = credentials.find((c) => c.id === value)
if (matchedCred) { if (matchedCred) {
setInputValue(matchedCred.name)
handleSelect(value) handleSelect(value)
return return
} }
setIsEditing(true) setIsEditing(true)
setEditingValue(value) setInputValue(value)
}, },
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect] [credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
) )
@@ -353,7 +359,7 @@ export function CredentialSelector({
<Combobox <Combobox
options={comboboxOptions} options={comboboxOptions}
groups={comboboxGroups} groups={comboboxGroups}
value={displayValue} value={inputValue}
selectedValue={rawSelectedId} selectedValue={rawSelectedId}
onChange={handleComboboxChange} onChange={handleComboboxChange}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}

View File

@@ -1,7 +1,7 @@
import type React from 'react' import type React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Loader2, WrenchIcon, XIcon } from 'lucide-react' import { ChevronRight, Loader2, ServerIcon, WrenchIcon, XIcon } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {
Badge, Badge,
@@ -111,18 +111,33 @@ interface ToolInputProps {
* Represents a tool selected and configured in the workflow * Represents a tool selected and configured in the workflow
* *
* @remarks * @remarks
* Valid types include:
* - Standard block types (e.g., 'api', 'search', 'function')
* - 'custom-tool': User-defined tools with custom code
* - 'mcp': Individual MCP tool from a connected server
* - 'mcp-server': All tools from an MCP server (agent discovery mode).
* At execution time, this expands into individual tool definitions for
* all tools available on the server.
*
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded. * For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
* Everything else (title, schema, code) is loaded dynamically from the database. * Everything else (title, schema, code) is loaded dynamically from the database.
* Legacy custom tools with inline schema/code are still supported for backwards compatibility. * Legacy custom tools with inline schema/code are still supported for backwards compatibility.
*/ */
interface StoredTool { interface StoredTool {
/** Block type identifier */ /**
* Block type identifier.
* 'mcp-server' enables server-level selection where all tools from
* the server are made available to the LLM at execution time.
*/
type: string type: string
/** Display title for the tool (optional for new custom tool format) */ /** Display title for the tool (optional for new custom tool format) */
title?: string title?: string
/** Direct tool ID for execution (optional for new custom tool format) */ /** Direct tool ID for execution (optional for new custom tool format) */
toolId?: string toolId?: string
/** Parameter values configured by the user (optional for new custom tool format) */ /**
* Parameter values configured by the user.
* For 'mcp-server' type, includes: serverId, serverUrl, serverName, toolCount
*/
params?: Record<string, string> params?: Record<string, string>
/** Whether the tool details are expanded in UI */ /** Whether the tool details are expanded in UI */
isExpanded?: boolean isExpanded?: boolean
@@ -1007,6 +1022,7 @@ export const ToolInput = memo(function ToolInput({
const [draggedIndex, setDraggedIndex] = useState<number | null>(null) const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null) const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
const [expandedMcpServers, setExpandedMcpServers] = useState<Set<string>>(new Set())
const value = isPreview ? previewValue : storeValue const value = isPreview ? previewValue : storeValue
@@ -1236,6 +1252,18 @@ export const ToolInput = memo(function ToolInput({
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId) return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
} }
/**
* Checks if an MCP server is already selected (all tools mode).
*
* @param serverId - The MCP server identifier to check
* @returns `true` if the MCP server is already selected
*/
const isMcpServerAlreadySelected = (serverId: string): boolean => {
return selectedTools.some(
(tool) => tool.type === 'mcp-server' && tool.params?.serverId === serverId
)
}
/** /**
* Checks if a custom tool is already selected. * Checks if a custom tool is already selected.
* *
@@ -1260,6 +1288,37 @@ export const ToolInput = memo(function ToolInput({
) )
} }
/**
* Groups MCP tools by their parent server.
*
* @returns Map of serverId to array of tools
*/
const mcpToolsByServer = useMemo(() => {
const grouped = new Map<string, typeof availableMcpTools>()
for (const tool of availableMcpTools) {
if (!grouped.has(tool.serverId)) {
grouped.set(tool.serverId, [])
}
grouped.get(tool.serverId)!.push(tool)
}
return grouped
}, [availableMcpTools])
/**
* Toggles the expanded state of an MCP server in the dropdown.
*/
const toggleMcpServerExpanded = useCallback((serverId: string) => {
setExpandedMcpServers((prev) => {
const next = new Set(prev)
if (next.has(serverId)) {
next.delete(serverId)
} else {
next.add(serverId)
}
return next
})
}, [])
/** /**
* Checks if a block supports multiple operations. * Checks if a block supports multiple operations.
* *
@@ -1805,41 +1864,125 @@ export const ToolInput = memo(function ToolInput({
}) })
} }
// MCP Tools section // MCP Servers section - grouped by server with expandable folders
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) { if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) {
groups.push({ // Create items for each server (as expandable folders)
section: 'MCP Tools', const serverItems: ComboboxOption[] = []
items: availableMcpTools.map((mcpTool) => {
const server = mcpServers.find((s) => s.id === mcpTool.serverId) for (const [serverId, tools] of mcpToolsByServer) {
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) const server = mcpServers.find((s) => s.id === serverId)
return { const serverName = tools[0]?.serverName || server?.name || 'Unknown Server'
label: mcpTool.name, const isExpanded = expandedMcpServers.has(serverId)
value: `mcp-${mcpTool.id}`, const serverAlreadySelected = isMcpServerAlreadySelected(serverId)
iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon), const toolCount = tools.length
// Server folder header (clickable to expand/collapse)
serverItems.push({
label: serverName,
value: `mcp-server-folder-${serverId}`,
iconElement: (
<div className='flex items-center gap-[4px]'>
<ChevronRight
className={cn(
'h-[12px] w-[12px] text-[var(--text-tertiary)] transition-transform',
isExpanded && 'rotate-90'
)}
/>
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: '#6366F1' }}
>
<ServerIcon className='h-[10px] w-[10px] text-white' />
</div>
</div>
),
onSelect: () => {
toggleMcpServerExpanded(serverId)
},
disabled: false,
keepOpen: true, // Keep dropdown open when toggling folder expansion
})
// If expanded, show "Use all tools" option and individual tools
if (isExpanded) {
// "Use all tools from server" option
serverItems.push({
label: `Use all ${toolCount} tools`,
value: `mcp-server-all-${serverId}`,
iconElement: (
<div className='ml-[20px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
<McpIcon className='h-[10px] w-[10px] text-white' />
</div>
),
onSelect: () => { onSelect: () => {
if (alreadySelected) return if (serverAlreadySelected) return
// Remove any individual tools from this server that were previously selected
const filteredTools = selectedTools.filter(
(tool) => !(tool.type === 'mcp' && tool.params?.serverId === serverId)
)
const newTool: StoredTool = { const newTool: StoredTool = {
type: 'mcp', type: 'mcp-server',
title: mcpTool.name, title: `${serverName} (all tools)`,
toolId: mcpTool.id, toolId: `mcp-server-${serverId}`,
params: { params: {
serverId: mcpTool.serverId, serverId,
...(server?.url && { serverUrl: server.url }), ...(server?.url && { serverUrl: server.url }),
toolName: mcpTool.name, serverName,
serverName: mcpTool.serverName, toolCount: String(toolCount),
}, },
isExpanded: true, isExpanded: false,
usageControl: 'auto', usageControl: 'auto',
schema: {
...mcpTool.inputSchema,
description: mcpTool.description,
},
} }
handleMcpToolSelect(newTool, true) setStoreValue([
...filteredTools.map((tool) => ({ ...tool, isExpanded: false })),
newTool,
])
setOpen(false)
}, },
disabled: isPreview || disabled || alreadySelected, disabled: isPreview || disabled || serverAlreadySelected,
})
// Individual tools from this server
for (const mcpTool of tools) {
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) || serverAlreadySelected
serverItems.push({
label: mcpTool.name,
value: `mcp-${mcpTool.id}`,
iconElement: (
<div className='ml-[20px]'>
{createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon)}
</div>
),
onSelect: () => {
if (alreadySelected) return
const newTool: StoredTool = {
type: 'mcp',
title: mcpTool.name,
toolId: mcpTool.id,
params: {
serverId: mcpTool.serverId,
...(server?.url && { serverUrl: server.url }),
toolName: mcpTool.name,
serverName: mcpTool.serverName,
},
isExpanded: true,
usageControl: 'auto',
schema: {
...mcpTool.inputSchema,
description: mcpTool.description,
},
}
handleMcpToolSelect(newTool, true)
},
disabled: isPreview || disabled || alreadySelected,
})
} }
}), }
}
groups.push({
section: 'MCP Servers',
items: serverItems,
}) })
} }
@@ -1922,6 +2065,8 @@ export const ToolInput = memo(function ToolInput({
customTools, customTools,
availableMcpTools, availableMcpTools,
mcpServers, mcpServers,
mcpToolsByServer,
expandedMcpServers,
toolBlocks, toolBlocks,
isPreview, isPreview,
disabled, disabled,
@@ -1935,8 +2080,10 @@ export const ToolInput = memo(function ToolInput({
getToolIdForOperation, getToolIdForOperation,
isToolAlreadySelected, isToolAlreadySelected,
isMcpToolAlreadySelected, isMcpToolAlreadySelected,
isMcpServerAlreadySelected,
isCustomToolAlreadySelected, isCustomToolAlreadySelected,
isWorkflowAlreadySelected, isWorkflowAlreadySelected,
toggleMcpServerExpanded,
]) ])
const toolRequiresOAuth = (toolId: string): boolean => { const toolRequiresOAuth = (toolId: string): boolean => {
@@ -2363,24 +2510,25 @@ export const ToolInput = memo(function ToolInput({
{/* Selected Tools List */} {/* Selected Tools List */}
{selectedTools.length > 0 && {selectedTools.length > 0 &&
selectedTools.map((tool, toolIndex) => { selectedTools.map((tool, toolIndex) => {
// Handle custom tools, MCP tools, and workflow tools differently // Handle custom tools, MCP tools, MCP servers, and workflow tools differently
const isCustomTool = tool.type === 'custom-tool' const isCustomTool = tool.type === 'custom-tool'
const isMcpTool = tool.type === 'mcp' const isMcpTool = tool.type === 'mcp'
const isMcpServer = tool.type === 'mcp-server'
const isWorkflowTool = tool.type === 'workflow' const isWorkflowTool = tool.type === 'workflow'
const toolBlock = const toolBlock =
!isCustomTool && !isMcpTool !isCustomTool && !isMcpTool && !isMcpServer
? toolBlocks.find((block) => block.type === tool.type) ? toolBlocks.find((block) => block.type === tool.type)
: null : null
// Get the current tool ID (may change based on operation) // Get the current tool ID (may change based on operation)
const currentToolId = const currentToolId =
!isCustomTool && !isMcpTool !isCustomTool && !isMcpTool && !isMcpServer
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || '' ? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || ''
: tool.toolId || '' : tool.toolId || ''
// Get tool parameters using the new utility with block type for UI components // Get tool parameters using the new utility with block type for UI components
const toolParams = const toolParams =
!isCustomTool && !isMcpTool && currentToolId !isCustomTool && !isMcpTool && !isMcpServer && currentToolId
? getToolParametersConfig(currentToolId, tool.type, { ? getToolParametersConfig(currentToolId, tool.type, {
operation: tool.operation, operation: tool.operation,
...tool.params, ...tool.params,
@@ -2449,21 +2597,32 @@ export const ToolInput = memo(function ToolInput({
? customToolParams ? customToolParams
: isMcpTool : isMcpTool
? mcpToolParams ? mcpToolParams
: toolParams?.userInputParameters || [] : isMcpServer
? [] // MCP servers have no user-configurable params
: toolParams?.userInputParameters || []
// Check if tool requires OAuth // Check if tool requires OAuth
const requiresOAuth = const requiresOAuth =
!isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId) !isCustomTool &&
!isMcpTool &&
!isMcpServer &&
currentToolId &&
toolRequiresOAuth(currentToolId)
const oauthConfig = const oauthConfig =
!isCustomTool && !isMcpTool && currentToolId ? getToolOAuthConfig(currentToolId) : null !isCustomTool && !isMcpTool && !isMcpServer && currentToolId
? getToolOAuthConfig(currentToolId)
: null
// Determine if tool has expandable body content // Determine if tool has expandable body content
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type) const hasOperations =
!isCustomTool && !isMcpTool && !isMcpServer && hasMultipleOperations(tool.type)
const filteredDisplayParams = displayParams.filter((param) => const filteredDisplayParams = displayParams.filter((param) =>
evaluateParameterCondition(param, tool) evaluateParameterCondition(param, tool)
) )
const hasToolBody = // MCP servers are expandable to show tool list
hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0 const hasToolBody = isMcpServer
? true
: hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0
// Only show expansion if tool has body content // Only show expansion if tool has body content
const isExpandedForDisplay = hasToolBody const isExpandedForDisplay = hasToolBody
@@ -2472,6 +2631,11 @@ export const ToolInput = memo(function ToolInput({
: !!tool.isExpanded : !!tool.isExpanded
: false : false
// For MCP servers, get the list of tools for display
const mcpServerTools = isMcpServer
? availableMcpTools.filter((t) => t.serverId === tool.params?.serverId)
: []
return ( return (
<div <div
key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`} key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`}
@@ -2508,7 +2672,7 @@ export const ToolInput = memo(function ToolInput({
style={{ style={{
backgroundColor: isCustomTool backgroundColor: isCustomTool
? '#3B82F6' ? '#3B82F6'
: isMcpTool : isMcpTool || isMcpServer
? mcpTool?.bgColor || '#6366F1' ? mcpTool?.bgColor || '#6366F1'
: isWorkflowTool : isWorkflowTool
? '#6366F1' ? '#6366F1'
@@ -2519,6 +2683,8 @@ export const ToolInput = memo(function ToolInput({
<WrenchIcon className='h-[10px] w-[10px] text-white' /> <WrenchIcon className='h-[10px] w-[10px] text-white' />
) : isMcpTool ? ( ) : isMcpTool ? (
<IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' /> <IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' />
) : isMcpServer ? (
<ServerIcon className='h-[10px] w-[10px] text-white' />
) : isWorkflowTool ? ( ) : isWorkflowTool ? (
<IconComponent icon={WorkflowIcon} className='h-[10px] w-[10px] text-white' /> <IconComponent icon={WorkflowIcon} className='h-[10px] w-[10px] text-white' />
) : ( ) : (
@@ -2531,6 +2697,11 @@ export const ToolInput = memo(function ToolInput({
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'> <span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{isCustomTool ? customToolTitle : tool.title} {isCustomTool ? customToolTitle : tool.title}
</span> </span>
{isMcpServer && (
<Badge variant='default' size='sm'>
{tool.params?.toolCount || mcpServerTools.length} tools
</Badge>
)}
{isMcpTool && {isMcpTool &&
!mcpDataLoading && !mcpDataLoading &&
(() => { (() => {
@@ -2636,31 +2807,53 @@ export const ToolInput = memo(function ToolInput({
{!isCustomTool && isExpandedForDisplay && ( {!isCustomTool && isExpandedForDisplay && (
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'> <div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'>
{/* Operation dropdown for tools with multiple operations */} {/* MCP Server tool list (read-only) */}
{(() => { {isMcpServer && mcpServerTools.length > 0 && (
const hasOperations = hasMultipleOperations(tool.type) <div className='flex flex-col gap-[4px]'>
const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] <div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Available tools:
return hasOperations && operationOptions.length > 0 ? (
<div className='relative space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
Operation
</div>
<Combobox
options={operationOptions
.filter((option) => option.id !== '')
.map((option) => ({
label: option.label,
value: option.id,
}))}
value={tool.operation || operationOptions[0].id}
onChange={(value) => handleOperationChange(toolIndex, value)}
placeholder='Select operation'
disabled={disabled}
/>
</div> </div>
) : null <div className='flex flex-wrap gap-[4px]'>
})()} {mcpServerTools.map((serverTool) => (
<Badge
key={serverTool.id}
variant='outline'
size='sm'
className='text-[11px]'
>
{serverTool.name}
</Badge>
))}
</div>
</div>
)}
{/* Operation dropdown for tools with multiple operations */}
{!isMcpServer &&
(() => {
const hasOperations = hasMultipleOperations(tool.type)
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
return hasOperations && operationOptions.length > 0 ? (
<div className='relative space-y-[6px]'>
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
Operation
</div>
<Combobox
options={operationOptions
.filter((option) => option.id !== '')
.map((option) => ({
label: option.label,
value: option.id,
}))}
value={tool.operation || operationOptions[0].id}
onChange={(value) => handleOperationChange(toolIndex, value)}
placeholder='Select operation'
disabled={disabled}
/>
</div>
) : null
})()}
{/* OAuth credential selector if required */} {/* OAuth credential selector if required */}
{requiresOAuth && oauthConfig && ( {requiresOAuth && oauthConfig && (

View File

@@ -9,9 +9,7 @@ import {
ChevronUp, ChevronUp,
ExternalLink, ExternalLink,
Loader2, Loader2,
Lock,
Pencil, Pencil,
Unlock,
} from 'lucide-react' } from 'lucide-react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
@@ -48,7 +46,6 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePanelEditorStore } from '@/stores/panel' import { usePanelEditorStore } from '@/stores/panel'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/** Stable empty object to avoid creating new references */ /** Stable empty object to avoid creating new references */
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any> const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
@@ -113,14 +110,6 @@ export function Editor() {
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
// Check if block is locked (or inside a locked container) and compute edit permission
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
const blocks = useWorkflowStore((state) => state.blocks)
const parentId = currentBlock?.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
const canEditBlock = userPermissions.canEdit && !isLocked
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const { advancedMode, triggerMode } = useEditorBlockProperties( const { advancedMode, triggerMode } = useEditorBlockProperties(
@@ -158,7 +147,9 @@ export function Editor() {
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex), () => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex] [subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
) )
const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent const displayAdvancedOptions = userPermissions.canEdit
? advancedMode
: advancedMode || advancedValuesPresent
const hasAdvancedOnlyFields = useMemo(() => { const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) { for (const subBlock of subBlocksForCanonical) {
@@ -219,13 +210,12 @@ export function Editor() {
collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalMode,
collaborativeUpdateBlockName, collaborativeUpdateBlockName,
collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockAdvancedMode,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
const handleToggleAdvancedMode = useCallback(() => { const handleToggleAdvancedMode = useCallback(() => {
if (!currentBlockId || !canEditBlock) return if (!currentBlockId || !userPermissions.canEdit) return
collaborativeToggleBlockAdvancedMode(currentBlockId) collaborativeToggleBlockAdvancedMode(currentBlockId)
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode]) }, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
const [editedName, setEditedName] = useState('') const [editedName, setEditedName] = useState('')
@@ -243,10 +233,10 @@ export function Editor() {
* Handles starting the rename process. * Handles starting the rename process.
*/ */
const handleStartRename = useCallback(() => { const handleStartRename = useCallback(() => {
if (!canEditBlock || !currentBlock) return if (!userPermissions.canEdit || !currentBlock) return
setEditedName(currentBlock.name || '') setEditedName(currentBlock.name || '')
setIsRenaming(true) setIsRenaming(true)
}, [canEditBlock, currentBlock]) }, [userPermissions.canEdit, currentBlock])
/** /**
* Handles saving the renamed block. * Handles saving the renamed block.
@@ -351,36 +341,6 @@ export function Editor() {
)} )}
</div> </div>
<div className='flex shrink-0 items-center gap-[8px]'> <div className='flex shrink-0 items-center gap-[8px]'>
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
{isLocked && currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
<Button
variant='ghost'
className='p-0'
onClick={() => collaborativeBatchToggleLocked([currentBlockId!])}
aria-label='Unlock block'
>
<Unlock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</Button>
) : (
<div className='flex items-center justify-center'>
<Lock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
</div>
)}
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{isParentLocked
? 'Parent container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Rename button */} {/* Rename button */}
{currentBlock && ( {currentBlock && (
<Tooltip.Root> <Tooltip.Root>
@@ -389,7 +349,7 @@ export function Editor() {
variant='ghost' variant='ghost'
className='p-0' className='p-0'
onClick={isRenaming ? handleSaveRename : handleStartRename} onClick={isRenaming ? handleSaveRename : handleStartRename}
disabled={!canEditBlock} disabled={!userPermissions.canEdit}
aria-label={isRenaming ? 'Save name' : 'Rename block'} aria-label={isRenaming ? 'Save name' : 'Rename block'}
> >
{isRenaming ? ( {isRenaming ? (
@@ -455,7 +415,7 @@ export function Editor() {
incomingConnections={incomingConnections} incomingConnections={incomingConnections}
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown} handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
toggleConnectionsCollapsed={toggleConnectionsCollapsed} toggleConnectionsCollapsed={toggleConnectionsCollapsed}
userCanEdit={canEditBlock} userCanEdit={userPermissions.canEdit}
isConnectionsAtMinHeight={isConnectionsAtMinHeight} isConnectionsAtMinHeight={isConnectionsAtMinHeight}
/> />
) : ( ) : (
@@ -557,14 +517,14 @@ export function Editor() {
config={subBlock} config={subBlock}
isPreview={false} isPreview={false}
subBlockValues={subBlockState} subBlockValues={subBlockState}
disabled={!canEditBlock} disabled={!userPermissions.canEdit}
fieldDiffStatus={undefined} fieldDiffStatus={undefined}
allowExpandInPreview={false} allowExpandInPreview={false}
canonicalToggle={ canonicalToggle={
isCanonicalSwap && canonicalMode && canonicalId isCanonicalSwap && canonicalMode && canonicalId
? { ? {
mode: canonicalMode, mode: canonicalMode,
disabled: !canEditBlock, disabled: !userPermissions.canEdit,
onToggle: () => { onToggle: () => {
if (!currentBlockId) return if (!currentBlockId) return
const nextMode = const nextMode =
@@ -588,7 +548,7 @@ export function Editor() {
) )
})} })}
{hasAdvancedOnlyFields && canEditBlock && ( {hasAdvancedOnlyFields && userPermissions.canEdit && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'> <div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} /> <div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<button <button
@@ -621,7 +581,7 @@ export function Editor() {
config={subBlock} config={subBlock}
isPreview={false} isPreview={false}
subBlockValues={subBlockState} subBlockValues={subBlockState}
disabled={!canEditBlock} disabled={!userPermissions.canEdit}
fieldDiffStatus={undefined} fieldDiffStatus={undefined}
allowExpandInPreview={false} allowExpandInPreview={false}
/> />

View File

@@ -45,13 +45,11 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config' import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useChatStore } from '@/stores/chat/store' import { useChatStore } from '@/stores/chat/store'
import { useNotificationStore } from '@/stores/notifications/store'
import type { PanelTab } from '@/stores/panel' import type { PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel' import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
import { useVariablesStore } from '@/stores/variables/store' import { useVariablesStore } from '@/stores/variables/store'
import { getWorkflowWithValues } from '@/stores/workflows' import { getWorkflowWithValues } from '@/stores/workflows'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Panel') const logger = createLogger('Panel')
/** /**
@@ -121,11 +119,6 @@ export const Panel = memo(function Panel() {
hydration.phase === 'state-loading' hydration.phase === 'state-loading'
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null) const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Check for locked blocks (disables auto-layout)
const hasLockedBlocks = useWorkflowStore((state) =>
Object.values(state.blocks).some((block) => block.locked)
)
// Delete workflow hook // Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({ const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
workspaceId, workspaceId,
@@ -237,24 +230,11 @@ export const Panel = memo(function Panel() {
setIsAutoLayouting(true) setIsAutoLayouting(true)
try { try {
const result = await autoLayoutWithFitView() await autoLayoutWithFitView()
if (!result.success && result.error) {
useNotificationStore.getState().addNotification({
level: 'info',
message: result.error,
workflowId: activeWorkflowId || undefined,
})
}
} finally { } finally {
setIsAutoLayouting(false) setIsAutoLayouting(false)
} }
}, [ }, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView])
isExecuting,
userPermissions.canEdit,
isAutoLayouting,
autoLayoutWithFitView,
activeWorkflowId,
])
/** /**
* Handles exporting workflow as JSON * Handles exporting workflow as JSON
@@ -424,10 +404,7 @@ export const Panel = memo(function Panel() {
<PopoverContent align='start' side='bottom' sideOffset={8}> <PopoverContent align='start' side='bottom' sideOffset={8}>
<PopoverItem <PopoverItem
onClick={handleAutoLayout} onClick={handleAutoLayout}
disabled={ disabled={isExecuting || !userPermissions.canEdit || isAutoLayouting}
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
}
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
> >
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' /> <Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
<span>Auto layout</span> <span>Auto layout</span>

View File

@@ -80,7 +80,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
: undefined : undefined
const isEnabled = currentBlock?.enabled ?? true const isEnabled = currentBlock?.enabled ?? true
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false const isPreview = data?.isPreview || false
// Focus state // Focus state
@@ -201,10 +200,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
{blockName} {blockName}
</span> </span>
</div> </div>
<div className='flex items-center gap-1'> {!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div>
</div> </div>
{!isPreview && ( {!isPreview && (

View File

@@ -24,7 +24,6 @@ import {
Tooltip, Tooltip,
} from '@/components/emcn' } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import { formatDuration } from '@/lib/core/utils/formatting'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { import {
@@ -44,6 +43,7 @@ import {
type EntryNode, type EntryNode,
type ExecutionGroup, type ExecutionGroup,
flattenBlockEntriesOnly, flattenBlockEntriesOnly,
formatDuration,
getBlockColor, getBlockColor,
getBlockIcon, getBlockIcon,
groupEntriesByExecution, groupEntriesByExecution,
@@ -128,7 +128,7 @@ const BlockRow = memo(function BlockRow({
<StatusDisplay <StatusDisplay
isRunning={isRunning} isRunning={isRunning}
isCanceled={isCanceled} isCanceled={isCanceled}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'} formattedDuration={formatDuration(entry.durationMs)}
/> />
</span> </span>
</div> </div>
@@ -201,7 +201,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
<StatusDisplay <StatusDisplay
isRunning={hasRunningChild} isRunning={hasRunningChild}
isCanceled={hasCanceledChild} isCanceled={hasCanceledChild}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'} formattedDuration={formatDuration(entry.durationMs)}
/> />
</span> </span>
</div> </div>
@@ -314,7 +314,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
<StatusDisplay <StatusDisplay
isRunning={hasRunningDescendant} isRunning={hasRunningDescendant}
isCanceled={hasCanceledDescendant} isCanceled={hasCanceledDescendant}
formattedDuration={formatDuration(entry.durationMs, { precision: 2 }) ?? '-'} formattedDuration={formatDuration(entry.durationMs)}
/> />
</span> </span>
</div> </div>

View File

@@ -53,6 +53,17 @@ export function getBlockColor(blockType: string): string {
return '#6b7280' return '#6b7280'
} }
/**
* Formats duration from milliseconds to readable format
*/
export function formatDuration(ms?: number): string {
if (ms === undefined || ms === null) return '-'
if (ms < 1000) {
return `${Math.round(ms)}ms`
}
return `${(ms / 1000).toFixed(2)}s`
}
/** /**
* Determines if a keyboard event originated from a text-editable element * Determines if a keyboard event originated from a text-editable element
*/ */

View File

@@ -30,7 +30,6 @@ import {
Textarea, Textarea,
} from '@/components/emcn' } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence' import { formatEditSequence } from '@/lib/workflows/training/compute-edit-sequence'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
@@ -576,9 +575,7 @@ export function TrainingModal() {
<span className='text-[var(--text-muted)]'>Duration:</span>{' '} <span className='text-[var(--text-muted)]'>Duration:</span>{' '}
<span className='text-[var(--text-secondary)]'> <span className='text-[var(--text-secondary)]'>
{dataset.metadata?.duration {dataset.metadata?.duration
? formatDuration(dataset.metadata.duration, { ? `${(dataset.metadata.duration / 1000).toFixed(1)}s`
precision: 1,
})
: 'N/A'} : 'N/A'}
</span> </span>
</div> </div>

View File

@@ -18,8 +18,6 @@ export interface UseBlockStateReturn {
diffStatus: DiffStatus diffStatus: DiffStatus
/** Whether this is a deleted block in diff mode */ /** Whether this is a deleted block in diff mode */
isDeletedBlock: boolean isDeletedBlock: boolean
/** Whether the block is locked */
isLocked: boolean
} }
/** /**
@@ -42,11 +40,6 @@ export function useBlockState(
? (data.blockState?.enabled ?? true) ? (data.blockState?.enabled ?? true)
: (currentBlock?.enabled ?? true) : (currentBlock?.enabled ?? true)
// Determine if block is locked
const isLocked = data.isPreview
? (data.blockState?.locked ?? false)
: (currentBlock?.locked ?? false)
// Get diff status // Get diff status
const diffStatus: DiffStatus = const diffStatus: DiffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock) currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
@@ -75,6 +68,5 @@ export function useBlockState(
isActive, isActive,
diffStatus, diffStatus,
isDeletedBlock: isDeletedBlock ?? false, isDeletedBlock: isDeletedBlock ?? false,
isLocked,
} }
} }

View File

@@ -672,7 +672,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
currentWorkflow, currentWorkflow,
activeWorkflowId, activeWorkflowId,
isEnabled, isEnabled,
isLocked,
handleClick, handleClick,
hasRing, hasRing,
ringStyles, ringStyles,
@@ -1101,7 +1100,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{name} {name}
</span> </span>
</div> </div>
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'> <div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
{isWorkflowSelector && {isWorkflowSelector &&
childWorkflowId && childWorkflowId &&
typeof childIsDeployed === 'boolean' && typeof childIsDeployed === 'boolean' &&
@@ -1134,7 +1133,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</Tooltip.Root> </Tooltip.Root>
)} )}
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>} {!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && ( {type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
<Tooltip.Root> <Tooltip.Root>

View File

@@ -47,7 +47,6 @@ export function useBlockVisual({
isActive: isExecuting, isActive: isExecuting,
diffStatus, diffStatus,
isDeletedBlock, isDeletedBlock,
isLocked,
} = useBlockState(blockId, currentWorkflow, data) } = useBlockState(blockId, currentWorkflow, data)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId) const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
@@ -104,7 +103,6 @@ export function useBlockVisual({
currentWorkflow, currentWorkflow,
activeWorkflowId, activeWorkflowId,
isEnabled, isEnabled,
isLocked,
handleClick, handleClick,
hasRing, hasRing,
ringStyles, ringStyles,

View File

@@ -31,8 +31,7 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
nodes.map((n) => { nodes.map((n) => {
const block = blocks[n.id] const block = blocks[n.id]
const parentId = block?.data?.parentId const parentId = block?.data?.parentId
const parentBlock = parentId ? blocks[parentId] : undefined const parentType = parentId ? blocks[parentId]?.type : undefined
const parentType = parentBlock?.type
return { return {
id: n.id, id: n.id,
type: block?.type || '', type: block?.type || '',
@@ -40,9 +39,6 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
horizontalHandles: block?.horizontalHandles ?? false, horizontalHandles: block?.horizontalHandles ?? false,
parentId, parentId,
parentType, parentType,
locked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
} }
}), }),
[blocks] [blocks]

View File

@@ -52,16 +52,6 @@ export async function applyAutoLayoutAndUpdateStore(
return { success: false, error: 'No blocks to layout' } return { success: false, error: 'No blocks to layout' }
} }
// Check for locked blocks - auto-layout is disabled when blocks are locked
const hasLockedBlocks = Object.values(blocks).some((block) => block.locked)
if (hasLockedBlocks) {
logger.info('Auto layout skipped: workflow contains locked blocks', { workflowId })
return {
success: false,
error: 'Auto-layout is disabled when blocks are locked. Unlock blocks to use auto-layout.',
}
}
// Merge with default options // Merge with default options
const layoutOptions = { const layoutOptions = {
spacing: { spacing: {

View File

@@ -1,72 +0,0 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Result of filtering protected blocks from a deletion operation
*/
export interface FilterProtectedBlocksResult {
/** Block IDs that can be deleted (not protected) */
deletableIds: string[]
/** Block IDs that are protected and cannot be deleted */
protectedIds: string[]
/** Whether all blocks are protected (deletion should be cancelled entirely) */
allProtected: boolean
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if its parent container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
const block = blocks[blockId]
if (!block) return false
// Block is locked directly
if (block.locked) return true
// Block is inside a locked container
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
/**
* Checks if an edge is protected from modification.
* An edge is protected if either its source or target block is protected.
*
* @param edge - The edge to check (must have source and target)
* @param blocks - Record of all blocks in the workflow
* @returns True if the edge is protected
*/
export function isEdgeProtected(
edge: { source: string; target: string },
blocks: Record<string, BlockState>
): boolean {
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
}
/**
* Filters out protected blocks from a list of block IDs for deletion.
* Protected blocks are those that are locked or inside a locked container.
*
* @param blockIds - Array of block IDs to filter
* @param blocks - Record of all blocks in the workflow
* @returns Result containing deletable IDs, protected IDs, and whether all are protected
*/
export function filterProtectedBlocks(
blockIds: string[],
blocks: Record<string, BlockState>
): FilterProtectedBlocksResult {
const protectedIds = blockIds.filter((id) => isBlockProtected(id, blocks))
const deletableIds = blockIds.filter((id) => !protectedIds.includes(id))
return {
deletableIds,
protectedIds,
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
}
}

View File

@@ -1,5 +1,4 @@
export * from './auto-layout-utils' export * from './auto-layout-utils'
export * from './block-protection-utils'
export * from './block-ring-utils' export * from './block-ring-utils'
export * from './node-position-utils' export * from './node-position-utils'
export * from './workflow-canvas-helpers' export * from './workflow-canvas-helpers'

View File

@@ -55,10 +55,7 @@ import {
clearDragHighlights, clearDragHighlights,
computeClampedPositionUpdates, computeClampedPositionUpdates,
estimateBlockDimensions, estimateBlockDimensions,
filterProtectedBlocks,
getClampedPositionForNode, getClampedPositionForNode,
isBlockProtected,
isEdgeProtected,
isInEditableElement, isInEditableElement,
resolveParentChildSelectionConflicts, resolveParentChildSelectionConflicts,
validateTriggerPaste, validateTriggerPaste,
@@ -546,7 +543,6 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
undo, undo,
redo, redo,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
@@ -1073,27 +1069,8 @@ const WorkflowContent = React.memo(() => {
const handleContextDelete = useCallback(() => { const handleContextDelete = useCallback(() => {
const blockIds = contextMenuBlocks.map((b) => b.id) const blockIds = contextMenuBlocks.map((b) => b.id)
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks) collaborativeBatchRemoveBlocks(blockIds)
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks, addNotification, activeWorkflowId, blocks])
const handleContextToggleEnabled = useCallback(() => { const handleContextToggleEnabled = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id) const blockIds = contextMenuBlocks.map((block) => block.id)
@@ -1105,11 +1082,6 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds) collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
const handleContextToggleLocked = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
collaborativeBatchToggleLocked(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
const handleContextRemoveFromSubflow = useCallback(() => { const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter( const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1979,6 +1951,7 @@ const WorkflowContent = React.memo(() => {
const loadingWorkflowRef = useRef<string | null>(null) const loadingWorkflowRef = useRef<string | null>(null)
const currentWorkflowExists = Boolean(workflows[workflowIdParam]) const currentWorkflowExists = Boolean(workflows[workflowIdParam])
/** Initializes workflow when it exists in registry and needs hydration. */
useEffect(() => { useEffect(() => {
const currentId = workflowIdParam const currentId = workflowIdParam
const currentWorkspaceHydration = hydration.workspaceId const currentWorkspaceHydration = hydration.workspaceId
@@ -2155,7 +2128,6 @@ const WorkflowContent = React.memo(() => {
parentId: block.data?.parentId, parentId: block.data?.parentId,
extent: block.data?.extent || undefined, extent: block.data?.extent || undefined,
dragHandle: '.workflow-drag-handle', dragHandle: '.workflow-drag-handle',
draggable: !isBlockProtected(block.id, blocks),
data: { data: {
...block.data, ...block.data,
name: block.name, name: block.name,
@@ -2191,7 +2163,6 @@ const WorkflowContent = React.memo(() => {
position, position,
parentId: block.data?.parentId, parentId: block.data?.parentId,
dragHandle, dragHandle,
draggable: !isBlockProtected(block.id, blocks),
extent: (() => { extent: (() => {
// Clamp children to subflow body (exclude header) // Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined const parentId = block.data?.parentId as string | undefined
@@ -2520,18 +2491,12 @@ const WorkflowContent = React.memo(() => {
const edgeIdsToRemove = changes const edgeIdsToRemove = changes
.filter((change: any) => change.type === 'remove') .filter((change: any) => change.type === 'remove')
.map((change: any) => change.id) .map((change: any) => change.id)
.filter((edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIdsToRemove.length > 0) { if (edgeIdsToRemove.length > 0) {
collaborativeBatchRemoveEdges(edgeIdsToRemove) collaborativeBatchRemoveEdges(edgeIdsToRemove)
} }
}, },
[collaborativeBatchRemoveEdges, edges, blocks] [collaborativeBatchRemoveEdges]
) )
/** /**
@@ -2593,16 +2558,6 @@ const WorkflowContent = React.memo(() => {
if (!sourceNode || !targetNode) return if (!sourceNode || !targetNode) return
// Prevent connections to/from protected blocks
if (isEdgeProtected(connection, blocks)) {
addNotification({
level: 'info',
message: 'Cannot connect to locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
// Get parent information (handle container start node case) // Get parent information (handle container start node case)
const sourceParentId = const sourceParentId =
blocks[sourceNode.id]?.data?.parentId || blocks[sourceNode.id]?.data?.parentId ||
@@ -2665,7 +2620,7 @@ const WorkflowContent = React.memo(() => {
connectionCompletedRef.current = true connectionCompletedRef.current = true
} }
}, },
[addEdge, getNodes, blocks, addNotification, activeWorkflowId] [addEdge, getNodes, blocks]
) )
/** /**
@@ -2760,9 +2715,6 @@ const WorkflowContent = React.memo(() => {
// Only consider container nodes that aren't the dragged node // Only consider container nodes that aren't the dragged node
if (n.type !== 'subflowNode' || n.id === node.id) return false if (n.type !== 'subflowNode' || n.id === node.id) return false
// Don't allow dropping into locked containers
if (blocks[n.id]?.locked) return false
// Get the container's absolute position // Get the container's absolute position
const containerAbsolutePos = getNodeAbsolutePosition(n.id) const containerAbsolutePos = getNodeAbsolutePosition(n.id)
@@ -2855,11 +2807,6 @@ const WorkflowContent = React.memo(() => {
/** Captures initial parent ID and position when drag starts. */ /** Captures initial parent ID and position when drag starts. */
const onNodeDragStart = useCallback( const onNodeDragStart = useCallback(
(_event: React.MouseEvent, node: any) => { (_event: React.MouseEvent, node: any) => {
// Prevent dragging protected blocks
if (isBlockProtected(node.id, blocks)) {
return
}
// Store the original parent ID when starting to drag // Store the original parent ID when starting to drag
const currentParentId = blocks[node.id]?.data?.parentId || null const currentParentId = blocks[node.id]?.data?.parentId || null
setDragStartParentId(currentParentId) setDragStartParentId(currentParentId)
@@ -2888,7 +2835,7 @@ const WorkflowContent = React.memo(() => {
} }
}) })
}, },
[blocks, setDragStartPosition, getNodes, setPotentialParentId] [blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
) )
/** Handles node drag stop to establish parent-child relationships. */ /** Handles node drag stop to establish parent-child relationships. */
@@ -2950,18 +2897,6 @@ const WorkflowContent = React.memo(() => {
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent // Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
if (potentialParentId === dragStartParentId) return if (potentialParentId === dragStartParentId) return
// Prevent moving locked blocks out of locked containers
// Unlocked blocks (e.g., duplicates) can be moved out freely
if (dragStartParentId && blocks[dragStartParentId]?.locked && blocks[node.id]?.locked) {
addNotification({
level: 'info',
message: 'Cannot move locked blocks out of locked containers',
workflowId: activeWorkflowId || undefined,
})
setPotentialParentId(dragStartParentId) // Reset to original parent
return
}
// Check if this is a starter block - starter blocks should never be in containers // Check if this is a starter block - starter blocks should never be in containers
const isStarterBlock = node.data?.type === 'starter' const isStarterBlock = node.data?.type === 'starter'
if (isStarterBlock) { if (isStarterBlock) {
@@ -3358,16 +3293,6 @@ const WorkflowContent = React.memo(() => {
/** Stable delete handler to avoid creating new function references per edge. */ /** Stable delete handler to avoid creating new function references per edge. */
const handleEdgeDelete = useCallback( const handleEdgeDelete = useCallback(
(edgeId: string) => { (edgeId: string) => {
// Prevent removing edges connected to protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (edge && isEdgeProtected(edge, blocks)) {
addNotification({
level: 'info',
message: 'Cannot remove connections from locked blocks',
workflowId: activeWorkflowId || undefined,
})
return
}
removeEdge(edgeId) removeEdge(edgeId)
// Remove this edge from selection (find by edge ID value) // Remove this edge from selection (find by edge ID value)
setSelectedEdges((prev) => { setSelectedEdges((prev) => {
@@ -3380,7 +3305,7 @@ const WorkflowContent = React.memo(() => {
return next return next
}) })
}, },
[removeEdge, edges, blocks, addNotification, activeWorkflowId] [removeEdge]
) )
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */ /** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
@@ -3421,15 +3346,9 @@ const WorkflowContent = React.memo(() => {
// Handle edge deletion first (edges take priority if selected) // Handle edge deletion first (edges take priority if selected)
if (selectedEdges.size > 0) { if (selectedEdges.size > 0) {
// Get all selected edge IDs and filter out edges connected to protected blocks // Get all selected edge IDs and batch delete them
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => { const edgeIds = Array.from(selectedEdges.values())
const edge = edges.find((e) => e.id === edgeId) collaborativeBatchRemoveEdges(edgeIds)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
})
if (edgeIds.length > 0) {
collaborativeBatchRemoveEdges(edgeIds)
}
setSelectedEdges(new Map()) setSelectedEdges(new Map())
return return
} }
@@ -3446,29 +3365,7 @@ const WorkflowContent = React.memo(() => {
event.preventDefault() event.preventDefault()
const selectedIds = selectedNodes.map((node) => node.id) const selectedIds = selectedNodes.map((node) => node.id)
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks( collaborativeBatchRemoveBlocks(selectedIds)
selectedIds,
blocks
)
if (protectedIds.length > 0) {
if (allProtected) {
addNotification({
level: 'info',
message: 'Cannot delete locked blocks or blocks inside locked containers',
workflowId: activeWorkflowId || undefined,
})
return
}
addNotification({
level: 'info',
message: `Skipped ${protectedIds.length} protected block(s)`,
workflowId: activeWorkflowId || undefined,
})
}
if (deletableIds.length > 0) {
collaborativeBatchRemoveBlocks(deletableIds)
}
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
@@ -3479,10 +3376,6 @@ const WorkflowContent = React.memo(() => {
getNodes, getNodes,
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
effectivePermissions.canEdit, effectivePermissions.canEdit,
blocks,
edges,
addNotification,
activeWorkflowId,
]) ])
return ( return (
@@ -3603,18 +3496,12 @@ const WorkflowContent = React.memo(() => {
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
)} )}
canRunFromBlock={runFromBlockState.canRun} canRunFromBlock={runFromBlockState.canRun}
disableEdit={ disableEdit={!effectivePermissions.canEdit}
!effectivePermissions.canEdit ||
contextMenuBlocks.some((b) => b.locked || b.isParentLocked)
}
userCanEdit={effectivePermissions.canEdit}
isExecuting={isExecuting} isExecuting={isExecuting}
isPositionalTrigger={ isPositionalTrigger={
contextMenuBlocks.length === 1 && contextMenuBlocks.length === 1 &&
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0 edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
} }
onToggleLocked={handleContextToggleLocked}
canAdmin={effectivePermissions.canAdmin}
/> />
<CanvasMenu <CanvasMenu
@@ -3637,7 +3524,6 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit} disableEdit={!effectivePermissions.canEdit}
canUndo={canUndo} canUndo={canUndo}
canRedo={canRedo} canRedo={canRedo}
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
/> />
</> </>
)} )}

View File

@@ -29,6 +29,7 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getUserColor } from '@/lib/workspaces/colors' import { getUserColor } from '@/lib/workspaces/colors'
import { getUserRole } from '@/lib/workspaces/organization' import { getUserRole } from '@/lib/workspaces/organization'
import { getAllBlocks } from '@/blocks' import { getAllBlocks } from '@/blocks'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import { import {
type PermissionGroup, type PermissionGroup,
useBulkAddPermissionGroupMembers, useBulkAddPermissionGroupMembers,
@@ -38,8 +39,7 @@ import {
usePermissionGroups, usePermissionGroups,
useRemovePermissionGroupMember, useRemovePermissionGroupMember,
useUpdatePermissionGroup, useUpdatePermissionGroup,
} from '@/ee/access-control/hooks/permission-groups' } from '@/hooks/queries/permission-groups'
import { useOrganization, useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
import { PROVIDER_DEFINITIONS } from '@/providers/models' import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { getAllProviderIds } from '@/providers/utils' import { getAllProviderIds } from '@/providers/utils'
@@ -255,6 +255,7 @@ export function AccessControl() {
queryEnabled queryEnabled
) )
// Show loading while dependencies load, or while permission groups query is pending
const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading) const isLoading = orgsLoading || subLoading || (queryEnabled && groupsLoading)
const { data: organization } = useOrganization(activeOrganization?.id || '') const { data: organization } = useOrganization(activeOrganization?.id || '')
@@ -409,8 +410,10 @@ export function AccessControl() {
}, [viewingGroup, editingConfig]) }, [viewingGroup, editingConfig])
const allBlocks = useMemo(() => { const allBlocks = useMemo(() => {
// Filter out hidden blocks and start_trigger (which should never be disabled)
const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger') const blocks = getAllBlocks().filter((b) => !b.hideFromToolbar && b.type !== 'start_trigger')
return blocks.sort((a, b) => { return blocks.sort((a, b) => {
// Group by category: triggers first, then blocks, then tools
const categoryOrder = { triggers: 0, blocks: 1, tools: 2 } const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
const catA = categoryOrder[a.category] ?? 3 const catA = categoryOrder[a.category] ?? 3
const catB = categoryOrder[b.category] ?? 3 const catB = categoryOrder[b.category] ?? 3
@@ -552,9 +555,10 @@ export function AccessControl() {
}, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup]) }, [viewingGroup, editingConfig, activeOrganization?.id, updatePermissionGroup])
const handleOpenAddMembersModal = useCallback(() => { const handleOpenAddMembersModal = useCallback(() => {
const existingMemberUserIds = new Set(members.map((m) => m.userId))
setSelectedMemberIds(new Set()) setSelectedMemberIds(new Set())
setShowAddMembersModal(true) setShowAddMembersModal(true)
}, []) }, [members])
const handleAddSelectedMembers = useCallback(async () => { const handleAddSelectedMembers = useCallback(async () => {
if (!viewingGroup || selectedMemberIds.size === 0) return if (!viewingGroup || selectedMemberIds.size === 0) return
@@ -887,6 +891,7 @@ export function AccessControl() {
prev prev
? { ? {
...prev, ...prev,
// When deselecting all, keep start_trigger allowed (it should never be disabled)
allowedIntegrations: allAllowed ? ['start_trigger'] : null, allowedIntegrations: allAllowed ? ['start_trigger'] : null,
} }
: prev : prev

View File

@@ -246,6 +246,7 @@ export function CredentialSets() {
setNewSetDescription('') setNewSetDescription('')
setNewSetProvider('google-email') setNewSetProvider('google-email')
// Open detail view for the newly created group
if (result?.credentialSet) { if (result?.credentialSet) {
setViewingSet(result.credentialSet) setViewingSet(result.credentialSet)
} }
@@ -335,6 +336,7 @@ export function CredentialSets() {
email, email,
}) })
// Start 60s cooldown
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const interval = setInterval(() => { const interval = setInterval(() => {
setResendCooldowns((prev) => { setResendCooldowns((prev) => {
@@ -391,6 +393,7 @@ export function CredentialSets() {
return <GmailIcon className='h-4 w-4' /> return <GmailIcon className='h-4 w-4' />
} }
// All hooks must be called before any early returns
const activeMemberships = useMemo( const activeMemberships = useMemo(
() => memberships.filter((m) => m.status === 'active'), () => memberships.filter((m) => m.status === 'active'),
[memberships] [memberships]
@@ -444,6 +447,7 @@ export function CredentialSets() {
<div className='flex h-full flex-col gap-[16px]'> <div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'> <div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'> <div className='flex flex-col gap-[16px]'>
{/* Group Info */}
<div className='flex items-center gap-[16px]'> <div className='flex items-center gap-[16px]'>
<div className='flex items-center gap-[8px]'> <div className='flex items-center gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'> <span className='font-medium text-[13px] text-[var(--text-primary)]'>
@@ -467,6 +471,7 @@ export function CredentialSets() {
</div> </div>
</div> </div>
{/* Invite Section - Email Tags Input */}
<div className='flex flex-col gap-[4px]'> <div className='flex flex-col gap-[4px]'>
<div className='flex items-center gap-[8px]'> <div className='flex items-center gap-[8px]'>
<TagInput <TagInput
@@ -490,6 +495,7 @@ export function CredentialSets() {
{emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>} {emailError && <p className='text-[12px] text-[var(--text-error)]'>{emailError}</p>}
</div> </div>
{/* Members List - styled like team members */}
<div className='flex flex-col gap-[16px]'> <div className='flex flex-col gap-[16px]'>
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4> <h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Members</h4>
@@ -513,6 +519,7 @@ export function CredentialSets() {
</p> </p>
) : ( ) : (
<div className='flex flex-col gap-[16px]'> <div className='flex flex-col gap-[16px]'>
{/* Active Members */}
{activeMembers.map((member) => { {activeMembers.map((member) => {
const name = member.userName || 'Unknown' const name = member.userName || 'Unknown'
const avatarInitial = name.charAt(0).toUpperCase() const avatarInitial = name.charAt(0).toUpperCase()
@@ -565,6 +572,7 @@ export function CredentialSets() {
) )
})} })}
{/* Pending Invitations */}
{pendingInvitations.map((invitation) => { {pendingInvitations.map((invitation) => {
const email = invitation.email || 'Unknown' const email = invitation.email || 'Unknown'
const emailPrefix = email.split('@')[0] const emailPrefix = email.split('@')[0]
@@ -633,6 +641,7 @@ export function CredentialSets() {
</div> </div>
</div> </div>
{/* Footer Actions */}
<div className='mt-auto flex items-center justify-end'> <div className='mt-auto flex items-center justify-end'>
<Button onClick={handleBackToList} variant='tertiary'> <Button onClick={handleBackToList} variant='tertiary'>
Back Back
@@ -813,6 +822,7 @@ export function CredentialSets() {
</div> </div>
</div> </div>
{/* Create Polling Group Modal */}
<Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}> <Modal open={showCreateModal} onOpenChange={handleCloseCreateModal}>
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Create Polling Group</ModalHeader> <ModalHeader>Create Polling Group</ModalHeader>
@@ -885,6 +895,7 @@ export function CredentialSets() {
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* Leave Confirmation Modal */}
<Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}> <Modal open={!!leavingMembership} onOpenChange={() => setLeavingMembership(null)}>
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Leave Polling Group</ModalHeader> <ModalHeader>Leave Polling Group</ModalHeader>
@@ -912,6 +923,7 @@ export function CredentialSets() {
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* Delete Confirmation Modal */}
<Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}> <Modal open={!!deletingSet} onOpenChange={() => setDeletingSet(null)}>
<ModalContent size='sm'> <ModalContent size='sm'>
<ModalHeader>Delete Polling Group</ModalHeader> <ModalHeader>Delete Polling Group</ModalHeader>

View File

@@ -1,3 +1,4 @@
export { AccessControl } from './access-control/access-control'
export { ApiKeys } from './api-keys/api-keys' export { ApiKeys } from './api-keys/api-keys'
export { BYOK } from './byok/byok' export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot' export { Copilot } from './copilot/copilot'
@@ -9,6 +10,7 @@ export { Files as FileUploads } from './files/files'
export { General } from './general/general' export { General } from './general/general'
export { Integrations } from './integrations/integrations' export { Integrations } from './integrations/integrations'
export { MCP } from './mcp/mcp' export { MCP } from './mcp/mcp'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription' export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management' export { TeamManagement } from './team-management/team-management'
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers' export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'

View File

@@ -407,12 +407,14 @@ export function MCP({ initialServerId }: MCPProps) {
const [urlScrollLeft, setUrlScrollLeft] = useState(0) const [urlScrollLeft, setUrlScrollLeft] = useState(0)
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({}) const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
// Auto-select server when initialServerId is provided
useEffect(() => { useEffect(() => {
if (initialServerId && servers.some((s) => s.id === initialServerId)) { if (initialServerId && servers.some((s) => s.id === initialServerId)) {
setSelectedServerId(initialServerId) setSelectedServerId(initialServerId)
} }
}, [initialServerId, servers]) }, [initialServerId, servers])
// Force refresh tools when entering server detail view to detect stale schemas
useEffect(() => { useEffect(() => {
if (selectedServerId) { if (selectedServerId) {
forceRefreshTools(workspaceId) forceRefreshTools(workspaceId)
@@ -673,7 +675,6 @@ export function MCP({ initialServerId }: MCPProps) {
/** /**
* Opens the detail view for a specific server. * Opens the detail view for a specific server.
* Note: Tool refresh is handled by the useEffect that watches selectedServerId
*/ */
const handleViewDetails = useCallback((serverId: string) => { const handleViewDetails = useCallback((serverId: string) => {
setSelectedServerId(serverId) setSelectedServerId(serverId)
@@ -716,6 +717,7 @@ export function MCP({ initialServerId }: MCPProps) {
`Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}` `Refreshed MCP server: ${serverId}, workflows updated: ${result.workflowsUpdated}`
) )
// If the active workflow was updated, reload its subblock values from DB
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) { if (activeWorkflowId && result.updatedWorkflowIds?.includes(activeWorkflowId)) {
logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`) logger.info(`Active workflow ${activeWorkflowId} was updated, reloading subblock values`)

View File

@@ -11,13 +11,55 @@ import { isBillingEnabled } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { getUserRole } from '@/lib/workspaces/organization/utils' import { getUserRole } from '@/lib/workspaces/organization/utils'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useConfigureSSO, useSSOProviders } from '@/hooks/queries/sso'
import { useSubscriptionData } from '@/hooks/queries/subscription' import { useSubscriptionData } from '@/hooks/queries/subscription'
const logger = createLogger('SSO') const logger = createLogger('SSO')
const TRUSTED_SSO_PROVIDERS = [
'okta',
'okta-saml',
'okta-prod',
'okta-dev',
'okta-staging',
'okta-test',
'azure-ad',
'azure-active-directory',
'azure-corp',
'azure-enterprise',
'adfs',
'adfs-company',
'adfs-corp',
'adfs-enterprise',
'auth0',
'auth0-prod',
'auth0-dev',
'auth0-staging',
'onelogin',
'onelogin-prod',
'onelogin-corp',
'jumpcloud',
'jumpcloud-prod',
'jumpcloud-corp',
'ping-identity',
'ping-federate',
'pingone',
'shibboleth',
'shibboleth-idp',
'google-workspace',
'google-sso',
'saml',
'saml2',
'saml-sso',
'oidc',
'oidc-sso',
'openid-connect',
'custom-sso',
'enterprise-sso',
'company-sso',
]
interface SSOProvider { interface SSOProvider {
id: string id: string
providerId: string providerId: string
@@ -523,7 +565,7 @@ export function SSO() {
<Combobox <Combobox
value={formData.providerId} value={formData.providerId}
onChange={(value: string) => handleInputChange('providerId', value)} onChange={(value: string) => handleInputChange('providerId', value)}
options={SSO_TRUSTED_PROVIDERS.map((id) => ({ options={TRUSTED_SSO_PROVIDERS.map((id) => ({
label: id, label: id,
value: id, value: id,
}))} }))}

View File

@@ -41,6 +41,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags' import { isHosted } from '@/lib/core/config/feature-flags'
import { getUserRole } from '@/lib/workspaces/organization' import { getUserRole } from '@/lib/workspaces/organization'
import { import {
AccessControl,
ApiKeys, ApiKeys,
BYOK, BYOK,
Copilot, Copilot,
@@ -52,18 +53,16 @@ import {
General, General,
Integrations, Integrations,
MCP, MCP,
SSO,
Subscription, Subscription,
TeamManagement, TeamManagement,
WorkflowMcpServers, WorkflowMcpServers,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components' } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile' import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
import { AccessControl } from '@/ee/access-control/components/access-control'
import { SSO } from '@/ee/sso/components/sso-settings'
import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso'
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings' import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization' import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
import { usePermissionConfig } from '@/hooks/use-permission-config' import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsModalStore } from '@/stores/modals/settings/store' import { useSettingsModalStore } from '@/stores/modals/settings/store'
@@ -205,13 +204,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general') const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore() const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null) const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const [isSuperUser, setIsSuperUser] = useState(false)
const { data: session } = useSession() const { data: session } = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations() const { data: organizationsData } = useOrganizations()
const { data: generalSettings } = useGeneralSettings() const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders() const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
const activeOrganization = organizationsData?.activeOrganization const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig() const { config: permissionConfig } = usePermissionConfig()
@@ -230,7 +229,22 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const hasEnterprisePlan = subscriptionStatus.isEnterprise const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id const hasOrganization = !!activeOrganization?.id
const isSuperUser = superUserData?.isSuperUser ?? false // Fetch superuser status
useEffect(() => {
const fetchSuperUserStatus = async () => {
if (!userId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser)
}
} catch {
setIsSuperUser(false)
}
}
fetchSuperUserStatus()
}, [userId])
// Memoize SSO provider ownership check // Memoize SSO provider ownership check
const isSSOProviderOwner = useMemo(() => { const isSSOProviderOwner = useMemo(() => {
@@ -314,13 +328,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
generalSettings?.superUserModeEnabled, generalSettings?.superUserModeEnabled,
]) ])
const effectiveActiveSection = useMemo(() => { // Memoized callbacks to prevent infinite loops in child components
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
return 'general'
}
return activeSection
}, [activeSection])
const registerEnvironmentBeforeLeaveHandler = useCallback( const registerEnvironmentBeforeLeaveHandler = useCallback(
(handler: (onProceed: () => void) => void) => { (handler: (onProceed: () => void) => void) => {
environmentBeforeLeaveHandler.current = handler environmentBeforeLeaveHandler.current = handler
@@ -334,18 +342,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const handleSectionChange = useCallback( const handleSectionChange = useCallback(
(sectionId: SettingsSection) => { (sectionId: SettingsSection) => {
if (sectionId === effectiveActiveSection) return if (sectionId === activeSection) return
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) { if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId)) environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
return return
} }
setActiveSection(sectionId) setActiveSection(sectionId)
}, },
[effectiveActiveSection] [activeSection]
) )
// Apply initial section from store when modal opens
useEffect(() => { useEffect(() => {
if (open && initialSection) { if (open && initialSection) {
setActiveSection(initialSection) setActiveSection(initialSection)
@@ -356,6 +365,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
} }
}, [open, initialSection, mcpServerId, clearInitialState]) }, [open, initialSection, mcpServerId, clearInitialState])
// Clear pending server ID when section changes away from MCP
useEffect(() => { useEffect(() => {
if (activeSection !== 'mcp') { if (activeSection !== 'mcp') {
setPendingMcpServerId(null) setPendingMcpServerId(null)
@@ -381,6 +391,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
} }
}, [onOpenChange]) }, [onOpenChange])
// Redirect away from billing tabs if billing is disabled
useEffect(() => {
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
setActiveSection('general')
}
}, [activeSection])
// Prefetch functions for React Query
const prefetchGeneral = () => { const prefetchGeneral = () => {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
queryKey: generalSettingsKeys.settings(), queryKey: generalSettingsKeys.settings(),
@@ -471,17 +489,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
// Handle dialog close - delegate to environment component if it's active // Handle dialog close - delegate to environment component if it's active
const handleDialogOpenChange = (newOpen: boolean) => { const handleDialogOpenChange = (newOpen: boolean) => {
if ( if (!newOpen && activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
!newOpen &&
effectiveActiveSection === 'environment' &&
environmentBeforeLeaveHandler.current
) {
environmentBeforeLeaveHandler.current(() => onOpenChange(false)) environmentBeforeLeaveHandler.current(() => onOpenChange(false))
} else if ( } else if (!newOpen && activeSection === 'integrations' && integrationsCloseHandler.current) {
!newOpen &&
effectiveActiveSection === 'integrations' &&
integrationsCloseHandler.current
) {
integrationsCloseHandler.current(newOpen) integrationsCloseHandler.current(newOpen)
} else { } else {
onOpenChange(newOpen) onOpenChange(newOpen)
@@ -512,7 +522,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{sectionItems.map((item) => ( {sectionItems.map((item) => (
<SModalSidebarItem <SModalSidebarItem
key={item.id} key={item.id}
active={effectiveActiveSection === item.id} active={activeSection === item.id}
icon={<item.icon />} icon={<item.icon />}
onMouseEnter={() => handlePrefetch(item.id)} onMouseEnter={() => handlePrefetch(item.id)}
onClick={() => handleSectionChange(item.id)} onClick={() => handleSectionChange(item.id)}
@@ -528,36 +538,35 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<SModalMain> <SModalMain>
<SModalMainHeader> <SModalMainHeader>
{navigationItems.find((item) => item.id === effectiveActiveSection)?.label || {navigationItems.find((item) => item.id === activeSection)?.label || activeSection}
effectiveActiveSection}
</SModalMainHeader> </SModalMainHeader>
<SModalMainBody> <SModalMainBody>
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />} {activeSection === 'general' && <General onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'environment' && ( {activeSection === 'environment' && (
<EnvironmentVariables <EnvironmentVariables
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler} registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
/> />
)} )}
{effectiveActiveSection === 'template-profile' && <TemplateProfile />} {activeSection === 'template-profile' && <TemplateProfile />}
{effectiveActiveSection === 'integrations' && ( {activeSection === 'integrations' && (
<Integrations <Integrations
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
registerCloseHandler={registerIntegrationsCloseHandler} registerCloseHandler={registerIntegrationsCloseHandler}
/> />
)} )}
{effectiveActiveSection === 'credential-sets' && <CredentialSets />} {activeSection === 'credential-sets' && <CredentialSets />}
{effectiveActiveSection === 'access-control' && <AccessControl />} {activeSection === 'access-control' && <AccessControl />}
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />} {activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
{effectiveActiveSection === 'files' && <FileUploads />} {activeSection === 'files' && <FileUploads />}
{isBillingEnabled && effectiveActiveSection === 'subscription' && <Subscription />} {isBillingEnabled && activeSection === 'subscription' && <Subscription />}
{isBillingEnabled && effectiveActiveSection === 'team' && <TeamManagement />} {isBillingEnabled && activeSection === 'team' && <TeamManagement />}
{effectiveActiveSection === 'sso' && <SSO />} {activeSection === 'sso' && <SSO />}
{effectiveActiveSection === 'byok' && <BYOK />} {activeSection === 'byok' && <BYOK />}
{effectiveActiveSection === 'copilot' && <Copilot />} {activeSection === 'copilot' && <Copilot />}
{effectiveActiveSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />} {activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{effectiveActiveSection === 'custom-tools' && <CustomTools />} {activeSection === 'custom-tools' && <CustomTools />}
{effectiveActiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />} {activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{effectiveActiveSection === 'debug' && <Debug />} {activeSection === 'debug' && <Debug />}
</SModalMainBody> </SModalMainBody>
</SModalMain> </SModalMain>
</SModalContent> </SModalContent>

View File

@@ -231,8 +231,6 @@ export function FolderItem({
const isFolderSelected = store.selectedFolders.has(folder.id) const isFolderSelected = store.selectedFolders.has(folder.id)
if (!isFolderSelected) { if (!isFolderSelected) {
// Replace selection with just this folder (Finder/Explorer pattern)
store.clearAllSelection()
store.selectFolder(folder.id) store.selectFolder(folder.id)
} }

View File

@@ -189,9 +189,6 @@ export function WorkflowItem({
const isCurrentlySelected = store.selectedWorkflows.has(workflow.id) const isCurrentlySelected = store.selectedWorkflows.has(workflow.id)
if (!isCurrentlySelected) { if (!isCurrentlySelected) {
// Replace selection with just this item (Finder/Explorer pattern)
// This clears both workflow and folder selections
store.clearAllSelection()
store.selectWorkflow(workflow.id) store.selectWorkflow(workflow.id)
} }

View File

@@ -19,7 +19,6 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption' import { decryptSecret } from '@/lib/core/security/encryption'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
import { sendEmail } from '@/lib/messaging/email/mailer' import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -228,6 +227,12 @@ async function deliverWebhook(
} }
} }
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}m`
}
function formatCost(cost?: Record<string, unknown>): string { function formatCost(cost?: Record<string, unknown>): string {
if (!cost?.total) return 'N/A' if (!cost?.total) return 'N/A'
const total = cost.total as number const total = cost.total as number
@@ -297,7 +302,7 @@ async function deliverEmail(
workflowName: payload.data.workflowName || 'Unknown Workflow', workflowName: payload.data.workflowName || 'Unknown Workflow',
status: payload.data.status, status: payload.data.status,
trigger: payload.data.trigger, trigger: payload.data.trigger,
duration: formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-', duration: formatDuration(payload.data.totalDurationMs),
cost: formatCost(payload.data.cost), cost: formatCost(payload.data.cost),
logUrl, logUrl,
alertReason, alertReason,
@@ -310,7 +315,7 @@ async function deliverEmail(
to: subscription.emailRecipients, to: subscription.emailRecipients,
subject, subject,
html, html,
text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`, text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`,
emailType: 'notifications', emailType: 'notifications',
}) })
@@ -368,10 +373,7 @@ async function deliverSlack(
fields: [ fields: [
{ type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` }, { type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` },
{ type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` }, { type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` },
{ { type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` },
type: 'mrkdwn',
text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs, { precision: 1 }) ?? '-'}`,
},
{ type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` }, { type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` },
], ],
}, },

View File

@@ -458,8 +458,8 @@ export function getCodeEditorProps(options?: {
'caret-[var(--text-primary)] dark:caret-white', 'caret-[var(--text-primary)] dark:caret-white',
// Font smoothing // Font smoothing
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]', '[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
// Disable interaction for streaming/preview/disabled // Disable interaction for streaming/preview
(isStreaming || isPreview || disabled) && 'pointer-events-none' (isStreaming || isPreview) && 'pointer-events-none'
), ),
} }
} }

View File

@@ -52,6 +52,8 @@ export type ComboboxOption = {
onSelect?: () => void onSelect?: () => void
/** Whether this option is disabled */ /** Whether this option is disabled */
disabled?: boolean disabled?: boolean
/** When true, keep the dropdown open after selecting this option */
keepOpen?: boolean
} }
/** /**
@@ -252,13 +254,15 @@ const Combobox = memo(
* Handles selection of an option * Handles selection of an option
*/ */
const handleSelect = useCallback( const handleSelect = useCallback(
(selectedValue: string, customOnSelect?: () => void) => { (selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => {
// If option has custom onSelect, use it instead // If option has custom onSelect, use it instead
if (customOnSelect) { if (customOnSelect) {
customOnSelect() customOnSelect()
setOpen(false) if (!keepOpen) {
setHighlightedIndex(-1) setOpen(false)
setSearchQuery('') setHighlightedIndex(-1)
setSearchQuery('')
}
return return
} }
@@ -270,11 +274,13 @@ const Combobox = memo(
onMultiSelectChange(newValues) onMultiSelectChange(newValues)
} else { } else {
onChange?.(selectedValue) onChange?.(selectedValue)
setOpen(false) if (!keepOpen) {
setHighlightedIndex(-1) setOpen(false)
setSearchQuery('') setHighlightedIndex(-1)
if (editable && inputRef.current) { setSearchQuery('')
inputRef.current.blur() if (editable && inputRef.current) {
inputRef.current.blur()
}
} }
} }
}, },
@@ -343,7 +349,7 @@ const Combobox = memo(
e.preventDefault() e.preventDefault()
const selectedOption = filteredOptions[highlightedIndex] const selectedOption = filteredOptions[highlightedIndex]
if (selectedOption && !selectedOption.disabled) { if (selectedOption && !selectedOption.disabled) {
handleSelect(selectedOption.value, selectedOption.onSelect) handleSelect(selectedOption.value, selectedOption.onSelect, selectedOption.keepOpen)
} }
} else if (!editable) { } else if (!editable) {
e.preventDefault() e.preventDefault()
@@ -668,7 +674,7 @@ const Combobox = memo(
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (!option.disabled) { if (!option.disabled) {
handleSelect(option.value, option.onSelect) handleSelect(option.value, option.onSelect, option.keepOpen)
} }
}} }}
onMouseEnter={() => onMouseEnter={() =>
@@ -743,7 +749,7 @@ const Combobox = memo(
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (!option.disabled) { if (!option.disabled) {
handleSelect(option.value, option.onSelect) handleSelect(option.value, option.onSelect, option.keepOpen)
} }
}} }}
onMouseEnter={() => !option.disabled && setHighlightedIndex(index)} onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}

View File

@@ -260,9 +260,6 @@ const Popover: React.FC<PopoverProps> = ({
setIsKeyboardNav(false) setIsKeyboardNav(false)
setSelectedIndex(-1) setSelectedIndex(-1)
registeredItemsRef.current = [] registeredItemsRef.current = []
} else {
// Reset hover state when opening to prevent stale submenu from previous menu
setLastHoveredItem(null)
} }
}, [open]) }, [open])

View File

@@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types' import type { ToolCallGroup, ToolCallState } from '@/lib/copilot/types'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
interface ToolCallProps { interface ToolCallProps {
toolCall: ToolCallState toolCall: ToolCallState
@@ -226,6 +225,11 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
const isError = toolCall.state === 'error' const isError = toolCall.state === 'error'
const isAborted = toolCall.state === 'aborted' const isAborted = toolCall.state === 'aborted'
const formatDuration = (duration?: number) => {
if (!duration) return ''
return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s`
}
return ( return (
<div <div
className={cn( className={cn(
@@ -275,7 +279,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
)} )}
style={{ fontSize: '0.625rem' }} style={{ fontSize: '0.625rem' }}
> >
{toolCall.duration ? formatDuration(toolCall.duration, { precision: 1 }) : ''} {formatDuration(toolCall.duration)}
</Badge> </Badge>
)} )}
</div> </div>

View File

@@ -1,43 +0,0 @@
Sim Enterprise License
Copyright (c) 2025-present Sim Studio, Inc.
This software and associated documentation files (the "Software") are licensed
under the following terms:
1. LICENSE GRANT
Subject to the terms of this license, Sim Studio, Inc. grants you a limited,
non-exclusive, non-transferable license to use the Software for:
- Development, testing, and evaluation purposes
- Internal non-production use
Production use of the Software requires a valid Sim Enterprise subscription.
2. RESTRICTIONS
You may not:
- Use the Software in production without a valid Enterprise subscription
- Modify, adapt, or create derivative works of the Software
- Redistribute, sublicense, or transfer the Software
- Remove or alter any proprietary notices in the Software
3. ENTERPRISE SUBSCRIPTION
Production deployment of enterprise features requires an active Sim Enterprise
subscription. Contact sales@simstudio.ai for licensing information.
4. DISCLAIMER
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
5. LIMITATION OF LIABILITY
IN NO EVENT SHALL SIM STUDIO, INC. BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
For questions about enterprise licensing, contact: sales@simstudio.ai

View File

@@ -1,21 +0,0 @@
# Sim Enterprise Edition
This directory contains enterprise features that require a Sim Enterprise subscription
for production use.
## Features
- **SSO (Single Sign-On)**: OIDC and SAML authentication integration
- **Access Control**: Permission groups for fine-grained user access management
- **Credential Sets**: Shared credential pools for email polling workflows
## Licensing
See [LICENSE](./LICENSE) for terms. Development and testing use is permitted.
Production deployment requires an active Enterprise subscription.
## Architecture
Enterprise features are imported directly throughout the codebase. The `ee/` directory
is required at build time. Feature visibility is controlled at runtime via environment
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`).

View File

@@ -5,7 +5,6 @@ import {
hydrateUserFilesWithBase64, hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server' } from '@/lib/uploads/utils/user-file-base64.server'
import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize' import { sanitizeInputFormat, sanitizeTools } from '@/lib/workflows/comparison/normalize'
import { validateBlockType } from '@/ee/access-control/utils/permission-check'
import { import {
BlockType, BlockType,
buildResumeApiUrl, buildResumeApiUrl,
@@ -32,6 +31,7 @@ import { streamingResponseFormatProcessor } from '@/executor/utils'
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors' import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
import { isJSONString } from '@/executor/utils/json' import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter' import { filterOutputForLog } from '@/executor/utils/output-filter'
import { validateBlockType } from '@/executor/utils/permission-check'
import type { VariableResolver } from '@/executor/variables/resolver' import type { VariableResolver } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
import type { SubflowType } from '@/stores/workflows/workflow/types' import type { SubflowType } from '@/stores/workflows/workflow/types'

View File

@@ -6,12 +6,6 @@ import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks' import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/ee/access-control/utils/permission-check'
import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants' import { AGENT, BlockType, DEFAULTS, REFERENCE, stripCustomToolPrefix } from '@/executor/constants'
import { memoryService } from '@/executor/handlers/agent/memory' import { memoryService } from '@/executor/handlers/agent/memory'
import type { import type {
@@ -24,6 +18,12 @@ import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/execu
import { collectBlockData } from '@/executor/utils/block-data' import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json' import { stringifyJSON } from '@/executor/utils/json'
import {
validateBlockType,
validateCustomToolsAllowed,
validateMcpToolsAllowed,
validateModelProvider,
} from '@/executor/utils/permission-check'
import { executeProviderRequest } from '@/providers' import { executeProviderRequest } from '@/providers'
import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import { getProviderFromModel, transformBlockTool } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
@@ -143,7 +143,7 @@ export class AgentBlockHandler implements BlockHandler {
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> { private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
if (!Array.isArray(tools) || tools.length === 0) return if (!Array.isArray(tools) || tools.length === 0) return
const hasMcpTools = tools.some((t) => t.type === 'mcp') const hasMcpTools = tools.some((t) => t.type === 'mcp' || t.type === 'mcp-server')
const hasCustomTools = tools.some((t) => t.type === 'custom-tool') const hasCustomTools = tools.some((t) => t.type === 'custom-tool')
if (hasMcpTools) { if (hasMcpTools) {
@@ -161,7 +161,7 @@ export class AgentBlockHandler implements BlockHandler {
): Promise<ToolInput[]> { ): Promise<ToolInput[]> {
if (!Array.isArray(tools) || tools.length === 0) return tools if (!Array.isArray(tools) || tools.length === 0) return tools
const mcpTools = tools.filter((t) => t.type === 'mcp') const mcpTools = tools.filter((t) => t.type === 'mcp' || t.type === 'mcp-server')
if (mcpTools.length === 0) return tools if (mcpTools.length === 0) return tools
const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))] const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))]
@@ -195,7 +195,7 @@ export class AgentBlockHandler implements BlockHandler {
} }
return tools.filter((tool) => { return tools.filter((tool) => {
if (tool.type !== 'mcp') return true if (tool.type !== 'mcp' && tool.type !== 'mcp-server') return true
const serverId = tool.params?.serverId const serverId = tool.params?.serverId
if (!serverId) return false if (!serverId) return false
return availableServerIds.has(serverId) return availableServerIds.has(serverId)
@@ -211,11 +211,14 @@ export class AgentBlockHandler implements BlockHandler {
}) })
const mcpTools: ToolInput[] = [] const mcpTools: ToolInput[] = []
const mcpServers: ToolInput[] = []
const otherTools: ToolInput[] = [] const otherTools: ToolInput[] = []
for (const tool of filtered) { for (const tool of filtered) {
if (tool.type === 'mcp') { if (tool.type === 'mcp') {
mcpTools.push(tool) mcpTools.push(tool)
} else if (tool.type === 'mcp-server') {
mcpServers.push(tool)
} else { } else {
otherTools.push(tool) otherTools.push(tool)
} }
@@ -224,7 +227,12 @@ export class AgentBlockHandler implements BlockHandler {
const otherResults = await Promise.all( const otherResults = await Promise.all(
otherTools.map(async (tool) => { otherTools.map(async (tool) => {
try { try {
if (tool.type && tool.type !== 'custom-tool' && tool.type !== 'mcp') { if (
tool.type &&
tool.type !== 'custom-tool' &&
tool.type !== 'mcp' &&
tool.type !== 'mcp-server'
) {
await validateBlockType(ctx.userId, tool.type, ctx) await validateBlockType(ctx.userId, tool.type, ctx)
} }
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) { if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
@@ -240,12 +248,133 @@ export class AgentBlockHandler implements BlockHandler {
const mcpResults = await this.processMcpToolsBatched(ctx, mcpTools) const mcpResults = await this.processMcpToolsBatched(ctx, mcpTools)
const allTools = [...otherResults, ...mcpResults] // Process MCP servers (all tools from server mode)
const mcpServerResults = await this.processMcpServerSelections(ctx, mcpServers)
const allTools = [...otherResults, ...mcpResults, ...mcpServerResults]
return allTools.filter( return allTools.filter(
(tool): tool is NonNullable<typeof tool> => tool !== null && tool !== undefined (tool): tool is NonNullable<typeof tool> => tool !== null && tool !== undefined
) )
} }
/**
* Process MCP server selections by discovering and formatting all tools from each server.
* This enables "agent discovery" mode where the LLM can call any tool from the server.
*/
private async processMcpServerSelections(
ctx: ExecutionContext,
mcpServerSelections: ToolInput[]
): Promise<any[]> {
if (mcpServerSelections.length === 0) return []
const results: any[] = []
for (const serverSelection of mcpServerSelections) {
const serverId = serverSelection.params?.serverId
const serverName = serverSelection.params?.serverName
const usageControl = serverSelection.usageControl || 'auto'
if (!serverId) {
logger.error('MCP server selection missing serverId:', serverSelection)
continue
}
try {
// Discover all tools from this server
const discoveredTools = await this.discoverMcpToolsForServer(ctx, serverId)
// Create tool definitions for each discovered tool
for (const mcpTool of discoveredTools) {
const created = await this.createMcpToolFromDiscoveredServerTool(
ctx,
mcpTool,
serverId,
serverName || serverId,
usageControl
)
if (created) results.push(created)
}
logger.info(
`[AgentHandler] Expanded MCP server ${serverName} into ${discoveredTools.length} tools`
)
} catch (error) {
logger.error(`[AgentHandler] Failed to process MCP server selection:`, { serverId, error })
}
}
return results
}
/**
* Create an MCP tool from server discovery for the "all tools" mode.
*/
private async createMcpToolFromDiscoveredServerTool(
ctx: ExecutionContext,
mcpTool: any,
serverId: string,
serverName: string,
usageControl: string
): Promise<any> {
const toolName = mcpTool.name
const { filterSchemaForLLM } = await import('@/tools/params')
const filteredSchema = filterSchemaForLLM(
mcpTool.inputSchema || { type: 'object', properties: {} },
{}
)
const toolId = createMcpToolId(serverId, toolName)
return {
id: toolId,
name: toolName,
description: mcpTool.description || `MCP tool ${toolName} from ${serverName}`,
parameters: filteredSchema,
params: {},
usageControl,
executeFunction: async (callParams: Record<string, any>) => {
const headers = await buildAuthHeaders()
const execUrl = buildAPIUrl('/api/mcp/tools/execute')
const execResponse = await fetch(execUrl.toString(), {
method: 'POST',
headers,
body: stringifyJSON({
serverId,
toolName,
arguments: callParams,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
toolSchema: mcpTool.inputSchema,
}),
})
if (!execResponse.ok) {
throw new Error(
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
)
}
const result = await execResponse.json()
if (!result.success) {
throw new Error(result.error || 'MCP tool execution failed')
}
return {
success: true,
output: result.data.output || {},
metadata: {
source: 'mcp-server',
serverId,
serverName,
toolName,
},
}
},
}
}
private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> { private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
const userProvidedParams = tool.params || {} const userProvidedParams = tool.params || {}

View File

@@ -29,11 +29,36 @@ export interface AgentInputs {
verbosity?: string verbosity?: string
} }
/**
* Represents a tool input for the agent block.
*
* @remarks
* Valid types include:
* - Standard block types (e.g., 'api', 'search', 'function')
* - 'custom-tool': User-defined tools with custom code
* - 'mcp': Individual MCP tool from a connected server
* - 'mcp-server': All tools from an MCP server (agent discovery mode).
* At execution time, this is expanded into individual tool definitions
* for all tools available on the server. This enables dynamic capability
* discovery where the LLM can call any tool from the server.
*/
export interface ToolInput { export interface ToolInput {
/**
* Tool type identifier.
* 'mcp-server' enables server-level selection where all tools from
* the server are made available to the LLM at execution time.
*/
type?: string type?: string
schema?: any schema?: any
title?: string title?: string
code?: string code?: string
/**
* Tool parameters. For 'mcp-server' type, includes:
* - serverId: The MCP server ID
* - serverUrl: The server URL (optional)
* - serverName: Human-readable server name
* - toolCount: Number of tools available (for display)
*/
params?: Record<string, any> params?: Record<string, any>
timeout?: number timeout?: number
usageControl?: 'auto' | 'force' | 'none' usageControl?: 'auto' | 'force' | 'none'

View File

@@ -4,11 +4,11 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants' import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils' import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'

View File

@@ -6,7 +6,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router' import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types' import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { import {
BlockType, BlockType,
DEFAULTS, DEFAULTS,
@@ -16,6 +15,7 @@ import {
} from '@/executor/constants' } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types' import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAuthHeaders } from '@/executor/utils/http' import { buildAuthHeaders } from '@/executor/utils/http'
import { validateModelProvider } from '@/executor/utils/permission-check'
import { calculateCost, getProviderFromModel } from '@/providers/utils' import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'

View File

@@ -212,11 +212,11 @@ export class WorkflowBlockHandler implements BlockHandler {
/** /**
* Parses a potentially nested workflow error message to extract: * Parses a potentially nested workflow error message to extract:
* - The chain of workflow names * - The chain of workflow names
* - The actual root error message (preserving the block name prefix for the failing block) * - The actual root error message (preserving the block prefix for the failing block)
* *
* Handles formats like: * Handles formats like:
* - "workflow-name" failed: error * - "workflow-name" failed: error
* - Block Name: "workflow-name" failed: error * - [block_type] Block Name: "workflow-name" failed: error
* - Workflow chain: A → B | error * - Workflow chain: A → B | error
*/ */
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } { private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
@@ -234,8 +234,8 @@ export class WorkflowBlockHandler implements BlockHandler {
// Extract workflow names from patterns like: // Extract workflow names from patterns like:
// - "workflow-name" failed: // - "workflow-name" failed:
// - Block Name: "workflow-name" failed: // - [block_type] Block Name: "workflow-name" failed:
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
let match: RegExpExecArray | null let match: RegExpExecArray | null
let lastIndex = 0 let lastIndex = 0
@@ -247,7 +247,7 @@ export class WorkflowBlockHandler implements BlockHandler {
} }
// The root error is everything after the last match // The root error is everything after the last match
// Keep the block name prefix (e.g., Function 1:) so we know which block failed // Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
return { chain, rootError: rootError.trim() || 'Unknown error' } return { chain, rootError: rootError.trim() || 'Unknown error' }

View File

@@ -47,7 +47,7 @@ export function buildBlockExecutionError(details: BlockExecutionErrorDetails): E
const blockName = details.block.metadata?.name || details.block.id const blockName = details.block.metadata?.name || details.block.id
const blockType = details.block.metadata?.id || 'unknown' const blockType = details.block.metadata?.id || 'unknown'
const error = new Error(`${blockName}: ${errorMessage}`) const error = new Error(`[${blockType}] ${blockName}: ${errorMessage}`)
Object.assign(error, { Object.assign(error, {
blockId: details.block.id, blockId: details.block.id,

View File

@@ -1,5 +1,3 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchJson } from '@/hooks/selectors/helpers' import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,5 +1,3 @@
'use client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { fetchJson } from '@/hooks/selectors/helpers' import { fetchJson } from '@/hooks/selectors/helpers'

View File

@@ -1,5 +1,3 @@
'use client'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { organizationKeys } from '@/hooks/queries/organization' import { organizationKeys } from '@/hooks/queries/organization'
@@ -77,3 +75,39 @@ export function useConfigureSSO() {
}, },
}) })
} }
/**
* Delete SSO provider mutation
*/
interface DeleteSSOParams {
providerId: string
orgId?: string
}
export function useDeleteSSO() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ providerId }: DeleteSSOParams) => {
const response = await fetch(`/api/auth/sso/providers/${providerId}`, {
method: 'DELETE',
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to delete SSO provider')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
if (variables.orgId) {
queryClient.invalidateQueries({
queryKey: organizationKeys.detail(variables.orgId),
})
}
},
})
}

View File

@@ -9,7 +9,6 @@ const logger = createLogger('UserProfileQuery')
export const userProfileKeys = { export const userProfileKeys = {
all: ['userProfile'] as const, all: ['userProfile'] as const,
profile: () => [...userProfileKeys.all, 'profile'] as const, profile: () => [...userProfileKeys.all, 'profile'] as const,
superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const,
} }
/** /**
@@ -110,37 +109,3 @@ export function useUpdateUserProfile() {
}, },
}) })
} }
/**
* Superuser status response type
*/
interface SuperUserStatus {
isSuperUser: boolean
}
/**
* Fetch superuser status from API
*/
async function fetchSuperUserStatus(): Promise<SuperUserStatus> {
const response = await fetch('/api/user/super-user')
if (!response.ok) {
return { isSuperUser: false }
}
const data = await response.json()
return { isSuperUser: data.isSuperUser ?? false }
}
/**
* Hook to fetch superuser status
* @param userId - User ID for cache isolation (required for proper per-user caching)
*/
export function useSuperUserStatus(userId?: string) {
return useQuery({
queryKey: userProfileKeys.superUser(userId),
queryFn: fetchSuperUserStatus,
enabled: Boolean(userId),
staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes
})
}

View File

@@ -409,20 +409,6 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-toggle-handles from remote user') logger.info('Successfully applied batch-toggle-handles from remote user')
break break
} }
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const { blockIds } = payload
logger.info('Received batch-toggle-locked from remote user', {
userId,
count: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0) {
useWorkflowStore.getState().batchToggleLocked(blockIds)
}
logger.info('Successfully applied batch-toggle-locked from remote user')
break
}
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: { case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
const { updates } = payload const { updates } = payload
logger.info('Received batch-update-parent from remote user', { logger.info('Received batch-update-parent from remote user', {
@@ -837,27 +823,14 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {} const previousStates: Record<string, boolean> = {}
const validIds: string[] = [] const validIds: string[] = []
// For each ID, collect non-locked blocks and their children for undo/redo
for (const id of ids) { for (const id of ids) {
const block = currentBlocks[id] const block = useWorkflowStore.getState().blocks[id]
if (!block) continue if (block) {
previousStates[id] = block.enabled
// Skip locked blocks validIds.push(id)
if (block.locked) continue
validIds.push(id)
previousStates[id] = block.enabled
// If it's a loop or parallel, also capture children's previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
previousStates[blockId] = b.enabled
}
})
} }
} }
@@ -1019,25 +992,12 @@ export function useCollaborativeWorkflow() {
if (ids.length === 0) return if (ids.length === 0) return
const blocks = useWorkflowStore.getState().blocks
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocks[blockId]
if (!block) return false
if (block.locked) return true
const parentId = block.data?.parentId
if (parentId && blocks[parentId]?.locked) return true
return false
}
const previousStates: Record<string, boolean> = {} const previousStates: Record<string, boolean> = {}
const validIds: string[] = [] const validIds: string[] = []
for (const id of ids) { for (const id of ids) {
const block = blocks[id] const block = useWorkflowStore.getState().blocks[id]
// Skip locked blocks and blocks inside locked containers if (block) {
if (block && !isProtected(id)) {
previousStates[id] = block.horizontalHandles ?? false previousStates[id] = block.horizontalHandles ?? false
validIds.push(id) validIds.push(id)
} }
@@ -1065,58 +1025,6 @@ export function useCollaborativeWorkflow() {
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo] [isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
) )
const collaborativeBatchToggleLocked = useCallback(
(ids: string[]) => {
if (isBaselineDiffView) {
return
}
if (ids.length === 0) return
const currentBlocks = useWorkflowStore.getState().blocks
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
// For each ID, collect blocks and their children for undo/redo
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
validIds.push(id)
previousStates[id] = block.locked ?? false
// If it's a loop or parallel, also capture children's previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
previousStates[blockId] = b.locked ?? false
}
})
}
}
if (validIds.length === 0) return
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds: validIds, previousStates },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
useWorkflowStore.getState().batchToggleLocked(validIds)
undoRedo.recordBatchToggleLocked(validIds, previousStates)
},
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
)
const collaborativeBatchAddEdges = useCallback( const collaborativeBatchAddEdges = useCallback(
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => { (edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
if (isBaselineDiffView) { if (isBaselineDiffView) {
@@ -1761,7 +1669,6 @@ export function useCollaborativeWorkflow() {
collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockAdvancedMode,
collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalMode,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
collaborativeBatchAddBlocks, collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
collaborativeBatchAddEdges, collaborativeBatchAddEdges,

View File

@@ -1,5 +1,3 @@
'use client'
import { useMemo } from 'react' import { useMemo } from 'react'
import { getEnv, isTruthy } from '@/lib/core/config/env' import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
@@ -7,8 +5,8 @@ import {
DEFAULT_PERMISSION_GROUP_CONFIG, DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig, type PermissionGroupConfig,
} from '@/lib/permission-groups/types' } from '@/lib/permission-groups/types'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
import { useOrganizations } from '@/hooks/queries/organization' import { useOrganizations } from '@/hooks/queries/organization'
import { useUserPermissionConfig } from '@/hooks/queries/permission-groups'
export interface PermissionConfigResult { export interface PermissionConfigResult {
config: PermissionGroupConfig config: PermissionGroupConfig

View File

@@ -20,7 +20,6 @@ import {
type BatchRemoveEdgesOperation, type BatchRemoveEdgesOperation,
type BatchToggleEnabledOperation, type BatchToggleEnabledOperation,
type BatchToggleHandlesOperation, type BatchToggleHandlesOperation,
type BatchToggleLockedOperation,
type BatchUpdateParentOperation, type BatchUpdateParentOperation,
captureLatestEdges, captureLatestEdges,
captureLatestSubBlockValues, captureLatestSubBlockValues,
@@ -416,36 +415,6 @@ export function useUndoRedo() {
[activeWorkflowId, userId] [activeWorkflowId, userId]
) )
const recordBatchToggleLocked = useCallback(
(blockIds: string[], previousStates: Record<string, boolean>) => {
if (!activeWorkflowId || blockIds.length === 0) return
const operation: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const inverse: BatchToggleLockedOperation = {
id: crypto.randomUUID(),
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
timestamp: Date.now(),
workflowId: activeWorkflowId,
userId,
data: { blockIds, previousStates },
}
const entry = createOperationEntry(operation, inverse)
useUndoRedoStore.getState().push(activeWorkflowId, userId, entry)
logger.debug('Recorded batch toggle locked', { blockIds, previousStates })
},
[activeWorkflowId, userId]
)
const undo = useCallback(async () => { const undo = useCallback(async () => {
if (!activeWorkflowId) return if (!activeWorkflowId) return
@@ -808,9 +777,7 @@ export function useUndoRedo() {
const toggleOp = entry.inverse as BatchToggleEnabledOperation const toggleOp = entry.inverse as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data const { blockIds, previousStates } = toggleOp.data
// Restore all blocks in previousStates (includes children of containers) const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) { if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist') logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
break break
@@ -821,14 +788,14 @@ export function useUndoRedo() {
operation: { operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS, target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates }, payload: { blockIds: validBlockIds, previousStates },
}, },
workflowId: activeWorkflowId, workflowId: activeWorkflowId,
userId, userId,
}) })
// Use setBlockEnabled to directly restore to previous state // Use setBlockEnabled to directly restore to previous state
// This restores all affected blocks including children of containers // This is more robust than conditional toggle in collaborative scenarios
validBlockIds.forEach((blockId) => { validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId]) useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
}) })
@@ -862,36 +829,6 @@ export function useUndoRedo() {
}) })
break break
} }
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.inverse as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Restore all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Undo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Use setBlockLocked to directly restore to previous state
// This restores all affected blocks including children of containers
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, previousStates[blockId])
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: { case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
const applyDiffInverse = entry.inverse as any const applyDiffInverse = entry.inverse as any
const { baselineSnapshot } = applyDiffInverse.data const { baselineSnapshot } = applyDiffInverse.data
@@ -1428,9 +1365,7 @@ export function useUndoRedo() {
const toggleOp = entry.operation as BatchToggleEnabledOperation const toggleOp = entry.operation as BatchToggleEnabledOperation
const { blockIds, previousStates } = toggleOp.data const { blockIds, previousStates } = toggleOp.data
// Process all blocks in previousStates (includes children of containers) const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) { if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist') logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
break break
@@ -1441,18 +1376,16 @@ export function useUndoRedo() {
operation: { operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
target: OPERATION_TARGETS.BLOCKS, target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates }, payload: { blockIds: validBlockIds, previousStates },
}, },
workflowId: activeWorkflowId, workflowId: activeWorkflowId,
userId, userId,
}) })
// Compute target state the same way batchToggleEnabled does: // Use setBlockEnabled to directly set to toggled state
// use !firstBlock.enabled, where firstBlock is blockIds[0] // Redo sets to !previousStates (the state after the original toggle)
const firstBlockId = blockIds[0]
const targetEnabled = !previousStates[firstBlockId]
validBlockIds.forEach((blockId) => { validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockEnabled(blockId, targetEnabled) useWorkflowStore.getState().setBlockEnabled(blockId, !previousStates[blockId])
}) })
break break
} }
@@ -1484,38 +1417,6 @@ export function useUndoRedo() {
}) })
break break
} }
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const toggleOp = entry.operation as BatchToggleLockedOperation
const { blockIds, previousStates } = toggleOp.data
// Process all blocks in previousStates (includes children of containers)
const allBlockIds = Object.keys(previousStates)
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
if (validBlockIds.length === 0) {
logger.debug('Redo batch-toggle-locked skipped; no blocks exist')
break
}
addToQueue({
id: opId,
operation: {
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, previousStates },
},
workflowId: activeWorkflowId,
userId,
})
// Compute target state the same way batchToggleLocked does:
// use !firstBlock.locked, where firstBlock is blockIds[0]
const firstBlockId = blockIds[0]
const targetLocked = !previousStates[firstBlockId]
validBlockIds.forEach((blockId) => {
useWorkflowStore.getState().setBlockLocked(blockId, targetLocked)
})
break
}
case UNDO_REDO_OPERATIONS.APPLY_DIFF: { case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
// Redo apply-diff means re-applying the proposed state with diff markers // Redo apply-diff means re-applying the proposed state with diff markers
const applyDiffOp = entry.operation as any const applyDiffOp = entry.operation as any
@@ -1837,7 +1738,6 @@ export function useUndoRedo() {
recordBatchUpdateParent, recordBatchUpdateParent,
recordBatchToggleEnabled, recordBatchToggleEnabled,
recordBatchToggleHandles, recordBatchToggleHandles,
recordBatchToggleLocked,
recordApplyDiff, recordApplyDiff,
recordAcceptDiff, recordAcceptDiff,
recordRejectDiff, recordRejectDiff,

View File

@@ -59,8 +59,8 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation' import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
const logger = createLogger('Auth') const logger = createLogger('Auth')

View File

@@ -1,7 +1,3 @@
/**
* List of trusted SSO provider identifiers.
* Used for validation and autocomplete in SSO configuration.
*/
export const SSO_TRUSTED_PROVIDERS = [ export const SSO_TRUSTED_PROVIDERS = [
'okta', 'okta',
'okta-saml', 'okta-saml',

View File

@@ -5,8 +5,8 @@ import { and, eq, isNull } from 'drizzle-orm'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { isHiddenFromDisplay } from '@/blocks/types' import { isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants' import { escapeRegExp } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import type { ChatContext } from '@/stores/panel/copilot/types' import type { ChatContext } from '@/stores/panel/copilot/types'
export type AgentContextType = export type AgentContextType =

View File

@@ -7,7 +7,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types' import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models' import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry' import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers' import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -6,7 +6,7 @@ import {
type GetBlockOptionsResultType, type GetBlockOptionsResultType,
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { tools as toolsRegistry } from '@/tools/registry' import { tools as toolsRegistry } from '@/tools/registry'
export const getBlockOptionsServerTool: BaseServerTool< export const getBlockOptionsServerTool: BaseServerTool<

View File

@@ -6,7 +6,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry' import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const getBlocksAndToolsServerTool: BaseServerTool< export const getBlocksAndToolsServerTool: BaseServerTool<
ReturnType<typeof GetBlocksAndToolsInput.parse>, ReturnType<typeof GetBlocksAndToolsInput.parse>,

View File

@@ -8,7 +8,7 @@ import {
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { registry as blockRegistry } from '@/blocks/registry' import { registry as blockRegistry } from '@/blocks/registry'
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models' import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry' import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers' import { getTrigger, isTriggerValid } from '@/triggers'

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { registry as blockRegistry } from '@/blocks/registry' import { registry as blockRegistry } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types' import type { BlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { getUserPermissionConfig } from '@/executor/utils/permission-check'
export const GetTriggerBlocksInput = z.object({}) export const GetTriggerBlocksInput = z.object({})
export const GetTriggerBlocksResult = z.object({ export const GetTriggerBlocksResult = z.object({

View File

@@ -15,8 +15,8 @@ import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry' import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { EDGE, normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -54,7 +54,6 @@ type SkippedItemType =
| 'block_not_found' | 'block_not_found'
| 'invalid_block_type' | 'invalid_block_type'
| 'block_not_allowed' | 'block_not_allowed'
| 'block_locked'
| 'tool_not_allowed' | 'tool_not_allowed'
| 'invalid_edge_target' | 'invalid_edge_target'
| 'invalid_edge_source' | 'invalid_edge_source'
@@ -619,7 +618,6 @@ function createBlockFromParams(
subBlocks: {}, subBlocks: {},
outputs: outputs, outputs: outputs,
data: parentId ? { parentId, extent: 'parent' as const } : {}, data: parentId ? { parentId, extent: 'parent' as const } : {},
locked: false,
} }
// Add validated inputs as subBlocks // Add validated inputs as subBlocks
@@ -1522,24 +1520,6 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if block is locked or inside a locked container
const deleteBlock = modifiedState.blocks[block_id]
const deleteParentId = deleteBlock.data?.parentId as string | undefined
const deleteParentLocked = deleteParentId
? modifiedState.blocks[deleteParentId]?.locked
: false
if (deleteBlock.locked || deleteParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'delete',
blockId: block_id,
reason: deleteParentLocked
? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted`
: `Block "${block_id}" is locked and cannot be deleted`,
})
break
}
// Find all child blocks to remove // Find all child blocks to remove
const blocksToRemove = new Set<string>([block_id]) const blocksToRemove = new Set<string>([block_id])
const findChildren = (parentId: string) => { const findChildren = (parentId: string) => {
@@ -1575,21 +1555,6 @@ function applyOperationsToWorkflowState(
const block = modifiedState.blocks[block_id] const block = modifiedState.blocks[block_id]
// Check if block is locked or inside a locked container
const editParentId = block.data?.parentId as string | undefined
const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false
if (block.locked || editParentLocked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'edit',
blockId: block_id,
reason: editParentLocked
? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited`
: `Block "${block_id}" is locked and cannot be edited`,
})
break
}
// Ensure block has essential properties // Ensure block has essential properties
if (!block.type) { if (!block.type) {
logger.warn(`Block ${block_id} missing type property, skipping edit`, { logger.warn(`Block ${block_id} missing type property, skipping edit`, {
@@ -2157,19 +2122,6 @@ function applyOperationsToWorkflowState(
// Handle nested nodes (for loops/parallels created from scratch) // Handle nested nodes (for loops/parallels created from scratch)
if (params.nestedNodes) { if (params.nestedNodes) {
// Defensive check: verify parent is not locked before adding children
// (Parent was just created with locked: false, but check for consistency)
const parentBlock = modifiedState.blocks[block_id]
if (parentBlock?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'add_nested_nodes',
blockId: block_id,
reason: `Container "${block_id}" is locked - cannot add nested nodes`,
})
break
}
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
// Validate childId is a valid string // Validate childId is a valid string
if (!isValidKey(childId)) { if (!isValidKey(childId)) {
@@ -2257,18 +2209,6 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if subflow is locked
if (subflowBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`,
details: { subflowId },
})
break
}
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') { if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
logger.error('Subflow block has invalid type', { logger.error('Subflow block has invalid type', {
subflowId, subflowId,
@@ -2307,17 +2247,6 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if existing block is locked
if (existingBlock.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be moved into a subflow`,
})
break
}
// Moving existing block into subflow - just update parent // Moving existing block into subflow - just update parent
existingBlock.data = { existingBlock.data = {
...existingBlock.data, ...existingBlock.data,
@@ -2463,30 +2392,6 @@ function applyOperationsToWorkflowState(
break break
} }
// Check if block is locked
if (block.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Block "${block_id}" is locked and cannot be extracted from subflow`,
})
break
}
// Check if parent subflow is locked
const parentSubflow = modifiedState.blocks[subflowId]
if (parentSubflow?.locked) {
logSkippedItem(skippedItems, {
type: 'block_locked',
operationType: 'extract_from_subflow',
blockId: block_id,
reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`,
details: { subflowId },
})
break
}
// Verify it's actually a child of this subflow // Verify it's actually a child of this subflow
if (block.data?.parentId !== subflowId) { if (block.data?.parentId !== subflowId) {
logger.warn('Block is not a child of specified subflow', { logger.warn('Block is not a child of specified subflow', {

View File

@@ -153,50 +153,22 @@ export function formatCompactTimestamp(iso: string): string {
} }
/** /**
* Format a duration to a human-readable format * Format a duration in milliseconds to a human-readable format
* @param duration - Duration in milliseconds (number) or as string (e.g., "500ms") * @param durationMs - The duration in milliseconds
* @param options - Optional formatting options * @param options - Optional formatting options
* @param options.precision - Number of decimal places for seconds (default: 0), trailing zeros are stripped * @param options.precision - Number of decimal places for seconds (default: 0)
* @returns A formatted duration string, or null if input is null/undefined * @returns A formatted duration string
*/ */
export function formatDuration( export function formatDuration(durationMs: number, options?: { precision?: number }): string {
duration: number | string | undefined | null,
options?: { precision?: number }
): string | null {
if (duration === undefined || duration === null) {
return null
}
// Parse string durations (e.g., "500ms", "0.44ms", "1234")
let ms: number
if (typeof duration === 'string') {
ms = Number.parseFloat(duration.replace(/[^0-9.-]/g, ''))
if (!Number.isFinite(ms)) {
return duration
}
} else {
ms = duration
}
const precision = options?.precision ?? 0 const precision = options?.precision ?? 0
if (ms < 1) { if (durationMs < 1000) {
// Sub-millisecond: show with 2 decimal places return `${durationMs}ms`
return `${ms.toFixed(2)}ms`
} }
if (ms < 1000) { const seconds = durationMs / 1000
// Milliseconds: round to integer
return `${Math.round(ms)}ms`
}
const seconds = ms / 1000
if (seconds < 60) { if (seconds < 60) {
if (precision > 0) { return precision > 0 ? `${seconds.toFixed(precision)}s` : `${Math.floor(seconds)}s`
// Strip trailing zeros (e.g., "5.00s" -> "5s", "5.10s" -> "5.1s")
return `${seconds.toFixed(precision).replace(/\.?0+$/, '')}s`
}
return `${Math.floor(seconds)}s`
} }
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60)

View File

@@ -296,26 +296,6 @@ describe('hasWorkflowChanged', () => {
}) })
expect(hasWorkflowChanged(state1, state2)).toBe(true) expect(hasWorkflowChanged(state1, state2)).toBe(true)
}) })
it.concurrent('should detect locked/unlocked changes', () => {
const state1 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: false }) },
})
const state2 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
expect(hasWorkflowChanged(state1, state2)).toBe(true)
})
it.concurrent('should not detect changes when locked state is the same', () => {
const state1 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
const state2 = createWorkflowState({
blocks: { block1: createBlock('block1', { locked: true }) },
})
expect(hasWorkflowChanged(state1, state2)).toBe(false)
})
}) })
describe('SubBlock Changes', () => { describe('SubBlock Changes', () => {

View File

@@ -157,7 +157,7 @@ export function generateWorkflowDiffSummary(
} }
// Check other block properties (boolean fields) // Check other block properties (boolean fields)
// Use !! to normalize: null/undefined/false are all equivalent (falsy) // Use !! to normalize: null/undefined/false are all equivalent (falsy)
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const
for (const field of blockFields) { for (const field of blockFields) {
if (!!currentBlock[field] !== !!previousBlock[field]) { if (!!currentBlock[field] !== !!previousBlock[field]) {
changes.push({ changes.push({

View File

@@ -100,7 +100,6 @@ function buildStartBlockState(
triggerMode: false, triggerMode: false,
height: 0, height: 0,
data: {}, data: {},
locked: false,
} }
return { blockState, subBlockValues } return { blockState, subBlockValues }

View File

@@ -1,173 +0,0 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
// Mock all external dependencies before imports
vi.mock('@sim/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}))
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: {
getState: () => ({
getWorkflowState: () => ({ blocks: {}, edges: [], loops: {}, parallels: {} }),
}),
},
}))
vi.mock('@/stores/workflows/utils', () => ({
mergeSubblockState: (blocks: Record<string, BlockState>) => blocks,
}))
vi.mock('@/lib/workflows/sanitization/key-validation', () => ({
isValidKey: (key: string) => key !== 'undefined' && key !== 'null' && key !== '',
}))
vi.mock('@/lib/workflows/autolayout', () => ({
transferBlockHeights: vi.fn(),
applyTargetedLayout: (blocks: Record<string, BlockState>) => blocks,
applyAutoLayout: () => ({ success: true, blocks: {} }),
}))
vi.mock('@/lib/workflows/autolayout/constants', () => ({
DEFAULT_HORIZONTAL_SPACING: 500,
DEFAULT_VERTICAL_SPACING: 400,
DEFAULT_LAYOUT_OPTIONS: {},
}))
vi.mock('@/stores/workflows/workflow/utils', () => ({
generateLoopBlocks: () => ({}),
generateParallelBlocks: () => ({}),
}))
import { WorkflowDiffEngine } from './diff-engine'
function createMockBlock(overrides: Partial<BlockState> = {}): BlockState {
return {
id: 'block-1',
type: 'agent',
name: 'Test Block',
enabled: true,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
...overrides,
} as BlockState
}
function createMockWorkflowState(blocks: Record<string, BlockState>): WorkflowState {
return {
blocks,
edges: [],
loops: {},
parallels: {},
}
}
describe('WorkflowDiffEngine', () => {
let engine: WorkflowDiffEngine
beforeEach(() => {
engine = new WorkflowDiffEngine()
vi.clearAllMocks()
})
describe('hasBlockChanged detection', () => {
describe('locked state changes', () => {
it.concurrent(
'should detect when block locked state changes from false to true',
async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: false }),
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(
proposed,
undefined,
baseline
)
expect(result.success).toBe(true)
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
}
)
it.concurrent('should not detect change when locked state is the same', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
})
it.concurrent('should detect change when locked goes from undefined to true', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: true }),
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
// The hasBlockChanged function uses !!locked for comparison
// so undefined -> true should be detected as a change
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
})
it.concurrent('should not detect change when both locked states are falsy', async () => {
const freshEngine = new WorkflowDiffEngine()
const baseline = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
})
const proposed = createMockWorkflowState({
'block-1': createMockBlock({ id: 'block-1', locked: false }), // locked false
})
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
expect(result.success).toBe(true)
// undefined and false should both be falsy, so !! comparison makes them equal
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
})
})
})
describe('diff lifecycle', () => {
it.concurrent('should start with no diff', () => {
const freshEngine = new WorkflowDiffEngine()
expect(freshEngine.hasDiff()).toBe(false)
expect(freshEngine.getCurrentDiff()).toBeUndefined()
})
it.concurrent('should clear diff', () => {
const freshEngine = new WorkflowDiffEngine()
freshEngine.clearDiff()
expect(freshEngine.hasDiff()).toBe(false)
})
})
})

View File

@@ -215,7 +215,6 @@ function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): b
if (currentBlock.name !== proposedBlock.name) return true if (currentBlock.name !== proposedBlock.name) return true
if (currentBlock.enabled !== proposedBlock.enabled) return true if (currentBlock.enabled !== proposedBlock.enabled) return true
if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true
if (!!currentBlock.locked !== !!proposedBlock.locked) return true
// Compare subBlocks // Compare subBlocks
const currentSubKeys = Object.keys(currentBlock.subBlocks || {}) const currentSubKeys = Object.keys(currentBlock.subBlocks || {})

View File

@@ -189,7 +189,6 @@ export async function duplicateWorkflow(
parentId: newParentId, parentId: newParentId,
extent: newExtent, extent: newExtent,
data: updatedData, data: updatedData,
locked: false, // Duplicated blocks should always be unlocked
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }

View File

@@ -226,7 +226,6 @@ export async function loadWorkflowFromNormalizedTables(
subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, subBlocks: (block.subBlocks as BlockState['subBlocks']) || {},
outputs: (block.outputs as BlockState['outputs']) || {}, outputs: (block.outputs as BlockState['outputs']) || {},
data: blockData, data: blockData,
locked: block.locked,
} }
blocksMap[block.id] = assembled blocksMap[block.id] = assembled
@@ -364,7 +363,6 @@ export async function saveWorkflowToNormalizedTables(
data: block.data || {}, data: block.data || {},
parentId: block.data?.parentId || null, parentId: block.data?.parentId || null,
extent: block.data?.extent || null, extent: block.data?.extent || null,
locked: block.locked ?? false,
})) }))
await tx.insert(workflowBlocks).values(blockInserts) await tx.insert(workflowBlocks).values(blockInserts)
@@ -629,8 +627,7 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
// Regenerate blocks with updated references // Regenerate blocks with updated references
Object.entries(state.blocks || {}).forEach(([oldId, block]) => { Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
const newId = blockIdMapping.get(oldId)! const newId = blockIdMapping.get(oldId)!
// Duplicated blocks are always unlocked so users can edit them const newBlock: BlockState = { ...block, id: newId }
const newBlock: BlockState = { ...block, id: newId, locked: false }
// Update parentId reference if it exists // Update parentId reference if it exists
if (newBlock.data?.parentId) { if (newBlock.data?.parentId) {

View File

@@ -17,7 +17,6 @@ export const BLOCKS_OPERATIONS = {
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_UPDATE_PARENT: 'batch-update-parent', BATCH_UPDATE_PARENT: 'batch-update-parent',
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
} as const } as const
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS] export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
@@ -86,7 +85,6 @@ export const UNDO_REDO_OPERATIONS = {
BATCH_UPDATE_PARENT: 'batch-update-parent', BATCH_UPDATE_PARENT: 'batch-update-parent',
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
APPLY_DIFF: 'apply-diff', APPLY_DIFF: 'apply-diff',
ACCEPT_DIFF: 'accept-diff', ACCEPT_DIFF: 'accept-diff',
REJECT_DIFF: 'reject-diff', REJECT_DIFF: 'reject-diff',

View File

@@ -507,37 +507,7 @@ async function handleBlocksOperationTx(
}) })
if (blocks && blocks.length > 0) { if (blocks && blocks.length > 0) {
// Fetch existing blocks to check for locked parents const blockValues = blocks.map((block: Record<string, unknown>) => {
const existingBlocks = await tx
.select({ id: workflowBlocks.id, locked: workflowBlocks.locked })
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type ExistingBlockRecord = (typeof existingBlocks)[number]
const lockedParentIds = new Set(
existingBlocks
.filter((b: ExistingBlockRecord) => b.locked)
.map((b: ExistingBlockRecord) => b.id)
)
// Filter out blocks being added to locked parents
const allowedBlocks = (blocks as Array<Record<string, unknown>>).filter((block) => {
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && lockedParentIds.has(parentId)) {
logger.info(`Skipping block ${block.id} - parent ${parentId} is locked`)
return false
}
return true
})
if (allowedBlocks.length === 0) {
logger.info('All blocks filtered out due to locked parents, skipping add')
break
}
const blockValues = allowedBlocks.map((block: Record<string, unknown>) => {
const blockId = block.id as string const blockId = block.id as string
const mergedSubBlocks = mergeSubBlockValues( const mergedSubBlocks = mergeSubBlockValues(
block.subBlocks as Record<string, unknown>, block.subBlocks as Record<string, unknown>,
@@ -559,7 +529,6 @@ async function handleBlocksOperationTx(
advancedMode: (block.advancedMode as boolean) ?? false, advancedMode: (block.advancedMode as boolean) ?? false,
triggerMode: (block.triggerMode as boolean) ?? false, triggerMode: (block.triggerMode as boolean) ?? false,
height: (block.height as number) || 0, height: (block.height as number) || 0,
locked: (block.locked as boolean) ?? false,
} }
}) })
@@ -568,7 +537,7 @@ async function handleBlocksOperationTx(
// Create subflow entries for loop/parallel blocks (skip if already in payload) // Create subflow entries for loop/parallel blocks (skip if already in payload)
const loopIds = new Set(loops ? Object.keys(loops) : []) const loopIds = new Set(loops ? Object.keys(loops) : [])
const parallelIds = new Set(parallels ? Object.keys(parallels) : []) const parallelIds = new Set(parallels ? Object.keys(parallels) : [])
for (const block of allowedBlocks) { for (const block of blocks) {
const blockId = block.id as string const blockId = block.id as string
if (block.type === 'loop' && !loopIds.has(blockId)) { if (block.type === 'loop' && !loopIds.has(blockId)) {
await tx.insert(workflowSubflows).values({ await tx.insert(workflowSubflows).values({
@@ -597,7 +566,7 @@ async function handleBlocksOperationTx(
// Update parent subflow node lists // Update parent subflow node lists
const parentIds = new Set<string>() const parentIds = new Set<string>()
for (const block of allowedBlocks) { for (const block of blocks) {
const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined
if (parentId) { if (parentId) {
parentIds.add(parentId) parentIds.add(parentId)
@@ -655,74 +624,44 @@ async function handleBlocksOperationTx(
logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`) logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`)
// Fetch all blocks to check lock status and filter out protected blocks
const allBlocks = await tx
.select({
id: workflowBlocks.id,
type: workflowBlocks.type,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type BlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
allBlocks.map((b: BlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
// Filter out protected blocks from deletion request
const deletableIds = ids.filter((id) => !isProtected(id))
if (deletableIds.length === 0) {
logger.info('All requested blocks are protected, skipping deletion')
return
}
if (deletableIds.length < ids.length) {
logger.info(
`Filtered out ${ids.length - deletableIds.length} protected blocks from deletion`
)
}
// Collect all block IDs including children of subflows // Collect all block IDs including children of subflows
const allBlocksToDelete = new Set<string>(deletableIds) const allBlocksToDelete = new Set<string>(ids)
for (const id of deletableIds) { for (const id of ids) {
const block = blocksById[id] const blockToRemove = await tx
if (block && isSubflowBlockType(block.type)) { .select({ type: workflowBlocks.type })
// Include all children of the subflow (they should be deleted with parent) .from(workflowBlocks)
for (const b of allBlocks) { .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
const parentId = (b.data as Record<string, unknown> | null)?.parentId .limit(1)
if (parentId === id) {
allBlocksToDelete.add(b.id) if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) {
} const childBlocks = await tx
} .select({ id: workflowBlocks.id })
.from(workflowBlocks)
.where(
and(
eq(workflowBlocks.workflowId, workflowId),
sql`${workflowBlocks.data}->>'parentId' = ${id}`
)
)
childBlocks.forEach((child: { id: string }) => allBlocksToDelete.add(child.id))
} }
} }
const blockIdsArray = Array.from(allBlocksToDelete) const blockIdsArray = Array.from(allBlocksToDelete)
// Collect parent IDs BEFORE deleting blocks (use blocksById, already fetched) // Collect parent IDs BEFORE deleting blocks
const parentIds = new Set<string>() const parentIds = new Set<string>()
for (const id of deletableIds) { for (const id of ids) {
const block = blocksById[id] const parentInfo = await tx
const parentId = (block?.data as Record<string, unknown> | null)?.parentId as .select({ parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'` })
| string .from(workflowBlocks)
| undefined .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
if (parentId) { .limit(1)
parentIds.add(parentId)
if (parentInfo.length > 0 && parentInfo[0].parentId) {
parentIds.add(parentInfo[0].parentId)
} }
} }
@@ -802,61 +741,22 @@ async function handleBlocksOperationTx(
`Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}` `Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}`
) )
// Get all blocks in workflow to find children and check locked state const blocks = await tx
const allBlocks = await tx .select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled })
.select({
id: workflowBlocks.id,
enabled: workflowBlocks.enabled,
locked: workflowBlocks.locked,
type: workflowBlocks.type,
data: workflowBlocks.data,
})
.from(workflowBlocks) .from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId)) .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds)))
type BlockRecord = (typeof allBlocks)[number] for (const block of blocks) {
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
allBlocks.map((b: BlockRecord) => [b.id, b])
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block || block.locked) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id && !b.locked) {
blocksToToggle.add(b.id)
}
}
}
}
// Determine target enabled state based on first toggleable block
if (blocksToToggle.size === 0) break
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = blocksById[firstToggleableId]
if (!firstBlock) break
const targetEnabled = !firstBlock.enabled
// Update all affected blocks
for (const blockId of blocksToToggle) {
await tx await tx
.update(workflowBlocks) .update(workflowBlocks)
.set({ .set({
enabled: targetEnabled, enabled: !block.enabled,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId)))
} }
logger.debug(`Batch toggled enabled state for ${blocksToToggle.size} blocks`) logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`)
break break
} }
@@ -868,118 +768,22 @@ async function handleBlocksOperationTx(
logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`) logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`)
// Fetch all blocks to check lock status and filter out protected blocks const blocks = await tx
const allBlocks = await tx .select({ id: workflowBlocks.id, horizontalHandles: workflowBlocks.horizontalHandles })
.select({
id: workflowBlocks.id,
horizontalHandles: workflowBlocks.horizontalHandles,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks) .from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId)) .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds)))
type HandleBlockRecord = (typeof allBlocks)[number] for (const block of blocks) {
const blocksById: Record<string, HandleBlockRecord> = Object.fromEntries(
allBlocks.map((b: HandleBlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (parentId && blocksById[parentId]?.locked) return true
return false
}
// Filter to only toggle handles on unprotected blocks
const blocksToToggle = blockIds.filter((id) => blocksById[id] && !isProtected(id))
if (blocksToToggle.length === 0) {
logger.info('All requested blocks are protected, skipping handles toggle')
break
}
for (const blockId of blocksToToggle) {
const block = blocksById[blockId]
await tx await tx
.update(workflowBlocks) .update(workflowBlocks)
.set({ .set({
horizontalHandles: !block.horizontalHandles, horizontalHandles: !block.horizontalHandles,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId)))
} }
logger.debug(`Batch toggled handles for ${blocksToToggle.length} blocks`) logger.debug(`Batch toggled handles for ${blocks.length} blocks`)
break
}
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
const { blockIds } = payload
if (!Array.isArray(blockIds) || blockIds.length === 0) {
return
}
logger.info(`Batch toggling locked for ${blockIds.length} blocks in workflow ${workflowId}`)
// Get all blocks in workflow to find children
const allBlocks = await tx
.select({
id: workflowBlocks.id,
locked: workflowBlocks.locked,
type: workflowBlocks.type,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type LockedBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, LockedBlockRecord> = Object.fromEntries(
allBlocks.map((b: LockedBlockRecord) => [b.id, b])
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including children of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id) {
blocksToToggle.add(b.id)
}
}
}
}
// Determine target locked state based on first toggleable block
if (blocksToToggle.size === 0) break
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = blocksById[firstToggleableId]
if (!firstBlock) break
const targetLocked = !firstBlock.locked
// Update all affected blocks
for (const blockId of blocksToToggle) {
await tx
.update(workflowBlocks)
.set({
locked: targetLocked,
updatedAt: new Date(),
})
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
}
logger.debug(`Batch toggled locked for ${blocksToToggle.size} blocks`)
break break
} }
@@ -991,54 +795,19 @@ async function handleBlocksOperationTx(
logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`) logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`)
// Fetch all blocks to check lock status
const allBlocks = await tx
.select({
id: workflowBlocks.id,
locked: workflowBlocks.locked,
data: workflowBlocks.data,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId))
type ParentBlockRecord = (typeof allBlocks)[number]
const blocksById: Record<string, ParentBlockRecord> = Object.fromEntries(
allBlocks.map((b: ParentBlockRecord) => [b.id, b])
)
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const currentParentId = (block.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
if (currentParentId && blocksById[currentParentId]?.locked) return true
return false
}
for (const update of updates) { for (const update of updates) {
const { id, parentId, position } = update const { id, parentId, position } = update
if (!id) continue if (!id) continue
// Skip protected blocks (locked or inside locked container)
if (isProtected(id)) {
logger.info(`Skipping block ${id} parent update - block is protected`)
continue
}
// Skip if trying to move into a locked container
if (parentId && blocksById[parentId]?.locked) {
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is locked`)
continue
}
// Fetch current parent to update subflow node lists // Fetch current parent to update subflow node lists
const existing = blocksById[id] const [existing] = await tx
const existingParentId = (existing?.data as Record<string, unknown> | null)?.parentId as .select({
| string id: workflowBlocks.id,
| undefined parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'`,
})
.from(workflowBlocks)
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
.limit(1)
if (!existing) { if (!existing) {
logger.warn(`Block ${id} not found for batch-update-parent`) logger.warn(`Block ${id} not found for batch-update-parent`)
@@ -1083,8 +852,8 @@ async function handleBlocksOperationTx(
await updateSubflowNodeList(tx, workflowId, parentId) await updateSubflowNodeList(tx, workflowId, parentId)
} }
// If the block had a previous parent, update that parent's node list as well // If the block had a previous parent, update that parent's node list as well
if (existingParentId && existingParentId !== parentId) { if (existing?.parentId && existing.parentId !== parentId) {
await updateSubflowNodeList(tx, workflowId, existingParentId) await updateSubflowNodeList(tx, workflowId, existing.parentId)
} }
} }
@@ -1429,7 +1198,6 @@ async function handleWorkflowOperationTx(
advancedMode: block.advancedMode ?? false, advancedMode: block.advancedMode ?? false,
triggerMode: block.triggerMode ?? false, triggerMode: block.triggerMode ?? false,
height: block.height || 0, height: block.height || 0,
locked: block.locked ?? false,
})) }))
await tx.insert(workflowBlocks).values(blockValues) await tx.insert(workflowBlocks).values(blockValues)

View File

@@ -214,12 +214,6 @@ describe('checkRolePermission', () => {
readAllowed: false, readAllowed: false,
}, },
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
{
operation: 'batch-toggle-locked',
adminAllowed: true,
writeAllowed: false, // Admin-only operation
readAllowed: false,
},
{ {
operation: 'batch-update-positions', operation: 'batch-update-positions',
adminAllowed: true, adminAllowed: true,

View File

@@ -14,10 +14,7 @@ import {
const logger = createLogger('SocketPermissions') const logger = createLogger('SocketPermissions')
// Admin-only operations (require admin role) // All write operations (admin and write roles have same permissions)
const ADMIN_ONLY_OPERATIONS: string[] = [BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED]
// Write operations (admin and write roles both have these permissions)
const WRITE_OPERATIONS: string[] = [ const WRITE_OPERATIONS: string[] = [
// Block operations // Block operations
BLOCK_OPERATIONS.UPDATE_POSITION, BLOCK_OPERATIONS.UPDATE_POSITION,
@@ -54,7 +51,7 @@ const READ_OPERATIONS: string[] = [
// Define operation permissions based on role // Define operation permissions based on role
const ROLE_PERMISSIONS: Record<string, string[]> = { const ROLE_PERMISSIONS: Record<string, string[]> = {
admin: [...ADMIN_ONLY_OPERATIONS, ...WRITE_OPERATIONS], admin: WRITE_OPERATIONS,
write: WRITE_OPERATIONS, write: WRITE_OPERATIONS,
read: READ_OPERATIONS, read: READ_OPERATIONS,
} }

View File

@@ -208,17 +208,6 @@ export const BatchToggleHandlesSchema = z.object({
operationId: z.string().optional(), operationId: z.string().optional(),
}) })
export const BatchToggleLockedSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED),
target: z.literal(OPERATION_TARGETS.BLOCKS),
payload: z.object({
blockIds: z.array(z.string()),
previousStates: z.record(z.boolean()),
}),
timestamp: z.number(),
operationId: z.string().optional(),
})
export const BatchUpdateParentSchema = z.object({ export const BatchUpdateParentSchema = z.object({
operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT), operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT),
target: z.literal(OPERATION_TARGETS.BLOCKS), target: z.literal(OPERATION_TARGETS.BLOCKS),
@@ -242,7 +231,6 @@ export const WorkflowOperationSchema = z.union([
BatchRemoveBlocksSchema, BatchRemoveBlocksSchema,
BatchToggleEnabledSchema, BatchToggleEnabledSchema,
BatchToggleHandlesSchema, BatchToggleHandlesSchema,
BatchToggleLockedSchema,
BatchUpdateParentSchema, BatchUpdateParentSchema,
EdgeOperationSchema, EdgeOperationSchema,
BatchAddEdgesSchema, BatchAddEdgesSchema,

View File

@@ -97,14 +97,6 @@ export interface BatchToggleHandlesOperation extends BaseOperation {
} }
} }
export interface BatchToggleLockedOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
export interface ApplyDiffOperation extends BaseOperation { export interface ApplyDiffOperation extends BaseOperation {
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
data: { data: {
@@ -144,7 +136,6 @@ export type Operation =
| BatchUpdateParentOperation | BatchUpdateParentOperation
| BatchToggleEnabledOperation | BatchToggleEnabledOperation
| BatchToggleHandlesOperation | BatchToggleHandlesOperation
| BatchToggleLockedOperation
| ApplyDiffOperation | ApplyDiffOperation
| AcceptDiffOperation | AcceptDiffOperation
| RejectDiffOperation | RejectDiffOperation

View File

@@ -167,15 +167,6 @@ export function createInverseOperation(operation: Operation): Operation {
}, },
} }
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED:
return {
...operation,
data: {
blockIds: operation.data.blockIds,
previousStates: operation.data.previousStates,
},
}
default: { default: {
const exhaustiveCheck: never = operation const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)

View File

@@ -432,104 +432,4 @@ describe('regenerateBlockIds', () => {
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 }) expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId) expect(duplicatedBlock.data?.parentId).toBe(loopId)
}) })
it('should unlock pasted block when source is locked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
// Pasted blocks are always unlocked so users can edit them
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should keep pasted block unlocked when source is unlocked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Unlocked Agent',
position: { x: 100, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should unlock all pasted blocks regardless of source locked state', () => {
const lockedId = 'locked-1'
const unlockedId = 'unlocked-1'
const blocksToCopy = {
[lockedId]: createAgentBlock({
id: lockedId,
name: 'Originally Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
[unlockedId]: createFunctionBlock({
id: unlockedId,
name: 'Originally Unlocked Function',
position: { x: 200, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
// All pasted blocks should be unlocked so users can edit them
for (const block of newBlocks) {
expect(block.locked).toBe(false)
}
})
}) })

View File

@@ -203,7 +203,6 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
advancedMode: false, advancedMode: false,
triggerMode, triggerMode,
height: 0, height: 0,
locked: false,
} }
} }
@@ -482,8 +481,6 @@ export function regenerateBlockIds(
position: newPosition, position: newPosition,
// Temporarily keep data as-is, we'll fix parentId in second pass // Temporarily keep data as-is, we'll fix parentId in second pass
data: block.data ? { ...block.data } : block.data, data: block.data ? { ...block.data } : block.data,
// Duplicated blocks are always unlocked so users can edit them
locked: false,
} }
newBlocks[newId] = newBlock newBlocks[newId] = newBlock
@@ -511,15 +508,15 @@ export function regenerateBlockIds(
parentId: newParentId, parentId: newParentId,
extent: 'parent', extent: 'parent',
} }
} else if (existingBlockNames[oldParentId] && !existingBlockNames[oldParentId].locked) { } else if (existingBlockNames[oldParentId]) {
// Parent exists in existing workflow and is not locked - keep original parentId // Parent exists in existing workflow - keep original parentId (block stays in same subflow)
block.data = { block.data = {
...block.data, ...block.data,
parentId: oldParentId, parentId: oldParentId,
extent: 'parent', extent: 'parent',
} }
} else { } else {
// Parent doesn't exist anywhere OR parent is locked - clear the relationship // Parent doesn't exist anywhere - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined } block.data = { ...block.data, parentId: undefined, extent: undefined }
} }
} }

View File

@@ -1144,223 +1144,6 @@ describe('workflow store', () => {
}) })
}) })
describe('batchToggleLocked', () => {
it('should toggle block locked state', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
// Initial state is undefined (falsy)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBeFalsy()
batchToggleLocked(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
batchToggleLocked(['block-1'])
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
})
it('should cascade lock to children when locking a loop', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
batchToggleLocked(['loop-1'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['loop-1'].locked).toBe(true)
expect(blocks['child-1'].locked).toBe(true)
})
it('should cascade unlock to children when unlocking a parallel', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('parallel-1', 'parallel', 'My Parallel', { x: 0, y: 0 }, { count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'parallel-1' },
'parallel-1',
'parent'
)
// Lock first
batchToggleLocked(['parallel-1'])
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
// Unlock
batchToggleLocked(['parallel-1'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['parallel-1'].locked).toBe(false)
expect(blocks['child-1'].locked).toBe(false)
})
it('should toggle multiple blocks at once', () => {
const { batchToggleLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test 1', { x: 0, y: 0 })
addBlock('block-2', 'function', 'Test 2', { x: 100, y: 0 })
batchToggleLocked(['block-1', 'block-2'])
const { blocks } = useWorkflowStore.getState()
expect(blocks['block-1'].locked).toBe(true)
expect(blocks['block-2'].locked).toBe(true)
})
})
describe('setBlockLocked', () => {
it('should set block locked state', () => {
const { setBlockLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
setBlockLocked('block-1', false)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
})
it('should not update if locked state is already the target value', () => {
const { setBlockLocked } = useWorkflowStore.getState()
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
// First set to true
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
// Setting to true again should still be true
setBlockLocked('block-1', true)
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
})
})
describe('duplicateBlock with locked', () => {
it('should unlock duplicate when duplicating a locked block', () => {
const { setBlockLocked, duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
setBlockLocked('original', true)
expect(useWorkflowStore.getState().blocks.original.locked).toBe(true)
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(2)
const duplicatedId = blockIds.find((id) => id !== 'original')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
// Original should still be locked
expect(blocks.original.locked).toBe(true)
// Duplicate should be unlocked so users can edit it
expect(blocks[duplicatedId].locked).toBe(false)
}
})
it('should create unlocked duplicate when duplicating an unlocked block', () => {
const { duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
const duplicatedId = blockIds.find((id) => id !== 'original')
if (duplicatedId) {
expect(blocks[duplicatedId].locked).toBeFalsy()
}
})
it('should place duplicate outside locked container when duplicating block inside locked loop', () => {
const { batchToggleLocked, duplicateBlock } = useWorkflowStore.getState()
// Create a loop with a child block
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
// Lock the loop (which cascades to the child)
batchToggleLocked(['loop-1'])
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
// Duplicate the child block
duplicateBlock('child-1')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(3) // loop, original child, duplicate
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
// Duplicate should be unlocked
expect(blocks[duplicatedId].locked).toBe(false)
// Duplicate should NOT have a parentId (placed outside the locked container)
expect(blocks[duplicatedId].data?.parentId).toBeUndefined()
// Original should still be inside the loop
expect(blocks['child-1'].data?.parentId).toBe('loop-1')
}
})
it('should keep duplicate inside unlocked container when duplicating block inside unlocked loop', () => {
const { duplicateBlock } = useWorkflowStore.getState()
// Create a loop with a child block (not locked)
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
addBlock(
'child-1',
'function',
'Child',
{ x: 50, y: 50 },
{ parentId: 'loop-1' },
'loop-1',
'parent'
)
// Duplicate the child block (loop is NOT locked)
duplicateBlock('child-1')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
if (duplicatedId) {
// Duplicate should still be inside the loop since it's not locked
expect(blocks[duplicatedId].data?.parentId).toBe('loop-1')
}
})
})
describe('updateBlockName', () => { describe('updateBlockName', () => {
beforeEach(() => { beforeEach(() => {
useWorkflowStore.setState({ useWorkflowStore.setState({

View File

@@ -207,7 +207,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
triggerMode?: boolean triggerMode?: boolean
height?: number height?: number
data?: Record<string, any> data?: Record<string, any>
locked?: boolean
}>, }>,
edges?: Edge[], edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>, subBlockValues?: Record<string, Record<string, unknown>>,
@@ -232,7 +231,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
triggerMode: block.triggerMode ?? false, triggerMode: block.triggerMode ?? false,
height: block.height ?? 0, height: block.height ?? 0,
data: block.data, data: block.data,
locked: block.locked ?? false,
} }
} }
@@ -367,69 +365,24 @@ export const useWorkflowStore = create<WorkflowStore>()(
}, },
batchToggleEnabled: (ids: string[]) => { batchToggleEnabled: (ids: string[]) => {
if (ids.length === 0) return const newBlocks = { ...get().blocks }
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle (skip locked blocks entirely)
// If it's a container, also include non-locked children
for (const id of ids) { for (const id of ids) {
const block = currentBlocks[id] if (newBlocks[id]) {
if (!block) continue newBlocks[id] = { ...newBlocks[id], enabled: !newBlocks[id].enabled }
// Skip locked blocks entirely (including their children)
if (block.locked) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include non-locked children
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
blocksToToggle.add(blockId)
}
})
} }
} }
// If no blocks can be toggled, exit early
if (blocksToToggle.size === 0) return
// Determine target enabled state based on first toggleable block
const firstToggleableId = Array.from(blocksToToggle)[0]
const firstBlock = currentBlocks[firstToggleableId]
const targetEnabled = !firstBlock.enabled
// Apply the enabled state to all toggleable blocks
for (const blockId of blocksToToggle) {
newBlocks[blockId] = { ...newBlocks[blockId], enabled: targetEnabled }
}
set({ blocks: newBlocks, edges: [...get().edges] }) set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved() get().updateLastSaved()
}, },
batchToggleHandles: (ids: string[]) => { batchToggleHandles: (ids: string[]) => {
const currentBlocks = get().blocks const newBlocks = { ...get().blocks }
const newBlocks = { ...currentBlocks }
// Helper to check if a block is protected (locked or inside locked parent)
const isProtected = (blockId: string): boolean => {
const block = currentBlocks[blockId]
if (!block) return false
if (block.locked) return true
const parentId = block.data?.parentId
if (parentId && currentBlocks[parentId]?.locked) return true
return false
}
for (const id of ids) { for (const id of ids) {
if (!newBlocks[id] || isProtected(id)) continue if (newBlocks[id]) {
newBlocks[id] = { newBlocks[id] = {
...newBlocks[id], ...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles, horizontalHandles: !newBlocks[id].horizontalHandles,
}
} }
} }
set({ blocks: newBlocks, edges: [...get().edges] }) set({ blocks: newBlocks, edges: [...get().edges] })
@@ -574,33 +527,9 @@ export const useWorkflowStore = create<WorkflowStore>()(
if (!block) return if (!block) return
const newId = crypto.randomUUID() const newId = crypto.randomUUID()
const offsetPosition = {
// Check if block is inside a locked container - if so, place duplicate outside x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
const parentId = block.data?.parentId y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
const parentBlock = parentId ? get().blocks[parentId] : undefined
const isParentLocked = parentBlock?.locked ?? false
// If parent is locked, calculate position outside the container
let offsetPosition: Position
const newData = block.data ? { ...block.data } : undefined
if (isParentLocked && parentBlock) {
// Place duplicate outside the locked container (to the right of it)
const containerWidth = parentBlock.data?.width ?? 400
offsetPosition = {
x: parentBlock.position.x + containerWidth + 50,
y: parentBlock.position.y,
}
// Remove parent relationship since we're placing outside
if (newData) {
newData.parentId = undefined
newData.extent = undefined
}
} else {
offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
} }
const newName = getUniqueBlockName(block.name, get().blocks) const newName = getUniqueBlockName(block.name, get().blocks)
@@ -628,8 +557,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
name: newName, name: newName,
position: offsetPosition, position: offsetPosition,
subBlocks: newSubBlocks, subBlocks: newSubBlocks,
locked: false,
data: newData,
}, },
}, },
edges: [...get().edges], edges: [...get().edges],
@@ -1237,70 +1164,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
getDragStartPosition: () => { getDragStartPosition: () => {
return get().dragStartPosition || null return get().dragStartPosition || null
}, },
setBlockLocked: (id: string, locked: boolean) => {
const block = get().blocks[id]
if (!block || block.locked === locked) return
const newState = {
blocks: {
...get().blocks,
[id]: {
...block,
locked,
},
},
edges: [...get().edges],
loops: { ...get().loops },
parallels: { ...get().parallels },
}
set(newState)
get().updateLastSaved()
},
batchToggleLocked: (ids: string[]) => {
if (ids.length === 0) return
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle
// If it's a container, also include all children
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
blocksToToggle.add(blockId)
}
})
}
}
// If no blocks found, exit early
if (blocksToToggle.size === 0) return
// Determine target locked state based on first block in original ids
const firstBlock = currentBlocks[ids[0]]
if (!firstBlock) return
const targetLocked = !firstBlock.locked
// Apply the locked state to all blocks
for (const blockId of blocksToToggle) {
newBlocks[blockId] = { ...newBlocks[blockId], locked: targetLocked }
}
set({ blocks: newBlocks, edges: [...get().edges] })
get().updateLastSaved()
},
}), }),
{ name: 'workflow-store' } { name: 'workflow-store' }
) )

View File

@@ -87,7 +87,6 @@ export interface BlockState {
triggerMode?: boolean triggerMode?: boolean
data?: BlockData data?: BlockData
layout?: BlockLayoutState layout?: BlockLayoutState
locked?: boolean
} }
export interface SubBlockState { export interface SubBlockState {
@@ -132,7 +131,6 @@ export interface Loop {
whileCondition?: string // JS expression that evaluates to boolean (for while loops) whileCondition?: string // JS expression that evaluates to boolean (for while loops)
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops) doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
enabled: boolean enabled: boolean
locked?: boolean
} }
export interface Parallel { export interface Parallel {
@@ -142,7 +140,6 @@ export interface Parallel {
count?: number // Number of parallel executions for count-based parallel count?: number // Number of parallel executions for count-based parallel
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
enabled: boolean enabled: boolean
locked?: boolean
} }
export interface Variable { export interface Variable {
@@ -236,8 +233,6 @@ export interface WorkflowActions {
workflowState: WorkflowState, workflowState: WorkflowState,
options?: { updateLastSaved?: boolean } options?: { updateLastSaved?: boolean }
) => void ) => void
setBlockLocked: (id: string, locked: boolean) => void
batchToggleLocked: (ids: string[]) => void
} }
export type WorkflowStore = WorkflowState & WorkflowActions export type WorkflowStore = WorkflowState & WorkflowActions

View File

@@ -286,30 +286,6 @@ describe('HTTP Request Tool', () => {
) )
}) })
it('should handle nested objects and arrays in URL-encoded form data', async () => {
tester.setup({ result: 'success' })
const body = {
name: 'test',
data: { nested: 'value' },
items: [1, 2, 3],
}
await tester.execute({
url: 'https://api.example.com/submit',
method: 'POST',
body,
headers: [{ cells: { Key: 'Content-Type', Value: 'application/x-www-form-urlencoded' } }],
})
const fetchCall = (global.fetch as any).mock.calls[0]
const bodyStr = fetchCall[1].body
expect(bodyStr).toContain('name=test')
expect(bodyStr).toContain('data=%7B%22nested%22%3A%22value%22%7D')
expect(bodyStr).toContain('items=%5B1%2C2%2C3%5D')
})
it('should handle OAuth client credentials requests', async () => { it('should handle OAuth client credentials requests', async () => {
tester.setup({ access_token: 'token123', token_type: 'Bearer' }) tester.setup({ access_token: 'token123', token_type: 'Bearer' })

View File

@@ -105,10 +105,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
const urlencoded = new URLSearchParams() const urlencoded = new URLSearchParams()
Object.entries(params.body as Record<string, unknown>).forEach(([key, value]) => { Object.entries(params.body as Record<string, unknown>).forEach(([key, value]) => {
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
urlencoded.append( urlencoded.append(key, String(value))
key,
typeof value === 'object' ? JSON.stringify(value) : String(value)
)
} }
}) })
return urlencoded.toString() return urlencoded.toString()

View File

@@ -1 +0,0 @@
ALTER TABLE "workflow_blocks" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1044,13 +1044,6 @@
"when": 1769656977701, "when": 1769656977701,
"tag": "0149_next_cerise", "tag": "0149_next_cerise",
"breakpoints": true "breakpoints": true
},
{
"idx": 150,
"version": "7",
"when": 1769897862156,
"tag": "0150_flimsy_hemingway",
"breakpoints": true
} }
] ]
} }

Some files were not shown because too many files have changed in this diff Show More