Skip to content

Commit 7689dcc

Browse files
authored
feat(files): surface file versions in the File block and logs, with conditional writes (#8056)
* feat(files): surface file versions in the File block and logs, with conditional writes * fix(files): bind the revision token to its file and refuse a conditional write with no target * feat(files): accept a content revision as the revert precondition and publish it on metadata * fix(files): validate the revert revision before the no-op branch and omit an absent revision * feat(files): accept a content revision on the v2 write endpoints * feat(files): return the produced revision from the v2 write responses * docs(files): declare the conflict response on replace file content * feat(files): number folder-selected reads without pairing a row to another write
1 parent 6d5f280 commit 7689dcc

42 files changed

Lines changed: 1009 additions & 90 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/cli/files.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,8 @@ sim files versions revert <fileId> <version> [options]
299299

300300
| Option | Required | Description |
301301
| --- | --- | --- |
302-
| `--expected-current-version <value>` | 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. |
302+
| `--expected-current-version <value>` | 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. |
303+
| `--expected-revision <value>` | 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. |
303304

304305
</CommandTable>
305306

@@ -354,6 +355,7 @@ sim files edit <fileId> [options]
354355
| Option | Required | Description |
355356
| --- | --- | --- |
356357
| `--edit <json\|@file>` | Yes | One edit object: &#123;"mode":"search_replace","search":"old","content":"new","replaceAll":false&#125;, &#123;"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"&#125;, &#123;"mode":"insert_after","anchor":"line","content":"new"&#125;, or &#123;"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"&#125;. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). |
358+
| `--expected-revision <value>` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. |
357359

358360
</CommandTable>
359361

@@ -613,6 +615,7 @@ sim files set-content <fileId> [options]
613615
| --- | --- | --- |
614616
| `--content <value>` | 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`. |
615617
| `--encoding <value>` | No | Content encoding. Accepted values: `utf-8`, `base64`. |
618+
| `--expected-revision <value>` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. |
616619

617620
</CommandTable>
618621

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1044,7 +1044,8 @@ sim files versions revert <fileId> <version> [options]
10441044

10451045
| Option | Required | Description |
10461046
| --- | --- | --- |
1047-
| `--expected-current-version <value>` | 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. |
1047+
| `--expected-current-version <value>` | 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. |
1048+
| `--expected-revision <value>` | 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. |
10481049

10491050
</CommandTable>
10501051

@@ -1103,6 +1104,7 @@ sim files edit <fileId> [options]
11031104
| Option | Required | Description |
11041105
| --- | --- | --- |
11051106
| `--edit <json\|@file>` | Yes | One edit object: &#123;"mode":"search_replace","search":"old","content":"new","replaceAll":false&#125;, &#123;"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"&#125;, &#123;"mode":"insert_after","anchor":"line","content":"new"&#125;, or &#123;"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"&#125;. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). |
1107+
| `--expected-revision <value>` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. |
11061108

11071109
</CommandTable>
11081110

@@ -1382,6 +1384,7 @@ sim files set-content <fileId> [options]
13821384
| --- | --- | --- |
13831385
| `--content <value>` | 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`. |
13841386
| `--encoding <value>` | No | Content encoding. Accepted values: `utf-8`, `base64`. |
1387+
| `--expected-revision <value>` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. |
13851388

13861389
</CommandTable>
13871390

‎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/docs/openapi-v2-files-audit.json‎

Lines changed: 159 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2744,7 +2744,7 @@
27442744
"put": {
27452745
"operationId": "updateFileContent",
27462746
"summary": "Replace File Content",
2747-
"description": "Replace the complete contents of an existing file from UTF-8 or base64 input.\n\nOAuth scope: `api:write`.",
2747+
"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`.",
27482748
"x-sim-operation": "files.update_content",
27492749
"x-oauth-scope": "api:write",
27502750
"tags": ["Files"],
@@ -2791,7 +2791,7 @@
27912791
"content": {
27922792
"application/json": {
27932793
"schema": {
2794-
"$ref": "#/components/schemas/V2FileResponse"
2794+
"$ref": "#/components/schemas/V2WrittenFileResponse"
27952795
}
27962796
}
27972797
}
@@ -2808,6 +2808,9 @@
28082808
"404": {
28092809
"$ref": "#/components/responses/NotFound"
28102810
},
2811+
"409": {
2812+
"$ref": "#/components/responses/Conflict"
2813+
},
28112814
"413": {
28122815
"$ref": "#/components/responses/PayloadTooLarge"
28132816
},
@@ -4988,10 +4991,15 @@
49884991
"description": "Workspace that owns the file."
49894992
},
49904993
"expectedCurrentVersion": {
4991-
"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.",
4994+
"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.",
49924995
"type": "integer",
49934996
"minimum": 1,
49944997
"maximum": 2147483647
4998+
},
4999+
"expectedRevision": {
5000+
"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.",
5001+
"type": "string",
5002+
"minLength": 1
49955003
}
49965004
},
49975005
"required": ["workspaceId"],
@@ -5315,6 +5323,10 @@
53155323
],
53165324
"description": "Current public-share state, or null when the file has never been shared."
53175325
},
5326+
"revision": {
5327+
"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.",
5328+
"type": "string"
5329+
},
53185330
"currentVersion": {
53195331
"type": "integer",
53205332
"minimum": 1,
@@ -5368,7 +5380,8 @@
53685380
"updatedAt": "2026-01-15T10:30:00Z",
53695381
"deletedAt": null,
53705382
"share": null,
5371-
"currentVersion": 1
5383+
"currentVersion": 1,
5384+
"revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg"
53725385
}
53735386
},
53745387
{
@@ -5382,7 +5395,7 @@
53825395
"folderPath": "/Engineering",
53835396
"uploadedByEmail": "jane@example.com",
53845397
"uploadedAt": "2026-01-15T10:30:00Z",
5385-
"updatedAt": "2026-01-15T10:30:00Z",
5398+
"updatedAt": "2026-01-16T09:12:00Z",
53865399
"deletedAt": null,
53875400
"share": {
53885401
"id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb",
@@ -5395,7 +5408,8 @@
53955408
"hasPassword": false,
53965409
"allowedEmails": []
53975410
},
5398-
"currentVersion": 3
5411+
"currentVersion": 3,
5412+
"revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTZUMDk6MTI6MDAuMDAwWg"
53995413
}
54005414
}
54015415
]
@@ -5799,6 +5813,10 @@
57995813
"minimum": 0,
58005814
"maximum": 9007199254740991,
58015815
"description": "Lines the file holds after the edit."
5816+
},
5817+
"revision": {
5818+
"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.",
5819+
"type": "string"
58025820
}
58035821
},
58045822
"required": ["file", "lineCount"],
@@ -5965,6 +5983,11 @@
59655983
}
59665984
],
59675985
"description": "One exact or anchor-based edit: search_replace, replace_between, insert_after, or delete_between."
5986+
},
5987+
"expectedRevision": {
5988+
"description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.",
5989+
"type": "string",
5990+
"minLength": 1
59685991
}
59695992
},
59705993
"required": ["workspaceId", "edit"],
@@ -6121,6 +6144,131 @@
61216144
}
61226145
]
61236146
},
6147+
"V2WrittenFile": {
6148+
"type": "object",
6149+
"properties": {
6150+
"id": {
6151+
"type": "string",
6152+
"description": "Unique file identifier.",
6153+
"examples": ["wf_V1StGXR8z5jdHi6BmyT91"]
6154+
},
6155+
"webUrl": {
6156+
"type": "string",
6157+
"format": "uri",
6158+
"description": "Canonical absolute URL for opening this resource in the Sim web application."
6159+
},
6160+
"name": {
6161+
"type": "string",
6162+
"description": "Original file name.",
6163+
"examples": ["data.csv"]
6164+
},
6165+
"size": {
6166+
"type": "number",
6167+
"minimum": 0,
6168+
"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.",
6169+
"examples": [1024]
6170+
},
6171+
"type": {
6172+
"type": "string",
6173+
"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.",
6174+
"examples": ["text/csv"]
6175+
},
6176+
"key": {
6177+
"type": "string",
6178+
"description": "Storage key for the file.",
6179+
"examples": ["workspace/example/data.csv"]
6180+
},
6181+
"folderPath": {
6182+
"type": "string",
6183+
"title": "Folder path",
6184+
"description": "Canonical containing-folder path. `/` is the workspace root.",
6185+
"maxLength": 4096
6186+
},
6187+
"uploadedByEmail": {
6188+
"type": "string",
6189+
"format": "email",
6190+
"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])?)*$",
6191+
"description": "Current email address of the uploader.",
6192+
"examples": ["jane@example.com"]
6193+
},
6194+
"uploadedAt": {
6195+
"type": "string",
6196+
"description": "ISO 8601 timestamp when the file was uploaded.",
6197+
"format": "date-time",
6198+
"examples": ["2026-01-15T10:30:00Z"]
6199+
},
6200+
"updatedAt": {
6201+
"type": "string",
6202+
"description": "ISO 8601 timestamp of the last content or metadata write.",
6203+
"format": "date-time",
6204+
"examples": ["2026-01-15T10:30:00Z"]
6205+
},
6206+
"deletedAt": {
6207+
"anyOf": [
6208+
{
6209+
"type": "string"
6210+
},
6211+
{
6212+
"type": "null"
6213+
}
6214+
],
6215+
"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.",
6216+
"format": "date-time",
6217+
"examples": ["2026-01-16T09:00:00Z"]
6218+
},
6219+
"revision": {
6220+
"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.",
6221+
"type": "string"
6222+
}
6223+
},
6224+
"required": [
6225+
"id",
6226+
"webUrl",
6227+
"name",
6228+
"size",
6229+
"type",
6230+
"key",
6231+
"folderPath",
6232+
"uploadedByEmail",
6233+
"uploadedAt",
6234+
"updatedAt",
6235+
"deletedAt"
6236+
],
6237+
"additionalProperties": false,
6238+
"title": "Written file",
6239+
"description": "A workspace file after a content replacement, with the revision it produced."
6240+
},
6241+
"V2WrittenFileResponse": {
6242+
"type": "object",
6243+
"properties": {
6244+
"data": {
6245+
"description": "Response data.",
6246+
"$ref": "#/components/schemas/V2WrittenFile"
6247+
}
6248+
},
6249+
"required": ["data"],
6250+
"additionalProperties": false,
6251+
"title": "Written file response",
6252+
"description": "A workspace file after a content replacement, with the revision the write produced.",
6253+
"examples": [
6254+
{
6255+
"data": {
6256+
"id": "wf_V1StGXR8z5jdHi6BmyT91",
6257+
"webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91",
6258+
"name": "data.csv",
6259+
"size": 1024,
6260+
"type": "text/csv",
6261+
"key": "workspace/example/data.csv",
6262+
"folderPath": "/Engineering",
6263+
"uploadedByEmail": "jane@example.com",
6264+
"uploadedAt": "2026-01-15T10:30:00Z",
6265+
"updatedAt": "2026-01-15T10:30:00Z",
6266+
"deletedAt": null,
6267+
"revision": "d2ZfVjFTdEdYUjh6NWpkSGk2Qm15VDkxOjIwMjYtMDEtMTVUMTA6MzA6MDAuMDAwWg"
6268+
}
6269+
}
6270+
]
6271+
},
61246272
"UpdateFileContentRequest": {
61256273
"type": "object",
61266274
"properties": {
@@ -6140,6 +6288,11 @@
61406288
"description": "Encoding of the content field.",
61416289
"type": "string",
61426290
"enum": ["utf-8", "base64"]
6291+
},
6292+
"expectedRevision": {
6293+
"description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.",
6294+
"type": "string",
6295+
"minLength": 1
61436296
}
61446297
},
61456298
"required": ["workspaceId", "content"],

‎apps/sim/app/api/v2/files/[fileId]/content/route.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ describe('PUT /api/v2/files/[fileId]/content', () => {
191191
uploadedAt: '2024-01-01T00:00:00.000Z',
192192
updatedAt: '2024-01-03T00:00:00.000Z',
193193
deletedAt: null,
194+
/** The token for the content this write produced, for the caller's next conditional write. */
195+
revision: expect.any(String),
194196
},
195197
})
196198
expect(mocks.updateContent).toHaveBeenCalledWith({

0 commit comments

Comments
 (0)