Skip to content

Commit 7f6eb10

Browse files
author
Theodore Li
committed
Merge branch 'staging' into feat/migrate-byok-script
2 parents b42691b + a2f8ed0 commit 7f6eb10

File tree

14 files changed

+425
-32
lines changed

14 files changed

+425
-32
lines changed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ export async function POST(request: NextRequest) {
4646

4747
const formData = await request.formData()
4848

49-
const files = formData.getAll('file') as File[]
49+
const rawFiles = formData.getAll('file')
50+
const files = rawFiles.filter((f): f is File => f instanceof File)
5051

51-
if (!files || files.length === 0) {
52+
if (files.length === 0) {
5253
throw new InvalidRequestError('No files provided')
5354
}
5455

@@ -73,7 +74,7 @@ export async function POST(request: NextRequest) {
7374
const uploadResults = []
7475

7576
for (const file of files) {
76-
const originalName = file.name
77+
const originalName = file.name || 'untitled'
7778

7879
if (!validateFileExtension(originalName)) {
7980
const extension = originalName.split('.').pop()?.toLowerCase() || 'unknown'

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {
2020
} from '@/lib/execution/call-chain'
2121
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
2222
import { processInputFileFields } from '@/lib/execution/files'
23+
import {
24+
registerManualExecutionAborter,
25+
unregisterManualExecutionAborter,
26+
} from '@/lib/execution/manual-cancellation'
2327
import { preprocessExecution } from '@/lib/execution/preprocessing'
2428
import { LoggingSession } from '@/lib/logs/execution/logging-session'
2529
import {
@@ -845,6 +849,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
845849
const encoder = new TextEncoder()
846850
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
847851
let isStreamClosed = false
852+
let isManualAbortRegistered = false
848853

849854
const eventWriter = createExecutionEventWriter(executionId)
850855
setExecutionMeta(executionId, {
@@ -857,6 +862,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
857862
async start(controller) {
858863
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
859864

865+
registerManualExecutionAborter(executionId, timeoutController.abort)
866+
isManualAbortRegistered = true
867+
860868
const sendEvent = (event: ExecutionEvent) => {
861869
if (!isStreamClosed) {
862870
try {
@@ -1224,6 +1232,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
12241232
})
12251233
finalMetaStatus = 'error'
12261234
} finally {
1235+
if (isManualAbortRegistered) {
1236+
unregisterManualExecutionAborter(executionId)
1237+
isManualAbortRegistered = false
1238+
}
12271239
try {
12281240
await eventWriter.close()
12291241
} catch (closeError) {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mockCheckHybridAuth = vi.fn()
9+
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
10+
const mockMarkExecutionCancelled = vi.fn()
11+
const mockAbortManualExecution = vi.fn()
12+
13+
vi.mock('@sim/logger', () => ({
14+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
15+
}))
16+
17+
vi.mock('@/lib/auth/hybrid', () => ({
18+
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
19+
}))
20+
21+
vi.mock('@/lib/execution/cancellation', () => ({
22+
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
23+
}))
24+
25+
vi.mock('@/lib/execution/manual-cancellation', () => ({
26+
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
27+
}))
28+
29+
vi.mock('@/lib/workflows/utils', () => ({
30+
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
31+
mockAuthorizeWorkflowByWorkspacePermission(params),
32+
}))
33+
34+
import { POST } from './route'
35+
36+
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
40+
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
41+
mockAbortManualExecution.mockReturnValue(false)
42+
})
43+
44+
it('returns success when cancellation was durably recorded', async () => {
45+
mockMarkExecutionCancelled.mockResolvedValue({
46+
durablyRecorded: true,
47+
reason: 'recorded',
48+
})
49+
50+
const response = await POST(
51+
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
52+
method: 'POST',
53+
}),
54+
{
55+
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
56+
}
57+
)
58+
59+
expect(response.status).toBe(200)
60+
await expect(response.json()).resolves.toEqual({
61+
success: true,
62+
executionId: 'ex-1',
63+
redisAvailable: true,
64+
durablyRecorded: true,
65+
locallyAborted: false,
66+
reason: 'recorded',
67+
})
68+
})
69+
70+
it('returns unsuccessful response when Redis is unavailable', async () => {
71+
mockMarkExecutionCancelled.mockResolvedValue({
72+
durablyRecorded: false,
73+
reason: 'redis_unavailable',
74+
})
75+
76+
const response = await POST(
77+
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
78+
method: 'POST',
79+
}),
80+
{
81+
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
82+
}
83+
)
84+
85+
expect(response.status).toBe(200)
86+
await expect(response.json()).resolves.toEqual({
87+
success: false,
88+
executionId: 'ex-1',
89+
redisAvailable: false,
90+
durablyRecorded: false,
91+
locallyAborted: false,
92+
reason: 'redis_unavailable',
93+
})
94+
})
95+
96+
it('returns unsuccessful response when Redis persistence fails', async () => {
97+
mockMarkExecutionCancelled.mockResolvedValue({
98+
durablyRecorded: false,
99+
reason: 'redis_write_failed',
100+
})
101+
102+
const response = await POST(
103+
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
104+
method: 'POST',
105+
}),
106+
{
107+
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
108+
}
109+
)
110+
111+
expect(response.status).toBe(200)
112+
await expect(response.json()).resolves.toEqual({
113+
success: false,
114+
executionId: 'ex-1',
115+
redisAvailable: true,
116+
durablyRecorded: false,
117+
locallyAborted: false,
118+
reason: 'redis_write_failed',
119+
})
120+
})
121+
122+
it('returns success when local fallback aborts execution without Redis durability', async () => {
123+
mockMarkExecutionCancelled.mockResolvedValue({
124+
durablyRecorded: false,
125+
reason: 'redis_unavailable',
126+
})
127+
mockAbortManualExecution.mockReturnValue(true)
128+
129+
const response = await POST(
130+
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
131+
method: 'POST',
132+
}),
133+
{
134+
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
135+
}
136+
)
137+
138+
expect(response.status).toBe(200)
139+
await expect(response.json()).resolves.toEqual({
140+
success: true,
141+
executionId: 'ex-1',
142+
redisAvailable: false,
143+
durablyRecorded: false,
144+
locallyAborted: true,
145+
reason: 'redis_unavailable',
146+
})
147+
})
148+
})

apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { checkHybridAuth } from '@/lib/auth/hybrid'
44
import { markExecutionCancelled } from '@/lib/execution/cancellation'
5+
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
56
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
67

78
const logger = createLogger('CancelExecutionAPI')
@@ -45,20 +46,27 @@ export async function POST(
4546

4647
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
4748

48-
const marked = await markExecutionCancelled(executionId)
49+
const cancellation = await markExecutionCancelled(executionId)
50+
const locallyAborted = abortManualExecution(executionId)
4951

50-
if (marked) {
52+
if (cancellation.durablyRecorded) {
5153
logger.info('Execution marked as cancelled in Redis', { executionId })
54+
} else if (locallyAborted) {
55+
logger.info('Execution cancelled via local in-process fallback', { executionId })
5256
} else {
53-
logger.info('Redis not available, cancellation will rely on connection close', {
57+
logger.warn('Execution cancellation was not durably recorded', {
5458
executionId,
59+
reason: cancellation.reason,
5560
})
5661
}
5762

5863
return NextResponse.json({
59-
success: true,
64+
success: cancellation.durablyRecorded || locallyAborted,
6065
executionId,
61-
redisAvailable: marked,
66+
redisAvailable: cancellation.reason !== 'redis_unavailable',
67+
durablyRecorded: cancellation.durablyRecorded,
68+
locallyAborted,
69+
reason: cancellation.reason,
6270
})
6371
} catch (error: any) {
6472
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })

apps/sim/app/api/workspaces/[id]/files/route.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,32 +87,33 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
8787
}
8888

8989
const formData = await request.formData()
90-
const file = formData.get('file') as File
90+
const rawFile = formData.get('file')
9191

92-
if (!file) {
92+
if (!rawFile || !(rawFile instanceof File)) {
9393
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
9494
}
9595

96-
// Validate file size (100MB limit)
96+
const fileName = rawFile.name || 'untitled'
97+
9798
const maxSize = 100 * 1024 * 1024
98-
if (file.size > maxSize) {
99+
if (rawFile.size > maxSize) {
99100
return NextResponse.json(
100-
{ error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)` },
101+
{ error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` },
101102
{ status: 400 }
102103
)
103104
}
104105

105-
const buffer = Buffer.from(await file.arrayBuffer())
106+
const buffer = Buffer.from(await rawFile.arrayBuffer())
106107

107108
const userFile = await uploadWorkspaceFile(
108109
workspaceId,
109110
session.user.id,
110111
buffer,
111-
file.name,
112-
file.type || 'application/octet-stream'
112+
fileName,
113+
rawFile.type || 'application/octet-stream'
113114
)
114115

115-
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
116+
logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`)
116117

117118
recordAudit({
118119
workspaceId,
@@ -122,8 +123,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
122123
action: AuditAction.FILE_UPLOADED,
123124
resourceType: AuditResourceType.FILE,
124125
resourceId: userFile.id,
125-
resourceName: file.name,
126-
description: `Uploaded file "${file.name}"`,
126+
resourceName: fileName,
127+
description: `Uploaded file "${fileName}"`,
127128
request,
128129
})
129130

apps/sim/executor/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,10 @@ export function isValidEnvVarName(name: string): boolean {
439439
return PATTERNS.ENV_VAR_NAME.test(name)
440440
}
441441

442-
export function sanitizeFileName(fileName: string): string {
442+
export function sanitizeFileName(fileName: string | null | undefined): string {
443+
if (!fileName || typeof fileName !== 'string') {
444+
return 'untitled'
445+
}
443446
return fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
444447
}
445448

0 commit comments

Comments
 (0)