mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-31 03:47:12 +08:00
Harden VFS mutation handling
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user