Skip to content
Merged
386 changes: 386 additions & 0 deletions apps/sim/app/api/tools/file/manage/route.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/sim/blocks/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ describe.concurrent('Blocks Module', () => {
'file_fetch',
'file_write',
'file_append',
'file_compress',
'file_decompress',
])
expect(block?.tools.config?.tool({ operation: 'file_compress' })).toBe('file_compress')
expect(block?.tools.config?.tool({ operation: 'file_decompress' })).toBe('file_decompress')
expect(block?.subBlocks.find((subBlock) => subBlock.id === 'readFile')?.multiple).toBe(true)
expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read')
expect(block?.tools.config?.tool({ operation: 'file_get_content' })).toBe('file_get_content')
Expand Down
144 changes: 140 additions & 4 deletions apps/sim/blocks/blocks/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,9 +822,9 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
...FileV4Block,
type: 'file_v5',
name: 'File',
description: 'Read, get content, fetch, write, and append files',
description: 'Read, get content, fetch, write, append, compress, and decompress files',
longDescription:
'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.',
'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, or extract a .zip archive into the workspace.',
hideFromToolbar: false,
bestPractices: `
- Read returns workspace file objects in the "files" output and does NOT include their text. Use it to pick files or pass file references downstream (e.g. as attachments).
Expand All @@ -833,6 +833,8 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
- Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way.
- Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token.
- Use Write to create a new workspace file and Append to add content to an existing one.
- 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.
`,
subBlocks: [
{
Expand All @@ -845,6 +847,8 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
{ label: 'Fetch', id: 'file_fetch' },
{ label: 'Write', id: 'file_write' },
{ label: 'Append', id: 'file_append' },
{ label: 'Compress', id: 'file_compress' },
{ label: 'Decompress', id: 'file_decompress' },
],
value: () => 'file_read',
},
Expand Down Expand Up @@ -962,9 +966,67 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
condition: { field: 'operation', value: 'file_append' },
required: { field: 'operation', value: 'file_append' },
},
{
id: 'compressFile',
title: 'Files',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'compressInput',
acceptedTypes: '*',
placeholder: 'Select workspace files',
multiple: true,
mode: 'basic',
condition: { field: 'operation', value: 'file_compress' },
required: { field: 'operation', value: 'file_compress' },
},
{
id: 'compressFileId',
title: 'File ID',
type: 'short-input' as SubBlockType,
canonicalParamId: 'compressInput',
placeholder: 'Workspace file ID or JSON array of IDs',
mode: 'advanced',
condition: { field: 'operation', value: 'file_compress' },
required: { field: 'operation', value: 'file_compress' },
},
{
id: 'archiveName',
title: 'Archive Name',
type: 'short-input' as SubBlockType,
placeholder: 'archive.zip (auto-named from source if omitted)',
condition: { field: 'operation', value: 'file_compress' },
},
{
id: 'decompressFile',
title: 'Archive',
type: 'file-upload' as SubBlockType,
canonicalParamId: 'decompressInput',
acceptedTypes: '.zip',
placeholder: 'Select a .zip archive',
mode: 'basic',
condition: { field: 'operation', value: 'file_decompress' },
required: { field: 'operation', value: 'file_decompress' },
},
{
id: 'decompressFileId',
title: 'File ID',
type: 'short-input' as SubBlockType,
canonicalParamId: 'decompressInput',
placeholder: 'Workspace file ID of the .zip archive',
mode: 'advanced',
condition: { field: 'operation', value: 'file_decompress' },
required: { field: 'operation', value: 'file_decompress' },
},
],
tools: {
access: ['file_read', 'file_get_content', 'file_fetch', 'file_write', 'file_append'],
access: [
'file_read',
'file_get_content',
'file_fetch',
'file_write',
'file_append',
'file_compress',
'file_decompress',
],
config: {
tool: (params) => params.operation || 'file_read',
params: (params) => {
Expand Down Expand Up @@ -1005,6 +1067,70 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
}
}

if (operation === 'file_compress') {
const compressInput = params.compressInput
if (!compressInput) {
throw new Error('File is required for compress')
}

const archiveName =
typeof params.archiveName === 'string' && params.archiveName.trim()
? params.archiveName.trim()
: undefined

const fileIds = parseReadFileIds(compressInput)
if (fileIds) {
return {
fileId: fileIds,
archiveName,
workspaceId: params._context?.workspaceId,
}
}

const normalized = normalizeFileInput(compressInput)
if (!normalized || normalized.length === 0) {
throw new Error('File is required for compress')
}

return {
fileInput: normalized,
archiveName,
workspaceId: params._context?.workspaceId,
}
}

if (operation === 'file_decompress') {
const decompressInput = params.decompressInput
if (!decompressInput) {
throw new Error('File is required for decompress')
}

const fileIds = parseReadFileIds(decompressInput)
if (fileIds) {
const ids = Array.isArray(fileIds) ? fileIds : [fileIds]
if (ids.length > 1) {
throw new Error('Decompress accepts a single .zip archive at a time')
}
return {
fileId: ids[0],
workspaceId: params._context?.workspaceId,
}
Comment thread
waleedlatif1 marked this conversation as resolved.
}

const normalized = normalizeFileInput(decompressInput)
if (!normalized || normalized.length === 0) {
throw new Error('File is required for decompress')
}
if (normalized.length > 1) {
throw new Error('Decompress accepts a single .zip archive at a time')
}

return {
fileInput: normalized[0],
workspaceId: params._context?.workspaceId,
}
}

if (operation === 'file_fetch') {
const fileUrl = resolveHttpFileUrl(params.fileUrl)

Expand Down Expand Up @@ -1089,11 +1215,21 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
contentType: { type: 'string', description: 'MIME content type for write' },
appendFileInput: { type: 'json', description: 'File to append to' },
appendContent: { type: 'string', description: 'Content to append to file' },
compressInput: {
type: 'json',
description: 'Selected workspace files or canonical file IDs to compress',
},
archiveName: { type: 'string', description: 'Name for the compressed .zip archive' },
decompressInput: {
type: 'json',
description: 'Selected .zip archive or canonical file ID to extract',
},
},
outputs: {
files: {
type: 'file[]',
description: 'Workspace file objects (read) or fetched file objects (fetch)',
description:
'Workspace file objects (read), fetched file objects (fetch), the compressed archive (compress), or extracted files (decompress)',
},
contents: {
type: 'array',
Expand Down
25 changes: 25 additions & 0 deletions apps/sim/lib/api/contracts/tools/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,38 @@ export const fileManageContentBodySchema = z
message: 'Either fileId or fileInput is required for content operation',
})

export const fileManageCompressBodySchema = z
.object({
operation: z.literal('compress'),
workspaceId: z.string().min(1).optional(),
fileId: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
fileInput: z.unknown().optional(),
archiveName: z.string().min(1).max(255).optional(),
})
.refine((data) => data.fileId !== undefined || data.fileInput !== undefined, {
message: 'Either fileId or fileInput is required for compress operation',
})

export const fileManageDecompressBodySchema = z
.object({
operation: z.literal('decompress'),
workspaceId: z.string().min(1).optional(),
fileId: z.string().min(1).optional(),
fileInput: z.unknown().optional(),
})
.refine((data) => data.fileId !== undefined || data.fileInput !== undefined, {
message: 'Either fileId or fileInput is required for decompress operation',
})

export const fileManageBodySchema = z.union([
fileManageWriteBodySchema,
fileManageAppendBodySchema,
fileManageGetBodySchema,
fileManageMoveBodySchema,
fileManageReadBodySchema,
fileManageContentBodySchema,
fileManageCompressBodySchema,
fileManageDecompressBodySchema,
])

export const fileManageContract = defineRouteContract({
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/uploads/utils/file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ const EXTENSION_TO_MIME: Record<string, string> = {
yml: 'application/x-yaml',
rtf: 'application/rtf',

// Archives
zip: 'application/zip',
gz: 'application/gzip',

// Code / plain-text source
py: 'text/x-python',
js: 'text/javascript',
Expand Down
120 changes: 120 additions & 0 deletions apps/sim/tools/file/compress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { fileCompressTool, fileDecompressTool } from '@/tools/file/compress'

describe('fileCompressTool', () => {
it('builds a compress request body from file IDs and archive name', () => {
const body = fileCompressTool.request.body?.({
fileId: ['wf_a', 'wf_b'],
archiveName: 'documents.zip',
_context: { workspaceId: 'ws_1' },
} as Parameters<NonNullable<typeof fileCompressTool.request.body>>[0])

expect(body).toMatchObject({
operation: 'compress',
fileId: ['wf_a', 'wf_b'],
archiveName: 'documents.zip',
workspaceId: 'ws_1',
})
})

it('forwards a selected file object when no IDs are provided', () => {
const fileInput = { id: 'wf_c', name: 'report.pdf' }
const body = fileCompressTool.request.body?.({
fileInput,
workspaceId: 'ws_2',
} as Parameters<NonNullable<typeof fileCompressTool.request.body>>[0])

expect(body).toMatchObject({
operation: 'compress',
fileInput,
workspaceId: 'ws_2',
})
})

it('returns the compressed archive on success', async () => {
const archive = {
id: 'wf_zip',
name: 'archive.zip',
size: 1024,
url: 'https://example.com/archive.zip',
type: 'application/zip',
key: 'workspace/ws_1/archive.zip',
}

const result = await fileCompressTool.transformResponse?.(
Response.json({
success: true,
data: {
id: archive.id,
name: archive.name,
size: archive.size,
url: archive.url,
files: [archive],
},
})
)

expect(result).toMatchObject({
success: true,
output: { id: 'wf_zip', name: 'archive.zip', size: 1024, files: [archive] },
})
})

it('propagates route failures as tool failures', async () => {
const result = await fileCompressTool.transformResponse?.(
Response.json({ success: false, error: 'Combined input is too large to compress.' })
)

expect(result).toMatchObject({
success: false,
error: 'Combined input is too large to compress.',
output: {},
})
})
})

describe('fileDecompressTool', () => {
it('builds a decompress request body from a file ID', () => {
const body = fileDecompressTool.request.body?.({
fileId: 'wf_zip',
_context: { workspaceId: 'ws_1' },
} as Parameters<NonNullable<typeof fileDecompressTool.request.body>>[0])

expect(body).toMatchObject({
operation: 'decompress',
fileId: 'wf_zip',
workspaceId: 'ws_1',
})
})

it('returns the extracted files on success', async () => {
const extracted = [
{ id: 'wf_a', name: 'a.txt', url: 'https://example.com/a.txt', key: 'k/a.txt' },
{ id: 'wf_b', name: 'b.txt', url: 'https://example.com/b.txt', key: 'k/b.txt' },
]

const result = await fileDecompressTool.transformResponse?.(
Response.json({ success: true, data: { files: extracted } })
)

expect(result).toMatchObject({
success: true,
output: { files: extracted },
})
})

it('propagates route failures as tool failures', async () => {
const result = await fileDecompressTool.transformResponse?.(
Response.json({ success: false, error: '"data.txt" is not a valid .zip archive' })
)

expect(result).toMatchObject({
success: false,
error: '"data.txt" is not a valid .zip archive',
output: {},
})
})
})
Loading
Loading