mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-14 16:35:01 -05:00
Checkpoint
This commit is contained in:
@@ -106,6 +106,31 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse subblock values that may arrive as JSON strings or already-materialized arrays.
|
||||
*/
|
||||
const parseStructuredArrayValue = (value: unknown): Array<Record<string, unknown>> | null => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(item): item is Record<string, unknown> => typeof item === 'object' && item !== null
|
||||
)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return parsed.filter(
|
||||
(item): item is Record<string, unknown> => typeof item === 'object' && item !== null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for variable assignments array
|
||||
*/
|
||||
@@ -961,25 +986,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
if (type !== 'condition') return [] as { id: string; title: string; value: string }[]
|
||||
|
||||
const conditionsValue = subBlockState.conditions?.value
|
||||
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const conditionItem = item as { id?: string; value?: unknown }
|
||||
const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if'
|
||||
return {
|
||||
id: conditionItem?.id ?? `${id}-cond-${index}`,
|
||||
title,
|
||||
value: typeof conditionItem?.value === 'string' ? conditionItem.value : '',
|
||||
}
|
||||
})
|
||||
const parsed = parseStructuredArrayValue(conditionsValue)
|
||||
if (parsed && parsed.length > 0) {
|
||||
return parsed.map((item, index) => {
|
||||
const conditionId = typeof item.id === 'string' ? item.id : `${id}-cond-${index}`
|
||||
const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if'
|
||||
return {
|
||||
id: conditionId,
|
||||
title,
|
||||
value: typeof item.value === 'string' ? item.value : '',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse condition subblock value', { error, blockId: id })
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof conditionsValue === 'string' && conditionsValue.trim()) {
|
||||
logger.warn('Failed to parse condition subblock value', { blockId: id })
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -997,24 +1018,16 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
if (type !== 'router_v2') return [] as { id: string; value: string }[]
|
||||
|
||||
const routesValue = subBlockState.routes?.value
|
||||
const raw = typeof routesValue === 'string' ? routesValue : undefined
|
||||
const parsed = parseStructuredArrayValue(routesValue)
|
||||
if (parsed && parsed.length > 0) {
|
||||
return parsed.map((item, index) => ({
|
||||
id: typeof item.id === 'string' ? item.id : `${id}-route${index + 1}`,
|
||||
value: typeof item.value === 'string' ? item.value : '',
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const routeItem = item as { id?: string; value?: string }
|
||||
return {
|
||||
// Use stable ID format that matches ConditionInput's generateStableId
|
||||
id: routeItem?.id ?? `${id}-route${index + 1}`,
|
||||
value: routeItem?.value ?? '',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse router routes value', { error, blockId: id })
|
||||
if (typeof routesValue === 'string' && routesValue.trim()) {
|
||||
logger.warn('Failed to parse router routes value', { blockId: id })
|
||||
}
|
||||
|
||||
// Fallback must match ConditionInput's default: generateStableId(blockId, 'route1') = `${blockId}-route1`
|
||||
|
||||
@@ -72,6 +72,31 @@ function extractValue(entry: SubBlockValueEntry | unknown): unknown {
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse subblock values that may be JSON strings or already-materialized arrays.
|
||||
*/
|
||||
function parseStructuredArrayValue(value: unknown): Array<Record<string, unknown>> | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(
|
||||
(item): item is Record<string, unknown> => typeof item === 'object' && item !== null
|
||||
)
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
return parsed.filter(
|
||||
(item): item is Record<string, unknown> => typeof item === 'object' && item !== null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface SubBlockRowProps {
|
||||
title: string
|
||||
value?: string
|
||||
@@ -347,25 +372,16 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
if (lightweight) return defaultRows
|
||||
|
||||
const conditionsValue = rawValues.conditions
|
||||
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const conditionItem = item as { id?: string; value?: unknown }
|
||||
const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if'
|
||||
return {
|
||||
id: conditionItem?.id ?? `cond-${index}`,
|
||||
title,
|
||||
value: typeof conditionItem?.value === 'string' ? conditionItem.value : '',
|
||||
}
|
||||
})
|
||||
const parsed = parseStructuredArrayValue(conditionsValue)
|
||||
if (parsed && parsed.length > 0) {
|
||||
return parsed.map((item, index) => {
|
||||
const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if'
|
||||
return {
|
||||
id: typeof item.id === 'string' ? item.id : `cond-${index}`,
|
||||
title,
|
||||
value: typeof item.value === 'string' ? item.value : '',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
})
|
||||
}
|
||||
|
||||
return defaultRows
|
||||
@@ -384,23 +400,12 @@ function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>
|
||||
if (lightweight) return defaultRows
|
||||
|
||||
const routesValue = rawValues.routes
|
||||
const raw = typeof routesValue === 'string' ? routesValue : undefined
|
||||
|
||||
try {
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const routeItem = item as { id?: string; value?: string }
|
||||
return {
|
||||
id: routeItem?.id ?? `route${index + 1}`,
|
||||
value: routeItem?.value ?? '',
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
const parsed = parseStructuredArrayValue(routesValue)
|
||||
if (parsed && parsed.length > 0) {
|
||||
return parsed.map((item, index) => ({
|
||||
id: typeof item.id === 'string' ? item.id : `route${index + 1}`,
|
||||
value: typeof item.value === 'string' ? item.value : '',
|
||||
}))
|
||||
}
|
||||
|
||||
return defaultRows
|
||||
|
||||
@@ -184,6 +184,48 @@ describe('resolveBlockReference', () => {
|
||||
const result = resolveBlockReference('start', ['input'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should allow nested access under json output schema fields', () => {
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
result: { type: 'json' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['result', 'emails', '0', 'subject'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should allow nested access under any output schema fields', () => {
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
payload: { type: 'any' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['payload', 'nested', 'key'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
|
||||
it('should allow nested access for arrays without explicit item schema', () => {
|
||||
const ctx = createContext({
|
||||
blockData: {},
|
||||
blockOutputSchemas: {
|
||||
'block-1': {
|
||||
rows: { type: 'array' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = resolveBlockReference('start', ['rows', '0', 'value'], ctx)
|
||||
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('without schema (pass-through mode)', () => {
|
||||
|
||||
@@ -53,6 +53,24 @@ function getProperties(schema: unknown): Record<string, unknown> | undefined {
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getSchemaType(schema: unknown): string | null {
|
||||
if (typeof schema !== 'object' || schema === null) return null
|
||||
const rawType = (schema as { type?: unknown }).type
|
||||
return typeof rawType === 'string' ? rawType.toLowerCase() : null
|
||||
}
|
||||
|
||||
function isDynamicSchemaNode(schema: unknown): boolean {
|
||||
const schemaType = getSchemaType(schema)
|
||||
if (!schemaType) return false
|
||||
if (schemaType === 'any' || schemaType === 'json') {
|
||||
return true
|
||||
}
|
||||
if (schemaType === 'object' && !getProperties(schema)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function lookupField(schema: unknown, fieldName: string): unknown | undefined {
|
||||
if (typeof schema !== 'object' || schema === null) return undefined
|
||||
const typed = schema as Record<string, unknown>
|
||||
@@ -83,6 +101,12 @@ function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]):
|
||||
return false
|
||||
}
|
||||
|
||||
if (isDynamicSchemaNode(current)) {
|
||||
// Dynamic schema node (json/any/object-without-properties):
|
||||
// allow deeper traversal without strict field validation.
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(part)) {
|
||||
if (isFileType(current)) {
|
||||
const nextPart = pathParts[i + 1]
|
||||
@@ -94,7 +118,12 @@ function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]):
|
||||
)
|
||||
}
|
||||
if (isArrayType(current)) {
|
||||
current = getArrayItems(current)
|
||||
const items = getArrayItems(current)
|
||||
if (items === undefined) {
|
||||
// Arrays without declared item schema are treated as dynamic.
|
||||
return true
|
||||
}
|
||||
current = items
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -116,6 +145,10 @@ function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]):
|
||||
}
|
||||
|
||||
current = isArrayType(fieldDef) ? getArrayItems(fieldDef) : fieldDef
|
||||
if (current === undefined) {
|
||||
// Array/object without explicit shape after this segment.
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,13 @@ import {
|
||||
|
||||
const logger = createLogger('WorkflowContextServerTool')
|
||||
|
||||
const WORKFLOW_HARD_CONSTRAINTS = [
|
||||
'No nested subflows: loop/parallel cannot be placed inside loop/parallel.',
|
||||
'No cyclic edges: workflow graph must remain acyclic. Use loop/parallel blocks for iteration.',
|
||||
'Container handle rules: loop/parallel start handles connect only to children; end handles connect only outside the container.',
|
||||
'Executable non-trigger blocks should have an incoming connection unless intentionally disconnected.',
|
||||
]
|
||||
|
||||
const WorkflowContextGetInputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
objective: z.string().optional(),
|
||||
@@ -196,6 +203,7 @@ export const workflowContextGetServerTool: BaseServerTool<WorkflowContextGetPara
|
||||
unresolvedRequestedBlockTypes: resolvedRequestedTypes.unresolved,
|
||||
knownBlockTypes: knownTypes,
|
||||
inScopeSchemas: schemasByType,
|
||||
hardConstraints: WORKFLOW_HARD_CONSTRAINTS,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -267,6 +275,7 @@ export const workflowContextExpandServerTool: BaseServerTool<WorkflowContextExpa
|
||||
unresolvedBlockTypes: resolvedTypes.unresolved,
|
||||
knownBlockTypes: knownTypes,
|
||||
warnings,
|
||||
hardConstraints: WORKFLOW_HARD_CONSTRAINTS,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,6 +46,15 @@ function resolveBlockToken(
|
||||
if (blockName === normalized) return blockId
|
||||
if (canonicalizeToken(blockName) === canonical) return blockId
|
||||
}
|
||||
|
||||
// Convenience fallback: if token matches exactly one block type in the workflow,
|
||||
// resolve to that block. This avoids false negatives for shorthand assertions
|
||||
// such as "block_exists:loop" when the workflow has a single loop block.
|
||||
const typeMatches = resolveBlocksByType(workflowState, token)
|
||||
if (typeMatches.length === 1) {
|
||||
return typeMatches[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
31
apps/sim/lib/workflows/sanitization/graph-validation.test.ts
Normal file
31
apps/sim/lib/workflows/sanitization/graph-validation.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectDirectedCycle } from './graph-validation'
|
||||
|
||||
describe('detectDirectedCycle', () => {
|
||||
it('returns no cycle for acyclic graph', () => {
|
||||
const result = detectDirectedCycle([
|
||||
{ source: 'a', target: 'b' },
|
||||
{ source: 'b', target: 'c' },
|
||||
])
|
||||
expect(result.hasCycle).toBe(false)
|
||||
expect(result.cyclePath).toEqual([])
|
||||
})
|
||||
|
||||
it('detects simple directed cycle', () => {
|
||||
const result = detectDirectedCycle([
|
||||
{ source: 'a', target: 'b' },
|
||||
{ source: 'b', target: 'c' },
|
||||
{ source: 'c', target: 'a' },
|
||||
])
|
||||
expect(result.hasCycle).toBe(true)
|
||||
expect(result.cyclePath.length).toBeGreaterThanOrEqual(3)
|
||||
expect(result.cyclePath[0]).toBe(result.cyclePath[result.cyclePath.length - 1])
|
||||
})
|
||||
|
||||
it('detects self loop as cycle', () => {
|
||||
const result = detectDirectedCycle([{ source: 'a', target: 'a' }])
|
||||
expect(result.hasCycle).toBe(true)
|
||||
expect(result.cyclePath).toEqual(['a', 'a'])
|
||||
})
|
||||
})
|
||||
|
||||
76
apps/sim/lib/workflows/sanitization/graph-validation.ts
Normal file
76
apps/sim/lib/workflows/sanitization/graph-validation.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export interface GraphCycleDetectionResult {
|
||||
hasCycle: boolean
|
||||
cyclePath: string[]
|
||||
}
|
||||
|
||||
function buildAdjacency(edges: Array<{ source?: string; target?: string }>): Map<string, Set<string>> {
|
||||
const adjacency = new Map<string, Set<string>>()
|
||||
|
||||
for (const edge of edges || []) {
|
||||
const source = String(edge?.source || '').trim()
|
||||
const target = String(edge?.target || '').trim()
|
||||
if (!source || !target) continue
|
||||
|
||||
if (!adjacency.has(source)) adjacency.set(source, new Set())
|
||||
if (!adjacency.has(target)) adjacency.set(target, new Set())
|
||||
adjacency.get(source)!.add(target)
|
||||
}
|
||||
|
||||
return adjacency
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects directed graph cycles using DFS color marking.
|
||||
* Returns the first detected cycle path in node-id order.
|
||||
*/
|
||||
export function detectDirectedCycle(
|
||||
edges: Array<{ source?: string; target?: string }>
|
||||
): GraphCycleDetectionResult {
|
||||
const adjacency = buildAdjacency(edges)
|
||||
const color = new Map<string, 0 | 1 | 2>() // 0=unvisited,1=visiting,2=done
|
||||
const stack: string[] = []
|
||||
let detectedPath: string[] = []
|
||||
|
||||
const dfs = (node: string): boolean => {
|
||||
color.set(node, 1)
|
||||
stack.push(node)
|
||||
|
||||
const neighbors = adjacency.get(node) || new Set<string>()
|
||||
for (const next of neighbors) {
|
||||
const nextColor = color.get(next) || 0
|
||||
if (nextColor === 0) {
|
||||
if (dfs(next)) return true
|
||||
continue
|
||||
}
|
||||
if (nextColor === 1) {
|
||||
const startIndex = stack.lastIndexOf(next)
|
||||
if (startIndex >= 0) {
|
||||
detectedPath = [...stack.slice(startIndex), next]
|
||||
} else {
|
||||
detectedPath = [node, next, node]
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
stack.pop()
|
||||
color.set(node, 2)
|
||||
return false
|
||||
}
|
||||
|
||||
for (const node of adjacency.keys()) {
|
||||
if ((color.get(node) || 0) !== 0) continue
|
||||
if (dfs(node)) {
|
||||
return {
|
||||
hasCycle: true,
|
||||
cyclePath: detectedPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasCycle: false,
|
||||
cyclePath: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { detectDirectedCycle } from '@/lib/workflows/sanitization/graph-validation'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { isCustomTool, isMcpTool } from '@/executor/constants'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -289,6 +290,16 @@ export function validateWorkflowState(
|
||||
errors.push(`Edge references non-existent target block '${edge.target}'`)
|
||||
}
|
||||
}
|
||||
|
||||
const cycleResult = detectDirectedCycle(
|
||||
workflowState.edges as Array<{ source?: string; target?: string }>
|
||||
)
|
||||
if (cycleResult.hasCycle) {
|
||||
const cyclePath = cycleResult.cyclePath.join(' -> ')
|
||||
errors.push(
|
||||
`Workflow contains a cycle (${cyclePath}). Use loop/parallel blocks for iteration instead of cyclic edges.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If we made changes during sanitization, create a new state object
|
||||
|
||||
Reference in New Issue
Block a user