From 50bab8094346fe62883fa8646d64cd856a3687c6 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 14:01:44 -0700 Subject: [PATCH 1/9] feat(input-format): upload files in file fields via the file uploader The file field in the start-block input format only offered a raw JSON editor expecting hand-written base64 objects, which users routinely filled with junk (local paths, raw text, leftover placeholders) and which never actually fed a run. Reuse the existing FileUpload component for file-typed fields: - Add an opt-in controlled mode (value/onValueChange) to FileUpload so it can be embedded where the value lives outside a subblock; store-bound consumers (stt/vision/agent) are unchanged. - Render the uploader for file fields, with a toggle to fall back to the raw JSON editor for power users / legacy values. - Detect file fields by normalized type (file[]/files/file/image) so copilot/API-authored variants render correctly too. - Wire editor-attached files (already uploaded, run-ready) into manual runs via the executor's files channel; chat/API runs still override. Backwards compatible: the inputFormat array shape is unchanged (file values are stored as a JSON string of run-ready file objects); legacy free-form values open in JSON mode so nothing is lost. --- .../components/file-upload/file-upload.tsx | 45 ++++-- .../components/starter/input-format.tsx | 147 +++++++++++++++--- .../hooks/use-workflow-execution.ts | 37 ++++- apps/sim/lib/workflows/input-format.test.ts | 65 ++++++++ apps/sim/lib/workflows/input-format.ts | 59 +++++++ 5 files changed, 318 insertions(+), 35 deletions(-) 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.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx index 82f3e157781..48e0ca7abd8 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, @@ -17,9 +17,19 @@ import { languages, } from '@sim/emcn' import { Trash } from '@sim/emcn/icons' +import { generateId } from '@sim/utils/id' import { Plus } from 'lucide-react' import Editor from 'react-simple-code-editor' -import { createDefaultInputFormatField } from '@/lib/workflows/input-format' +import { + createDefaultInputFormatField, + type InputFormatFile, + isFileFieldType, + parseInputFormatFiles, +} from '@/lib/workflows/input-format' +import { + FileUpload, + type UploadedFile, +} 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 { 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' @@ -80,6 +90,66 @@ const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\] const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json') +/** + * Maps stored run-ready file objects to the {@link FileUpload} value shape + * (which keys off `path`). + */ +const filesToControlValue = (files: InputFormatFile[]): UploadedFile[] => + 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. + */ +const 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). + */ +const serializeInputFormatFiles = (files: InputFormatFile[]): string => + 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. + */ +const defaultFileFieldMode = (value: string | undefined): 'upload' | 'json' => { + if (!value || !value.trim()) return 'upload' + try { + const parsed = JSON.parse(value) + if (!Array.isArray(parsed)) return 'json' + if (parsed.length === 0) return 'upload' + return parseInputFormatFiles(value).length > 0 ? 'upload' : 'json' + } catch { + return 'json' + } +} + export function FieldFormat({ blockId, subBlockId, @@ -101,6 +171,7 @@ export function FieldFormat({ const overlayRefs = useRef>({}) const nameOverlayRefs = useRef>({}) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + const [fileFieldModes, setFileFieldModes] = useState>({}) const inputController = useSubBlockInput({ blockId, @@ -480,12 +551,14 @@ export function FieldFormat({ ) } - if (field.type === 'file[]') { + if (isFileFieldType(field.type)) { + const mode = fileFieldModes[field.id] ?? defaultFileFieldMode(field.value) + const currentFiles = parseInputFormatFiles(field.value) 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]' +
+
+ +
+ {mode === 'upload' ? ( + + updateField( + field.id, + 'value', + serializeInputFormatFiles(controlValueToFiles(next, currentFiles)) + ) + } /> - - + ) : ( + + {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..fdfb2d97924 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,11 @@ 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 { + type InputFormatFile, + isFileFieldType, + parseInputFormatFiles, +} from '@/lib/workflows/input-format' import { extractTriggerMockPayload, selectBestTrigger, @@ -948,13 +953,23 @@ export function useWorkflowExecution() { selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId) } - // Helper to extract test values from inputFormat subblock + /** + * Extracts test values from the inputFormat subblock. File fields are + * excluded here — they flow through the dedicated `files` channel (see + * extractFilesFromInputFormat) rather than as named structured inputs. + */ 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) { + if ( + field && + typeof field === 'object' && + field.name && + field.value !== undefined && + !isFileFieldType(field.type) + ) { testInput[field.name] = coerceValue(field.type, field.value) } }) @@ -963,6 +978,20 @@ export function useWorkflowExecution() { return testInput } + /** + * Collects editor-attached files from file-typed inputFormat fields. These + * are already uploaded to workspace storage, so they pass straight to the + * executor's file channel (normalizeStartFile) without a re-upload. + */ + const extractFilesFromInputFormat = (inputFormatValue: any): InputFormatFile[] => { + if (!Array.isArray(inputFormatValue)) return [] + return inputFormatValue.flatMap((field: any) => + field && typeof field === 'object' && isFileFieldType(field.type) + ? parseInputFormatFiles(field.value) + : [] + ) + } + // Determine start block and workflow input based on execution type let startBlockId: string | undefined let finalWorkflowInput = workflowInput @@ -1054,6 +1083,10 @@ export function useWorkflowExecution() { ) { const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value const testInput = extractTestValuesFromInputFormat(inputFormatValue) + const inputFiles = extractFilesFromInputFormat(inputFormatValue) + if (inputFiles.length > 0) { + testInput.files = inputFiles + } if (Object.keys(testInput).length > 0) { finalWorkflowInput = testInput } diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index 886e9aac5ab..c64b150c326 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { createDefaultInputFormatField, extractInputFieldsFromBlocks, + isFileFieldType, normalizeInputFormatValue, + parseInputFormatFiles, } from '@/lib/workflows/input-format' describe('extractInputFieldsFromBlocks', () => { @@ -229,6 +231,69 @@ describe('normalizeInputFormatValue', () => { }) }) +describe('isFileFieldType', () => { + it.concurrent('matches canonical and legacy file type variants', () => { + expect(isFileFieldType('file[]')).toBe(true) + expect(isFileFieldType('files')).toBe(true) + expect(isFileFieldType('file')).toBe(true) + expect(isFileFieldType('image')).toBe(true) + }) + + it.concurrent('is case- and whitespace-insensitive', () => { + expect(isFileFieldType(' File[] ')).toBe(true) + expect(isFileFieldType('FILES')).toBe(true) + }) + + it.concurrent('returns false for non-file and nullish types', () => { + 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/key', + 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]) + }) +}) + 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..c1b9f767c0b 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -41,6 +41,65 @@ export function createDefaultInputFormatField(): InputFormatFieldState { } } +/** + * Field type strings that denote a file input. The editor writes the canonical + * `file[]`, but workflows authored via copilot or the API persist variants + * (`files`, `file`, `image`); treat them all as file fields so the uploader and + * runtime behave consistently. + */ +const FILE_FIELD_TYPES = new Set(['file[]', 'files', 'file', 'image']) + +/** + * Whether an input-format field type denotes a file input. + */ +export function isFileFieldType(type: string | null | undefined): boolean { + return typeof type === 'string' && FILE_FIELD_TYPES.has(type.trim().toLowerCase()) +} + +/** + * Run-ready file object stored as a file field's value. Mirrors the executor's + * `UserFile` requirements (`normalizeStartFile`): an internal `url`/`key` plus a + * stable `id`, so editor-attached files flow into a run unchanged. + */ +export interface InputFormatFile { + id: string + name: string + url: string + key?: string + size: number + type: string +} + +/** + * 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 => + file !== null && + typeof file === 'object' && + typeof (file as InputFormatFile).name === 'string' && + typeof (file as InputFormatFile).url === 'string' && + typeof (file as InputFormatFile).id === 'string' + ) +} + /** * Extracts input fields from workflow blocks. * Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat. From f7ca85acd0cb5f8c415095fc806945cbe4ec545a Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 14:25:03 -0700 Subject: [PATCH 2/9] refactor(input-format): extract file adapters, share run-input builder, harden types Follow-up cleanup from /simplify + /cleanup review: - Extract pure file adapters into input-format-files.ts (filesToControlValue, controlValueToFiles, serializeInputFormatFiles, defaultFileFieldMode) so they are unit-tested without a DOM; add tests. - Derive InputFormatFile from the canonical executor UserFile so the editor and runtime file shapes can't drift. - Consolidate the two manual run-input builders into one buildInputFormatInput helper so manual run and run-from-block handle files identically. - Narrow file-field detection to the canonical file[] (matches the field-type dropdown and the existing execution/webhook file paths) so no pre-existing non-file[] field changes behavior. - defaultFileFieldMode parses once; the file value is only parsed in upload mode. - Add lib/component unit tests (45 passing). --- .../starter/input-format-files.test.ts | 108 +++++++++++ .../components/starter/input-format-files.ts | 71 +++++++ .../components/starter/input-format.tsx | 182 +++++++----------- .../hooks/use-workflow-execution.ts | 100 ++++------ apps/sim/lib/workflows/input-format.test.ts | 51 ++++- apps/sim/lib/workflows/input-format.ts | 49 +++-- 6 files changed, 351 insertions(+), 210 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format-files.ts 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..209467669d5 --- /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,108 @@ +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/key', + key: 'key', + size: 10, + type: 'application/pdf', +} + +describe('filesToControlValue', () => { + it.concurrent('maps url -> path for the FileUpload value shape', () => { + expect(filesToControlValue([file])).toEqual([ + { + name: 'doc.pdf', + path: '/api/files/serve/key', + key: 'key', + size: 10, + type: 'application/pdf', + }, + ]) + }) + + 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: '/api/files/serve/key', 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: 'doc.pdf', + path: '/api/files/serve/key', + key: 'key', + size: 10, + type: 'application/pdf', + } + 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') + }) +}) 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..d242892d6be --- /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,71 @@ +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' + return parsed.length === 0 || parseInputFormatFiles(parsed).length > 0 ? '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 48e0ca7abd8..1e2f1eaca1b 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 @@ -17,20 +17,21 @@ import { languages, } from '@sim/emcn' import { Trash } from '@sim/emcn/icons' -import { generateId } from '@sim/utils/id' import { Plus } from 'lucide-react' import Editor from 'react-simple-code-editor' import { createDefaultInputFormatField, - type InputFormatFile, isFileFieldType, parseInputFormatFiles, } from '@/lib/workflows/input-format' -import { - FileUpload, - type UploadedFile, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload' +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' @@ -90,66 +91,6 @@ const validateFieldName = (name: string): string => name.replace(/[\x00-\x1F"\\] const jsonHighlight = (code: string): string => highlight(code, languages.json, 'json') -/** - * Maps stored run-ready file objects to the {@link FileUpload} value shape - * (which keys off `path`). - */ -const filesToControlValue = (files: InputFormatFile[]): UploadedFile[] => - 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. - */ -const 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). - */ -const serializeInputFormatFiles = (files: InputFormatFile[]): string => - 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. - */ -const defaultFileFieldMode = (value: string | undefined): 'upload' | 'json' => { - if (!value || !value.trim()) return 'upload' - try { - const parsed = JSON.parse(value) - if (!Array.isArray(parsed)) return 'json' - if (parsed.length === 0) return 'upload' - return parseInputFormatFiles(value).length > 0 ? 'upload' : 'json' - } catch { - return 'json' - } -} - export function FieldFormat({ blockId, subBlockId, @@ -553,40 +494,31 @@ export function FieldFormat({ if (isFileFieldType(field.type)) { const mode = fileFieldModes[field.id] ?? defaultFileFieldMode(field.value) - const currentFiles = parseInputFormatFiles(field.value) - const lineCount = fieldValue.split('\n').length - const gutterWidth = calculateGutterWidth(lineCount) - const renderLineNumbers = () => - Array.from({ length: lineCount }, (_, i) => ( -
+
- )) + {mode === 'upload' ? 'Enter JSON manually' : 'Use file uploader'} + +
+ ) - return ( -
-
- -
- {mode === 'upload' ? ( + if (mode === 'upload') { + const currentFiles = parseInputFormatFiles(field.value) + return ( +
+ {modeToggle} - ) : ( - - {renderLineNumbers()} - - - { - '[\n {\n "data": "",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]' - } - - - - - )} +
+ ) + } + + const lineCount = fieldValue.split('\n').length + const gutterWidth = calculateGutterWidth(lineCount) + const renderLineNumbers = () => + Array.from({ length: lineCount }, (_, i) => ( +
+ {i + 1} +
+ )) + + return ( +
+ {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 fdfb2d97924..ddcf1029870 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,11 +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 { - type InputFormatFile, - isFileFieldType, - parseInputFormatFiles, -} from '@/lib/workflows/input-format' +import { collectInputFormatFiles, isFileFieldType } from '@/lib/workflows/input-format' import { extractTriggerMockPayload, selectBestTrigger, @@ -143,6 +139,36 @@ 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) + } + } + + 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) @@ -953,45 +979,6 @@ export function useWorkflowExecution() { selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId) } - /** - * Extracts test values from the inputFormat subblock. File fields are - * excluded here — they flow through the dedicated `files` channel (see - * extractFilesFromInputFormat) rather than as named structured inputs. - */ - 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 && - !isFileFieldType(field.type) - ) { - testInput[field.name] = coerceValue(field.type, field.value) - } - }) - } - - return testInput - } - - /** - * Collects editor-attached files from file-typed inputFormat fields. These - * are already uploaded to workspace storage, so they pass straight to the - * executor's file channel (normalizeStartFile) without a re-upload. - */ - const extractFilesFromInputFormat = (inputFormatValue: any): InputFormatFile[] => { - if (!Array.isArray(inputFormatValue)) return [] - return inputFormatValue.flatMap((field: any) => - field && typeof field === 'object' && isFileFieldType(field.type) - ? parseInputFormatFiles(field.value) - : [] - ) - } - // Determine start block and workflow input based on execution type let startBlockId: string | undefined let finalWorkflowInput = workflowInput @@ -1081,14 +1068,9 @@ export function useWorkflowExecution() { selectedCandidate.path === StartBlockPath.SPLIT_INPUT || selectedCandidate.path === StartBlockPath.UNIFIED ) { - const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value - const testInput = extractTestValuesFromInputFormat(inputFormatValue) - const inputFiles = extractFilesFromInputFormat(inputFormatValue) - if (inputFiles.length > 0) { - testInput.files = inputFiles - } - if (Object.keys(testInput).length > 0) { - finalWorkflowInput = testInput + const builtInput = buildInputFormatInput(selectedTrigger.subBlocks?.inputFormat?.value) + if (builtInput) { + finalWorkflowInput = builtInput } } } @@ -1840,17 +1822,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 c64b150c326..723e594ae1a 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + collectInputFormatFiles, createDefaultInputFormatField, extractInputFieldsFromBlocks, isFileFieldType, @@ -232,19 +233,14 @@ describe('normalizeInputFormatValue', () => { }) describe('isFileFieldType', () => { - it.concurrent('matches canonical and legacy file type variants', () => { + it.concurrent('matches the canonical file[] type', () => { expect(isFileFieldType('file[]')).toBe(true) - expect(isFileFieldType('files')).toBe(true) - expect(isFileFieldType('file')).toBe(true) - expect(isFileFieldType('image')).toBe(true) }) - it.concurrent('is case- and whitespace-insensitive', () => { - expect(isFileFieldType(' File[] ')).toBe(true) - expect(isFileFieldType('FILES')).toBe(true) - }) - - it.concurrent('returns false for non-file and nullish types', () => { + 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) @@ -294,6 +290,41 @@ describe('parseInputFormatFiles', () => { }) }) +describe('collectInputFormatFiles', () => { + const file = { + id: 'f1', + name: 'doc.pdf', + url: '/api/files/serve/key', + 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 c1b9f767c0b..deb4721f349 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -1,6 +1,7 @@ import { generateId } from '@sim/utils/id' 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 @@ -42,33 +43,23 @@ export function createDefaultInputFormatField(): InputFormatFieldState { } /** - * Field type strings that denote a file input. The editor writes the canonical - * `file[]`, but workflows authored via copilot or the API persist variants - * (`files`, `file`, `image`); treat them all as file fields so the uploader and - * runtime behave consistently. - */ -const FILE_FIELD_TYPES = new Set(['file[]', 'files', 'file', 'image']) - -/** - * Whether an input-format field type denotes a file input. + * 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 typeof type === 'string' && FILE_FIELD_TYPES.has(type.trim().toLowerCase()) + return type === 'file[]' } /** - * Run-ready file object stored as a file field's value. Mirrors the executor's - * `UserFile` requirements (`normalizeStartFile`): an internal `url`/`key` plus a - * stable `id`, so editor-attached files flow into a run unchanged. + * 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 interface InputFormatFile { - id: string - name: string - url: string - key?: string - size: number - type: string -} +export type InputFormatFile = Pick & + Pick, 'key'> /** * Tolerantly parses a file field's stored value (a JSON string, or an already @@ -100,6 +91,22 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { ) } +/** + * 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. From 881234047dcd7620005a0636414c819a995aadd3 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 14:27:59 -0700 Subject: [PATCH 3/9] fix(input-format): require run-ready file shape, don't clobber named files input Addresses review (Greptile P1s): - parseInputFormatFiles now requires the full run-ready shape (id/name/url + finite size + type), so a partial file can't open in uploader mode or reach workflowInput.files only to be rejected by normalizeStartFile (which would silently drop every file). - buildInputFormatInput no longer overwrites a user field literally named 'files' with the upload channel; that reserved-name collision is left for the executor to surface. --- .../hooks/use-workflow-execution.ts | 5 +++- apps/sim/lib/workflows/input-format.test.ts | 11 +++++++++ apps/sim/lib/workflows/input-format.ts | 23 ++++++++++++------- 3 files changed, 30 insertions(+), 9 deletions(-) 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 ddcf1029870..9219f7fa373 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 @@ -163,8 +163,11 @@ function buildInputFormatInput(inputFormatValue: unknown): Record | } } + // Route file[] fields to the dedicated `files` channel, but never clobber a + // user field that is itself named `files` (a reserved-name config error the + // executor surfaces on its own). const files = collectInputFormatFiles(inputFormatValue) - if (files.length > 0) testInput.files = files + if (files.length > 0 && !('files' in testInput)) testInput.files = files return Object.keys(testInput).length > 0 ? testInput : undefined } diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index 723e594ae1a..1e773183494 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -288,6 +288,17 @@ describe('parseInputFormatFiles', () => { 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([]) + }) }) describe('collectInputFormatFiles', () => { diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index deb4721f349..ddc11961e49 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -81,14 +81,21 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { if (!Array.isArray(raw)) return [] - return raw.filter( - (file): file is InputFormatFile => - file !== null && - typeof file === 'object' && - typeof (file as InputFormatFile).name === 'string' && - typeof (file as InputFormatFile).url === 'string' && - typeof (file as InputFormatFile).id === 'string' - ) + return raw.filter((file): file is InputFormatFile => { + if (file === null || typeof file !== 'object') return false + const f = file as InputFormatFile + // Require the full run-ready shape the executor's normalizeStartFile needs, + // so a partial object never opens in uploader mode or reaches the files + // channel only to be rejected (which would silently drop every file). + return ( + typeof f.id === 'string' && + typeof f.name === 'string' && + typeof f.url === 'string' && + typeof f.size === 'number' && + Number.isFinite(f.size) && + typeof f.type === 'string' + ) + }) } /** From 7f490d560e2cf372b6928a56e4842dd067574405 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 14:35:19 -0700 Subject: [PATCH 4/9] fix(input-format): require file key, keep mixed/partial values in JSON mode Addresses review round 2 (Greptile P1 + Cursor): - parseInputFormatFiles now requires a non-empty key (the uploader always sets one); an external/signed URL with no recoverable key no longer opens uploader mode or reaches workflowInput.files only to be rejected. - defaultFileFieldMode uses the uploader only when EVERY entry is run-ready; a mixed array with any legacy/partial entry stays in JSON mode so the uploader can't drop the entries it can't represent on the next save. --- .../components/starter/input-format-files.test.ts | 4 ++++ .../sub-block/components/starter/input-format-files.ts | 6 +++++- apps/sim/lib/workflows/input-format.test.ts | 6 ++++++ apps/sim/lib/workflows/input-format.ts | 8 ++++++-- 4 files changed, 21 insertions(+), 3 deletions(-) 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 index 209467669d5..0709b75b9bd 100644 --- 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 @@ -105,4 +105,8 @@ describe('defaultFileFieldMode', () => { 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 index d242892d6be..ddb805bc380 100644 --- 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 @@ -67,5 +67,9 @@ export function defaultFileFieldMode(value: string | undefined): 'upload' | 'jso return 'json' } if (!Array.isArray(parsed)) return 'json' - return parsed.length === 0 || parseInputFormatFiles(parsed).length > 0 ? 'upload' : '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/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index 1e773183494..de10d931f07 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -299,6 +299,12 @@ describe('parseInputFormatFiles', () => { ) ).toEqual([]) }) + + it.concurrent('rejects files without a usable key', () => { + const { key, ...noKey } = file + expect(parseInputFormatFiles(JSON.stringify([noKey]))).toEqual([]) + expect(parseInputFormatFiles(JSON.stringify([{ ...file, key: '' }]))).toEqual([]) + }) }) describe('collectInputFormatFiles', () => { diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index ddc11961e49..ffb2196ff7e 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -85,12 +85,16 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { if (file === null || typeof file !== 'object') return false const f = file as InputFormatFile // Require the full run-ready shape the executor's normalizeStartFile needs, - // so a partial object never opens in uploader mode or reaches the files - // channel only to be rejected (which would silently drop every file). + // including a usable `key` (the uploader always sets one), so a partial + // object or an external/signed URL never opens in uploader mode or reaches + // the files channel only to be rejected — which would silently drop every + // file. Anything short of this falls back to the JSON editor. return ( typeof f.id === 'string' && typeof f.name === 'string' && typeof f.url === 'string' && + typeof f.key === 'string' && + f.key.length > 0 && typeof f.size === 'number' && Number.isFinite(f.size) && typeof f.type === 'string' From a028114b5b1c332d14b4b1d2dc46fce51152082e Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 14:44:07 -0700 Subject: [PATCH 5/9] fix(input-format): recover internal-url keys, gate uploader to lossless values Addresses review round 3 (Greptile P1 + Cursor): - parseInputFormatFiles accepts a key-less file when its url is an internal /api/files/serve/... url (the executor recovers the key from it, same as normalizeStartFile); only a missing key with a non-internal url is rejected. - The file field offers the uploader only when it can represent the stored value losslessly (empty or all run-ready); for mixed/legacy values it forces JSON mode and hides the toggle, so a sticky 'upload' preference can no longer drop entries the uploader cannot show on save. --- .../components/starter/input-format.tsx | 13 +++++++++---- apps/sim/lib/workflows/input-format.test.ts | 12 +++++++++--- apps/sim/lib/workflows/input-format.ts | 18 +++++++++++------- 3 files changed, 29 insertions(+), 14 deletions(-) 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 1e2f1eaca1b..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 @@ -493,9 +493,14 @@ export function FieldFormat({ } if (isFileFieldType(field.type)) { - const mode = fileFieldModes[field.id] ?? defaultFileFieldMode(field.value) - - const modeToggle = ( + // 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) diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index de10d931f07..7750052f073 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -300,10 +300,16 @@ describe('parseInputFormatFiles', () => { ).toEqual([]) }) - it.concurrent('rejects files without a usable key', () => { + it.concurrent('rejects files with no key and a non-internal url (key unrecoverable)', () => { const { key, ...noKey } = file - expect(parseInputFormatFiles(JSON.stringify([noKey]))).toEqual([]) - expect(parseInputFormatFiles(JSON.stringify([{ ...file, key: '' }]))).toEqual([]) + 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]) }) }) diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index ffb2196ff7e..2bebec7a9f7 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -1,4 +1,5 @@ import { generateId } from '@sim/utils/id' +import { isInternalFileUrl } 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' @@ -85,19 +86,22 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { if (file === null || typeof file !== 'object') return false const f = file as InputFormatFile // Require the full run-ready shape the executor's normalizeStartFile needs, - // including a usable `key` (the uploader always sets one), so a partial - // object or an external/signed URL never opens in uploader mode or reaches - // the files channel only to be rejected — which would silently drop every - // file. Anything short of this falls back to the JSON editor. + // with a recoverable key: either an explicit non-empty `key`, or an internal + // `/api/files/serve/...` URL the executor can recover the key from (same rule + // as normalizeStartFile). A partial object or external/signed URL without a + // key is rejected so it never opens in uploader mode or reaches the files + // channel only to be dropped; it falls back to the JSON editor instead. + const hasRecoverableKey = + (typeof f.key === 'string' && f.key.length > 0) || + (typeof f.url === 'string' && isInternalFileUrl(f.url)) return ( typeof f.id === 'string' && typeof f.name === 'string' && typeof f.url === 'string' && - typeof f.key === 'string' && - f.key.length > 0 && typeof f.size === 'number' && Number.isFinite(f.size) && - typeof f.type === 'string' + typeof f.type === 'string' && + hasRecoverableKey ) }) } From f2718e90b816196ce6f4ec7655e871c740b79ad6 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 14:50:57 -0700 Subject: [PATCH 6/9] fix(input-format): require non-empty id/name/url/type in parseInputFormatFiles Addresses review round 4 (Greptile): normalizeStartFile rejects falsy id/name/url/type and file normalization is all-or-nothing, so an empty-string field (e.g. hand-edited value with an empty id + internal url) would open in uploader mode and drop every file from the run. Require non-empty strings to match the executor. --- apps/sim/lib/workflows/input-format.test.ts | 7 +++++++ apps/sim/lib/workflows/input-format.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index 7750052f073..71f6cc71289 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -311,6 +311,13 @@ describe('parseInputFormatFiles', () => { const { key, ...noKey } = file expect(parseInputFormatFiles(JSON.stringify([noKey]))).toEqual([noKey]) }) + + 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', () => { diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 2bebec7a9f7..2e95d3b6a48 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -94,13 +94,20 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { const hasRecoverableKey = (typeof f.key === 'string' && f.key.length > 0) || (typeof f.url === 'string' && isInternalFileUrl(f.url)) + // Non-empty strings: normalizeStartFile rejects falsy id/name/url/type, and + // file normalization is all-or-nothing, so one empty-string field would drop + // every file from the run. 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 && hasRecoverableKey ) }) From 519917a3bb718e5fd731ecaa4e1eb9bcee584c3b Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 14:59:41 -0700 Subject: [PATCH 7/9] fix(input-format): validate recovered internal-url keys via parseInternalFileUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review round 5 (Greptile): isInternalFileUrl only checks the URL prefix, but the executor parses+decodes the key. A malformed internal URL (no extractable key) passed the prefix check, got collected into workflowInput.files, then normalizeStartFile rejected it and dropped the whole files array. Now mirror the executor exactly — attempt parseInternalFileUrl and require a real key. Tests use a realistic recoverable internal URL. --- .../starter/input-format-files.test.ts | 26 +++++++++---------- apps/sim/lib/workflows/input-format.test.ts | 11 ++++++-- apps/sim/lib/workflows/input-format.ts | 23 ++++++++++++---- 3 files changed, 39 insertions(+), 21 deletions(-) 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 index 0709b75b9bd..1f6aae754aa 100644 --- 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 @@ -10,7 +10,7 @@ import { const file: InputFormatFile = { id: 'f1', name: 'doc.pdf', - url: '/api/files/serve/key', + url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace', key: 'key', size: 10, type: 'application/pdf', @@ -20,11 +20,11 @@ describe('filesToControlValue', () => { it.concurrent('maps url -> path for the FileUpload value shape', () => { expect(filesToControlValue([file])).toEqual([ { - name: 'doc.pdf', - path: '/api/files/serve/key', - key: 'key', - size: 10, - type: 'application/pdf', + name: file.name, + path: file.url, + key: file.key, + size: file.size, + type: file.type, }, ]) }) @@ -43,9 +43,7 @@ describe('controlValueToFiles', () => { }) it.concurrent('matches an existing file by url when key is absent', () => { - const control = [ - { name: 'doc.pdf', path: '/api/files/serve/key', size: 10, type: 'application/pdf' }, - ] + const control = [{ name: 'doc.pdf', path: file.url, size: 10, type: 'application/pdf' }] expect(controlValueToFiles(control, [file])[0].id).toBe('f1') }) @@ -67,11 +65,11 @@ describe('controlValueToFiles', () => { it.concurrent('normalizes a single object or null to an array', () => { const single = { - name: 'doc.pdf', - path: '/api/files/serve/key', - key: 'key', - size: 10, - type: 'application/pdf', + 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([]) diff --git a/apps/sim/lib/workflows/input-format.test.ts b/apps/sim/lib/workflows/input-format.test.ts index 71f6cc71289..d948e5f5bdc 100644 --- a/apps/sim/lib/workflows/input-format.test.ts +++ b/apps/sim/lib/workflows/input-format.test.ts @@ -252,7 +252,7 @@ describe('parseInputFormatFiles', () => { const file = { id: 'f1', name: 'doc.pdf', - url: '/api/files/serve/key', + url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace', key: 'key', size: 10, type: 'application/pdf', @@ -312,6 +312,13 @@ describe('parseInputFormatFiles', () => { 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([]) @@ -324,7 +331,7 @@ describe('collectInputFormatFiles', () => { const file = { id: 'f1', name: 'doc.pdf', - url: '/api/files/serve/key', + url: '/api/files/serve/workspace%2Fws-1%2F1700000000000-doc.pdf?context=workspace', key: 'key', size: 10, type: 'application/pdf', diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 2e95d3b6a48..27082886eb9 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -1,5 +1,5 @@ import { generateId } from '@sim/utils/id' -import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' +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' @@ -62,6 +62,22 @@ export function isFileFieldType(type: string | null | undefined): boolean { 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 @@ -91,9 +107,6 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { // as normalizeStartFile). A partial object or external/signed URL without a // key is rejected so it never opens in uploader mode or reaches the files // channel only to be dropped; it falls back to the JSON editor instead. - const hasRecoverableKey = - (typeof f.key === 'string' && f.key.length > 0) || - (typeof f.url === 'string' && isInternalFileUrl(f.url)) // Non-empty strings: normalizeStartFile rejects falsy id/name/url/type, and // file normalization is all-or-nothing, so one empty-string field would drop // every file from the run. @@ -108,7 +121,7 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { Number.isFinite(f.size) && typeof f.type === 'string' && f.type.length > 0 && - hasRecoverableKey + hasRecoverableFileKey(f) ) }) } From 47952a73c967213995a16d31afd5dddc592efaad Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 15:07:29 -0700 Subject: [PATCH 8/9] fix(input-format): uploaded files always own the files channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review round 6 (Greptile): files is NOT in the executor's reserved input-format names (EXECUTION_CONTROL_OUTPUT_FIELD_NAMES), so the previous guard silently dropped uploaded file[] values whenever a plain field named files coexisted. files is the start block's canonical file channel (the chat trigger names its own file field files), so uploaded files now always populate it and take precedence — dropping real attachments is the worse outcome, and making files a reserved-name error isn't viable since it's the legitimate file-field name. --- .../w/[workflowId]/hooks/use-workflow-execution.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 9219f7fa373..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 @@ -163,11 +163,13 @@ function buildInputFormatInput(inputFormatValue: unknown): Record | } } - // Route file[] fields to the dedicated `files` channel, but never clobber a - // user field that is itself named `files` (a reserved-name config error the - // executor surfaces on its own). + // 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 && !('files' in testInput)) testInput.files = files + if (files.length > 0) testInput.files = files return Object.keys(testInput).length > 0 ? testInput : undefined } From 701abc68aa8f46930b6e33bf5095107512c93085 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 15:56:19 -0700 Subject: [PATCH 9/9] chore(input-format): tighten parseInputFormatFiles comment --- apps/sim/lib/workflows/input-format.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index 27082886eb9..8969959dbaa 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -101,15 +101,10 @@ export function parseInputFormatFiles(value: unknown): InputFormatFile[] { return raw.filter((file): file is InputFormatFile => { if (file === null || typeof file !== 'object') return false const f = file as InputFormatFile - // Require the full run-ready shape the executor's normalizeStartFile needs, - // with a recoverable key: either an explicit non-empty `key`, or an internal - // `/api/files/serve/...` URL the executor can recover the key from (same rule - // as normalizeStartFile). A partial object or external/signed URL without a - // key is rejected so it never opens in uploader mode or reaches the files - // channel only to be dropped; it falls back to the JSON editor instead. - // Non-empty strings: normalizeStartFile rejects falsy id/name/url/type, and - // file normalization is all-or-nothing, so one empty-string field would drop - // every file from the run. + // 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 &&