Checkpoint

This commit is contained in:
Siddharth Ganesan
2026-02-14 12:52:55 -08:00
parent c0e76b374a
commit 3edb71f9b5
10 changed files with 1715 additions and 128 deletions

View File

@@ -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`

View File

@@ -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

View File

@@ -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)', () => {

View File

@@ -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

View File

@@ -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,
}
},
}

View File

@@ -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
}

View 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'])
})
})

View 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: [],
}
}

View File

@@ -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