Skip to content

Commit 427b260

Browse files
committed
fix(uploads): roll back folders archive extraction created
Extraction now materializes folders before uploading files, but the failure path only deleted the extracted files — every folder the call created was left behind. That is not cosmetic: `materialize_file` guards re-extraction by looking up the root folder path and refusing when it has any child, so a half-extracted nested archive turned every retry into "already extracted — delete that folder first" until a human cleaned up the tree by hand. The rollback must delete only folders this call actually inserted, never one it reused: extracting into an existing path is normal (a sibling entry, an earlier successful extraction), and deleting a pre-existing folder would destroy unrelated user data. `ensureWorkspaceFileFolderPath` already distinguishes the two while walking the segment chain, so it (and its application operation) now reports `createdFolderIds` alongside the leaf id. The extractor accumulates those ids in creation order and, on failure, deletes them in reverse — parents are recorded before their children, so reverse order is deepest-first and a parent is never removed out from under a child. Folder cleanup is best-effort like the existing file cleanup, so a cleanup failure never masks the original error.
1 parent 0077285 commit 427b260

7 files changed

Lines changed: 190 additions & 22 deletions

File tree

apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,10 @@ describe('vfs mv/cp', () => {
237237
return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId: null })
238238
})
239239
mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null)
240-
mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder')
240+
mocks.ensureWorkspaceFileFolderPath.mockResolvedValue({
241+
folderId: 'ensured-folder',
242+
createdFolderIds: [],
243+
})
241244
mocks.ensureCopilotFileFolderPath.mockResolvedValue('ensured-folder')
242245
mocks.moveWorkspaceFileItems.mockResolvedValue({ movedItems: { files: 1, folders: 0 } })
243246
mocks.updateWorkspaceFileFolder.mockResolvedValue({ folder: { name: 'Reports 2025' } })

