Compare commits

..

2 Commits

62 changed files with 849 additions and 2830 deletions

View File

@@ -90,16 +90,6 @@ jobs:
echo "✅ All feature flags are properly configured"
- name: Check subblock ID stability
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="origin/${{ github.base_ref }}"
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
else
BASE_REF="HEAD~1"
fi
bun run apps/sim/scripts/check-subblock-id-stability.ts "$BASE_REF"
- name: Lint code
run: bun run lint:check

View File

@@ -69,9 +69,7 @@ Read records from a ServiceNow table
| `number` | string | No | Record number \(e.g., INC0010001\) |
| `query` | string | No | Encoded query string \(e.g., "active=true^priority=1"\) |
| `limit` | number | No | Maximum number of records to return \(e.g., 10, 50, 100\) |
| `offset` | number | No | Number of records to skip for pagination \(e.g., 0, 10, 20\) |
| `fields` | string | No | Comma-separated list of fields to return \(e.g., sys_id,number,short_description,state\) |
| `displayValue` | string | No | Return display values for reference fields: "true" \(display only\), "false" \(sys_id only\), or "all" \(both\) |
#### Output

View File

@@ -1,6 +1,6 @@
---
title: Slack
description: Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack
description: Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
@@ -39,7 +39,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai](
## Usage Instructions
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
@@ -799,128 +799,4 @@ Add an emoji reaction to a Slack message
| ↳ `timestamp` | string | Message timestamp |
| ↳ `reaction` | string | Emoji reaction name |
### `slack_remove_reaction`
Remove an emoji reaction from a Slack message
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Channel ID where the message was posted \(e.g., C1234567890\) |
| `timestamp` | string | Yes | Timestamp of the message to remove reaction from \(e.g., 1405894322.002768\) |
| `name` | string | Yes | Name of the emoji reaction to remove \(without colons, e.g., thumbsup, heart, eyes\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | Success message |
| `metadata` | object | Reaction metadata |
| ↳ `channel` | string | Channel ID |
| ↳ `timestamp` | string | Message timestamp |
| ↳ `reaction` | string | Emoji reaction name |
### `slack_get_channel_info`
Get detailed information about a Slack channel by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Channel ID to get information about \(e.g., C1234567890\) |
| `includeNumMembers` | boolean | No | Whether to include the member count in the response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `channelInfo` | object | Detailed channel information |
| ↳ `id` | string | Channel ID \(e.g., C1234567890\) |
| ↳ `name` | string | Channel name without # prefix |
| ↳ `is_channel` | boolean | Whether this is a channel |
| ↳ `is_private` | boolean | Whether channel is private |
| ↳ `is_archived` | boolean | Whether channel is archived |
| ↳ `is_general` | boolean | Whether this is the general channel |
| ↳ `is_member` | boolean | Whether the bot/user is a member |
| ↳ `is_shared` | boolean | Whether channel is shared across workspaces |
| ↳ `is_ext_shared` | boolean | Whether channel is externally shared |
| ↳ `is_org_shared` | boolean | Whether channel is org-wide shared |
| ↳ `num_members` | number | Number of members in the channel |
| ↳ `topic` | string | Channel topic |
| ↳ `purpose` | string | Channel purpose/description |
| ↳ `created` | number | Unix timestamp when channel was created |
| ↳ `creator` | string | User ID of channel creator |
| ↳ `updated` | number | Unix timestamp of last update |
### `slack_get_user_presence`
Check whether a Slack user is currently active or away
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `userId` | string | Yes | User ID to check presence for \(e.g., U1234567890\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `presence` | string | User presence status: "active" or "away" |
| `online` | boolean | Whether user has an active client connection \(only available when checking own presence\) |
| `autoAway` | boolean | Whether user was automatically set to away due to inactivity \(only available when checking own presence\) |
| `manualAway` | boolean | Whether user manually set themselves as away \(only available when checking own presence\) |
| `connectionCount` | number | Total number of active connections for the user \(only available when checking own presence\) |
| `lastActivity` | number | Unix timestamp of last detected activity \(only available when checking own presence\) |
### `slack_edit_canvas`
Edit an existing Slack canvas by inserting, replacing, or deleting content
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `canvasId` | string | Yes | Canvas ID to edit \(e.g., F1234ABCD\) |
| `operation` | string | Yes | Edit operation: insert_at_start, insert_at_end, insert_after, insert_before, replace, delete, or rename |
| `content` | string | No | Markdown content for the operation \(required for insert/replace operations\) |
| `sectionId` | string | No | Section ID to target \(required for insert_after, insert_before, replace, and delete\) |
| `title` | string | No | New title for the canvas \(only used with rename operation\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `content` | string | Success message |
### `slack_create_channel_canvas`
Create a canvas pinned to a Slack channel as its resource hub
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `authMethod` | string | No | Authentication method: oauth or bot_token |
| `botToken` | string | No | Bot token for Custom Bot |
| `channel` | string | Yes | Channel ID to create the canvas in \(e.g., C1234567890\) |
| `title` | string | No | Title for the channel canvas |
| `content` | string | No | Canvas content in markdown format |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `canvas_id` | string | ID of the created channel canvas |

View File

@@ -1,87 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
export const dynamic = 'force-dynamic'
const SlackRemoveReactionSchema = z.object({
accessToken: z.string().min(1, 'Access token is required'),
channel: z.string().min(1, 'Channel is required'),
timestamp: z.string().min(1, 'Message timestamp is required'),
name: z.string().min(1, 'Emoji name is required'),
})
export async function POST(request: NextRequest) {
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json(
{
success: false,
error: authResult.error || 'Authentication required',
},
{ status: 401 }
)
}
const body = await request.json()
const validatedData = SlackRemoveReactionSchema.parse(body)
const slackResponse = await fetch('https://slack.com/api/reactions.remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${validatedData.accessToken}`,
},
body: JSON.stringify({
channel: validatedData.channel,
timestamp: validatedData.timestamp,
name: validatedData.name,
}),
})
const data = await slackResponse.json()
if (!data.ok) {
return NextResponse.json(
{
success: false,
error: data.error || 'Failed to remove reaction',
},
{ status: slackResponse.status }
)
}
return NextResponse.json({
success: true,
output: {
content: `Successfully removed :${validatedData.name}: reaction`,
metadata: {
channel: validatedData.channel,
timestamp: validatedData.timestamp,
reaction: validatedData.name,
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
error: 'Invalid request data',
details: error.errors,
},
{ status: 400 }
)
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
},
{ status: 500 }
)
}
}

View File

@@ -150,7 +150,6 @@ export async function POST(request: NextRequest) {
method: 'GET',
})
if (!response.ok) {
await response.text().catch(() => {})
throw new Error(`Failed to download audio from URL: ${response.statusText}`)
}

View File

@@ -135,7 +135,6 @@ async function fetchDocumentBytes(url: string): Promise<{ bytes: string; content
method: 'GET',
})
if (!response.ok) {
await response.text().catch(() => {})
throw new Error(`Failed to fetch document: ${response.statusText}`)
}

View File

