Skip to content

Commit aabe9b4

Browse files
committed
feat(files): surface file versions in the File block and logs, with conditional writes
1 parent 83fd36a commit aabe9b4

27 files changed

Lines changed: 501 additions & 55 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ Create a new workspace file, either from text content or from an existing file.
139139
| `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. |
140140
| `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. |
141141
| `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. |
142+
| `expectedRevision` | string | No | Refuse the write unless the file still holds the content this revision names, as returned by Get File or an earlier write. Use it so an edit computed from what you read cannot overwrite someone else’s change. |
142143

143144
#### Output
144145

@@ -148,6 +149,8 @@ Create a new workspace file, either from text content or from an existing file.
148149
| `name` | string | File name |
149150
| `size` | number | File size in bytes |
150151
| `url` | string | URL to access the file |
152+
| `version` | number | Version number of the content this write recorded |
153+
| `revision` | string | Opaque token for the content this write produced. Pass it back as expectedRevision to make a later write conditional on nothing having changed since. |
151154

152155
### File Append
153156

@@ -171,6 +174,8 @@ Append content to an existing workspace file. The file must already exist. Conte
171174
| `name` | string | File name |
172175
| `size` | number | File size in bytes |
173176
| `url` | string | URL to access the file |
177+
| `version` | number | Version number of the content this write recorded |
178+
| `revision` | string | Opaque token for the content this write produced. Pass it back as expectedRevision to make a later write conditional on nothing having changed since. |
174179

175180
### Apply File Edit
176181

@@ -194,6 +199,7 @@ Apply one precise edit to an existing text file without rewriting it. Use search
194199
| `startAnchor` | string | No | For delete_between, the complete first line to delete. The start anchor is removed. |
195200
| `endAnchor` | string | No | For delete_between, the complete ending boundary line. The end anchor remains in the file. |
196201
| `occurrence` | number | No | For anchored edits, which matching anchor occurrence to use, starting at 1. Defaults to 1. |
202+
| `expectedRevision` | string | No | Refuse the edit unless the file still holds the content this revision names, as returned by Get File or an earlier write. Use it so an edit computed from what you read cannot overwrite someone else’s change. |
197203

198204
#### Output
199205

@@ -203,6 +209,8 @@ Apply one precise edit to an existing text file without rewriting it. Use search
203209
| `name` | string | File name |
204210
| `size` | number | File size in bytes |
205211
| `lineCount` | number | Lines in the file after the edit |
212+
| `version` | number | Version number of the content this edit recorded |
213+
| `revision` | string | Opaque token for the content this edit produced. Pass it back as expectedRevision to make a later write conditional on nothing having changed since. |
206214

207215
### File Compress
208216

‎apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface FileData {
1717
url: string
1818
storageProvider?: 's3' | 'blob' | 'gcs' | 'local'
1919
bucketName?: string
20+
/** Workspace file version these bytes came from; absent on runs recorded before versioning. */
21+
version?: number
2022
}
2123

2224
interface FileCardsProps {
@@ -100,7 +102,10 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
100102
</div>
101103

102104
<div className='flex items-center justify-between'>
103-
<span className='text-[var(--text-subtle)] text-xs'>{file.type}</span>
105+
<span className='text-[var(--text-subtle)] text-xs'>
106+
{file.type}
107+
{file.version === undefined ? '' : ` · v${file.version}`}
108+
</span>
104109
<Button
105110
variant='ghost'
106111
className='h-[20px]! px-1.5! py-0! text-xs'

‎apps/sim/blocks/blocks/file.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2350,6 +2350,15 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
23502350
type: 'number',
23512351
description: 'Lines in the file after the change (edit, insert)',
23522352
},
2353+
version: {
2354+
type: 'number',
2355+
description: 'Version number of the content a write recorded (write, append, edit)',
2356+
},
2357+
revision: {
2358+
type: 'string',
2359+
description:
2360+
'Opaque token for the content read or written, sent back as expectedRevision to make a later write conditional (get, write, append, edit)',
2361+
},
23532362
results: {
23542363
type: 'array',
23552364
description: 'Matching lines as objects with fileId, lineNumber, and text fields (search)',

‎apps/sim/executor/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export interface UserFile {
2929
key: string
3030
context?: string
3131
base64?: string
32+
/**
33+
* Version number of a workspace file's content, present when the record was read with it. Each
34+
* version owns its own storage key, so `key` already pins the bytes; this is the number that
35+
* names them in the file version API.
36+
*/
37+
version?: number
3238
/** Provider Files API handle (OpenAI/Anthropic `file_...` id) set when a large file is uploaded instead of inlined as base64. */
3339
providerFileId?: string
3440
/** Provider File API uri (Gemini `fileUri`) set when a large file is uploaded instead of inlined as base64. */

‎apps/sim/lib/api/contracts/primitives.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,8 @@ export const userFileSchema = z
408408
key: z.string().min(1),
409409
context: z.string().optional(),
410410
base64: z.string().optional(),
411+
/** Workspace file version these bytes came from; absent on files with no version history. */
412+
version: versionNumberSchema.optional(),
411413
})
412414
.passthrough()
413415

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ function validateFolderTarget(
3838
}
3939
}
4040

41+
/**
42+
* Makes a content write conditional on the file still holding the content the caller read, named
43+
* by the opaque `revision` an earlier read or write returned. Version numbers cannot express this:
44+
* collaborative and workflow writes fold into the current version rather than adding one, so the
45+
* same number can name different bytes.
46+
*/
47+
const expectedRevisionSchema = z
48+
.string()
49+
.min(1)
50+
.optional()
51+
.describe('Revision from an earlier read or write; the write is refused if content moved on.')
52+
4153
export const fileManageWriteBodySchema = z
4254
.object({
4355
operation: z.literal('write'),
@@ -52,9 +64,23 @@ export const fileManageWriteBodySchema = z
5264
fileInput: z.unknown().optional(),
5365
contentType: z.string().optional(),
5466
overwrite: z.boolean().optional(),
67+
expectedRevision: expectedRevisionSchema,
68+
5569
[PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.optional(),
5670
})
5771
.superRefine((body, context) => {
72+
/*
73+
* A precondition asserts something about an existing file, and only the overwrite branch
74+
* writes into one. Refused here so the request fails before it uploads bytes or creates
75+
* folders on the way to a write that could never satisfy it.
76+
*/
77+
if (body.expectedRevision !== undefined && body.overwrite !== true) {
78+
context.addIssue({
79+
code: z.ZodIssueCode.custom,
80+
path: ['expectedRevision'],
81+
message: 'expectedRevision requires overwrite: true.',
82+
})
83+
}
5884
const hasContent = body.content !== undefined
5985
const hasFileInput = body.fileInput !== undefined && body.fileInput !== null
6086
if (hasContent === hasFileInput) {
@@ -312,6 +338,7 @@ const fileEditTargetShape = {
312338
folderPath: v2FolderPathInputSchema.optional(),
313339
folderPaths: fileFolderPathsSchema.optional(),
314340
includeSubfolders: z.boolean().optional(),
341+
expectedRevision: expectedRevisionSchema,
315342
} as const
316343

317344
export const fileManageEditBodySchema = z

‎apps/sim/lib/core/security/redaction.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,26 @@ describe('redactApiKeys', () => {
633633
expect(result.processedFiles[0]).not.toHaveProperty('key')
634634
expect(result.processedFiles[0]).not.toHaveProperty('context')
635635
})
636+
637+
it.concurrent('should keep the workspace file version, which names the exposed bytes', () => {
638+
const result = redactApiKeys({
639+
files: [
640+
{
641+
id: 'file-123',
642+
name: 'notes.md',
643+
url: 'http://localhost/api/files/serve/notes.md',
644+
size: 12,
645+
type: 'text/markdown',
646+
key: 'workspace/ws/notes.md',
647+
context: 'workspace',
648+
version: 4,
649+
},
650+
],
651+
})
652+
653+
expect(result.files[0].version).toBe(4)
654+
expect(result.files[0]).not.toHaveProperty('key')
655+
})
636656
})
637657

638658
describe('primitive handling', () => {

‎apps/sim/lib/core/utils/user-file.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,15 @@ export type UserFileLike = Pick<UserFile, 'id' | 'name' | 'url' | 'key'> &
77
* Fields exposed for UserFile objects in UI (tag dropdown) and logs.
88
* Internal fields like 'key' and 'context' are not exposed.
99
*/
10-
export const USER_FILE_DISPLAY_FIELDS = ['id', 'name', 'url', 'size', 'type', 'base64'] as const
10+
export const USER_FILE_DISPLAY_FIELDS = [
11+
'id',
12+
'name',
13+
'url',
14+
'size',
15+
'type',
16+
'base64',
17+
'version',
18+
] as const
1119

1220
export type UserFileDisplayField = (typeof USER_FILE_DISPLAY_FIELDS)[number]
1321

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
112112
fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args),
113113
getWorkspaceFileByName: (...args: unknown[]) => mockGetWorkspaceFileByName(...args),
114114
getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args),
115+
/* The versioned read is the same row plus the number the metadata surface reports. */
116+
getWorkspaceFileWithCurrentVersion: async (...args: unknown[]) => {
117+
const file = await mockGetWorkspaceFile(...args)
118+
return file ? { ...file, currentVersion: 1 } : file
119+
},
115120
loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args),
116121
loadActiveWorkspaceFileContext: (...args: unknown[]) =>
117122
mockLoadActiveWorkspaceFileContext(...args),
@@ -133,6 +138,11 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({
133138
fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args),
134139
getWorkspaceFileByName: (...args: unknown[]) => mockGetWorkspaceFileByName(...args),
135140
getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args),
141+
/* The versioned read is the same row plus the number the metadata surface reports. */
142+
getWorkspaceFileWithCurrentVersion: async (...args: unknown[]) => {
143+
const file = await mockGetWorkspaceFile(...args)
144+
return file ? { ...file, currentVersion: 1 } : file
145+
},
136146
loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args),
137147
loadActiveWorkspaceFileContext: (...args: unknown[]) =>
138148
mockLoadActiveWorkspaceFileContext(...args),

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

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
import { normalizeWorkspaceFileItemName } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
4444
import type {
4545
getWorkspaceFile,
46+
getWorkspaceFileWithCurrentVersion,
4647
WorkspaceFileRecord,
4748
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
4849
import {
@@ -51,6 +52,7 @@ import {
5152
type WorkspaceFileSecretProvenance,
5253
type WorkspaceFileSecretProvenanceIdentity,
5354
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
55+
import { INITIAL_WORKSPACE_FILE_VERSION } from '@/lib/uploads/contexts/workspace/workspace-file-versions'
5456
import {
5557
getFileExtension,
5658
getMimeTypeFromExtension,
@@ -68,14 +70,18 @@ import {
6870
createWorkspaceFileFromBuffer,
6971
} from '@/lib/workspace-files/application/create-workspace-file'
7072
import { editWorkspaceFileContent } from '@/lib/workspace-files/application/edit-workspace-file-content'
73+
import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
7174
import {
7275
listWorkspaceFilesInFolderScope,
7376
queryWorkspaceFilePage,
7477
} from '@/lib/workspace-files/application/list-workspace-files'
7578
import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items'
7679
import { fileOperations } from '@/lib/workspace-files/application/operations'
7780
import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content'
78-
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
81+
import {
82+
readWorkspaceFileMetadata,
83+
readWorkspaceFileMetadataWithVersion,
84+
} from '@/lib/workspace-files/application/read-workspace-file-metadata'
7985
import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record'
8086
import { readWorkspaceFileSecretProvenance } from '@/lib/workspace-files/application/read-workspace-file-secret-provenance'
8187
import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference'
@@ -177,7 +183,14 @@ async function assertOperationFileAccess(
177183
}
178184
}
179185

180-
const workspaceFileToUserFile = (file: Awaited<ReturnType<typeof getWorkspaceFile>>) => {
186+
/**
187+
* A workspace file record as an execution file. `version` rides along only when the record was
188+
* read with it — a file addressed by id — so the number always describes the very bytes this row
189+
* carries rather than a version a second query might have raced ahead to.
190+
*/
191+
const workspaceFileToUserFile = (
192+
file: (WorkspaceFileRecord & { currentVersion?: number }) | null
193+
) => {
181194
if (!file) return null
182195

183196
return {
@@ -188,6 +201,7 @@ const workspaceFileToUserFile = (file: Awaited<ReturnType<typeof getWorkspaceFil
188201
type: file.type,
189202
key: file.key,
190203
context: 'workspace' as const,
204+
...(file.currentVersion === undefined ? {} : { version: file.currentVersion }),
191205
}
192206
}
193207

@@ -1004,10 +1018,10 @@ export async function executeFileManageOperation(
10041018
return Response.json({ success: false, error: 'File is required' }, { status: 400 })
10051019
}
10061020

1007-
let file: Awaited<ReturnType<typeof getWorkspaceFile>>
1021+
let file: Awaited<ReturnType<typeof getWorkspaceFileWithCurrentVersion>>
10081022
try {
10091023
file = (
1010-
await readWorkspaceFileMetadata.execute({
1024+
await readWorkspaceFileMetadataWithVersion.execute({
10111025
principal,
10121026
input: { fileId: selectedFileId, assertedWorkspaceId: workspaceId },
10131027
})
@@ -1031,6 +1045,8 @@ export async function executeFileManageOperation(
10311045
success: true,
10321046
data: {
10331047
file: workspaceFileToUserFile(file),
1048+
/** The token a conditional write sends back; see `expectedRevision`. */
1049+
revision: workspaceFileRevision(file),
10341050
},
10351051
})
10361052
}
@@ -1224,7 +1240,15 @@ export async function executeFileManageOperation(
12241240
}
12251241

12261242
case 'write': {
1227-
const { fileName, content, fileInput, contentType, overwrite, folderPath } = body
1243+
const {
1244+
fileName,
1245+
content,
1246+
fileInput,
1247+
contentType,
1248+
overwrite,
1249+
folderPath,
1250+
expectedRevision,
1251+
} = body
12281252
signal?.throwIfAborted()
12291253
const provenanceResolution = resolveFileWriteSecretProvenance({
12301254
headers,
@@ -1365,6 +1389,7 @@ export async function executeFileManageOperation(
13651389
contentType: mimeType,
13661390
provenanceMode: 'replace_empty',
13671391
expectedUpdatedAt: existing.contentUpdatedAt ?? undefined,
1392+
expectedRevision,
13681393
...(overwriteProvenance ? { secretProvenance: overwriteProvenance } : {}),
13691394
},
13701395
})
@@ -1382,6 +1407,7 @@ export async function executeFileManageOperation(
13821407
name: overwritten.name,
13831408
size: overwritten.size,
13841409
url: ensureAbsoluteUrl(overwritten.url ?? overwritten.path),
1410+
version: overwritten.currentVersion,
13851411
},
13861412
})
13871413
}
@@ -1418,6 +1444,8 @@ export async function executeFileManageOperation(
14181444
name: result.file.name,
14191445
size: fileBuffer.length,
14201446
url: ensureAbsoluteUrl(result.file.url ?? result.file.path),
1447+
/** A file created with its content has no history yet, so those bytes are version 1. */
1448+
version: INITIAL_WORKSPACE_FILE_VERSION,
14211449
},
14221450
})
14231451
}
@@ -1591,7 +1619,7 @@ export async function executeFileManageOperation(
15911619
})
15921620
const finalContent = existingBuffer.toString('utf-8') + content
15931621
const fileBuffer = Buffer.from(finalContent, 'utf-8')
1594-
await updateWorkspaceFileContent.execute({
1622+
const { file: appended } = await updateWorkspaceFileContent.execute({
15951623
principal,
15961624
input: {
15971625
fileId: existing.id,
@@ -1617,6 +1645,7 @@ export async function executeFileManageOperation(
16171645
name: existing.name,
16181646
size: fileBuffer.length,
16191647
url: ensureAbsoluteUrl(existing.path),
1648+
version: appended.currentVersion,
16201649
},
16211650
})
16221651
} finally {
@@ -1721,13 +1750,21 @@ export async function executeFileManageOperation(
17211750
fileId: target.id,
17221751
assertedWorkspaceId: workspaceId,
17231752
...(secretProvenance ? { secretProvenance } : {}),
1753+
expectedRevision: body.expectedRevision,
17241754
edit,
17251755
},
17261756
})
17271757

17281758
return Response.json({
17291759
success: true,
1730-
data: { id: file.id, name: file.name, size: file.size, lineCount },
1760+
data: {
1761+
id: file.id,
1762+
name: file.name,
1763+
size: file.size,
1764+
lineCount,
1765+
version: file.currentVersion,
1766+
revision: workspaceFileRevision(file),
1767+
},
17311768
})
17321769
}
17331770

0 commit comments

Comments
 (0)