diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index 41f15299483..db168e20bfe 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -299,7 +299,8 @@ sim files versions revert [options] | Option | Required | Description | | --- | --- | --- | -| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number. | +| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. | +| `--expected-revision ` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. | @@ -354,6 +355,7 @@ sim files edit [options] | Option | Required | Description | | --- | --- | --- | | `--edit ` | Yes | One edit object: {"mode":"search_replace","search":"old","content":"new","replaceAll":false}, {"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"}, {"mode":"insert_after","anchor":"line","content":"new"}, or {"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"}. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | @@ -613,6 +615,7 @@ sim files set-content [options] | --- | --- | --- | | `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | | `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 49d8a095210..483d1677144 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -1044,7 +1044,8 @@ sim files versions revert [options] | Option | Required | Description | | --- | --- | --- | -| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number. | +| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. | +| `--expected-revision ` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. | @@ -1103,6 +1104,7 @@ sim files edit [options] | Option | Required | Description | | --- | --- | --- | | `--edit ` | Yes | One edit object: {"mode":"search_replace","search":"old","content":"new","replaceAll":false}, {"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"}, {"mode":"insert_after","anchor":"line","content":"new"}, or {"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"}. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | @@ -1382,6 +1384,7 @@ sim files set-content [options] | --- | --- | --- | | `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | | `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index cee24c8b1c2..0fc151aad4e 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -139,6 +139,7 @@ Create a new workspace file, either from text content or from an existing file. | `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. | | `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. | | `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. | +| `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. | #### Output @@ -148,6 +149,8 @@ Create a new workspace file, either from text content or from an existing file. | `name` | string | File name | | `size` | number | File size in bytes | | `url` | string | URL to access the file | +| `version` | number | Version number of the content this write recorded | +| `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. | ### File Append @@ -171,6 +174,8 @@ Append content to an existing workspace file. The file must already exist. Conte | `name` | string | File name | | `size` | number | File size in bytes | | `url` | string | URL to access the file | +| `version` | number | Version number of the content this write recorded | +| `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. | ### Apply File Edit @@ -194,6 +199,7 @@ Apply one precise edit to an existing text file without rewriting it. Use search | `startAnchor` | string | No | For delete_between, the complete first line to delete. The start anchor is removed. | | `endAnchor` | string | No | For delete_between, the complete ending boundary line. The end anchor remains in the file. | | `occurrence` | number | No | For anchored edits, which matching anchor occurrence to use, starting at 1. Defaults to 1. | +| `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. | #### Output @@ -203,6 +209,8 @@ Apply one precise edit to an existing text file without rewriting it. Use search | `name` | string | File name | | `size` | number | File size in bytes | | `lineCount` | number | Lines in the file after the edit | +| `version` | number | Version number of the content this edit recorded | +| `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. | ### File Compress diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 762120da570..a1ce1ccd7f7 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2744,7 +2744,7 @@ "put": { "operationId": "updateFileContent", "summary": "Replace File Content", - "description": "Replace the complete contents of an existing file from UTF-8 or base64 input.\n\nOAuth scope: `api:write`.", + "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", "x-sim-operation": "files.update_content", "x-oauth-scope": "api:write", "tags": ["Files"], @@ -2791,7 +2791,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" + "$ref": "#/components/schemas/V2WrittenFileResponse" } } } @@ -2808,6 +2808,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -4988,10 +4991,15 @@ "description": "Workspace that owns the file." }, "expectedCurrentVersion": { - "description": "Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number.", + "description": "Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.", "type": "integer", "minimum": 1, "maximum": 2147483647 + }, + "expectedRevision": { + "description": "Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version.", + "type": "string", + "minLength": 1 } }, "required": ["workspaceId"], @@ -5315,6 +5323,10 @@ ], "description": "Current public-share state, or null when the file has never been shared." }, + "revision": { + "description": "Opaque token for the file's current content. Send it back as `expectedRevision` so a write or revert is refused when the content moved on. Absent for a file with no recorded content version.", + "type": "string" + }, "currentVersion": { "type": "integer", "minimum": 1, @@ -5368,7 +5380,8 @@ "updatedAt": "2026-01-15T10:30:00Z", "deletedAt": null, "share": null, - "currentVersion": 1 + "currentVersion": 1, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" } }, { @@ -5382,7 +5395,7 @@ "folderPath": "/Engineering", "uploadedByEmail": "jane@example.com", "uploadedAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-16T09:12:00Z", "deletedAt": null, "share": { "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", @@ -5395,7 +5408,8 @@ "hasPassword": false, "allowedEmails": [] }, - "currentVersion": 3 + "currentVersion": 3, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTZUMDk6MTI6MDAuMDAwWg" } } ] @@ -5799,6 +5813,10 @@ "minimum": 0, "maximum": 9007199254740991, "description": "Lines the file holds after the edit." + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" } }, "required": ["file", "lineCount"], @@ -5965,6 +5983,11 @@ } ], "description": "One exact or anchor-based edit: search_replace, replace_between, insert_after, or delete_between." + }, + "expectedRevision": { + "description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.", + "type": "string", + "minLength": 1 } }, "required": ["workspaceId", "edit"], @@ -6121,6 +6144,131 @@ } ] }, + "V2WrittenFile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "examples": ["wf_V1StGXR8z5jdHi6BmyT91"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Original file name.", + "examples": ["data.csv"] + }, + "size": { + "type": "number", + "minimum": 0, + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", + "examples": [1024] + }, + "type": { + "type": "string", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", + "examples": ["text/csv"] + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "examples": ["workspace/example/data.csv"] + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the workspace root.", + "maxLength": 4096 + }, + "uploadedByEmail": { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "description": "Current email address of the uploader.", + "examples": ["jane@example.com"] + }, + "uploadedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the file was uploaded.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last content or metadata write.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" + } + }, + "required": [ + "id", + "webUrl", + "name", + "size", + "type", + "key", + "folderPath", + "uploadedByEmail", + "uploadedAt", + "updatedAt", + "deletedAt" + ], + "additionalProperties": false, + "title": "Written file", + "description": "A workspace file after a content replacement, with the revision it produced." + }, + "V2WrittenFileResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2WrittenFile" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Written file response", + "description": "A workspace file after a content replacement, with the revision the write produced.", + "examples": [ + { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null, + "revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg" + } + } + ] + }, "UpdateFileContentRequest": { "type": "object", "properties": { @@ -6140,6 +6288,11 @@ "description": "Encoding of the content field.", "type": "string", "enum": ["utf-8", "base64"] + }, + "expectedRevision": { + "description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.", + "type": "string", + "minLength": 1 } }, "required": ["workspaceId", "content"], diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index f829f3a44d6..6c160f29915 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -191,6 +191,8 @@ describe('PUT /api/v2/files/[fileId]/content', () => { uploadedAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-03T00:00:00.000Z', deletedAt: null, + /** The token for the content this write produced, for the caller's next conditional write. */ + revision: expect.any(String), }, }) expect(mocks.updateContent).toHaveBeenCalledWith({ diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 673e0a3d3ed..b2845c3a5e8 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -5,6 +5,7 @@ import { import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { editWorkspaceFileContent } from '@/lib/workspace-files/application/edit-workspace-file-content' +import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision' import { fileOperations } from '@/lib/workspace-files/application/operations' import { admitUpdateWorkspaceFileContent, @@ -36,9 +37,13 @@ export const PUT = defineV2JsonRoute({ assertedWorkspaceId: body.workspaceId, content: body.content, encoding: body.encoding, + expectedRevision: body.expectedRevision, }), useCase: updateWorkspaceFileContent, - present: async ({ file }) => ({ data: await toV2File(file) }), + present: async ({ file }) => { + const revision = workspaceFileRevision(file) + return { data: { ...(await toV2File(file)), ...(revision === null ? {} : { revision }) } } + }, }) /** @@ -70,7 +75,13 @@ export const PATCH = defineV2JsonRoute({ fileId: params.fileId, assertedWorkspaceId: body.workspaceId, edit: body.edit, + expectedRevision: body.expectedRevision, }), useCase: editWorkspaceFileContent, - present: async ({ file, lineCount }) => ({ data: { file: await toV2File(file), lineCount } }), + present: async ({ file, lineCount }) => { + const revision = workspaceFileRevision(file) + return { + data: { file: await toV2File(file), lineCount, ...(revision === null ? {} : { revision }) }, + } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index aa37d0e95d0..6efbae5b9e3 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -148,6 +148,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { deletedAt: null, share: SHARE, currentVersion: 3, + revision: expect.any(String), }, }) expect(mocks.readMetadata).toHaveBeenCalledWith({ @@ -206,6 +207,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { deletedAt: '2024-01-03T00:00:00.000Z', share: SHARE, currentVersion: 3, + revision: expect.any(String), }, }) expect(mocks.readMetadata).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 22e14d6d20a..415ccadad7e 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,6 +1,7 @@ import { v2GetFileContract } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileMetadataWithVersion } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { toV2File } from '@/app/api/v2/files/utils' @@ -29,7 +30,15 @@ export const GET = defineV2JsonRoute({ includeDeleted: query.scope === 'archived', }), useCase: readWorkspaceFileMetadataWithVersion, - present: async ({ file, share }) => ({ - data: { ...(await toV2File(file)), share, currentVersion: file.currentVersion }, - }), + present: async ({ file, share }) => { + const revision = workspaceFileRevision(file) + return { + data: { + ...(await toV2File(file)), + share, + currentVersion: file.currentVersion, + ...(revision === null ? {} : { revision }), + }, + } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.ts index 81100c681b0..91a6c26cfb3 100644 --- a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/revert/route.ts @@ -25,6 +25,7 @@ export const POST = defineV2JsonRoute({ assertedWorkspaceId: body.workspaceId, version: params.version, expectedCurrentVersion: body.expectedCurrentVersion, + expectedRevision: body.expectedRevision, }), useCase: revertWorkspaceFileVersion, present: async ({ file, version, reverted }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx index 2cb8e023541..8d310f38b29 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx @@ -17,6 +17,8 @@ interface FileData { url: string storageProvider?: 's3' | 'blob' | 'gcs' | 'local' bucketName?: string + /** Workspace file version these bytes came from; absent on runs recorded before versioning. */ + version?: number } interface FileCardsProps { @@ -100,7 +102,10 @@ function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps)
- {file.type} + + {file.type} + {file.version === undefined ? '' : ` · v${file.version}`} +