Skip to content

Commit e1359b0

Browse files
TheodoreSpeaksTheodore Li
andauthored
feat(block) add block write and append operations (#3665)
* Add file write and delete operations * Add file block write operation * Fix lint * Allow loop-in-loop workflow edits * Fix type error * Remove file id input, output link correctly * Add append tool * fix lint * Address feedback * Handle writing to same file name gracefully * Removed mime type from append block * Add lock for file append operation --------- Co-authored-by: Theodore Li <theo@sim.ai>
1 parent 35b3646 commit e1359b0

File tree

7 files changed

+461
-16
lines changed

7 files changed

+461
-16
lines changed
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
4+
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
5+
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
6+
import {
7+
downloadWorkspaceFile,
8+
getWorkspaceFileByName,
9+
updateWorkspaceFileContent,
10+
uploadWorkspaceFile,
11+
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
12+
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
const logger = createLogger('FileManageAPI')
17+
18+
export async function POST(request: NextRequest) {
19+
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
20+
if (!auth.success) {
21+
return NextResponse.json({ success: false, error: auth.error }, { status: 401 })
22+
}
23+
24+
const { searchParams } = new URL(request.url)
25+
const userId = auth.userId || searchParams.get('userId')
26+
27+
if (!userId) {
28+
return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 })
29+
}
30+
31+
let body: Record<string, unknown>
32+
try {
33+
body = await request.json()
34+
} catch {
35+
return NextResponse.json({ success: false, error: 'Invalid JSON body' }, { status: 400 })
36+
}
37+
38+
const workspaceId = (body.workspaceId as string) || searchParams.get('workspaceId')
39+
if (!workspaceId) {
40+
return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 })
41+
}
42+
43+
const operation = body.operation as string
44+
45+
try {
46+
switch (operation) {
47+
case 'write': {
48+
const fileName = body.fileName as string | undefined
49+
const content = body.content as string | undefined
50+
const contentType = body.contentType as string | undefined
51+
52+
if (!fileName) {
53+
return NextResponse.json(
54+
{ success: false, error: 'fileName is required for write operation' },
55+
{ status: 400 }
56+
)
57+
}
58+
59+
if (!content && content !== '') {
60+
return NextResponse.json(
61+
{ success: false, error: 'content is required for write operation' },
62+
{ status: 400 }
63+
)
64+
}
65+
66+
const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(fileName))
67+
const fileBuffer = Buffer.from(content ?? '', 'utf-8')
68+
const result = await uploadWorkspaceFile(
69+
workspaceId,
70+
userId,
71+
fileBuffer,
72+
fileName,
73+
mimeType
74+
)
75+
76+
logger.info('File created', {
77+
fileId: result.id,
78+
name: fileName,
79+
size: fileBuffer.length,
80+
})
81+
82+
return NextResponse.json({
83+
success: true,
84+
data: {
85+
id: result.id,
86+
name: result.name,
87+
size: fileBuffer.length,
88+
url: ensureAbsoluteUrl(result.url),
89+
},
90+
})
91+
}
92+
93+
case 'append': {
94+
const fileName = body.fileName as string | undefined
95+
const content = body.content as string | undefined
96+
97+
if (!fileName) {
98+
return NextResponse.json(
99+
{ success: false, error: 'fileName is required for append operation' },
100+
{ status: 400 }
101+
)
102+
}
103+
104+
if (!content && content !== '') {
105+
return NextResponse.json(
106+
{ success: false, error: 'content is required for append operation' },
107+
{ status: 400 }
108+
)
109+
}
110+
111+
const existing = await getWorkspaceFileByName(workspaceId, fileName)
112+
if (!existing) {
113+
return NextResponse.json(
114+
{ success: false, error: `File not found: "${fileName}"` },
115+
{ status: 404 }
116+
)
117+
}
118+
119+
const lockKey = `file-append:${workspaceId}:${existing.id}`
120+
const lockValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`
121+
const acquired = await acquireLock(lockKey, lockValue, 30)
122+
if (!acquired) {
123+
return NextResponse.json(
124+
{ success: false, error: 'File is busy, please retry' },
125+
{ status: 409 }
126+
)
127+
}
128+
129+
try {
130+
const existingBuffer = await downloadWorkspaceFile(existing)
131+
const finalContent = existingBuffer.toString('utf-8') + content
132+
const fileBuffer = Buffer.from(finalContent, 'utf-8')
133+
await updateWorkspaceFileContent(workspaceId, existing.id, userId, fileBuffer)
134+
135+
logger.info('File appended', {
136+
fileId: existing.id,
137+
name: existing.name,
138+
size: fileBuffer.length,
139+
})
140+
141+
return NextResponse.json({
142+
success: true,
143+
data: {
144+
id: existing.id,
145+
name: existing.name,
146+
size: fileBuffer.length,
147+
url: ensureAbsoluteUrl(existing.path),
148+
},
149+
})
150+
} finally {
151+
await releaseLock(lockKey, lockValue)
152+
}
153+
}
154+
155+
default:
156+
return NextResponse.json(
157+
{ success: false, error: `Unknown operation: ${operation}. Supported: write, append` },
158+
{ status: 400 }
159+
)
160+
}
161+
} catch (error) {
162+
const message = error instanceof Error ? error.message : 'Unknown error'
163+
logger.error('File operation failed', { operation, error: message })
164+
return NextResponse.json({ success: false, error: message }, { status: 500 })
165+
}
166+
}

apps/sim/blocks/blocks/file.ts

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -250,16 +250,27 @@ export const FileV2Block: BlockConfig<FileParserOutput> = {
250250
export const FileV3Block: BlockConfig<FileParserV3Output> = {
251251
type: 'file_v3',
252252
name: 'File',
253-
description: 'Read and parse multiple files',
253+
description: 'Read and write workspace files',
254254
longDescription:
255-
'Upload files directly or import from external URLs to get UserFile objects for use in other blocks.',
255+
'Read and parse files from uploads or URLs, write new workspace files, or append content to existing files.',
256256
docsLink: 'https://docs.sim.ai/tools/file',
257257
category: 'tools',
258258
integrationType: IntegrationType.FileStorage,
259259
tags: ['document-processing'],
260260
bgColor: '#40916C',
261261
icon: DocumentIcon,
262262
subBlocks: [
263+
{
264+
id: 'operation',
265+
title: 'Operation',
266+
type: 'dropdown' as SubBlockType,
267+
options: [
268+
{ label: 'Read', id: 'file_parser_v3' },
269+
{ label: 'Write', id: 'file_write' },
270+
{ label: 'Append', id: 'file_append' },
271+
],
272+
value: () => 'file_parser_v3',
273+
},
263274
{
264275
id: 'file',
265276
title: 'Files',
@@ -270,7 +281,8 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
270281
multiple: true,
271282
mode: 'basic',
272283
maxSize: 100,
273-
required: true,
284+
required: { field: 'operation', value: 'file_parser_v3' },
285+
condition: { field: 'operation', value: 'file_parser_v3' },
274286
},
275287
{
276288
id: 'fileUrl',
@@ -279,15 +291,84 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
279291
canonicalParamId: 'fileInput',
280292
placeholder: 'https://example.com/document.pdf',
281293
mode: 'advanced',
282-
required: true,
294+
required: { field: 'operation', value: 'file_parser_v3' },
295+
condition: { field: 'operation', value: 'file_parser_v3' },
296+
},
297+
{
298+
id: 'fileName',
299+
title: 'File Name',
300+
type: 'short-input' as SubBlockType,
301+
placeholder: 'File name (e.g., data.csv)',
302+
condition: { field: 'operation', value: 'file_write' },
303+
required: { field: 'operation', value: 'file_write' },
304+
},
305+
{
306+
id: 'content',
307+
title: 'Content',
308+
type: 'long-input' as SubBlockType,
309+
placeholder: 'File content to write...',
310+
condition: { field: 'operation', value: 'file_write' },
311+
required: { field: 'operation', value: 'file_write' },
312+
},
313+
{
314+
id: 'contentType',
315+
title: 'Content Type',
316+
type: 'short-input' as SubBlockType,
317+
placeholder: 'text/plain (auto-detected from extension)',
318+
condition: { field: 'operation', value: 'file_write' },
319+
mode: 'advanced',
320+
},
321+
{
322+
id: 'appendFileName',
323+
title: 'File',
324+
type: 'dropdown' as SubBlockType,
325+
placeholder: 'Select a workspace file...',
326+
condition: { field: 'operation', value: 'file_append' },
327+
required: { field: 'operation', value: 'file_append' },
328+
options: [],
329+
fetchOptions: async () => {
330+
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
331+
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
332+
if (!workspaceId) return []
333+
const response = await fetch(`/api/workspaces/${workspaceId}/files`)
334+
const data = await response.json()
335+
if (!data.success || !data.files) return []
336+
return data.files.map((f: { name: string }) => ({ label: f.name, id: f.name }))
337+
},
338+
},
339+
{
340+
id: 'appendContent',
341+
title: 'Content',
342+
type: 'long-input' as SubBlockType,
343+
placeholder: 'Content to append...',
344+
condition: { field: 'operation', value: 'file_append' },
345+
required: { field: 'operation', value: 'file_append' },
283346
},
284347
],
285348
tools: {
286-
access: ['file_parser_v3'],
349+
access: ['file_parser_v3', 'file_write', 'file_append'],
287350
config: {
288-
tool: () => 'file_parser_v3',
351+
tool: (params) => params.operation || 'file_parser_v3',
289352
params: (params) => {
290-
// Use canonical 'fileInput' param directly
353+
const operation = params.operation || 'file_parser_v3'
354+
355+
if (operation === 'file_write') {
356+
return {
357+
fileName: params.fileName,
358+
content: params.content,
359+
contentType: params.contentType,
360+
workspaceId: params._context?.workspaceId,
361+
}
362+
}
363+
364+
if (operation === 'file_append') {
365+
return {
366+
fileName: params.appendFileName,
367+
content: params.appendContent,
368+
workspaceId: params._context?.workspaceId,
369+
}
370+
}
371+
291372
const fileInput = params.fileInput
292373
if (!fileInput) {
293374
logger.error('No file input provided')
@@ -326,17 +407,39 @@ export const FileV3Block: BlockConfig<FileParserV3Output> = {
326407
},
327408
},
328409
inputs: {
329-
fileInput: { type: 'json', description: 'File input (canonical param)' },
330-
fileType: { type: 'string', description: 'File type' },
410+
operation: { type: 'string', description: 'Operation to perform (read, write, or append)' },
411+
fileInput: { type: 'json', description: 'File input for read (canonical param)' },
412+
fileType: { type: 'string', description: 'File type for read' },
413+
fileName: { type: 'string', description: 'Name for a new file (write)' },
414+
content: { type: 'string', description: 'File content to write' },
415+
contentType: { type: 'string', description: 'MIME content type for write' },
416+
appendFileName: { type: 'string', description: 'Name of existing file to append to' },
417+
appendContent: { type: 'string', description: 'Content to append to file' },
331418
},
332419
outputs: {
333420
files: {
334421
type: 'file[]',
335-
description: 'Parsed files as UserFile objects',
422+
description: 'Parsed files as UserFile objects (read)',
336423
},
337424
combinedContent: {
338425
type: 'string',
339-
description: 'All file contents merged into a single text string',
426+
description: 'All file contents merged into a single text string (read)',
427+
},
428+
id: {
429+
type: 'string',
430+
description: 'File ID (write)',
431+
},
432+
name: {
433+
type: 'string',
434+
description: 'File name (write)',
435+
},
436+
size: {
437+
type: 'number',
438+
description: 'File size in bytes (write)',
439+
},
440+
url: {
441+
type: 'string',
442+
description: 'URL to access the file (write)',
340443
},
341444
},
342445
}

0 commit comments

Comments
 (0)