d25d482dc2
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
606 lines
17 KiB
TypeScript
606 lines
17 KiB
TypeScript
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,
|
|
}
|
|
}
|