diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 59261e1001d..205db37466c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -39,9 +39,17 @@ interface FileUploadProps { isPreview?: boolean previewValue?: any | null disabled?: boolean + /** + * Controlled value. When `onValueChange` is provided the component reads from + * this prop and writes through `onValueChange` instead of the subblock store, + * letting it be embedded where the value lives outside a subblock (e.g. a + * single field inside the input-format editor). + */ + value?: UploadedFile | UploadedFile[] | null + onValueChange?: (value: UploadedFile | UploadedFile[] | null) => void } -interface UploadedFile { +export interface UploadedFile { name: string path: string key?: string @@ -165,9 +173,25 @@ export function FileUpload({ isPreview = false, previewValue, disabled = false, + value: controlledValue, + onValueChange, }: FileUploadProps) { const activeSearchTarget = useActiveSearchTarget() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const isControlled = onValueChange !== undefined + + /** + * Persists a new value. In controlled mode the caller owns persistence; in + * store mode we write through the subblock store and notify collaborators. + */ + const commitValue = (next: UploadedFile | UploadedFile[] | null) => { + if (isControlled) { + onValueChange(next) + return + } + setStoreValue(next) + useWorkflowStore.getState().triggerUpdate() + } const [modelValue] = useSubBlockValue(blockId, 'model') const [uploadingFiles, setUploadingFiles] = useState([]) const [uploadProgress, setUploadProgress] = useState(0) @@ -191,7 +215,7 @@ export function FileUpload({ const uploadFileMutation = useUploadWorkspaceFile() const queryClient = useQueryClient() - const value = isPreview ? previewValue : storeValue + const value = isControlled ? controlledValue : isPreview ? previewValue : storeValue const maxSizeInBytes = useMemo(() => { const fallback = maxSize * 1024 * 1024 @@ -413,11 +437,9 @@ export function FileUpload({ const newFiles = Array.from(uniqueFiles.values()) - setStoreValue(newFiles) - useWorkflowStore.getState().triggerUpdate() + commitValue(newFiles) } else { - setStoreValue(uploadedFiles[0] || null) - useWorkflowStore.getState().triggerUpdate() + commitValue(uploadedFiles[0] || null) } } catch (error) { logger.error(getErrorMessage(error, 'Failed to upload file(s)'), activeWorkflowId) @@ -459,12 +481,11 @@ export function FileUpload({ uniqueFiles.set(uploadedFile.path, uploadedFile) const newFiles = Array.from(uniqueFiles.values()) - setStoreValue(newFiles) + commitValue(newFiles) } else { - setStoreValue(uploadedFile) + commitValue(uploadedFile) } - useWorkflowStore.getState().triggerUpdate() logger.info(`Selected workspace file: ${selectedFile.name}`, activeWorkflowId) } @@ -501,12 +522,10 @@ export function FileUpload({ if (multiple) { const filesArray = Array.isArray(value) ? value : value ? [value] : [] const updatedFiles = filesArray.filter((f) => f.path !== file.path) - setStoreValue(updatedFiles.length > 0 ? updatedFiles : null) + commitValue(updatedFiles.length > 0 ? updatedFiles : null) } else { - setStoreValue(null) + commitValue(null) } - - useWorkflowStore.getState().triggerUpdate() } catch (error) { logger.error(getErrorMessage(error, 'Failed to remove file'), activeWorkflowId) } finally { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.test.ts new file mode 100644 index 00000000000..1f6aae754aa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import type { InputFormatFile } from '@/lib/workflows/input-format' +import { + controlValueToFiles, + defaultFileFieldMode, + filesToControlValue, + serializeInputFormatFiles, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files' + +const file: InputFormatFile = { + id: 'f1', + name: 'doc.pdf', + url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace', + key: 'key', + size: 10, + type: 'application/pdf', +} + +describe('filesToControlValue', () => { + it.concurrent('maps url -> path for the FileUpload value shape', () => { + expect(filesToControlValue([file])).toEqual([ + { + name: file.name, + path: file.url, + key: file.key, + size: file.size, + type: file.type, + }, + ]) + }) + + it.concurrent('round-trips through controlValueToFiles without data loss', () => { + expect(controlValueToFiles(filesToControlValue([file]), [file])).toEqual([file]) + }) +}) + +describe('controlValueToFiles', () => { + it.concurrent('preserves the stable id of an existing file (matched by key)', () => { + const control = [ + { name: 'doc.pdf', path: '/moved', key: 'key', size: 10, type: 'application/pdf' }, + ] + expect(controlValueToFiles(control, [file])[0].id).toBe('f1') + }) + + it.concurrent('matches an existing file by url when key is absent', () => { + const control = [{ name: 'doc.pdf', path: file.url, size: 10, type: 'application/pdf' }] + expect(controlValueToFiles(control, [file])[0].id).toBe('f1') + }) + + it.concurrent('generates an id for a newly added file', () => { + const control = [ + { + name: 'new.pdf', + path: '/api/files/serve/new', + key: 'new', + size: 5, + type: 'application/pdf', + }, + ] + const result = controlValueToFiles(control, [file]) + expect(result[0].id).toEqual(expect.any(String)) + expect(result[0].id).not.toBe('f1') + expect(result[0].url).toBe('/api/files/serve/new') + }) + + it.concurrent('normalizes a single object or null to an array', () => { + const single = { + name: file.name, + path: file.url, + key: file.key, + size: file.size, + type: file.type, + } + expect(controlValueToFiles(single, [file])).toEqual([file]) + expect(controlValueToFiles(null, [file])).toEqual([]) + }) +}) + +describe('serializeInputFormatFiles', () => { + it.concurrent('serializes to JSON that parses back to the same files', () => { + expect(JSON.parse(serializeInputFormatFiles([file]))).toEqual([file]) + }) + + it.concurrent('returns an empty string for no files', () => { + expect(serializeInputFormatFiles([])).toBe('') + }) +}) + +describe('defaultFileFieldMode', () => { + it.concurrent('defaults to upload for empty or whitespace values', () => { + expect(defaultFileFieldMode(undefined)).toBe('upload') + expect(defaultFileFieldMode('')).toBe('upload') + expect(defaultFileFieldMode(' ')).toBe('upload') + }) + + it.concurrent('uses upload for an empty array or run-ready files', () => { + expect(defaultFileFieldMode('[]')).toBe('upload') + expect(defaultFileFieldMode(JSON.stringify([file]))).toBe('upload') + }) + + it.concurrent('falls back to json for legacy free-form values (no data loss)', () => { + expect(defaultFileFieldMode('C:/Users/x/budget.xlsx')).toBe('json') + expect(defaultFileFieldMode('[{"data":"","name":"x.pdf"}]')).toBe('json') + expect(defaultFileFieldMode('{"csv":"a,b,c"}')).toBe('json') + }) + + it.concurrent('uses json when only some entries are run-ready (no silent drop)', () => { + expect(defaultFileFieldMode(JSON.stringify([file, { name: 'legacy-only' }]))).toBe('json') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.ts new file mode 100644 index 00000000000..ddb805bc380 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.ts @@ -0,0 +1,75 @@ +import { generateId } from '@sim/utils/id' +import { type InputFormatFile, parseInputFormatFiles } from '@/lib/workflows/input-format' +import type { UploadedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload' + +/** + * Pure adapters bridging a file-typed input-format field's stored value (a JSON + * string of run-ready {@link InputFormatFile} objects) and the {@link FileUpload} + * component's value shape (which keys off `path`). Kept separate from the React + * component so they can be unit-tested without a DOM. + */ + +/** + * Maps stored run-ready file objects to the {@link FileUpload} value shape. + */ +export function filesToControlValue(files: InputFormatFile[]): UploadedFile[] { + return files.map((file) => ({ + name: file.name, + path: file.url, + key: file.key, + size: file.size, + type: file.type, + })) +} + +/** + * Maps a {@link FileUpload} value back to stored run-ready file objects, + * preserving the stable `id` of files that were already present. + */ +export function controlValueToFiles( + value: UploadedFile | UploadedFile[] | null, + previous: InputFormatFile[] +): InputFormatFile[] { + const uploaded = Array.isArray(value) ? value : value ? [value] : [] + return uploaded.map((file) => { + const existing = previous.find( + (prev) => (file.key && prev.key === file.key) || prev.url === file.path + ) + return { + id: existing?.id ?? generateId(), + name: file.name, + url: file.path, + key: file.key, + size: file.size, + type: file.type, + } + }) +} + +/** + * Serializes run-ready file objects into a field value string (empty when none). + */ +export function serializeInputFormatFiles(files: InputFormatFile[]): string { + return files.length > 0 ? JSON.stringify(files, null, 2) : '' +} + +/** + * Default editor mode for a file field: the uploader, unless the stored value is + * legacy free-form content (raw text or a non-file array) that only the JSON + * editor can represent without data loss. + */ +export function defaultFileFieldMode(value: string | undefined): 'upload' | 'json' { + if (!value || !value.trim()) return 'upload' + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return 'json' + } + if (!Array.isArray(parsed)) return 'json' + if (parsed.length === 0) return 'upload' + // Only use the uploader when EVERY entry is run-ready; if any entry is legacy + // or partial, stay in JSON mode so the uploader can't drop the entries it + // cannot represent when the field is next saved. + return parseInputFormatFiles(parsed).length === parsed.length ? 'upload' : 'json' +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 82f3e157781..06b0b586ba8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import { Badge, Button, @@ -19,8 +19,19 @@ import { import { Trash } from '@sim/emcn/icons' import { Plus } from 'lucide-react' import Editor from 'react-simple-code-editor' -import { createDefaultInputFormatField } from '@/lib/workflows/input-format' +import { + createDefaultInputFormatField, + isFileFieldType, + parseInputFormatFiles, +} from '@/lib/workflows/input-format' +import { FileUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' +import { + controlValueToFiles, + defaultFileFieldMode, + filesToControlValue, + serializeInputFormatFiles, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files' import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' @@ -101,6 +112,7 @@ export function FieldFormat({ const overlayRefs = useRef>({}) const nameOverlayRefs = useRef>({}) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + const [fileFieldModes, setFileFieldModes] = useState>({}) const inputController = useSubBlockInput({ blockId, @@ -480,12 +492,60 @@ export function FieldFormat({ ) } - if (field.type === 'file[]') { + if (isFileFieldType(field.type)) { + // The uploader is only offered when it can represent the stored value + // losslessly (empty or all run-ready). For mixed/legacy values it would + // drop the entries it can't show on save, so we force JSON mode and hide + // the toggle until the value is cleared or made fully run-ready. + const canUseUploader = defaultFileFieldMode(field.value) === 'upload' + const mode = canUseUploader ? (fileFieldModes[field.id] ?? 'upload') : 'json' + + const modeToggle = canUseUploader ? ( +
+ +
+ ) : null + + if (mode === 'upload') { + const currentFiles = parseInputFormatFiles(field.value) + return ( +
+ {modeToggle} + + updateField( + field.id, + 'value', + serializeInputFormatFiles(controlValueToFiles(next, currentFiles)) + ) + } + /> +
+ ) + } + const lineCount = fieldValue.split('\n').length const gutterWidth = calculateGutterWidth(lineCount) - - const renderLineNumbers = () => { - return Array.from({ length: lineCount }, (_, i) => ( + const renderLineNumbers = () => + Array.from({ length: lineCount }, (_, i) => (
)) - } return ( - - {renderLineNumbers()} - - - { - '[\n {\n "data": "",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]' - } - - - - +
+ {modeToggle} + + {renderLineNumbers()} + + + { + '[\n {\n "data": "",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]' + } + + + + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 49069eac243..4346acc4650 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -13,6 +13,7 @@ import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload' import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events' +import { collectInputFormatFiles, isFileFieldType } from '@/lib/workflows/input-format' import { extractTriggerMockPayload, selectBestTrigger, @@ -138,6 +139,41 @@ function normalizeErrorMessage(error: unknown): string { return WORKFLOW_EXECUTION_FAILURE_MESSAGE } +/** + * Builds the manual-run workflow input from a trigger's inputFormat subblock. + * Named fields are coerced by type; file-typed fields are excluded as named + * inputs and routed to the dedicated `files` channel (already uploaded, so they + * pass straight to the executor's normalizeStartFile). Returns undefined when + * the format yields nothing. Shared by every manual entry path so they stay + * consistent. + */ +function buildInputFormatInput(inputFormatValue: unknown): Record | undefined { + if (!Array.isArray(inputFormatValue)) return undefined + + const testInput: Record = {} + for (const field of inputFormatValue) { + if ( + field && + typeof field === 'object' && + field.name && + field.value !== undefined && + !isFileFieldType(field.type) + ) { + testInput[field.name] = coerceValue(field.type, field.value) + } + } + + // Route file[] fields to the dedicated `files` channel. `files` is the start + // block's canonical file channel (the chat trigger names its own file field + // `files`), so uploaded files must own it and take precedence over a plain + // field that happens to be named `files` — dropping real attachments would be + // the worse outcome. + const files = collectInputFormatFiles(inputFormatValue) + if (files.length > 0) testInput.files = files + + return Object.keys(testInput).length > 0 ? testInput : undefined +} + export function useWorkflowExecution() { const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>() const hydrationWorkspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId) @@ -948,21 +984,6 @@ export function useWorkflowExecution() { selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId) } - // Helper to extract test values from inputFormat subblock - const extractTestValuesFromInputFormat = (inputFormatValue: any): Record => { - const testInput: Record = {} - - if (Array.isArray(inputFormatValue)) { - inputFormatValue.forEach((field: any) => { - if (field && typeof field === 'object' && field.name && field.value !== undefined) { - testInput[field.name] = coerceValue(field.type, field.value) - } - }) - } - - return testInput - } - // Determine start block and workflow input based on execution type let startBlockId: string | undefined let finalWorkflowInput = workflowInput @@ -1052,10 +1073,9 @@ export function useWorkflowExecution() { selectedCandidate.path === StartBlockPath.SPLIT_INPUT || selectedCandidate.path === StartBlockPath.UNIFIED ) { - const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value - const testInput = extractTestValuesFromInputFormat(inputFormatValue) - if (Object.keys(testInput).length > 0) { - finalWorkflowInput = testInput + const builtInput = buildInputFormatInput(selectedTrigger.subBlocks?.inputFormat?.value) + if (builtInput) { + finalWorkflowInput = builtInput } } } @@ -1807,17 +1827,9 @@ export function useWorkflowExecution() { candidate.path === StartBlockPath.SPLIT_INPUT || candidate.path === StartBlockPath.UNIFIED ) { - const inputFormatValue = candidate.block.subBlocks?.inputFormat?.value - if (Array.isArray(inputFormatValue)) { - const testInput: Record = {} - inputFormatValue.forEach((field: any) => { - if (field && typeof field === 'object' && field.name && field.value !== undefined) { - testInput[field.name] = coerceValue(field.type, field.value) - } - }) - if (Object.keys(testInput).length > 0) { - workflowInput = testInput - } + const builtInput = buildInputFormatInput(candidate.block.subBlocks?.inputFormat?.value) + if (builtInput) { + workflowInput = builtInput } } } else { diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index 886e9aac5ab..d948e5f5bdc 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' import { + collectInputFormatFiles, createDefaultInputFormatField, extractInputFieldsFromBlocks, + isFileFieldType, normalizeInputFormatValue, + parseInputFormatFiles, } from '@/lib/workflows/input-format' describe('extractInputFieldsFromBlocks', () => { @@ -229,6 +232,136 @@ describe('normalizeInputFormatValue', () => { }) }) +describe('isFileFieldType', () => { + it.concurrent('matches the canonical file[] type', () => { + expect(isFileFieldType('file[]')).toBe(true) + }) + + it.concurrent('does not match legacy variants or other types (no behavior change)', () => { + expect(isFileFieldType('files')).toBe(false) + expect(isFileFieldType('file')).toBe(false) + expect(isFileFieldType('image')).toBe(false) + expect(isFileFieldType('string')).toBe(false) + expect(isFileFieldType('array')).toBe(false) + expect(isFileFieldType(undefined)).toBe(false) + expect(isFileFieldType(null)).toBe(false) + }) +}) + +describe('parseInputFormatFiles', () => { + const file = { + id: 'f1', + name: 'doc.pdf', + url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace', + key: 'key', + size: 10, + type: 'application/pdf', + } + + it.concurrent('parses a JSON string of run-ready files', () => { + expect(parseInputFormatFiles(JSON.stringify([file]))).toEqual([file]) + }) + + it.concurrent('accepts an already-materialized array', () => { + expect(parseInputFormatFiles([file])).toEqual([file]) + }) + + it.concurrent('returns empty for blank, invalid, or non-array values', () => { + expect(parseInputFormatFiles('')).toEqual([]) + expect(parseInputFormatFiles(' ')).toEqual([]) + expect(parseInputFormatFiles(undefined)).toEqual([]) + expect(parseInputFormatFiles('not json')).toEqual([]) + expect(parseInputFormatFiles('{"name":"x"}')).toEqual([]) + }) + + it.concurrent('drops legacy entries missing id/url (base64 placeholder, raw text)', () => { + expect( + parseInputFormatFiles( + JSON.stringify([{ data: '', type: 'file', name: 'document.pdf', mime: 'x' }]) + ) + ).toEqual([]) + expect(parseInputFormatFiles(JSON.stringify([{ name: 'doc.pdf', path: '/legacy' }]))).toEqual( + [] + ) + }) + + it.concurrent('keeps only the valid files in a mixed array', () => { + expect(parseInputFormatFiles(JSON.stringify([file, { name: 'bad' }]))).toEqual([file]) + }) + + it.concurrent('rejects partial files missing the run-ready size/type', () => { + expect(parseInputFormatFiles(JSON.stringify([{ id: 'x', name: 'a.pdf', url: '/u' }]))).toEqual( + [] + ) + expect( + parseInputFormatFiles( + JSON.stringify([{ id: 'x', name: 'a.pdf', url: '/u', size: Number.NaN, type: 'x' }]) + ) + ).toEqual([]) + }) + + it.concurrent('rejects files with no key and a non-internal url (key unrecoverable)', () => { + const { key, ...noKey } = file + const external = { ...noKey, url: 'https://example.com/x.pdf' } + expect(parseInputFormatFiles(JSON.stringify([external]))).toEqual([]) + expect(parseInputFormatFiles(JSON.stringify([{ ...external, key: '' }]))).toEqual([]) + }) + + it.concurrent('accepts a key-less file when the url is an internal serve url', () => { + const { key, ...noKey } = file + expect(parseInputFormatFiles(JSON.stringify([noKey]))).toEqual([noKey]) + }) + + it.concurrent('rejects a key-less file whose internal url yields no key', () => { + const { key, ...noKey } = file + expect(parseInputFormatFiles(JSON.stringify([{ ...noKey, url: '/api/files/serve/' }]))).toEqual( + [] + ) + }) + + it.concurrent('rejects empty-string id/name/url/type (executor treats them as falsy)', () => { + expect(parseInputFormatFiles(JSON.stringify([{ ...file, id: '' }]))).toEqual([]) + expect(parseInputFormatFiles(JSON.stringify([{ ...file, name: '' }]))).toEqual([]) + expect(parseInputFormatFiles(JSON.stringify([{ ...file, url: '' }]))).toEqual([]) + expect(parseInputFormatFiles(JSON.stringify([{ ...file, type: '' }]))).toEqual([]) + }) +}) + +describe('collectInputFormatFiles', () => { + const file = { + id: 'f1', + name: 'doc.pdf', + url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace', + key: 'key', + size: 10, + type: 'application/pdf', + } + + it.concurrent('returns empty for non-array values', () => { + expect(collectInputFormatFiles(null)).toEqual([]) + expect(collectInputFormatFiles('nope')).toEqual([]) + }) + + it.concurrent('collects files only from file[] fields, ignoring other types', () => { + const value = [ + { name: 'query', type: 'string', value: 'hi' }, + { name: 'a', type: 'file[]', value: JSON.stringify([file]) }, + { name: 'b', type: 'file[]', value: JSON.stringify([{ ...file, id: 'f2' }]) }, + { name: 'legacy', type: 'files', value: JSON.stringify([{ ...file, id: 'ignored' }]) }, + ] + expect(collectInputFormatFiles(value).map((f) => f.id)).toEqual(['f1', 'f2']) + }) + + it.concurrent('ignores legacy/unparseable file values', () => { + const value = [ + { name: 'a', type: 'file[]', value: 'C:/Users/x/budget.xlsx' }, + { name: 'b', type: 'file[]', value: '[{"data":""}]' }, + { name: 'c', type: 'file[]', value: '' }, + ] + expect(collectInputFormatFiles(value)).toEqual([]) + }) +}) + describe('createDefaultInputFormatField', () => { it.concurrent('creates an empty field with the canonical default shape', () => { const field = createDefaultInputFormatField() diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index b5a8ac2612b..8969959dbaa 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -1,6 +1,8 @@ import { generateId } from '@sim/utils/id' +import { isInternalFileUrl, parseInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' +import type { UserFile } from '@/executor/types' /** * Simplified input field representation for workflow input mapping @@ -41,6 +43,100 @@ export function createDefaultInputFormatField(): InputFormatFieldState { } } +/** + * Whether an input-format field type denotes a file input. Matches the canonical + * `file[]` written by the field-type dropdown — the same literal the execution + * and webhook file paths already key off (`lib/execution/files.ts`, + * `lib/webhooks/providers/generic.ts`) — so the editor and runtime agree and no + * existing non-`file[]` field changes behavior. + */ +export function isFileFieldType(type: string | null | undefined): boolean { + return type === 'file[]' +} + +/** + * Run-ready file object stored as a file field's value. Derived from the + * executor's canonical {@link UserFile} (validated by `normalizeStartFile`) so + * editor-attached files flow into a run unchanged and the shape can't drift. + */ +export type InputFormatFile = Pick & + Pick, 'key'> + +/** + * Whether a file's key is usable at run time: an explicit non-empty `key`, or an + * internal `/api/files/serve/...` URL the key can actually be parsed from. This + * mirrors `normalizeStartFile` exactly (including the parse, so a malformed + * internal URL is rejected rather than accepted on the prefix alone). + */ +function hasRecoverableFileKey(file: InputFormatFile): boolean { + if (typeof file.key === 'string' && file.key.length > 0) return true + if (typeof file.url !== 'string' || !isInternalFileUrl(file.url)) return false + try { + return parseInternalFileUrl(file.url).key.length > 0 + } catch { + return false + } +} + +/** + * Tolerantly parses a file field's stored value (a JSON string, or an already + * materialized array) into run-ready file objects. Returns an empty array for + * legacy free-form values (base64 placeholders, raw text) that don't describe + * uploaded files, so callers degrade gracefully instead of throwing. + */ +export function parseInputFormatFiles(value: unknown): InputFormatFile[] { + let raw: unknown = value + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed) return [] + try { + raw = JSON.parse(trimmed) + } catch { + return [] + } + } + + if (!Array.isArray(raw)) return [] + + return raw.filter((file): file is InputFormatFile => { + if (file === null || typeof file !== 'object') return false + const f = file as InputFormatFile + // Accept only the run-ready shape `normalizeStartFile` accepts (non-empty + // id/name/url/type + finite size + recoverable key); file normalization is + // all-or-nothing, so anything short of this falls back to the JSON editor + // rather than silently dropping every file at run time. + return ( + typeof f.id === 'string' && + f.id.length > 0 && + typeof f.name === 'string' && + f.name.length > 0 && + typeof f.url === 'string' && + f.url.length > 0 && + typeof f.size === 'number' && + Number.isFinite(f.size) && + typeof f.type === 'string' && + f.type.length > 0 && + hasRecoverableFileKey(f) + ) + }) +} + +/** + * Collects all editor-attached files from the file-typed fields of an + * inputFormat value. Files are already uploaded (run-ready), so callers can pass + * them straight to the executor's file channel without a re-upload. + */ +export function collectInputFormatFiles(inputFormatValue: unknown): InputFormatFile[] { + if (!Array.isArray(inputFormatValue)) return [] + return inputFormatValue.flatMap((field) => + field && + typeof field === 'object' && + isFileFieldType((field as { type?: unknown }).type as string) + ? parseInputFormatFiles((field as { value?: unknown }).value) + : [] + ) +} + /** * Extracts input fields from workflow blocks. * Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat.