d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
817 lines
24 KiB
TypeScript
817 lines
24 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { isRecordLike } from '@sim/utils/object'
|
|
import { ApiClientError } from '@/lib/api/client/errors'
|
|
import { requestJson } from '@/lib/api/client/request'
|
|
import {
|
|
getWorkflowStateContract,
|
|
getWorkflowVariablesContract,
|
|
putWorkflowNormalizedStateContract,
|
|
type WorkflowStateContractInput,
|
|
workflowVariablesContract,
|
|
} from '@/lib/api/contracts/workflows'
|
|
import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations'
|
|
import {
|
|
type ExportWorkflowState,
|
|
sanitizeForExport,
|
|
} from '@/lib/workflows/sanitization/json-sanitizer'
|
|
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
|
|
import { regenerateWorkflowIds } from '@/stores/workflows/utils'
|
|
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
|
|
|
|
const logger = createLogger('WorkflowImportExport')
|
|
|
|
function unwrapWorkflowExportEnvelope(data: unknown): unknown {
|
|
if (!isRecordLike(data)) {
|
|
return data
|
|
}
|
|
|
|
const envelopeData = data.data
|
|
if (
|
|
isRecordLike(envelopeData) &&
|
|
(envelopeData.state || envelopeData.version || envelopeData.workflow)
|
|
) {
|
|
return envelopeData
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
async function getJSZip() {
|
|
const { default: JSZip } = await import('jszip')
|
|
return JSZip
|
|
}
|
|
|
|
export interface WorkflowExportData {
|
|
workflow: {
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
folderId?: string | null
|
|
sortOrder?: number
|
|
}
|
|
state: WorkflowState
|
|
variables?: Record<string, Variable>
|
|
}
|
|
|
|
export interface FolderExportData {
|
|
id: string
|
|
name: string
|
|
parentId: string | null
|
|
sortOrder?: number
|
|
}
|
|
|
|
interface WorkspaceExportStructure {
|
|
workspace: {
|
|
name: string
|
|
exportedAt: string
|
|
}
|
|
workflows: WorkflowExportData[]
|
|
folders: FolderExportData[]
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a string for use as a path segment in a ZIP file.
|
|
*/
|
|
export function sanitizePathSegment(name: string): string {
|
|
return name.replace(/[^\p{L}\p{N}\-_]/gu, '-').replace(/-+/g, '-')
|
|
}
|
|
|
|
/**
|
|
* Downloads a file to the user's device.
|
|
*/
|
|
export function downloadFile(
|
|
content: Blob | string,
|
|
filename: string,
|
|
mimeType = 'application/json'
|
|
): void {
|
|
try {
|
|
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
} catch (error) {
|
|
logger.error('Failed to download file:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetches a workflow's state and variables for export.
|
|
* Returns null if the workflow cannot be fetched.
|
|
*/
|
|
export async function fetchWorkflowForExport(
|
|
workflowId: string,
|
|
workflowMeta: { name: string; description?: string; folderId?: string | null }
|
|
): Promise<WorkflowExportData | null> {
|
|
try {
|
|
let workflowData: { state?: WorkflowState }
|
|
try {
|
|
const response = await requestJson(getWorkflowStateContract, {
|
|
params: { id: workflowId },
|
|
})
|
|
workflowData = { state: response.data.state as WorkflowState }
|
|
} catch {
|
|
logger.error(`Failed to fetch workflow ${workflowId}`)
|
|
return null
|
|
}
|
|
|
|
if (!workflowData?.state) {
|
|
logger.warn(`Workflow ${workflowId} has no state`)
|
|
return null
|
|
}
|
|
|
|
let workflowVariables: Record<string, Variable> | undefined
|
|
try {
|
|
const { data } = await requestJson(getWorkflowVariablesContract, {
|
|
params: { id: workflowId },
|
|
})
|
|
workflowVariables = data as Record<string, Variable>
|
|
} catch (error) {
|
|
if (error instanceof ApiClientError) {
|
|
logger.warn(`Failed to fetch workflow ${workflowId} variables for export`, {
|
|
status: error.status,
|
|
message: error.message,
|
|
})
|
|
} else {
|
|
logger.warn(`Failed to fetch workflow ${workflowId} variables for export:`, error)
|
|
}
|
|
}
|
|
|
|
return {
|
|
workflow: {
|
|
id: workflowId,
|
|
name: workflowMeta.name,
|
|
description: workflowMeta.description,
|
|
folderId: workflowMeta.folderId,
|
|
},
|
|
state: workflowData.state,
|
|
variables: workflowVariables,
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch workflow ${workflowId} for export:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exports a single workflow to a JSON string.
|
|
*/
|
|
export function exportWorkflowToJson(workflowData: WorkflowExportData): string {
|
|
const workflowState = {
|
|
...workflowData.state,
|
|
metadata: {
|
|
name: workflowData.workflow.name,
|
|
description: workflowData.workflow.description,
|
|
exportedAt: new Date().toISOString(),
|
|
},
|
|
variables: workflowData.variables,
|
|
}
|
|
|
|
const exportState = sanitizeForExport(workflowState)
|
|
return JSON.stringify(exportState, null, 2)
|
|
}
|
|
|
|
/**
|
|
* Exports multiple workflows to a ZIP file.
|
|
* Workflows are placed at the root level (no folder structure).
|
|
*/
|
|
export async function exportWorkflowsToZip(workflows: WorkflowExportData[]): Promise<Blob> {
|
|
const JSZip = await getJSZip()
|
|
const zip = new JSZip()
|
|
const seenFilenames = new Set<string>()
|
|
|
|
for (const workflow of workflows) {
|
|
const jsonContent = exportWorkflowToJson(workflow)
|
|
const baseName = sanitizePathSegment(workflow.workflow.name)
|
|
let filename = `${baseName}.json`
|
|
let counter = 1
|
|
|
|
while (seenFilenames.has(filename.toLowerCase())) {
|
|
filename = `${baseName}-${counter}.json`
|
|
counter++
|
|
}
|
|
seenFilenames.add(filename.toLowerCase())
|
|
zip.file(filename, jsonContent)
|
|
}
|
|
|
|
return await zip.generateAsync({ type: 'blob' })
|
|
}
|
|
|
|
function buildFolderPath(
|
|
folderId: string | null | undefined,
|
|
foldersMap: Map<string, FolderExportData>
|
|
): string {
|
|
if (!folderId) return ''
|
|
|
|
const path: string[] = []
|
|
let currentId: string | null = folderId
|
|
|
|
while (currentId && foldersMap.has(currentId)) {
|
|
const folder: FolderExportData = foldersMap.get(currentId)!
|
|
path.unshift(sanitizePathSegment(folder.name))
|
|
currentId = folder.parentId
|
|
}
|
|
|
|
return path.join('/')
|
|
}
|
|
|
|
export async function exportWorkspaceToZip(
|
|
workspaceName: string,
|
|
workflows: WorkflowExportData[],
|
|
folders: FolderExportData[]
|
|
): Promise<Blob> {
|
|
const JSZip = await getJSZip()
|
|
const zip = new JSZip()
|
|
const foldersMap = new Map(folders.map((f) => [f.id, f]))
|
|
|
|
const metadata = {
|
|
workspace: {
|
|
name: workspaceName,
|
|
exportedAt: new Date().toISOString(),
|
|
},
|
|
folders: folders.map((f) => ({
|
|
id: f.id,
|
|
name: f.name,
|
|
parentId: f.parentId,
|
|
sortOrder: f.sortOrder,
|
|
})),
|
|
}
|
|
|
|
zip.file('_workspace.json', JSON.stringify(metadata, null, 2))
|
|
|
|
for (const workflow of workflows) {
|
|
try {
|
|
const workflowState = {
|
|
...workflow.state,
|
|
metadata: {
|
|
name: workflow.workflow.name,
|
|
description: workflow.workflow.description,
|
|
sortOrder: workflow.workflow.sortOrder,
|
|
exportedAt: new Date().toISOString(),
|
|
},
|
|
variables: workflow.variables,
|
|
}
|
|
|
|
const exportState = sanitizeForExport(workflowState)
|
|
const sanitizedName = sanitizePathSegment(workflow.workflow.name)
|
|
const filename = `${sanitizedName}-${workflow.workflow.id}.json`
|
|
|
|
const folderPath = buildFolderPath(workflow.workflow.folderId, foldersMap)
|
|
const fullPath = folderPath ? `${folderPath}/${filename}` : filename
|
|
|
|
zip.file(fullPath, JSON.stringify(exportState, null, 2))
|
|
} catch (error) {
|
|
logger.error(`Failed to export workflow ${workflow.workflow.id}:`, error)
|
|
}
|
|
}
|
|
|
|
return await zip.generateAsync({ type: 'blob' })
|
|
}
|
|
|
|
/**
|
|
* Export a folder and its contents to a ZIP file.
|
|
* Preserves nested folder structure with paths relative to the exported folder.
|
|
*
|
|
* @param folderName - Name of the folder being exported
|
|
* @param workflows - Workflows to export (should be filtered to only those in the folder subtree)
|
|
* @param folders - Subfolders within the exported folder (parentId should be null for direct children)
|
|
*/
|
|
export async function exportFolderToZip(
|
|
folderName: string,
|
|
workflows: WorkflowExportData[],
|
|
folders: FolderExportData[]
|
|
): Promise<Blob> {
|
|
const JSZip = await getJSZip()
|
|
const zip = new JSZip()
|
|
const foldersMap = new Map(folders.map((f) => [f.id, f]))
|
|
|
|
const metadata = {
|
|
folder: {
|
|
name: folderName,
|
|
exportedAt: new Date().toISOString(),
|
|
},
|
|
folders: folders.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })),
|
|
}
|
|
|
|
zip.file('_folder.json', JSON.stringify(metadata, null, 2))
|
|
|
|
for (const workflow of workflows) {
|
|
try {
|
|
const workflowState = {
|
|
...workflow.state,
|
|
metadata: {
|
|
name: workflow.workflow.name,
|
|
description: workflow.workflow.description,
|
|
exportedAt: new Date().toISOString(),
|
|
},
|
|
variables: workflow.variables,
|
|
}
|
|
|
|
const exportState = sanitizeForExport(workflowState)
|
|
const sanitizedName = sanitizePathSegment(workflow.workflow.name)
|
|
const filename = `${sanitizedName}-${workflow.workflow.id}.json`
|
|
|
|
const folderPath = buildFolderPath(workflow.workflow.folderId, foldersMap)
|
|
const fullPath = folderPath ? `${folderPath}/${filename}` : filename
|
|
|
|
zip.file(fullPath, JSON.stringify(exportState, null, 2))
|
|
} catch (error) {
|
|
logger.error(`Failed to export workflow ${workflow.workflow.id}:`, error)
|
|
}
|
|
}
|
|
|
|
return await zip.generateAsync({ type: 'blob' })
|
|
}
|
|
|
|
export interface ImportedWorkflow {
|
|
content: string
|
|
name: string
|
|
folderPath: string[]
|
|
sortOrder?: number
|
|
}
|
|
|
|
export interface WorkspaceImportMetadata {
|
|
workspaceName: string
|
|
exportedAt?: string
|
|
folders?: Array<{
|
|
id: string
|
|
name: string
|
|
parentId: string | null
|
|
sortOrder?: number
|
|
}>
|
|
}
|
|
|
|
function extractSortOrder(content: string): number | undefined {
|
|
try {
|
|
const parsed = unwrapWorkflowExportEnvelope(JSON.parse(content)) as Record<string, any>
|
|
return parsed.state?.metadata?.sortOrder ?? parsed.metadata?.sortOrder
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
export async function extractWorkflowsFromZip(
|
|
zipFile: File
|
|
): Promise<{ workflows: ImportedWorkflow[]; metadata?: WorkspaceImportMetadata }> {
|
|
const JSZip = await getJSZip()
|
|
const zip = await JSZip.loadAsync(await zipFile.arrayBuffer())
|
|
const workflows: ImportedWorkflow[] = []
|
|
let metadata: WorkspaceImportMetadata | undefined
|
|
|
|
for (const [path, file] of Object.entries(zip.files)) {
|
|
if (file.dir) continue
|
|
|
|
if (path === '_workspace.json') {
|
|
try {
|
|
const content = await file.async('string')
|
|
const parsed = JSON.parse(content)
|
|
metadata = {
|
|
workspaceName: parsed.workspace?.name || 'Imported Workspace',
|
|
exportedAt: parsed.workspace?.exportedAt,
|
|
folders: parsed.folders,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to parse workspace metadata:', error)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (!path.toLowerCase().endsWith('.json')) continue
|
|
|
|
try {
|
|
const content = await file.async('string')
|
|
const pathParts = path.split('/').filter((p) => p.length > 0)
|
|
const filename = pathParts.pop() || path
|
|
|
|
workflows.push({
|
|
content,
|
|
name: filename,
|
|
folderPath: pathParts,
|
|
sortOrder: extractSortOrder(content),
|
|
})
|
|
} catch (error) {
|
|
logger.error(`Failed to extract ${path}:`, error)
|
|
}
|
|
}
|
|
|
|
return { workflows, metadata }
|
|
}
|
|
|
|
export async function extractWorkflowsFromFiles(files: File[]): Promise<ImportedWorkflow[]> {
|
|
const workflows: ImportedWorkflow[] = []
|
|
|
|
for (const file of files) {
|
|
if (!file.name.toLowerCase().endsWith('.json')) continue
|
|
|
|
try {
|
|
const content = await file.text()
|
|
|
|
workflows.push({
|
|
content,
|
|
name: file.name,
|
|
folderPath: [],
|
|
sortOrder: extractSortOrder(content),
|
|
})
|
|
} catch (error) {
|
|
logger.error(`Failed to read ${file.name}:`, error)
|
|
}
|
|
}
|
|
|
|
return workflows
|
|
}
|
|
|
|
export function extractWorkflowName(content: string, filename: string): string {
|
|
try {
|
|
const parsed = unwrapWorkflowExportEnvelope(JSON.parse(content)) as Record<string, any>
|
|
|
|
if (parsed.state?.metadata?.name && typeof parsed.state.metadata.name === 'string') {
|
|
return parsed.state.metadata.name.trim()
|
|
}
|
|
|
|
if (parsed.workflow?.name && typeof parsed.workflow.name === 'string') {
|
|
return parsed.workflow.name.trim()
|
|
}
|
|
} catch {
|
|
// JSON parse failed, fall through to filename
|
|
}
|
|
|
|
let name = filename.replace(/\.json$/i, '')
|
|
|
|
name = name.replace(/-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, '')
|
|
|
|
name = name
|
|
.replace(/[-_]/g, ' ')
|
|
.split(' ')
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
.join(' ')
|
|
|
|
return name.trim() || 'Imported Workflow'
|
|
}
|
|
|
|
/**
|
|
* Normalize subblock values by converting empty strings to null and repairing invalid subblocks.
|
|
* This provides backwards compatibility for workflows exported before the null sanitization fix,
|
|
* preventing Zod validation errors like "Expected array, received string".
|
|
*
|
|
* Also filters out subBlocks with the literal key "undefined", which cannot be associated
|
|
* with a stable block field.
|
|
*/
|
|
function normalizeSubblockValues(blocks: Record<string, any>): Record<string, any> {
|
|
const { blocks: migratedBlocks } = migrateSubblockIds(blocks)
|
|
const normalizedBlocks: Record<string, any> = {}
|
|
|
|
Object.entries(migratedBlocks).forEach(([blockId, block]) => {
|
|
const normalizedBlock = { ...block }
|
|
|
|
if (block.subBlocks) {
|
|
const { subBlocks: normalizedSubBlocks } = sanitizeMalformedSubBlocks(
|
|
{
|
|
id: typeof block.id === 'string' ? block.id : blockId,
|
|
type: typeof block.type === 'string' ? block.type : '',
|
|
subBlocks: block.subBlocks,
|
|
},
|
|
{ convertEmptyStringToNull: true }
|
|
)
|
|
normalizedBlock.subBlocks = normalizedSubBlocks
|
|
}
|
|
|
|
normalizedBlocks[blockId] = normalizedBlock
|
|
})
|
|
|
|
return normalizedBlocks
|
|
}
|
|
|
|
/**
|
|
* Parse and validate workflow JSON content for import.
|
|
* Handles both new format (with version/exportedAt/state) and legacy format (blocks/edges at root).
|
|
*/
|
|
export function parseWorkflowJson(
|
|
jsonContent: string,
|
|
regenerateIdsFlag = true
|
|
): {
|
|
data: WorkflowState | null
|
|
errors: string[]
|
|
} {
|
|
const errors: string[] = []
|
|
|
|
try {
|
|
// Parse JSON content
|
|
let data: any
|
|
try {
|
|
data = JSON.parse(jsonContent)
|
|
} catch (parseError) {
|
|
errors.push(`Invalid JSON: ${getErrorMessage(parseError, 'Parse error')}`)
|
|
return { data: null, errors }
|
|
}
|
|
|
|
// Validate top-level structure
|
|
if (!data || typeof data !== 'object') {
|
|
errors.push('Invalid JSON: Root must be an object')
|
|
return { data: null, errors }
|
|
}
|
|
|
|
data = unwrapWorkflowExportEnvelope(data)
|
|
|
|
// Handle new export format (version/exportedAt/state) or old format (blocks/edges at root)
|
|
let workflowData: any
|
|
if (isRecordLike(data.state)) {
|
|
// Export/API envelope format with workflow state nested under `state`
|
|
logger.info('Parsing workflow JSON with version', {
|
|
version: data.version,
|
|
exportedAt: data.exportedAt,
|
|
})
|
|
workflowData = data.state
|
|
} else {
|
|
// Old format - blocks/edges at root level
|
|
logger.info('Parsing legacy workflow JSON format')
|
|
workflowData = data
|
|
}
|
|
|
|
// Validate required fields
|
|
if (!workflowData.blocks || typeof workflowData.blocks !== 'object') {
|
|
errors.push('Missing or invalid field: blocks')
|
|
return { data: null, errors }
|
|
}
|
|
|
|
if (!Array.isArray(workflowData.edges)) {
|
|
errors.push('Missing or invalid field: edges (must be an array)')
|
|
return { data: null, errors }
|
|
}
|
|
|
|
// Validate blocks have required fields
|
|
Object.entries(workflowData.blocks).forEach(([blockId, block]: [string, any]) => {
|
|
if (!block || typeof block !== 'object') {
|
|
errors.push(`Invalid block ${blockId}: must be an object`)
|
|
return
|
|
}
|
|
|
|
if (!block.id) {
|
|
errors.push(`Block ${blockId} missing required field: id`)
|
|
}
|
|
if (!block.type) {
|
|
errors.push(`Block ${blockId} missing required field: type`)
|
|
}
|
|
if (
|
|
!block.position ||
|
|
typeof block.position.x !== 'number' ||
|
|
typeof block.position.y !== 'number'
|
|
) {
|
|
errors.push(`Block ${blockId} missing or invalid position`)
|
|
}
|
|
})
|
|
|
|
// Validate edges have required fields
|
|
workflowData.edges.forEach((edge: any, index: number) => {
|
|
if (!edge || typeof edge !== 'object') {
|
|
errors.push(`Invalid edge at index ${index}: must be an object`)
|
|
return
|
|
}
|
|
|
|
if (!edge.id) {
|
|
errors.push(`Edge at index ${index} missing required field: id`)
|
|
}
|
|
if (!edge.source) {
|
|
errors.push(`Edge at index ${index} missing required field: source`)
|
|
}
|
|
if (!edge.target) {
|
|
errors.push(`Edge at index ${index} missing required field: target`)
|
|
}
|
|
})
|
|
|
|
// If there are errors, return null
|
|
if (errors.length > 0) {
|
|
return { data: null, errors }
|
|
}
|
|
|
|
// Normalize non-string subblock values (convert empty strings to null)
|
|
// This handles exported workflows that may have empty strings for non-string types
|
|
const normalizedBlocks = normalizeSubblockValues(workflowData.blocks || {})
|
|
|
|
// Construct the workflow state with defaults
|
|
let workflowState: WorkflowState = {
|
|
blocks: normalizedBlocks,
|
|
edges: workflowData.edges || [],
|
|
loops: workflowData.loops || {},
|
|
parallels: workflowData.parallels || {},
|
|
metadata: workflowData.metadata,
|
|
variables:
|
|
workflowData.variables && typeof workflowData.variables === 'object'
|
|
? workflowData.variables
|
|
: undefined,
|
|
}
|
|
|
|
if (regenerateIdsFlag) {
|
|
const { idMap: _, ...regeneratedState } = regenerateWorkflowIds(workflowState, {
|
|
clearTriggerRuntimeValues: true,
|
|
})
|
|
workflowState = {
|
|
...regeneratedState,
|
|
metadata: workflowState.metadata,
|
|
variables: workflowState.variables,
|
|
}
|
|
logger.info('Regenerated IDs for imported workflow to avoid conflicts')
|
|
}
|
|
|
|
logger.info('Successfully parsed workflow JSON', {
|
|
blocksCount: Object.keys(workflowState.blocks).length,
|
|
edgesCount: workflowState.edges.length,
|
|
loopsCount: Object.keys(workflowState.loops).length,
|
|
parallelsCount: Object.keys(workflowState.parallels).length,
|
|
})
|
|
|
|
return { data: workflowState, errors: [] }
|
|
} catch (error) {
|
|
logger.error('Failed to parse workflow JSON:', error)
|
|
errors.push(`Unexpected error: ${getErrorMessage(error, 'Unknown error')}`)
|
|
return { data: null, errors }
|
|
}
|
|
}
|
|
|
|
interface CreateImportedWorkflowInput {
|
|
name: string
|
|
description: string
|
|
workspaceId: string
|
|
folderId?: string
|
|
sortOrder?: number
|
|
}
|
|
|
|
interface CreatedImportedWorkflow {
|
|
id: string
|
|
}
|
|
|
|
interface PersistImportedWorkflowOptions {
|
|
content: string
|
|
filename: string
|
|
workspaceId: string
|
|
folderId?: string
|
|
sortOrder?: number
|
|
nameOverride?: string
|
|
descriptionOverride?: string
|
|
createWorkflow: (input: CreateImportedWorkflowInput) => Promise<CreatedImportedWorkflow>
|
|
}
|
|
|
|
export async function persistImportedWorkflow({
|
|
content,
|
|
filename,
|
|
workspaceId,
|
|
folderId,
|
|
sortOrder,
|
|
nameOverride,
|
|
descriptionOverride,
|
|
createWorkflow,
|
|
}: PersistImportedWorkflowOptions): Promise<{ workflowId: string; workflowName: string } | null> {
|
|
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(content)
|
|
|
|
if (!workflowData || parseErrors.length > 0) {
|
|
logger.warn(`Failed to parse imported workflow ${filename}:`, parseErrors)
|
|
return null
|
|
}
|
|
|
|
const workflowName = nameOverride || extractWorkflowName(content, filename)
|
|
|
|
const createdWorkflow = await createWorkflow({
|
|
name: workflowName,
|
|
description: descriptionOverride || workflowData.metadata?.description || 'Imported from JSON',
|
|
workspaceId,
|
|
folderId,
|
|
sortOrder,
|
|
})
|
|
|
|
const newWorkflowId = createdWorkflow.id
|
|
|
|
type ContractEdgeInput = WorkflowStateContractInput['edges'][number]
|
|
|
|
// Imported workflow JSON may carry nullable sourceHandle / targetHandle
|
|
// values from older exports. The contract input schema rejects nulls
|
|
// (it expects `string | undefined`), so drop nullish handles before
|
|
// sending. Pre-validation by `parseWorkflowJson` already ensures the
|
|
// shape is otherwise contract-compatible.
|
|
const sanitizedEdges: ContractEdgeInput[] = (workflowData.edges || []).map((edge) => {
|
|
const { sourceHandle, targetHandle, ...rest } = edge
|
|
const sanitized: ContractEdgeInput = { ...rest } as ContractEdgeInput
|
|
if (typeof sourceHandle === 'string' && sourceHandle.length > 0) {
|
|
sanitized.sourceHandle = sourceHandle
|
|
}
|
|
if (typeof targetHandle === 'string' && targetHandle.length > 0) {
|
|
sanitized.targetHandle = targetHandle
|
|
}
|
|
return sanitized
|
|
})
|
|
|
|
const stateBody: WorkflowStateContractInput = {
|
|
...workflowData,
|
|
loops: workflowData.loops || {},
|
|
parallels: workflowData.parallels || {},
|
|
edges: sanitizedEdges,
|
|
}
|
|
|
|
try {
|
|
await requestJson(putWorkflowNormalizedStateContract, {
|
|
params: { id: newWorkflowId },
|
|
body: stateBody,
|
|
})
|
|
} catch {
|
|
throw new Error(`Failed to save workflow state for ${newWorkflowId}`)
|
|
}
|
|
|
|
if (workflowData.variables) {
|
|
const variablesArray = Array.isArray(workflowData.variables)
|
|
? workflowData.variables
|
|
: Object.values(workflowData.variables)
|
|
|
|
if (variablesArray.length > 0) {
|
|
type WorkflowVariablesBodyInput = NonNullable<
|
|
Parameters<typeof requestJson<typeof workflowVariablesContract>>[1]['body']
|
|
>
|
|
const variablesRecord: WorkflowVariablesBodyInput['variables'] = {}
|
|
|
|
for (const variable of variablesArray) {
|
|
const id =
|
|
typeof variable.id === 'string' && variable.id.trim() ? variable.id : generateId()
|
|
|
|
variablesRecord[id] = {
|
|
id,
|
|
name: variable.name,
|
|
type: variable.type,
|
|
value: variable.value,
|
|
}
|
|
}
|
|
|
|
try {
|
|
await requestJson(workflowVariablesContract, {
|
|
params: { id: newWorkflowId },
|
|
body: { variables: variablesRecord },
|
|
})
|
|
} catch {
|
|
throw new Error(`Failed to save variables for ${newWorkflowId}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`Imported workflow: ${workflowName}`)
|
|
return { workflowId: newWorkflowId, workflowName }
|
|
}
|
|
|
|
export interface GenerateWorkflowJsonOptions {
|
|
workflowId: string
|
|
name?: string
|
|
description?: string
|
|
variables?: Array<{
|
|
id: string
|
|
name: string
|
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
|
|
value: any
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* Generate JSON export for a workflow.
|
|
* This is a pure function that takes the workflow state and metadata and returns the JSON string.
|
|
*/
|
|
export function generateWorkflowJson(
|
|
workflowState: WorkflowState,
|
|
options: GenerateWorkflowJsonOptions
|
|
): string {
|
|
const variablesRecord: Record<string, Variable> | undefined = options.variables?.reduce(
|
|
(acc, v) => {
|
|
acc[v.id] = {
|
|
id: v.id,
|
|
name: v.name,
|
|
type: v.type,
|
|
value: v.value,
|
|
}
|
|
return acc
|
|
},
|
|
{} as Record<string, Variable>
|
|
)
|
|
|
|
const stateWithMetadata: WorkflowState = {
|
|
...workflowState,
|
|
metadata: {
|
|
name: options.name,
|
|
description: options.description,
|
|
exportedAt: new Date().toISOString(),
|
|
},
|
|
variables: variablesRecord,
|
|
}
|
|
|
|
const exportState: ExportWorkflowState = sanitizeForExport(stateWithMetadata)
|
|
const jsonString = JSON.stringify(exportState, null, 2)
|
|
|
|
logger.info('Workflow JSON generated successfully', {
|
|
version: exportState.version,
|
|
exportedAt: exportState.exportedAt,
|
|
blocksCount: Object.keys(exportState.state.blocks).length,
|
|
edgesCount: exportState.state.edges.length,
|
|
jsonLength: jsonString.length,
|
|
})
|
|
|
|
return jsonString
|
|
}
|