chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,605 @@
import { isRecordLike, sortObjectKeysDeep } from '@sim/utils/object'
import type { Edge } from 'reactflow'
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
/**
* Sanitized workflow state for copilot (removes all UI-specific data)
* Connections are embedded in blocks for consistency with operations format
* Loops and parallels use nested structure - no separate loops/parallels objects
*/
export interface CopilotWorkflowState {
blocks: Record<string, CopilotBlockState>
}
/**
* Block state for copilot (no positions, no UI dimensions, no redundant IDs)
* Connections are embedded here instead of separate edges array
* Loops and parallels have nested structure for clarity
*/
interface CopilotBlockState {
type: string
name: string
inputs?: Record<string, string | number | string[][] | object>
connections?: Record<string, string | string[]>
nestedNodes?: Record<string, CopilotBlockState>
enabled: boolean
advancedMode?: boolean
triggerMode?: boolean
}
/**
* Edge state for copilot (only semantic connection data)
*/
interface CopilotEdge {
id: string
source: string
target: string
sourceHandle?: string
targetHandle?: string
}
/**
* Export workflow state (includes positions but removes secrets)
*/
export interface ExportWorkflowState {
version: string
exportedAt: string
state: {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
metadata?: {
name?: string
description?: string
sortOrder?: number
exportedAt?: string
}
variables?: Record<
string,
{
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
value: unknown
}
>
}
}
/** Condition structure for sanitization */
interface SanitizedCondition {
id: string
title: string
value: string
}
function toSanitizedCondition(condition: unknown): SanitizedCondition {
const record = isRecordLike(condition) ? condition : {}
return {
id: String(record.id ?? ''),
title: String(record.title ?? ''),
value: String(record.value ?? ''),
}
}
function parseArrayValue(value: unknown): unknown[] | null {
if (Array.isArray(value)) {
return value
}
if (typeof value !== 'string') {
return null
}
try {
const parsed: unknown = JSON.parse(value)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function parseConditions(value: unknown): Array<{ id: string; title: string }> | null {
const items = parseArrayValue(value)
if (!items) {
return null
}
const conditions: Array<{ id: string; title: string }> = []
for (const item of items) {
if (!isRecordLike(item) || typeof item.id !== 'string') {
return null
}
conditions.push({
id: item.id,
title: typeof item.title === 'string' ? item.title : '',
})
}
return conditions
}
function parseRoutes(value: unknown): Array<{ id: string; title?: string }> | null {
const items = parseArrayValue(value)
if (!items) {
return null
}
const routes: Array<{ id: string; title?: string }> = []
for (const item of items) {
if (!isRecordLike(item) || typeof item.id !== 'string') {
return null
}
routes.push({
id: item.id,
title: typeof item.title === 'string' ? item.title : undefined,
})
}
return routes
}
/**
* Sanitize condition blocks by removing UI-specific metadata
* Returns cleaned JSON string (not parsed array)
*/
function sanitizeConditions(conditionsJson: string): string {
try {
const conditions: unknown = JSON.parse(conditionsJson)
if (!Array.isArray(conditions)) return conditionsJson
// Keep only id, title, and value - remove UI state
const cleaned: SanitizedCondition[] = conditions.map(toSanitizedCondition)
return JSON.stringify(cleaned)
} catch {
return conditionsJson
}
}
/** Tool input structure for sanitization */
interface ToolInput {
type: string
customToolId?: string
schema?: {
type?: string
function?: {
name: string
description?: string
parameters?: unknown
}
}
code?: string
title?: string
toolId?: string
usageControl?: string
isExpanded?: boolean
[key: string]: unknown
}
/** Sanitized tool output structure */
interface SanitizedTool {
type: string
customToolId?: string
usageControl?: string
title?: string
toolId?: string
schema?: {
type: string
function: {
name: string
description?: string
parameters?: unknown
}
}
code?: string
[key: string]: unknown
}
/**
* Sanitize tools array by removing UI state and redundant fields
*/
function sanitizeTools(tools: ToolInput[]): SanitizedTool[] {
return tools.map((tool): SanitizedTool => {
if (tool.type === 'custom-tool') {
// New reference format: minimal fields only
if (tool.customToolId && !tool.schema && !tool.code) {
return {
type: tool.type,
customToolId: tool.customToolId,
usageControl: tool.usageControl,
}
}
// Legacy inline format: include all fields
const sanitized: SanitizedTool = {
type: tool.type,
title: tool.title,
toolId: tool.toolId,
usageControl: tool.usageControl,
}
// Include schema for inline format (legacy format)
if (tool.schema?.function) {
sanitized.schema = {
type: tool.schema.type || 'function',
function: {
name: tool.schema.function.name,
description: tool.schema.function.description,
parameters: tool.schema.function.parameters,
},
}
}
// Include code for inline format (legacy format)
if (tool.code) {
sanitized.code = tool.code
}
return sanitized
}
const { isExpanded: _isExpanded, ...cleanTool } = tool
return cleanTool as SanitizedTool
})
}
function isToolInput(value: unknown): value is ToolInput {
return isRecordLike(value) && typeof value.type === 'string'
}
/**
* Sanitize subblocks by removing null values and simplifying structure
* Maps each subblock key directly to its value instead of the full object
*/
function sanitizeSubBlocks(
subBlocks: BlockState['subBlocks']
): Record<string, string | number | string[][] | object> {
const sanitized: Record<string, string | number | string[][] | object> = {}
Object.entries(subBlocks).forEach(([key, subBlock]) => {
// Skip null/undefined values
if (subBlock.value === null || subBlock.value === undefined) {
return
}
// Normalize responseFormat for consistent key ordering (important for training data)
if (key === 'responseFormat') {
try {
let obj = subBlock.value
// Parse JSON string if needed
if (typeof subBlock.value === 'string') {
const trimmed = subBlock.value.trim()
if (!trimmed) {
return
}
obj = JSON.parse(trimmed)
}
// Sort keys for consistent comparison
if (obj && typeof obj === 'object') {
sanitized[key] = sortObjectKeysDeep(obj) as Record<string, unknown>
return
}
} catch {
// Invalid JSON - pass through as-is
sanitized[key] = subBlock.value
return
}
}
// Special handling for condition-input type - clean UI metadata
if (subBlock.type === 'condition-input') {
if (typeof subBlock.value === 'string') {
sanitized[key] = sanitizeConditions(subBlock.value)
} else if (Array.isArray(subBlock.value)) {
sanitized[key] = subBlock.value.map(toSanitizedCondition)
} else {
sanitized[key] = subBlock.value
}
return
}
if (key === 'tools' && Array.isArray(subBlock.value)) {
const toolItems: unknown[] = subBlock.value
sanitized[key] = sanitizeTools(toolItems.filter(isToolInput))
return
}
// Skip knowledge base tag filters and document tags (workspace-specific data)
if (key === 'tagFilters' || key === 'documentTags') {
return
}
sanitized[key] = subBlock.value
})
return sanitized
}
/**
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
* Uses 0-indexed numbering for else-if conditions
*/
function convertConditionHandleToSimple(
handle: string,
_blockId: string,
block: BlockState
): string {
if (!handle.startsWith('condition-')) {
return handle
}
// Extract the condition UUID from the handle
const conditionId = handle.substring('condition-'.length)
// Get conditions from block subBlocks (may be JSON string or array)
const conditionsValue = block.subBlocks?.conditions?.value
if (!conditionsValue) {
return handle
}
const conditions = parseConditions(conditionsValue)
if (!conditions) {
return handle
}
// Find the condition by ID and generate simple handle
let elseIfIndex = 0
for (const condition of conditions) {
const title = condition.title?.toLowerCase()
if (condition.id === conditionId) {
if (title === 'if') {
return 'if'
}
if (title === 'else if') {
return `else-if-${elseIfIndex}`
}
if (title === 'else') {
return 'else'
}
}
// Count else-ifs as we iterate (for index tracking)
if (title === 'else if') {
elseIfIndex++
}
}
// Fallback: return original handle if condition not found
return handle
}
/**
* Convert internal router handle (router-{uuid}) to simple format (route-0, route-1)
* Uses 0-indexed numbering for routes
*/
function convertRouterHandleToSimple(handle: string, _blockId: string, block: BlockState): string {
if (!handle.startsWith('router-')) {
return handle
}
// Extract the route UUID from the handle
const routeId = handle.substring('router-'.length)
// Get routes from block subBlocks (may be JSON string or array)
const routesValue = block.subBlocks?.routes?.value
if (!routesValue) {
return handle
}
const routes = parseRoutes(routesValue)
if (!routes) {
return handle
}
// Find the route by ID and generate simple handle (0-indexed)
for (let i = 0; i < routes.length; i++) {
if (routes[i].id === routeId) {
return `route-${i}`
}
}
// Fallback: return original handle if route not found
return handle
}
/**
* Convert source handle to simple format for condition and router blocks
* Outputs: if, else-if-0, else (for conditions) and route-0, route-1 (for routers)
*/
function convertToSimpleHandle(handle: string, blockId: string, block: BlockState): string {
if (handle.startsWith('condition-') && block.type === 'condition') {
return convertConditionHandleToSimple(handle, blockId, block)
}
if (handle.startsWith('router-') && block.type === 'router_v2') {
return convertRouterHandleToSimple(handle, blockId, block)
}
return handle
}
/**
* Extract connections for a block from edges and format as operations-style connections
* Converts internal UUID handles to semantic format for training data
*/
function extractConnectionsForBlock(
blockId: string,
edges: WorkflowState['edges'],
block: BlockState
): Record<string, string | string[]> | undefined {
const connections: Record<string, string[]> = {}
// Find all outgoing edges from this block
const outgoingEdges = edges.filter((edge) => edge.source === blockId)
if (outgoingEdges.length === 0) {
return undefined
}
// Group by source handle (converting to simple format)
for (const edge of outgoingEdges) {
let handle = edge.sourceHandle || 'source'
// Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.)
handle = convertToSimpleHandle(handle, blockId, block)
if (!connections[handle]) {
connections[handle] = []
}
connections[handle].push(edge.target)
}
// Simplify single-element arrays to just the string
const simplified: Record<string, string | string[]> = {}
for (const [handle, targets] of Object.entries(connections)) {
simplified[handle] = targets.length === 1 ? targets[0] : targets
}
return simplified
}
/**
* Sanitize workflow state for copilot by removing all UI-specific data
* Creates nested structure for loops/parallels with their child blocks inside
*/
export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
const sanitizedBlocks: Record<string, CopilotBlockState> = {}
const processedBlocks = new Set<string>()
// Helper to find child blocks of a parent (loop/parallel container)
const findChildBlocks = (parentId: string): string[] => {
return Object.keys(state.blocks).filter(
(blockId) => state.blocks[blockId].data?.parentId === parentId
)
}
// Helper to recursively sanitize a block and its children
const sanitizeBlock = (blockId: string, block: BlockState): CopilotBlockState => {
const connections = extractConnectionsForBlock(blockId, state.edges, block)
// For loop/parallel blocks, extract config from block.data instead of subBlocks
let inputs: Record<string, string | number | string[][] | object>
if (block.type === 'loop' || block.type === 'parallel') {
// Extract configuration from block.data (only include type-appropriate fields)
const loopInputs: Record<string, string | number | string[][] | object> = {}
if (block.type === 'loop') {
const loopType = block.data?.loopType || 'for'
loopInputs.loopType = loopType
// Only export fields relevant to the current loopType
if (loopType === 'for' && block.data?.count !== undefined) {
loopInputs.iterations = block.data.count
}
if (loopType === 'forEach' && block.data?.collection !== undefined) {
loopInputs.collection = block.data.collection
}
if (loopType === 'while' && block.data?.whileCondition !== undefined) {
loopInputs.condition = block.data.whileCondition
}
if (loopType === 'doWhile' && block.data?.doWhileCondition !== undefined) {
loopInputs.condition = block.data.doWhileCondition
}
} else if (block.type === 'parallel') {
const parallelType = block.data?.parallelType || 'count'
loopInputs.parallelType = parallelType
// Only export fields relevant to the current parallelType
if (parallelType === 'count' && block.data?.count !== undefined) {
loopInputs.iterations = block.data.count
}
if (parallelType === 'collection' && block.data?.collection !== undefined) {
loopInputs.collection = block.data.collection
}
}
inputs = loopInputs
} else {
// For regular blocks, sanitize subBlocks
inputs = sanitizeSubBlocks(block.subBlocks)
}
// Check if this is a loop or parallel (has children)
const childBlockIds = findChildBlocks(blockId)
const nestedNodes: Record<string, CopilotBlockState> = {}
if (childBlockIds.length > 0) {
// Recursively sanitize child blocks
childBlockIds.forEach((childId) => {
const childBlock = state.blocks[childId]
if (childBlock) {
nestedNodes[childId] = sanitizeBlock(childId, childBlock)
processedBlocks.add(childId)
}
})
}
// Create clean result without runtime data (outputs, positions, layout, etc.)
const result: CopilotBlockState = {
type: block.type,
name: block.name,
enabled: block.enabled,
}
if (Object.keys(inputs).length > 0) result.inputs = inputs
if (connections) result.connections = connections
if (Object.keys(nestedNodes).length > 0) result.nestedNodes = nestedNodes
if (block.advancedMode !== undefined) result.advancedMode = block.advancedMode
if (block.triggerMode !== undefined) result.triggerMode = block.triggerMode
// Note: outputs, position, height, layout, horizontalHandles are intentionally excluded
// These are runtime/UI-specific fields not needed for copilot understanding
return result
}
// Process only root-level blocks (those without a parent)
Object.entries(state.blocks).forEach(([blockId, block]) => {
// Skip if already processed as a child
if (processedBlocks.has(blockId)) return
// Skip if it has a parent (it will be processed as nested)
if (block.data?.parentId) return
sanitizedBlocks[blockId] = sanitizeBlock(blockId, block)
})
return {
blocks: sanitizedBlocks,
}
}
/**
* Sanitize workflow state for export by removing secrets but keeping positions
* Users need positions to restore the visual layout when importing
*/
export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
const canonicalLoops = generateLoopBlocks(state.blocks || {})
const canonicalParallels = generateParallelBlocks(state.blocks || {})
// Preserve edges, loops, parallels, metadata, and variables
const fullState = {
blocks: state.blocks,
edges: state.edges,
loops: canonicalLoops,
parallels: canonicalParallels,
metadata: state.metadata,
variables: state.variables,
}
// Use unified sanitization with env var preservation for export
const sanitizedState = sanitizeWorkflowForSharing(fullState, {
preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows
}) as ExportWorkflowState['state']
return {
version: '1.0',
exportedAt: new Date().toISOString(),
state: sanitizedState,
}
}
@@ -0,0 +1,9 @@
/**
* Checks if a key is valid (not undefined, null, empty, or literal "undefined"/"null")
* Use this to validate BEFORE setting a dynamic key on any object.
*/
export function isValidKey(key: unknown): key is string {
return (
!!key && typeof key === 'string' && key !== 'undefined' && key !== 'null' && key.trim() !== ''
)
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import {
isLikelyReferenceSegment,
splitReferenceSegment,
} from '@/lib/workflows/sanitization/references'
describe('splitReferenceSegment', () => {
it('should return leading and reference for simple segments', () => {
const result = splitReferenceSegment('<block.output>')
expect(result).toEqual({
leading: '',
reference: '<block.output>',
})
})
it('should separate comparator prefixes from reference', () => {
const result = splitReferenceSegment('< <block2.output>')
expect(result).toEqual({
leading: '< ',
reference: '<block2.output>',
})
})
it('should handle <= comparator prefixes', () => {
const result = splitReferenceSegment('<= <block2.output>')
expect(result).toEqual({
leading: '<= ',
reference: '<block2.output>',
})
})
})
describe('isLikelyReferenceSegment', () => {
it('should return true for regular references', () => {
expect(isLikelyReferenceSegment('<block.output>')).toBe(true)
})
it('should return true for references after comparator', () => {
expect(isLikelyReferenceSegment('< <block2.output>')).toBe(true)
expect(isLikelyReferenceSegment('<= <block2.output>')).toBe(true)
})
it('should return false when leading content is not comparator characters', () => {
expect(isLikelyReferenceSegment('<foo<bar>')).toBe(false)
})
it('should return true for references starting with a digit', () => {
expect(isLikelyReferenceSegment('<1password1>')).toBe(true)
expect(isLikelyReferenceSegment('<1password1.secret>')).toBe(true)
})
it('should return false for purely numeric references', () => {
expect(isLikelyReferenceSegment('<123>')).toBe(false)
})
})
@@ -0,0 +1,120 @@
import { normalizeName, REFERENCE } from '@/executor/constants'
export const SYSTEM_REFERENCE_PREFIXES = new Set(['loop', 'parallel', 'variable'])
const INVALID_REFERENCE_CHARS = /[+*/=<>!]/
const LEADING_REFERENCE_PATTERN = /^[<>=!\s]*$/
export function splitReferenceSegment(
segment: string
): { leading: string; reference: string } | null {
if (!segment.startsWith(REFERENCE.START) || !segment.endsWith(REFERENCE.END)) {
return null
}
const lastOpenBracket = segment.lastIndexOf(REFERENCE.START)
if (lastOpenBracket === -1) {
return null
}
const leading = lastOpenBracket > 0 ? segment.slice(0, lastOpenBracket) : ''
const reference = segment.slice(lastOpenBracket)
if (!reference.startsWith(REFERENCE.START) || !reference.endsWith(REFERENCE.END)) {
return null
}
return { leading, reference }
}
export function isLikelyReferenceSegment(segment: string): boolean {
const split = splitReferenceSegment(segment)
if (!split) {
return false
}
const { leading, reference } = split
if (leading && !LEADING_REFERENCE_PATTERN.test(leading)) {
return false
}
const inner = reference.slice(REFERENCE.START.length, -REFERENCE.END.length)
if (!inner) {
return false
}
if (inner.startsWith(' ')) {
return false
}
if (inner.match(/^\s*[<>=!]+\s*$/) || inner.match(/\s[<>=!]+\s/)) {
return false
}
if (inner.match(/^[<>=!]+\s/)) {
return false
}
if (inner.includes(REFERENCE.PATH_DELIMITER)) {
const dotIndex = inner.indexOf(REFERENCE.PATH_DELIMITER)
const beforeDot = inner.substring(0, dotIndex)
const afterDot = inner.substring(dotIndex + REFERENCE.PATH_DELIMITER.length)
if (afterDot.includes(' ')) {
return false
}
if (INVALID_REFERENCE_CHARS.test(beforeDot) || INVALID_REFERENCE_CHARS.test(afterDot)) {
return false
}
} else if (INVALID_REFERENCE_CHARS.test(inner) || inner.match(/^\d+$/) || inner.match(/\s\d/)) {
return false
}
return true
}
export function extractReferencePrefixes(value: string): Array<{ raw: string; prefix: string }> {
if (!value || typeof value !== 'string') {
return []
}
const referencePattern = new RegExp(`${REFERENCE.START}[^${REFERENCE.END}]+${REFERENCE.END}`, 'g')
const matches = value.match(referencePattern)
if (!matches) {
return []
}
const references: Array<{ raw: string; prefix: string }> = []
for (const match of matches) {
const split = splitReferenceSegment(match)
if (!split) {
continue
}
if (split.leading && !LEADING_REFERENCE_PATTERN.test(split.leading)) {
continue
}
const referenceSegment = split.reference
if (!isLikelyReferenceSegment(referenceSegment)) {
continue
}
const inner = referenceSegment.slice(REFERENCE.START.length, -REFERENCE.END.length)
const [rawPrefix] = inner.split(REFERENCE.PATH_DELIMITER)
if (!rawPrefix) {
continue
}
const normalized = normalizeName(rawPrefix)
references.push({ raw: referenceSegment, prefix: normalized })
}
return references
}
@@ -0,0 +1,137 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.unmock('@/blocks/registry')
import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations'
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
const FIELD_ID = 'cd7e4a16-c608-4087-8f2d-61f9672baeda'
/**
* A placed custom block whose stored structure only has the wiring sub-blocks.
* `getBlock` cannot resolve `custom_block_*` types outside the org overlay —
* exactly the draft-load context where sanitization runs.
*/
function makeCustomBlock(subBlocks: Record<string, unknown>) {
return { id: 'block-1', type: 'custom_block_abc123', subBlocks }
}
describe('sanitizeMalformedSubBlocks', () => {
describe('custom blocks (schema-agnostic)', () => {
it('keeps and repairs a consumer-typed field value stored with the realtime "unknown" fallback', () => {
const { subBlocks, changed } = sanitizeMalformedSubBlocks(
makeCustomBlock({
workflowId: { id: 'workflowId', type: 'short-input', value: 'wf-1' },
inputMapping: { id: 'inputMapping', type: 'code', value: '{}' },
[FIELD_ID]: { id: FIELD_ID, type: 'unknown', value: 'theo' },
})
)
expect(changed).toBe(true)
expect(subBlocks[FIELD_ID]).toEqual({ id: FIELD_ID, type: 'short-input', value: 'theo' })
expect(subBlocks.workflowId.value).toBe('wf-1')
expect(subBlocks.inputMapping.value).toBe('{}')
})
it('keeps a raw non-record field value by wrapping it in a repaired entry', () => {
const { subBlocks, changed } = sanitizeMalformedSubBlocks(
makeCustomBlock({ [FIELD_ID]: 'theo' })
)
expect(changed).toBe(true)
expect(subBlocks[FIELD_ID]).toEqual({ id: FIELD_ID, type: 'short-input', value: 'theo' })
})
it('keeps an entry with missing metadata, keying it by the map key', () => {
const { subBlocks, changed } = sanitizeMalformedSubBlocks(
makeCustomBlock({ [FIELD_ID]: { value: 'theo' } })
)
expect(changed).toBe(true)
expect(subBlocks[FIELD_ID]).toEqual({ id: FIELD_ID, type: 'short-input', value: 'theo' })
})
it('leaves well-formed sub-blocks untouched and reports no change', () => {
const input = {
workflowId: { id: 'workflowId', type: 'short-input', value: 'wf-1' },
[FIELD_ID]: { id: FIELD_ID, type: 'short-input', value: 'theo' },
}
const { subBlocks, changed } = sanitizeMalformedSubBlocks(makeCustomBlock(input))
expect(changed).toBe(false)
expect(subBlocks).toBe(input)
})
it('still drops the literal "undefined" key', () => {
const { subBlocks, changed } = sanitizeMalformedSubBlocks(
makeCustomBlock({ undefined: { id: 'undefined', type: 'unknown', value: 'x' } })
)
expect(changed).toBe(true)
expect(subBlocks).toEqual({})
})
})
describe('through migrateSubblockIds (the draft-load migration pipeline)', () => {
it('a typed custom-block field value survives the load-time migration pass', () => {
const blocks: Record<string, BlockState> = {
b1: {
id: 'b1',
name: 'Update Internal Allowlist 1',
position: { x: 0, y: 0 },
type: 'custom_block_abc123',
subBlocks: {
workflowId: { id: 'workflowId', type: 'short-input', value: 'wf-child' },
inputMapping: { id: 'inputMapping', type: 'code', value: '{}' },
[FIELD_ID]: { id: FIELD_ID, type: 'unknown', value: 'test' },
},
outputs: {},
enabled: true,
} as BlockState,
}
const { blocks: migrated, migrated: changed } = migrateSubblockIds(blocks)
expect(changed).toBe(true)
expect(migrated.b1.subBlocks[FIELD_ID]).toEqual({
id: FIELD_ID,
type: 'short-input',
value: 'test',
})
})
})
describe('regular blocks (config is the schema)', () => {
it('still drops an "unknown"-typed entry that matches no configured sub-block', () => {
const { subBlocks, changed } = sanitizeMalformedSubBlocks({
id: 'block-1',
type: 'function',
subBlocks: {
code: { id: 'code', type: 'code', value: 'return 1' },
stale: { id: 'stale', type: 'unknown', value: 'x' },
},
})
expect(changed).toBe(true)
expect(subBlocks.stale).toBeUndefined()
expect(subBlocks.code.value).toBe('return 1')
})
it('repairs an "unknown"-typed entry that matches a configured sub-block', () => {
const { subBlocks, changed } = sanitizeMalformedSubBlocks({
id: 'block-1',
type: 'function',
subBlocks: {
code: { id: 'code', type: 'unknown', value: 'return 1' },
},
})
expect(changed).toBe(true)
expect(subBlocks.code).toEqual({ id: 'code', type: 'code', value: 'return 1' })
})
})
})
@@ -0,0 +1,125 @@
import { createLogger } from '@sim/logger'
import { isPlainRecord } from '@sim/utils/object'
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
import { getBlock } from '@/blocks'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowSubblockSanitization')
interface SanitizeMalformedSubBlocksOptions {
convertEmptyStringToNull?: boolean
}
interface SanitizableBlock {
id: string
type: string
subBlocks?: Record<string, unknown>
}
/**
* Repairs legacy subBlock metadata when the map key identifies a real field,
* and drops entries that cannot be associated with a stable subBlock.
*
* Custom blocks are schema-agnostic here: their server-side config never
* declares the per-field input sub-blocks (the execution overlay passes bare
* wiring rows, and this may run with no overlay at all), so "not in config"
* carries no signal for them. A consumer-typed field value stored via the
* realtime `type: 'unknown'` fallback must be repaired to a concrete type and
* kept — dropping it would delete user input from the draft. Values for fields
* the source workflow no longer has are filtered at serialization/execution
* (`customBlockHasDeclaredInputs`, `remapCustomBlockInputKeys`), never at rest.
*/
export function sanitizeMalformedSubBlocks(
block: SanitizableBlock,
options: SanitizeMalformedSubBlocksOptions = {}
): { subBlocks: Record<string, BlockState['subBlocks'][string]>; changed: boolean } {
let changed = false
const blockConfig = getBlock(block.type)
const schemaAgnostic = isCustomBlockType(block.type)
const result: Record<string, BlockState['subBlocks'][string]> = {}
for (const [subBlockId, subBlock] of Object.entries(block.subBlocks || {})) {
if (subBlockId === 'undefined') {
logger.warn('Skipping malformed subBlock with key "undefined"', { blockId: block.id })
changed = true
continue
}
const configuredType = blockConfig?.subBlocks?.find((config) => config.id === subBlockId)?.type
if (!isPlainRecord(subBlock)) {
if (!configuredType && !schemaAgnostic) {
logger.warn('Skipping malformed subBlock: unrecognized value entry', {
blockId: block.id,
subBlockId,
})
changed = true
continue
}
logger.warn('Repairing malformed subBlock value', { blockId: block.id, subBlockId })
result[subBlockId] = {
id: subBlockId,
type: configuredType || DEFAULT_SUBBLOCK_TYPE,
value: options.convertEmptyStringToNull && subBlock === '' ? null : subBlock,
} as BlockState['subBlocks'][string]
changed = true
continue
}
if (subBlock.type === 'unknown' && !configuredType && !schemaAgnostic) {
logger.warn('Skipping malformed subBlock: type is "unknown"', {
blockId: block.id,
subBlockId,
})
changed = true
continue
}
const id = typeof subBlock.id === 'string' && subBlock.id.length > 0 ? subBlock.id : subBlockId
const typeFromConfig =
configuredType || blockConfig?.subBlocks?.find((config) => config.id === id)?.type
const missingMetadata =
typeof subBlock.id !== 'string' ||
subBlock.id.length === 0 ||
typeof subBlock.type !== 'string' ||
subBlock.type.length === 0
if (missingMetadata && !typeFromConfig && !schemaAgnostic) {
logger.warn('Skipping malformed subBlock: unrecognized metadata entry', {
blockId: block.id,
subBlockId,
})
changed = true
continue
}
const type =
typeof subBlock.type === 'string' && subBlock.type.length > 0 && subBlock.type !== 'unknown'
? subBlock.type
: typeFromConfig || DEFAULT_SUBBLOCK_TYPE
const hasValue = Object.hasOwn(subBlock, 'value')
const value =
options.convertEmptyStringToNull && subBlock.value === ''
? null
: hasValue
? subBlock.value
: null
const repairedMetadata = id !== subBlock.id || type !== subBlock.type
const normalizedValue = hasValue && value !== subBlock.value
if (repairedMetadata) {
logger.warn('Repairing malformed subBlock metadata', { blockId: block.id, subBlockId })
changed = true
} else if (normalizedValue) {
logger.warn('Normalizing malformed subBlock value', { blockId: block.id, subBlockId })
changed = true
}
result[subBlockId] = { ...subBlock, id, type, value } as BlockState['subBlocks'][string]
}
return { subBlocks: changed ? result : (block.subBlocks as BlockState['subBlocks']), changed }
}
@@ -0,0 +1,315 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { getBlock } from '@/blocks/registry'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
const logger = createLogger('WorkflowValidation')
/**
* Checks if a custom tool has a valid inline schema
*/
function isValidCustomToolSchema(tool: unknown): boolean {
try {
if (!isRecordLike(tool)) return false
if (tool.type !== 'custom-tool') return true // non-custom tools are validated elsewhere
const schema = tool.schema
if (!isRecordLike(schema)) return false
const fn = schema.function
if (!isRecordLike(fn)) return false
if (!fn.name || typeof fn.name !== 'string') return false
const params = fn.parameters
if (!isRecordLike(params)) return false
if (params.type !== 'object') return false
if (!isRecordLike(params.properties)) return false
return true
} catch (_err) {
return false
}
}
/**
* Checks if a custom tool is a valid reference-only format (new format)
*/
function isValidCustomToolReference(tool: unknown): boolean {
try {
if (!isRecordLike(tool)) return false
if (tool.type !== 'custom-tool') return false
// Reference format: has customToolId but no inline schema/code
// This is valid - the tool will be loaded dynamically during execution
if (tool.customToolId && typeof tool.customToolId === 'string') {
return true
}
return false
} catch (_err) {
return false
}
}
export function sanitizeAgentToolsInBlocks(blocks: Record<string, BlockState>): {
blocks: Record<string, BlockState>
warnings: string[]
} {
const warnings: string[] = []
// Shallow clone to avoid mutating callers
const sanitizedBlocks: Record<string, BlockState> = { ...blocks }
for (const [blockId, block] of Object.entries(sanitizedBlocks)) {
try {
if (!block || block.type !== 'agent') continue
const subBlocks = block.subBlocks || {}
const toolsSubBlock = subBlocks.tools
if (!toolsSubBlock) continue
let value = toolsSubBlock.value
// Parse legacy string format
if (typeof value === 'string') {
try {
value = JSON.parse(value)
} catch (_e) {
warnings.push(
`Block ${block.name || blockId}: invalid tools JSON; resetting tools to empty array`
)
value = []
}
}
if (!Array.isArray(value)) {
// Force to array to keep client safe
warnings.push(`Block ${block.name || blockId}: tools value is not an array; resetting`)
toolsSubBlock.value = []
continue
}
const originalLength = value.length
const cleaned = value
.filter((tool: unknown) => {
// Allow non-custom tools to pass through as-is
if (!isRecordLike(tool)) return false
if (tool.type !== 'custom-tool') return true
// Check if it's a valid reference-only format (new format)
if (isValidCustomToolReference(tool)) {
return true
}
// Check if it's a valid inline schema format (legacy format)
const ok = isValidCustomToolSchema(tool)
if (!ok) {
logger.warn('Removing invalid custom tool from workflow', {
blockId,
blockName: block.name,
hasCustomToolId: !!tool.customToolId,
hasSchema: !!tool.schema,
})
}
return ok
})
.map((tool: unknown) => {
if (isRecordLike(tool) && tool.type === 'custom-tool') {
// For reference-only tools, ensure usageControl default
if (!tool.usageControl) {
tool.usageControl = 'auto'
}
// For inline tools (legacy), also ensure code default
if (!tool.customToolId && (!tool.code || typeof tool.code !== 'string')) {
tool.code = ''
}
}
return tool
})
if (cleaned.length !== originalLength) {
warnings.push(
`Block ${block.name || blockId}: removed ${originalLength - cleaned.length} invalid tool(s)`
)
}
// Persisted agent tools can be arrays even though SubBlockState.value is typed narrowly.
const toolsValueTarget: { value: unknown } = toolsSubBlock
toolsValueTarget.value = cleaned
// Reassign in case caller uses object identity
sanitizedBlocks[blockId] = { ...block, subBlocks: { ...subBlocks, tools: toolsSubBlock } }
} catch (err: unknown) {
const message = toError(err).message
warnings.push(`Block ${block?.name || blockId}: tools sanitation failed: ${message}`)
}
}
return { blocks: sanitizedBlocks, warnings }
}
export interface WorkflowValidationResult {
valid: boolean
errors: string[]
warnings: string[]
sanitizedState?: WorkflowState
}
/**
* Comprehensive workflow state validation
* Checks all tool references, block types, and required fields
*/
export function validateWorkflowState(
workflowState: WorkflowState,
options: { sanitize?: boolean } = {}
): WorkflowValidationResult {
const errors: string[] = []
const warnings: string[] = []
let sanitizedState = workflowState
try {
// Basic structure validation
if (!workflowState || typeof workflowState !== 'object') {
errors.push('Invalid workflow state: must be an object')
return { valid: false, errors, warnings }
}
if (!workflowState.blocks || typeof workflowState.blocks !== 'object') {
errors.push('Invalid workflow state: missing blocks')
return { valid: false, errors, warnings }
}
// Validate each block
const sanitizedBlocks: Record<string, BlockState> = {}
let hasChanges = false
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
if (!block || typeof block !== 'object') {
errors.push(`Block ${blockId}: invalid block structure`)
continue
}
// Check if block type exists
const blockConfig = getBlock(block.type)
// Special handling for container blocks (loop and parallel)
if (block.type === 'loop' || block.type === 'parallel') {
// These are valid container types, they don't need block configs
sanitizedBlocks[blockId] = block
continue
}
if (!blockConfig) {
errors.push(`Block ${block.name || blockId}: unknown block type '${block.type}'`)
if (options.sanitize) {
hasChanges = true
continue // Skip this block in sanitized output
}
}
// Validate tool references in blocks that use tools
if (block.type === 'api' || block.type === 'generic') {
// For API and generic blocks, the tool is determined by the block's tool configuration
// In the workflow state, we need to check if the block type has valid tool access
const blockConfig = getBlock(block.type)
if (blockConfig?.tools?.access) {
// API block has static tool access
const toolIds = blockConfig.tools.access
for (const toolId of toolIds) {
const validationError = validateToolReference(toolId, block.type, block.name)
if (validationError) {
errors.push(validationError)
}
}
}
} else if (block.type === 'knowledge' || block.type === 'supabase' || block.type === 'mcp') {
// These blocks have dynamic tool selection based on operation
// The actual tool validation happens at runtime based on the operation value
// For now, just ensure the block type is valid (already checked above)
}
// Special validation for agent blocks
if (block.type === 'agent' && block.subBlocks?.tools?.value) {
const toolsSanitization = sanitizeAgentToolsInBlocks({ [blockId]: block })
warnings.push(...toolsSanitization.warnings)
if (toolsSanitization.warnings.length > 0) {
sanitizedBlocks[blockId] = toolsSanitization.blocks[blockId]
hasChanges = true
} else {
sanitizedBlocks[blockId] = block
}
} else {
sanitizedBlocks[blockId] = block
}
}
// Validate edges reference existing blocks
if (workflowState.edges && Array.isArray(workflowState.edges)) {
const blockIds = new Set(Object.keys(sanitizedBlocks))
const loopIds = new Set(Object.keys(workflowState.loops || {}))
const parallelIds = new Set(Object.keys(workflowState.parallels || {}))
for (const edge of workflowState.edges) {
if (!edge || typeof edge !== 'object') {
errors.push('Invalid edge structure')
continue
}
// Check if source and target exist
const sourceExists =
blockIds.has(edge.source) || loopIds.has(edge.source) || parallelIds.has(edge.source)
const targetExists =
blockIds.has(edge.target) || loopIds.has(edge.target) || parallelIds.has(edge.target)
if (!sourceExists) {
errors.push(`Edge references non-existent source block '${edge.source}'`)
}
if (!targetExists) {
errors.push(`Edge references non-existent target block '${edge.target}'`)
}
}
}
// If we made changes during sanitization, create a new state object
if (hasChanges && options.sanitize) {
sanitizedState = {
...workflowState,
blocks: sanitizedBlocks,
}
}
const valid = errors.length === 0
return {
valid,
errors,
warnings,
sanitizedState: options.sanitize ? sanitizedState : undefined,
}
} catch (err) {
logger.error('Workflow validation failed with exception', err)
errors.push(`Validation failed: ${toError(err).message}`)
return { valid: false, errors, warnings }
}
}
/**
* Validate tool reference for a specific block
* Returns null if valid, error message if invalid
*/
export function validateToolReference(
toolId: string | undefined,
blockType: string,
blockName?: string
): string | null {
if (!toolId) return null
if (!isCustomTool(toolId) && !isMcpTool(toolId)) {
// For built-in tools, verify they exist
const tool = getTool(toolId)
if (!tool) {
return `Block ${blockName || 'unknown'} (${blockType}): references non-existent tool '${toolId}'`
}
}
return null
}