Skip to content

Commit cd812b1

Browse files
committed
fix(files): keep folder creation working under a slash-named parent
1 parent b10d214 commit cd812b1

4 files changed

Lines changed: 104 additions & 36 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folde
1818
import { isFileInFolderScope } from '@/lib/workspace-files/folder-path-selection'
1919
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
2020
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
21+
import { readFolderPath } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sim-folder-tree-selector/selection'
2122
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
2223
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
2324
import {
@@ -341,8 +342,13 @@ export function FileUpload({
341342
blockId,
342343
folderScope?.recursiveFieldId ?? subBlockId
343344
)
344-
const folderScopePath =
345-
folderScope && typeof folderScopeValue === 'string' ? folderScopeValue.trim() : ''
345+
/*
346+
* Through `readFolderPath` rather than a `typeof === 'string'` check: a value
347+
* saved by the multi-select tree that preceded this one is a JSON array, and
348+
* its literal text passes a string check and then matches no folder, so every
349+
* file is filtered out of a picker that looks correctly configured.
350+
*/
351+
const folderScopePath = folderScope ? readFolderPath(folderScopeValue) : ''
346352
const folderScopeIncludesSubfolders =
347353
!folderScope?.recursiveFieldId ||
348354
folderScopeRecursive === undefined ||

apps/sim/lib/api/contracts/tools/file.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ export const fileManageAppendBodySchema = z.object({
6060
/**
6161
* Folder the name is resolved inside. A name is only unique within a folder,
6262
* so without this a duplicate name resolves to the oldest match anywhere.
63-
* Ignored when `fileName` is already a canonical id.
63+
*
64+
* It constrains a canonical id too, rather than being ignored for one: an id
65+
* that does not sit in the named folder is a `404`. Refusing is the safer
66+
* reading of a contradictory request — silently preferring the id would write
67+
* to a file outside the folder the caller named.
6468
*/
6569
folderPath: v2FolderPathInputSchema.optional(),
6670
includeSubfolders: z.boolean().optional(),

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

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ vi.mock('@sim/audit', () => ({
6161
}))
6262
vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify }))
6363

64+
import { OrchestrationError } from '@/lib/core/orchestration/types'
6465
import {
6566
createWorkspaceFileFolderOperation,
6667
deleteWorkspaceFileFolderOperation,
@@ -103,30 +104,70 @@ describe('workspace file folder operations', () => {
103104
mockArchive.mockResolvedValue({ files: 0, folders: 1, fileIds: [], folderIds: ['folder-1'] })
104105
})
105106

107+
const LEAF = {
108+
folder: {
109+
id: 'folder-c',
110+
name: 'C',
111+
path: 'A/B/C',
112+
createdAt: new Date(),
113+
updatedAt: new Date(),
114+
},
115+
path: 'A/B/C',
116+
}
117+
106118
/*
107119
* The tool description promises "Parent folders are created as needed", and
108-
* the manager throws "Parent folder not found" when they are not — so the
109-
* ancestors are materialized first, the same way the write path materializes
110-
* its destination. Only the ancestors: the leaf keeps its own call so it
111-
* still audits and still conflicts when something is already there.
120+
* the manager throws "Parent folder not found" when they are not — so a
121+
* missing ancestor is materialized and the create retried. Only the
122+
* ancestors: the leaf keeps its own call so it still audits and still
123+
* conflicts when something is already there.
112124
*/
113-
it('materializes missing ancestors before creating the leaf', async () => {
125+
it('materializes missing ancestors and retries the leaf', async () => {
114126
mockEnsure.mockResolvedValue({
115127
folderId: 'folder-b',
116128
createdFolderIds: ['folder-a', 'folder-b'],
117129
})
118-
mockCreate.mockResolvedValue({
119-
folder: { id: 'folder-c', name: 'C', path: 'A/B/C', createdAt: new Date(), updatedAt: new Date() },
120-
path: 'A/B/C',
121-
})
130+
mockCreate
131+
.mockRejectedValueOnce(new OrchestrationError('not_found', 'Parent folder not found'))
132+
.mockResolvedValue(LEAF)
122133

123134
await createWorkspaceFileFolderOperation.execute({
124135
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
125136
input: { workspaceId: 'ws-1', path: '/A/B/C' },
126137
})
127138

128139
expect(mockEnsure).toHaveBeenCalledWith(expect.objectContaining({ pathSegments: ['A', 'B'] }))
129-
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ path: '/A/B/C' }))
140+
expect(mockCreate).toHaveBeenCalledTimes(2)
141+
})
142+
143+
/*
144+
* A folder name may contain a slash; a path segment may not. Materializing
145+
* ancestors up front re-normalized the decoded name and rejected the folder's
146+
* own existing parent, so the ancestors are only touched once the create has
147+
* actually reported the parent missing.
148+
*/
149+
it('does not touch the materializer when the parent already exists', async () => {
150+
mockCreate.mockResolvedValue(LEAF)
151+
152+
await createWorkspaceFileFolderOperation.execute({
153+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
154+
input: { workspaceId: 'ws-1', path: '/A/Q3%2FQ4/C' },
155+
})
156+
157+
expect(mockEnsure).not.toHaveBeenCalled()
158+
expect(mockCreate).toHaveBeenCalledTimes(1)
159+
})
160+
161+
it('surfaces a create failure that is not a missing parent', async () => {
162+
mockCreate.mockRejectedValue(new OrchestrationError('conflict', 'Folder already exists'))
163+
164+
await expect(
165+
createWorkspaceFileFolderOperation.execute({
166+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
167+
input: { workspaceId: 'ws-1', path: '/A/B/C' },
168+
})
169+
).rejects.toThrow(/already exists/)
170+
expect(mockEnsure).not.toHaveBeenCalled()
130171
})
131172

132173
it('does not materialize anything for a top-level folder', async () => {

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

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,45 @@ async function executeListWorkspaceFileFolders(args: {
165165
return { folders }
166166
}
167167

168+
/**
169+
* Creates a folder at a path, materializing missing ancestors.
170+
*
171+
* Ancestors are created only after the direct attempt reports the parent
172+
* missing, rather than up front. `ensureWorkspaceFileFolderPath` re-normalizes
173+
* every segment it is handed, and a folder name is allowed to contain a slash
174+
* while a segment is not — so materializing first rejected `/A/Q3%2FQ4/C` on
175+
* its own existing parent. Reaching for the materializer only when the parent
176+
* is genuinely absent keeps that path untouched in the common case.
177+
*
178+
* Only the ancestors go through the materializer. The leaf keeps its own call
179+
* so it still emits FOLDER_CREATED with a full record, and still conflicts when
180+
* something is already there — the materializer is silent on both counts, so
181+
* routing the whole path through it would make "create" stop meaning create.
182+
*/
183+
async function createWorkspaceFileFolderAtPathCreatingAncestors(params: {
184+
workspaceId: string
185+
userId: string
186+
path: string
187+
}) {
188+
try {
189+
return await createWorkspaceFileFolderAtPath(params)
190+
} catch (error) {
191+
const parentMissing =
192+
error instanceof OrchestrationError &&
193+
error.code === 'not_found' &&
194+
error.message === 'Parent folder not found'
195+
const segments = parseFolderPath(params.path)
196+
if (!parentMissing || segments.length <= 1) throw error
197+
198+
await ensureWorkspaceFileFolderPath({
199+
workspaceId: params.workspaceId,
200+
userId: params.userId,
201+
pathSegments: segments.slice(0, -1),
202+
})
203+
return await createWorkspaceFileFolderAtPath(params)
204+
}
205+
}
206+
168207
async function executeCreateWorkspaceFileFolder(args: {
169208
principal: Parameters<typeof resolvePrincipalAttribution>[0]
170209
input: CreateWorkspaceFileFolderInput
@@ -173,31 +212,9 @@ async function executeCreateWorkspaceFileFolder(args: {
173212
const attribution = resolvePrincipalAttribution(args.principal, {
174213
workspaceBillingOwnerUserId: args.context.billedAccountUserId,
175214
})
176-
if (args.input.path !== undefined) {
177-
/*
178-
* Materialize the ancestors before creating the leaf, so creating
179-
* `/A/B/C` works when `/A/B` does not exist yet — which is what the tool
180-
* description promises and what the write path already does for its own
181-
* destination.
182-
*
183-
* Only the ancestors go through the materializer. The leaf keeps its
184-
* existing call so it still emits FOLDER_CREATED with a full record, and
185-
* still conflicts when something is already there — the materializer is
186-
* silent on both counts, so routing the whole path through it would make
187-
* "create" stop meaning create.
188-
*/
189-
const segments = parseFolderPath(args.input.path)
190-
if (segments.length > 1) {
191-
await ensureWorkspaceFileFolderPath({
192-
workspaceId: args.context.workspaceId,
193-
userId: attribution.attributedUserId,
194-
pathSegments: segments.slice(0, -1),
195-
})
196-
}
197-
}
198215
const result =
199216
args.input.path !== undefined
200-
? await createWorkspaceFileFolderAtPath({
217+
? await createWorkspaceFileFolderAtPathCreatingAncestors({
201218
workspaceId: args.context.workspaceId,
202219
userId: attribution.attributedUserId,
203220
path: args.input.path,

0 commit comments

Comments
 (0)