@@ -65,7 +65,6 @@ export async function POST(request: NextRequest) {
})
if (!response.ok) {
await response.body?.cancel().catch(() => {})
logger.error(`Failed to generate TTS: ${response.status} ${response.statusText}`)
return NextResponse.json(
{ error: `Failed to generate TTS: ${response.status} ${response.statusText}` },

View File

@@ -184,7 +184,6 @@ export async function POST(request: NextRequest) {
method: 'GET',
})
if (!response.ok) {
await response.text().catch(() => {})
return NextResponse.json(
{ success: false, error: 'Failed to fetch image for Gemini' },
{ status: 400 }

View File

@@ -964,7 +964,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.error(`[${requestId}] Error streaming block content:`, error)
} finally {
try {
await reader.cancel().catch(() => {})
reader.releaseLock()
} catch {}
}
}

View File

@@ -164,7 +164,7 @@ export const ActionBar = memo(
return (
<div
className={cn(
'-top-[46px] pointer-events-auto absolute right-0',
'-top-[46px] absolute right-0',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] p-[5px]',

View File

@@ -501,6 +501,17 @@ export function Chat() {
}
}, [])
useEffect(() => {
if (!isExecuting && isStreaming) {
const lastMessage = workflowMessages[workflowMessages.length - 1]
if (lastMessage?.isStreaming) {
streamReaderRef.current?.cancel()
streamReaderRef.current = null
finalizeMessageStream(lastMessage.id)
}
}
}, [isExecuting, isStreaming, workflowMessages, finalizeMessageStream])
const handleStopStreaming = useCallback(() => {
streamReaderRef.current?.cancel()
streamReaderRef.current = null

View File

@@ -31,7 +31,12 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import {
restoreCursorAfterInsertion,
sanitizeForParsing,
validateJavaScript,
validatePython,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -166,7 +171,7 @@ interface CodeProps {
defaultCollapsed?: boolean
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
showCopyButton?: boolean
onValidationChange?: (isValid: boolean) => void
onValidationChange?: (isValid: boolean, errorMessage?: string | null) => void
wandConfig: {
enabled: boolean
prompt: string
@@ -250,6 +255,18 @@ export const Code = memo(function Code({
}
}, [shouldValidateJson, trimmedCode])
const syntaxError = useMemo(() => {
if (effectiveLanguage === 'json' || !trimmedCode) return null
const sanitized = sanitizeForParsing(trimmedCode)
if (effectiveLanguage === 'javascript') {
return validateJavaScript(sanitized)
}
if (effectiveLanguage === 'python') {
return validatePython(sanitized)
}
return null
}, [effectiveLanguage, trimmedCode])
const gutterWidthPx = useMemo(() => {
const lineCount = code.split('\n').length
return calculateGutterWidth(lineCount)
@@ -341,19 +358,21 @@ export const Code = memo(function Code({
useEffect(() => {
if (!onValidationChange) return
const isValid = !shouldValidateJson || isValidJson
const isValid = (!shouldValidateJson || isValidJson) && !syntaxError
if (isValid) {
onValidationChange(true)
onValidationChange(true, null)
return
}
const errorMessage = !isValidJson ? 'Invalid JSON' : syntaxError
const timeoutId = setTimeout(() => {
onValidationChange(false)
onValidationChange(false, errorMessage)
}, 150)
return () => clearTimeout(timeoutId)
}, [isValidJson, onValidationChange, shouldValidateJson])
}, [isValidJson, syntaxError, onValidationChange, shouldValidateJson])
useEffect(() => {
handleStreamStartRef.current = () => {

View File

@@ -189,7 +189,7 @@ const getPreviewValue = (
* Renders the label with optional validation and description tooltips.
*
* @param config - The sub-block configuration defining the label content
* @param isValidJson - Whether the JSON content is valid (for code blocks)
* @param codeValidation - Validation state for code blocks (valid flag + optional error message)
* @param subBlockValues - Current values of all subblocks for evaluating conditional requirements
* @param wandState - State and handlers for the inline AI generate feature
* @param canonicalToggle - Metadata and handlers for the basic/advanced mode toggle
@@ -200,7 +200,7 @@ const getPreviewValue = (
*/
const renderLabel = (
config: SubBlockConfig,
isValidJson: boolean,
codeValidation: { isValid: boolean; errorMessage: string | null },
subBlockValues?: Record<string, any>,
wandState?: {
isSearchActive: boolean
@@ -250,21 +250,18 @@ const renderLabel = (
{config.title}
{required && <span className='ml-0.5'>*</span>}
{labelSuffix}
{config.type === 'code' &&
config.language === 'json' &&
!isValidJson &&
!wandState?.isStreaming && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>Invalid JSON</p>
</Tooltip.Content>
</Tooltip.Root>
)}
{config.type === 'code' && !codeValidation.isValid && !wandState?.isStreaming && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<AlertTriangle className='h-3 w-3 flex-shrink-0 cursor-pointer text-destructive' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>{codeValidation.errorMessage ?? 'Syntax error'}</p>
</Tooltip.Content>
</Tooltip.Root>
)}
</Label>
<div className='flex min-w-0 flex-1 items-center justify-end gap-[6px]'>
{showCopy && (
@@ -466,7 +463,8 @@ function SubBlockComponent({
const params = useParams()
const workspaceId = params.workspaceId as string
const [isValidJson, setIsValidJson] = useState(true)
const [isValidCode, setIsValidCode] = useState(true)
const [codeErrorMessage, setCodeErrorMessage] = useState<string | null>(null)
const [isSearchActive, setIsSearchActive] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [copied, setCopied] = useState(false)
@@ -484,8 +482,9 @@ function SubBlockComponent({
e.stopPropagation()
}
const handleValidationChange = (isValid: boolean): void => {
setIsValidJson(isValid)
const handleValidationChange = (isValid: boolean, errorMessage?: string | null): void => {
setIsValidCode(isValid)
setCodeErrorMessage(errorMessage ?? null)
}
const isWandEnabled = config.wandConfig?.enabled ?? false
@@ -1151,7 +1150,7 @@ function SubBlockComponent({
<div onMouseDown={handleMouseDown} className='subblock-content flex flex-col gap-[10px]'>
{renderLabel(
config,
isValidJson,
{ isValid: isValidCode, errorMessage: codeErrorMessage },
subBlockValues,
{
isSearchActive,

View File

@@ -40,10 +40,6 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
isAncestorProtected,
isBlockProtected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
@@ -111,11 +107,12 @@ export function Editor() {
const userPermissions = useUserPermissionsContext()
// Check if block is locked (or inside a locked ancestor) and compute edit permission
// 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 isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false
const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false
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)
@@ -250,7 +247,10 @@ export function Editor() {
const block = blocks[blockId]
if (!block) return
if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return
const parentId = block.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (block.locked ?? false) || isParentLocked
if (!userPermissions.canEdit || isLocked) return
renamingBlockIdRef.current = blockId
setEditedName(block.name || '')
@@ -364,11 +364,11 @@ export function Editor() {
)}
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked directly, and not locked by an ancestor */}
{/* 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 && !isAncestorLocked ? (
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
<Button
variant='ghost'
className='p-0'
@@ -385,8 +385,8 @@ export function Editor() {
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{isAncestorLocked
? 'Ancestor container is locked'
{isParentLocked
? 'Parent container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}

View File

@@ -0,0 +1,197 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { sanitizeForParsing, validateJavaScript, validatePython } from './utils'
describe('sanitizeForParsing', () => {
it('replaces <Block.output> references with valid identifiers', () => {
const result = sanitizeForParsing('const x = <Block.output>')
expect(result).not.toContain('<')
expect(result).not.toContain('>')
expect(result).toContain('__placeholder_')
})
it('replaces {{ENV_VAR}} with valid identifiers', () => {
const result = sanitizeForParsing('const url = {{API_URL}}')
expect(result).not.toContain('{{')
expect(result).not.toContain('}}')
expect(result).toContain('__placeholder_')
})
it('replaces nested path references like <Block.output[0].field>', () => {
const result = sanitizeForParsing('const x = <Agent.response.choices[0].text>')
expect(result).not.toContain('<Agent')
})
it('replaces loop/parallel context references', () => {
const result = sanitizeForParsing('const item = <loop.currentItem>')
expect(result).not.toContain('<loop')
})
it('replaces variable references', () => {
const result = sanitizeForParsing('const v = <variable.myVar>')
expect(result).not.toContain('<variable')
})
it('handles multiple references in one string', () => {
const code = 'const a = <Block1.out>; const b = {{SECRET}}; const c = <Block2.value>'
const result = sanitizeForParsing(code)
expect(result).not.toContain('<Block1')
expect(result).not.toContain('{{SECRET}}')
expect(result).not.toContain('<Block2')
expect(result.match(/__placeholder_/g)?.length).toBe(3)
})
it('does not replace regular JS comparison operators', () => {
const code = 'if (a < b && c > d) {}'
const result = sanitizeForParsing(code)
expect(result).toBe(code)
})
it('does not replace HTML tags that are not references', () => {
const code = 'const html = "<div>hello</div>"'
const result = sanitizeForParsing(code)
expect(result).toBe(code)
})
})
describe('validateJavaScript', () => {
it('returns null for valid JavaScript', () => {
expect(validateJavaScript('const x = 1')).toBeNull()
expect(validateJavaScript('function foo() { return 42 }')).toBeNull()
expect(validateJavaScript('const arr = [1, 2, 3].map(x => x * 2)')).toBeNull()
})
it('returns null for valid async/await code', () => {
expect(validateJavaScript('async function foo() { await bar() }')).toBeNull()
})
it('returns null for bare return statements (function block wraps in async fn)', () => {
expect(validateJavaScript('return 42')).toBeNull()
expect(validateJavaScript(sanitizeForParsing('return <Block.output>'))).toBeNull()
expect(validateJavaScript('const x = 1\nreturn x')).toBeNull()
})
it('returns null for await at top level (wrapped in async fn)', () => {
expect(validateJavaScript('const res = await fetch("url")')).toBeNull()
})
it('returns null for valid ES module syntax', () => {
expect(validateJavaScript('import { foo } from "bar"')).toBeNull()
expect(validateJavaScript('export default function() {}')).toBeNull()
})
it('detects missing closing brace', () => {
const result = validateJavaScript('function foo() {')
expect(result).not.toBeNull()
expect(result).toContain('Syntax error')
})
it('detects missing closing paren', () => {
const result = validateJavaScript('console.log("hello"')
expect(result).not.toBeNull()
expect(result).toContain('Syntax error')
})
it('detects unexpected token', () => {
const result = validateJavaScript('const = 5')
expect(result).not.toBeNull()
expect(result).toContain('Syntax error')
})
it('includes adjusted line and column in error message', () => {
const result = validateJavaScript('const x = 1\nconst = 5')
expect(result).toMatch(/line 2/)
expect(result).toMatch(/col \d+/)
})
it('returns null for empty code', () => {
expect(validateJavaScript('')).toBeNull()
})
it('does not error on sanitized references', () => {
const code = sanitizeForParsing('const x = <Block.output> + {{ENV_VAR}}')
expect(validateJavaScript(code)).toBeNull()
})
})
describe('validatePython', () => {
it('returns null for valid Python', () => {
expect(validatePython('x = 1')).toBeNull()
expect(validatePython('def foo():\n return 42')).toBeNull()
expect(validatePython('arr = [1, 2, 3]')).toBeNull()
})
it('returns null for Python with comments', () => {
expect(validatePython('x = 1 # this is a comment')).toBeNull()
expect(validatePython('# full line comment\nx = 1')).toBeNull()
})
it('returns null for Python with strings containing brackets', () => {
expect(validatePython('x = "hello (world)"')).toBeNull()
expect(validatePython("x = 'brackets [here] {too}'")).toBeNull()
})
it('returns null for triple-quoted strings', () => {
expect(validatePython('x = """hello\nworld"""')).toBeNull()
expect(validatePython("x = '''multi\nline\nstring'''")).toBeNull()
})
it('returns null for triple-quoted strings with brackets', () => {
expect(validatePython('x = """has { and ( inside"""')).toBeNull()
})
it('detects unmatched opening paren', () => {
const result = validatePython('foo(1, 2')
expect(result).not.toBeNull()
expect(result).toContain("'('")
})
it('detects unmatched closing paren', () => {
const result = validatePython('foo)')
expect(result).not.toBeNull()
expect(result).toContain("')'")
})
it('detects unmatched bracket', () => {
const result = validatePython('arr = [1, 2')
expect(result).not.toBeNull()
expect(result).toContain("'['")
})
it('detects unterminated string', () => {
const result = validatePython('x = "hello')
expect(result).not.toBeNull()
expect(result).toContain('Unterminated string')
})
it('detects unterminated triple-quoted string', () => {
const result = validatePython('x = """hello')
expect(result).not.toBeNull()
expect(result).toContain('Unterminated triple-quoted string')
})
it('includes line number in error', () => {
const result = validatePython('x = 1\ny = (2')
expect(result).toMatch(/line 2/)
})
it('handles escaped quotes in strings', () => {
expect(validatePython('x = "hello \\"world\\""')).toBeNull()
expect(validatePython("x = 'it\\'s fine'")).toBeNull()
})
it('handles brackets inside comments', () => {
expect(validatePython('x = 1 # unmatched ( here')).toBeNull()
})
it('returns null for empty code', () => {
expect(validatePython('')).toBeNull()
})
it('does not error on sanitized references', () => {
const code = sanitizeForParsing('x = <Block.output> + {{ENV_VAR}}')
expect(validatePython(code)).toBeNull()
})
})

View File

@@ -1,3 +1,17 @@
import { parse } from 'acorn'
/**
* Matches Sim block references: `<word.path>`, `<word.path[0].nested>`, `<loop.index>`, etc.
* Must contain a dot (.) to distinguish from HTML tags or comparison operators.
*/
const REFERENCE_PATTERN = /<[a-zA-Z]\w*(?:\.\w+(?:\[\d+\])?)+>/g
/**
* Matches Sim env-var placeholders: `{{WORD}}`, `{{MY_VAR}}`.
* Only allows word characters (no spaces, special chars).
*/
const ENV_VAR_PATTERN = /\{\{\w+\}\}/g
/**
* Restores the cursor position in a textarea after a dropdown insertion.
* Schedules a macrotask (via setTimeout) that runs after React's controlled-component commit
@@ -18,3 +32,132 @@ export function restoreCursorAfterInsertion(
}
}, 0)
}
/**
* Replaces `<Block.output>` references and `{{ENV_VAR}}` placeholders with
* valid JS/Python identifiers so the code can be parsed without false errors.
*/
export function sanitizeForParsing(code: string): string {
let counter = 0
let sanitized = code.replace(ENV_VAR_PATTERN, () => `__placeholder_${counter++}__`)
sanitized = sanitized.replace(REFERENCE_PATTERN, () => `__placeholder_${counter++}__`)
return sanitized
}
/**
* Validates JavaScript code for syntax errors using acorn.
*
* Tries two parse strategies to match the Function block's runtime behavior:
* 1. As a module (`import`/`export` are valid at top level)
* 2. Wrapped in `async () => { ... }` (bare `return`/`await` are valid)
*
* Only reports an error if both strategies fail, using the wrapped error
* since that's the primary execution context.
*
* @returns Error message string, or null if valid.
*/
export function validateJavaScript(code: string): string | null {
try {
parse(code, { ecmaVersion: 'latest', sourceType: 'module' })
return null
} catch {
// Module parse failed — try as function body (allows bare return/await)
}
const wrapped = `(async () => {\n${code}\n})()`
try {
parse(wrapped, { ecmaVersion: 'latest', sourceType: 'script' })
return null
} catch (e: unknown) {
if (e instanceof SyntaxError) {
const msg = e.message
const match = msg.match(/\((\d+):(\d+)\)/)
if (match) {
const adjustedLine = Number(match[1]) - 1
if (adjustedLine < 1) return null
return `Syntax error at line ${adjustedLine}, col ${match[2]}: ${msg.replace(/\s*\(\d+:\d+\)/, '')}`
}
return `Syntax error: ${msg}`
}
return null
}
}
/**
* Validates Python code for common syntax errors: unmatched brackets/parens,
* unterminated strings (single-line and triple-quoted).
* Processes the entire code string as a stream to correctly handle
* multiline triple-quoted strings.
*
* @returns Error message string, or null if no issues detected.
*/
export function validatePython(code: string): string | null {
const stack: { char: string; line: number }[] = []
const openers: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
const closers = new Set([')', ']', '}'])
const openChars = new Set(['(', '[', '{'])
let line = 1
let i = 0
while (i < code.length) {
const ch = code[i]
if (ch === '\n') {
line++
i++
continue
}
if (ch === '#') {
const newline = code.indexOf('\n', i)
i = newline === -1 ? code.length : newline
continue
}
if (ch === '"' || ch === "'") {
const tripleQuote = ch.repeat(3)
if (code.slice(i, i + 3) === tripleQuote) {
const startLine = line
const endIdx = code.indexOf(tripleQuote, i + 3)
if (endIdx === -1) {
return `Unterminated triple-quoted string starting at line ${startLine}`
}
for (let k = i; k < endIdx + 3; k++) {
if (code[k] === '\n') line++
}
i = endIdx + 3
continue
}
const startLine = line
i++
while (i < code.length && code[i] !== ch && code[i] !== '\n') {
if (code[i] === '\\') i++
i++
}
if (i >= code.length || code[i] === '\n') {
return `Unterminated string at line ${startLine}`
}
i++
continue
}
if (openChars.has(ch)) {
stack.push({ char: ch, line })
} else if (closers.has(ch)) {
if (stack.length === 0 || stack[stack.length - 1].char !== openers[ch]) {
return `Unmatched '${ch}' at line ${line}`
}
stack.pop()
}
i++
}
if (stack.length > 0) {
const unmatched = stack[stack.length - 1]
return `Unmatched '${unmatched.char}' opened at line ${unmatched.line}`
}
return null
}

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'
import { memo, useMemo, useRef } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Badge } from '@/components/emcn'
@@ -28,28 +28,6 @@ export interface SubflowNodeData {
executionStatus?: 'success' | 'error' | 'not-executed'
}
const HANDLE_STYLE = {
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
} as const
/**
* Reusable class names for Handle components.
* Matches the styling pattern from workflow-block.tsx.
*/
const getHandleClasses = (position: 'left' | 'right') => {
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
const colorClasses = '!bg-[var(--workflow-edge)]'
const positionClasses = {
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
right:
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
}
return cn(baseClasses, colorClasses, positionClasses[position])
}
/**
* Subflow node component for loop and parallel execution containers.
* Renders a resizable container with a header displaying the block name and icon,
@@ -60,6 +38,7 @@ const getHandleClasses = (position: 'left' | 'right') => {
*/
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const blockRef = useRef<HTMLDivElement>(null)
const userPermissions = useUserPermissionsContext()
const currentWorkflow = useCurrentWorkflow()
@@ -73,6 +52,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false
// Focus state
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
@@ -104,7 +84,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
}
return level
}, [data?.parentId, getNodes])
}, [id, data?.parentId, getNodes])
const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
@@ -112,6 +92,27 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
/**
* Reusable styles and positioning for Handle components.
* Matches the styling pattern from workflow-block.tsx.
*/
const getHandleClasses = (position: 'left' | 'right') => {
const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
const colorClasses = '!bg-[var(--workflow-edge)]'
const positionClasses = {
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
right:
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
}
return cn(baseClasses, colorClasses, positionClasses[position])
}
const getHandleStyle = () => {
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
@@ -126,37 +127,46 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
diffStatus === 'new' ||
diffStatus === 'edited' ||
!!runPathStatus
/**
* Compute the ring color for the subflow selection indicator.
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
* Compute the outline color for the subflow ring.
* Uses CSS outline instead of box-shadow ring because in ReactFlow v11,
* child nodes are DOM children of parent nodes and paint over the parent's
* internal ring overlay. Outline renders on the element's own compositing
* layer, so it stays visible above nested child nodes.
*/
const getRingColor = (): string | undefined => {
if (!hasRing) return undefined
if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
if (diffStatus === 'new') return 'var(--brand-tertiary-2)'
if (diffStatus === 'edited') return 'var(--warning)'
if (runPathStatus === 'success') {
return executionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
}
if (runPathStatus === 'error') return 'var(--text-error)'
return undefined
}
const ringColor = getRingColor()
const outlineColor = hasRing
? isFocused || isSelected || isPreviewSelected
? 'var(--brand-secondary)'
: diffStatus === 'new'
? 'var(--brand-tertiary-2)'
: diffStatus === 'edited'
? 'var(--warning)'
: runPathStatus === 'success'
? executionStatus
? 'var(--brand-tertiary-2)'
: 'var(--border-success)'
: runPathStatus === 'error'
? 'var(--text-error)'
: undefined
: undefined
return (
<div className='group pointer-events-none relative'>
<div className='group relative'>
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)] transition-block-bg'
ref={blockRef}
className={cn(
'relative select-none rounded-[8px] border border-[var(--border-1)]',
'transition-block-bg'
)}
style={{
width: data.width || 500,
height: data.height || 300,
position: 'relative',
overflow: 'visible',
pointerEvents: 'none',
...(ringColor && {
boxShadow: `0 0 0 1.75px ${ringColor}`,
...(outlineColor && {
outline: `1.75px solid ${outlineColor}`,
outlineOffset: '-1px',
}),
}}
data-node-id={id}
@@ -171,7 +181,9 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
{/* Header Section — only interactive area for dragging */}
<div
onClick={() => setCurrentBlockId(id)}
className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
)}
style={{ pointerEvents: 'auto' }}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
@@ -197,17 +209,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
</div>
</div>
{/*
* Click-catching background — selects this subflow when the body area is clicked.
* No event bubbling concern: ReactFlow renders child nodes as viewport-level siblings,
* not as DOM children of this component, so child clicks never reach this div.
*/}
<div
className='absolute inset-0 top-[44px] rounded-b-[8px]'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={() => setCurrentBlockId(id)}
/>
{!isPreview && (
<div
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
@@ -216,9 +217,12 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
)}
<div
className='relative h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
data-dragarea='true'
style={{ pointerEvents: 'none' }}
style={{
position: 'relative',
pointerEvents: 'none',
}}
>
{/* Subflow Start */}
<div
@@ -251,7 +255,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
position={Position.Left}
className={getHandleClasses('left')}
style={{
...HANDLE_STYLE,
...getHandleStyle(),
pointerEvents: 'auto',
}}
/>
@@ -262,7 +266,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
position={Position.Right}
className={getHandleClasses('right')}
style={{
...HANDLE_STYLE,
...getHandleStyle(),
pointerEvents: 'auto',
}}
id={endHandleId}

View File

@@ -527,8 +527,7 @@ const SubBlockRow = memo(function SubBlockRow({
const { displayName: credentialName } = useCredentialName(
credentialSourceId,
credentialProviderId,
workflowId,
workspaceId
workflowId
)
const credentialId = dependencyValues.credential

View File

@@ -20,10 +20,7 @@ import {
TriggerUtils,
} from '@/lib/workflows/triggers/triggers'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import {
markOutgoingEdgesFromOutput,
updateActiveBlockRefCount,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
import { updateActiveBlockRefCount } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
import { getBlock } from '@/blocks'
import type { SerializableExecutionState } from '@/executor/execution/types'
import type {
@@ -66,7 +63,7 @@ interface DebugValidationResult {
interface BlockEventHandlerConfig {
workflowId?: string
executionIdRef: { current: string }
workflowEdges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null }>
workflowEdges: Array<{ id: string; target: string; sourceHandle?: string | null }>
activeBlocksSet: Set<string>
activeBlockRefCounts: Map<string, number>
accumulatedBlockLogs: BlockLog[]
@@ -338,9 +335,13 @@ export function useWorkflowExecution() {
setActiveBlocks(workflowId, new Set(activeBlocksSet))
}
const markOutgoingEdges = (blockId: string, output: Record<string, any> | undefined) => {
const markIncomingEdges = (blockId: string) => {
if (!workflowId) return
markOutgoingEdgesFromOutput(blockId, output, workflowEdges, workflowId, setEdgeRunStatus)
const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId)
incomingEdges.forEach((edge) => {
const status = edge.sourceHandle === 'error' ? 'error' : 'success'
setEdgeRunStatus(workflowId, edge.id, status)
})
}
const isContainerBlockType = (blockType?: string) => {
@@ -459,6 +460,7 @@ export function useWorkflowExecution() {
const onBlockStarted = (data: BlockStartedData) => {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, true)
markIncomingEdges(data.blockId)
if (!includeStartConsoleEntry || !workflowId) return
@@ -485,7 +487,6 @@ export function useWorkflowExecution() {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, false)
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
markOutgoingEdges(data.blockId, data.output as Record<string, any> | undefined)
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
output: data.output,
@@ -504,9 +505,7 @@ export function useWorkflowExecution() {
}
if (isContainerBlockType(data.blockType) && !data.iterationContainerId) {
const output = data.output as Record<string, any> | undefined
const isEmptySubflow = Array.isArray(output?.results) && output.results.length === 0
if (!isEmptySubflow) return
return
}
accumulatedBlockLogs.push(createBlockLogEntry(data, { success: true, output: data.output }))
@@ -528,7 +527,6 @@ export function useWorkflowExecution() {
if (isStaleExecution()) return
updateActiveBlocks(data.blockId, false)
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
markOutgoingEdges(data.blockId, { error: data.error })
executedBlockIds.add(data.blockId)
accumulatedBlockStates.set(data.blockId, {
@@ -1126,7 +1124,9 @@ export function useWorkflowExecution() {
{} as typeof workflowBlocks
)
const isExecutingFromChat = overrideTriggerType === 'chat'
const isExecutingFromChat =
overrideTriggerType === 'chat' ||
(workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput)
logger.info('Executing workflow', {
isDiffMode: currentWorkflow.isDiffMode,
@@ -1495,13 +1495,8 @@ export function useWorkflowExecution() {
: null
if (activeWorkflowId && !workflowExecState?.isDebugging) {
setExecutionResult(executionResult)
// For chat executions, don't set isExecuting=false here — the chat's
// client-side stream wrapper still has buffered data to deliver.
// The chat's finally block handles cleanup after the stream is fully consumed.
if (!isExecutingFromChat) {
setIsExecuting(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
}
setIsExecuting(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: subscriptionKeys.all })
}, 1000)
@@ -1541,7 +1536,7 @@ export function useWorkflowExecution() {
isPreExecutionError,
})
if (activeWorkflowId && !isExecutingFromChat) {
if (activeWorkflowId) {
setIsExecuting(activeWorkflowId, false)
setIsDebugging(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
@@ -1567,7 +1562,7 @@ export function useWorkflowExecution() {
durationMs: data?.duration,
})
if (activeWorkflowId && !isExecutingFromChat) {
if (activeWorkflowId) {
setIsExecuting(activeWorkflowId, false)
setIsDebugging(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())

View File

@@ -1,7 +1,4 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
import { isAncestorProtected, isBlockProtected } from '@/stores/workflows/workflow/utils'
export { isAncestorProtected, isBlockProtected }
/**
* Result of filtering protected blocks from a deletion operation
@@ -15,6 +12,28 @@ export interface FilterProtectedBlocksResult {
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 only if its target block is protected.

View File

@@ -29,62 +29,6 @@ export function updateActiveBlockRefCount(
}
}
/**
* Determines if a workflow edge should be marked as active based on its handle and the block output.
* Mirrors the executor's EdgeManager.shouldActivateEdge logic on the client side.
* Exclude sentinel handles here
*/
function shouldActivateEdgeClient(
handle: string | null | undefined,
output: Record<string, any> | undefined
): boolean {
if (!handle) return true
if (handle.startsWith('condition-')) {
return output?.selectedOption === handle.substring('condition-'.length)
}
if (handle.startsWith('router-')) {
return output?.selectedRoute === handle.substring('router-'.length)
}
switch (handle) {
case 'error':
return !!output?.error
case 'source':
return !output?.error
case 'loop-start-source':
case 'loop-end-source':
case 'parallel-start-source':
case 'parallel-end-source':
return true
default:
return true
}
}
export function markOutgoingEdgesFromOutput(
blockId: string,
output: Record<string, any> | undefined,
workflowEdges: Array<{
id: string
source: string
target: string
sourceHandle?: string | null
}>,
workflowId: string,
setEdgeRunStatus: (wfId: string, edgeId: string, status: 'success' | 'error') => void
): void {
const outgoing = workflowEdges.filter((edge) => edge.source === blockId)
for (const edge of outgoing) {
const handle = edge.sourceHandle
if (shouldActivateEdgeClient(handle, output)) {
const status = handle === 'error' ? 'error' : output?.error ? 'error' : 'success'
setEdgeRunStatus(workflowId, edge.id, status)
}
}
}
export interface WorkflowExecutionOptions {
workflowInput?: any
onStream?: (se: StreamingExecution) => Promise<void>
@@ -191,6 +135,13 @@ export async function executeWorkflowWithFullLogging(
true
)
setActiveBlocks(wfId, new Set(activeBlocksSet))
const incomingEdges = workflowEdges.filter(
(edge) => edge.target === event.data.blockId
)
incomingEdges.forEach((edge) => {
setEdgeRunStatus(wfId, edge.id, 'success')
})
break
}
@@ -204,13 +155,6 @@ export async function executeWorkflowWithFullLogging(
setActiveBlocks(wfId, new Set(activeBlocksSet))
setBlockRunStatus(wfId, event.data.blockId, 'success')
markOutgoingEdgesFromOutput(
event.data.blockId,
event.data.output,
workflowEdges,
wfId,
setEdgeRunStatus
)
addConsole({
input: event.data.input || {},
@@ -250,13 +194,6 @@ export async function executeWorkflowWithFullLogging(
setActiveBlocks(wfId, new Set(activeBlocksSet))
setBlockRunStatus(wfId, event.data.blockId, 'error')
markOutgoingEdgesFromOutput(
event.data.blockId,
{ error: event.data.error },
workflowEdges,
wfId,
setEdgeRunStatus
)
addConsole({
input: event.data.input || {},

View File

@@ -196,14 +196,17 @@ const edgeTypes: EdgeTypes = {
const defaultEdgeOptions = { type: 'custom' }
const reactFlowStyles = [
'bg-[var(--bg)]',
'[&_.react-flow__edges]:!z-0',
'[&_.react-flow__node]:z-[21]',
'[&_.react-flow__handle]:!z-[30]',
'[&_.react-flow__edge-labels]:!z-[1001]',
'[&_.react-flow__edge-labels]:!z-[60]',
'[&_.react-flow__pane]:!bg-[var(--bg)]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
'[&_.react-flow__renderer]:!bg-[var(--bg)]',
'[&_.react-flow__viewport]:!bg-[var(--bg)]',
'[&_.react-flow__background]:hidden',
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const reactFlowProOptions = { hideAttribution: true } as const
@@ -2409,12 +2412,6 @@ const WorkflowContent = React.memo(() => {
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
// Compute zIndex for blocks inside containers so they render above the
// parent subflow's interactive body area (which needs pointer-events for
// click-to-select). Container nodes use zIndex: depth (0, 1, 2...),
// so child blocks use a baseline that is always above any container.
const childZIndex = block.data?.parentId ? 1000 : undefined
// Create stable node object - React Flow will handle shallow comparison
nodeArray.push({
id: block.id,
@@ -2423,7 +2420,6 @@ const WorkflowContent = React.memo(() => {
parentId: block.data?.parentId,
dragHandle,
draggable: !isBlockProtected(block.id, blocks),
...(childZIndex !== undefined && { zIndex: childZIndex }),
extent: (() => {
// Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined
@@ -3772,20 +3768,21 @@ const WorkflowContent = React.memo(() => {
return (
<div className='flex h-full w-full flex-col overflow-hidden'>
<div className='relative h-full w-full flex-1'>
{!isWorkflowReady && (
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
<div
className='h-[18px] w-[18px] animate-spin rounded-full'
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
)}
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
<div
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
>
<div
className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
style={{
background:
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
WebkitMask:
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
}}
/>
</div>
{isWorkflowReady && (
<>
@@ -3838,7 +3835,7 @@ const WorkflowContent = React.memo(() => {
noWheelClassName='allow-scroll'
edgesFocusable={true}
edgesUpdatable={effectivePermissions.canEdit}
className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
@@ -3850,7 +3847,7 @@ const WorkflowContent = React.memo(() => {
elevateEdgesOnSelect={true}
onlyRenderVisibleElements={false}
deleteKeyCode={null}
elevateNodesOnSelect={false}
elevateNodesOnSelect={true}
autoPanOnConnect={effectivePermissions.canEdit}
autoPanOnNodeDrag={effectivePermissions.canEdit}
/>

View File

@@ -145,7 +145,7 @@ interface PreviewWorkflowProps {
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string; output?: unknown }>
executedBlocks?: Record<string, { status: string }>
/** Currently selected block ID for highlighting */
selectedBlockId?: string | null
/** Skips expensive subblock computations for thumbnails/template previews */
@@ -274,9 +274,9 @@ export function PreviewWorkflow({
/** Maps base block IDs to execution data, handling parallel iteration variants (blockId₍n₎). */
const blockExecutionMap = useMemo(() => {
if (!executedBlocks) return new Map<string, { status: string; output?: unknown }>()
if (!executedBlocks) return new Map<string, { status: string }>()
const map = new Map<string, { status: string; output?: unknown }>()
const map = new Map<string, { status: string }>()
for (const [key, value] of Object.entries(executedBlocks)) {
// Extract base ID (remove iteration suffix like ₍0₎)
const baseId = key.includes('₍') ? key.split('₍')[0] : key
@@ -451,6 +451,7 @@ export function PreviewWorkflow({
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
/** Edge is green if target executed and source condition met by edge type. */
const getEdgeExecutionStatus = (edge: {
source: string
target: string
@@ -462,40 +463,17 @@ export function PreviewWorkflow({
if (!targetStatus?.executed) return 'not-executed'
const sourceStatus = getBlockExecutionStatus(edge.source)
if (!sourceStatus?.executed) return 'not-executed'
const { sourceHandle } = edge
const handle = edge.sourceHandle
if (!handle) {
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
if (sourceHandle === 'error') {
return sourceStatus?.status === 'error' ? 'success' : 'not-executed'
}
const sourceOutput = blockExecutionMap.get(edge.source)?.output as
| Record<string, any>
| undefined
if (handle.startsWith('condition-')) {
const conditionValue = handle.substring('condition-'.length)
return sourceOutput?.selectedOption === conditionValue ? 'success' : 'not-executed'
if (sourceHandle === 'loop-start-source' || sourceHandle === 'parallel-start-source') {
return 'success'
}
if (handle.startsWith('router-')) {
const routeId = handle.substring('router-'.length)
return sourceOutput?.selectedRoute === routeId ? 'success' : 'not-executed'
}
switch (handle) {
case 'error':
return sourceStatus.status === 'error' ? 'error' : 'not-executed'
case 'source':
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
case 'loop-start-source':
case 'loop-end-source':
case 'parallel-start-source':
case 'parallel-end-source':
return 'success'
default:
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
}
return sourceStatus?.status === 'success' ? 'success' : 'not-executed'
}
return (workflowState.edges || []).map((edge) => {

View File

@@ -129,30 +129,6 @@ Output: {"short_description": "Network outage", "description": "Network connecti
condition: { field: 'operation', value: 'servicenow_read_record' },
mode: 'advanced',
},
{
id: 'offset',
title: 'Offset',
type: 'short-input',
placeholder: '0',
condition: { field: 'operation', value: 'servicenow_read_record' },
description: 'Number of records to skip for pagination',
mode: 'advanced',
},
{
id: 'displayValue',
title: 'Display Value',
type: 'dropdown',
options: [
{ label: 'Default (not set)', id: '' },
{ label: 'False (sys_id only)', id: 'false' },
{ label: 'True (display value only)', id: 'true' },
{ label: 'All (both)', id: 'all' },
],
value: () => '',
condition: { field: 'operation', value: 'servicenow_read_record' },
description: 'Return display values for reference fields instead of sys_ids',
mode: 'advanced',
},
{
id: 'fields',
title: 'Fields to Return',
@@ -227,9 +203,6 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
const isCreateOrUpdate =
operation === 'servicenow_create_record' || operation === 'servicenow_update_record'
if (rest.limit != null && rest.limit !== '') rest.limit = Number(rest.limit)
if (rest.offset != null && rest.offset !== '') rest.offset = Number(rest.offset)
if (fields && isCreateOrUpdate) {
const parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields
return { ...rest, fields: parsedFields }
@@ -249,9 +222,7 @@ Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and st
number: { type: 'string', description: 'Record number' },
query: { type: 'string', description: 'Query string' },
limit: { type: 'number', description: 'Result limit' },
offset: { type: 'number', description: 'Pagination offset' },
fields: { type: 'json', description: 'Fields object or JSON string' },
displayValue: { type: 'string', description: 'Display value mode for reference fields' },
},
outputs: {
record: { type: 'json', description: 'Single ServiceNow record' },

View File

@@ -9,10 +9,10 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
type: 'slack',
name: 'Slack',
description:
'Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack',
'Send, update, delete messages, send ephemeral messages, add reactions in Slack or trigger workflows from Slack events',
authMode: AuthMode.OAuth,
longDescription:
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.',
docsLink: 'https://docs.sim.ai/tools/slack',
category: 'tools',
bgColor: '#611f69',
@@ -38,11 +38,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
{ label: 'Update Message', id: 'update' },
{ label: 'Delete Message', id: 'delete' },
{ label: 'Add Reaction', id: 'react' },
{ label: 'Remove Reaction', id: 'unreact' },
{ label: 'Get Channel Info', id: 'get_channel_info' },
{ label: 'Get User Presence', id: 'get_user_presence' },
{ label: 'Edit Canvas', id: 'edit_canvas' },
{ label: 'Create Channel Canvas', id: 'create_channel_canvas' },
],
value: () => 'send',
},
@@ -146,7 +141,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
}
return {
field: 'operation',
value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'],
value: ['list_channels', 'list_users', 'get_user'],
not: true,
and: {
field: 'destinationType',
@@ -171,7 +166,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
}
return {
field: 'operation',
value: ['list_channels', 'list_users', 'get_user', 'get_user_presence', 'edit_canvas'],
value: ['list_channels', 'list_users', 'get_user'],
not: true,
and: {
field: 'destinationType',
@@ -214,26 +209,8 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
{
id: 'ephemeralUser',
title: 'Target User',
type: 'user-selector',
canonicalParamId: 'ephemeralUser',
serviceId: 'slack',
selectorKey: 'slack.users',
placeholder: 'Select Slack user',
mode: 'basic',
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
condition: {
field: 'operation',
value: 'ephemeral',
},
required: true,
},
{
id: 'manualEphemeralUser',
title: 'Target User ID',
type: 'short-input',
canonicalParamId: 'ephemeralUser',
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
mode: 'advanced',
placeholder: 'User ID who will see the message (e.g., U1234567890)',
condition: {
field: 'operation',
value: 'ephemeral',
@@ -463,27 +440,9 @@ Do not include any explanations, markdown formatting, or other text outside the
// Get User specific fields
{
id: 'userId',
title: 'User',
type: 'user-selector',
canonicalParamId: 'userId',
serviceId: 'slack',
selectorKey: 'slack.users',
placeholder: 'Select Slack user',
mode: 'basic',
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
condition: {
field: 'operation',
value: 'get_user',
},
required: true,
},
{
id: 'manualUserId',
title: 'User ID',
type: 'short-input',
canonicalParamId: 'userId',
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
mode: 'advanced',
condition: {
field: 'operation',
value: 'get_user',
@@ -649,7 +608,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Message timestamp (e.g., 1405894322.002768)',
condition: {
field: 'operation',
value: ['react', 'unreact'],
value: 'react',
},
required: true,
},
@@ -660,150 +619,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Emoji name without colons (e.g., thumbsup, heart, eyes)',
condition: {
field: 'operation',
value: ['react', 'unreact'],
value: 'react',
},
required: true,
},
// Get Channel Info specific fields
{
id: 'includeNumMembers',
title: 'Include Member Count',
type: 'dropdown',
options: [
{ label: 'Yes', id: 'true' },
{ label: 'No', id: 'false' },
],
value: () => 'true',
condition: {
field: 'operation',
value: 'get_channel_info',
},
},
// Get User Presence specific fields
{
id: 'presenceUserId',
title: 'User',
type: 'user-selector',
canonicalParamId: 'presenceUserId',
serviceId: 'slack',
selectorKey: 'slack.users',
placeholder: 'Select Slack user',
mode: 'basic',
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] },
condition: {
field: 'operation',
value: 'get_user_presence',
},
required: true,
},
{
id: 'manualPresenceUserId',
title: 'User ID',
type: 'short-input',
canonicalParamId: 'presenceUserId',
placeholder: 'Enter Slack user ID (e.g., U1234567890)',
mode: 'advanced',
condition: {
field: 'operation',
value: 'get_user_presence',
},
required: true,
},
// Edit Canvas specific fields
{
id: 'editCanvasId',
title: 'Canvas ID',
type: 'short-input',
placeholder: 'Enter canvas ID (e.g., F1234ABCD)',
condition: {
field: 'operation',
value: 'edit_canvas',
},
required: true,
},
{
id: 'canvasOperation',
title: 'Edit Operation',
type: 'dropdown',
options: [
{ label: 'Insert at Start', id: 'insert_at_start' },
{ label: 'Insert at End', id: 'insert_at_end' },
{ label: 'Insert After Section', id: 'insert_after' },
{ label: 'Insert Before Section', id: 'insert_before' },
{ label: 'Replace Section', id: 'replace' },
{ label: 'Delete Section', id: 'delete' },
{ label: 'Rename Canvas', id: 'rename' },
],
value: () => 'insert_at_end',
condition: {
field: 'operation',
value: 'edit_canvas',
},
required: true,
},
{
id: 'canvasContent',
title: 'Content',
type: 'long-input',
placeholder: 'Enter content in markdown format',
condition: {
field: 'operation',
value: 'edit_canvas',
and: {
field: 'canvasOperation',
value: ['delete', 'rename'],
not: true,
},
},
},
{
id: 'sectionId',
title: 'Section ID',
type: 'short-input',
placeholder: 'Section ID to target',
condition: {
field: 'operation',
value: 'edit_canvas',
and: {
field: 'canvasOperation',
value: ['insert_after', 'insert_before', 'replace', 'delete'],
},
},
required: true,
},
{
id: 'canvasTitle',
title: 'New Title',
type: 'short-input',
placeholder: 'Enter new canvas title',
condition: {
field: 'operation',
value: 'edit_canvas',
and: { field: 'canvasOperation', value: 'rename' },
},
required: true,
},
// Create Channel Canvas specific fields
{
id: 'channelCanvasTitle',
title: 'Canvas Title',
type: 'short-input',
placeholder: 'Enter canvas title (optional)',
condition: {
field: 'operation',
value: 'create_channel_canvas',
},
},
{
id: 'channelCanvasContent',
title: 'Canvas Content',
type: 'long-input',
placeholder: 'Enter canvas content (markdown supported)',
condition: {
field: 'operation',
value: 'create_channel_canvas',
},
},
...getTrigger('slack_webhook').subBlocks,
],
tools: {
@@ -822,11 +641,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
'slack_update_message',
'slack_delete_message',
'slack_add_reaction',
'slack_remove_reaction',
'slack_get_channel_info',
'slack_get_user_presence',
'slack_edit_canvas',
'slack_create_channel_canvas',
],
config: {
tool: (params) => {
@@ -859,16 +673,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
return 'slack_delete_message'
case 'react':
return 'slack_add_reaction'
case 'unreact':
return 'slack_remove_reaction'
case 'get_channel_info':
return 'slack_get_channel_info'
case 'get_user_presence':
return 'slack_get_user_presence'
case 'edit_canvas':
return 'slack_edit_canvas'
case 'create_channel_canvas':
return 'slack_create_channel_canvas'
default:
throw new Error(`Invalid Slack operation: ${params.operation}`)
}
@@ -906,15 +710,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
getMessageTimestamp,
getThreadTimestamp,
threadLimit,
includeNumMembers,
presenceUserId,
editCanvasId,
canvasOperation,
canvasContent,
sectionId,
canvasTitle,
channelCanvasTitle,
channelCanvasContent,
...rest
} = params
@@ -1025,10 +820,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
case 'download': {
const fileId = (rest as any).fileId
const fileName = (rest as any).fileName
const downloadFileName = (rest as any).downloadFileName
baseParams.fileId = fileId
if (fileName) {
baseParams.fileName = fileName
if (downloadFileName) {
baseParams.fileName = downloadFileName
}
break
}
@@ -1046,41 +841,9 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
break
case 'react':
case 'unreact':
baseParams.timestamp = reactionTimestamp
baseParams.name = emojiName
break
case 'get_channel_info':
baseParams.includeNumMembers = includeNumMembers !== 'false'
break
case 'get_user_presence':
baseParams.userId = presenceUserId
break
case 'edit_canvas':
baseParams.canvasId = editCanvasId
baseParams.operation = canvasOperation
if (canvasContent) {
baseParams.content = canvasContent
}
if (sectionId) {
baseParams.sectionId = sectionId
}
if (canvasTitle) {
baseParams.title = canvasTitle
}
break
case 'create_channel_canvas':
if (channelCanvasTitle) {
baseParams.title = channelCanvasTitle
}
if (channelCanvasContent) {
baseParams.content = channelCanvasContent
}
break
}
return baseParams
@@ -1135,19 +898,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
type: 'string',
description: 'Maximum number of messages to return from thread',
},
// Get Channel Info inputs
includeNumMembers: { type: 'string', description: 'Include member count (true/false)' },
// Get User Presence inputs
presenceUserId: { type: 'string', description: 'User ID to check presence for' },
// Edit Canvas inputs
editCanvasId: { type: 'string', description: 'Canvas ID to edit' },
canvasOperation: { type: 'string', description: 'Canvas edit operation' },
canvasContent: { type: 'string', description: 'Markdown content for canvas edit' },
sectionId: { type: 'string', description: 'Canvas section ID to target' },
canvasTitle: { type: 'string', description: 'New canvas title for rename' },
// Create Channel Canvas inputs
channelCanvasTitle: { type: 'string', description: 'Title for channel canvas' },
channelCanvasContent: { type: 'string', description: 'Content for channel canvas' },
},
outputs: {
// slack_message outputs (send operation)
@@ -1244,43 +994,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
description: 'Updated message metadata (legacy, use message object instead)',
},
// slack_get_channel_info outputs (get_channel_info operation)
channelInfo: {
type: 'json',
description:
'Detailed channel object with properties: id, name, is_private, is_archived, is_member, num_members, topic, purpose, created, creator',
},
// slack_get_user_presence outputs (get_user_presence operation)
presence: {
type: 'string',
description: 'User presence status: "active" or "away"',
},
online: {
type: 'boolean',
description:
'Whether user has an active client connection (only available when checking own presence)',
},
autoAway: {
type: 'boolean',
description:
'Whether user was automatically set to away (only available when checking own presence)',
},
manualAway: {
type: 'boolean',
description:
'Whether user manually set themselves as away (only available when checking own presence)',
},
connectionCount: {
type: 'number',
description: 'Total number of active connections (only available when checking own presence)',
},
lastActivity: {
type: 'number',
description:
'Unix timestamp of last detected activity (only available when checking own presence)',
},
// Trigger outputs (when used as webhook trigger)
event_type: { type: 'string', description: 'Type of Slack event that triggered the workflow' },
channel_name: { type: 'string', description: 'Human-readable channel name' },

View File

@@ -618,8 +618,6 @@ export class BlockExecutor {
await ctx.onStream?.(clientStreamingExec)
} catch (error) {
logger.error('Error in onStream callback', { blockId, error })
// Cancel the client stream to release the tee'd buffer
await processedClientStream.cancel().catch(() => {})
}
})()
@@ -648,7 +646,6 @@ export class BlockExecutor {
})
} catch (error) {
logger.error('Error in onStream callback', { blockId, error })
await processedStream.cancel().catch(() => {})
}
}
@@ -660,25 +657,22 @@ export class BlockExecutor {
): Promise<void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
let fullContent = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(decoder.decode(value, { stream: true }))
fullContent += decoder.decode(value, { stream: true })
}
const tail = decoder.decode()
if (tail) chunks.push(tail)
} catch (error) {
logger.error('Error reading executor stream for block', { blockId, error })
} finally {
try {
await reader.cancel().catch(() => {})
reader.releaseLock()
} catch {}
}
const fullContent = chunks.join('')
if (!fullContent) {
return
}

View File

@@ -66,15 +66,11 @@ describe('EdgeManager', () => {
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
const readyAfterA = edgeManager.processOutgoingEdges(blockANode, {
result: 'done',
})
const readyAfterA = edgeManager.processOutgoingEdges(blockANode, { result: 'done' })
expect(readyAfterA).toContain(blockBId)
expect(readyAfterA).not.toContain(blockCId)
const readyAfterB = edgeManager.processOutgoingEdges(blockBNode, {
result: 'done',
})
const readyAfterB = edgeManager.processOutgoingEdges(blockBNode, { result: 'done' })
expect(readyAfterB).toContain(blockCId)
})
@@ -595,9 +591,7 @@ describe('EdgeManager', () => {
function1Node.incomingEdges.add(conditionId)
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'if',
})
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'if' })
expect(readyNodes).toContain(function1Id)
})
})
@@ -983,15 +977,11 @@ describe('EdgeManager', () => {
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
const ready1 = edgeManager.processOutgoingEdges(condition1Node, {
selectedOption: 'if',
})
const ready1 = edgeManager.processOutgoingEdges(condition1Node, { selectedOption: 'if' })
expect(ready1).toContain(condition2Id)
expect(ready1).not.toContain(target1Id)
const ready2 = edgeManager.processOutgoingEdges(condition2Node, {
selectedOption: 'else',
})
const ready2 = edgeManager.processOutgoingEdges(condition2Node, { selectedOption: 'else' })
expect(ready2).toContain(target1Id)
expect(ready2).not.toContain(target2Id)
})
@@ -1404,14 +1394,10 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Path: condition1(if) → condition2(else) → nodeC → sentinel_end
const ready1 = edgeManager.processOutgoingEdges(condition1Node, {
selectedOption: 'if',
})
const ready1 = edgeManager.processOutgoingEdges(condition1Node, { selectedOption: 'if' })
expect(ready1).toContain(condition2Id)
const ready2 = edgeManager.processOutgoingEdges(condition2Node, {
selectedOption: 'else',
})
const ready2 = edgeManager.processOutgoingEdges(condition2Node, { selectedOption: 'else' })
expect(ready2).toContain(nodeCId)
const ready3 = edgeManager.processOutgoingEdges(nodeCNode, {})
@@ -1462,9 +1448,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Test else path through diamond
const ready1 = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else',
})
const ready1 = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
expect(ready1).toContain(nodeBId)
expect(ready1).not.toContain(nodeAId)
@@ -1525,9 +1509,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Select else - triggers deep cascade deactivation of if path
const ready1 = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else',
})
const ready1 = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
expect(ready1).toContain(nodeDId)
const ready2 = edgeManager.processOutgoingEdges(nodeDNode, {})
@@ -1584,9 +1566,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Test middle branch (elseif2)
const ready1 = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'elseif2',
})
const ready1 = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'elseif2' })
expect(ready1).toContain(nodeCId)
expect(ready1).not.toContain(nodeAId)
expect(ready1).not.toContain(nodeBId)
@@ -1649,7 +1629,7 @@ describe('EdgeManager', () => {
// Scenario: Loop with Function 1 → Condition 1 → Function 2
// Condition has "if" branch → Function 2
// Condition has "else" branch → NO connection (dead end)
// When else is selected, the loop sentinel should still fire
// When else is selected (selectedOption: null), the loop should continue
//
// DAG structure:
// sentinel_start → func1 → condition → (if) → func2 → sentinel_end
@@ -1657,12 +1637,11 @@ describe('EdgeManager', () => {
// sentinel_end → (loop_continue) → sentinel_start
//
// When condition takes else with no edge:
// - selectedOption is set (condition made a routing decision)
// - selectedOption: null (no condition matches)
// - The "if" edge gets deactivated
// - func2 has no other active incoming edges, so edge to sentinel_end gets deactivated
// - sentinel_end is the enclosing loop's sentinel and should become ready
// - sentinel_end has no active incoming edges and should become ready
const loopId = 'loop-1'
const sentinelStartId = 'sentinel-start'
const sentinelEndId = 'sentinel-end'
const func1Id = 'func1'
@@ -1670,21 +1649,14 @@ describe('EdgeManager', () => {
const func2Id = 'func2'
const sentinelStartNode = createMockNode(sentinelStartId, [{ target: func1Id }])
sentinelStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId }
const func1Node = createMockNode(func1Id, [{ target: conditionId }], [sentinelStartId])
func1Node.metadata = { loopId, isLoopNode: true }
// Condition only has "if" branch, no "else" edge (dead end)
const conditionNode = createMockNode(
conditionId,
[{ target: func2Id, sourceHandle: 'condition-if' }],
[func1Id]
)
conditionNode.metadata = { loopId, isLoopNode: true }
const func2Node = createMockNode(func2Id, [{ target: sentinelEndId }], [conditionId])
func2Node.metadata = { loopId, isLoopNode: true }
const sentinelEndNode = createMockNode(
sentinelEndId,
[
@@ -1693,8 +1665,6 @@ describe('EdgeManager', () => {
],
[func2Id]
)
sentinelEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId }
const afterLoopNode = createMockNode('after-loop', [], [sentinelEndId])
const nodes = new Map<string, DAGNode>([
@@ -1709,17 +1679,22 @@ describe('EdgeManager', () => {
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Simulate execution: sentinel_start → func1 → condition
// Clear incoming edges as execution progresses (simulating normal flow)
func1Node.incomingEdges.clear()
conditionNode.incomingEdges.clear()
// Condition selects dead-end else (selectedOption is set — routing decision made)
// but it's inside the loop, so the enclosing sentinel should still fire
// Condition takes "else" but there's no else edge
// selectedOption: null means no condition branch matches
const ready = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else-id',
conditionResult: true,
selectedOption: null,
conditionResult: false,
selectedPath: null,
})
// The "if" edge to func2 should be deactivated
// func2 has no other incoming edges, so its edge to sentinel_end gets deactivated
// sentinel_end has no active incoming edges and should be ready
expect(ready).toContain(sentinelEndId)
})
@@ -1788,12 +1763,11 @@ describe('EdgeManager', () => {
// → (else) → [nothing]
// → (else) → [nothing]
//
// When condition1 takes if, then condition2 takes else (dead-end):
// When condition1 takes if, then condition2 takes else:
// - condition2's "if" edge to func gets deactivated
// - func's edge to sentinel_end gets deactivated
// - sentinel_end is the enclosing loop's sentinel and should become ready
// - sentinel_end should become ready
const loopId = 'loop-1'
const sentinelStartId = 'sentinel-start'
const sentinelEndId = 'sentinel-end'
const condition1Id = 'condition1'
@@ -1801,31 +1775,22 @@ describe('EdgeManager', () => {
const funcId = 'func'
const sentinelStartNode = createMockNode(sentinelStartId, [{ target: condition1Id }])
sentinelStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId }
const condition1Node = createMockNode(
condition1Id,
[{ target: condition2Id, sourceHandle: 'condition-if' }],
[sentinelStartId]
)
condition1Node.metadata = { loopId, isLoopNode: true }
const condition2Node = createMockNode(
condition2Id,
[{ target: funcId, sourceHandle: 'condition-if' }],
[condition1Id]
)
condition2Node.metadata = { loopId, isLoopNode: true }
const funcNode = createMockNode(funcId, [{ target: sentinelEndId }], [condition2Id])
funcNode.metadata = { loopId, isLoopNode: true }
const sentinelEndNode = createMockNode(
sentinelEndId,
[{ target: sentinelStartId, sourceHandle: 'loop_continue' }],
[funcId]
)
sentinelEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId }
const nodes = new Map<string, DAGNode>([
[sentinelStartId, sentinelStartNode],
@@ -1838,95 +1803,22 @@ describe('EdgeManager', () => {
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Clear incoming edges as execution progresses
condition1Node.incomingEdges.clear()
const ready1 = edgeManager.processOutgoingEdges(condition1Node, {
selectedOption: 'if',
})
// condition1 takes "if" - condition2 becomes ready
const ready1 = edgeManager.processOutgoingEdges(condition1Node, { selectedOption: 'if' })
expect(ready1).toContain(condition2Id)
condition2Node.incomingEdges.clear()
// condition2 selects dead-end else (selectedOption set — routing decision made)
const ready2 = edgeManager.processOutgoingEdges(condition2Node, {
selectedOption: 'else-id',
})
// condition2 takes "else" (dead end)
const ready2 = edgeManager.processOutgoingEdges(condition2Node, { selectedOption: null })
// sentinel_end is the enclosing loop's sentinel and should be ready
// sentinel_end should be ready because all paths to it are deactivated
expect(ready2).toContain(sentinelEndId)
})
it('should not fire nested subflow sentinel when condition inside outer loop hits dead-end', () => {
// Scenario: outer loop contains condition → (if) → inner loop → sentinel_end
// → (else) → [dead end]
//
// When condition selects dead-end else:
// - The outer loop's sentinel should fire (enclosing subflow)
// - The inner loop's sentinel should NOT fire (downstream subflow)
const outerLoopId = 'outer-loop'
const innerLoopId = 'inner-loop'
const outerStartId = 'outer-start'
const outerEndId = 'outer-end'
const conditionId = 'condition'
const innerStartId = 'inner-start'
const innerBodyId = 'inner-body'
const innerEndId = 'inner-end'
const outerStartNode = createMockNode(outerStartId, [{ target: conditionId }])
outerStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId: outerLoopId }
const conditionNode = createMockNode(
conditionId,
[{ target: innerStartId, sourceHandle: 'condition-if' }],
[outerStartId]
)
conditionNode.metadata = { loopId: outerLoopId, isLoopNode: true }
const innerStartNode = createMockNode(innerStartId, [{ target: innerBodyId }], [conditionId])
innerStartNode.metadata = { isSentinel: true, sentinelType: 'start', loopId: innerLoopId }
const innerBodyNode = createMockNode(innerBodyId, [{ target: innerEndId }], [innerStartId])
innerBodyNode.metadata = { loopId: innerLoopId, isLoopNode: true }
const innerEndNode = createMockNode(
innerEndId,
[{ target: outerEndId, sourceHandle: 'loop_exit' }],
[innerBodyId]
)
innerEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId: innerLoopId }
const outerEndNode = createMockNode(
outerEndId,
[{ target: outerStartId, sourceHandle: 'loop_continue' }],
[innerEndId]
)
outerEndNode.metadata = { isSentinel: true, sentinelType: 'end', loopId: outerLoopId }
const nodes = new Map<string, DAGNode>([
[outerStartId, outerStartNode],
[conditionId, conditionNode],
[innerStartId, innerStartNode],
[innerBodyId, innerBodyNode],
[innerEndId, innerEndNode],
[outerEndId, outerEndNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
conditionNode.incomingEdges.clear()
const ready = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else-id',
})
// Outer loop sentinel should fire (condition is inside outer loop)
expect(ready).toContain(outerEndId)
// Inner loop sentinel should NOT fire (it's a downstream subflow)
expect(ready).not.toContain(innerEndId)
})
it('should NOT execute intermediate nodes in long cascade chains (2+ hops)', () => {
// Regression test: When condition hits dead-end with 2+ intermediate nodes,
// only sentinel_end should be ready, NOT the intermediate nodes.
@@ -2030,9 +1922,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Select else path
const ready1 = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else',
})
const ready1 = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
expect(ready1).toContain(nodeBId)
expect(ready1).not.toContain(nodeAId)
@@ -2078,9 +1968,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// When selectedOption is null, the cascade deactivation makes parallel_end ready
const ready = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: null,
})
const ready = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: null })
expect(ready).toContain(parallelEndId)
})
@@ -2151,15 +2039,11 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Branch 1: condition1 selects else
const ready1 = edgeManager.processOutgoingEdges(condition1Node, {
selectedOption: 'else',
})
const ready1 = edgeManager.processOutgoingEdges(condition1Node, { selectedOption: 'else' })
expect(ready1).toContain(nodeBId)
// Branch 2: condition2 selects if
const ready2 = edgeManager.processOutgoingEdges(condition2Node, {
selectedOption: 'if',
})
const ready2 = edgeManager.processOutgoingEdges(condition2Node, { selectedOption: 'if' })
expect(ready2).toContain(nodeCId)
// Both complete
@@ -2316,9 +2200,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// nodeA errors
const ready1 = edgeManager.processOutgoingEdges(nodeANode, {
error: 'Something failed',
})
const ready1 = edgeManager.processOutgoingEdges(nodeANode, { error: 'Something failed' })
expect(ready1).toContain(errorNodeId)
expect(ready1).not.toContain(successNodeId)
@@ -2407,9 +2289,7 @@ describe('EdgeManager', () => {
edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'if' })
edgeManager.processOutgoingEdges(nodeANode, {})
const ready2 = edgeManager.processOutgoingEdges(loopEndNode, {
selectedRoute: 'loop_exit',
})
const ready2 = edgeManager.processOutgoingEdges(loopEndNode, { selectedRoute: 'loop_exit' })
expect(ready2).toContain(parallelEndId)
const ready3 = edgeManager.processOutgoingEdges(parallelEndNode, {
@@ -2533,9 +2413,7 @@ describe('EdgeManager', () => {
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
const successReady = edgeManager.processOutgoingEdges(sourceNode, {
result: 'ok',
})
const successReady = edgeManager.processOutgoingEdges(sourceNode, { result: 'ok' })
expect(successReady).toContain(targetId)
})
})
@@ -2594,9 +2472,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Condition selects "else" branch, deactivating the "if" branch (which contains the loop)
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else',
})
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
// Only otherBranch should be ready
expect(readyNodes).toContain(otherBranchId)
@@ -2663,9 +2539,7 @@ describe('EdgeManager', () => {
const edgeManager = new EdgeManager(dag)
// Condition selects "else" branch
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else',
})
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
expect(readyNodes).toContain(otherBranchId)
expect(readyNodes).not.toContain(parallelStartId)
@@ -2752,171 +2626,6 @@ describe('EdgeManager', () => {
expect(readyNodes).not.toContain(afterLoopId)
})
it('should not queue sentinel-end when condition selects no-edge path (loop)', () => {
// Bug scenario: condition → (if) → sentinel_start → body → sentinel_end → (loop_exit) → after_loop
// → (else) → [NO outgoing edge]
// Condition evaluates false, else is selected but has no edge.
// With selectedOption set (routing decision made), cascadeTargets should NOT be queued.
// Previously sentinel_end was queued via cascadeTargets, causing downstream blocks to execute.
const conditionId = 'condition'
const sentinelStartId = 'sentinel-start'
const loopBodyId = 'loop-body'
const sentinelEndId = 'sentinel-end'
const afterLoopId = 'after-loop'
const conditionNode = createMockNode(conditionId, [
{ target: sentinelStartId, sourceHandle: 'condition-if-id' },
])
const sentinelStartNode = createMockNode(
sentinelStartId,
[{ target: loopBodyId }],
[conditionId]
)
const loopBodyNode = createMockNode(
loopBodyId,
[{ target: sentinelEndId }],
[sentinelStartId]
)
const sentinelEndNode = createMockNode(
sentinelEndId,
[
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
{ target: afterLoopId, sourceHandle: 'loop_exit' },
],
[loopBodyId]
)
const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId])
const nodes = new Map<string, DAGNode>([
[conditionId, conditionNode],
[sentinelStartId, sentinelStartNode],
[loopBodyId, loopBodyNode],
[sentinelEndId, sentinelEndNode],
[afterLoopId, afterLoopNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Condition selected else, but else has no outgoing edge.
// selectedOption is set (routing decision was made).
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else-id',
})
// Nothing should be queued -- the entire branch is intentionally dead
expect(readyNodes).not.toContain(sentinelStartId)
expect(readyNodes).not.toContain(loopBodyId)
expect(readyNodes).not.toContain(sentinelEndId)
expect(readyNodes).not.toContain(afterLoopId)
expect(readyNodes).toHaveLength(0)
})
it('should not queue sentinel-end when condition selects no-edge path (parallel)', () => {
// Same scenario with parallel instead of loop
const conditionId = 'condition'
const parallelStartId = 'parallel-start'
const branchId = 'branch-0'
const parallelEndId = 'parallel-end'
const afterParallelId = 'after-parallel'
const conditionNode = createMockNode(conditionId, [
{ target: parallelStartId, sourceHandle: 'condition-if-id' },
])
const parallelStartNode = createMockNode(
parallelStartId,
[{ target: branchId }],
[conditionId]
)
const branchNode = createMockNode(
branchId,
[{ target: parallelEndId, sourceHandle: 'parallel_exit' }],
[parallelStartId]
)
const parallelEndNode = createMockNode(
parallelEndId,
[{ target: afterParallelId, sourceHandle: 'parallel_exit' }],
[branchId]
)
const afterParallelNode = createMockNode(afterParallelId, [], [parallelEndId])
const nodes = new Map<string, DAGNode>([
[conditionId, conditionNode],
[parallelStartId, parallelStartNode],
[branchId, branchNode],
[parallelEndId, parallelEndNode],
[afterParallelId, afterParallelNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: 'else-id',
})
expect(readyNodes).not.toContain(parallelStartId)
expect(readyNodes).not.toContain(branchId)
expect(readyNodes).not.toContain(parallelEndId)
expect(readyNodes).not.toContain(afterParallelId)
expect(readyNodes).toHaveLength(0)
})
it('should still queue sentinel-end inside loop when no condition matches (true dead-end)', () => {
// Contrast: condition INSIDE a loop with selectedOption null (no match, no routing decision).
// This is a true dead-end where cascadeTargets SHOULD fire so the loop sentinel can handle exit.
const sentinelStartId = 'sentinel-start'
const sentinelEndId = 'sentinel-end'
const conditionId = 'condition'
const nodeAId = 'node-a'
const sentinelStartNode = createMockNode(sentinelStartId, [{ target: conditionId }])
const conditionNode = createMockNode(
conditionId,
[{ target: nodeAId, sourceHandle: 'condition-if' }],
[sentinelStartId]
)
const nodeANode = createMockNode(nodeAId, [{ target: sentinelEndId }], [conditionId])
const sentinelEndNode = createMockNode(
sentinelEndId,
[
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
{ target: 'after-loop', sourceHandle: 'loop_exit' },
],
[nodeAId]
)
const nodes = new Map<string, DAGNode>([
[sentinelStartId, sentinelStartNode],
[conditionId, conditionNode],
[nodeAId, nodeANode],
[sentinelEndId, sentinelEndNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
conditionNode.incomingEdges.clear()
// selectedOption: null → no routing decision, true dead-end
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, {
selectedOption: null,
})
// sentinel-end SHOULD be queued (true dead-end inside loop)
expect(readyNodes).toContain(sentinelEndId)
})
it('should still correctly handle normal loop exit (not deactivate when loop runs)', () => {
// When a loop actually executes and exits normally, after_loop should become ready
const sentinelStartId = 'sentinel-start'

View File

@@ -69,23 +69,15 @@ export class EdgeManager {
}
}
const isDeadEnd = activatedTargets.length === 0
const isRoutedDeadEnd = isDeadEnd && !!(output.selectedOption || output.selectedRoute)
for (const targetId of cascadeTargets) {
if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) {
if (!isDeadEnd || !this.isTargetReady(targetId)) continue
if (isRoutedDeadEnd) {
// A condition/router deliberately selected a dead-end path.
// Only queue the sentinel if it belongs to the SAME subflow as the
// current node (the condition is inside the loop/parallel and the
// loop still needs to continue/exit). Downstream subflow sentinels
// should NOT fire.
if (this.isEnclosingSentinel(node, targetId)) {
readyNodes.push(targetId)
}
} else {
// Only queue cascade terminal control nodes when ALL outgoing edges from the
// current node were deactivated (dead-end scenario). When some edges are
// activated, terminal control nodes on deactivated branches should NOT be
// queued - they will be reached through the normal activated path's completion.
// This prevents loop/parallel sentinels on fully deactivated paths (e.g., an
// upstream condition took a different branch) from being spuriously executed.
if (activatedTargets.length === 0 && this.isTargetReady(targetId)) {
readyNodes.push(targetId)
}
}
@@ -153,27 +145,6 @@ export class EdgeManager {
return targetNode ? this.isNodeReady(targetNode) : false
}
/**
* Checks if the cascade target sentinel belongs to the same subflow as the source node.
* A condition inside a loop that hits a dead-end should still allow the enclosing
* loop's sentinel to fire so the loop can continue or exit.
*/
private isEnclosingSentinel(sourceNode: DAGNode, sentinelId: string): boolean {
const sentinel = this.dag.nodes.get(sentinelId)
if (!sentinel?.metadata.isSentinel) return false
const sourceLoopId = sourceNode.metadata.loopId
const sourceParallelId = sourceNode.metadata.parallelId
const sentinelLoopId = sentinel.metadata.loopId
const sentinelParallelId = sentinel.metadata.parallelId
if (sourceLoopId && sentinelLoopId && sourceLoopId === sentinelLoopId) return true
if (sourceParallelId && sentinelParallelId && sourceParallelId === sentinelParallelId)
return true
return false
}
private isLoopEdge(handle?: string): boolean {
return (
handle === EDGE.LOOP_CONTINUE ||

View File

@@ -555,7 +555,7 @@ describe('ConditionBlockHandler', () => {
})
describe('Condition with no outgoing edge', () => {
it('should set selectedOption when condition matches but has no edge', async () => {
it('should return null path when condition matches but has no edge', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'true' },
{ id: 'else1', title: 'else', value: '' },
@@ -570,52 +570,9 @@ describe('ConditionBlockHandler', () => {
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
// Condition matches but no edge for it
expect((result as any).conditionResult).toBe(false)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBe('cond1')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
})
it('should set selectedOption when else is selected but has no edge', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'false' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
// Only the if branch has an edge; else has no outgoing connection
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedPath).toBeNull()
expect((result as any).selectedOption).toBe('else1')
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
})
it('should deactivate if-path when else is selected with no edge', async () => {
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 100' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
// Only the if branch has an edge to a loop; else has nothing
mockContext.workflow!.connections = [
{ source: mockSourceBlock.id, target: mockBlock.id },
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
]
const result = await handler.execute(mockContext, mockBlock, inputs)
// Else was selected (value 10 is not > 100), so selectedOption should be 'else1'
// This allows the edge manager to deactivate the cond1 edge
expect((result as any).selectedOption).toBe('else1')
expect((result as any).conditionResult).toBe(true)
})
})
@@ -645,67 +602,6 @@ describe('ConditionBlockHandler', () => {
})
})
describe('Source output filtering', () => {
it('should not propagate error field from source block output', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, text: 'hello', error: 'upstream block failed' },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect(result).not.toHaveProperty('error')
})
it('should not propagate _pauseMetadata from source block output', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, _pauseMetadata: { contextId: 'abc' } },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).conditionResult).toBe(true)
expect(result).not.toHaveProperty('_pauseMetadata')
})
it('should still pass through non-control fields from source output', async () => {
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
output: { value: 10, text: 'hello', customData: { nested: true } },
executed: true,
executionTime: 100,
})
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(mockContext, mockBlock, inputs)
expect((result as any).value).toBe(10)
expect((result as any).text).toBe('hello')
expect((result as any).customData).toEqual({ nested: true })
})
})
describe('Virtual block ID handling', () => {
it('should use currentVirtualBlockId for decision key when available', async () => {
mockContext.currentVirtualBlockId = 'virtual-block-123'

View File

@@ -108,7 +108,9 @@ export class ConditionBlockHandler implements BlockHandler {
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
const rawSourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
const sourceOutput = this.filterSourceOutput(rawSourceOutput)
// Filter out _pauseMetadata from source output to prevent the engine from
// thinking this block is pausing (it was already resumed by the HITL block)
const sourceOutput = this.filterPauseMetadata(rawSourceOutput)
const outgoingConnections = ctx.workflow?.connections.filter(
(conn) => conn.source === baseBlockId
@@ -122,7 +124,7 @@ export class ConditionBlockHandler implements BlockHandler {
block.id
)
if (!selectedCondition) {
if (!selectedConnection || !selectedCondition) {
return {
...((sourceOutput as any) || {}),
conditionResult: false,
@@ -131,17 +133,6 @@ export class ConditionBlockHandler implements BlockHandler {
}
}
if (!selectedConnection) {
const decisionKey = ctx.currentVirtualBlockId || block.id
ctx.decisions.condition.set(decisionKey, selectedCondition.id)
return {
...((sourceOutput as any) || {}),
conditionResult: true,
selectedPath: null,
selectedOption: selectedCondition.id,
}
}
const targetBlock = ctx.workflow?.blocks.find((b) => b.id === selectedConnection?.target)
if (!targetBlock) {
throw new Error(`Target block ${selectedConnection?.target} not found`)
@@ -162,11 +153,11 @@ export class ConditionBlockHandler implements BlockHandler {
}
}
private filterSourceOutput(output: any): any {
private filterPauseMetadata(output: any): any {
if (!output || typeof output !== 'object') {
return output
}
const { _pauseMetadata, error, ...rest } = output
const { _pauseMetadata, ...rest } = output
return rest
}
@@ -232,7 +223,8 @@ export class ConditionBlockHandler implements BlockHandler {
if (connection) {
return { selectedConnection: connection, selectedCondition: condition }
}
return { selectedConnection: null, selectedCondition: condition }
// Condition is true but has no outgoing edge - branch ends gracefully
return { selectedConnection: null, selectedCondition: null }
}
} catch (error: any) {
logger.error(`Failed to evaluate condition "${condition.title}": ${error.message}`)
@@ -246,7 +238,7 @@ export class ConditionBlockHandler implements BlockHandler {
if (elseConnection) {
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
}
return { selectedConnection: null, selectedCondition: elseCondition }
return { selectedConnection: null, selectedCondition: null }
}
return { selectedConnection: null, selectedCondition: null }

View File

@@ -21,7 +21,6 @@ import {
buildParallelSentinelStartId,
buildSentinelEndId,
buildSentinelStartId,
emitEmptySubflowEvents,
extractBaseBlockId,
resolveArrayInput,
validateMaxCount,
@@ -597,7 +596,6 @@ export class LoopOrchestrator {
if (!scope.items || scope.items.length === 0) {
logger.info('ForEach loop has empty collection, skipping loop body', { loopId })
this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME)
emitEmptySubflowEvents(ctx, loopId, 'loop', this.contextExtensions)
return false
}
return true
@@ -607,7 +605,6 @@ export class LoopOrchestrator {
if (scope.maxIterations === 0) {
logger.info('For loop has 0 iterations, skipping loop body', { loopId })
this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME)
emitEmptySubflowEvents(ctx, loopId, 'loop', this.contextExtensions)
return false
}
return true
@@ -620,8 +617,6 @@ export class LoopOrchestrator {
if (scope.loopType === 'while') {
if (!scope.condition) {
logger.warn('No condition defined for while loop', { loopId })
this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME)
emitEmptySubflowEvents(ctx, loopId, 'loop', this.contextExtensions)
return false
}
@@ -632,11 +627,6 @@ export class LoopOrchestrator {
result,
})
if (!result) {
this.state.setBlockOutput(loopId, { results: [] }, DEFAULTS.EXECUTION_TIME)
emitEmptySubflowEvents(ctx, loopId, 'loop', this.contextExtensions)
}
return result
}

View File

@@ -13,7 +13,6 @@ import { buildContainerIterationContext } from '@/executor/utils/iteration-conte
import { ParallelExpander } from '@/executor/utils/parallel-expansion'
import {
addSubflowErrorLog,
emitEmptySubflowEvents,
extractBranchIndex,
resolveArrayInput,
validateMaxCount,
@@ -109,8 +108,6 @@ export class ParallelOrchestrator {
this.state.setBlockOutput(parallelId, { results: [] })
emitEmptySubflowEvents(ctx, parallelId, 'parallel', this.contextExtensions)
logger.info('Parallel scope initialized with empty distribution, skipping body', {
parallelId,
branchCount: 0,

View File

@@ -1,7 +1,6 @@
import { DEFAULTS, LOOP, PARALLEL, REFERENCE } from '@/executor/constants'
import { LOOP, PARALLEL, REFERENCE } from '@/executor/constants'
import type { ContextExtensions } from '@/executor/execution/types'
import { type BlockLog, type ExecutionContext, getNextExecutionOrder } from '@/executor/types'
import { buildContainerIterationContext } from '@/executor/utils/iteration-context'
import type { VariableResolver } from '@/executor/variables/resolver'
const BRANCH_PATTERN = new RegExp(`${PARALLEL.BRANCH.PREFIX}\\d+${PARALLEL.BRANCH.SUFFIX}$`)
@@ -310,54 +309,3 @@ export function addSubflowErrorLog(
})
}
}
/**
* Emits block log + SSE events for a loop/parallel that was skipped due to an
* empty collection or false initial condition. This ensures the container block
* appears in terminal logs, execution snapshots, and edge highlighting.
*/
export function emitEmptySubflowEvents(
ctx: ExecutionContext,
blockId: string,
blockType: 'loop' | 'parallel',
contextExtensions: ContextExtensions | null
): void {
const now = new Date().toISOString()
const executionOrder = getNextExecutionOrder(ctx)
const output = { results: [] }
const block = ctx.workflow?.blocks.find((b) => b.id === blockId)
const blockName = block?.metadata?.name ?? blockType
const iterationContext = buildContainerIterationContext(ctx, blockId)
ctx.blockLogs.push({
blockId,
blockName,
blockType,
startedAt: now,
endedAt: now,
durationMs: DEFAULTS.EXECUTION_TIME,
success: true,
output,
executionOrder,
})
if (contextExtensions?.onBlockStart) {
contextExtensions.onBlockStart(blockId, blockName, blockType, executionOrder)
}
if (contextExtensions?.onBlockComplete) {
contextExtensions.onBlockComplete(
blockId,
blockName,
blockType,
{
output,
executionTime: DEFAULTS.EXECUTION_TIME,
startedAt: now,
executionOrder,
endedAt: now,
},
iterationContext
)
}
}

View File

@@ -27,7 +27,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
const logger = createLogger('CollaborativeWorkflow')
@@ -749,7 +748,9 @@ export function useCollaborativeWorkflow() {
const block = blocks[id]
if (block) {
if (isBlockProtected(id, blocks)) {
const parentId = block.data?.parentId
const isParentLocked = parentId ? blocks[parentId]?.locked : false
if (block.locked || isParentLocked) {
logger.error('Cannot rename locked block')
useNotificationStore.getState().addNotification({
level: 'info',
@@ -857,21 +858,21 @@ export function useCollaborativeWorkflow() {
const previousStates: Record<string, boolean> = {}
const validIds: string[] = []
// For each ID, collect non-locked blocks and their descendants for undo/redo
// For each ID, collect non-locked blocks and their children for undo/redo
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
// Skip protected blocks (locked or inside a locked ancestor)
if (isBlockProtected(id, currentBlocks)) continue
// Skip locked blocks
if (block.locked) continue
validIds.push(id)
previousStates[id] = block.enabled
// If it's a loop or parallel, also capture descendants' previous states for undo/redo
// If it's a loop or parallel, also capture children's previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
if (!isBlockProtected(descId, currentBlocks)) {
previousStates[descId] = currentBlocks[descId]?.enabled ?? true
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
previousStates[blockId] = b.enabled
}
})
}
@@ -1037,12 +1038,21 @@ export function useCollaborativeWorkflow() {
const blocks = useWorkflowStore.getState().blocks
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 validIds: string[] = []
for (const id of ids) {
const block = blocks[id]
if (block && !isBlockProtected(id, blocks)) {
if (block && !isProtected(id)) {
previousStates[id] = block.horizontalHandles ?? false
validIds.push(id)
}
@@ -1090,8 +1100,10 @@ export function useCollaborativeWorkflow() {
previousStates[id] = block.locked ?? false
if (block.type === 'loop' || block.type === 'parallel') {
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
previousStates[descId] = currentBlocks[descId]?.locked ?? false
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
previousStates[blockId] = b.locked ?? false
}
})
}
}

View File

@@ -761,17 +761,9 @@ function groupIterationBlocksRecursive(
}
}
const containerIdsWithIterations = new Set<string>()
for (const span of iterationSpans) {
const outermost = getOutermostContainer(span)
if (outermost) containerIdsWithIterations.add(outermost.containerId)
}
// Non-iteration spans that aren't consumed container sentinels go straight to result
const nonContainerSpans = nonIterationSpans.filter(
(span) =>
(span.type !== 'parallel' && span.type !== 'loop') ||
span.status === 'error' ||
(span.blockId && !containerIdsWithIterations.has(span.blockId))
(span) => (span.type !== 'parallel' && span.type !== 'loop') || span.status === 'error'
)
if (iterationSpans.length === 0) {

View File

@@ -23,16 +23,6 @@ export function startMemoryTelemetry(intervalMs = 60_000) {
started = true
const timer = setInterval(() => {
// Trigger opportunistic (non-blocking) garbage collection if running on Bun.
// This signals JSC GC + mimalloc page purge without blocking the event loop,
// helping reclaim RSS that mimalloc otherwise retains under sustained load.
const bunGlobal = (globalThis as Record<string, unknown>).Bun as
| { gc?: (force: boolean) => void }
| undefined
if (typeof bunGlobal?.gc === 'function') {
bunGlobal.gc(false)
}
const mem = process.memoryUsage()
const heap = v8.getHeapStatistics()

View File

@@ -759,7 +759,6 @@ async function markEmailAsRead(accessToken: string, messageId: string) {
})
if (!response.ok) {
await response.body?.cancel().catch(() => {})
throw new Error(
`Failed to mark email ${messageId} as read: ${response.status} ${response.statusText}`
)

View File

@@ -1,183 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { migrateSubblockIds } from './subblock-migrations'
function makeBlock(overrides: Partial<BlockState> & { type: string }): BlockState {
return {
id: 'block-1',
name: 'Test',
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
...overrides,
} as BlockState
}
describe('migrateSubblockIds', () => {
describe('knowledge block', () => {
it('should rename knowledgeBaseId to knowledgeBaseSelector', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseId: {
id: 'knowledgeBaseId',
type: 'knowledge-base-selector',
value: 'kb-uuid-123',
},
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(true)
expect(blocks['b1'].subBlocks['knowledgeBaseSelector']).toEqual({
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid-123',
})
expect(blocks['b1'].subBlocks['knowledgeBaseId']).toBeUndefined()
expect(blocks['b1'].subBlocks['operation'].value).toBe('search')
})
it('should prefer new key when both old and new exist', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
subBlocks: {
knowledgeBaseId: {
id: 'knowledgeBaseId',
type: 'knowledge-base-selector',
value: 'stale-kb',
},
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'fresh-kb',
},
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(true)
expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('fresh-kb')
expect(blocks['b1'].subBlocks['knowledgeBaseId']).toBeUndefined()
})
it('should not touch blocks that already use the new key', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
subBlocks: {
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid',
},
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(false)
expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-uuid')
})
})
it('should not mutate the input blocks', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
subBlocks: {
knowledgeBaseId: {
id: 'knowledgeBaseId',
type: 'knowledge-base-selector',
value: 'kb-uuid',
},
},
}),
}
const { blocks } = migrateSubblockIds(input)
expect(input['b1'].subBlocks['knowledgeBaseId']).toBeDefined()
expect(blocks['b1'].subBlocks['knowledgeBaseSelector']).toBeDefined()
expect(blocks).not.toBe(input)
})
it('should skip blocks with no registered migrations', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'function',
subBlocks: {
code: { id: 'code', type: 'code', value: 'console.log("hi")' },
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(false)
expect(blocks['b1'].subBlocks['code'].value).toBe('console.log("hi")')
})
it('should migrate multiple blocks in one pass', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
id: 'b1',
type: 'knowledge',
subBlocks: {
knowledgeBaseId: {
id: 'knowledgeBaseId',
type: 'knowledge-base-selector',
value: 'kb-1',
},
},
}),
b2: makeBlock({
id: 'b2',
type: 'knowledge',
subBlocks: {
knowledgeBaseId: {
id: 'knowledgeBaseId',
type: 'knowledge-base-selector',
value: 'kb-2',
},
},
}),
b3: makeBlock({
id: 'b3',
type: 'function',
subBlocks: {
code: { id: 'code', type: 'code', value: '' },
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(true)
expect(blocks['b1'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-1')
expect(blocks['b2'].subBlocks['knowledgeBaseSelector'].value).toBe('kb-2')
expect(blocks['b3'].subBlocks['code']).toBeDefined()
})
it('should handle blocks with empty subBlocks', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({ type: 'knowledge', subBlocks: {} }),
}
const { migrated } = migrateSubblockIds(input)
expect(migrated).toBe(false)
})
})

View File

@@ -1,90 +0,0 @@
import { createLogger } from '@sim/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('SubblockMigrations')
/**
* Maps old subblock IDs to their current equivalents per block type.
*
* When a subblock is renamed in a block definition, old deployed/saved states
* still carry the value under the previous key. Without this mapping the
* serializer silently drops the value, breaking execution.
*
* Format: { blockType: { oldSubblockId: newSubblockId } }
*/
export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
knowledge: {
knowledgeBaseId: 'knowledgeBaseSelector',
},
}
/**
* Migrates legacy subblock IDs inside a single block's subBlocks map.
* Returns a new subBlocks record if anything changed, or the original if not.
*/
function migrateBlockSubblockIds(
subBlocks: Record<string, BlockState['subBlocks'][string]>,
renames: Record<string, string>
): { subBlocks: Record<string, BlockState['subBlocks'][string]>; migrated: boolean } {
let migrated = false
for (const oldId of Object.keys(renames)) {
if (oldId in subBlocks) {
migrated = true
break
}
}
if (!migrated) return { subBlocks, migrated: false }
const result = { ...subBlocks }
for (const [oldId, newId] of Object.entries(renames)) {
if (!(oldId in result)) continue
if (newId in result) {
delete result[oldId]
continue
}
const oldEntry = result[oldId]
result[newId] = { ...oldEntry, id: newId }
delete result[oldId]
}
return { subBlocks: result, migrated: true }
}
/**
* Applies subblock-ID migrations to every block in a workflow.
* Returns a new blocks record with migrated subBlocks where needed.
*/
export function migrateSubblockIds(blocks: Record<string, BlockState>): {
blocks: Record<string, BlockState>
migrated: boolean
} {
let anyMigrated = false
const result: Record<string, BlockState> = {}
for (const [blockId, block] of Object.entries(blocks)) {
const renames = SUBBLOCK_ID_MIGRATIONS[block.type]
if (!renames || !block.subBlocks) {
result[blockId] = block
continue
}
const { subBlocks, migrated } = migrateBlockSubblockIds(block.subBlocks, renames)
if (migrated) {
logger.info('Migrated legacy subblock IDs', {
blockId: block.id,
blockType: block.type,
})
anyMigrated = true
result[blockId] = { ...block, subBlocks }
} else {
result[blockId] = block
}
}
return { blocks: result, migrated: anyMigrated }
}

View File

@@ -14,7 +14,6 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import type { DbOrTx } from '@/lib/db/types'
import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
@@ -114,10 +113,10 @@ export async function loadDeployedWorkflowState(
resolvedWorkspaceId = wfRow?.workspaceId ?? undefined
}
const { blocks: migratedBlocks } = await applyBlockMigrations(
state.blocks || {},
resolvedWorkspaceId
)
const resolvedBlocks = state.blocks || {}
const { blocks: migratedBlocks } = resolvedWorkspaceId
? await migrateCredentialIds(resolvedBlocks, resolvedWorkspaceId)
: { blocks: resolvedBlocks }
return {
blocks: migratedBlocks,
@@ -134,50 +133,6 @@ export async function loadDeployedWorkflowState(
}
}
interface MigrationContext {
blocks: Record<string, BlockState>
workspaceId?: string
migrated: boolean
}
type BlockMigration = (ctx: MigrationContext) => MigrationContext | Promise<MigrationContext>
function createMigrationPipeline(migrations: BlockMigration[]) {
return async (
blocks: Record<string, BlockState>,
workspaceId?: string
): Promise<{ blocks: Record<string, BlockState>; migrated: boolean }> => {
let ctx: MigrationContext = { blocks, workspaceId, migrated: false }
for (const migration of migrations) {
ctx = await migration(ctx)
}
return { blocks: ctx.blocks, migrated: ctx.migrated }
}
}
const applyBlockMigrations = createMigrationPipeline([
(ctx) => {
const { blocks } = sanitizeAgentToolsInBlocks(ctx.blocks)
return { ...ctx, blocks }
},
(ctx) => ({
...ctx,
blocks: migrateAgentBlocksToMessagesFormat(ctx.blocks),
}),
async (ctx) => {
if (!ctx.workspaceId) return ctx
const { blocks, migrated } = await migrateCredentialIds(ctx.blocks, ctx.workspaceId)
return { ...ctx, blocks, migrated: ctx.migrated || migrated }
},
(ctx) => {
const { blocks, migrated } = migrateSubblockIds(ctx.blocks)
return { ...ctx, blocks, migrated: ctx.migrated || migrated }
},
])
/**
* Migrates agent blocks from old format (systemPrompt/userPrompt) to new format (messages array)
* This ensures backward compatibility for workflows created before the messages-input refactor.
@@ -401,16 +356,22 @@ export async function loadWorkflowFromNormalizedTables(
blocksMap[block.id] = assembled
})
const { blocks: finalBlocks, migrated } = await applyBlockMigrations(
blocksMap,
workflowRow?.workspaceId ?? undefined
)
// Sanitize any invalid custom tools in agent blocks to prevent client crashes
const { blocks: sanitizedBlocks } = sanitizeAgentToolsInBlocks(blocksMap)
if (migrated) {
// Migrate old agent block format (systemPrompt/userPrompt) to new messages array format
const migratedBlocks = migrateAgentBlocksToMessagesFormat(sanitizedBlocks)
// Migrate legacy account.id → credential.id in OAuth subblocks
const { blocks: credMigratedBlocks, migrated: credentialsMigrated } = workflowRow?.workspaceId
? await migrateCredentialIds(migratedBlocks, workflowRow.workspaceId)
: { blocks: migratedBlocks, migrated: false }
if (credentialsMigrated) {
Promise.resolve().then(async () => {
try {
for (const [blockId, block] of Object.entries(finalBlocks)) {
if (block.subBlocks !== blocksMap[blockId]?.subBlocks) {
for (const [blockId, block] of Object.entries(credMigratedBlocks)) {
if (block.subBlocks !== migratedBlocks[blockId]?.subBlocks) {
await db
.update(workflowBlocks)
.set({ subBlocks: block.subBlocks, updatedAt: new Date() })
@@ -420,7 +381,7 @@ export async function loadWorkflowFromNormalizedTables(
}
}
} catch (err) {
logger.warn('Failed to persist block migrations', { workflowId, error: err })
logger.warn('Failed to persist credential ID migration', { workflowId, error: err })
}
})
}
@@ -461,13 +422,13 @@ export async function loadWorkflowFromNormalizedTables(
forEachItems: (config as Loop).forEachItems ?? '',
whileCondition: (config as Loop).whileCondition ?? '',
doWhileCondition: (config as Loop).doWhileCondition ?? '',
enabled: finalBlocks[subflow.id]?.enabled ?? true,
enabled: credMigratedBlocks[subflow.id]?.enabled ?? true,
}
loops[subflow.id] = loop
if (finalBlocks[subflow.id]) {
const block = finalBlocks[subflow.id]
finalBlocks[subflow.id] = {
if (credMigratedBlocks[subflow.id]) {
const block = credMigratedBlocks[subflow.id]
credMigratedBlocks[subflow.id] = {
...block,
data: {
...block.data,
@@ -488,7 +449,7 @@ export async function loadWorkflowFromNormalizedTables(
(config as Parallel).parallelType === 'collection'
? (config as Parallel).parallelType
: 'count',
enabled: finalBlocks[subflow.id]?.enabled ?? true,
enabled: credMigratedBlocks[subflow.id]?.enabled ?? true,
}
parallels[subflow.id] = parallel
} else {
@@ -497,7 +458,7 @@ export async function loadWorkflowFromNormalizedTables(
})
return {
blocks: finalBlocks,
blocks: credMigratedBlocks,
edges: edgesArray,
loops,
parallels,

View File

@@ -95,7 +95,6 @@ const nextConfig: NextConfig = {
optimizeCss: true,
turbopackSourceMaps: false,
turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
},
...(isDev && {
allowedDevOrigins: [

View File

@@ -82,6 +82,7 @@
"@trigger.dev/sdk": "4.1.2",
"@types/react-window": "2.0.0",
"@types/three": "0.177.0",
"acorn": "8.16.0",
"better-auth": "1.3.12",
"binary-extensions": "^2.0.0",
"browser-image-compression": "^2.0.2",

View File

@@ -122,40 +122,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
contextWindow: 128000,
},
{
id: 'gpt-5.4',
pricing: {
input: 2.5,
cachedInput: 0.25,
output: 15.0,
updatedAt: '2026-03-05',
},
capabilities: {
reasoningEffort: {
values: ['none', 'low', 'medium', 'high', 'xhigh'],
},
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 1050000,
},
{
id: 'gpt-5.4-pro',
pricing: {
input: 30.0,
output: 180.0,
updatedAt: '2026-03-05',
},
capabilities: {
reasoningEffort: {
values: ['medium', 'high', 'xhigh'],
},
maxOutputTokens: 128000,
},
contextWindow: 1050000,
},
{
id: 'gpt-5.2',
pricing: {
@@ -527,25 +493,6 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
},
contextWindow: 128000,
},
{
id: 'azure/gpt-5.4',
pricing: {
input: 2.5,
cachedInput: 0.25,
output: 15.0,
updatedAt: '2026-03-05',
},
capabilities: {
reasoningEffort: {
values: ['none', 'low', 'medium', 'high', 'xhigh'],
},
verbosity: {
values: ['low', 'medium', 'high'],
},
maxOutputTokens: 128000,
},
contextWindow: 1050000,
},
{
id: 'azure/gpt-5.2',
pricing: {

View File

@@ -523,16 +523,13 @@ describe('Model Capabilities', () => {
it.concurrent('should have GPT-5 models in both reasoning effort and verbosity arrays', () => {
const gpt5ModelsWithReasoningEffort = MODELS_WITH_REASONING_EFFORT.filter(
(m) => m.includes('gpt-5') && !m.includes('chat-latest') && !m.includes('gpt-5.4-pro')
(m) => m.includes('gpt-5') && !m.includes('chat-latest')
)
const gpt5ModelsWithVerbosity = MODELS_WITH_VERBOSITY.filter(
(m) => m.includes('gpt-5') && !m.includes('chat-latest')
)
expect(gpt5ModelsWithReasoningEffort.sort()).toEqual(gpt5ModelsWithVerbosity.sort())
expect(MODELS_WITH_REASONING_EFFORT).toContain('gpt-5.4-pro')
expect(MODELS_WITH_VERBOSITY).not.toContain('gpt-5.4-pro')
expect(MODELS_WITH_REASONING_EFFORT).toContain('o1')
expect(MODELS_WITH_VERBOSITY).not.toContain('o1')
})

View File

@@ -1,170 +0,0 @@
#!/usr/bin/env bun
/**
* CI check: detect subblock ID renames that would break deployed workflows.
*
* Compares the current block registry against the parent commit.
* If any subblock ID was removed from a block, it must have a corresponding
* entry in SUBBLOCK_ID_MIGRATIONS — otherwise this script exits non-zero.
*
* Usage:
* bun run apps/sim/scripts/check-subblock-id-stability.ts [base-ref]
*
* base-ref defaults to HEAD~1. In a PR CI pipeline, pass the merge base:
* bun run apps/sim/scripts/check-subblock-id-stability.ts origin/main
*/
import { execSync } from 'child_process'
import { SUBBLOCK_ID_MIGRATIONS } from '@/lib/workflows/migrations/subblock-migrations'
import { getAllBlocks } from '@/blocks/registry'
const baseRef = process.argv[2] || 'HEAD~1'
const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim()
const gitOpts = { encoding: 'utf-8' as const, cwd: gitRoot }
type IdMap = Record<string, Set<string>>
/**
* Extracts subblock IDs from the `subBlocks: [ ... ]` section of a block
* definition. Only grabs the top-level `id:` of each subblock object —
* ignores nested IDs inside `options`, `columns`, etc.
*/
function extractSubBlockIds(source: string): string[] {
const startIdx = source.indexOf('subBlocks:')
if (startIdx === -1) return []
const bracketStart = source.indexOf('[', startIdx)
if (bracketStart === -1) return []
const ids: string[] = []
let braceDepth = 0
let bracketDepth = 0
let i = bracketStart + 1
bracketDepth = 1
while (i < source.length && bracketDepth > 0) {
const ch = source[i]
if (ch === '[') bracketDepth++
else if (ch === ']') {
bracketDepth--
if (bracketDepth === 0) break
} else if (ch === '{') {
braceDepth++
if (braceDepth === 1) {
const ahead = source.slice(i, i + 200)
const idMatch = ahead.match(/{\s*(?:\/\/[^\n]*\n\s*)*id:\s*['"]([^'"]+)['"]/)
if (idMatch) {
ids.push(idMatch[1])
}
}
} else if (ch === '}') {
braceDepth--
}
i++
}
return ids
}
function getCurrentIds(): IdMap {
const map: IdMap = {}
for (const block of getAllBlocks()) {
map[block.type] = new Set(block.subBlocks.map((sb) => sb.id))
}
return map
}
function getPreviousIds(): IdMap {
const registryPath = 'apps/sim/blocks/registry.ts'
const blocksDir = 'apps/sim/blocks/blocks'
let hasChanges = false
try {
const diff = execSync(
`git diff --name-only ${baseRef} HEAD -- ${registryPath} ${blocksDir}`,
gitOpts
).trim()
hasChanges = diff.length > 0
} catch {
console.log('⚠ Could not diff against base ref — skipping check')
process.exit(0)
}
if (!hasChanges) {
console.log('✓ No block definition changes detected — nothing to check')
process.exit(0)
}
const map: IdMap = {}
try {
const blockFiles = execSync(`git ls-tree -r --name-only ${baseRef} -- ${blocksDir}`, gitOpts)
.trim()
.split('\n')
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
for (const filePath of blockFiles) {
let content: string
try {
content = execSync(`git show ${baseRef}:${filePath}`, gitOpts)
} catch {
continue
}
const typeMatch = content.match(/BlockConfig\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/)
if (!typeMatch) continue
const blockType = typeMatch[1]
const ids = extractSubBlockIds(content)
if (ids.length === 0) continue
map[blockType] = new Set(ids)
}
} catch (err) {
console.log(`⚠ Could not read previous block files from ${baseRef} — skipping check`, err)
process.exit(0)
}
return map
}
const previous = getPreviousIds()
const current = getCurrentIds()
const errors: string[] = []
for (const [blockType, prevIds] of Object.entries(previous)) {
const currIds = current[blockType]
if (!currIds) continue
const migrations = SUBBLOCK_ID_MIGRATIONS[blockType] ?? {}
for (const oldId of prevIds) {
if (currIds.has(oldId)) continue
if (oldId in migrations) continue
errors.push(
`Block "${blockType}": subblock ID "${oldId}" was removed.\n` +
` → Add a migration in SUBBLOCK_ID_MIGRATIONS (lib/workflows/migrations/subblock-migrations.ts)\n` +
` mapping "${oldId}" to its replacement ID.`
)
}
}
if (errors.length > 0) {
console.error('✗ Subblock ID stability check FAILED\n')
console.error(
'Removing subblock IDs breaks deployed workflows.\n' +
'Either revert the rename or add a migration entry.\n'
)
for (const err of errors) {
console.error(` ${err}\n`)
}
process.exit(1)
} else {
console.log('✓ Subblock ID stability check passed')
process.exit(0)
}

View File

@@ -39,56 +39,6 @@ const db = socketDb
const DEFAULT_LOOP_ITERATIONS = 5
const DEFAULT_PARALLEL_COUNT = 5
/** Minimal block shape needed for protection and descendant checks */
interface DbBlockRef {
id: string
locked?: boolean | null
data: unknown
}
/**
* Checks if a block is protected (locked or inside a locked ancestor).
* Works with raw DB records.
*/
function isDbBlockProtected(blockId: string, blocksById: Record<string, DbBlockRef>): boolean {
const block = blocksById[blockId]
if (!block) return false
if (block.locked) return true
const visited = new Set<string>()
let parentId = (block.data as Record<string, unknown> | null)?.parentId as string | undefined
while (parentId && !visited.has(parentId)) {
visited.add(parentId)
if (blocksById[parentId]?.locked) return true
parentId = (blocksById[parentId]?.data as Record<string, unknown> | null)?.parentId as
| string
| undefined
}
return false
}
/**
* Finds all descendant block IDs of a container (recursive).
* Works with raw DB block arrays.
*/
function findDbDescendants(containerId: string, allBlocks: DbBlockRef[]): string[] {
const descendants: string[] = []
const visited = new Set<string>()
const stack = [containerId]
while (stack.length > 0) {
const current = stack.pop()!
if (visited.has(current)) continue
visited.add(current)
for (const b of allBlocks) {
const pid = (b.data as Record<string, unknown> | null)?.parentId
if (pid === current) {
descendants.push(b.id)
stack.push(b.id)
}
}
}
return descendants
}
/**
* Shared function to handle auto-connect edge insertion
* @param tx - Database transaction
@@ -803,8 +753,20 @@ async function handleBlocksOperationTx(
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) => !isDbBlockProtected(id, blocksById))
const deletableIds = ids.filter((id) => !isProtected(id))
if (deletableIds.length === 0) {
logger.info('All requested blocks are protected, skipping deletion')
return
@@ -816,14 +778,18 @@ async function handleBlocksOperationTx(
)
}
// Collect all block IDs including all descendants of subflows
// Collect all block IDs including children of subflows
const allBlocksToDelete = new Set<string>(deletableIds)
for (const id of deletableIds) {
const block = blocksById[id]
if (block && isSubflowBlockType(block.type)) {
for (const descId of findDbDescendants(id, allBlocks)) {
allBlocksToDelete.add(descId)
// Include all children of the subflow (they should be deleted with parent)
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id) {
allBlocksToDelete.add(b.id)
}
}
}
}
@@ -936,18 +902,19 @@ async function handleBlocksOperationTx(
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including descendants of containers
// Collect all blocks to toggle including children of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block || isDbBlockProtected(id, blocksById)) continue
if (!block || block.locked) continue
blocksToToggle.add(id)
// If it's a loop or parallel, also include all non-locked descendants
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
for (const descId of findDbDescendants(id, allBlocks)) {
if (!isDbBlockProtected(descId, blocksById)) {
blocksToToggle.add(descId)
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id && !b.locked) {
blocksToToggle.add(b.id)
}
}
}
@@ -999,10 +966,20 @@ async function handleBlocksOperationTx(
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] && !isDbBlockProtected(id, blocksById)
)
const blocksToToggle = blockIds.filter((id) => blocksById[id] && !isProtected(id))
if (blocksToToggle.length === 0) {
logger.info('All requested blocks are protected, skipping handles toggle')
break
@@ -1048,17 +1025,20 @@ async function handleBlocksOperationTx(
)
const blocksToToggle = new Set<string>()
// Collect all blocks to toggle including descendants of containers
// 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 descendants
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
for (const descId of findDbDescendants(id, allBlocks)) {
blocksToToggle.add(descId)
for (const b of allBlocks) {
const parentId = (b.data as Record<string, unknown> | null)?.parentId
if (parentId === id) {
blocksToToggle.add(b.id)
}
}
}
}
@@ -1108,19 +1088,31 @@ async function handleBlocksOperationTx(
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) {
const { id, parentId, position } = update
if (!id) continue
// Skip protected blocks (locked or inside locked container)
if (isDbBlockProtected(id, blocksById)) {
if (isProtected(id)) {
logger.info(`Skipping block ${id} parent update - block is protected`)
continue
}
// Skip if trying to move into a locked container (or any of its ancestors)
if (parentId && isDbBlockProtected(parentId, blocksById)) {
logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`)
// 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
}
@@ -1243,7 +1235,18 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
}
}
if (isDbBlockProtected(payload.target, blocksById)) {
const isBlockProtected = (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
}
if (isBlockProtected(payload.target)) {
logger.info(`Skipping edge add - target block is protected`)
break
}
@@ -1331,7 +1334,18 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
}
}
if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) {
const isBlockProtected = (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
}
if (isBlockProtected(edgeToRemove.targetBlockId)) {
logger.info(`Skipping edge remove - target block is protected`)
break
}
@@ -1441,8 +1455,19 @@ async function handleEdgesOperationTx(
}
}
const isBlockProtected = (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
}
const safeEdgeIds = edgesToRemove
.filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById))
.filter((e: EdgeToRemove) => !isBlockProtected(e.targetBlockId))
.map((e: EdgeToRemove) => e.id)
if (safeEdgeIds.length === 0) {
@@ -1527,9 +1552,20 @@ async function handleEdgesOperationTx(
}
}
const isBlockProtected = (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 edges - only add edges where target block is not protected
const safeEdges = (edges as Array<Record<string, unknown>>).filter(
(e) => !isDbBlockProtected(e.target as string, blocksById)
(e) => !isBlockProtected(e.target as string)
)
if (safeEdges.length === 0) {

View File

@@ -20,10 +20,8 @@ import type {
WorkflowStore,
} from '@/stores/workflows/workflow/types'
import {
findAllDescendantNodes,
generateLoopBlocks,
generateParallelBlocks,
isBlockProtected,
wouldCreateCycle,
} from '@/stores/workflows/workflow/utils'
@@ -376,21 +374,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
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 descendants
// If it's a container, also include non-locked children
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
// Skip protected blocks entirely (locked or inside a locked ancestor)
if (isBlockProtected(id, currentBlocks)) continue
// 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 descendants
// If it's a loop or parallel, also include non-locked children
if (block.type === 'loop' || block.type === 'parallel') {
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
if (!isBlockProtected(descId, currentBlocks)) {
blocksToToggle.add(descId)
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id && !b.locked) {
blocksToToggle.add(blockId)
}
})
}
@@ -417,8 +415,18 @@ export const useWorkflowStore = create<WorkflowStore>()(
const currentBlocks = 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) {
if (!newBlocks[id] || isBlockProtected(id, currentBlocks)) continue
if (!newBlocks[id] || isProtected(id)) continue
newBlocks[id] = {
...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles,
@@ -1259,17 +1267,19 @@ export const useWorkflowStore = create<WorkflowStore>()(
const blocksToToggle = new Set<string>()
// For each ID, collect blocks to toggle
// If it's a container, also include all descendants
// 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 descendants
// If it's a loop or parallel, also include all children
if (block.type === 'loop' || block.type === 'parallel') {
findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
blocksToToggle.add(descId)
Object.entries(currentBlocks).forEach(([blockId, b]) => {
if (b.data?.parentId === id) {
blocksToToggle.add(blockId)
}
})
}
}

View File

@@ -143,56 +143,21 @@ export function findAllDescendantNodes(
blocks: Record<string, BlockState>
): string[] {
const descendants: string[] = []
const visited = new Set<string>()
const stack = [containerId]
while (stack.length > 0) {
const current = stack.pop()!
if (visited.has(current)) continue
visited.add(current)
for (const block of Object.values(blocks)) {
if (block.data?.parentId === current) {
descendants.push(block.id)
stack.push(block.id)
}
}
const findDescendants = (parentId: string) => {
const children = Object.values(blocks)
.filter((block) => block.data?.parentId === parentId)
.map((block) => block.id)
children.forEach((childId) => {
descendants.push(childId)
findDescendants(childId)
})
}
findDescendants(containerId)
return descendants
}
/**
* Checks if any ancestor container of a block is locked.
* Unlike {@link isBlockProtected}, this ignores the block's own locked state.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if any ancestor is locked
*/
export function isAncestorProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
const visited = new Set<string>()
let parentId = blocks[blockId]?.data?.parentId
while (parentId && !visited.has(parentId)) {
visited.add(parentId)
if (blocks[parentId]?.locked) return true
parentId = blocks[parentId]?.data?.parentId
}
return false
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if any ancestor 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
if (block.locked) return true
return isAncestorProtected(blockId, blocks)
}
/**
* Builds a complete collection of loops from the UI blocks
*

View File

@@ -239,7 +239,6 @@ export async function downloadAttachments(
)
if (!attachmentResponse.ok) {
await attachmentResponse.body?.cancel().catch(() => {})
continue
}

View File

@@ -1797,22 +1797,17 @@ import {
import {
slackAddReactionTool,
slackCanvasTool,
slackCreateChannelCanvasTool,
slackDeleteMessageTool,
slackDownloadTool,
slackEditCanvasTool,
slackEphemeralMessageTool,
slackGetChannelInfoTool,
slackGetMessageTool,
slackGetThreadTool,
slackGetUserPresenceTool,
slackGetUserTool,
slackListChannelsTool,
slackListMembersTool,
slackListUsersTool,
slackMessageReaderTool,
slackMessageTool,
slackRemoveReactionTool,
slackUpdateMessageTool,
} from '@/tools/slack'
import { smsSendTool } from '@/tools/sms'
@@ -2616,11 +2611,6 @@ export const tools: Record<string, ToolConfig> = {
slack_update_message: slackUpdateMessageTool,
slack_delete_message: slackDeleteMessageTool,
slack_add_reaction: slackAddReactionTool,
slack_remove_reaction: slackRemoveReactionTool,
slack_get_channel_info: slackGetChannelInfoTool,
slack_get_user_presence: slackGetUserPresenceTool,
slack_edit_canvas: slackEditCanvasTool,
slack_create_channel_canvas: slackCreateChannelCanvasTool,
github_repo_info: githubRepoInfoTool,
github_repo_info_v2: githubRepoInfoV2Tool,
github_latest_commit: githubLatestCommitTool,

View File

@@ -60,12 +60,6 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
visibility: 'user-or-llm',
description: 'Maximum number of records to return (e.g., 10, 50, 100)',
},
offset: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of records to skip for pagination (e.g., 0, 10, 20)',
},
fields: {
type: 'string',
required: false,
@@ -73,13 +67,6 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
description:
'Comma-separated list of fields to return (e.g., sys_id,number,short_description,state)',
},
displayValue: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Return display values for reference fields: "true" (display only), "false" (sys_id only), or "all" (both)',
},
},
request: {
@@ -109,18 +96,10 @@ export const readRecordTool: ToolConfig<ServiceNowReadParams, ServiceNowReadResp
queryParams.append('sysparm_limit', params.limit.toString())
}
if (params.offset !== undefined && params.offset !== null) {
queryParams.append('sysparm_offset', params.offset.toString())
}
if (params.fields) {
queryParams.append('sysparm_fields', params.fields)
}
if (params.displayValue) {
queryParams.append('sysparm_display_value', params.displayValue)
}
const queryString = queryParams.toString()
return queryString ? `${url}?${queryString}` : url
},

View File

@@ -31,9 +31,7 @@ export interface ServiceNowReadParams extends ServiceNowBaseParams {
number?: string
query?: string
limit?: number
offset?: number
fields?: string
displayValue?: string
}
export interface ServiceNowReadResponse extends ToolResponse {

View File

@@ -87,21 +87,9 @@ export const slackCanvasTool: ToolConfig<SlackCanvasParams, SlackCanvasResponse>
},
},
transformResponse: async (response: Response): Promise<SlackCanvasResponse> => {
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
return {
success: false,
output: {
canvas_id: '',
channel: '',
title: '',
},
error: data.error || 'Unknown error',
}
}
return {
success: true,
output: {

View File

@@ -1,108 +0,0 @@
import type {
SlackCreateChannelCanvasParams,
SlackCreateChannelCanvasResponse,
} from '@/tools/slack/types'
import type { ToolConfig } from '@/tools/types'
export const slackCreateChannelCanvasTool: ToolConfig<
SlackCreateChannelCanvasParams,
SlackCreateChannelCanvasResponse
> = {
id: 'slack_create_channel_canvas',
name: 'Slack Create Channel Canvas',
description: 'Create a canvas pinned to a Slack channel as its resource hub',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
},
params: {
authMethod: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Authentication method: oauth or bot_token',
},
botToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'OAuth access token or bot token for Slack API',
},
channel: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Channel ID to create the canvas in (e.g., C1234567890)',
},
title: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Title for the channel canvas',
},
content: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Canvas content in markdown format',
},
},
request: {
url: 'https://slack.com/api/conversations.canvases.create',
method: 'POST',
headers: (params: SlackCreateChannelCanvasParams) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken || params.botToken}`,
}),
body: (params: SlackCreateChannelCanvasParams) => {
const body: Record<string, unknown> = {
channel_id: params.channel.trim(),
}
if (params.title) {
body.title = params.title
}
if (params.content) {
body.document_content = {
type: 'markdown',
markdown: params.content,
}
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
if (data.error === 'channel_canvas_already_exists') {
throw new Error('A canvas already exists for this channel. Use Edit Canvas to modify it.')
}
throw new Error(data.error || 'Failed to create channel canvas')
}
return {
success: true,
output: {
canvas_id: data.canvas_id,
},
}
},
outputs: {
canvas_id: { type: 'string', description: 'ID of the created channel canvas' },
},
}

View File

@@ -1,121 +0,0 @@
import type { SlackEditCanvasParams, SlackEditCanvasResponse } from '@/tools/slack/types'
import type { ToolConfig } from '@/tools/types'
export const slackEditCanvasTool: ToolConfig<SlackEditCanvasParams, SlackEditCanvasResponse> = {
id: 'slack_edit_canvas',
name: 'Slack Edit Canvas',
description: 'Edit an existing Slack canvas by inserting, replacing, or deleting content',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
},
params: {
authMethod: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Authentication method: oauth or bot_token',
},
botToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'OAuth access token or bot token for Slack API',
},
canvasId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Canvas ID to edit (e.g., F1234ABCD)',
},
operation: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Edit operation: insert_at_start, insert_at_end, insert_after, insert_before, replace, delete, or rename',
},
content: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Markdown content for the operation (required for insert/replace operations)',
},
sectionId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Section ID to target (required for insert_after, insert_before, replace, and delete)',
},
title: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'New title for the canvas (only used with rename operation)',
},
},
request: {
url: 'https://slack.com/api/canvases.edit',
method: 'POST',
headers: (params: SlackEditCanvasParams) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken || params.botToken}`,
}),
body: (params: SlackEditCanvasParams) => {
const change: Record<string, unknown> = {
operation: params.operation,
}
if (params.sectionId) {
change.section_id = params.sectionId.trim()
}
if (params.operation === 'rename' && params.title) {
change.title_content = {
type: 'markdown',
markdown: params.title,
}
} else if (params.content && params.operation !== 'delete') {
change.document_content = {
type: 'markdown',
markdown: params.content,
}
}
return {
canvas_id: params.canvasId.trim(),
changes: [change],
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
throw new Error(data.error || 'Failed to edit canvas')
}
return {
success: true,
output: {
content: 'Successfully edited canvas',
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
},
}

View File

@@ -1,115 +0,0 @@
import type { SlackGetChannelInfoParams, SlackGetChannelInfoResponse } from '@/tools/slack/types'
import { CHANNEL_OUTPUT_PROPERTIES } from '@/tools/slack/types'
import type { ToolConfig } from '@/tools/types'
export const slackGetChannelInfoTool: ToolConfig<
SlackGetChannelInfoParams,
SlackGetChannelInfoResponse
> = {
id: 'slack_get_channel_info',
name: 'Slack Get Channel Info',
description: 'Get detailed information about a Slack channel by its ID',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
},
params: {
authMethod: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Authentication method: oauth or bot_token',
},
botToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'OAuth access token or bot token for Slack API',
},
channel: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Channel ID to get information about (e.g., C1234567890)',
},
includeNumMembers: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to include the member count in the response',
},
},
request: {
url: (params: SlackGetChannelInfoParams) => {
const url = new URL('https://slack.com/api/conversations.info')
url.searchParams.append('channel', params.channel.trim())
url.searchParams.append('include_num_members', String(params.includeNumMembers ?? true))
return url.toString()
},
method: 'GET',
headers: (params: SlackGetChannelInfoParams) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken || params.botToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
if (data.error === 'channel_not_found') {
throw new Error('Channel not found. Please check the channel ID and try again.')
}
if (data.error === 'missing_scope') {
throw new Error(
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:read).'
)
}
throw new Error(data.error || 'Failed to get channel info from Slack')
}
const channel = data.channel
return {
success: true,
output: {
channelInfo: {
id: channel.id,
name: channel.name ?? '',
is_channel: channel.is_channel ?? false,
is_private: channel.is_private ?? false,
is_archived: channel.is_archived ?? false,
is_general: channel.is_general ?? false,
is_member: channel.is_member ?? false,
is_shared: channel.is_shared ?? false,
is_ext_shared: channel.is_ext_shared ?? false,
is_org_shared: channel.is_org_shared ?? false,
num_members: channel.num_members ?? null,
topic: channel.topic?.value ?? '',
purpose: channel.purpose?.value ?? '',
created: channel.created ?? null,
creator: channel.creator ?? null,
updated: channel.updated ?? null,
},
},
}
},
outputs: {
channelInfo: {
type: 'object',
description: 'Detailed channel information',
properties: CHANNEL_OUTPUT_PROPERTIES,
},
},
}

View File

@@ -1,122 +0,0 @@
import type { SlackGetUserPresenceParams, SlackGetUserPresenceResponse } from '@/tools/slack/types'
import type { ToolConfig } from '@/tools/types'
export const slackGetUserPresenceTool: ToolConfig<
SlackGetUserPresenceParams,
SlackGetUserPresenceResponse
> = {
id: 'slack_get_user_presence',
name: 'Slack Get User Presence',
description: 'Check whether a Slack user is currently active or away',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
},
params: {
authMethod: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Authentication method: oauth or bot_token',
},
botToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'OAuth access token or bot token for Slack API',
},
userId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'User ID to check presence for (e.g., U1234567890)',
},
},
request: {
url: (params: SlackGetUserPresenceParams) => {
const url = new URL('https://slack.com/api/users.getPresence')
url.searchParams.append('user', params.userId.trim())
return url.toString()
},
method: 'GET',
headers: (params: SlackGetUserPresenceParams) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${params.accessToken || params.botToken}`,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.ok) {
if (data.error === 'user_not_found') {
throw new Error('User not found. Please check the user ID and try again.')
}
if (data.error === 'missing_scope') {
throw new Error(
'Missing required permissions. Please reconnect your Slack account with the necessary scopes (users:read).'
)
}
throw new Error(data.error || 'Failed to get user presence from Slack')
}
return {
success: true,
output: {
presence: data.presence,
online: data.online ?? null,
autoAway: data.auto_away ?? null,
manualAway: data.manual_away ?? null,
connectionCount: data.connection_count ?? null,
lastActivity: data.last_activity ?? null,
},
}
},
outputs: {
presence: {
type: 'string',
description: 'User presence status: "active" or "away"',
},
online: {
type: 'boolean',
description:
'Whether user has an active client connection (only available when checking own presence)',
optional: true,
},
autoAway: {
type: 'boolean',
description:
'Whether user was automatically set to away due to inactivity (only available when checking own presence)',
optional: true,
},
manualAway: {
type: 'boolean',
description:
'Whether user manually set themselves as away (only available when checking own presence)',
optional: true,
},
connectionCount: {
type: 'number',
description:
'Total number of active connections for the user (only available when checking own presence)',
optional: true,
},
lastActivity: {
type: 'number',
description:
'Unix timestamp of last detected activity (only available when checking own presence)',
optional: true,
},
},
}

View File

@@ -1,41 +1,31 @@
import { slackAddReactionTool } from '@/tools/slack/add_reaction'
import { slackCanvasTool } from '@/tools/slack/canvas'
import { slackCreateChannelCanvasTool } from '@/tools/slack/create_channel_canvas'
import { slackDeleteMessageTool } from '@/tools/slack/delete_message'
import { slackDownloadTool } from '@/tools/slack/download'
import { slackEditCanvasTool } from '@/tools/slack/edit_canvas'
import { slackEphemeralMessageTool } from '@/tools/slack/ephemeral_message'
import { slackGetChannelInfoTool } from '@/tools/slack/get_channel_info'
import { slackGetMessageTool } from '@/tools/slack/get_message'
import { slackGetThreadTool } from '@/tools/slack/get_thread'
import { slackGetUserTool } from '@/tools/slack/get_user'
import { slackGetUserPresenceTool } from '@/tools/slack/get_user_presence'
import { slackListChannelsTool } from '@/tools/slack/list_channels'
import { slackListMembersTool } from '@/tools/slack/list_members'
import { slackListUsersTool } from '@/tools/slack/list_users'
import { slackMessageTool } from '@/tools/slack/message'
import { slackMessageReaderTool } from '@/tools/slack/message_reader'
import { slackRemoveReactionTool } from '@/tools/slack/remove_reaction'
import { slackUpdateMessageTool } from '@/tools/slack/update_message'
export {
slackMessageTool,
slackCanvasTool,
slackCreateChannelCanvasTool,
slackMessageReaderTool,
slackDownloadTool,
slackEditCanvasTool,
slackEphemeralMessageTool,
slackUpdateMessageTool,
slackDeleteMessageTool,
slackAddReactionTool,
slackRemoveReactionTool,
slackGetChannelInfoTool,
slackListChannelsTool,
slackListMembersTool,
slackListUsersTool,
slackGetUserTool,
slackGetUserPresenceTool,
slackGetMessageTool,
slackGetThreadTool,
}

View File

@@ -1,108 +0,0 @@
import type { SlackRemoveReactionParams, SlackRemoveReactionResponse } from '@/tools/slack/types'
import { REACTION_METADATA_OUTPUT_PROPERTIES } from '@/tools/slack/types'
import type { ToolConfig } from '@/tools/types'
export const slackRemoveReactionTool: ToolConfig<
SlackRemoveReactionParams,
SlackRemoveReactionResponse
> = {
id: 'slack_remove_reaction',
name: 'Slack Remove Reaction',
description: 'Remove an emoji reaction from a Slack message',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
},
params: {
authMethod: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Authentication method: oauth or bot_token',
},
botToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'OAuth access token or bot token for Slack API',
},
channel: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Channel ID where the message was posted (e.g., C1234567890)',
},
timestamp: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Timestamp of the message to remove reaction from (e.g., 1405894322.002768)',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Name of the emoji reaction to remove (without colons, e.g., thumbsup, heart, eyes)',
},
},
request: {
url: '/api/tools/slack/remove-reaction',
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params: SlackRemoveReactionParams) => ({
accessToken: params.accessToken || params.botToken,
channel: params.channel,
timestamp: params.timestamp,
name: params.name,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!data.success) {
return {
success: false,
output: {
content: data.error || 'Failed to remove reaction',
metadata: {
channel: '',
timestamp: '',
reaction: '',
},
},
error: data.error,
}
}
return {
success: true,
output: {
content: data.output.content,
metadata: data.output.metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Success message' },
metadata: {
type: 'object',
description: 'Reaction metadata',
properties: REACTION_METADATA_OUTPUT_PROPERTIES,
},
},
}

View File

@@ -561,12 +561,6 @@ export interface SlackAddReactionParams extends SlackBaseParams {
name: string
}
export interface SlackRemoveReactionParams extends SlackBaseParams {
channel: string
timestamp: string
name: string
}
export interface SlackListChannelsParams extends SlackBaseParams {
includePrivate?: boolean
excludeArchived?: boolean
@@ -606,29 +600,6 @@ export interface SlackGetThreadParams extends SlackBaseParams {
limit?: number
}
export interface SlackGetChannelInfoParams extends SlackBaseParams {
channel: string
includeNumMembers?: boolean
}
export interface SlackGetUserPresenceParams extends SlackBaseParams {
userId: string
}
export interface SlackEditCanvasParams extends SlackBaseParams {
canvasId: string
operation: string
content?: string
sectionId?: string
title?: string
}
export interface SlackCreateChannelCanvasParams extends SlackBaseParams {
channel: string
title?: string
content?: string
}
export interface SlackMessageResponse extends ToolResponse {
output: {
// Legacy properties for backward compatibility
@@ -788,34 +759,17 @@ export interface SlackAddReactionResponse extends ToolResponse {
}
}
export interface SlackRemoveReactionResponse extends ToolResponse {
output: {
content: string
metadata: {
channel: string
timestamp: string
reaction: string
}
}
}
export interface SlackChannel {
id: string
name: string
is_channel?: boolean
is_private: boolean
is_archived: boolean
is_general?: boolean
is_member: boolean
is_shared?: boolean
is_ext_shared?: boolean
is_org_shared?: boolean
num_members?: number
topic?: string
purpose?: string
created?: number
creator?: string
updated?: number
}
export interface SlackListChannelsResponse extends ToolResponse {
@@ -904,35 +858,6 @@ export interface SlackGetThreadResponse extends ToolResponse {
}
}
export interface SlackGetChannelInfoResponse extends ToolResponse {
output: {
channelInfo: SlackChannel
}
}
export interface SlackGetUserPresenceResponse extends ToolResponse {
output: {
presence: string
online?: boolean | null
autoAway?: boolean | null
manualAway?: boolean | null
connectionCount?: number | null
lastActivity?: number | null
}
}
export interface SlackEditCanvasResponse extends ToolResponse {
output: {
content: string
}
}
export interface SlackCreateChannelCanvasResponse extends ToolResponse {
output: {
canvas_id: string
}
}
export type SlackResponse =
| SlackCanvasResponse
| SlackMessageReaderResponse
@@ -941,7 +866,6 @@ export type SlackResponse =
| SlackUpdateMessageResponse
| SlackDeleteMessageResponse
| SlackAddReactionResponse
| SlackRemoveReactionResponse
| SlackListChannelsResponse
| SlackListMembersResponse
| SlackListUsersResponse
@@ -949,7 +873,3 @@ export type SlackResponse =
| SlackEphemeralMessageResponse
| SlackGetMessageResponse
| SlackGetThreadResponse
| SlackGetChannelInfoResponse
| SlackGetUserPresenceResponse
| SlackEditCanvasResponse
| SlackCreateChannelCanvasResponse

View File

@@ -13,7 +13,7 @@
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.8.13",
"turbo": "2.8.12",
},
},
"apps/docs": {
@@ -115,6 +115,7 @@
"@trigger.dev/sdk": "4.1.2",
"@types/react-window": "2.0.0",
"@types/three": "0.177.0",
"acorn": "8.16.0",
"better-auth": "1.3.12",
"binary-extensions": "^2.0.0",
"browser-image-compression": "^2.0.2",
@@ -1641,7 +1642,7 @@
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
@@ -3493,19 +3494,19 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="],
"turbo": ["turbo@2.8.12", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.12", "turbo-darwin-arm64": "2.8.12", "turbo-linux-64": "2.8.12", "turbo-linux-arm64": "2.8.12", "turbo-windows-64": "2.8.12", "turbo-windows-arm64": "2.8.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-auUAMLmi0eJhxDhQrxzvuhfEbICnVt0CTiYQYY8WyRJ5nwCDZxD0JG8bCSxT4nusI2CwJzmZAay5BfF6LmK7Hw=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="],
"turbo-darwin-64": ["turbo-darwin-64@2.8.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-EiHJmW2MeQQx+21x8hjMHw/uPhXt9PIxvDrxzOtyVwrXzL0tQmsxtO4qHf2l7uA+K6PUJ4+TjY1MHZDuCvWXrw=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cbqqGN0vd7ly2TeuaM8k9AK9u1CABO4kBA5KPSqovTiLL3sORccn/mZzJSbvQf0EsYRfU34MgW5FotfwW3kx8Q=="],
"turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="],
"turbo-linux-64": ["turbo-linux-64@2.8.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jXKw9j4r4q6s0goSXuKI3aKbQK2qiNeP25lGGEnq018TM6SWRW1CCpPMxyG91aCKrub7wDm/K45sGNT4ZFBcFQ=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-BRJCMdyXjyBoL0GYpvj9d2WNfMHwc3tKmJG5ATn2Efvil9LsiOsd/93/NxDqW0jACtHFNVOPnd/CBwXRPiRbwA=="],
"turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="],
"turbo-windows-64": ["turbo-windows-64@2.8.12", "", { "os": "win32", "cpu": "x64" }, "sha512-vyFOlpFFzQFkikvSVhVkESEfzIopgs2J7J1rYvtSwSHQ4zmHxkC95Q8Kjkus8gg+8X2mZyP1GS5jirmaypGiPw=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-9nRnlw5DF0LkJClkIws1evaIF36dmmMEO84J5Uj4oQ8C0QTHwlH7DNe5Kq2Jdmu8GXESCNDNuUYG8Cx6W/vm3g=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
@@ -3825,6 +3826,8 @@
"@langchain/core/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"@mdx-js/mdx/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
@@ -4079,6 +4082,8 @@
"engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"esast-util-from-js/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -4139,6 +4144,8 @@
"imapflow/pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="],
"import-in-the-middle/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"inquirer/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
@@ -4173,8 +4180,12 @@
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mlly/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"neo4j-driver-bolt-connection/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],

View File

@@ -42,7 +42,7 @@
"glob": "13.0.0",
"husky": "9.1.7",
"lint-staged": "16.0.0",
"turbo": "2.8.13"
"turbo": "2.8.12"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,scss}": [