From 3738347b05f5115c123ecd604ec2e07b4e5875d9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 19:41:48 -0700 Subject: [PATCH] Harden VFS mutation handling --- .../copilot/tools/handlers/vfs-mutate.test.ts | 196 ++++++++++- .../lib/copilot/tools/handlers/vfs-mutate.ts | 309 ++++++++++++------ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 40 ++- .../lib/folders/application/resource-vfs.ts | Bin 0 -> 14692 bytes .../knowledge/application/knowledge-vfs.ts | 154 ++++++++- .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 12 + apps/sim/lib/table/application/operations.ts | 6 + apps/sim/lib/table/application/table-vfs.ts | 157 ++++++++- 9 files changed, 752 insertions(+), 124 deletions(-) create mode 100644 apps/sim/lib/folders/application/resource-vfs.ts diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index e19935bb9a..3245a6b897 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -42,8 +42,14 @@ const mocks = vi.hoisted(() => ({ deleteFileVfsItems: vi.fn(), renameTableVfs: vi.fn(), deleteTableVfs: vi.fn(), + transferTableVfs: vi.fn(), + createTableFolders: vi.fn(), + deleteTableFolders: vi.fn(), renameKnowledgeVfs: vi.fn(), deleteKnowledgeVfs: vi.fn(), + transferKnowledgeVfs: vi.fn(), + createKnowledgeFolders: vi.fn(), + deleteKnowledgeFolders: vi.fn(), })) vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) @@ -163,6 +169,18 @@ vi.mock('@/lib/table/application/table-vfs', () => ({ operation: tableOperations.deleteByVfsPath, execute: mocks.deleteTableVfs, }, + transferTableVfsItems: { + operation: tableOperations.moveByVfsPath, + execute: mocks.transferTableVfs, + }, + createTableVfsFolders: { + operation: tableOperations.createFolder, + execute: mocks.createTableFolders, + }, + deleteTableVfsFolders: { + operation: tableOperations.deleteFolder, + execute: mocks.deleteTableFolders, + }, })) vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ @@ -174,6 +192,18 @@ vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ operation: knowledgeOperations.deleteByVfsPath, execute: mocks.deleteKnowledgeVfs, }, + transferKnowledgeVfsItems: { + operation: knowledgeOperations.moveByVfsPath, + execute: mocks.transferKnowledgeVfs, + }, + createKnowledgeVfsFolders: { + operation: knowledgeOperations.manageVfsFolders, + execute: mocks.createKnowledgeFolders, + }, + deleteKnowledgeVfsFolders: { + operation: knowledgeOperations.manageVfsFolders, + execute: mocks.deleteKnowledgeFolders, + }, })) vi.mock('@/lib/table/service', () => ({ @@ -776,15 +806,41 @@ describe('vfs mv/cp', () => { }) }) - it('rejects flat namespaces', async () => { + it('creates table folders through the table application operation', async () => { + mocks.createTableFolders.mockResolvedValue({ + outcomes: [ + { source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1', targetSegments: ['CRM'] }, + ], + }) + const result = await executeVfsMkdir({ paths: ['tables/CRM'] }, context) - expect(result.success).toBe(false) + + expect(mocks.createTableFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + paths: [{ source: 'tables/CRM', segments: ['CRM'] }], + }, + }) + ) + expect(result.success).toBe(true) expect(result.output).toMatchObject({ - results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], + results: [{ from: 'tables/CRM', to: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], }) expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) + it('rejects the reserved knowledgebases/connectors folder path', async () => { + const result = await executeVfsMkdir({ paths: ['knowledgebases/connectors/sub'] }, context) + expect(result.success).toBe(false) + expect(result.output).toMatchObject({ + results: [ + { from: 'knowledgebases/connectors/sub', error: expect.stringContaining('reserved') }, + ], + }) + expect(mocks.createKnowledgeFolders).not.toHaveBeenCalled() + }) + it('rejects creation inside a locked workflow folder', async () => { mocks.createWorkflowVfsFolders.mockResolvedValue({ outcomes: [ @@ -804,30 +860,65 @@ describe('vfs mv/cp', () => { }) }) - describe('tables and knowledge bases (flat namespaces)', () => { - it('renames a table', async () => { + describe('tables and knowledge bases (foldered)', () => { + it('renames a table through the transfer application operation', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/Leads', + kind: 'resource', + resourceId: 'tbl-1', + targetSegments: ['Customers'], + }, + ], + }) + const result = await executeVfsMv( { sources: ['tables/Leads'], destination: 'tables/Customers' }, context ) - expect(mocks.renameTableVfs).toHaveBeenCalledWith( + expect(mocks.transferTableVfs).toHaveBeenCalledWith( expect.objectContaining({ - input: { workspaceId: 'ws-1', sourceName: 'Leads', newName: 'Customers' }, + input: { + workspaceId: 'ws-1', + sources: [{ source: 'tables/Leads', segments: ['Leads'] }], + destination: { segments: ['Customers'], trailingSlash: false }, + }, }) ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) }) - it('rejects nested table destinations as flat-namespace violations', async () => { + it('moves a table into a folder, folders auto-created server-side', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/Leads', + kind: 'resource', + resourceId: 'tbl-1', + targetSegments: ['CRM', 'Leads'], + }, + ], + }) + const result = await executeVfsMv( - { sources: ['tables/Leads'], destination: 'tables/CRM/Leads' }, + { sources: ['tables/Leads'], destination: 'tables/CRM/' }, context ) - expect(result.success).toBe(false) - expect(result.error).toContain('flat namespace') - expect(mocks.renameTable).not.toHaveBeenCalled() + + expect(mocks.transferTableVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + destination: { segments: ['CRM'], trailingSlash: true }, + }), + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'tables/CRM/Leads', kind: 'table' }], + }) }) it('rejects copying tables', async () => { @@ -840,12 +931,23 @@ describe('vfs mv/cp', () => { }) it('renames a knowledge base through trusted application operations', async () => { + mocks.transferKnowledgeVfs.mockResolvedValue({ + outcomes: [ + { + source: 'knowledgebases/Docs', + kind: 'resource', + resourceId: 'kb-1', + targetSegments: ['Product Docs'], + }, + ], + }) + const result = await executeVfsMv( { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, context ) - expect(mocks.renameKnowledgeVfs).toHaveBeenCalledWith( + expect(mocks.transferKnowledgeVfs).toHaveBeenCalledWith( expect.objectContaining({ principal: expect.objectContaining({ kind: 'delegated', @@ -855,8 +957,8 @@ describe('vfs mv/cp', () => { }), input: { workspaceId: 'ws-1', - sourceName: 'Docs', - newName: 'Product Docs', + sources: [{ source: 'knowledgebases/Docs', segments: ['Docs'] }], + destination: { segments: ['Product Docs'], trailingSlash: false }, }, }) ) @@ -864,7 +966,7 @@ describe('vfs mv/cp', () => { }) it('propagates knowledge application infrastructure failures', async () => { - mocks.renameKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) + mocks.transferKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) await expect( executeVfsMv( @@ -875,7 +977,7 @@ describe('vfs mv/cp', () => { }) it('preserves an actionable knowledge rename conflict', async () => { - mocks.renameKnowledgeVfs.mockRejectedValue( + mocks.transferKnowledgeVfs.mockRejectedValue( new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists') ) @@ -912,11 +1014,71 @@ describe('vfs mv/cp', () => { input: { workspaceId: 'ws-1', sourceName: 'Docs', + sourceSegments: ['Docs'], }, }) ) }) + it('moves a whole table folder through the transfer operation', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/CRM', + kind: 'folder', + resourceId: 'fld-1', + targetSegments: ['Archive', 'CRM'], + }, + ], + }) + + const result = await executeVfsMv( + { sources: ['tables/CRM'], destination: 'tables/Archive/' }, + context + ) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'tables/Archive/CRM', kind: 'table_folder' }], + }) + }) + + it('rm retargets to the folder cascade when the path is a folder', async () => { + mocks.deleteTableVfs.mockRejectedValue( + new OrchestrationError('invalid', 'tables/CRM is a folder; this operation takes a table.') + ) + mocks.deleteTableFolders.mockResolvedValue({ + outcomes: [{ source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1' }], + }) + + const result = await executeVfsRm({ paths: ['tables/CRM'] }, context) + + expect(mocks.deleteTableFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: 'ws-1', paths: [{ source: 'tables/CRM', segments: ['CRM'] }] }, + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], + }) + }) + + it('deletes a nested knowledge base by its folder path', async () => { + const result = await executeVfsRm({ paths: ['knowledgebases/Legal/Contracts'] }, context) + + expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + sourceName: 'Contracts', + sourceSegments: ['Legal', 'Contracts'], + }, + }) + ) + expect(result.success).toBe(true) + }) + it('preserves an actionable knowledge delete failure', async () => { mocks.deleteKnowledgeVfs.mockRejectedValue( new OrchestrationError('not_found', 'Knowledge base no longer exists') diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 6516fe35f1..8fa50ce6d6 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -12,16 +12,23 @@ import { import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/files/file-folder-application' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' +import type { ResourceVfsOutcome } from '@/lib/folders/application/resource-vfs' import { + createKnowledgeVfsFolders, deleteKnowledgeBaseByVfsPath, - renameKnowledgeBaseByVfsPath, + deleteKnowledgeVfsFolders, + transferKnowledgeVfsItems, } from '@/lib/knowledge/application/knowledge-vfs' import { captureServerEvent } from '@/lib/posthog/server' -import { deleteTableByVfsPath, renameTableByVfsPath } from '@/lib/table/application/table-vfs' +import { + createTableVfsFolders, + deleteTableByVfsPath, + deleteTableVfsFolders, + transferTableVfsItems, +} from '@/lib/table/application/table-vfs' import { VfsPathLimitError, validateVfsPathBatch } from '@/lib/vfs/limits' import { copyWorkflowVfsItems, @@ -66,7 +73,15 @@ const RM_CATEGORY_REJECTIONS: Record = { interface VfsMutateOutcome { from: string to?: string - kind: 'file' | 'file_folder' | 'workflow' | 'workflow_folder' | 'table' | 'knowledge_base' + kind: + | 'file' + | 'file_folder' + | 'workflow' + | 'workflow_folder' + | 'table' + | 'table_folder' + | 'knowledge_base' + | 'knowledge_base_folder' id?: string error?: string } @@ -217,18 +232,72 @@ export async function executeVfsMkdir( } } + const folderedOutcomes = new Map() + for (const category of ['tables', 'knowledgebases'] as const) { + const categoryPaths = paths.filter((path) => topLevelSegment(path) === category) + if (categoryPaths.length === 0) continue + const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' + const reserved = categoryPaths.filter((path) => + isReservedKnowledgePath(category, decodeVfsPathSegments(path).slice(1)) + ) + for (const path of reserved) { + folderedOutcomes.set(path, { + from: path, + kind: folderKind, + error: '"knowledgebases/connectors" is a reserved path.', + }) + } + const eligible = categoryPaths.filter((path) => !folderedOutcomes.has(path)) + if (eligible.length === 0) continue + const input = { + workspaceId, + paths: eligible.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + } + try { + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, createTableVfsFolders, input, {}) + : await executeCopilotKnowledgeUseCase(context, createKnowledgeVfsFolders, input) + for (const outcome of result.outcomes) { + folderedOutcomes.set(outcome.source, presentResourceVfsOutcome(category, outcome)) + } + } catch (error) { + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, 'Write access required to create folders') + for (const path of eligible) { + folderedOutcomes.set(path, { from: path, kind: folderKind, error: message }) + } + } + } + const outcomes: VfsMutateOutcome[] = [] for (const path of paths) { const top = topLevelSegment(path) const segments = decodeVfsPathSegments(path).slice(1) - const kind = top === 'workflows' ? 'workflow_folder' : 'file_folder' + const kind = + top === 'workflows' + ? 'workflow_folder' + : top === 'tables' + ? 'table_folder' + : top === 'knowledgebases' + ? 'knowledge_base_folder' + : 'file_folder' + if (top === 'tables' || top === 'knowledgebases') { + outcomes.push( + folderedOutcomes.get(path) ?? { from: path, kind, error: 'Folder creation failed' } + ) + continue + } if (top !== 'files' && top !== 'workflows') { const rejection = - top === 'tables' || top === 'knowledgebases' - ? `${top}/ is a flat namespace with no folders.` - : (CATEGORY_REJECTIONS[top] ?? - `"${path}" is not a folder target. mkdir supports files/ and workflows/ paths.`) + CATEGORY_REJECTIONS[top] ?? + `"${path}" is not a folder target. mkdir supports files/, workflows/, tables/, and knowledgebases/ paths.` outcomes.push({ from: path, kind, error: rejection }) continue } @@ -319,7 +388,14 @@ async function executeVfsMutate( case 'workflows': return await mutateWorkflows(verb, sources, destination, context, workspaceId) default: - return await renameFlatResource(verb, category, sources, destination, context, workspaceId) + return await transferFolderedResource( + verb, + category, + sources, + destination, + context, + workspaceId + ) } } catch (error) { if (error instanceof KnowledgeVfsInfrastructureError) { @@ -333,6 +409,28 @@ async function executeVfsMutate( } } +function presentResourceVfsOutcome( + category: 'tables' | 'knowledgebases', + outcome: ResourceVfsOutcome +): VfsMutateOutcome { + const resourceKind = category === 'tables' ? 'table' : 'knowledge_base' + const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' + return { + from: outcome.source, + ...(outcome.targetSegments + ? { to: `${category}/${encodeVfsPathSegments(outcome.targetSegments)}` } + : {}), + kind: outcome.kind === 'folder' ? folderKind : resourceKind, + id: outcome.resourceId, + error: outcome.error, + } +} + +/** knowledgebases/connectors is a virtual tree, not a knowledge base or folder. */ +function isReservedKnowledgePath(category: string, segments: readonly string[]): boolean { + return category === 'knowledgebases' && segments[0]?.toLowerCase() === 'connectors' +} + async function mutateWorkspaceFiles( verb: MutateVerb, sources: string[], @@ -419,7 +517,7 @@ async function mutateWorkflows( } } -async function renameFlatResource( +async function transferFolderedResource( verb: MutateVerb, category: 'tables' | 'knowledgebases', sources: string[], @@ -428,77 +526,49 @@ async function renameFlatResource( workspaceId: string ): Promise { const label = category === 'tables' ? 'Tables' : 'Knowledge bases' - const kind = category === 'tables' ? 'table' : 'knowledge_base' - if (verb === 'cp') { return { success: false, error: `${label} cannot be copied — duplication is not supported.` } } - if (sources.length > 1) { - return { success: false, error: `${label} are renamed one at a time.` } - } - const sourceSegments = decodeVfsPathSegments(sources[0]).slice(1) - const destSegments = decodeVfsPathSegments(destination).slice(1) - if (sourceSegments.length !== 1 || destSegments.length !== 1 || hasTrailingSlash(destination)) { - return { - success: false, - error: `${label} have a flat namespace with no folders — mv only renames them, e.g. mv({sources: ["${category}/Old Name"], destination: "${category}/New Name"}).`, + const sourceRefs = sources.map((source) => ({ + source, + segments: decodeVfsPathSegments(source).slice(1), + })) + const destinationSegments = decodeVfsPathSegments(destination).slice(1) + for (const ref of sourceRefs) { + if (isReservedKnowledgePath(category, ref.segments)) { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } } - - const sourceName = sourceSegments[0] - const newName = destSegments[0] - - if (category === 'tables') { - try { - const renamed = await executeCopilotTableUseCase( - context, - renameTableByVfsPath, - { workspaceId, sourceName, newName }, - {} - ) - return buildResult(verb, [ - { - from: sources[0], - to: `tables/${normalizeVfsSegment(renamed.name)}`, - kind, - id: renamed.id, - }, - ]) - } catch (error) { - return { success: false, error: messageForExpectedTableVfsError(error) } - } - } - - if (newName.toLowerCase() === 'connectors') { + if (isReservedKnowledgePath(category, destinationSegments)) { return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } + + const input = { + workspaceId, + sources: sourceRefs, + destination: { + segments: destinationSegments, + trailingSlash: hasTrailingSlash(destination), + }, + } + assertMutationNotAborted(context) try { - const renamed = await executeCopilotKnowledgeUseCase(context, renameKnowledgeBaseByVfsPath, { - workspaceId, - sourceName, - newName, - }) - logger.info('Renamed knowledge base via mv', { - knowledgeBaseId: renamed.id, - workspaceId, - }) - return buildResult(verb, [ - { - from: sources[0], - to: `knowledgebases/${normalizeVfsSegment(renamed.name)}`, - kind, - id: renamed.id, - }, - ]) + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, transferTableVfsItems, input, {}) + : await executeCopilotKnowledgeUseCase(context, transferKnowledgeVfsItems, input) + return buildResult( + verb, + result.outcomes.map((outcome) => presentResourceVfsOutcome(category, outcome)) + ) } catch (error) { - return { - success: false, - error: messageForKnowledgeVfsError( - error, - `Write access required to rename knowledge base "${sourceName}"` - ), - } + if (context.abortSignal?.aborted) throw error + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, `Write access required to move ${label.toLowerCase()}`) + return { success: false, error: message } } } @@ -641,31 +711,25 @@ function removeOne( } } -/** Resolves a flat tables/{name} or knowledgebases/{name} path to its single segment. */ -function flatResourceName(path: string): string | null { - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length !== 1) return null - return segments[0] -} - async function removeTablePath( path: string, context: ExecutionContext, workspaceId: string ): Promise { - const sourceName = flatResourceName(path) - if (!sourceName) { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { return { from: path, kind: 'table', - error: 'tables/ is a flat namespace — rm takes a single name, e.g. rm(["tables/Leads"]).', + error: 'rm takes a table or folder path, e.g. rm(["tables/Leads"]) or rm(["tables/CRM"]).', } } + const sourceName = segments[segments.length - 1] try { const deleted = await executeCopilotTableUseCase( context, deleteTableByVfsPath, - { workspaceId, sourceName }, + { workspaceId, sourceName, sourceSegments: segments }, {} ) captureServerEvent( @@ -677,7 +741,51 @@ async function removeTablePath( logger.info('Archived table via rm', { tableId: deleted.id, workspaceId }) return { from: path, kind: 'table', id: deleted.id } } catch (error) { - return { from: path, kind: 'table', error: messageForExpectedTableVfsError(error) } + const message = messageForExpectedTableVfsError(error) + const folderOutcome = await removeResourceFolderFallback( + 'tables', + path, + segments, + message, + context, + workspaceId + ) + if (folderOutcome) return folderOutcome + return { from: path, kind: 'table', error: message } + } +} + +/** + * rm resolution is resource-first (matching mv); when the resource resolver + * reports the path IS a folder, the delete retargets to the folder cascade. + */ +async function removeResourceFolderFallback( + category: 'tables' | 'knowledgebases', + path: string, + segments: string[], + resourceError: string, + context: ExecutionContext, + workspaceId: string +): Promise { + if (!resourceError.includes('is a folder')) return null + const input = { workspaceId, paths: [{ source: path, segments }] } + try { + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, deleteTableVfsFolders, input, {}) + : await executeCopilotKnowledgeUseCase(context, deleteKnowledgeVfsFolders, input) + const outcome = result.outcomes[0] + return outcome ? presentResourceVfsOutcome(category, outcome) : null + } catch (error) { + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, 'Write access required to delete folders') + return { + from: path, + kind: category === 'tables' ? 'table_folder' : 'knowledge_base_folder', + error: message, + } } } @@ -686,16 +794,16 @@ async function removeKnowledgeBasePath( context: ExecutionContext, workspaceId: string ): Promise { - const sourceName = flatResourceName(path) - if (!sourceName) { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { return { from: path, kind: 'knowledge_base', - error: - 'knowledgebases/ is a flat namespace — rm takes a single name, e.g. rm(["knowledgebases/support-docs"]).', + error: 'rm takes a knowledge base or folder path, e.g. rm(["knowledgebases/support-docs"]).', } } - if (sourceName.toLowerCase() === 'connectors') { + const sourceName = segments[segments.length - 1] + if (isReservedKnowledgePath('knowledgebases', segments)) { return { from: path, kind: 'knowledge_base', @@ -706,6 +814,7 @@ async function removeKnowledgeBasePath( const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseByVfsPath, { workspaceId, sourceName, + sourceSegments: segments, }) PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: deleted.id }) logger.info('Deleted knowledge base via rm', { @@ -714,13 +823,19 @@ async function removeKnowledgeBasePath( }) return { from: path, kind: 'knowledge_base', id: deleted.id } } catch (error) { - return { - from: path, - kind: 'knowledge_base', - error: messageForKnowledgeVfsError( - error, - `Write access required to delete knowledge base "${sourceName}"` - ), - } + const message = messageForKnowledgeVfsError( + error, + `Write access required to delete knowledge base "${sourceName}"` + ) + const folderOutcome = await removeResourceFolderFallback( + 'knowledgebases', + path, + segments, + message, + context, + workspaceId + ) + if (folderOutcome) return folderOutcome + return { from: path, kind: 'knowledge_base', error: message } } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 1cdb3dd34b..96ca933172 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -109,6 +109,7 @@ import { listWorkspaceSandboxes, } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { listFoldersForWorkspace } from '@/lib/folders/queries' import { isIntegrationDeploymentAvailableForVisibility, isOAuthServiceDeploymentAvailable, @@ -1612,6 +1613,26 @@ export class WorkspaceVFS { return buildVfsFolderPathMap(folders) } + /** + * Folder paths for a non-workflow resource tree (tables, knowledge bases), + * plus `.folder` markers so empty folders are discoverable via glob — the + * same contract workflows/ has. Returns folderId → encoded folder path. + */ + private async registerResourceFolders( + workspaceId: string, + resourceType: 'table' | 'knowledge_base', + rootSegment: 'tables' | 'knowledgebases' + ): Promise> { + const folders = await listFoldersForWorkspace(workspaceId, 'active', resourceType) + const paths = buildVfsFolderPathMap( + folders.map((f) => ({ folderId: f.id, folderName: f.name, parentId: f.parentId })) + ) + for (const folderPath of paths.values()) { + this.files.set(`${rootSegment}/${folderPath}/.folder`, '') + } + return paths + } + /** * Resolve the set of folder IDs that are effectively locked — locked directly * or via a locked ancestor folder. A workflow inside any of these folders is @@ -1814,10 +1835,18 @@ export class WorkspaceVFS { input: { workspaceId }, }) const kbs = knowledgeBases.map(({ knowledgeBase }) => knowledgeBase) + const folderPaths = await this.registerResourceFolders( + workspaceId, + 'knowledge_base', + 'knowledgebases' + ) for (const { knowledgeBase: kb, tagDefinitions } of knowledgeBases) { const safeName = sanitizeName(kb.name) - const prefix = `knowledgebases/${safeName}/` + const folderPath = kb.folderId ? folderPaths.get(kb.folderId) : undefined + const prefix = folderPath + ? `knowledgebases/${folderPath}/${safeName}/` + : `knowledgebases/${safeName}/` this.files.set( `${prefix}meta.json`, @@ -1912,12 +1941,17 @@ export class WorkspaceVFS { */ private async materializeTables(workspaceId: string): Promise { try { - const tables = await listTables(workspaceId) + const [tables, folderPaths] = await Promise.all([ + listTables(workspaceId), + this.registerResourceFolders(workspaceId, 'table', 'tables'), + ]) for (const table of tables) { const safeName = sanitizeName(table.name) + const folderPath = table.folderId ? folderPaths.get(table.folderId) : undefined + const prefix = folderPath ? `tables/${folderPath}/${safeName}` : `tables/${safeName}` this.files.set( - `tables/${safeName}/meta.json`, + `${prefix}/meta.json`, serializeTableMeta({ id: table.id, name: table.name, diff --git a/apps/sim/lib/folders/application/resource-vfs.ts b/apps/sim/lib/folders/application/resource-vfs.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c997fa7f19d5505692b5757a935e207dd50d1a4 GIT binary patch literal 14692 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 Ih{6W{AA}y(lmGw# literal 0 HcmV?d00001 diff --git a/apps/sim/lib/knowledge/application/knowledge-vfs.ts b/apps/sim/lib/knowledge/application/knowledge-vfs.ts index f7fbff3e8d..4c4e5243da 100644 --- a/apps/sim/lib/knowledge/application/knowledge-vfs.ts +++ b/apps/sim/lib/knowledge/application/knowledge-vfs.ts @@ -1,6 +1,14 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { + createResourceVfsFolders, + deleteResourceVfsFolders, + type FolderedResourceAdapter, + resolveResourceRowBySegments, + transferResourceVfsItems, +} from '@/lib/folders/application/resource-vfs' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { type KnowledgeWorkspaceContext, @@ -17,6 +25,29 @@ import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' interface KnowledgeVfsReferenceInput { workspaceId: string sourceName: string + /** Folder segments + leaf name; when present the nested-aware resolver is used. */ + sourceSegments?: string[] +} + +const knowledgeVfsAdapter: FolderedResourceAdapter = { + resourceType: 'knowledge_base', + rootSegment: 'knowledgebases', + label: 'knowledge base', + async listRows(workspaceId) { + const { data: rows } = await getWorkspaceKnowledgeBases(workspaceId, 'active', {}) + return rows.map((kb) => ({ id: kb.id, name: kb.name, folderId: kb.folderId ?? null })) + }, + async moveRow(row, folderId, workspaceId) { + await updateKnowledgeBase(row.id, { folderId }, generateRequestId(), { + assertedWorkspaceId: workspaceId, + }) + }, + async renameRow(row, newName, workspaceId) { + const updated = await updateKnowledgeBase(row.id, { name: newName }, generateRequestId(), { + assertedWorkspaceId: workspaceId, + }) + return { id: updated.id, name: updated.name } + }, } export interface RenameKnowledgeBaseByVfsPathInput extends KnowledgeVfsReferenceInput { @@ -27,8 +58,27 @@ export type DeleteKnowledgeBaseByVfsPathInput = KnowledgeVfsReferenceInput async function resolveKnowledgeBaseByVfsName( context: KnowledgeWorkspaceContext, - sourceName: string + sourceName: string, + sourceSegments?: string[] ): Promise { + if (sourceSegments && sourceSegments.length > 1) { + const row = await resolveResourceRowBySegments( + knowledgeVfsAdapter, + context.workspaceId, + sourceSegments + ) + const { data: rows } = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { + search: row.name, + }) + const match = rows.find((kb) => kb.id === row.id) + if (!match) { + throw new OrchestrationError( + 'not_found', + `Knowledge base not found at knowledgebases/${sourceSegments.join('/')}` + ) + } + return match + } const { data: rows } = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { search: sourceName, }) @@ -54,7 +104,11 @@ export const renameKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: RenameKnowledgeBaseByVfsPathInput }) => resolveKnowledgeWorkspaceContext(input), async execute({ input, context }) { - const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const knowledgeBase = await resolveKnowledgeBaseByVfsName( + context, + input.sourceName, + input.sourceSegments + ) const updated = await updateKnowledgeBase( knowledgeBase.id, { name: input.newName }, @@ -83,7 +137,11 @@ export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: DeleteKnowledgeBaseByVfsPathInput }) => resolveKnowledgeWorkspaceContext(input), async execute({ input, context }) { - const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const knowledgeBase = await resolveKnowledgeBaseByVfsName( + context, + input.sourceName, + input.sourceSegments + ) await deleteKnowledgeBase(knowledgeBase.id, generateRequestId(), { assertedWorkspaceId: context.workspaceId, }) @@ -103,3 +161,93 @@ export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ metadata: { source: 'copilot_vfs', knowledgeBaseName: result.name }, }), }) + +export interface KnowledgeVfsPathsInput { + workspaceId: string + paths: Array<{ source: string; segments: string[] }> +} + +export interface TransferKnowledgeVfsItemsInput { + workspaceId: string + sources: Array<{ source: string; segments: string[] }> + destination: { segments: string[]; trailingSlash: boolean } +} + +/** mkdir -p under knowledgebases/ — folder invariants live in lib/folders. */ +export const createKnowledgeVfsFolders = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.manageVfsFolders, + resolveContext: ({ input }: { input: KnowledgeVfsPathsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await createResourceVfsFolders(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Created knowledge base folders', + metadata: { op: 'vfs_mkdir', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) + +/** mv under knowledgebases/: rows into folders, folder moves/renames, leaf renames. */ +export const transferKnowledgeVfsItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.moveByVfsPath, + resolveContext: ({ input }: { input: TransferKnowledgeVfsItemsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await transferResourceVfsItems(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + sources: input.sources, + destination: input.destination, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Moved knowledge base VFS items', + metadata: { op: 'vfs_mv', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) + +/** rm of knowledgebases/ folder paths — recursive via the shared cascade. */ +export const deleteKnowledgeVfsFolders = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.manageVfsFolders, + resolveContext: ({ input }: { input: KnowledgeVfsPathsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await deleteResourceVfsFolders(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Deleted knowledge base folders', + metadata: { op: 'vfs_rm_folder', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 32847a90a7..cc54bcbd65 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -18,6 +18,8 @@ describe('knowledge operation registry', () => { 'knowledge.delete', 'knowledge.bulk_delete', 'knowledge.vfs.rename', + 'knowledge.vfs.move', + 'knowledge.vfs.folders.manage', 'knowledge.vfs.delete', 'knowledge.search', 'knowledge.folders.list', diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index c0181d2f8e..318aecdbac 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -77,6 +77,18 @@ export const knowledgeOperations = { workspaceApiKey: 'deny', ...COPILOT_PRINCIPAL_POLICY, }), + moveByVfsPath: defineWorkspaceOperation({ + id: 'knowledge.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), + manageVfsFolders: defineWorkspaceOperation({ + id: 'knowledge.vfs.folders.manage', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), deleteByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.delete', minimumRole: 'write', diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index dd590edd4f..18633f8755 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -86,6 +86,12 @@ export const tableOperations = { workspaceApiKey: 'deny', ...COPILOT_PRINCIPAL_POLICY, }), + moveByVfsPath: defineWorkspaceOperation({ + id: 'tables.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), deleteByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.delete', minimumRole: 'write', diff --git a/apps/sim/lib/table/application/table-vfs.ts b/apps/sim/lib/table/application/table-vfs.ts index cba5c91866..2e61a3b057 100644 --- a/apps/sim/lib/table/application/table-vfs.ts +++ b/apps/sim/lib/table/application/table-vfs.ts @@ -1,16 +1,52 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { + createResourceVfsFolders, + deleteResourceVfsFolders, + type FolderedResourceAdapter, + resolveResourceRowBySegments, + transferResourceVfsItems, +} from '@/lib/folders/application/resource-vfs' import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveTableWorkspaceContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' -import { deleteTable, findActiveTablesByExactName, renameTable } from '@/lib/table/service' +import { + deleteTable, + findActiveTablesByExactName, + listTables, + moveTableToFolder, + renameTable, +} from '@/lib/table/service' import type { TableDefinition } from '@/lib/table/types' interface TableVfsReferenceInput { workspaceId: string sourceName: string + /** Folder segments + leaf name; when present the nested-aware resolver is used. */ + sourceSegments?: string[] +} + +const tableVfsAdapter: FolderedResourceAdapter = { + resourceType: 'table', + rootSegment: 'tables', + label: 'table', + async listRows(workspaceId) { + const tables = await listTables(workspaceId) + return tables.map((t) => ({ id: t.id, name: t.name, folderId: t.folderId ?? null })) + }, + async moveRow(row, folderId, workspaceId) { + await moveTableToFolder(row.id, workspaceId, folderId, generateRequestId()) + }, + async renameRow(row, newName, workspaceId) { + const renamed = await renameTable(row.id, newName, generateRequestId(), { + expectedWorkspaceId: workspaceId, + skipNotify: true, + }) + return { id: renamed.id, name: renamed.name } + }, } export interface RenameTableByVfsPathInput extends TableVfsReferenceInput { @@ -21,8 +57,20 @@ export type DeleteTableByVfsPathInput = TableVfsReferenceInput async function resolveTableByVfsName( workspaceId: string, - sourceName: string + sourceName: string, + sourceSegments?: string[] ): Promise { + if (sourceSegments && sourceSegments.length > 1) { + const row = await resolveResourceRowBySegments(tableVfsAdapter, workspaceId, sourceSegments) + const matches = await findActiveTablesByExactName(workspaceId, row.name) + const table = matches.find((t) => t.id === row.id) + if (!table) + throw new OrchestrationError( + 'not_found', + `Table not found at tables/${sourceSegments.join('/')}` + ) + return table + } const matches = await findActiveTablesByExactName(workspaceId, sourceName) if (matches.length > 1) { throw new OrchestrationError('conflict', `Table path is ambiguous: tables/${sourceName}`) @@ -37,7 +85,11 @@ export const renameTableByVfsPath = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: RenameTableByVfsPathInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const table = await resolveTableByVfsName( + context.workspaceId, + input.sourceName, + input.sourceSegments + ) const renamed = await renameTable(table.id, input.newName, generateRequestId(), { expectedWorkspaceId: context.workspaceId, skipNotify: true, @@ -65,7 +117,11 @@ export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: DeleteTableByVfsPathInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const table = await resolveTableByVfsName( + context.workspaceId, + input.sourceName, + input.sourceSegments + ) const { archived } = await deleteTable(table.id, generateRequestId(), { expectedWorkspaceId: context.workspaceId, skipNotify: true, @@ -89,3 +145,96 @@ export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ }), afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), }) + +export interface TableVfsPathsInput { + workspaceId: string + paths: Array<{ source: string; segments: string[] }> +} + +export interface TransferTableVfsItemsInput { + workspaceId: string + sources: Array<{ source: string; segments: string[] }> + destination: { segments: string[]; trailingSlash: boolean } +} + +/** mkdir -p under tables/ — folder invariants live in lib/folders. */ +export const createTableVfsFolders = defineAuthorizedTableUseCase({ + operation: tableOperations.createFolder, + resolveContext: ({ input }: { input: TableVfsPathsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await createResourceVfsFolders(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Created table folders', + metadata: { op: 'vfs_mkdir', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +/** mv under tables/: rows into folders, folder moves/renames, leaf renames. */ +export const transferTableVfsItems = defineAuthorizedTableUseCase({ + operation: tableOperations.moveByVfsPath, + resolveContext: ({ input }: { input: TransferTableVfsItemsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await transferResourceVfsItems(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + sources: input.sources, + destination: input.destination, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Moved table VFS items', + metadata: { op: 'vfs_mv', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +/** rm of tables/ folder paths — recursive via the shared cascade. */ +export const deleteTableVfsFolders = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteFolder, + resolveContext: ({ input }: { input: TableVfsPathsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await deleteResourceVfsFolders(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Deleted table folders', + metadata: { op: 'vfs_rm_folder', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +})