Compare commits

...

4 Commits

Author SHA1 Message Date
Vikhyath Mondreti
1814aef84f fix error message 2026-01-10 12:16:07 -08:00
Vikhyath Mondreti
c07dbfa06a don't show dup button for response 2026-01-10 12:08:55 -08:00
Vikhyath Mondreti
a2930446bc respect singleton triggers and blocks in copilot 2026-01-10 12:05:21 -08:00
Vikhyath Mondreti
40fc9ca504 improvement(response): only allow singleton 2026-01-10 11:55:20 -08:00
8 changed files with 94 additions and 8 deletions

View File

@@ -87,8 +87,8 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
// Check for start_trigger (unified start block) - prevent duplication but allow deletion
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
/**
@@ -140,7 +140,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && (
{!isStartBlock && !isResponseBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -23,7 +23,8 @@ interface TriggerValidationResult {
}
/**
* Validates that pasting/duplicating trigger blocks won't violate constraints.
* Validates that pasting/duplicating blocks won't violate constraints.
* Checks both trigger constraints and single-instance block constraints.
* Returns validation result with error message if invalid.
*/
export function validateTriggerPaste(
@@ -43,6 +44,12 @@ export function validateTriggerPaste(
return { isValid: false, message }
}
}
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(existingBlocks, block.type)
if (singleInstanceIssue) {
const message = `A workflow can only have one ${singleInstanceIssue.blockName} block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
return { isValid: false, message }
}
}
return { isValid: true }
}

View File

@@ -1129,17 +1129,18 @@ const WorkflowContent = React.memo(() => {
)
/**
* Checks if adding a trigger block would violate constraints and shows notification if so.
* Checks if adding a block would violate constraints (triggers or single-instance blocks)
* and shows notification if so.
* @returns true if validation failed (caller should return early), false if ok to proceed
*/
const checkTriggerConstraints = useCallback(
(blockType: string): boolean => {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (issue) {
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (triggerIssue) {
const message =
issue.issue === 'legacy'
triggerIssue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
: `A workflow can only have one ${triggerIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
@@ -1147,6 +1148,17 @@ const WorkflowContent = React.memo(() => {
})
return true
}
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(blocks, blockType)
if (singleInstanceIssue) {
addNotification({
level: 'error',
message: `A workflow can only have one ${singleInstanceIssue.blockName} block. Please remove the existing one before adding a new one.`,
workflowId: activeWorkflowId || undefined,
})
return true
}
return false
},
[blocks, addNotification, activeWorkflowId]

View File

@@ -17,6 +17,7 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
category: 'blocks',
bgColor: '#2F55FF',
icon: ResponseIcon,
singleInstance: true,
subBlocks: [
{
id: 'dataMode',

View File

@@ -320,6 +320,7 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
subBlocks: SubBlockConfig[]
triggerAllowed?: boolean
authMode?: AuthMode
singleInstance?: boolean
tools: {
access: string[]
config?: {

View File

@@ -11,6 +11,7 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName } from '@/executor/constants'
@@ -62,6 +63,8 @@ type SkippedItemType =
| 'invalid_subflow_parent'
| 'nested_subflow_not_allowed'
| 'duplicate_block_name'
| 'duplicate_trigger'
| 'duplicate_single_instance_block'
/**
* Represents an item that was skipped during operation application
@@ -1775,6 +1778,34 @@ function applyOperationsToWorkflowState(
break
}
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type)
if (triggerIssue) {
logSkippedItem(skippedItems, {
type: 'duplicate_trigger',
operationType: 'add',
blockId: block_id,
reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`,
details: { requestedType: params.type, issue: triggerIssue.issue },
})
break
}
// Check single-instance block constraints (e.g., Response block)
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(
modifiedState.blocks,
params.type
)
if (singleInstanceIssue) {
logSkippedItem(skippedItems, {
type: 'duplicate_single_instance_block',
operationType: 'add',
blockId: block_id,
reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`,
details: { requestedType: params.type },
})
break
}
// Create new block with proper structure
const newBlock = createBlockFromParams(
block_id,

View File

@@ -592,4 +592,34 @@ export class TriggerUtils {
const parentWithType = parent as T & { type?: string }
return parentWithType.type === 'loop' || parentWithType.type === 'parallel'
}
static isSingleInstanceBlockType(blockType: string): boolean {
const blockConfig = getBlock(blockType)
return blockConfig?.singleInstance === true
}
static wouldViolateSingleInstanceBlock<T extends { type: string }>(
blocks: T[] | Record<string, T>,
blockType: string
): boolean {
if (!TriggerUtils.isSingleInstanceBlockType(blockType)) {
return false
}
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
return blockArray.some((block) => block.type === blockType)
}
static getSingleInstanceBlockIssue<T extends { type: string }>(
blocks: T[] | Record<string, T>,
blockType: string
): { issue: 'duplicate'; blockName: string } | null {
if (!TriggerUtils.wouldViolateSingleInstanceBlock(blocks, blockType)) {
return null
}
const blockConfig = getBlock(blockType)
const blockName = blockConfig?.name || blockType
return { issue: 'duplicate', blockName }
}
}

View File

@@ -41,6 +41,10 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record<stri
return 'Start'
}
if (normalizedBaseName === 'response') {
return 'Response'
}
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName