Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions apps/sim/blocks/blocks/file-folders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ describe('file_v5 folder operations produce contract-valid tool input', () => {
describe('move file', () => {
it('sends the canonical destination the picker produced', () => {
const params = paramsFor('file_move', {
moveFileId: 'wf_123',
moveFileInput: 'wf_123',
moveTargetRef: '/Reports/Q3%20Results',
})

Expand All @@ -157,13 +157,66 @@ describe('file_v5 folder operations produce contract-valid tool input', () => {
})

it('omits the destination when no folder is picked', () => {
const params = paramsFor('file_move', { moveFileId: 'wf_123' })
const params = paramsFor('file_move', { moveFileInput: 'wf_123' })

expect(params.folderPath).toBeUndefined()
expect(fileManageMoveBodySchema.safeParse({ operation: 'move', ...params }).success).toBe(
true
)
})

/*
* The file is a basic/advanced pair like every other single-file operand,
* so it can be picked as well as typed. The tool takes only an id, so a
* picked file travels as the id its selection carries.
*/
it('pairs a workspace file picker with the typed id', () => {
const picker = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'moveFile')
const typed = FileV5Block.subBlocks.find((subBlock) => subBlock.id === 'moveFileId')

expect(picker?.type).toBe('file-upload')
expect(picker?.mode).toBe('basic')
expect(picker?.canonicalParamId).toBe('moveFileInput')
expect(typed?.type).toBe('short-input')
expect(typed?.mode).toBe('advanced')
expect(typed?.canonicalParamId).toBe('moveFileInput')
expect(typed?.condition).toEqual(picker?.condition)
expect(typed?.required).toEqual(picker?.required)
})

it('moves a picked file by the id it carries', () => {
const params = paramsFor('file_move', {
moveFileInput: { id: 'wf_abc', name: 'notes.md', key: 'workspace/ws-1/notes.md' },
})

expect(params.fileId).toBe('wf_abc')
expect(fileManageMoveBodySchema.safeParse({ operation: 'move', ...params }).success).toBe(
true
)
})

it('reads one id from the serialized list a reference can produce', () => {
expect(paramsFor('file_move', { moveFileInput: '["wf_abc"]' }).fileId).toBe('wf_abc')
})

it('refuses more than one file', () => {
expect(() => paramsFor('file_move', { moveFileInput: '["wf_a","wf_b"]' })).toThrow(
/single file/
)
expect(() =>
paramsFor('file_move', { moveFileInput: [{ id: 'wf_a' }, { id: 'wf_b' }] })
).toThrow(/single file/)
})

it('refuses a file object that carries no id, naming the remedy', () => {
expect(() =>
paramsFor('file_move', { moveFileInput: { name: 'notes.md', key: 'k' } })
).toThrow(/workspace file ID/)
})

it('refuses an empty operand', () => {
expect(() => paramsFor('file_move', {})).toThrow(/File is required for move/)
})
})

describe('write takes a folder, append does not', () => {
Expand Down
54 changes: 48 additions & 6 deletions apps/sim/blocks/blocks/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const APPEND_FILE_FIELD = ['appendFile', 'appendFileName'] as const
const COMPRESS_FILE_FIELD = ['compressFile', 'compressFileId'] as const
const DECOMPRESS_FILE_FIELD = ['decompressFile', 'decompressFileId'] as const
const SHARE_FILE_FIELD = ['shareFile', 'shareFileId'] as const
const MOVE_FILE_FIELD = ['moveFile', 'moveFileId'] as const
/* Text and file are mutually exclusive sources, so the clause names whichever
one the card actually carries. */
const WRITE_CONTENT_FIELD = ['content', 'writeFile', 'writeFileId'] as const
Expand Down Expand Up @@ -1129,6 +1130,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
- Use Write to create a new workspace file and Append to add content to an existing one. Write adds a numeric suffix when the name is taken; turn on "Overwrite Existing File" to replace the contents of the file at that exact path (folder and name) instead — a same-named file in another folder is left alone.
- Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "files" output.
- Use Decompress to extract a .zip archive back into the workspace; the extracted files are returned in the "files" output, ready to chain into Get Content or downstream blocks.
- Move File takes one workspace file, picked or given as a canonical file ID such as an earlier block's file output, and the folder to move it into.
`,
canvasPresentation: {
defaultTitle: 'File',
Expand Down Expand Up @@ -1178,7 +1180,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
file_delete_folder: [{ text: 'Delete folder', field: FOLDER_PATH_FIELD, core: true }],
file_restore_folder: [{ text: 'Restore folder', field: 'restoreFolderId', core: true }],
file_move: [
{ text: 'Move', field: 'moveFileId', core: true },
{ text: 'Move', field: MOVE_FILE_FIELD, core: true },
{ text: 'into', field: MOVE_TARGET_FIELD },
],
file_decompress: [{ text: 'Unzip', field: DECOMPRESS_FILE_FIELD, core: true }],
Expand Down Expand Up @@ -1873,11 +1875,24 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
type: 'switch' as SubBlockType,
condition: { field: 'operation', value: 'file_delete_folder' },
},
{
id: 'moveFile',
title: 'File',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'moveFileInput',
acceptedTypes: '*',
placeholder: 'Select a workspace file',
mode: 'basic',
condition: { field: 'operation', value: 'file_move' },
required: { field: 'operation', value: 'file_move' },
},
{
id: 'moveFileId',
title: 'File ID',
type: 'short-input' as SubBlockType,
placeholder: 'Canonical workspace file ID',
canonicalParamId: 'moveFileInput',
placeholder: 'Workspace file ID',
mode: 'advanced',
condition: { field: 'operation', value: 'file_move' },
required: { field: 'operation', value: 'file_move' },
},
Expand Down Expand Up @@ -2045,8 +2060,35 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
}

if (operation === 'file_move') {
const moveInput = params.moveFileInput
if (!moveInput) {
throw new Error('File is required for move')
Comment on lines +2063 to +2065

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Persisted move key is ignored

When a Move File value is stored under the retained moveFileId field, input resolution preserves that key but this mapper reads only moveFileInput, causing saved and advanced-mode workflows to throw “File is required for move” instead of moving the file.

Knowledge Base Used: Workflow authoring and rendering

}

/*
* The tool takes an id and nothing else, so a picked file resolves
* here by the id every picker selection and in-place upload carries.
* A file object without one is refused with the remedy rather than
* forwarded as a shape the contract rejects.
*/
const fileIds = parseReadFileIds(moveInput)

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When moveFileInput contains a mixed array of IDs and file objects, this mapper silently moves only the first string ID instead of refusing multiple files. Make the array parser reject mixed entries or count all normalized file members before selecting an ID.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/blocks/blocks/file.ts, line 2074:

<comment>When `moveFileInput` contains a mixed array of IDs and file objects, this mapper silently moves only the first string ID instead of refusing multiple files. Make the array parser reject mixed entries or count all normalized file members before selecting an ID.</comment>

<file context>
@@ -2045,8 +2060,35 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
+           * A file object without one is refused with the remedy rather than
+           * forwarded as a shape the contract rejects.
+           */
+          const fileIds = parseReadFileIds(moveInput)
+          if (Array.isArray(fileIds)) {
+            throw new Error('Move File accepts a single file at a time')
</file context>
Fix with cubic

if (Array.isArray(fileIds)) {
throw new Error('Move File accepts a single file at a time')
}
const file = fileIds
? null
: (normalizeFileInput(moveInput, {
single: true,
errorMessage: 'Move File accepts a single file at a time',
}) as Record<string, unknown> | undefined)
const pickedId = typeof file?.id === 'string' ? file.id.trim() : ''
const fileId = fileIds ?? pickedId
if (!fileId) {
throw new Error('Could not determine the file to move; pass its workspace file ID')
}

return {
fileId: optionalText(params.moveFileId),
fileId,
folderPath: optionalText(params.moveTargetRef),
workspaceId: params._context?.workspaceId,
}
Expand Down Expand Up @@ -2293,9 +2335,9 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
type: 'string',
description: 'Folder the file is moved into (move file)',
},
moveFileId: {
type: 'string',
description: 'Canonical ID of the file to move (move file)',
moveFileInput: {
type: 'json',
description: 'Selected workspace file or canonical file ID to move (move file)',
},
restoreFolderId: {
type: 'string',
Expand Down
Loading