feat(input-format): upload files in file fields via the file uploader (#5297)

* 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.

* 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).

* 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.

* 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.

* 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.

* 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.

* fix(input-format): validate recovered internal-url keys via parseInternalFileUrl

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.

* fix(input-format): uploaded files always own the files channel

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.

* chore(input-format): tighten parseInputFormatFiles comment
This commit is contained in:
Waleed
2026-06-30 16:02:42 -07:00
committed by GitHub
parent 32b6a425fd
commit c17c003a91
7 changed files with 574 additions and 67 deletions
@@ -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<UploadingFile[]>([])
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 {
@@ -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":"<base64>","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')
})
})
@@ -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'
}
@@ -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<Record<string, HTMLDivElement>>({})
const nameOverlayRefs = useRef<Record<string, HTMLDivElement>>({})
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const [fileFieldModes, setFileFieldModes] = useState<Record<string, 'upload' | 'json'>>({})
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 ? (
<div className='flex justify-end'>
<Button
type='button'
variant='ghost'
onClick={() =>
setFileFieldModes((prev) => ({
...prev,
[field.id]: mode === 'upload' ? 'json' : 'upload',
}))
}
disabled={isReadOnly}
className='h-auto p-0 text-[var(--text-muted)] text-xs hover-hover:text-[var(--text-body)]'
>
{mode === 'upload' ? 'Enter JSON manually' : 'Use file uploader'}
</Button>
</div>
) : null
if (mode === 'upload') {
const currentFiles = parseInputFormatFiles(field.value)
return (
<div className='flex flex-col gap-1.5'>
{modeToggle}
<FileUpload
blockId={blockId}
subBlockId={subBlockId}
multiple
disabled={isReadOnly}
value={filesToControlValue(currentFiles)}
onValueChange={(next) =>
updateField(
field.id,
'value',
serializeInputFormatFiles(controlValueToFiles(next, currentFiles))
)
}
/>
</div>
)
}
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) => (
<div
key={i}
className='font-medium font-mono text-[var(--text-muted)] text-xs'
@@ -494,26 +554,28 @@ export function FieldFormat({
{i + 1}
</div>
))
}
return (
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
}
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
</Code.Content>
</Code.Container>
<div className='flex flex-col gap-1.5'>
{modeToggle}
<Code.Container className='min-h-[120px]'>
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
<Code.Content paddingLeft={`${gutterWidth}px`}>
<Code.Placeholder gutterWidth={gutterWidth} show={fieldValue.length === 0}>
{
'[\n {\n "data": "<base64>",\n "type": "file",\n "name": "document.pdf",\n "mime": "application/pdf"\n }\n]'
}
</Code.Placeholder>
<Editor
value={fieldValue}
onValueChange={getEditorValueChangeHandler(field.id)}
highlight={jsonHighlight}
disabled={isReadOnly}
{...getCodeEditorProps({ disabled: isReadOnly })}
/>
</Code.Content>
</Code.Container>
</div>
)
}
@@ -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<string, any> | undefined {
if (!Array.isArray(inputFormatValue)) return undefined
const testInput: Record<string, any> = {}
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<string, any> => {
const testInput: Record<string, any> = {}
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<string, any> = {}
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 {
+133
View File
@@ -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: '<base64>', 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":"<base64>"}]' },
{ 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()
+96
View File
@@ -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<UserFile, 'id' | 'name' | 'url' | 'size' | 'type'> &
Pick<Partial<UserFile>, '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.