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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user