Harden VFS mutation handling

This commit is contained in:
Siddharth Ganesan
2026-08-12 19:41:48 -07:00
parent eaee669c85
commit 3738347b05
9 changed files with 752 additions and 124 deletions
@@ -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')
+212 -97
View File
@@ -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<string, string> = {
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<string, VfsMutateOutcome>()
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<ToolCallResult> {
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<VfsMutateOutcome> {
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<VfsMutateOutcome | null> {
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<VfsMutateOutcome> {
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 }
}
}
+37 -3
View File
@@ -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<Map<string, string>> {
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<WorkspaceMdData['tables']> {
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,
Binary file not shown.
@@ -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<KnowledgeBaseWithCounts> {
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' },
}),
})
@@ -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',
@@ -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',
@@ -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',
+153 -4
View File
@@ -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<TableDefinition> {
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),
})