From c1e4f019b697f0b14dd3495dae9e9bd38c4a2167 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 18 Mar 2026 19:42:06 -0700 Subject: [PATCH 1/6] Add file write and delete operations --- .../app/api/tools/sim-file/manage/route.ts | 203 ++++++++++++++++++ apps/sim/blocks/blocks/file.ts | 115 ++++++++-- .../workspace/workspace-file-manager.ts | 46 ++++ apps/sim/tools/file/delete.ts | 46 ++++ apps/sim/tools/file/index.ts | 3 + apps/sim/tools/file/write.ts | 84 ++++++++ apps/sim/tools/registry.ts | 10 +- 7 files changed, 493 insertions(+), 14 deletions(-) create mode 100644 apps/sim/app/api/tools/sim-file/manage/route.ts create mode 100644 apps/sim/tools/file/delete.ts create mode 100644 apps/sim/tools/file/write.ts diff --git a/apps/sim/app/api/tools/sim-file/manage/route.ts b/apps/sim/app/api/tools/sim-file/manage/route.ts new file mode 100644 index 00000000000..fcc4e3d681c --- /dev/null +++ b/apps/sim/app/api/tools/sim-file/manage/route.ts @@ -0,0 +1,203 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + deleteWorkspaceFile, + downloadWorkspaceFile, + getWorkspaceFile, + getWorkspaceFileByName, + updateWorkspaceFileContent, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SimFileManageAPI') + +const EXT_TO_MIME: Record = { + '.txt': 'text/plain', + '.md': 'text/markdown', + '.html': 'text/html', + '.json': 'application/json', + '.csv': 'text/csv', + '.xml': 'application/xml', + '.yaml': 'application/x-yaml', + '.yml': 'application/x-yaml', +} + +function inferContentType(fileName: string, explicitType?: string): string { + if (explicitType) return explicitType + const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() + return EXT_TO_MIME[ext] || 'text/plain' +} + +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 fileId = body.fileId as string | undefined + const content = body.content as string | undefined + const contentType = body.contentType as string | undefined + const append = Boolean(body.append) + + if (!content && content !== '') { + return NextResponse.json( + { success: false, error: 'content is required for write operation' }, + { status: 400 } + ) + } + + if (fileName && !fileId) { + if (append) { + const existing = await getWorkspaceFileByName(workspaceId, fileName) + if (existing) { + const existingBuffer = await downloadWorkspaceFile(existing) + const existingContent = existingBuffer.toString('utf-8') + const finalContent = existingContent + content + const fileBuffer = Buffer.from(finalContent, 'utf-8') + await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer) + + logger.info('File appended by name', { + fileId: existing.id, + name: existing.name, + size: fileBuffer.length, + }) + + return NextResponse.json({ + success: true, + data: { id: existing.id, name: existing.name, size: fileBuffer.length }, + }) + } + } + + const mimeType = inferContentType(fileName, contentType) + 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: result.url }, + }) + } + + if (fileId) { + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json( + { success: false, error: `File with ID "${fileId}" not found` }, + { status: 404 } + ) + } + + let finalContent: string + if (append) { + const existingBuffer = await downloadWorkspaceFile(fileRecord) + const existingContent = existingBuffer.toString('utf-8') + finalContent = existingContent + content + } else { + finalContent = content ?? '' + } + + const fileBuffer = Buffer.from(finalContent, 'utf-8') + await updateWorkspaceFileContent(workspaceId, fileId, userId, fileBuffer) + + logger.info('Sim file written', { + fileId, + name: fileRecord.name, + size: fileBuffer.length, + append, + }) + + return NextResponse.json({ + success: true, + data: { id: fileId, name: fileRecord.name, size: fileBuffer.length }, + }) + } + + return NextResponse.json( + { + success: false, + error: 'Either fileName (to create) or fileId (to update) is required', + }, + { status: 400 } + ) + } + + case 'delete': { + const fileId = body.fileId as string | undefined + if (!fileId) { + return NextResponse.json( + { success: false, error: 'fileId is required for delete operation' }, + { status: 400 } + ) + } + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json( + { success: false, error: `File with ID "${fileId}" not found` }, + { status: 404 } + ) + } + + await deleteWorkspaceFile(workspaceId, fileId) + + logger.info('Sim file deleted', { fileId, name: fileRecord.name }) + + return NextResponse.json({ + success: true, + data: { id: fileId, name: fileRecord.name }, + }) + } + + default: + return NextResponse.json( + { success: false, error: `Unknown operation: ${operation}. Supported: write, delete` }, + { status: 400 } + ) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Sim 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 f9b3058f254..620def5b3f4 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -247,14 +247,25 @@ export const FileV2Block: BlockConfig = { export const FileV3Block: BlockConfig = { type: 'file_v3', name: 'File', - description: 'Read and parse multiple files', + description: 'Read, write, or delete 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 or update existing workspace resource files (with optional append), or delete workspace files.', docsLink: 'https://docs.sim.ai/tools/file', category: 'tools', 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: 'Delete', id: 'file_delete' }, + ], + value: () => 'file_parser_v3', + }, { id: 'file', title: 'Files', @@ -265,7 +276,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', @@ -274,22 +286,78 @@ 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: 'New file name (e.g., data.csv)', + condition: { field: 'operation', value: 'file_write' }, + }, + { + id: 'fileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + placeholder: 'Existing file ID', + condition: { field: 'operation', value: ['file_write', 'file_delete'] }, + required: { field: 'operation', value: 'file_delete' }, + }, + { + 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: 'append', + title: 'Append', + type: 'switch' as SubBlockType, + condition: { 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', }, ], tools: { - access: ['file_parser_v3'], + access: ['file_parser_v3', 'file_write', 'file_delete'], 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, + fileId: params.fileId, + content: params.content, + contentType: params.contentType, + append: Boolean(params.append), + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_delete') { + return { + fileId: params.fileId, + workspaceId: params._context?.workspaceId, + } + } + const fileInput = params.fileInput if (!fileInput) { logger.error('No file input provided') throw new Error('File input is required') } - // First, try to normalize as file objects (handles JSON strings from advanced mode) const normalizedFiles = normalizeFileInput(fileInput) if (normalizedFiles) { const filePaths = resolveFilePathsFromInput(normalizedFiles) @@ -304,7 +372,6 @@ export const FileV3Block: BlockConfig = { } } - // If normalization fails, treat as direct URL string if (typeof fileInput === 'string' && fileInput.trim()) { return { filePath: fileInput.trim(), @@ -321,17 +388,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, delete)' }, + 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)' }, + fileId: { type: 'string', description: 'ID of an existing file (write/delete)' }, + content: { type: 'string', description: 'File content to write' }, + contentType: { type: 'string', description: 'MIME content type for write' }, + append: { type: 'string', description: 'Whether to append content (write)' }, }, 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/delete)', + }, + name: { + type: 'string', + description: 'File name (write/delete)', + }, + 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 4d18638d3ff..9d43304c2cc 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -278,6 +278,52 @@ export async function fileExistsInWorkspace( } } +/** + * Look up a single active workspace file by its original name. + * Returns the record if found, or null otherwise. + */ +export async function getWorkspaceFileByName( + workspaceId: string, + fileName: string +): Promise { + try { + 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 { getServePathPrefix } = await import('@/lib/uploads') + 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, + } + } catch (error) { + logger.error(`Failed to get workspace file by name "${fileName}":`, error) + return null + } +} + /** * List all files for a workspace */ diff --git a/apps/sim/tools/file/delete.ts b/apps/sim/tools/file/delete.ts new file mode 100644 index 00000000000..cc8a7c2f49d --- /dev/null +++ b/apps/sim/tools/file/delete.ts @@ -0,0 +1,46 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface FileDeleteParams { + fileId: string + workspaceId?: string +} + +export const fileDeleteTool: ToolConfig = { + id: 'file_delete', + name: 'File Delete', + description: 'Delete a workspace resource file by its ID (soft delete).', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the file to delete.', + }, + }, + + request: { + url: '/api/tools/sim-file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'delete', + fileId: params.fileId, + workspaceId: params.workspaceId || (params as Record)._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to delete file' } + } + return { success: true, output: data.data } + }, + + outputs: { + id: { type: 'string', description: 'Deleted file ID' }, + name: { type: 'string', description: 'Deleted file name' }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 6714c7dddc4..485e5b13940 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 { fileDeleteTool } from '@/tools/file/delete' +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..a80323e57b2 --- /dev/null +++ b/apps/sim/tools/file/write.ts @@ -0,0 +1,84 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface FileWriteParams { + fileName?: string + fileId?: string + content: string + contentType?: string + append?: boolean + workspaceId?: string +} + +export const fileWriteTool: ToolConfig = { + id: 'file_write', + name: 'File Write', + description: + 'Write content to a workspace resource file. Provide fileName to create a new file, or fileId to update an existing one. Use append mode to add content to the end of an existing file.', + version: '1.0.0', + + params: { + fileName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name for a new file (e.g., "data.csv"). Provide this to create a new file.', + }, + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of an existing file to update. Provide this to write to an existing file.', + }, + 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.', + }, + append: { + type: 'boolean', + required: false, + default: false, + visibility: 'user-only', + description: + 'When true, appends content to the end of an existing file instead of replacing it.', + }, + }, + + request: { + url: '/api/tools/sim-file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'write', + fileName: params.fileName, + fileId: params.fileId, + content: params.content, + contentType: params.contentType, + append: params.append ?? false, + workspaceId: params.workspaceId || (params as Record)._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 4c7f2af33ad..dbda01d6fb4 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -454,7 +454,13 @@ import { fathomListTeamMembersTool, fathomListTeamsTool, } from '@/tools/fathom' -import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file' +import { + fileDeleteTool, + fileParserV2Tool, + fileParserV3Tool, + fileParseTool, + fileWriteTool, +} from '@/tools/file' import { firecrawlAgentTool, firecrawlCrawlTool, @@ -2416,6 +2422,8 @@ export const tools: Record = { file_parser: fileParseTool, file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, + file_write: fileWriteTool, + file_delete: fileDeleteTool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool, firecrawl_crawl: firecrawlCrawlTool, From 7d0baa4825a0067b8b53afd033c2689fa470aa29 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 18 Mar 2026 19:49:34 -0700 Subject: [PATCH 2/6] Add file block write operation --- .../app/api/tools/sim-file/manage/route.ts | 71 +++++++------------ apps/sim/blocks/blocks/file.ts | 29 +++----- apps/sim/lib/core/config/feature-flags.ts | 6 +- apps/sim/tools/file/delete.ts | 46 ------------ apps/sim/tools/file/index.ts | 1 - apps/sim/tools/file/write.ts | 7 +- apps/sim/tools/registry.ts | 2 - 7 files changed, 41 insertions(+), 121 deletions(-) delete mode 100644 apps/sim/tools/file/delete.ts diff --git a/apps/sim/app/api/tools/sim-file/manage/route.ts b/apps/sim/app/api/tools/sim-file/manage/route.ts index fcc4e3d681c..6b2be49b152 100644 --- a/apps/sim/app/api/tools/sim-file/manage/route.ts +++ b/apps/sim/app/api/tools/sim-file/manage/route.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { - deleteWorkspaceFile, downloadWorkspaceFile, getWorkspaceFile, getWorkspaceFileByName, @@ -75,26 +74,31 @@ export async function POST(request: NextRequest) { } if (fileName && !fileId) { - if (append) { - const existing = await getWorkspaceFileByName(workspaceId, fileName) - if (existing) { + const existing = await getWorkspaceFileByName(workspaceId, fileName) + + if (existing) { + let finalContent: string + if (append) { const existingBuffer = await downloadWorkspaceFile(existing) - const existingContent = existingBuffer.toString('utf-8') - const finalContent = existingContent + content - const fileBuffer = Buffer.from(finalContent, 'utf-8') - await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer) - - logger.info('File appended by name', { - fileId: existing.id, - name: existing.name, - size: fileBuffer.length, - }) - - return NextResponse.json({ - success: true, - data: { id: existing.id, name: existing.name, size: fileBuffer.length }, - }) + finalContent = existingBuffer.toString('utf-8') + content + } else { + finalContent = content ?? '' } + + const fileBuffer = Buffer.from(finalContent, 'utf-8') + await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer) + + logger.info('File overwritten by name', { + fileId: existing.id, + name: existing.name, + size: fileBuffer.length, + append, + }) + + return NextResponse.json({ + success: true, + data: { id: existing.id, name: existing.name, size: fileBuffer.length }, + }) } const mimeType = inferContentType(fileName, contentType) @@ -162,36 +166,9 @@ export async function POST(request: NextRequest) { ) } - case 'delete': { - const fileId = body.fileId as string | undefined - if (!fileId) { - return NextResponse.json( - { success: false, error: 'fileId is required for delete operation' }, - { status: 400 } - ) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json( - { success: false, error: `File with ID "${fileId}" not found` }, - { status: 404 } - ) - } - - await deleteWorkspaceFile(workspaceId, fileId) - - logger.info('Sim file deleted', { fileId, name: fileRecord.name }) - - return NextResponse.json({ - success: true, - data: { id: fileId, name: fileRecord.name }, - }) - } - default: return NextResponse.json( - { success: false, error: `Unknown operation: ${operation}. Supported: write, delete` }, + { success: false, error: `Unknown operation: ${operation}. Supported: write` }, { status: 400 } ) } diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index 620def5b3f4..5487486e635 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -247,9 +247,9 @@ export const FileV2Block: BlockConfig = { export const FileV3Block: BlockConfig = { type: 'file_v3', name: 'File', - description: 'Read, write, or delete workspace files', + description: 'Read and write workspace files', longDescription: - 'Read and parse files from uploads or URLs, write new or update existing workspace resource files (with optional append), or delete workspace files.', + 'Read and parse files from uploads or URLs, or write workspace resource files. Writing by name creates the file if it does not exist, or overwrites it if it does. Use append mode to add content to existing files.', docsLink: 'https://docs.sim.ai/tools/file', category: 'tools', bgColor: '#40916C', @@ -262,7 +262,6 @@ export const FileV3Block: BlockConfig = { options: [ { label: 'Read', id: 'file_parser_v3' }, { label: 'Write', id: 'file_write' }, - { label: 'Delete', id: 'file_delete' }, ], value: () => 'file_parser_v3', }, @@ -293,16 +292,15 @@ export const FileV3Block: BlockConfig = { id: 'fileName', title: 'File Name', type: 'short-input' as SubBlockType, - placeholder: 'New file name (e.g., data.csv)', + placeholder: 'File name (e.g., data.csv) — overwrites if exists', condition: { field: 'operation', value: 'file_write' }, }, { id: 'fileId', title: 'File ID', type: 'short-input' as SubBlockType, - placeholder: 'Existing file ID', - condition: { field: 'operation', value: ['file_write', 'file_delete'] }, - required: { field: 'operation', value: 'file_delete' }, + placeholder: 'Existing file ID to update', + condition: { field: 'operation', value: 'file_write' }, }, { id: 'content', @@ -328,7 +326,7 @@ export const FileV3Block: BlockConfig = { }, ], tools: { - access: ['file_parser_v3', 'file_write', 'file_delete'], + access: ['file_parser_v3', 'file_write'], config: { tool: (params) => params.operation || 'file_parser_v3', params: (params) => { @@ -345,13 +343,6 @@ export const FileV3Block: BlockConfig = { } } - if (operation === 'file_delete') { - return { - fileId: params.fileId, - workspaceId: params._context?.workspaceId, - } - } - const fileInput = params.fileInput if (!fileInput) { logger.error('No file input provided') @@ -388,11 +379,11 @@ export const FileV3Block: BlockConfig = { }, }, inputs: { - operation: { type: 'string', description: 'Operation to perform (read, write, delete)' }, + operation: { type: 'string', description: 'Operation to perform (read or write)' }, 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)' }, - fileId: { type: 'string', description: 'ID of an existing file (write/delete)' }, + fileId: { type: 'string', description: 'ID of an existing file to update (write)' }, content: { type: 'string', description: 'File content to write' }, contentType: { type: 'string', description: 'MIME content type for write' }, append: { type: 'string', description: 'Whether to append content (write)' }, @@ -408,11 +399,11 @@ export const FileV3Block: BlockConfig = { }, id: { type: 'string', - description: 'File ID (write/delete)', + description: 'File ID (write)', }, name: { type: 'string', - description: 'File name (write/delete)', + description: 'File name (write)', }, size: { type: 'number', diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index b1e3b148d61..474651ffd46 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application */ -export const isHosted = - getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || - getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' +export const isHosted = true; + // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || + // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' /** * Is billing enforcement enabled diff --git a/apps/sim/tools/file/delete.ts b/apps/sim/tools/file/delete.ts deleted file mode 100644 index cc8a7c2f49d..00000000000 --- a/apps/sim/tools/file/delete.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface FileDeleteParams { - fileId: string - workspaceId?: string -} - -export const fileDeleteTool: ToolConfig = { - id: 'file_delete', - name: 'File Delete', - description: 'Delete a workspace resource file by its ID (soft delete).', - version: '1.0.0', - - params: { - fileId: { - type: 'string', - required: true, - visibility: 'user-or-llm', - description: 'ID of the file to delete.', - }, - }, - - request: { - url: '/api/tools/sim-file/manage', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: (params) => ({ - operation: 'delete', - fileId: params.fileId, - workspaceId: params.workspaceId || (params as Record)._context?.workspaceId, - }), - }, - - transformResponse: async (response) => { - const data = await response.json() - if (!response.ok || !data.success) { - return { success: false, output: {}, error: data.error || 'Failed to delete file' } - } - return { success: true, output: data.data } - }, - - outputs: { - id: { type: 'string', description: 'Deleted file ID' }, - name: { type: 'string', description: 'Deleted file name' }, - }, -} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index 485e5b13940..1a5e6dfd542 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -1,6 +1,5 @@ import { fileParserTool, fileParserV2Tool, fileParserV3Tool } from '@/tools/file/parser' -export { fileDeleteTool } from '@/tools/file/delete' export { fileWriteTool } from '@/tools/file/write' export const fileParseTool = fileParserTool diff --git a/apps/sim/tools/file/write.ts b/apps/sim/tools/file/write.ts index a80323e57b2..ef0a8d3a71a 100644 --- a/apps/sim/tools/file/write.ts +++ b/apps/sim/tools/file/write.ts @@ -13,7 +13,7 @@ export const fileWriteTool: ToolConfig = { id: 'file_write', name: 'File Write', description: - 'Write content to a workspace resource file. Provide fileName to create a new file, or fileId to update an existing one. Use append mode to add content to the end of an existing file.', + 'Write content to a workspace resource file. Provide fileName to create a new file or overwrite an existing one with the same name. Provide fileId to update a specific file. Use append mode to add content to the end instead of replacing.', version: '1.0.0', params: { @@ -21,7 +21,8 @@ export const fileWriteTool: ToolConfig = { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Name for a new file (e.g., "data.csv"). Provide this to create a new file.', + description: + 'File name (e.g., "data.csv"). Creates the file if it does not exist, or overwrites/appends to it if it does.', }, fileId: { type: 'string', @@ -48,7 +49,7 @@ export const fileWriteTool: ToolConfig = { default: false, visibility: 'user-only', description: - 'When true, appends content to the end of an existing file instead of replacing it.', + 'When true, appends content to the end of an existing file. When false (default), replaces the file content entirely.', }, }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index dbda01d6fb4..51024f9ae31 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -455,7 +455,6 @@ import { fathomListTeamsTool, } from '@/tools/fathom' import { - fileDeleteTool, fileParserV2Tool, fileParserV3Tool, fileParseTool, @@ -2423,7 +2422,6 @@ export const tools: Record = { file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, file_write: fileWriteTool, - file_delete: fileDeleteTool, firecrawl_scrape: firecrawlScrapeTool, firecrawl_search: firecrawlSearchTool, firecrawl_crawl: firecrawlCrawlTool, From fdb6039cdba8ee666cb7464fde26ba6bb5013b7f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 18 Mar 2026 19:51:26 -0700 Subject: [PATCH 3/6] Fix lint --- apps/sim/lib/core/config/feature-flags.ts | 6 +++--- apps/sim/tools/registry.ts | 7 +------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 474651ffd46..b1e3b148d61 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test' /** * Is this the hosted version of the application */ -export const isHosted = true; - // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || - // getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' +export const isHosted = + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || + getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' /** * Is billing enforcement enabled diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 51024f9ae31..3d33db8a6b4 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -454,12 +454,7 @@ import { fathomListTeamMembersTool, fathomListTeamsTool, } from '@/tools/fathom' -import { - fileParserV2Tool, - fileParserV3Tool, - fileParseTool, - fileWriteTool, -} from '@/tools/file' +import { fileParserV2Tool, fileParserV3Tool, fileParseTool, fileWriteTool } from '@/tools/file' import { firecrawlAgentTool, firecrawlCrawlTool, From 031e12fffdbf0374ac3d43167859c16054a8a384 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 19 Mar 2026 10:28:54 -0700 Subject: [PATCH 4/6] --wip-- [skip ci] --- .../tools/{sim-file => file}/manage/route.ts | 26 +----- .../tools/server/files/workspace-file.ts | 17 +--- .../workspace/workspace-file-manager.ts | 92 +++++++++---------- apps/sim/tools/file/write.ts | 2 +- 4 files changed, 50 insertions(+), 87 deletions(-) rename apps/sim/app/api/tools/{sim-file => file}/manage/route.ts (87%) diff --git a/apps/sim/app/api/tools/sim-file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts similarity index 87% rename from apps/sim/app/api/tools/sim-file/manage/route.ts rename to apps/sim/app/api/tools/file/manage/route.ts index 6b2be49b152..d7573f9cb46 100644 --- a/apps/sim/app/api/tools/sim-file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -8,27 +8,11 @@ import { 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('SimFileManageAPI') - -const EXT_TO_MIME: Record = { - '.txt': 'text/plain', - '.md': 'text/markdown', - '.html': 'text/html', - '.json': 'application/json', - '.csv': 'text/csv', - '.xml': 'application/xml', - '.yaml': 'application/x-yaml', - '.yml': 'application/x-yaml', -} - -function inferContentType(fileName: string, explicitType?: string): string { - if (explicitType) return explicitType - const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() - return EXT_TO_MIME[ext] || 'text/plain' -} +const logger = createLogger('FileManageAPI') export async function POST(request: NextRequest) { const auth = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -101,7 +85,7 @@ export async function POST(request: NextRequest) { }) } - const mimeType = inferContentType(fileName, contentType) + const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName)) const fileBuffer = Buffer.from(content ?? '', 'utf-8') const result = await uploadWorkspaceFile( workspaceId, @@ -144,7 +128,7 @@ export async function POST(request: NextRequest) { const fileBuffer = Buffer.from(finalContent, 'utf-8') await updateWorkspaceFileContent(workspaceId, fileId, userId, fileBuffer) - logger.info('Sim file written', { + logger.info('File written', { fileId, name: fileRecord.name, size: fileBuffer.length, @@ -174,7 +158,7 @@ export async function POST(request: NextRequest) { } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' - logger.error('Sim file operation failed', { operation, error: message }) + logger.error('File operation failed', { operation, error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } } diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 75c6abe6110..1724247288b 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -8,23 +8,10 @@ import { updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' const logger = createLogger('WorkspaceFileServerTool') -const EXT_TO_MIME: Record = { - '.txt': 'text/plain', - '.md': 'text/markdown', - '.html': 'text/html', - '.json': 'application/json', - '.csv': 'text/csv', -} - -function inferContentType(fileName: string, explicitType?: string): string { - if (explicitType) return explicitType - const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() - return EXT_TO_MIME[ext] || 'text/plain' -} - export const workspaceFileServerTool: BaseServerTool = { name: 'workspace_file', async execute( @@ -58,7 +45,7 @@ export const workspaceFileServerTool: BaseServerTool { - try { - const existing = await db - .select() - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.originalName, fileName), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) + const existing = await db + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.originalName, fileName), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) ) - .limit(1) + ) + .limit(1) - return existing.length > 0 - } catch (error) { - logger.error(`Failed to check file existence for ${fileName}:`, error) - return false - } + return existing.length > 0 } /** * Look up a single active workspace file by its original name. - * Returns the record if found, or null otherwise. + * 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 { - try { - const files = await db - .select() - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.originalName, fileName), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) + 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) + ) + .limit(1) - if (files.length === 0) return null + if (files.length === 0) return null - const { getServePathPrefix } = await import('@/lib/uploads') - const pathPrefix = getServePathPrefix() + const { getServePathPrefix } = await import('@/lib/uploads') + 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, - } - } catch (error) { - logger.error(`Failed to get workspace file by name "${fileName}":`, error) - return null + 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, } } diff --git a/apps/sim/tools/file/write.ts b/apps/sim/tools/file/write.ts index ef0a8d3a71a..98e46d1b895 100644 --- a/apps/sim/tools/file/write.ts +++ b/apps/sim/tools/file/write.ts @@ -54,7 +54,7 @@ export const fileWriteTool: ToolConfig = { }, request: { - url: '/api/tools/sim-file/manage', + url: '/api/tools/file/manage', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ From 80cba4d57bdb8894c2a55d57cd129d8496641744 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 23 Mar 2026 15:07:09 -0700 Subject: [PATCH 5/6] Allow loop-in-loop workflow edits --- .../workflow/edit-workflow/operations.test.ts | 145 +++++ .../workflow/edit-workflow/operations.ts | 586 +++++++++--------- 2 files changed, 442 insertions(+), 289 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts index 2a9248ed6b5..bbc1339ad4a 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts @@ -134,6 +134,66 @@ function makeLoopWorkflow() { } } +function makeNestedLoopWorkflow() { + return { + blocks: { + 'outer-loop': { + id: 'outer-loop', + type: 'loop', + name: 'Outer Loop', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: {}, + outputs: {}, + data: { loopType: 'for', count: 2 }, + }, + 'inner-loop': { + id: 'inner-loop', + type: 'loop', + name: 'Inner Loop', + position: { x: 120, y: 80 }, + enabled: true, + subBlocks: {}, + outputs: {}, + data: { parentId: 'outer-loop', extent: 'parent', loopType: 'for', count: 3 }, + }, + 'inner-agent': { + id: 'inner-agent', + type: 'agent', + name: 'Inner Agent', + position: { x: 240, y: 120 }, + enabled: true, + subBlocks: { + systemPrompt: { id: 'systemPrompt', type: 'long-input', value: 'Original prompt' }, + model: { id: 'model', type: 'combobox', value: 'gpt-4o' }, + }, + outputs: {}, + data: { parentId: 'inner-loop', extent: 'parent' }, + }, + }, + edges: [ + { + id: 'edge-outer-inner', + source: 'outer-loop', + sourceHandle: 'loop-start-source', + target: 'inner-loop', + targetHandle: 'target', + type: 'default', + }, + { + id: 'edge-inner-agent', + source: 'inner-loop', + sourceHandle: 'loop-start-source', + target: 'inner-agent', + targetHandle: 'target', + type: 'default', + }, + ], + loops: {}, + parallels: {}, + } +} + describe('handleEditOperation nestedNodes merge', () => { it('preserves existing child block IDs when editing a loop with nestedNodes', () => { const workflow = makeLoopWorkflow() @@ -261,4 +321,89 @@ describe('handleEditOperation nestedNodes merge', () => { expect(agent).toBeDefined() expect(agent.subBlocks.systemPrompt.value).toBe('New prompt') }) + + it('recursively updates an existing nested loop and preserves grandchild IDs', () => { + const workflow = makeNestedLoopWorkflow() + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'outer-loop', + params: { + nestedNodes: { + 'new-inner-loop': { + type: 'loop', + name: 'Inner Loop', + inputs: { + loopType: 'forEach', + collection: '', + }, + nestedNodes: { + 'new-inner-agent': { + type: 'agent', + name: 'Inner Agent', + inputs: { systemPrompt: 'Updated prompt' }, + }, + 'new-helper': { + type: 'function', + name: 'Helper', + inputs: { code: 'return 1' }, + }, + }, + }, + }, + }, + }, + ]) + + expect(state.blocks['inner-loop']).toBeDefined() + expect(state.blocks['new-inner-loop']).toBeUndefined() + expect(state.blocks['inner-loop'].data.loopType).toBe('forEach') + expect(state.blocks['inner-loop'].data.collection).toBe('') + + expect(state.blocks['inner-agent']).toBeDefined() + expect(state.blocks['new-inner-agent']).toBeUndefined() + expect(state.blocks['inner-agent'].subBlocks.systemPrompt.value).toBe('Updated prompt') + + const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper') as + | any + | undefined + expect(helperBlock).toBeDefined() + expect(helperBlock?.data?.parentId).toBe('inner-loop') + }) + + it('removes grandchildren omitted from an existing nested loop update', () => { + const workflow = makeNestedLoopWorkflow() + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'outer-loop', + params: { + nestedNodes: { + 'new-inner-loop': { + type: 'loop', + name: 'Inner Loop', + nestedNodes: { + 'new-helper': { + type: 'function', + name: 'Helper', + inputs: { code: 'return 1' }, + }, + }, + }, + }, + }, + }, + ]) + + expect(state.blocks['inner-loop']).toBeDefined() + expect(state.blocks['inner-agent']).toBeUndefined() + expect( + state.edges.some((edge: any) => edge.source === 'inner-agent' || edge.target === 'inner-agent') + ).toBe(false) + + const helperBlock = Object.values(state.blocks).find((block: any) => block.name === 'Helper') + expect(helperBlock).toBeDefined() + }) }) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts index e0f45efd99f..5455fb5d21f 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts @@ -26,6 +26,289 @@ import { const logger = createLogger('EditWorkflowServerTool') +/** + * Applies loop/parallel container config from `inputs` onto a block state (data.loopType, etc.). + */ +function applyLoopOrParallelContainerData(block: any, params: Record): void { + if (params.type === 'loop') { + const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] + const loopType = + params.inputs?.loopType && validLoopTypes.includes(params.inputs.loopType) + ? params.inputs.loopType + : 'for' + block.data = { + ...block.data, + loopType, + ...(loopType === 'forEach' && + params.inputs?.collection && { collection: params.inputs.collection }), + ...(loopType === 'for' && params.inputs?.iterations && { count: params.inputs.iterations }), + ...(loopType === 'while' && + params.inputs?.condition && { whileCondition: params.inputs.condition }), + ...(loopType === 'doWhile' && + params.inputs?.condition && { doWhileCondition: params.inputs.condition }), + } + } else if (params.type === 'parallel') { + const validParallelTypes = ['count', 'collection'] + const parallelType = + params.inputs?.parallelType && validParallelTypes.includes(params.inputs.parallelType) + ? params.inputs.parallelType + : 'count' + block.data = { + ...block.data, + parallelType, + ...(parallelType === 'collection' && + params.inputs?.collection && { collection: params.inputs.collection }), + ...(parallelType === 'count' && params.inputs?.count && { count: params.inputs.count }), + } + } +} + +/** + * Adds child blocks under a loop/parallel container, including nested loop/parallel subflows. + */ +function processNestedNodesForParent( + parentBlockId: string, + nestedNodes: Record, + ctx: OperationContext +): void { + const { modifiedState, skippedItems, validationErrors, permissionConfig, deferredConnections } = + ctx + + const parentBlock = modifiedState.blocks[parentBlockId] + if (parentBlock?.locked) { + logSkippedItem(skippedItems, { + type: 'block_locked', + operationType: 'add_nested_nodes', + blockId: parentBlockId, + reason: `Container "${parentBlockId}" is locked - cannot add nested nodes`, + }) + return + } + + Object.entries(nestedNodes).forEach(([childId, childBlock]: [string, any]) => { + if (!isValidKey(childId)) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'add_nested_node', + blockId: String(childId || 'invalid'), + reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, + }) + logger.error('Invalid childId detected in nestedNodes', { + parentBlockId, + childId, + childId_type: typeof childId, + }) + return + } + + const childBlockState = createBlockFromParams( + childId, + childBlock, + parentBlockId, + validationErrors, + permissionConfig, + skippedItems + ) + if (childBlock.type === 'loop' || childBlock.type === 'parallel') { + applyLoopOrParallelContainerData(childBlockState, childBlock) + } + modifiedState.blocks[childId] = childBlockState + + if (childBlock.connections) { + deferredConnections.push({ + blockId: childId, + connections: childBlock.connections, + }) + } + + if ( + childBlock.nestedNodes && + (childBlock.type === 'loop' || childBlock.type === 'parallel') + ) { + processNestedNodesForParent(childId, childBlock.nestedNodes, ctx) + } + }) +} + +function updateLoopOrParallelContainerData(block: any, params: Record): void { + if (block.type === 'loop') { + block.data = block.data || {} + if (params.inputs?.loopType) { + const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] + if (validLoopTypes.includes(params.inputs.loopType)) { + block.data.loopType = params.inputs.loopType + } + } + const effectiveLoopType = params.inputs?.loopType ?? block.data.loopType ?? 'for' + if (params.inputs?.iterations && effectiveLoopType === 'for') { + block.data.count = params.inputs.iterations + } + if (params.inputs?.collection && effectiveLoopType === 'forEach') { + block.data.collection = params.inputs.collection + } + if (params.inputs?.condition && (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile')) { + if (effectiveLoopType === 'doWhile') { + block.data.doWhileCondition = params.inputs.condition + } else { + block.data.whileCondition = params.inputs.condition + } + } + } else if (block.type === 'parallel') { + block.data = block.data || {} + if (params.inputs?.parallelType) { + const validParallelTypes = ['count', 'collection'] + if (validParallelTypes.includes(params.inputs.parallelType)) { + block.data.parallelType = params.inputs.parallelType + } + } + const effectiveParallelType = params.inputs?.parallelType ?? block.data.parallelType ?? 'count' + if (params.inputs?.count && effectiveParallelType === 'count') { + block.data.count = params.inputs.count + } + if (params.inputs?.collection && effectiveParallelType === 'collection') { + block.data.collection = params.inputs.collection + } + } +} + +function mergeNestedNodesForParent( + parentBlockId: string, + nestedNodes: Record, + ctx: OperationContext +): void { + const { modifiedState, skippedItems, validationErrors, permissionConfig, deferredConnections } = + ctx + + const existingChildren: Array<[string, any]> = Object.entries(modifiedState.blocks).filter( + ([, block]: [string, any]) => block.data?.parentId === parentBlockId + ) + + const existingByName = new Map() + for (const [id, child] of existingChildren) { + existingByName.set(normalizeName(child.name), [id, child]) + } + + const matchedExistingIds = new Set() + + Object.entries(nestedNodes).forEach(([childId, childBlock]: [string, any]) => { + const incomingName = normalizeName(childBlock.name || '') + const existingMatch = incomingName ? existingByName.get(incomingName) : undefined + + if (existingMatch) { + const [existingId, existingBlock] = existingMatch + matchedExistingIds.add(existingId) + + if (childBlock.inputs) { + if (!existingBlock.subBlocks) existingBlock.subBlocks = {} + const childValidation = validateInputsForBlock(existingBlock.type, childBlock.inputs, existingId) + validationErrors.push(...childValidation.errors) + + Object.entries(childValidation.validInputs).forEach(([key, value]) => { + if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return + let sanitizedValue = value + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) + } + sanitizedValue = normalizeConditionRouterIds(existingId, key, sanitizedValue) + if (key === 'tools' && Array.isArray(value)) { + sanitizedValue = filterDisallowedTools( + normalizeTools(value), + permissionConfig, + existingId, + skippedItems + ) + } + if (key === 'responseFormat' && value) { + sanitizedValue = normalizeResponseFormat(value) + } + + const subBlockDef = getBlock(existingBlock.type)?.subBlocks.find((sb: any) => sb.id === key) + if (!existingBlock.subBlocks[key]) { + existingBlock.subBlocks[key] = { + id: key, + type: subBlockDef?.type || 'short-input', + value: sanitizedValue, + } + } else { + existingBlock.subBlocks[key].value = sanitizedValue + } + }) + } + + if (existingBlock.type === 'loop' || existingBlock.type === 'parallel') { + updateLoopOrParallelContainerData(existingBlock, childBlock) + } + + if (childBlock.connections) { + modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== existingId) + deferredConnections.push({ + blockId: existingId, + connections: childBlock.connections, + }) + } + + if ( + childBlock.nestedNodes && + (existingBlock.type === 'loop' || existingBlock.type === 'parallel') + ) { + mergeNestedNodesForParent(existingId, childBlock.nestedNodes, ctx) + } + return + } + + if (!isValidKey(childId)) { + logSkippedItem(skippedItems, { + type: 'missing_required_params', + operationType: 'add_nested_node', + blockId: String(childId || 'invalid'), + reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, + }) + return + } + + const childBlockState = createBlockFromParams( + childId, + childBlock, + parentBlockId, + validationErrors, + permissionConfig, + skippedItems + ) + if (childBlock.type === 'loop' || childBlock.type === 'parallel') { + applyLoopOrParallelContainerData(childBlockState, childBlock) + } + modifiedState.blocks[childId] = childBlockState + + if (childBlock.connections) { + deferredConnections.push({ + blockId: childId, + connections: childBlock.connections, + }) + } + + if ( + childBlock.nestedNodes && + (childBlock.type === 'loop' || childBlock.type === 'parallel') + ) { + processNestedNodesForParent(childId, childBlock.nestedNodes, ctx) + } + }) + + const removedIds = new Set() + for (const [existingId] of existingChildren) { + if (!matchedExistingIds.has(existingId)) { + delete modifiedState.blocks[existingId] + removedIds.add(existingId) + } + } + + if (removedIds.size > 0) { + modifiedState.edges = modifiedState.edges.filter( + (edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target) + ) + } +} + export function handleDeleteOperation(op: EditWorkflowOperation, ctx: OperationContext): void { const { modifiedState, skippedItems } = ctx const { block_id } = op @@ -347,181 +630,10 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon // (preserving their block ID). New children are created. Children not present // in the incoming set are removed. if (params?.nestedNodes) { - const existingChildren: Array<[string, any]> = Object.entries(modifiedState.blocks).filter( - ([, b]: [string, any]) => b.data?.parentId === block_id - ) - - const existingByName = new Map() - for (const [id, child] of existingChildren) { - existingByName.set(normalizeName(child.name), [id, child]) - } - - const matchedExistingIds = new Set() - - Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { - if (childBlock.type === 'loop' || childBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'edit_nested_node', - blockId: childId, - reason: `Cannot nest ${childBlock.type} inside ${block.type} - nested subflows are not supported`, - details: { parentType: block.type, childType: childBlock.type }, - }) - return - } - - const incomingName = normalizeName(childBlock.name || '') - const existingMatch = incomingName ? existingByName.get(incomingName) : undefined - - if (existingMatch) { - const [existingId, existingBlock] = existingMatch - matchedExistingIds.add(existingId) - - if (childBlock.inputs) { - if (!existingBlock.subBlocks) existingBlock.subBlocks = {} - const childValidation = validateInputsForBlock( - existingBlock.type, - childBlock.inputs, - existingId - ) - validationErrors.push(...childValidation.errors) - - Object.entries(childValidation.validInputs).forEach(([key, value]) => { - if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return - let sanitizedValue = value - if (shouldNormalizeArrayIds(key)) { - sanitizedValue = normalizeArrayWithIds(value) - } - sanitizedValue = normalizeConditionRouterIds(existingId, key, sanitizedValue) - if (key === 'tools' && Array.isArray(value)) { - sanitizedValue = filterDisallowedTools( - normalizeTools(value), - permissionConfig, - existingId, - skippedItems - ) - } - if (key === 'responseFormat' && value) { - sanitizedValue = normalizeResponseFormat(value) - } - - const subBlockDef = getBlock(existingBlock.type)?.subBlocks.find( - (sb: any) => sb.id === key - ) - if (!existingBlock.subBlocks[key]) { - existingBlock.subBlocks[key] = { - id: key, - type: subBlockDef?.type || 'short-input', - value: sanitizedValue, - } - } else { - existingBlock.subBlocks[key].value = sanitizedValue - } - }) - } - - if (childBlock.connections) { - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => edge.source !== existingId - ) - deferredConnections.push({ - blockId: existingId, - connections: childBlock.connections, - }) - } - } else { - if (!isValidKey(childId)) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'add_nested_node', - blockId: String(childId || 'invalid'), - reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, - }) - return - } - - const childBlockState = createBlockFromParams( - childId, - childBlock, - block_id, - validationErrors, - permissionConfig, - skippedItems - ) - modifiedState.blocks[childId] = childBlockState - - if (childBlock.connections) { - deferredConnections.push({ - blockId: childId, - connections: childBlock.connections, - }) - } - } - }) - - const removedIds = new Set() - for (const [existingId] of existingChildren) { - if (!matchedExistingIds.has(existingId)) { - delete modifiedState.blocks[existingId] - removedIds.add(existingId) - } - } - if (removedIds.size > 0) { - modifiedState.edges = modifiedState.edges.filter( - (edge: any) => !removedIds.has(edge.source) && !removedIds.has(edge.target) - ) - } + mergeNestedNodesForParent(block_id, params.nestedNodes, ctx) // Update loop/parallel configuration based on type (strict validation) - if (block.type === 'loop') { - block.data = block.data || {} - // loopType is always valid - if (params.inputs?.loopType) { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] - if (validLoopTypes.includes(params.inputs.loopType)) { - block.data.loopType = params.inputs.loopType - } - } - const effectiveLoopType = params.inputs?.loopType ?? block.data.loopType ?? 'for' - // iterations only valid for 'for' loopType - if (params.inputs?.iterations && effectiveLoopType === 'for') { - block.data.count = params.inputs.iterations - } - // collection only valid for 'forEach' loopType - if (params.inputs?.collection && effectiveLoopType === 'forEach') { - block.data.collection = params.inputs.collection - } - // condition only valid for 'while' or 'doWhile' loopType - if ( - params.inputs?.condition && - (effectiveLoopType === 'while' || effectiveLoopType === 'doWhile') - ) { - if (effectiveLoopType === 'doWhile') { - block.data.doWhileCondition = params.inputs.condition - } else { - block.data.whileCondition = params.inputs.condition - } - } - } else if (block.type === 'parallel') { - block.data = block.data || {} - // parallelType is always valid - if (params.inputs?.parallelType) { - const validParallelTypes = ['count', 'collection'] - if (validParallelTypes.includes(params.inputs.parallelType)) { - block.data.parallelType = params.inputs.parallelType - } - } - const effectiveParallelType = - params.inputs?.parallelType ?? block.data.parallelType ?? 'count' - // count only valid for 'count' parallelType - if (params.inputs?.count && effectiveParallelType === 'count') { - block.data.count = params.inputs.count - } - // collection only valid for 'collection' parallelType - if (params.inputs?.collection && effectiveParallelType === 'collection') { - block.data.collection = params.inputs.collection - } - } + updateLoopOrParallelContainerData(block, params) } // Defer connections to pass 2 so all blocks exist before edges are created @@ -664,41 +776,8 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont skippedItems ) - // Set loop/parallel data on parent block BEFORE adding to blocks (strict validation) - if (params.nestedNodes) { - if (params.type === 'loop') { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] - const loopType = - params.inputs?.loopType && validLoopTypes.includes(params.inputs.loopType) - ? params.inputs.loopType - : 'for' - newBlock.data = { - ...newBlock.data, - loopType, - // Only include type-appropriate fields - ...(loopType === 'forEach' && - params.inputs?.collection && { collection: params.inputs.collection }), - ...(loopType === 'for' && params.inputs?.iterations && { count: params.inputs.iterations }), - ...(loopType === 'while' && - params.inputs?.condition && { whileCondition: params.inputs.condition }), - ...(loopType === 'doWhile' && - params.inputs?.condition && { doWhileCondition: params.inputs.condition }), - } - } else if (params.type === 'parallel') { - const validParallelTypes = ['count', 'collection'] - const parallelType = - params.inputs?.parallelType && validParallelTypes.includes(params.inputs.parallelType) - ? params.inputs.parallelType - : 'count' - newBlock.data = { - ...newBlock.data, - parallelType, - // Only include type-appropriate fields - ...(parallelType === 'collection' && - params.inputs?.collection && { collection: params.inputs.collection }), - ...(parallelType === 'count' && params.inputs?.count && { count: params.inputs.count }), - } - } + if (params.type === 'loop' || params.type === 'parallel') { + applyLoopOrParallelContainerData(newBlock, params) } // Add parent block FIRST before adding children @@ -707,65 +786,7 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont // Handle nested nodes (for loops/parallels created from scratch) if (params.nestedNodes) { - // Defensive check: verify parent is not locked before adding children - // (Parent was just created with locked: false, but check for consistency) - const parentBlock = modifiedState.blocks[block_id] - if (parentBlock?.locked) { - logSkippedItem(skippedItems, { - type: 'block_locked', - operationType: 'add_nested_nodes', - blockId: block_id, - reason: `Container "${block_id}" is locked - cannot add nested nodes`, - }) - return - } - - Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => { - // Validate childId is a valid string - if (!isValidKey(childId)) { - logSkippedItem(skippedItems, { - type: 'missing_required_params', - operationType: 'add_nested_node', - blockId: String(childId || 'invalid'), - reason: `Invalid childId "${childId}" in nestedNodes - child block skipped`, - }) - logger.error('Invalid childId detected in nestedNodes', { - parentBlockId: block_id, - childId, - childId_type: typeof childId, - }) - return - } - - if (childBlock.type === 'loop' || childBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'add_nested_node', - blockId: childId, - reason: `Cannot nest ${childBlock.type} inside ${params.type} - nested subflows are not supported`, - details: { parentType: params.type, childType: childBlock.type }, - }) - return - } - - const childBlockState = createBlockFromParams( - childId, - childBlock, - block_id, - validationErrors, - permissionConfig, - skippedItems - ) - modifiedState.blocks[childId] = childBlockState - - // Defer connection processing to ensure all blocks exist first - if (childBlock.connections) { - deferredConnections.push({ - blockId: childId, - connections: childBlock.connections, - }) - } - }) + processNestedNodesForParent(block_id, params.nestedNodes, ctx) } // Defer connection processing to ensure all blocks exist first (pass 2) @@ -834,32 +855,10 @@ export function handleInsertIntoSubflowOperation( return } - if (params.type === 'loop' || params.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Cannot nest ${params.type} inside ${subflowBlock.type} - nested subflows are not supported`, - details: { parentType: subflowBlock.type, childType: params.type }, - }) - return - } - // Check if block already exists (moving into subflow) or is new const existingBlock = modifiedState.blocks[block_id] if (existingBlock) { - if (existingBlock.type === 'loop' || existingBlock.type === 'parallel') { - logSkippedItem(skippedItems, { - type: 'nested_subflow_not_allowed', - operationType: 'insert_into_subflow', - blockId: block_id, - reason: `Cannot move ${existingBlock.type} into ${subflowBlock.type} - nested subflows are not supported`, - details: { parentType: subflowBlock.type, childType: existingBlock.type }, - }) - return - } - // Check if existing block is locked if (existingBlock.locked) { logSkippedItem(skippedItems, { @@ -981,6 +980,15 @@ export function handleInsertIntoSubflowOperation( skippedItems ) modifiedState.blocks[block_id] = newBlock + if (params.type === 'loop' || params.type === 'parallel') { + applyLoopOrParallelContainerData(newBlock, params) + } + if ( + params.nestedNodes && + (params.type === 'loop' || params.type === 'parallel') + ) { + processNestedNodesForParent(block_id, params.nestedNodes, ctx) + } } // Defer connection processing to ensure all blocks exist first From ebbd26c0fa546f1cca13e7a7ba960e7b94ab37ca Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 23 Mar 2026 16:32:49 -0700 Subject: [PATCH 6/6] Fix type error --- apps/sim/tools/file/write.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/file/write.ts b/apps/sim/tools/file/write.ts index 98e46d1b895..d2bff145018 100644 --- a/apps/sim/tools/file/write.ts +++ b/apps/sim/tools/file/write.ts @@ -1,4 +1,4 @@ -import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { ToolConfig, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' interface FileWriteParams { fileName?: string @@ -7,6 +7,7 @@ interface FileWriteParams { contentType?: string append?: boolean workspaceId?: string + _context?: WorkflowToolExecutionContext } export const fileWriteTool: ToolConfig = { @@ -64,7 +65,7 @@ export const fileWriteTool: ToolConfig = { content: params.content, contentType: params.contentType, append: params.append ?? false, - workspaceId: params.workspaceId || (params as Record)._context?.workspaceId, + workspaceId: params.workspaceId || params._context?.workspaceId, }), },