Skip to content

Commit b19553e

Browse files
feat(files): add an overwrite option to file write (#7295)
* feat(files): add an overwrite option to file write * fix(files): keep an overwrite create on its exact path * docs(files): describe overwrite as an exact-path match
1 parent eefbb20 commit b19553e

7 files changed

Lines changed: 317 additions & 8 deletions

File tree

apps/docs/content/docs/integrations/file.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,16 +117,17 @@ Fetch and parse a file from a URL with optional custom headers.
117117

118118
### File Write
119119

120-
Create a new workspace file, either from text content or from an existing file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv").
120+
Create a new workspace file, either from text content or from an existing file. If a file with the same name already exists, a numeric suffix is added (e.g., "data (1).csv") unless overwrite is enabled.
121121

122122
#### Input
123123

124124
| Parameter | Type | Required | Description |
125125
| --------- | ---- | -------- | ----------- |
126-
| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically. |
126+
| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically unless overwrite is enabled. |
127127
| `content` | string | No | The text content to write to the file. Provide exactly one of content or fileInput. |
128128
| `fileInput` | file | No | An existing file to store in the workspace, such as one produced by an earlier tool. Use this for anything that is not text — PDFs, images, audio, archives. Provide exactly one of content or fileInput. |
129129
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from the file extension, or taken from the stored file, if omitted. |
130+
| `overwrite` | boolean | No | Replace the contents of an existing file at the exact target path \(folder and name\) instead of creating a suffixed copy. Creates the file when that path does not exist yet. |
130131

131132
#### Output
132133

apps/sim/blocks/blocks/file.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -911,7 +911,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
911911
- Search finds literal text across all active workspace files and returns structured results with fileId, lineNumber, and text. Lowercase queries are case-insensitive; adding any uppercase letter makes the search case-sensitive.
912912
- Search is eventually consistent. Check "complete" and "indexStatus" when pending, failed, skipped, or partially indexed files matter to the task.
913913
- Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token.
914-
- Use Write to create a new workspace file and Append to add content to an existing one.
914+
- Use Write to create a new workspace file and Append to add content to an existing one. Write adds a numeric suffix when the name is taken; turn on "Overwrite Existing File" to replace the contents of the file at that exact path (folder and name) instead — a same-named file in another folder is left alone.
915915
- Use Compress to bundle one or more files into a single .zip archive stored in the workspace. The new archive is returned in the "files" output.
916916
- Use Decompress to extract a .zip archive back into the workspace; the extracted files are returned in the "files" output, ready to chain into Get Content or downstream blocks.
917917
`,
@@ -1088,6 +1088,12 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
10881088
condition: { field: 'operation', value: 'file_write' },
10891089
mode: 'advanced',
10901090
},
1091+
{
1092+
id: 'overwrite',
1093+
title: 'Overwrite Existing File',
1094+
type: 'switch' as SubBlockType,
1095+
condition: { field: 'operation', value: 'file_write' },
1096+
},
10911097
{
10921098
id: 'appendFile',
10931099
title: 'File',
@@ -1284,6 +1290,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
12841290
...(omitContent ? {} : { content: params.content }),
12851291
...(fileInput ? { fileInput } : {}),
12861292
contentType: params.contentType,
1293+
overwrite: params.overwrite === true || params.overwrite === 'true',
12871294
workspaceId: params._context?.workspaceId,
12881295
}
12891296
}
@@ -1513,6 +1520,10 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
15131520
description: 'An existing file to store in the workspace, instead of text content',
15141521
},
15151522
contentType: { type: 'string', description: 'MIME content type for write' },
1523+
overwrite: {
1524+
type: 'boolean',
1525+
description: 'Replace an existing file with the same name instead of creating a copy (write)',
1526+
},
15161527
appendFileInput: { type: 'json', description: 'File to append to' },
15171528
appendContent: { type: 'string', description: 'Content to append to file' },
15181529
compressInput: {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const fileManageWriteBodySchema = z
2222
*/
2323
fileInput: z.unknown().optional(),
2424
contentType: z.string().optional(),
25+
overwrite: z.boolean().optional(),
2526
[PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.optional(),
2627
})
2728
.superRefine((body, context) => {

apps/sim/lib/internal/file/operations.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ vi.mock('@/app/api/files/authorization', () => ({
164164

165165
import { fileManageBodySchema } from '@/lib/api/contracts/tools/file'
166166
import { executeFileManageOperation } from '@/lib/internal/file/operations'
167+
import { FileConflictError } from '@/lib/uploads/contexts/workspace'
167168
import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
168169

169170
async function POST(request: Request): Promise<Response> {
@@ -621,6 +622,210 @@ describe('file manage operations', () => {
621622
)
622623
})
623624

625+
it('replaces the existing file at the target path when overwrite is on', async () => {
626+
const existing = workspaceFile('report')
627+
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
628+
mockUpdateWorkspaceFileContent.mockResolvedValue(existing)
629+
630+
const response = await POST(
631+
createMockRequest('POST', {
632+
operation: 'write',
633+
workspaceId: 'workspace-1',
634+
fileName: 'report.txt',
635+
content: 'fresh',
636+
overwrite: true,
637+
})
638+
)
639+
640+
expect(response.status).toBe(200)
641+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
642+
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledWith(
643+
'workspace-1',
644+
'report',
645+
'user-1',
646+
Buffer.from('fresh'),
647+
'text/plain',
648+
{
649+
expectedUpdatedAt: CONTENT_UPDATED_AT,
650+
secretProvenancePolicy: { mode: 'replace', provenance: { status: 'exact', entries: [] } },
651+
}
652+
)
653+
await expect(response.json()).resolves.toMatchObject({
654+
success: true,
655+
data: { id: 'report', name: 'report.txt' },
656+
})
657+
})
658+
659+
it('creates the file when overwrite finds nothing at the target path', async () => {
660+
mockResolveWorkspaceFileReference.mockResolvedValue(null)
661+
662+
const response = await POST(
663+
createMockRequest('POST', {
664+
operation: 'write',
665+
workspaceId: 'workspace-1',
666+
fileName: 'report.txt',
667+
content: 'fresh',
668+
overwrite: true,
669+
})
670+
)
671+
672+
expect(response.status).toBe(200)
673+
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
674+
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
675+
'workspace-1',
676+
'user-1',
677+
Buffer.from('fresh'),
678+
'report.txt',
679+
'text/plain',
680+
// Exact, so a path created by a concurrent write conflicts instead of being suffixed.
681+
expect.objectContaining({ exactName: true, folderId: null })
682+
)
683+
})
684+
685+
it('surfaces a conflict when a concurrent write claims the overwrite path', async () => {
686+
mockResolveWorkspaceFileReference.mockResolvedValue(null)
687+
mockUploadWorkspaceFile.mockRejectedValue(new FileConflictError('report.txt'))
688+
689+
const response = await POST(
690+
createMockRequest('POST', {
691+
operation: 'write',
692+
workspaceId: 'workspace-1',
693+
fileName: 'report.txt',
694+
content: 'fresh',
695+
overwrite: true,
696+
})
697+
)
698+
699+
expect(response.status).toBe(409)
700+
await expect(response.json()).resolves.toMatchObject({ success: false })
701+
})
702+
703+
it('never overwrites a same-named file resolved outside the target folder', async () => {
704+
mockResolveWorkspaceFileReference.mockResolvedValue({
705+
...workspaceFile('report'),
706+
folderId: 'folder-9',
707+
})
708+
709+
const response = await POST(
710+
createMockRequest('POST', {
711+
operation: 'write',
712+
workspaceId: 'workspace-1',
713+
fileName: 'report.txt',
714+
content: 'fresh',
715+
overwrite: true,
716+
})
717+
)
718+
719+
expect(response.status).toBe(200)
720+
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
721+
expect(mockUploadWorkspaceFile).toHaveBeenCalled()
722+
})
723+
724+
it('keeps the suffixing create path when overwrite is off', async () => {
725+
mockResolveWorkspaceFileReference.mockResolvedValue(workspaceFile('report'))
726+
727+
const response = await POST(
728+
createMockRequest('POST', {
729+
operation: 'write',
730+
workspaceId: 'workspace-1',
731+
fileName: 'report.txt',
732+
content: 'fresh',
733+
})
734+
)
735+
736+
expect(response.status).toBe(200)
737+
expect(mockUpdateWorkspaceFileContent).not.toHaveBeenCalled()
738+
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
739+
'workspace-1',
740+
'user-1',
741+
Buffer.from('fresh'),
742+
'report.txt',
743+
'text/plain',
744+
expect.objectContaining({ exactName: false })
745+
)
746+
})
747+
748+
it('overwrites an existing file with the bytes of a stored file input', async () => {
749+
const existing = workspaceFile('report')
750+
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
751+
mockUpdateWorkspaceFileContent.mockResolvedValue(existing)
752+
mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ status: 'exact', entries: [] })
753+
754+
const response = await POST(
755+
createMockRequest('POST', {
756+
operation: 'write',
757+
workspaceId: 'workspace-1',
758+
fileName: 'report.txt',
759+
fileInput: {
760+
key: 'workspace/workspace-1/source.txt',
761+
name: 'source.txt',
762+
type: 'text/plain',
763+
size: 6,
764+
},
765+
overwrite: true,
766+
})
767+
)
768+
769+
expect(response.status).toBe(200)
770+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
771+
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledWith(
772+
'workspace-1',
773+
'report',
774+
'user-1',
775+
Buffer.from('content:source.txt'),
776+
'text/plain',
777+
expect.objectContaining({ expectedUpdatedAt: CONTENT_UPDATED_AT })
778+
)
779+
})
780+
781+
it('downgrades provenance when overwriting a file owned by another user', async () => {
782+
const existing = workspaceFile('report', 'other-user')
783+
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
784+
mockUpdateWorkspaceFileContent.mockResolvedValue(existing)
785+
786+
const response = await POST(
787+
createMockRequest(
788+
'POST',
789+
{
790+
operation: 'write',
791+
workspaceId: 'workspace-1',
792+
fileName: 'report.txt',
793+
content: 'secret-value',
794+
overwrite: true,
795+
__privateSecretProvenance: {
796+
version: 1,
797+
complete: true,
798+
selections: [
799+
{
800+
key: 'content',
801+
provenance: {
802+
version: 1,
803+
complete: true,
804+
entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
805+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
806+
},
807+
},
808+
],
809+
},
810+
},
811+
PRIVATE_SECRET_PROVENANCE_HEADER
812+
)
813+
)
814+
815+
expect(response.status).toBe(200)
816+
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledWith(
817+
'workspace-1',
818+
'report',
819+
'user-1',
820+
Buffer.from('secret-value'),
821+
'text/plain',
822+
{
823+
expectedUpdatedAt: CONTENT_UPDATED_AT,
824+
secretProvenancePolicy: { mode: 'replace', provenance: { status: 'unknown' } },
825+
}
826+
)
827+
})
828+
624829
it('atomically binds append provenance to the exact predecessor version', async () => {
625830
const existing = workspaceFile('file-1')
626831
mockResolveWorkspaceFileReference.mockResolvedValue(existing)

0 commit comments

Comments
 (0)