Skip to content

Commit de93e16

Browse files
feat(execution-filesystem): system to pass files between blocks (#866)
* feat(files): pass files between blocks * presigned URL for downloads * Remove latest migration before merge * starter block file upload wasn't getting logged * checkpoint in human readable form * checkpoint files / file type outputs * file downloads working for block outputs * checkpoint file download * fix type issues * remove filereference interface with simpler user file interface * show files in the tag dropdown for start block * more migration to simple url object, reduce presigned time to 5 min * Remove migration 0065_parallel_nightmare and related files - Deleted apps/sim/db/migrations/0065_parallel_nightmare.sql - Deleted apps/sim/db/migrations/meta/0065_snapshot.json - Removed 0065 entry from apps/sim/db/migrations/meta/_journal.json Preparing for merge with origin/staging and migration regeneration * add migration files * fix tests * Update apps/sim/lib/uploads/setup.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update apps/sim/lib/workflows/execution-file-storage.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update apps/sim/lib/workflows/execution-file-storage.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * cleanup types * fix lint * fix logs typing for file refs * open download in new tab * fixed * Update apps/sim/tools/index.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix file block * cleanup unused code * fix bugs * remove hacky file id logic * fix drag and drop * fix tests --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent 75963eb commit de93e16

File tree

60 files changed

+8489
-457
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+8489
-457
lines changed

apps/docs/content/docs/triggers/starter.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Choose your input method from the dropdown:
5050
<video autoPlay loop muted playsInline className="w-full -mb-2 rounded-lg" src="/chat-input.mp4"></video>
5151
</div>
5252

53-
<p className="text-sm text-gray-600">Chat with your workflow and access both input text and conversation ID for context-aware responses.</p>
53+
<p className="text-sm text-gray-600">Chat with your workflow and access input text, conversation ID, and uploaded files for context-aware responses.</p>
5454
</div>
5555
</Tab>
5656
</Tabs>
@@ -60,13 +60,15 @@ Choose your input method from the dropdown:
6060
In Chat mode, access user input and conversation context through special variables:
6161

6262
```yaml
63-
# Reference the chat input and conversation ID in your workflow
63+
# Reference the chat input, conversation ID, and files in your workflow
6464
user_message: "<start.input>"
6565
conversation_id: "<start.conversationId>"
66+
uploaded_files: "<start.files>"
6667
```
6768
6869
- **`<start.input>`** - Contains the user's message text
6970
- **`<start.conversationId>`** - Unique identifier for the conversation thread
71+
- **`<start.files>`** - Array of files uploaded by the user (if any)
7072

7173
## API Execution
7274

apps/sim/app/api/__test-utils__/utils.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,7 @@ export function mockFileSystem(
712712
}
713713
return Promise.reject(new Error('File not found'))
714714
}),
715+
mkdir: vi.fn().mockResolvedValue(undefined),
715716
}))
716717
}
717718

@@ -761,14 +762,15 @@ export function createStorageProviderMocks(options: StorageProviderMockOptions =
761762
getStorageProvider: vi.fn().mockReturnValue(provider),
762763
isUsingCloudStorage: vi.fn().mockReturnValue(isCloudEnabled),
763764
uploadFile: vi.fn().mockResolvedValue({
764-
path: '/api/files/serve/test-key',
765-
key: 'test-key',
765+
path: '/api/files/serve/test-key.txt',
766+
key: 'test-key.txt',
766767
name: 'test.txt',
767768
size: 100,
768769
type: 'text/plain',
769770
}),
770771
downloadFile: vi.fn().mockResolvedValue(Buffer.from('test content')),
771772
deleteFile: vi.fn().mockResolvedValue(undefined),
773+
getPresignedUrl: vi.fn().mockResolvedValue(presignedUrl),
772774
}))
773775

774776
if (provider === 's3') {
@@ -1235,14 +1237,15 @@ export function setupFileApiMocks(
12351237
getStorageProvider: vi.fn().mockReturnValue('local'),
12361238
isUsingCloudStorage: vi.fn().mockReturnValue(cloudEnabled),
12371239
uploadFile: vi.fn().mockResolvedValue({
1238-
path: '/api/files/serve/test-key',
1239-
key: 'test-key',
1240+
path: '/api/files/serve/test-key.txt',
1241+
key: 'test-key.txt',
12401242
name: 'test.txt',
12411243
size: 100,
12421244
type: 'text/plain',
12431245
}),
12441246
downloadFile: vi.fn().mockResolvedValue(Buffer.from('test content')),
12451247
deleteFile: vi.fn().mockResolvedValue(undefined),
1248+
getPresignedUrl: vi.fn().mockResolvedValue('https://example.com/presigned-url'),
12461249
}))
12471250
}
12481251