apps/sim/lib/copilot/vfs/resource-writer.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ import {
5353
describe('resource writer', () => {
5454
beforeEach(() => {
5555
vi.clearAllMocks()
56-
mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-id')
56+
mocks.ensureWorkspaceFileFolderPath.mockResolvedValue({
57+
folderId: 'folder-id',
58+
createdFolderIds: [],
59+
})
5760
mocks.admitCreateWorkspaceFile.mockResolvedValue(undefined)
5861
})
5962

@@ -107,7 +110,10 @@ describe('resource writer', () => {
107110
})
108111

109112
it('auto-creates missing parent folders for plain workspace file creates', async () => {
110-
mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested')
113+
mocks.ensureWorkspaceFileFolderPath.mockResolvedValue({
114+
folderId: 'folder-nested',
115+
createdFolderIds: [],
116+
})
111117
mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({
112118
id: 'file-report',
113119
name: 'summary.csv',

apps/sim/lib/uploads/archive.test.ts

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1616
* - `exactName: true` throws `FileConflictError` on a duplicate leaf name, while
1717
* `exactName: false` auto-suffixes, mirroring `uploadWorkspaceFile`.
1818
*/
19-
const { store, mockUpload, mockDelete, mockEnsureFolder } = vi.hoisted(() => ({
19+
const { store, mockUpload, mockDelete, mockEnsureFolder, mockDeleteFolder } = vi.hoisted(() => ({
2020
store: {
2121
folderIdByPath: new Map<string, string>(),
2222
fileKeys: new Set<string>(),
23+
/** Paths passed to the folder-delete operation, in call order. */
24+
deletedFolderPaths: [] as string[],
2325
sequence: 0,
2426
},
2527
mockUpload: vi.fn(),
2628
mockDelete: vi.fn(),
2729
mockEnsureFolder: vi.fn(),
30+
mockDeleteFolder: vi.fn(),
2831
}))
2932
vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({
3033
ensureWorkspaceFileFolderPathOperation: { execute: mockEnsureFolder },
34+
deleteWorkspaceFileFolderOperation: { execute: mockDeleteFolder },
3135
}))
3236
vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({
3337
createWorkspaceFileFromBuffer: {
@@ -109,6 +113,21 @@ function allocateUniqueName(folderKey: string, name: string): string {
109113
}
110114
}
111115

116+
/** Reverse lookup of the fake folder store: id -> path, or `undefined` if gone. */
117+
function folderPathById(folderId: string | undefined): string | undefined {
118+
for (const [path, id] of store.folderIdByPath) {
119+
if (id === folderId) return path
120+
}
121+
return undefined
122+
}
123+
124+
/** Pre-seeds a folder chain that existed before extraction ran. */
125+
function seedExistingFolders(...paths: string[][]): void {
126+
for (const segments of paths) {
127+
store.folderIdByPath.set(buildFolderPath(segments), `preexisting_${++store.sequence}`)
128+
}
129+
}
130+
112131
/** Pre-seeds an already-existing workspace file so a later leaf name collides. */
113132
function seedExistingFile(folderId: string | null, name: string): void {
114133
store.fileKeys.add(`${folderId ?? ''}|${name}`)
@@ -118,11 +137,15 @@ beforeEach(() => {
118137
vi.clearAllMocks()
119138
store.folderIdByPath.clear()
120139
store.fileKeys.clear()
140+
store.deletedFolderPaths.length = 0
121141
store.sequence = 0
122142

123143
mockEnsureFolder.mockImplementation(async ({ input }: { input: { pathSegments: string[] } }) => {
124144
let folderId: string | null = null
125145
const walked: string[] = []
146+
// Only the segments this call actually inserts are reported as created; a
147+
// segment resolved from the store was reused and must never be rolled back.
148+
const createdFolderIds: string[] = []
126149
for (const segment of input.pathSegments) {
127150
walked.push(segment)
128151
const path = buildFolderPath(walked)
@@ -133,10 +156,28 @@ beforeEach(() => {
133156
}
134157
folderId = `folder_${++store.sequence}`
135158
store.folderIdByPath.set(path, folderId)
159+
createdFolderIds.push(folderId)
136160
}
137-
return { folderId }
161+
return { folderId, createdFolderIds }
138162
})
139163

164+
mockDeleteFolder.mockImplementation(
165+
async ({ input }: { input: { folderId?: string; recursive?: boolean } }) => {
166+
const path = folderPathById(input.folderId)
167+
// Mirrors `deleteWorkspaceFileFolderOperation`, which raises `not_found` when
168+
// nothing was archived — deleting a parent before its children would make the
169+
// child's own delete hit this.
170+
if (!path) throw new Error('Folder not found')
171+
store.deletedFolderPaths.push(path)
172+
for (const [candidate] of store.folderIdByPath) {
173+
if (candidate === path || candidate.startsWith(`${path}/`)) {
174+
store.folderIdByPath.delete(candidate)
175+
}
176+
}
177+
return { deletedItems: { files: 0, folders: 1 } }
178+
}
179+
)
180+
140181
mockDelete.mockResolvedValue(undefined)
141182
mockUpload.mockImplementation(
142183
async ({
@@ -443,6 +484,84 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => {
443484
)
444485
})
445486

487+
it('rolls back the folders it created when an upload fails mid-extraction', async () => {
488+
// `materialize_file` refuses to re-extract into a root folder that still has any
489+
// child, so a folder left behind by a failed run turns every retry into
490+
// "already extracted" until a human deletes the tree by hand.
491+
const buffer = await buildZip({ 'a/one.txt': 'first', 'b/two.txt': 'second' })
492+
mockUpload
493+
.mockResolvedValueOnce({
494+
file: { id: 'f_one', name: 'one.txt', url: '/one', key: 'k/one', size: 5 },
495+
})
496+
.mockRejectedValueOnce(new Error('storage quota exceeded'))
497+
498+
await expect(
499+
decompressArchiveBufferToWorkspaceFiles(buffer, {
500+
workspaceId: 'ws',
501+
principal: TEST_PRINCIPAL,
502+
rootFolderSegments: ['bundle'],
503+
})
504+
).rejects.toThrow('storage quota exceeded')
505+
506+
expect([...store.folderIdByPath.keys()]).toEqual([])
507+
expect(mockDeleteFolder).toHaveBeenCalledWith(
508+
expect.objectContaining({
509+
input: expect.objectContaining({ workspaceId: 'ws', recursive: true }),
510+
})
511+
)
512+
})
513+
514+
it('leaves a folder that already existed before the call untouched on rollback', async () => {
515+
// Extracting into an existing path is normal — a sibling entry, or an earlier
516+
// successful extraction. Deleting a reused folder would destroy unrelated data.
517+
seedExistingFolders(['bundle'], ['bundle', 'keep'])
518+
const preexistingIds = [...store.folderIdByPath.values()]
519+
const buffer = await buildZip({ 'keep/kept.txt': 'a', 'fresh/new.txt': 'b' })
520+
mockUpload
521+
.mockResolvedValueOnce({
522+
file: { id: 'f_kept', name: 'kept.txt', url: '/kept', key: 'k/kept', size: 1 },
523+
})
524+
.mockRejectedValueOnce(new Error('storage quota exceeded'))
525+
526+
await expect(
527+
decompressArchiveBufferToWorkspaceFiles(buffer, {
528+
workspaceId: 'ws',
529+
principal: TEST_PRINCIPAL,
530+
rootFolderSegments: ['bundle'],
531+
})
532+
).rejects.toThrow('storage quota exceeded')
533+
534+
expect([...store.folderIdByPath.keys()].sort()).toEqual(['/bundle', '/bundle/keep'])
535+
expect(store.deletedFolderPaths).toEqual(['/bundle/fresh'])
536+
const deletedIds = mockDeleteFolder.mock.calls.map(([args]) => args.input.folderId)
537+
for (const preexistingId of preexistingIds) {
538+
expect(deletedIds).not.toContain(preexistingId)
539+
}
540+
})
541+
542+
it('deletes rolled-back folders deepest-first', async () => {
543+
// A parent removed before its children would make the children's own deletes
544+
// fail (nothing left to archive), so the unwind walks creation order backwards.
545+
const buffer = await buildZip({ 'x/y/z/leaf.txt': 'a' })
546+
mockUpload.mockRejectedValueOnce(new Error('storage quota exceeded'))
547+
548+
await expect(
549+
decompressArchiveBufferToWorkspaceFiles(buffer, {
550+
workspaceId: 'ws',
551+
principal: TEST_PRINCIPAL,
552+
rootFolderSegments: ['bundle'],
553+
})
554+
).rejects.toThrow('storage quota exceeded')
555+
556+
expect(store.deletedFolderPaths).toEqual([
557+
'/bundle/x/y/z',
558+
'/bundle/x/y',
559+
'/bundle/x',
560+
'/bundle',
561+
])
562+
expect([...store.folderIdByPath.keys()]).toEqual([])
563+
})
564+
446565
it('does not count noise entries toward the extraction cap when they are being skipped', async () => {
447566
// macOS Finder zips carry a __MACOSX/._* shadow per file, doubling the raw
448567
// entry count. 501 files + 501 shadows = 1002 raw entries — over the

apps/sim/lib/uploads/archive.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/works
77
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
88
import { createWorkspaceFileFromBuffer } from '@/lib/workspace-files/application/create-workspace-file'
99
import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file'
10-
import { ensureWorkspaceFileFolderPathOperation } from '@/lib/workspace-files/application/workspace-file-folders'
10+
import {
11+
deleteWorkspaceFileFolderOperation,
12+
ensureWorkspaceFileFolderPathOperation,
13+
} from '@/lib/workspace-files/application/workspace-file-folders'
1114
import type { UserFile } from '@/executor/types'
1215

1316
/**
@@ -345,9 +348,14 @@ export async function decompressArchiveBufferToWorkspaceFiles(
345348

346349
// Pass 2 — extract: the archive is proven within caps; inflate again and upload.
347350
// Uploads themselves can still fail mid-loop (storage/DB errors, quota crossed
348-
// by another writer), so a failure rolls back every file written so far —
349-
// callers and their retries must never observe a partial tree.
351+
// by another writer), so a failure rolls back every file written so far *and*
352+
// every folder this call materialized — callers and their retries must never
353+
// observe a partial tree. Leftover folders are not cosmetic: `materialize_file`
354+
// refuses to re-extract into a root folder that still has any child, so a
355+
// half-extracted tree would make every retry fail until a human deletes it.
350356
const folderIdCache = new Map<string, string | null>()
357+
/** Only folders this call inserted, in creation order — never a reused one. */
358+
const createdFolderIds: string[] = []
351359
const extracted: UserFile[] = []
352360
let totalBytes = 0
353361
try {
@@ -365,12 +373,12 @@ export async function decompressArchiveBufferToWorkspaceFiles(
365373
// Ensure-semantics, not create-semantics: an archive addresses every folder
366374
// by its full chain, so intermediates must be materialized and any folder
367375
// that already exists (from a sibling entry or an earlier extraction) reused.
368-
folderId = (
369-
await ensureWorkspaceFileFolderPathOperation.execute({
370-
principal,
371-
input: { workspaceId, pathSegments: folderSegments },
372-
})
373-
).folderId
376+
const ensured = await ensureWorkspaceFileFolderPathOperation.execute({
377+
principal,
378+
input: { workspaceId, pathSegments: folderSegments },
379+
})
380+
folderId = ensured.folderId
381+
createdFolderIds.push(...ensured.createdFolderIds)
374382
folderIdCache.set(folderKey, folderId)
375383
}
376384

@@ -413,6 +421,19 @@ export async function decompressArchiveBufferToWorkspaceFiles(
413421
// the original error is what the caller needs to see.
414422
}
415423
}
424+
// Deepest-first (creation order records parents before children), so a parent is
425+
// never removed out from under a child that is still being cleaned up.
426+
for (let index = createdFolderIds.length - 1; index >= 0; index--) {
427+
try {
428+
await deleteWorkspaceFileFolderOperation.execute({
429+
principal,
430+
input: { workspaceId, folderId: createdFolderIds[index], recursive: true },
431+
})
432+
} catch {
433+
// Best-effort: a folder whose cleanup fails is still deletable by hand;
434+
// the original error is what the caller needs to see.
435+
}
436+
}
416437
throw error
417438
}
418439

apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,18 +564,31 @@ export async function createWorkspaceFileFolder(params: {
564564
return mapFolderWithPath(params.workspaceId, folder)
565565
}
566566

567+
/**
568+
* Outcome of {@link ensureWorkspaceFileFolderPath}. `createdFolderIds` lists only the
569+
* folders this call actually inserted, outermost-first, so a caller that has to unwind
570+
* a partial write can delete exactly what it added (reverse the list for deepest-first)
571+
* without ever touching a folder that was merely reused.
572+
*/
573+
export interface EnsureWorkspaceFileFolderPathOutcome {
574+
/** Id of the deepest folder, or `null` when the path resolves to the root. */
575+
folderId: string | null
576+
/** Ids inserted by this call, in creation order (parents before children). */
577+
createdFolderIds: string[]
578+
}
579+
567580
export async function ensureWorkspaceFileFolderPath(params: {
568581
workspaceId: string
569582
userId: string
570583
pathSegments: string[]
571-
}): Promise<string | null> {
572-
if (params.pathSegments.length === 0) return null
584+
}): Promise<EnsureWorkspaceFileFolderPathOutcome> {
585+
if (params.pathSegments.length === 0) return { folderId: null, createdFolderIds: [] }
573586

574587
// Fast path: the whole chain already exists (the common case for repeated
575588
// writes into known folders) — per-segment indexed lookups instead of
576589
// loading the workspace's entire folder table.
577590
const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments)
578-
if (existing) return existing
591+
if (existing) return { folderId: existing, createdFolderIds: [] }
579592

580593
// Load all active folders once and build a lookup keyed by "name|parentId"
581594
// so we can resolve existing segments without a per-segment SELECT.
@@ -597,6 +610,7 @@ export async function ensureWorkspaceFileFolderPath(params: {
597610
}
598611

599612
let parentId: string | null = null
613+
const createdFolderIds: string[] = []
600614

601615
for (const rawSegment of params.pathSegments) {
602616
const name = normalizeWorkspaceFileItemName(rawSegment, 'Folder')
@@ -629,6 +643,7 @@ export async function ensureWorkspaceFileFolderPath(params: {
629643
updatedAt: created.updatedAt,
630644
})
631645
parentId = created.id
646+
createdFolderIds.push(created.id)
632647
} catch (error) {
633648
if (
634649
error instanceof WorkspaceFileFolderConflictError ||
@@ -654,7 +669,7 @@ export async function ensureWorkspaceFileFolderPath(params: {
654669
}
655670
}
656671

657-
return parentId
672+
return { folderId: parentId, createdFolderIds }
658673
}
659674

660675
export async function updateWorkspaceFileFolder(params: {

apps/sim/lib/workspace-files/application/workspace-file-folders.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export interface EnsureWorkspaceFileFolderPathInput {
5656
export interface EnsureWorkspaceFileFolderPathResult {
5757
/** Id of the deepest folder, or `null` when the path resolves to the root. */
5858
folderId: string | null
59+
/**
60+
* Ids this call inserted, outermost-first — never a folder it reused. Callers that
61+
* materialize a tree use it to unwind exactly their own writes on failure.
62+
*/
63+
createdFolderIds: string[]
5964
}
6065

6166
export interface UpdateWorkspaceFileFolderInput {
@@ -168,12 +173,11 @@ async function executeEnsureWorkspaceFileFolderPath(args: {
168173
const attribution = resolvePrincipalAttribution(args.principal, {
169174
workspaceBillingOwnerUserId: args.context.billedAccountUserId,
170175
})
171-
const folderId = await ensureWorkspaceFileFolderPath({
176+
return ensureWorkspaceFileFolderPath({
172177
workspaceId: args.context.workspaceId,
173178
userId: attribution.attributedUserId,
174179
pathSegments: args.input.pathSegments,
175180
})
176-
return { folderId }
177181
}
178182

179183
async function executeUpdateWorkspaceFileFolder(args: {

apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ async function executeCreate({
8585

8686
const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId)
8787

88-
const folderId = await ensureWorkspaceFileFolderPath({
88+
const { folderId } = await ensureWorkspaceFileFolderPath({
8989
workspaceId: input.workspaceId,
9090
userId: folderUserId,
9191
pathSegments: parsed.folderSegments,
@@ -145,7 +145,7 @@ async function executeCreateBuffer({
145145
const parsed = parseWorkspaceFileCreatePath(input.path)
146146
await admitCreateWorkspaceFile(principal, input.workspaceId)
147147
const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId)
148-
const folderId = await ensureWorkspaceFileFolderPath({
148+
const { folderId } = await ensureWorkspaceFileFolderPath({
149149
workspaceId: input.workspaceId,
150150
userId: folderUserId,
151151
pathSegments: parsed.folderSegments,

0 commit comments

Comments
 (0)