diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts new file mode 100644 index 00000000000..9b57cf5c379 --- /dev/null +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -0,0 +1,166 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' +import { + downloadWorkspaceFile, + getWorkspaceFileByName, + updateWorkspaceFileContent, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('FileManageAPI') + +export async function POST(request: NextRequest) { + const auth = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success) { + return NextResponse.json({ success: false, error: auth.error }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const userId = auth.userId || searchParams.get('userId') + + if (!userId) { + return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 }) + } + + let body: Record + try { + body = await request.json() + } catch { + return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 }) + } + + const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 }) + } + + const operation = body.operation as string + + try { + switch (operation) { + case 'write': { + const fileName = body.fileName as string | undefined + const content = body.content as string | undefined + const contentType = body.contentType as string | undefined + + if (!fileName) { + return NextResponse.json( + { success: false, error: 'fileName is required for write operation' }, + { status: 400 } + ) + } + + if (!content && content !== '') { + return NextResponse.json( + { success: false, error: 'content is required for write operation' }, + { status: 400 } + ) + } + + const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) + const fileBuffer = Buffer.from(content ?? '', 'utf-8') + const result = await uploadWorkspaceFile( + workspaceId, + userId, + fileBuffer, + fileName, + mimeType + ) + + logger.info('File created', { + fileId: result.id, + name: fileName, + size: fileBuffer.length, + }) + + return NextResponse.json({ + success: true, + data: { + id: result.id, + name: result.name, + size: fileBuffer.length, + url: ensureAbsoluteUrl(result.url), + }, + }) + } + + case 'append': { + const fileName = body.fileName as string | undefined + const content = body.content as string | undefined + + if (!fileName) { + return NextResponse.json( + { success: false, error: 'fileName is required for append operation' }, + { status: 400 } + ) + } + + if (!content && content !== '') { + return NextResponse.json( + { success: false, error: 'content is required for append operation' }, + { status: 400 } + ) + } + + const existing = await getWorkspaceFileByName(workspaceId, fileName) + if (!existing) { + return NextResponse.json( + { success: false, error: `File not found: "${fileName}"` }, + { status: 404 } + ) + } + + const lockKey = `file-append:${workspaceId}:${existing.id}` + const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}` + const acquired = await acquireLock(lockKey, lockValue, 30) + if (!acquired) { + return NextResponse.json( + { success: false, error: 'File is busy, please retry' }, + { status: 409 } + ) + } + + try { + const existingBuffer = await downloadWorkspaceFile(existing) + const finalContent = existingBuffer.toString('utf-8') + content + const fileBuffer = Buffer.from(finalContent, 'utf-8') + await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer) + + logger.info('File appended', { + fileId: existing.id, + name: existing.name, + size: fileBuffer.length, + }) + + return NextResponse.json({ + success: true, + data: { + id: existing.id, + name: existing.name, + size: fileBuffer.length, + url: ensureAbsoluteUrl(existing.path), + }, + }) + } finally { + await releaseLock(lockKey, lockValue) + } + } + + default: + return NextResponse.json( + { success: false, error: `Unknown operation: ${operation}. Supported: write, append` }, + { status: 400 } + ) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('File operation failed', { operation, error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 9e6b14b7d9b..bf377179645 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -250,9 +250,9 @@ export const FileV2Block: BlockConfig = { export const FileV3Block: BlockConfig = { type: 'file_v3', name: 'File', - description: 'Read and parse multiple files', + description: 'Read and write workspace files', longDescription: - 'Upload files directly or import from external URLs to get UserFile objects for use in other blocks.', + 'Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.', docsLink: 'https://docs.sim.ai/tools/file', category: 'tools', integrationType: IntegrationType.FileStorage, @@ -260,6 +260,17 @@ export const FileV3Block: BlockConfig = { bgColor: '#40916C', icon: DocumentIcon, subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown' as SubBlockType, + options: [ + { label: 'Read', id: 'file_parser_v3' }, + { label: 'Write', id: 'file_write' }, + { label: 'Append', id: 'file_append' }, + ], + value: () => 'file_parser_v3', + }, { id: 'file', title: 'Files', @@ -270,7 +281,8 @@ export const FileV3Block: BlockConfig = { multiple: true, mode: 'basic', maxSize: 100, - required: true, + required: { field: 'operation', value: 'file_parser_v3' }, + condition: { field: 'operation', value: 'file_parser_v3' }, }, { id: 'fileUrl', @@ -279,15 +291,84 @@ export const FileV3Block: BlockConfig = { canonicalParamId: 'fileInput', placeholder: 'https://example.com/document.pdf', mode: 'advanced', - required: true, + required: { field: 'operation', value: 'file_parser_v3' }, + condition: { field: 'operation', value: 'file_parser_v3' }, + }, + { + id: 'fileName', + title: 'File Name', + type: 'short-input' as SubBlockType, + placeholder: 'File name (e.g., data.csv)', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'File content to write...', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'contentType', + title: 'Content Type', + type: 'short-input' as SubBlockType, + placeholder: 'text/plain (auto-detected from extension)', + condition: { field: 'operation', value: 'file_write' }, + mode: 'advanced', + }, + { + id: 'appendFileName', + title: 'File', + type: 'dropdown' as SubBlockType, + placeholder: 'Select a workspace file...', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + options: [], + fetchOptions: async () => { + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return [] + const response = await fetch(`/api/workspaces/${workspaceId}/files`) + const data = await response.json() + if (!data.success || !data.files) return [] + return data.files.map((f: { name: string }) => ({ label: f.name, id: f.name })) + }, + }, + { + id: 'appendContent', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'Content to append...', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, }, ], tools: { - access: ['file_parser_v3'], + access: ['file_parser_v3', 'file_write', 'file_append'], config: { - tool: () => 'file_parser_v3', + tool: (params) => params.operation || 'file_parser_v3', params: (params) => { - // Use canonical 'fileInput' param directly + const operation = params.operation || 'file_parser_v3' + + if (operation === 'file_write') { + return { + fileName: params.fileName, + content: params.content, + contentType: params.contentType, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_append') { + return { + fileName: params.appendFileName, + content: params.appendContent, + workspaceId: params._context?.workspaceId, + } + } + const fileInput = params.fileInput if (!fileInput) { logger.error('No file input provided') @@ -326,17 +407,39 @@ export const FileV3Block: BlockConfig = { }, }, inputs: { - fileInput: { type: 'json', description: 'File input (canonical param)' }, - fileType: { type: 'string', description: 'File type' }, + operation: { type: 'string', description: 'Operation to perform (read, write, or append)' }, + fileInput: { type: 'json', description: 'File input for read (canonical param)' }, + fileType: { type: 'string', description: 'File type for read' }, + fileName: { type: 'string', description: 'Name for a new file (write)' }, + content: { type: 'string', description: 'File content to write' }, + contentType: { type: 'string', description: 'MIME content type for write' }, + appendFileName: { type: 'string', description: 'Name of existing file to append to' }, + appendContent: { type: 'string', description: 'Content to append to file' }, }, outputs: { files: { type: 'file[]', - description: 'Parsed files as UserFile objects', + description: 'Parsed files as UserFile objects (read)', }, combinedContent: { type: 'string', - description: 'All file contents merged into a single text string', + description: 'All file contents merged into a single text string (read)', + }, + id: { + type: 'string', + description: 'File ID (write)', + }, + name: { + type: 'string', + description: 'File name (write)', + }, + size: { + type: 'number', + description: 'File size in bytes (write)', + }, + url: { + type: 'string', + description: 'URL to access the file (write)', }, }, } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index e247231c411..1c69405ffe9 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -15,8 +15,10 @@ import { import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { getPostgresErrorCode } from '@/lib/core/utils/pg-error' import { generateRestoreName } from '@/lib/core/utils/restore-name' +import { getServePathPrefix } from '@/lib/uploads' import { downloadFile, hasCloudStorage, uploadFile } from '@/lib/uploads/core/storage-service' import { getFileMetadataByKey, insertFileMetadata } from '@/lib/uploads/server/metadata' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { isUuid, sanitizeFileName } from '@/executor/constants' import type { UserFile } from '@/executor/types' @@ -221,7 +223,6 @@ export async function uploadWorkspaceFile( logger.error(`Failed to update storage tracking:`, storageError) } - const { getServePathPrefix } = await import('@/lib/uploads') const pathPrefix = getServePathPrefix() const serveUrl = `${pathPrefix}${encodeURIComponent(uploadResult.key)}?context=workspace` @@ -336,6 +337,47 @@ export async function fileExistsInWorkspace( } } +/** + * Look up a single active workspace file by its original name. + * Returns the record if found, or null if no matching file exists. + * Throws on DB errors so callers can distinguish "not found" from "lookup failed." + */ +export async function getWorkspaceFileByName( + workspaceId: string, + fileName: string +): Promise { + const files = await db + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.originalName, fileName), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + + if (files.length === 0) return null + + const pathPrefix = getServePathPrefix() + + const file = files[0] + return { + id: file.id, + workspaceId: file.workspaceId || workspaceId, + name: file.originalName, + key: file.key, + path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`, + size: file.size, + type: file.contentType, + uploadedBy: file.userId, + deletedAt: file.deletedAt, + uploadedAt: file.uploadedAt, + } +} + /** * List all files for a workspace */ @@ -368,7 +410,6 @@ export async function listWorkspaceFiles( ) .orderBy(workspaceFiles.uploadedAt) - const { getServePathPrefix } = await import('@/lib/uploads') const pathPrefix = getServePathPrefix() return files.map((file) => ({ @@ -493,7 +534,6 @@ export async function getWorkspaceFile( if (files.length === 0) return null - const { getServePathPrefix } = await import('@/lib/uploads') const pathPrefix = getServePathPrefix() const file = files[0] @@ -731,7 +771,6 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): throw new Error('File is not archived') } - const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { throw new Error('Cannot restore file into an archived workspace') diff --git a/apps/sim/tools/file/append.ts b/apps/sim/tools/file/append.ts new file mode 100644 index 00000000000..63ac9eaa99e --- /dev/null +++ b/apps/sim/tools/file/append.ts @@ -0,0 +1,58 @@ +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileAppendParams { + fileName: string + content: string + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileAppendTool: ToolConfig = { + id: 'file_append', + name: 'File Append', + description: + 'Append content to an existing workspace file. The file must already exist. Content is added to the end of the file.', + version: '1.0.0', + + params: { + fileName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of an existing workspace file to append to.', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The text content to append to the file.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'append', + fileName: params.fileName, + content: params.content, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to append to file' } + } + return { success: true, output: data.data } + }, + + outputs: { + id: { type: 'string', description: 'File ID' }, + name: { type: 'string', description: 'File name' }, + size: { type: 'number', description: 'File size in bytes' }, + url: { type: 'string', description: 'URL to access the file', optional: true }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 6714c7dddc4..2a60ea594f1 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -1,5 +1,8 @@ import { fileParserTool, fileParserV2Tool, fileParserV3Tool } from '@/tools/file/parser' +export { fileAppendTool } from '@/tools/file/append' +export { fileWriteTool } from '@/tools/file/write' + export const fileParseTool = fileParserTool export { fileParserV2Tool } export { fileParserV3Tool } diff --git a/apps/sim/tools/file/write.ts b/apps/sim/tools/file/write.ts new file mode 100644 index 00000000000..cf49b9e735b --- /dev/null +++ b/apps/sim/tools/file/write.ts @@ -0,0 +1,68 @@ +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +interface FileWriteParams { + fileName: string + content: string + contentType?: string + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileWriteTool: ToolConfig = { + id: 'file_write', + name: 'File Write', + description: + 'Create a new workspace file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv").', + version: '1.0.0', + + params: { + fileName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'File name (e.g., "data.csv"). If a file with this name exists, a numeric suffix is added automatically.', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The text content to write to the file.', + }, + contentType: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'MIME type for new files (e.g., "text/plain"). Auto-detected from file extension if omitted.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'write', + fileName: params.fileName, + content: params.content, + contentType: params.contentType, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to write file' } + } + return { success: true, output: data.data } + }, + + outputs: { + id: { type: 'string', description: 'File ID' }, + name: { type: 'string', description: 'File name' }, + size: { type: 'number', description: 'File size in bytes' }, + url: { type: 'string', description: 'URL to access the file', optional: true }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 66d70a07f7b..2ba6281a52d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -498,7 +498,13 @@ import { fathomListTeamMembersTool, fathomListTeamsTool, } from '@/tools/fathom' -import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file' +import { + fileAppendTool, + fileParserV2Tool, + fileParserV3Tool, + fileParseTool, + fileWriteTool, +} from '@/tools/file' import { firecrawlAgentTool, firecrawlCrawlTool, @@ -2603,6 +2609,8 @@ export const tools: Record = { file_parser: fileParseTool, file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, + file_append: fileAppendTool, + file_write: fileWriteTool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool, firecrawl_crawl: firecrawlCrawlTool,