Skip to content

Commit 9df6dff

Browse files
committed
fix(folders): close workflow-folder restore lock race, align restore error codes
Three related restore-path bugs found by Greptile/Cursor this round: 1. performRestoreFolder (workflow folders) read the folder's own `locked` flag BEFORE opening the restore transaction. A folder locked in the window between that read and the transaction's write could still be resurrected and mutated. Restructured to match performRestoreResourceFolder (kb/table): the not_found/not_archived/locked checks and the restore writes all now happen inside one transaction. 2. Restoring an already-active (non-archived) workflow folder silently returned 200/success with zero restoredItems, while the same request for knowledge base, table, and file folders correctly returns a 400 validation error. Now returns errorCode: 'validation' -> 400, consistent with the other three resource types. 3. performRestoreFileFolder's 'Folder not found' case returned no errorCode, so it fell through the route's default and mapped to 400 instead of 404 (workflow/kb/table restores already mapped this correctly). Added errorCode: 'not_found' to that branch, plus errorCode: 'conflict'/'validation'/'internal' to the sibling branches for consistency with performCreateFileFolder's existing pattern, and stopped leaking the raw internal error message to the client on an unexpected failure.
1 parent cdd5045 commit 9df6dff

2 files changed

Lines changed: 47 additions & 30 deletions

File tree

apps/sim/lib/folders/orchestration.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,18 @@ async function performRestoreFileFolder(
311311
return {
312312
success: false,
313313
error: 'A folder with this name already exists in this location',
314+
errorCode: 'conflict',
314315
}
315316
}
316317
const message = toError(error).message
317-
if (message === 'Folder not found' || message === 'Folder is not archived') {
318-
return { success: false, error: message }
318+
if (message === 'Folder not found') {
319+
return { success: false, error: message, errorCode: 'not_found' }
320+
}
321+
if (message === 'Folder is not archived') {
322+
return { success: false, error: message, errorCode: 'validation' }
319323
}
320324
logger.error('Failed to restore file folder', { error })
321-
return { success: false, error: message }
325+
return { success: false, error: 'Internal server error', errorCode: 'internal' }
322326
}
323327
}
324328

apps/sim/lib/workflows/orchestration/folder-lifecycle.ts

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -474,32 +474,30 @@ export async function performRestoreFolder(
474474
): Promise<PerformRestoreFolderResult> {
475475
const { folderId, workspaceId, userId, folderName } = params
476476

477-
const [existingFolder] = await db
478-
.select()
479-
.from(folder)
480-
.where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId), isWorkflowFolder))
481-
482-
if (!existingFolder) {
483-
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
484-
}
485-
486-
if (!existingFolder.deletedAt) {
487-
return { success: true, restoredItems: { folders: 0, workflows: 0 } }
488-
}
489-
490-
// The folder row is soft-deleted, so the generic lock engine (which only reads
491-
// active rows) can't see it -- check its own `locked` flag directly.
492-
if (existingFolder.locked) {
493-
throw new ResourceLockedError('workflow', false, 'Folder is locked')
494-
}
495-
496477
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
497478
const ws = await getWorkspaceWithOwner(workspaceId)
498479
if (!ws || ws.archivedAt) {
499480
return { success: false, error: 'Cannot restore folder into an archived workspace' }
500481
}
501482

502-
const restoredStats = await db.transaction(async (tx) => {
483+
const outcome = await db.transaction(async (tx) => {
484+
const [existingFolder] = await tx
485+
.select()
486+
.from(folder)
487+
.where(and(eq(folder.id, folderId), eq(folder.workspaceId, workspaceId), isWorkflowFolder))
488+
489+
if (!existingFolder) return { kind: 'not_found' as const }
490+
if (!existingFolder.deletedAt) return { kind: 'not_archived' as const }
491+
492+
// The folder row is soft-deleted, so the generic lock engine (which only reads
493+
// active rows) can't see it -- check its own `locked` flag directly. This read
494+
// must happen inside the same transaction as the restore write below (not before
495+
// it opens): otherwise a concurrent lock landing between the read and the write
496+
// could still resurrect and mutate a folder that is now locked.
497+
if (existingFolder.locked) {
498+
throw new ResourceLockedError('workflow', false, 'Folder is locked')
499+
}
500+
503501
let resolvedParentId = existingFolder.parentId
504502
if (resolvedParentId) {
505503
const [parentFolder] = await tx
@@ -520,26 +518,41 @@ export async function performRestoreFolder(
520518
// TOCTOU window where a concurrent request locks resolvedParentId in between.
521519
await assertFolderMutable(resolvedParentId, 'workflow', tx)
522520

523-
return restoreFolderRecursively(folderId, workspaceId, existingFolder.deletedAt!, tx)
521+
const stats = await restoreFolderRecursively(
522+
folderId,
523+
workspaceId,
524+
existingFolder.deletedAt,
525+
tx
526+
)
527+
return { kind: 'ok' as const, stats, name: existingFolder.name }
524528
})
525529

526-
logger.info('Restored folder and all contents:', { folderId, restoredStats })
530+
if (outcome.kind === 'not_found') {
531+
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
532+
}
533+
if (outcome.kind === 'not_archived') {
534+
return { success: false, error: 'Folder is not archived', errorCode: 'validation' }
535+
}
536+
537+
const { stats, name } = outcome
538+
539+
logger.info('Restored folder and all contents:', { folderId, restoredStats: stats })
527540

528541
recordAudit({
529542
workspaceId,
530543
actorId: userId,
531544
action: AuditAction.FOLDER_RESTORED,
532545
resourceType: AuditResourceType.FOLDER,
533546
resourceId: folderId,
534-
resourceName: folderName ?? existingFolder.name,
535-
description: `Restored folder "${folderName ?? existingFolder.name}"`,
547+
resourceName: folderName ?? name,
548+
description: `Restored folder "${folderName ?? name}"`,
536549
metadata: {
537550
affected: {
538-
workflows: restoredStats.workflows,
539-
subfolders: restoredStats.folders - 1,
551+
workflows: stats.workflows,
552+
subfolders: stats.folders - 1,
540553
},
541554
},
542555
})
543556

544-
return { success: true, restoredItems: restoredStats }
557+
return { success: true, restoredItems: stats }
545558
}

0 commit comments

Comments
 (0)