feat(files): folder operations for the File block, and folders as scope - #7377
feat(files): folder operations for the File block, and folders as scope#7377mzxchandra wants to merge 11 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThe PR adds folder creation, movement, deletion, restoration, and listing to File v5 while extending existing file operations with folder scopes.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/blocks/blocks/file.ts | Extends File v5 configuration and parameter transformation with folder operations and folder-scoped file behavior. |
| apps/sim/lib/internal/file/operations.ts | Implements internal dispatch for the new folder and file-move operations. |
| apps/sim/lib/workspace-files/application/workspace-file-folders.ts | Adds application-layer folder lifecycle operations for workspace files. |
| apps/sim/lib/workspace-files/directory-listing.ts | Adds bounded folder-and-file directory listing with recursive, depth, and search controls. |
| apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sim-folder-tree-selector/sim-folder-tree-selector.tsx | Adds the single-select folder tree used by File block folder parameters. |
| apps/sim/lib/api/contracts/tools/file.ts | Extends File tool contracts with folder operations and scoped file inputs. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
UI[File v5 block inputs] --> Params[Parameter transformation]
Params --> Tool[File tool dispatch]
Tool --> Ops[Authorized file operations]
Ops --> Folders[Workspace folders]
Ops --> Files[Workspace files]
Folders --> Result[Folder or directory result]
Files --> Result
Reviews (5): Last reviewed commit: "fix(files): resolve an overwrite target ..." | Re-trigger Greptile
There was a problem hiding this comment.
All reported issues were addressed across 42 files
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Re-trigger cubic
Adds folder operations to file_v5 and to the agent tool surface, and makes a folder a scope on the file operations that already existed rather than a second set of operations beside them. New operations: List, Create Folder, Move Folder, Delete Folder, Restore Folder, and Move File. List answers "what is in here" — subfolders and files together, direct children by default, the whole subtree under Recursive, subject to Max Depth and Search. Its entries are a discriminated union on kind, so a consumer narrows before reaching for the fields only one side has, and the listing is capped with a truncated flag rather than being unbounded now that it includes files. Read, Get Content, Compress and Append gain an optional Folder above their file picker. It narrows what the picker offers, and on the three read operations it also stands for that folder's files when none are picked — resolved when the workflow runs, so a file added later is included. Append only narrows: a folder is not something you can append to, so it shapes the options and does not travel. Write gains a folder destination, placed above File Name because it names where before it names what. Every folder field is one single-select tree control paired with a manual entry. The picker cannot hold a reference expression — `<` autocomplete comes from TagDropdown, which scans for the last `<` before the cursor and so only exists on a text surface — which is why the pair exists rather than being a convenience. Root is the absence of a selection rather than a row, since a root row would be meaningless on create and wrong on delete. Path handling is the part most worth reviewing. Two spellings circulate: the stored display path, which backslash-escapes a slash inside a folder name, and the canonical percent-encoded path the tools take. A folder genuinely named "Q3/Q4" is one level in both and two if either is split on "/", so folderPathSegments decodes by the leading slash the canonical form carries, and resolveFolderIdsForPaths, isFileInFolderScope and selectDirectoryEntries are pure and tested against exactly that case. Delete Folder keeps its recursive flag as a guard rather than a scope: without it, deleting a non-empty folder fails, and it is user-only so a model asked to clean up a folder cannot set it on a guess.
The list operation's entry selection had no tests. These pin the parts that are easy to get subtly wrong: a file counts one level below its folder, so the deepest folder a depth admits arrives without its contents; search filters the result rather than the traversal, so a deep match still reports even when its parent folders do not match; and a cut listing reports truncated rather than looking complete.
e3fb656 to
b5a77e3
Compare
Three fixes from the AI reviewers on #7377. The single-file picker kept its unscoped options when the folder changed: singleFileOptions read scopedWorkspaceFiles but listed workspaceFiles in its memo dependencies, so it never recomputed. That is the picker Append uses, so the folder narrowing I added for it did nothing after the first render. The v2 files-folders route called toWorkspaceFileFolderPathView while importing staging's toV2Folder — a conflict resolution I got half right, and a compile error I would have found by running the type-check I skipped. The file is back to staging's version, since keeping its projection was the decision anyway. fileFamilyInput took Record<string, any>, which the repo's TypeScript rules forbid. It now takes an interface naming the three fields it actually reads, so a shape change fails at the call site rather than at run time.
There was a problem hiding this comment.
All reported issues were addressed
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Re-trigger cubic
Three P1s from cubic, all real, all mine, and all introduced by the rebase. Folder expansion was gone from read, get content and compress. Taking the upstream copy of the operations file and re-applying only the folder CASES dropped expandFolderPathsToFileIds and the folderPaths handling with it, so the block sent a folder, the contract accepted it, the tool forwarded it, and the operation ignored it — a folder-only selection failed with "File is required". file_write never read folderPath: it derived the destination from the file name alone, so choosing a folder wrote to the workspace root. It now prefixes the picked folder's decoded segments onto whatever the name itself spells. The six folder tool ids were registered in FILE_MANAGE_TOOL_IDS, which chooses the handler branch, but not in FILE_TOOL_IDS, which decides whether the internal registry has a handler at all. Every workflow execution of them would have failed before reaching the branch. None of this was caught because the tests covered the two ends — the block's params and the pure path helpers — and nothing covered the middle. Worse, the test file's module mock stubbed one export from workspace-file-folders, so the five folder operations were undefined and no test could reach a folder case at all. The mock is complete now, and five tests cover the wiring: a folder-only read expands, a missing folder refuses rather than reading nothing, write lands in the chosen folder and still lands at the root without one, and list returns folders and files together.
…lock Two more from cubic, both correct. The workflow diff rendered every folder change as the same generic label. Adding sim-folder-tree-selector to SELECTOR_TYPES_HYDRATION_REQUIRED without a resolver makes each consumer of that list fall back to a semantic placeholder — the canvas card showed "-" for the same reason, which I fixed there and not here. The comparison path now calls the same resolveFolderPathLabel, so a diff between two folders shows which two. Max Depth and Limit were bounded only at the contract, so an out-of-range value was accepted while configuring and failed on the first run. They now carry the same ceilings in the block, where the mistake is made.
|
@greptile review |
|
@cubic review |
@mzxchandra I can't start this review because your workspace has reached its monthly review limit. cubic has reviewed 574,058 of the 560,000 allowed lines of code this month. Reviews resume on 28 September 2026 (in 27 days). Enable flex capacity to cover overages automatically and resume reviews now. Learn how flex capacity works. To help optimise your usage, you can tune cubic to get the most out of your usage limits:
|
…tools # Conflicts: # apps/sim/tools/generated/tool-metadata.ts
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
CI's Lint and Test failed on sub-block-password.test.tsx, a test this branch does not touch, with a missing icon export from a partial mock. The cause was a deep import. sub-block.tsx takes every other subblock component from the components barrel, and that test mocks the barrel wholesale — which is what keeps the editor's module graph out of its way. My folder picker was imported directly from its file instead, bypassing the mock and pulling the real chain in behind it: use-resource-folders, the realtime invalidation room, the socket provider, the workflow stores, and through them the block and trigger registries. Every trigger's icon then had to appear in that test's icon mock. Exporting the picker from the barrel and importing it from there fixes it with no change to the test, which is the sign it was the right fix — I had first patched the mock, then stubbed the socket provider, and both were treating a symptom. The repo's import rules already say to use the barrel; I had not.
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
|
@greptile review |
cubic raised this twice; I deferred it once and it deserved the fix. Append resolves a picked file by canonical id, where the folder beside it is redundant. The advanced entry supplies a name instead, and a name is only unique within a folder — a workspace-wide lookup takes the oldest match anywhere, so the folder sat next to the field looking like it scoped the operation while doing nothing. That is the same objection that had me remove this field earlier in the branch, reappearing in advanced mode only. The rule is now stated properly: the folder travels when it is what identifies the file, and stays behind when the id already does. A named append expands the chosen folder through the authorized use case, matches the name inside it, and resolves by the id it finds — so no path-shaped reference is ever built and the slash-in-a-folder-name hazard cannot arise on this path. A name that is not in the folder refuses rather than reaching for a same-named file elsewhere. Tests cover the case that motivated it: two files named notes.md in different folders, appending to the one in the folder that was picked.
…tools # Conflicts: # apps/sim/lib/workflows/subblocks/display.ts
A slash-delimited reference cannot express a folder whose own name contains a slash. `Q3/Q4` joins to two segments and re-reads as two levels, so the overwrite lookup never found the existing file and the write landed as a duplicate beside it. The guard on folder id and name meant it could not overwrite the WRONG file, which is why this was bounded rather than dangerous. I introduced it: before this branch the segments came only from splitting the file name, so no segment could contain a slash. Prepending the picked folder's decoded segments is what made one able to. When a folder was picked its canonical path is unambiguous, so the target is found inside that folder by name and resolved by id — the same route the named append now takes, using the same authorized expansion. A typed path with no picked folder keeps the joined reference, which is exactly as expressive as the name it came from.
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
6 issues found across 44 files
Confidence score: 2/5
apps/sim/lib/internal/file/operations.ts:scoped.findcan select an arbitrary duplicate filename in a recursive scope, causing edits to the wrong file; reject ambiguous names or require an exact path/ID.apps/sim/lib/internal/file/operations.ts: manually entered filenames beginning withwf_can be misclassified as file IDs, bypassing folder lookup and targeting the wrong resource; distinguish canonical IDs from user-provided names.apps/sim/lib/api/contracts/tools/file.ts: nestedfile_create_folderpaths are accepted even though execution fails when the parent is absent, while path-baseddelete_folderaudit entries omitresourceId; align parent creation with execution and preserve the resolved folder ID in auditing.apps/sim/lib/workflows/subblocks/display.ts: folder selections can still render as-, and legacy array-shaped values remain unnamed in previews; apply the same folder-path normalization andresolveFolderPathLabelhandling across editor and preview resolution.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/lib/internal/file/operations.ts">
<violation number="1" location="apps/sim/lib/internal/file/operations.ts:1235">
P2: When a manually entered filename starts with `wf_`, this condition treats it as a file id and skips the folder lookup. Distinguish canonical picked ids from names without reserving a valid filename prefix, so named appends remain scoped.</violation>
<violation number="2" location="apps/sim/lib/internal/file/operations.ts:1242">
P1: When a recursive folder scope contains duplicate filenames, `scoped.find` appends to whichever file happens to come first. Reject ambiguous names or require an exact path/id instead of silently modifying an arbitrary descendant.</violation>
</file>
<file name="apps/sim/lib/workflows/subblocks/display.ts">
<violation number="1" location="apps/sim/lib/workflows/subblocks/display.ts:619">
P2: The read-only preview still shows `-` for selected `sim-folder-tree-selector` values. Wire `resolveFolderPathLabel` into the preview resolver's hydrated-name chain so the editor and preview display folder scopes consistently.
(Based on your team's feedback about resolving hydrated folder selectors in every display consumer.)</violation>
<violation number="2" location="apps/sim/lib/workflows/subblocks/display.ts:624">
P3: When a workflow contains a legacy array-shaped folder value, this resolver cannot name it even though the selector and file block still normalize it. Apply the same folder-path normalization before parsing so migrated folder selections remain readable.</violation>
</file>
<file name="apps/sim/lib/api/contracts/tools/file.ts">
<violation number="1" location="apps/sim/lib/api/contracts/tools/file.ts:243">
P2: When the new path-based `delete_folder` operation succeeds, its `FOLDER_DELETED` audit entry has no `resourceId` because the projector reads `input.folderId`, which this contract never supplies. Resolve and retain the folder ID for auditing while continuing to return the public path.
(Based on your team's feedback about preserving canonical folder IDs in folder deletion audits.)</violation>
<violation number="2" location="apps/sim/lib/api/contracts/tools/file.ts:243">
P2: When `file_create_folder` receives a nested path whose parent does not exist, this contract accepts it but execution returns `Parent folder not found`, contradicting the tool's parent-creation behavior. Route this operation through the folder-path materializer so missing parents are created before the leaf.</violation>
</file>
| folderPaths: [folderPath], | ||
| includeSubfolders, | ||
| }) | ||
| const match = scoped.find((file) => file.name === fileName) |
There was a problem hiding this comment.
P1: When a recursive folder scope contains duplicate filenames, scoped.find appends to whichever file happens to come first. Reject ambiguous names or require an exact path/id instead of silently modifying an arbitrary descendant.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/internal/file/operations.ts, line 1242:
<comment>When a recursive folder scope contains duplicate filenames, `scoped.find` appends to whichever file happens to come first. Reject ambiguous names or require an exact path/id instead of silently modifying an arbitrary descendant.</comment>
<file context>
@@ -1090,14 +1220,37 @@ export async function executeFileManageOperation(
+ folderPaths: [folderPath],
+ includeSubfolders,
+ })
+ const match = scoped.find((file) => file.name === fileName)
+ if (!match) {
+ throw new OrchestrationError('not_found', `No file named ${fileName} in ${folderPath}`)
</file context>
| * hazard of a path-shaped reference never arises. | ||
| */ | ||
| let scopedReference = fileName | ||
| if (folderPath && !fileName.startsWith('wf_')) { |
There was a problem hiding this comment.
P2: When a manually entered filename starts with wf_, this condition treats it as a file id and skips the folder lookup. Distinguish canonical picked ids from names without reserving a valid filename prefix, so named appends remain scoped.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/internal/file/operations.ts, line 1235:
<comment>When a manually entered filename starts with `wf_`, this condition treats it as a file id and skips the folder lookup. Distinguish canonical picked ids from names without reserving a valid filename prefix, so named appends remain scoped.</comment>
<file context>
@@ -1090,14 +1220,37 @@ export async function executeFileManageOperation(
+ * hazard of a path-shaped reference never arises.
+ */
+ let scopedReference = fileName
+ if (folderPath && !fileName.startsWith('wf_')) {
+ const scoped = await expandFolderPathsToFiles({
+ principal,
</file context>
| * request per canvas row, no loading state, and nothing to go stale that the | ||
| * stored path has not gone stale with. | ||
| */ | ||
| export function resolveFolderPathLabel( |
There was a problem hiding this comment.
P2: The read-only preview still shows - for selected sim-folder-tree-selector values. Wire resolveFolderPathLabel into the preview resolver's hydrated-name chain so the editor and preview display folder scopes consistently.
(Based on your team's feedback about resolving hydrated folder selectors in every display consumer.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/workflows/subblocks/display.ts, line 619:
<comment>The read-only preview still shows `-` for selected `sim-folder-tree-selector` values. Wire `resolveFolderPathLabel` into the preview resolver's hydrated-name chain so the editor and preview display folder scopes consistently.
(Based on your team's feedback about resolving hydrated folder selectors in every display consumer.) </comment>
<file context>
@@ -602,3 +603,31 @@ export function resolveSandboxLabel(
+ * request per canvas row, no loading state, and nothing to go stale that the
+ * stored path has not gone stale with.
+ */
+export function resolveFolderPathLabel(
+ subBlock: SubBlockConfig | undefined,
+ rawValue: unknown
</file context>
| export const fileManageCreateFolderBodySchema = z.object({ | ||
| operation: z.literal('create_folder'), | ||
| workspaceId: z.string().min(1).optional(), | ||
| path: v2NonRootFolderPathInputSchema, |
There was a problem hiding this comment.
P2: When the new path-based delete_folder operation succeeds, its FOLDER_DELETED audit entry has no resourceId because the projector reads input.folderId, which this contract never supplies. Resolve and retain the folder ID for auditing while continuing to return the public path.
(Based on your team's feedback about preserving canonical folder IDs in folder deletion audits.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/api/contracts/tools/file.ts, line 243:
<comment>When the new path-based `delete_folder` operation succeeds, its `FOLDER_DELETED` audit entry has no `resourceId` because the projector reads `input.folderId`, which this contract never supplies. Resolve and retain the folder ID for auditing while continuing to return the public path.
(Based on your team's feedback about preserving canonical folder IDs in folder deletion audits.) </comment>
<file context>
@@ -137,6 +208,61 @@ export const fileManageDecompressBodySchema = z
+export const fileManageCreateFolderBodySchema = z.object({
+ operation: z.literal('create_folder'),
+ workspaceId: z.string().min(1).optional(),
+ path: v2NonRootFolderPathInputSchema,
+})
+
</file context>
| export const fileManageCreateFolderBodySchema = z.object({ | ||
| operation: z.literal('create_folder'), | ||
| workspaceId: z.string().min(1).optional(), | ||
| path: v2NonRootFolderPathInputSchema, |
There was a problem hiding this comment.
P2: When file_create_folder receives a nested path whose parent does not exist, this contract accepts it but execution returns Parent folder not found, contradicting the tool's parent-creation behavior. Route this operation through the folder-path materializer so missing parents are created before the leaf.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/api/contracts/tools/file.ts, line 243:
<comment>When `file_create_folder` receives a nested path whose parent does not exist, this contract accepts it but execution returns `Parent folder not found`, contradicting the tool's parent-creation behavior. Route this operation through the folder-path materializer so missing parents are created before the leaf.</comment>
<file context>
@@ -137,6 +208,61 @@ export const fileManageDecompressBodySchema = z
+export const fileManageCreateFolderBodySchema = z.object({
+ operation: z.literal('create_folder'),
+ workspaceId: z.string().min(1).optional(),
+ path: v2NonRootFolderPathInputSchema,
+})
+
</file context>
| rawValue: unknown | ||
| ): string | null { | ||
| if (subBlock?.type !== 'sim-folder-tree-selector') return null | ||
| if (typeof rawValue !== 'string' || rawValue.trim() === '') return null |
There was a problem hiding this comment.
P3: When a workflow contains a legacy array-shaped folder value, this resolver cannot name it even though the selector and file block still normalize it. Apply the same folder-path normalization before parsing so migrated folder selections remain readable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/sim/lib/workflows/subblocks/display.ts, line 624:
<comment>When a workflow contains a legacy array-shaped folder value, this resolver cannot name it even though the selector and file block still normalize it. Apply the same folder-path normalization before parsing so migrated folder selections remain readable.</comment>
<file context>
@@ -602,3 +603,31 @@ export function resolveSandboxLabel(
+ rawValue: unknown
+): string | null {
+ if (subBlock?.type !== 'sim-folder-tree-selector') return null
+ if (typeof rawValue !== 'string' || rawValue.trim() === '') return null
+
+ try {
</file context>
|
Superseded by #7388 — same change, one commit against current Closing rather than continuing here because the incremental history was feeding the review loop: 11 commits and two staging merges meant each round reviewed a diff-of-diffs, and the last round's findings were mostly bugs in the fixes from the round before. #7388 is byte-identical in net diff (verified) plus six further fixes from this PR's final round. The reasoning argued on the threads here is carried into #7388's description rather than lost — the two path spellings and why resolution goes by id, delete's |
What
Adds folder operations to
file_v5and to the agent tool surface, and makes a folder a scope on the file operations that already existed rather than a second set of operations beside them.New operations: List, Create Folder, Move Folder, Delete Folder, Restore Folder, Move File.
List answers "what is in here" — subfolders and files together, direct children by default, the whole subtree under Recursive, subject to Max Depth and Search. Entries are a discriminated union on
kind, so a consumer narrows before reaching for the fields only one side has. The listing is capped with atruncatedflag rather than unbounded, now that it includes files.Read / Get Content / Compress / Append gain an optional Folder above their file picker. It narrows what the picker offers; on the three read operations it also stands for that folder's files when none are picked, resolved when the workflow runs so a file added later is included. Append only narrows — a folder is not something you can append to, so it shapes the options and does not travel.
Write gains a folder destination, above File Name, because it names where before it names what.
Where to spend review attention
Path handling — this is where the bugs live. Two spellings circulate: the stored display path, which backslash-escapes a slash inside a folder name (
Reports/Q3\/Q4), and the canonical percent-encoded path the tools take (/Reports/Q3%2FQ4). A folder genuinely namedQ3/Q4is one level in both and two if either is split on/.folderPathSegmentspicks the parser by the leading slash the canonical form always carries;resolveFolderIdsForPaths,isFileInFolderScopeandselectDirectoryEntriesare pure and tested against exactly that case. I shipped this bug once during development — it surfaced as "Workspace file folder path contains an empty name" on a correctly picked folder.Delete Folder's recursive flag is a guard, not a scope. Without it, deleting a non-empty folder fails, and it is
user-onlyso a model asked to "clean up" a folder cannot set it on a guess. Deliberately unlike the read family's Include Subfolders, which is a scope and defaults on.Canonical pairs. Every folder field is a single-select tree paired with a manual entry. The pair is load-bearing, not a convenience:
<reference autocomplete comes fromTagDropdown, which scans for the last<before the cursor, so it can only live on a text surface. A checkbox tree has no cursor.No new tools for folder-scoped reads. The manage operation already accepts
folderPathsalongside file ids, so the folder scope reusesfile_read/file_get_content/file_compress. Two nearly-overlapping tools are harder for an agent to choose between than one that takes either.Root is the absence of a selection, not a row — a root row would be meaningless on create and wrong on delete. The placeholders say so.
Known characteristic, not a blocker
The
listcase reads the workspace's full folder and file lists and filters in memory, the same wayexpandFolderPathsToFileIdsand roughly ten existing callers oflistAllWorkspaceFilesdo. Output is capped bylimitwith atruncatedflag, but the read itself is not bounded.queryWorkspaceFilePageis the paged, folder-scoped alternative if this becomes a problem on large workspaces — worth doing as a follow-up across all callers rather than only this one.Testing
blocks,tools/file,lib/internal/file,lib/workspace-files,lib/workflows/subblocks.check-canvas-sentences, generated docs, and tool metadata.check:api-validationandcheck:client-boundaryclean; biome clean.folder-path-selection,directory-listing,display) has direct unit coverage including the slash-in-name case.Follow-ups (not in this PR)