Skip to content

Commit 3ec86ce

Browse files
committed
improvement(uploads): migrate execution-trigger file uploads to presigned PUT
1 parent 4cb6d65 commit 3ec86ce

4 files changed

Lines changed: 113 additions & 54 deletions

File tree

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { CopilotFiles } from '@/lib/uploads'
88
import type { StorageContext } from '@/lib/uploads/config'
99
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
10+
import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
1011
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1112
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
1213
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
@@ -23,6 +24,7 @@ const VALID_UPLOAD_TYPES = [
2324
'profile-pictures',
2425
'mothership',
2526
'workspace-logos',
27+
'execution',
2628
] as const
2729

2830
class PresignedUrlError extends Error {
@@ -155,6 +157,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
155157
expirationSeconds: 3600,
156158
metadata: { workspaceId },
157159
})
160+
} else if (uploadType === 'execution') {
161+
const workflowId = request.nextUrl.searchParams.get('workflowId')
162+
const executionId = request.nextUrl.searchParams.get('executionId')
163+
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
164+
if (!workflowId?.trim() || !executionId?.trim() || !workspaceId?.trim()) {
165+
throw new ValidationError(
166+
'workflowId, executionId, and workspaceId query parameters are required for execution uploads'
167+
)
168+
}
169+
170+
const fileValidationError = validateFileType(fileName, contentType)
171+
if (fileValidationError) {
172+
throw new ValidationError(fileValidationError.message)
173+
}
174+
175+
const customKey = generateExecutionFileKey({ workspaceId, workflowId, executionId }, fileName)
176+
presignedUrlResponse = await generatePresignedUploadUrl({
177+
fileName,
178+
contentType,
179+
fileSize,
180+
context: 'execution',
181+
userId: sessionUserId,
182+
customKey,
183+
expirationSeconds: 3600,
184+
metadata: { workspaceId, workflowId, executionId },
185+
})
158186
} else if (uploadType === 'workspace-logos') {
159187
const workspaceId = request.nextUrl.searchParams.get('workspaceId')
160188
if (!workspaceId?.trim()) {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 70 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { requestJson } from '@/lib/api/client/request'
1010
import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows'
1111
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
1212
import { processStreamingBlockLogs } from '@/lib/tokenization'
13+
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
1314
import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events'
1415
import {
1516
extractTriggerMockPayload,
@@ -505,65 +506,82 @@ export function useWorkflowExecution() {
505506
typeof (value as any).onUploadError === 'function'
506507
if (workflowInput.files && Array.isArray(workflowInput.files)) {
507508
try {
509+
const presignedEndpoint = `/api/files/presigned?type=execution&workflowId=${encodeURIComponent(activeWorkflowId)}&executionId=${encodeURIComponent(executionId)}&workspaceId=${encodeURIComponent(workspaceId)}`
508510
for (const fileData of workflowInput.files) {
509-
// Create FormData for upload
510-
const formData = new FormData()
511-
formData.append('file', fileData.file)
512-
formData.append('context', 'execution')
513-
formData.append('workflowId', activeWorkflowId)
514-
formData.append('executionId', executionId)
515-
formData.append('workspaceId', workspaceId)
516-
517-
// boundary-raw-fetch: multipart/form-data file upload, requestJson only supports JSON bodies
518-
const response = await fetch('/api/files/upload', {
519-
method: 'POST',
520-
body: formData,
521-
})
522-
523-
if (response.ok) {
524-
const uploadResult = await response.json()
525-
// Convert upload result to clean UserFile format
526-
const processUploadResult = (result: any) => ({
527-
id:
528-
result.id ||
529-
`file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
530-
name: result.name,
531-
url: result.url,
532-
size: result.size,
533-
type: result.type,
511+
try {
512+
const result = await runUploadStrategy({
513+
file: fileData.file,
514+
workspaceId,
515+
context: 'execution',
516+
presignedEndpoint,
517+
})
518+
uploadedFiles.push({
519+
id: `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
520+
name: fileData.file.name,
521+
url: result.path,
522+
size: fileData.file.size,
523+
type: fileData.file.type,
534524
key: result.key,
535-
uploadedAt: result.uploadedAt,
536-
expiresAt: result.expiresAt,
537525
})
538-
539-
// The API returns the file directly for single uploads
540-
// or { files: [...] } for multiple uploads
541-
if (uploadResult.files && Array.isArray(uploadResult.files)) {
542-
uploadedFiles.push(...uploadResult.files.map(processUploadResult))
543-
} else if (uploadResult.path || uploadResult.url) {
544-
// Single file upload - the result IS the file object
545-
uploadedFiles.push(processUploadResult(uploadResult))
526+
} catch (uploadError) {
527+
if (
528+
uploadError instanceof DirectUploadError &&
529+
uploadError.code === 'FALLBACK_REQUIRED'
530+
) {
531+
const formData = new FormData()
532+
formData.append('file', fileData.file)
533+
formData.append('context', 'execution')
534+
formData.append('workflowId', activeWorkflowId)
535+
formData.append('executionId', executionId)
536+
formData.append('workspaceId', workspaceId)
537+
538+
// boundary-raw-fetch: local-dev fallback when cloud storage is not configured; multipart upload incompatible with requestJson
539+
const response = await fetch('/api/files/upload', {
540+
method: 'POST',
541+
body: formData,
542+
})
543+
if (!response.ok) {
544+
const errorData = await response.json().catch(() => null)
545+
const reason =
546+
errorData?.message || errorData?.error || `${response.status}`
547+
const message = `Failed to upload ${fileData.name}: ${reason}`
548+
logger.error(message)
549+
if (isUploadErrorCapable(workflowInput)) {
550+
try {
551+
workflowInput.onUploadError(message)
552+
} catch {}
553+
}
554+
continue
555+
}
556+
const uploadResult = await response.json()
557+
const processUploadResult = (r: any) => ({
558+
id:
559+
r.id ||
560+
`file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
561+
name: r.name,
562+
url: r.url,
563+
size: r.size,
564+
type: r.type,
565+
key: r.key,
566+
uploadedAt: r.uploadedAt,
567+
expiresAt: r.expiresAt,
568+
})
569+
if (uploadResult.files && Array.isArray(uploadResult.files)) {
570+
uploadedFiles.push(...uploadResult.files.map(processUploadResult))
571+
} else if (uploadResult.path || uploadResult.url) {
572+
uploadedFiles.push(processUploadResult(uploadResult))
573+
}
546574
} else {
547-
logger.error('Unexpected upload response format:', uploadResult)
548-
}
549-
} else {
550-
const cloned = response.clone()
551-
const errorData = await response.json().catch(() => null)
552-
const reason =
553-
errorData?.message ||
554-
errorData?.error ||
555-
(await cloned.text().catch(() => '')) ||
556-
`${response.status}`
557-
const message = `Failed to upload ${fileData.name}: ${reason}`
558-
logger.error(message)
559-
if (isUploadErrorCapable(workflowInput)) {
560-
try {
561-
workflowInput.onUploadError(message)
562-
} catch {}
575+
const message = `Failed to upload ${fileData.name}: ${toError(uploadError).message}`
576+
logger.error(message)
577+
if (isUploadErrorCapable(workflowInput)) {
578+
try {
579+
workflowInput.onUploadError(message)
580+
} catch {}
581+
}
563582
}
564583
}
565584
}
566-
// Update workflow input with uploaded files
567585
workflowInput.files = uploadedFiles
568586
} catch (error) {
569587
logger.error('Error uploading files:', error)

apps/sim/lib/api/contracts/storage-transfer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ export const validUploadTypes = [
323323
'profile-pictures',
324324
'mothership',
325325
'workspace-logos',
326+
'execution',
326327
] as const
327328

328329
export const uploadTypeSchema = z.enum(validUploadTypes)

apps/sim/lib/uploads/client/direct-upload.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,13 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise<void> => {
303303
interface MultipartUploadOptions {
304304
file: File
305305
workspaceId: string
306-
context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos'
306+
context:
307+
| 'workspace'
308+
| 'knowledge-base'
309+
| 'mothership'
310+
| 'profile-pictures'
311+
| 'workspace-logos'
312+
| 'execution'
307313
signal?: AbortSignal
308314
onProgress?: (event: UploadProgressEvent) => void
309315
}
@@ -517,7 +523,13 @@ const uploadViaMultipart = async (
517523
export interface RunUploadStrategyOptions {
518524
file: File
519525
workspaceId: string
520-
context: 'workspace' | 'knowledge-base' | 'mothership' | 'profile-pictures' | 'workspace-logos'
526+
context:
527+
| 'workspace'
528+
| 'knowledge-base'
529+
| 'mothership'
530+
| 'profile-pictures'
531+
| 'workspace-logos'
532+
| 'execution'
521533
/** Endpoint to mint a presigned PUT URL. Required unless `presignedOverride` is provided. */
522534
presignedEndpoint?: string
523535
/** Pre-fetched presigned data (e.g. from a batch endpoint). Skips per-file fetch. */

0 commit comments

Comments
 (0)