Skip to content

Commit b770dc8

Browse files
committed
feat(tables): folder tools for the Table block, and folders as picker scope
Tables already had the folder machinery - the five folder operations, their application use cases, and a public v2 route - but no agent-callable tools, so a run could query a table's rows and could not file the table anywhere. Six tools, mirroring the File block's set: `table_list_folders`, `table_create_folder`, `table_update_folder`, `table_delete_folder`, `table_restore_folder`, and `table_move`. Restore is addressed by the path the folder held when it was deleted rather than by id, because that is what `restoreTableFolderUseCase` takes; the response reports where the folder actually landed, which differs when its old parent is still archived or a live sibling has taken its name. Executor admission. The folder operations delegated to Copilot only, so every one of these tools would have 403'd. Widened to `toolReadOperation` / `toolWriteOperation(id, 'tables.use')`: tables.folders.list <- table_list_folders tables.folders.create <- table_create_folder tables.folders.update <- table_update_folder tables.folders.delete <- table_delete_folder tables.folders.restore <- table_restore_folder tables.update <- table_move `tables.use` rather than `tables.create` throughout: a folder organizes, it does not add a table, so a workspace with table creation withheld can still file what it has. `table_move` reuses `updateTableUseCase` with only `folderPath` set rather than growing a second way to move a table. Both `check:actorless-executor-operations` and `check:capability-subject` pass with the widening - neither the folder use cases nor `updateTableUseCase` requires a human subject, so an actorless run (schedule, webhook, deployed API) can reach them without a 500. Dispatch is in-process, as new cases in `executeTableTool`. The dispatcher keyed every case to a routed contract, which these have no business inventing - there is no HTTP route for them - so it now carries a response schema and the folder cases pass standalone schemas through `parseInternalOperationInput`, the path the Knowledge handler already takes. Reusing the public v2 folder contracts was the alternative and was rejected: `recursive` there is a `z.stringbool()` shaped by URL query encoding, and this flag is the guard between deleting one empty folder and deleting a subtree. None of the tools sends a `workspaceId`. The executor mints a delegated principal bound to the run's workspace and the operations read it from there, so accepting one would declare a field the server ignores. `table_move` is still scoped to its table, but through its own schema: `getTableContract`, which the older scoped tools use, requires the `workspaceId` these deliberately omit, and reading it that way rejected every move as malformed. On `table_v2`, each folder path is a tree selector paired with a manual text entry so a `<reference>` can be typed on a text surface. The one exception is the Folder that narrows the table picker: it never travels, because a folder cannot stand in for a table the way it stands in for a file, so it is basic-mode only rather than a pair whose advanced half would resolve a reference and have it discarded. It renders above the picker it narrows - below reads as a second choice rather than a filter. The scope comparison is by decoded segment, never by string prefix: `/a/bc` starts with `/a/b` and is not inside it, and a folder genuinely named `Q3/Q4` is one level. `isFileInFolderScope` now delegates to the same predicate instead of keeping a second copy.
1 parent e2c42c5 commit b770dc8

20 files changed

Lines changed: 2713 additions & 30 deletions

File tree

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

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,118 @@ Get the schema configuration of a table
269269
| `maxRows` | number | Max rows per table for the workspace's plan |
270270
| `message` | string | Status message |
271271