@@ -1347,8 +1350,8 @@ export function mockUploadUtils(
13471350
const {
13481351
isCloudStorage = false,
13491352
uploadResult = {
1350-
path: '/api/files/serve/test-key',
1351-
key: 'test-key',
1353+
path: '/api/files/serve/test-key.txt',
1354+
key: 'test-key.txt',
13521355
name: 'test.txt',
13531356
size: 100,
13541357
type: 'text/plain',
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { createLogger } from '@/lib/logs/console/logger'
3+
import { getPresignedUrl, getPresignedUrlWithConfig, isUsingCloudStorage } from '@/lib/uploads'
4+
import { BLOB_EXECUTION_FILES_CONFIG, S3_EXECUTION_FILES_CONFIG } from '@/lib/uploads/setup'
5+
import { createErrorResponse } from '@/app/api/files/utils'
6+
7+
const logger = createLogger('FileDownload')
8+
9+
export const dynamic = 'force-dynamic'
10+
11+
export async function POST(request: NextRequest) {
12+
try {
13+
const body = await request.json()
14+
const { key, name, storageProvider, bucketName, isExecutionFile } = body
15+
16+
if (!key) {
17+
return createErrorResponse(new Error('File key is required'), 400)
18+
}
19+
20+
logger.info(`Generating download URL for file: ${name || key}`)
21+
22+
if (isUsingCloudStorage()) {
23+
// Generate a fresh 5-minute presigned URL for cloud storage
24+
try {
25+
let downloadUrl: string
26+
27+
// Use execution files storage if flagged as execution file
28+
if (isExecutionFile) {
29+
logger.info(`Using execution files storage for file: ${key}`)
30+
downloadUrl = await getPresignedUrlWithConfig(
31+
key,
32+
{
33+
bucket: S3_EXECUTION_FILES_CONFIG.bucket,
34+
region: S3_EXECUTION_FILES_CONFIG.region,
35+
},
36+
5 * 60 // 5 minutes
37+
)
38+
} else if (storageProvider && (storageProvider === 's3' || storageProvider === 'blob')) {
39+
// Use explicitly specified storage provider (legacy support)
40+
logger.info(`Using specified storage provider '${storageProvider}' for file: ${key}`)
41+
42+
if (storageProvider === 's3') {
43+
downloadUrl = await getPresignedUrlWithConfig(
44+
key,
45+
{
46+
bucket: bucketName || S3_EXECUTION_FILES_CONFIG.bucket,
47+
region: S3_EXECUTION_FILES_CONFIG.region,
48+
},
49+
5 * 60 // 5 minutes
50+
)
51+
} else {
52+
// blob
53+
downloadUrl = await getPresignedUrlWithConfig(
54+
key,
55+
{
56+
accountName: BLOB_EXECUTION_FILES_CONFIG.accountName,
57+
accountKey: BLOB_EXECUTION_FILES_CONFIG.accountKey,
58+
connectionString: BLOB_EXECUTION_FILES_CONFIG.connectionString,
59+
containerName: bucketName || BLOB_EXECUTION_FILES_CONFIG.containerName,
60+
},
61+
5 * 60 // 5 minutes
62+
)
63+
}
64+
} else {
65+
// Use default storage (regular uploads)
66+
logger.info(`Using default storage for file: ${key}`)
67+
downloadUrl = await getPresignedUrl(key, 5 * 60) // 5 minutes
68+
}
69+
70+
return NextResponse.json({
71+
downloadUrl,
72+
expiresIn: 300, // 5 minutes in seconds
73+
fileName: name || key.split('/').pop() || 'download',
74+
})
75+
} catch (error) {
76+
logger.error(`Failed to generate presigned URL for ${key}:`, error)
77+
return createErrorResponse(
78+
error instanceof Error ? error : new Error('Failed to generate download URL'),
79+
500
80+
)
81+
}
82+
} else {
83+
// For local storage, return the direct path
84+
const downloadUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/files/serve/${key}`
85+
86+
return NextResponse.json({
87+
downloadUrl,
88+
expiresIn: null, // Local URLs don't expire
89+
fileName: name || key.split('/').pop() || 'download',
90+
})
91+
}
92+
} catch (error) {
93+
logger.error('Error in file download endpoint:', error)
94+
return createErrorResponse(
95+
error instanceof Error ? error : new Error('Internal server error'),
96+
500
97+
)
98+
}
99+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { createLogger } from '@/lib/logs/console/logger'
3+
import { generateExecutionFileDownloadUrl } from '@/lib/workflows/execution-file-storage'
4+
import { getExecutionFiles } from '@/lib/workflows/execution-files-server'
5+
import type { UserFile } from '@/executor/types'
6+
7+
const logger = createLogger('ExecutionFileDownloadAPI')
8+
9+
/**
10+
* Generate a short-lived presigned URL for secure execution file download
11+
* GET /api/files/execution/[executionId]/[fileId]
12+
*/
13+
export async function GET(
14+
request: NextRequest,
15+
{ params }: { params: Promise<{ executionId: string; fileId: string }> }
16+
) {
17+
try {
18+
const { executionId, fileId } = await params
19+
20+
if (!executionId || !fileId) {
21+
return NextResponse.json({ error: 'Execution ID and File ID are required' }, { status: 400 })
22+
}
23+
24+
logger.info(`Generating download URL for file ${fileId} in execution ${executionId}`)
25+
26+
// Get files for this execution
27+
const executionFiles = await getExecutionFiles(executionId)
28+
29+
if (executionFiles.length === 0) {
30+
return NextResponse.json({ error: 'No files found for this execution' }, { status: 404 })
31+
}
32+
33+
// Find the specific file
34+
const file = executionFiles.find((f) => f.id === fileId)
35+
if (!file) {
36+
return NextResponse.json({ error: 'File not found in this execution' }, { status: 404 })
37+
}
38+
39+
// Check if file is expired
40+
if (new Date(file.expiresAt) < new Date()) {
41+
return NextResponse.json({ error: 'File has expired' }, { status: 410 })
42+
}
43+
44+
// Since ExecutionFileMetadata is now just UserFile, no conversion needed
45+
const userFile: UserFile = file
46+
47+
// Generate a new short-lived presigned URL (5 minutes)
48+
const downloadUrl = await generateExecutionFileDownloadUrl(userFile)
49+
50+
logger.info(`Generated download URL for file ${file.name} (execution: ${executionId})`)
51+
52+
const response = NextResponse.json({
53+
downloadUrl,
54+
fileName: file.name,
55+
fileSize: file.size,
56+
fileType: file.type,
57+
expiresIn: 300, // 5 minutes
58+
})
59+
60+
// Ensure no caching of download URLs
61+
response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
62+
response.headers.set('Pragma', 'no-cache')
63+
response.headers.set('Expires', '0')
64+
65+
return response
66+
} catch (error) {
67+
logger.error('Error generating execution file download URL:', error)
68+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
69+
}
70+
}

apps/sim/app/api/files/parse/route.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
88
import { createLogger } from '@/lib/logs/console/logger'
99
import { downloadFile, isUsingCloudStorage } from '@/lib/uploads'
10-
import { UPLOAD_DIR } from '@/lib/uploads/setup'
10+
import { UPLOAD_DIR_SERVER } from '@/lib/uploads/setup.server'
1111
import '@/lib/uploads/setup.server'
1212

1313
export const dynamic = 'force-dynamic'
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
7070
const requestData = await request.json()
7171
const { filePath, fileType } = requestData
7272

73-
if (!filePath) {
73+
if (!filePath || (typeof filePath === 'string' && filePath.trim() === '')) {
7474
return NextResponse.json({ success: false, error: 'No file path provided' }, { status: 400 })
7575
}
7676

@@ -80,6 +80,16 @@ export async function POST(request: NextRequest) {
8080
if (Array.isArray(filePath)) {
8181
const results = []
8282
for (const path of filePath) {
83+
// Skip empty or invalid paths
84+
if (!path || (typeof path === 'string' && path.trim() === '')) {
85+
results.push({
86+
success: false,
87+
error: 'Empty file path in array',
88+
filePath: path || '',
89+
})
90+
continue
91+
}
92+
8393
const result = await parseFileSingle(path, fileType)
8494
// Add processing time to metadata
8595
if (result.metadata) {
@@ -154,6 +164,15 @@ export async function POST(request: NextRequest) {
154164
async function parseFileSingle(filePath: string, fileType?: string): Promise<ParseResult> {
155165
logger.info('Parsing file:', filePath)
156166

167+
// Validate that filePath is not empty
168+
if (!filePath || filePath.trim() === '') {
169+
return {
170+
success: false,
171+
error: 'Empty file path provided',
172+
filePath: filePath || '',
173+
}
174+
}
175+
157176
// Validate path for security before any processing
158177
const pathValidation = validateFilePath(filePath)
159178
if (!pathValidation.isValid) {
@@ -337,7 +356,7 @@ async function handleLocalFile(filePath: string, fileType?: string): Promise<Par
337356
try {
338357
// Extract filename from path
339358
const filename = filePath.split('/').pop() || filePath
340-
const fullPath = path.join(UPLOAD_DIR, filename)
359+
const fullPath = path.join(UPLOAD_DIR_SERVER, filename)
341360

342361
logger.info('Processing local file:', fullPath)
343362

apps/sim/app/api/files/presigned/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { v4 as uuidv4 } from 'uuid'
55
import { createLogger } from '@/lib/logs/console/logger'
66
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
7-
import { getBlobServiceClient } from '@/lib/uploads/blob/blob-client'
8-
import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-client'
7+
// Dynamic imports for storage clients to avoid client-side bundling
98
import {
109
BLOB_CHAT_CONFIG,
1110
BLOB_CONFIG,
@@ -169,6 +168,7 @@ async function handleS3PresignedUrl(
169168

170169
const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}`
171170

171+
const { sanitizeFilenameForMetadata } = await import('@/lib/uploads/s3/s3-client')
172172
const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
173173

174174
const metadata: Record<string, string> = {
@@ -194,6 +194,7 @@ async function handleS3PresignedUrl(
194194

195195
let presignedUrl: string
196196
try {
197+
const { getS3Client } = await import('@/lib/uploads/s3/s3-client')
197198
presignedUrl = await getSignedUrl(getS3Client(), command, { expiresIn: 3600 })
198199
} catch (s3Error) {
199200
logger.error('Failed to generate S3 presigned URL:', s3Error)
@@ -272,6 +273,7 @@ async function handleBlobPresignedUrl(
272273

273274
const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}`
274275

276+
const { getBlobServiceClient } = await import('@/lib/uploads/blob/blob-client')
275277
const blobServiceClient = getBlobServiceClient()
276278
const containerClient = blobServiceClient.getContainerClient(config.containerName)
277279
const blockBlobClient = containerClient.getBlockBlobClient(uniqueKey)

apps/sim/app/api/files/upload/route.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ describe('File Upload API Route', () => {
2626

2727
beforeEach(() => {
2828
vi.resetModules()
29-
vi.doMock('@/lib/uploads/setup.server', () => ({}))
29+
vi.doMock('@/lib/uploads/setup.server', () => ({
30+
UPLOAD_DIR_SERVER: '/tmp/test-uploads',
31+
}))
3032
})
3133

3234
afterEach(() => {
@@ -52,15 +54,22 @@ describe('File Upload API Route', () => {
5254
const response = await POST(req)
5355
const data = await response.json()
5456

57+
// Log error details if test fails
58+
if (response.status !== 200) {
59+
console.error('Upload failed with status:', response.status)
60+
console.error('Error response:', data)
61+
}
62+
5563
expect(response.status).toBe(200)
5664
expect(data).toHaveProperty('path')
5765
expect(data.path).toMatch(/\/api\/files\/serve\/.*\.txt$/)
5866
expect(data).toHaveProperty('name', 'test.txt')
5967
expect(data).toHaveProperty('size')
6068
expect(data).toHaveProperty('type', 'text/plain')
6169

62-
const fs = await import('fs/promises')
63-
expect(fs.writeFile).toHaveBeenCalled()
70+
// Verify the upload function was called (we're mocking at the uploadFile level)
71+
const { uploadFile } = await import('@/lib/uploads')
72+
expect(uploadFile).toHaveBeenCalled()
6473
})
6574

6675
it('should upload a file to S3 when in S3 mode', async () => {

0 commit comments

Comments
 (0)