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,162 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers/triggers'
|
||||
import { normalizeName, startsWithUuid } from '@/executor/constants'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('DeploymentUtils')
|
||||
|
||||
interface InputField {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the input format from the Start block
|
||||
* Returns an array of field definitions with name and type
|
||||
*/
|
||||
export function getStartBlockInputFormat(): InputField[] {
|
||||
try {
|
||||
const candidates = resolveStartCandidates(useWorkflowStore.getState().blocks, {
|
||||
execution: 'api',
|
||||
})
|
||||
|
||||
const targetCandidate =
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.UNIFIED) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_API) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_INPUT) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.LEGACY_STARTER)
|
||||
|
||||
const targetBlock = targetCandidate?.block
|
||||
|
||||
if (targetBlock) {
|
||||
const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat')
|
||||
if (inputFormat && Array.isArray(inputFormat)) {
|
||||
return inputFormat
|
||||
.map((field: { name?: string; type?: string }) => ({
|
||||
name: field.name || '',
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
.filter((field) => field.name)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error getting start block input format:', error)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the input format example for a workflow's API deployment
|
||||
* Returns the -d flag with example data if inputs exist, empty string otherwise
|
||||
*
|
||||
* @param includeStreaming - Whether to include streaming parameters in the example
|
||||
* @param selectedStreamingOutputs - Array of output IDs to stream
|
||||
* @returns A string containing the curl -d flag with example data, or empty string if no inputs
|
||||
*/
|
||||
export function getInputFormatExample(
|
||||
includeStreaming = false,
|
||||
selectedStreamingOutputs: string[] = []
|
||||
): string {
|
||||
let inputFormatExample = ''
|
||||
try {
|
||||
const blocks = Object.values(useWorkflowStore.getState().blocks)
|
||||
const candidates = resolveStartCandidates(useWorkflowStore.getState().blocks, {
|
||||
execution: 'api',
|
||||
})
|
||||
|
||||
const targetCandidate =
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.UNIFIED) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_API) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.SPLIT_INPUT) ||
|
||||
candidates.find((candidate) => candidate.path === StartBlockPath.LEGACY_STARTER)
|
||||
|
||||
const targetBlock = targetCandidate?.block
|
||||
|
||||
if (targetBlock) {
|
||||
const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat')
|
||||
|
||||
const exampleData: Record<string, any> = {}
|
||||
|
||||
if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
|
||||
inputFormat.forEach((field: any) => {
|
||||
if (field.name) {
|
||||
switch (field.type) {
|
||||
case 'string':
|
||||
exampleData[field.name] = 'example'
|
||||
break
|
||||
case 'number':
|
||||
exampleData[field.name] = 42
|
||||
break
|
||||
case 'boolean':
|
||||
exampleData[field.name] = true
|
||||
break
|
||||
case 'object':
|
||||
exampleData[field.name] = { key: 'value' }
|
||||
break
|
||||
case 'array':
|
||||
exampleData[field.name] = [1, 2, 3]
|
||||
break
|
||||
case 'file[]':
|
||||
exampleData[field.name] = [
|
||||
{
|
||||
data: 'data:application/pdf;base64,...',
|
||||
type: 'file',
|
||||
name: 'document.pdf',
|
||||
mime: 'application/pdf',
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (includeStreaming && selectedStreamingOutputs.length > 0) {
|
||||
exampleData.stream = true
|
||||
|
||||
const convertedOutputs = selectedStreamingOutputs
|
||||
.map((outputId) => {
|
||||
if (startsWithUuid(outputId)) {
|
||||
const underscoreIndex = outputId.indexOf('_')
|
||||
if (underscoreIndex === -1) return null
|
||||
|
||||
const blockId = outputId.substring(0, underscoreIndex)
|
||||
const attribute = outputId.substring(underscoreIndex + 1)
|
||||
|
||||
const block = blocks.find((b) => b.id === blockId)
|
||||
if (block?.name) {
|
||||
return `${normalizeName(block.name)}.${attribute}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const parts = outputId.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const blockName = parts[0]
|
||||
const block = blocks.find(
|
||||
(b) => b.name && normalizeName(b.name) === normalizeName(blockName)
|
||||
)
|
||||
if (!block) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return outputId
|
||||
})
|
||||
.filter((output): output is string => output !== null)
|
||||
|
||||
exampleData.selectedOutputs = convertedOutputs
|
||||
}
|
||||
|
||||
if (Object.keys(exampleData).length > 0) {
|
||||
inputFormatExample = ` -d '${JSON.stringify(exampleData)}'`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error generating input format example:', error)
|
||||
}
|
||||
|
||||
return inputFormatExample
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.unmock('@/blocks/registry')
|
||||
|
||||
import {
|
||||
extractWorkflowName,
|
||||
parseWorkflowJson,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
|
||||
function createLegacyState() {
|
||||
return {
|
||||
blocks: {
|
||||
'start-1': {
|
||||
id: 'start-1',
|
||||
type: 'start_trigger',
|
||||
name: 'Start',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
id: 'inputFormat',
|
||||
type: 'input-format',
|
||||
value: [],
|
||||
},
|
||||
undefined: {
|
||||
type: 'unknown',
|
||||
value: 'stale duplicate',
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
metadata: {
|
||||
name: 'Wrapped Workflow',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('workflow import/export parsing', () => {
|
||||
it('parses workflow exports wrapped in an API data envelope', () => {
|
||||
const content = JSON.stringify({
|
||||
data: {
|
||||
version: '1.0',
|
||||
exportedAt: '2026-05-07T06:45:06.892Z',
|
||||
workflow: {
|
||||
name: 'Wrapped Workflow',
|
||||
},
|
||||
state: createLegacyState(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = parseWorkflowJson(content, false)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.data?.blocks['start-1']).toBeDefined()
|
||||
expect(result.data?.blocks['start-1'].subBlocks.inputFormat).toEqual({
|
||||
id: 'inputFormat',
|
||||
type: 'input-format',
|
||||
value: [],
|
||||
})
|
||||
expect(result.data?.blocks['start-1'].subBlocks.undefined).toBeUndefined()
|
||||
})
|
||||
|
||||
it('extracts workflow names from wrapped exports', () => {
|
||||
const content = JSON.stringify({
|
||||
data: {
|
||||
workflow: {
|
||||
name: 'Wrapped Workflow',
|
||||
},
|
||||
state: createLegacyState(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(extractWorkflowName(content, 'wf.json')).toBe('Wrapped Workflow')
|
||||
})
|
||||
|
||||
it('parses API envelopes that contain state without an export version', () => {
|
||||
const content = JSON.stringify({
|
||||
data: {
|
||||
workflow: {
|
||||
name: 'API Workflow',
|
||||
},
|
||||
state: createLegacyState(),
|
||||
},
|
||||
})
|
||||
|
||||
const result = parseWorkflowJson(content, false)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.data?.blocks['start-1']).toBeDefined()
|
||||
expect(result.data?.blocks['start-1'].subBlocks.undefined).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves malformed legacy renamed subBlocks during import parsing', () => {
|
||||
const state = {
|
||||
...createLegacyState(),
|
||||
blocks: {
|
||||
knowledge: {
|
||||
id: 'knowledge',
|
||||
type: 'knowledge',
|
||||
name: 'Knowledge',
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
subBlocks: {
|
||||
operation: { id: 'operation', type: 'dropdown', value: 'search' },
|
||||
knowledgeBaseId: {
|
||||
id: 'knowledgeBaseId',
|
||||
type: 'unknown',
|
||||
value: 'kb-uuid-123',
|
||||
},
|
||||
},
|
||||
outputs: {},
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
const content = JSON.stringify({ data: { workflow: { name: 'Knowledge Workflow' }, state } })
|
||||
|
||||
const result = parseWorkflowJson(content, false)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
expect(result.data?.blocks.knowledge.subBlocks.knowledgeBaseId).toBeUndefined()
|
||||
expect(result.data?.blocks.knowledge.subBlocks.knowledgeBaseSelector).toEqual({
|
||||
id: 'knowledgeBaseSelector',
|
||||
type: 'knowledge-base-selector',
|
||||
value: 'kb-uuid-123',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizePathSegment', () => {
|
||||
it('should preserve ASCII alphanumeric characters', () => {
|
||||
expect(sanitizePathSegment('workflow-123_abc')).toBe('workflow-123_abc')
|
||||
})
|
||||
|
||||
it('should replace spaces with dashes', () => {
|
||||
expect(sanitizePathSegment('my workflow')).toBe('my-workflow')
|
||||
})
|
||||
|
||||
it('should replace special characters with dashes', () => {
|
||||
expect(sanitizePathSegment('workflow!@#')).toBe('workflow-')
|
||||
})
|
||||
|
||||
it('should preserve Korean characters (BUG REPRODUCTION)', () => {
|
||||
expect(sanitizePathSegment('한글')).toBe('한글')
|
||||
})
|
||||
|
||||
it('should preserve other Unicode characters', () => {
|
||||
expect(sanitizePathSegment('日本語')).toBe('日本語')
|
||||
})
|
||||
|
||||
it('should remove filesystem unsafe characters', () => {
|
||||
expect(sanitizePathSegment('work/flow?name*')).not.toContain('/')
|
||||
expect(sanitizePathSegment('work/flow?name*')).not.toContain('?')
|
||||
expect(sanitizePathSegment('work/flow?name*')).not.toContain('*')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,816 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
|
||||
|
||||
const logger = createLogger('WorkflowSocketOperations')
|
||||
|
||||
async function resolveUserId(): Promise<string> {
|
||||
try {
|
||||
const sessionResult = await client.getSession()
|
||||
const userId = sessionResult.data?.user?.id
|
||||
if (userId) {
|
||||
return userId
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve session user id for workflow operation', { error })
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
interface EnqueueWorkflowOperationArgs {
|
||||
operation: string
|
||||
target: string
|
||||
payload: any
|
||||
workflowId: string
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a workflow socket operation so it flows through the standard operation queue,
|
||||
* ensuring consistent retries, confirmations, and telemetry.
|
||||
*/
|
||||
async function enqueueWorkflowOperation({
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
workflowId,
|
||||
operationId,
|
||||
}: EnqueueWorkflowOperationArgs): Promise<string> {
|
||||
const userId = await resolveUserId()
|
||||
const opId = operationId ?? generateId()
|
||||
|
||||
useOperationQueueStore.getState().addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation,
|
||||
target,
|
||||
payload,
|
||||
},
|
||||
workflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
logger.debug('Queued workflow operation', {
|
||||
workflowId,
|
||||
operation,
|
||||
target,
|
||||
operationId: opId,
|
||||
})
|
||||
|
||||
return opId
|
||||
}
|
||||
|
||||
interface EnqueueReplaceStateArgs {
|
||||
workflowId: string
|
||||
state: WorkflowState
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for broadcasting a full workflow state replacement via the queue.
|
||||
*/
|
||||
export async function enqueueReplaceWorkflowState({
|
||||
workflowId,
|
||||
state,
|
||||
operationId,
|
||||
}: EnqueueReplaceStateArgs): Promise<string> {
|
||||
const { state: validatedState, warnings } = normalizeWorkflowState(state)
|
||||
|
||||
if (warnings.length > 0) {
|
||||
logger.warn('Normalized state before enqueuing replace-state', {
|
||||
workflowId,
|
||||
warningCount: warnings.length,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
return enqueueWorkflowOperation({
|
||||
workflowId,
|
||||
operation: 'replace-state',
|
||||
target: 'workflow',
|
||||
payload: { state: validatedState },
|
||||
operationId,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user