272+
### List Table Folders
273+
274+
List the folders that organize a workspace’s tables. Lists direct children by default; set Recursive to walk the whole subtree. Use List Tables to see the tables themselves.
275+
276+
#### Input
277+
278+
| Parameter | Type | Required | Description |
279+
| --------- | ---- | -------- | ----------- |
280+
| `path` | string | No | Folder to list. Omit to list from the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
281+
| `recursive` | boolean | No | List every folder beneath the path rather than only its direct children. Each entry carries its depth below the listed folder. |
282+
| `depth` | number | No | Deepest level to include, counted from the listed folder. 1 is direct children. Setting it implies Recursive, so it can be used on its own. |
283+
| `search` | string | No | Case-insensitive substring match against a folder name. Filters the result, so a deep match is still reported even when its parent folders do not match. |
284+
| `limit` | number | No | Most folders to return, 200 by default. A listing cut short comes back with truncated set. |
285+
286+
#### Output
287+
288+
| Parameter | Type | Description |
289+
| --------- | ---- | ----------- |
290+
| `path` | string | The folder that was listed. |
291+
| `folders` | array | The folders inside it. Each carries its name, canonical path, parent path, timestamps, and its depth below the listed folder. |
292+
| `truncated` | boolean | True when the limit cut the listing short, so more folders exist. |
293+
294+
### Create Table Folder
295+
296+
Create a table folder at a path. The parent folder must already exist, so build a nested path one level at a time, top down. Fails if a folder already exists at the path.
297+
298+
#### Input
299+
300+
| Parameter | Type | Required | Description |
301+
| --------- | ---- | -------- | ----------- |
302+
| `path` | string | Yes | Path of the folder to create. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
303+
304+
#### Output
305+
306+
| Parameter | Type | Description |
307+
| --------- | ---- | ----------- |
308+
| `folder` | object | The created folder, with its name, canonical path, parent path, and timestamps. |
309+
310+
### Move Table Folder
311+
312+
Move or rename a table folder by giving its full destination path. Everything inside the folder, including its tables, moves with it.
313+
314+
#### Input
315+
316+
| Parameter | Type | Required | Description |
317+
| --------- | ---- | -------- | ----------- |
318+
| `path` | string | Yes | Folder to move. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
319+
| `destinationPath` | string | Yes | Full path the folder should have afterwards. Renaming is a destination whose parent is unchanged. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
320+
321+
#### Output
322+
323+
| Parameter | Type | Description |
324+
| --------- | ---- | ----------- |
325+
| `folder` | object | The folder at its new path. |
326+
| `previousPath` | string | The path the folder had before the move. |
327+
328+
### Delete Table Folder
329+
330+
Delete a table folder. It moves to Recently deleted and can be brought back with Restore Table Folder. Deleting a folder that still has contents requires the recursive option.
331+
332+
#### Input
333+
334+
| Parameter | Type | Required | Description |
335+
| --------- | ---- | -------- | ----------- |
336+
| `path` | string | Yes | Folder to delete. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
337+
| `recursive` | boolean | No | Also delete the folder’s nested folders and the tables inside them. Without it, deleting a non-empty folder fails. |
338+
339+
#### Output
340+
341+
| Parameter | Type | Description |
342+
| --------- | ---- | ----------- |
343+
| `path` | string | The folder that was deleted. |
344+
| `deleted` | boolean | Always true when the operation succeeded. |
345+
| `deletedItems` | object | Counts of the folders and tables deleted alongside it. |
346+
347+
### Restore Table Folder
348+
349+
Restore a deleted table folder and its contents from Recently deleted. Addressed by the path the folder held when it was deleted; the response reports where it actually landed, which can differ if that path is taken or its parent is still deleted.
350+
351+
#### Input
352+
353+
| Parameter | Type | Required | Description |
354+
| --------- | ---- | -------- | ----------- |
355+
| `path` | string | Yes | Path the folder held when it was deleted. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
356+
357+
#### Output
358+
359+
| Parameter | Type | Description |
360+
| --------- | ---- | ----------- |
361+
| `folder` | object | The restored folder at its live path. |
362+
| `requestedPath` | string | The path the folder was addressed by. |
363+
| `restoredItems` | object | Counts of the folders and tables restored alongside it. |
364+
365+
### Move Table
366+
367+
File an existing table into a folder. Moves the table itself; use Move Table Folder to relocate a whole folder.
368+
369+
#### Input
370+
371+
| Parameter | Type | Required | Description |
372+
| --------- | ---- | -------- | ----------- |
373+
| `tableId` | string | Yes | ID of the table to move. Use List Tables to find it. |
374+
| `folderPath` | string | No | Destination folder. Omit to move the table to the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
375+
376+
#### Output
377+
378+
| Parameter | Type | Description |
379+
| --------- | ---- | ----------- |
380+
| `tableId` | string | The table that was moved. |
381+
| `name` | string | Name of the moved table. |
382+
| `folderPath` | string | The folder the table now lives in. |
383+
272384
{/* MANUAL-CONTENT-START:notes */}
273385
## Filter Operators
274386

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
isTableInFolderScope,
7+
parseFolderScope,
8+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/scope'
9+
import type { WorkflowFolder } from '@/stores/folders/types'
10+
11+
function folder(id: string, name: string, parentId: string | null = null): WorkflowFolder {
12+
return {
13+
id,
14+
name,
15+
parentId,
16+
resourceType: 'table',
17+
userId: 'user-1',
18+
workspaceId: 'ws-1',
19+
locked: false,
20+
sortOrder: 0,
21+
createdAt: new Date('2026-08-27T00:00:00.000Z'),
22+
updatedAt: new Date('2026-08-27T00:00:00.000Z'),
23+
deletedAt: null,
24+
} as unknown as WorkflowFolder
25+
}
26+
27+
const FOLDERS: Record<string, WorkflowFolder> = {
28+
reports: folder('reports', 'Reports'),
29+
q3: folder('q3', 'Q3', 'reports'),
30+
slashy: folder('slashy', 'Q3/Q4', 'reports'),
31+
archive: folder('archive', 'Archive'),
32+
}
33+
34+
describe('isTableInFolderScope', () => {
35+
it('offers everything when no folder is picked', () => {
36+
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, '')).toBe(true)
37+
expect(isTableInFolderScope({ folderId: null }, FOLDERS, '')).toBe(true)
38+
})
39+
40+
it('keeps a table in the scoped folder and its descendants', () => {
41+
expect(isTableInFolderScope({ folderId: 'reports' }, FOLDERS, '/Reports')).toBe(true)
42+
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, '/Reports')).toBe(true)
43+
})
44+
45+
it('drops a table outside the scope, including one at the workspace root', () => {
46+
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, '/Reports')).toBe(false)
47+
expect(isTableInFolderScope({ folderId: null }, FOLDERS, '/Reports')).toBe(false)
48+
})
49+
50+
it('reads a slash inside a folder name as one level', () => {
51+
/* `Q3/Q4` is ONE folder, so it is not inside a folder called `Q3`. */
52+
expect(isTableInFolderScope({ folderId: 'slashy' }, FOLDERS, '/Reports/Q3%2FQ4')).toBe(true)
53+
expect(isTableInFolderScope({ folderId: 'slashy' }, FOLDERS, '/Reports/Q3')).toBe(false)
54+
})
55+
56+
it('does not mistake a shared name prefix for containment', () => {
57+
const folders = { ...FOLDERS, archive2: folder('archive2', 'Reports Archive') }
58+
expect(isTableInFolderScope({ folderId: 'archive2' }, folders, '/Reports')).toBe(false)
59+
})
60+
61+
/*
62+
* The failure mode is the point. A picker that silently empties reads as
63+
* "these tables are gone" rather than "your filter is malformed", so a scope
64+
* that cannot be parsed and a folder the cache has not loaded both fail OPEN.
65+
*/
66+
it('offers the table when the scope path cannot be parsed', () => {
67+
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, 'Reports')).toBe(true)
68+
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, '/Reports/')).toBe(true)
69+
})
70+
71+
it('offers the table when its folder is missing from the loaded map', () => {
72+
expect(isTableInFolderScope({ folderId: 'not-loaded-yet' }, FOLDERS, '/Reports')).toBe(true)
73+
})
74+
75+
it('offers the table when the folder map contains a cycle', () => {
76+
const cyclic: Record<string, WorkflowFolder> = {
77+
a: folder('a', 'A', 'b'),
78+
b: folder('b', 'B', 'a'),
79+
}
80+
expect(isTableInFolderScope({ folderId: 'a' }, cyclic, '/Reports')).toBe(true)
81+
})
82+
})
83+
84+
describe('parseFolderScope', () => {
85+
it('decodes a canonical path once for the whole list', () => {
86+
expect(parseFolderScope('/Reports/Q3%20Results')).toEqual(['Reports', 'Q3 Results'])
87+
expect(parseFolderScope('/Reports/Q3%2FQ4')).toEqual(['Reports', 'Q3/Q4'])
88+
})
89+
90+
it('reads an empty scope as no scope', () => {
91+
expect(parseFolderScope('')).toEqual([])
92+
})
93+
94+
it('reports an unusable scope as null rather than throwing', () => {
95+
expect(parseFolderScope('Reports')).toBeNull()
96+
expect(parseFolderScope('/Reports/')).toBeNull()
97+
})
98+
99+
it('feeds pre-parsed segments back through the filter unchanged', () => {
100+
const segments = parseFolderScope('/Reports')
101+
expect(isTableInFolderScope({ folderId: 'q3' }, FOLDERS, segments)).toBe(true)
102+
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, segments)).toBe(false)
103+
})
104+
105+
it('fails open when handed a null scope', () => {
106+
expect(isTableInFolderScope({ folderId: 'archive' }, FOLDERS, null)).toBe(true)
107+
})
108+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { parseFolderPath } from '@/lib/folders/paths'
2+
import { isWithinFolderScope } from '@/lib/folders/scope'
3+
import { getCanonicalFolderPath } from '@/hooks/queries/utils/folder-tree'
4+
import type { WorkflowFolder } from '@/stores/folders/types'
5+
6+
/** The shape the scope filter needs from a table row. */
7+
export interface ScopableTable {
8+
folderId?: string | null
9+
}
10+
11+
/**
12+
* Decodes a scope path once for a whole list.
13+
*
14+
* {@link isTableInFolderScope} would otherwise re-parse the same scope for every
15+
* row, and parsing re-encodes each segment to prove canonicality — real work,
16+
* repeated per table. `null` means the scope is unusable, which fails open.
17+
*/
18+
export function parseFolderScope(scopePath: string): string[] | null {
19+
if (!scopePath) return []
20+
try {
21+
return parseFolderPath(scopePath)
22+
} catch {
23+
return null
24+
}
25+
}
26+
27+
/**
28+
* Whether a table survives the folder scope its picker is narrowed by.
29+
*
30+
* Pure and separate from the component because the interesting behaviour is the
31+
* failure mode, not the happy path: an unparseable scope or a folder the client
32+
* cache has not loaded yet must leave the table OFFERED. A table nobody can
33+
* select reads as "this table is gone" rather than "the filter is malformed",
34+
* and failing open is the recoverable direction.
35+
*
36+
* An empty scope is no scope at all, so everything passes without touching the
37+
* folder map.
38+
*/
39+
export function isTableInFolderScope(
40+
table: ScopableTable,
41+
folders: Record<string, WorkflowFolder>,
42+
scope: string | string[] | null
43+
): boolean {
44+
const scopeSegments = typeof scope === 'string' ? parseFolderScope(scope) : scope
45+
if (scopeSegments === null || scopeSegments.length === 0) return true
46+
try {
47+
return isWithinFolderScope(
48+
parseFolderPath(getCanonicalFolderPath(table.folderId, folders)),
49+
scopeSegments
50+
)
51+
} catch {
52+
return true
53+
}
54+
}

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/table-selector.tsx‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { useCallback, useMemo } from 'react'
44
import { Combobox, type ComboboxOption } from '@sim/emcn'
55
import { useParams } from 'next/navigation'
66
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
7+
import {
8+
isTableInFolderScope,
9+
parseFolderScope,
10+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table-selector/scope'
711
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
812
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
913
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
@@ -52,6 +56,23 @@ export function TableSelector({
5256
'table'
5357
)
5458

59+
/*
60+
* A sibling folder field narrows what this picker offers, so a user cannot
61+
* build a selection from outside the folder they just chose. Purely a
62+
* design-time affordance — the folder never travels, because every operation
63+
* here addresses exactly one table.
64+
*
65+
* Falling back to this control's own id keeps the hook call unconditional for
66+
* a picker with no folder scope; its own value is a table id, never a folder
67+
* path, so the scope reads as absent.
68+
*/
69+
const [folderScopeValue] = useSubBlockValue<unknown>(
70+
blockId,
71+
subBlock.folderScope?.fieldId ?? subBlock.id
72+
)
73+
const folderScopePath =
74+
subBlock.folderScope && typeof folderScopeValue === 'string' ? folderScopeValue.trim() : ''
75+
5576
const value = isPreview ? previewValue : storeValue
5677
const tableId = typeof value === 'string' ? value : null
5778

@@ -63,8 +84,13 @@ export function TableSelector({
6384
* disambiguated too. The folder path keeps its authored casing.
6485
*/
6586
const options = useMemo<ComboboxOption[]>(() => {
66-
const duplicateNames = collectDuplicateNames(tables.map((table) => table.name.toLowerCase()))
67-
return tables.map((table) => ({
87+
/* Decode the scope once for the whole list rather than per row. */
88+
const scopeSegments = parseFolderScope(folderScopePath)
89+
const scoped = scopeSegments?.length
90+
? tables.filter((table) => isTableInFolderScope(table, tableFolders, scopeSegments))
91+
: tables
92+
const duplicateNames = collectDuplicateNames(scoped.map((table) => table.name.toLowerCase()))
93+
return scoped.map((table) => ({
6894
label: disambiguateLabelByFolder(
6995
table.name.toLowerCase(),
7096
table.folderId,
@@ -73,7 +99,7 @@ export function TableSelector({
7399
),
74100
value: table.id,
75101
}))
76-
}, [tables, tableFolders])
102+
}, [tables, tableFolders, folderScopePath])
77103

78104
const handleChange = useCallback(
79105
(selectedValue: string) => {

0 commit comments

Comments
 (0)