import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { CreateFileFolder, DeleteFileFolder, ListFileFolders, MoveFile, MoveFileFolder, RenameFileFolder, } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { findWorkspaceFileFolderIdByPath, getWorkspaceFileFolder, listWorkspaceFileFolders, type WorkspaceFileFolderRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performCreateWorkspaceFileFolder, performDeleteWorkspaceFileItems, performMoveWorkspaceFileItems, performUpdateWorkspaceFileFolder, } from '@/lib/workspace-files/orchestration' const logger = createLogger('FileFolderServerTools') interface WorkspaceScopedArgs { workspaceId?: string args?: Record } type ListFileFoldersArgs = WorkspaceScopedArgs interface CreateFileFolderArgs extends WorkspaceScopedArgs { path?: string name?: string parentId?: string | null parentPath?: string | null } interface RenameFileFolderArgs extends WorkspaceScopedArgs { path?: string folderId?: string name?: string } interface MoveFileFolderArgs extends WorkspaceScopedArgs { path?: string folderId?: string destinationPath?: string | null parentId?: string | null } interface DeleteFileFolderArgs extends WorkspaceScopedArgs { paths?: string[] path?: string folderIds?: string[] folderId?: string } interface MoveFileArgs extends WorkspaceScopedArgs { paths?: string[] path?: string destinationPath?: string | null fileIds?: string[] fileId?: string folderId?: string | null } interface FileFolderResult { success: boolean message: string data?: unknown } function nested(params: WorkspaceScopedArgs): Record | undefined { return params.args && typeof params.args === 'object' ? params.args : undefined } function stringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } function stringArrayValue(value: unknown): string[] | undefined { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined } function nullableStringValue(value: unknown): string | null | undefined { if (value === null) return null if (typeof value !== 'string') return undefined return value.trim() ? value : null } function stringListFromValues(...values: unknown[]): string[] { for (const value of values) { const arr = stringArrayValue(value) if (arr && arr.length > 0) return arr } return values .map((value) => stringValue(value)) .filter((value): value is string => Boolean(value)) } function decodeFileFolderPath(path: string): string[] | null { const trimmed = path.trim().replace(/\/+$/, '') if (!trimmed || trimmed === 'files') return null const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed const withoutMarker = withoutPrefix.endsWith('/.folder') ? withoutPrefix.slice(0, -'/.folder'.length) : withoutPrefix const segments = decodeVfsPathSegments(withoutMarker).filter(Boolean) return segments.length > 0 ? segments : null } async function resolveFolderIdFromPath( workspaceId: string, path: string, label = 'Folder' ): Promise { const segments = decodeFileFolderPath(path) if (!segments) throw new Error(`${label} path must identify a folder under files/`) const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) if (!folderId) throw new Error(`${label} not found at files/${segments.join('/')}`) return folderId } async function resolveOptionalFolderId( workspaceId: string, value: unknown ): Promise { const raw = nullableStringValue(value) if (raw === undefined) return undefined if (raw === null) return null const segments = decodeFileFolderPath(raw) if (!segments) return null const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) if (!folderId) throw new Error(`Target folder not found at files/${segments.join('/')}`) return folderId } async function resolveFileIdsFromPaths( workspaceId: string, paths: string[] ): Promise<{ fileIds: string[] failed: string[] }> { const fileIds: string[] = [] const failed: string[] = [] for (const path of paths) { const file = await resolveWorkspaceFileReference(workspaceId, path) if (!file) { failed.push(path) continue } fileIds.push(file.id) } return { fileIds, failed } } async function resolveWorkspaceId( params: WorkspaceScopedArgs, context: ServerToolContext | undefined, permission: 'read' | 'write' ): Promise { if (!context?.userId) { throw new Error('Authentication required') } const payload = nested(params) const workspaceId = stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || context.workspaceId if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } await ensureWorkspaceAccess(workspaceId, context.userId, permission) return workspaceId } function folderLabel(folder: WorkspaceFileFolderRecord): string { return folder.path || folder.name } export const listFileFoldersServerTool: BaseServerTool = { name: ListFileFolders.id, async execute( params: ListFileFoldersArgs, context?: ServerToolContext ): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'read') if (typeof workspaceId !== 'string') return workspaceId const folders = await listWorkspaceFileFolders(workspaceId) return { success: true, message: folders.length === 1 ? 'Found 1 file folder' : `Found ${folders.length} file folders`, data: { workspaceId, folders }, } } catch (error) { return { success: false, message: toError(error).message } } }, } export const createFileFolderServerTool: BaseServerTool = { name: CreateFileFolder.id, async execute( params: CreateFileFolderArgs, context?: ServerToolContext ): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const rawPath = stringValue(params.path) || stringValue(payload?.path) const pathSegments = rawPath ? decodeFileFolderPath(rawPath) : undefined const name = ( pathSegments?.at(-1) || stringValue(params.name) || stringValue(payload?.name) || '' ).trim() if (!name) return { success: false, message: 'name is required' } let parentId = (await resolveOptionalFolderId(workspaceId, params.parentPath ?? payload?.parentPath)) ?? nullableStringValue(params.parentId ?? payload?.parentId) ?? null if (pathSegments && pathSegments.length > 1) { const resolvedParentId = await findWorkspaceFileFolderIdByPath( workspaceId, pathSegments.slice(0, -1) ) if (!resolvedParentId) { return { success: false, message: `Parent folder not found at files/${pathSegments.slice(0, -1).join('/')}`, } } parentId = resolvedParentId } assertServerToolNotAborted(context) const result = await performCreateWorkspaceFileFolder({ workspaceId, userId: context.userId, name, parentId, }) if (!result.success || !result.folder) { return { success: false, message: result.error || 'Failed to create file folder' } } const { folder } = result logger.info('File folder created via create_file_folder', { workspaceId, folderId: folder.id, parentId, userId: context.userId, }) return { success: true, message: `Created file folder "${folderLabel(folder)}"`, data: { folder }, } } catch (error) { return { success: false, message: toError(error).message } } }, } export const renameFileFolderServerTool: BaseServerTool = { name: RenameFileFolder.id, async execute( params: RenameFileFolderArgs, context?: ServerToolContext ): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const folderPath = stringValue(params.path) || stringValue(payload?.path) const folderId = (folderPath ? await resolveFolderIdFromPath(workspaceId, folderPath) : undefined) || stringValue(params.folderId) || stringValue(payload?.folderId) || '' const name = (stringValue(params.name) || stringValue(payload?.name) || '').trim() if (!folderId) return { success: false, message: 'path is required' } if (!name) return { success: false, message: 'name is required' } const existing = await getWorkspaceFileFolder(workspaceId, folderId) if (!existing) return { success: false, message: 'Folder not found' } assertServerToolNotAborted(context) const result = await performUpdateWorkspaceFileFolder({ workspaceId, folderId, userId: context.userId, name, }) if (!result.success || !result.folder) { return { success: false, message: result.error || 'Failed to rename file folder' } } const { folder } = result logger.info('File folder renamed via rename_file_folder', { workspaceId, folderId, oldName: existing.name, name, userId: context.userId, }) return { success: true, message: `Renamed file folder "${folderLabel(existing)}" to "${folderLabel(folder)}"`, data: { folder }, } } catch (error) { return { success: false, message: toError(error).message } } }, } export const moveFileFolderServerTool: BaseServerTool = { name: MoveFileFolder.id, async execute( params: MoveFileFolderArgs, context?: ServerToolContext ): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const folderPath = stringValue(params.path) || stringValue(payload?.path) const folderId = (folderPath ? await resolveFolderIdFromPath(workspaceId, folderPath) : undefined) || stringValue(params.folderId) || stringValue(payload?.folderId) || '' if (!folderId) return { success: false, message: 'path is required' } const parentId = (await resolveOptionalFolderId( workspaceId, params.destinationPath ?? payload?.destinationPath )) ?? nullableStringValue(params.parentId ?? payload?.parentId) ?? null assertServerToolNotAborted(context) const result = await performUpdateWorkspaceFileFolder({ workspaceId, folderId, userId: context.userId, parentId, }) if (!result.success || !result.folder) { return { success: false, message: result.error || 'Failed to move file folder' } } const { folder } = result logger.info('File folder moved via move_file_folder', { workspaceId, folderId, parentId, userId: context.userId, }) return { success: true, message: parentId ? `Moved file folder "${folderLabel(folder)}"` : `Moved file folder "${folderLabel(folder)}" to root`, data: { folder }, } } catch (error) { return { success: false, message: toError(error).message } } }, } export const deleteFileFolderServerTool: BaseServerTool = { name: DeleteFileFolder.id, async execute( params: DeleteFileFolderArgs, context?: ServerToolContext ): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) const folderIds = paths.length > 0 ? await Promise.all(paths.map((path) => resolveFolderIdFromPath(workspaceId, path))) : (params.folderIds ?? stringArrayValue(payload?.folderIds) ?? [stringValue(params.folderId) || stringValue(payload?.folderId) || ''].filter(Boolean)) if (folderIds.length === 0) return { success: false, message: 'paths is required' } assertServerToolNotAborted(context) const result = await performDeleteWorkspaceFileItems({ workspaceId, userId: context.userId, folderIds, }) if (!result.success || !result.deletedItems) { return { success: false, message: result.error || 'Failed to delete file folders' } } logger.info('File folders deleted via delete_file_folder', { workspaceId, folderIds, folders: result.deletedItems.folders, files: result.deletedItems.files, userId: context.userId, }) return { success: result.deletedItems.folders > 0 || result.deletedItems.files > 0, message: `Deleted ${result.deletedItems.folders} file folder${result.deletedItems.folders === 1 ? '' : 's'} and ${result.deletedItems.files} file${result.deletedItems.files === 1 ? '' : 's'}`, data: result.deletedItems, } } catch (error) { return { success: false, message: toError(error).message } } }, } export const moveFileServerTool: BaseServerTool = { name: MoveFile.id, async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) const resolvedByPath = paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths) : undefined if (resolvedByPath?.failed.length) { return { success: false, message: `Files not found: ${resolvedByPath.failed.join(', ')}`, } } const fileIds = resolvedByPath?.fileIds ?? params.fileIds ?? stringArrayValue(payload?.fileIds) ?? [stringValue(params.fileId) || stringValue(payload?.fileId) || ''].filter(Boolean) if (fileIds.length === 0) return { success: false, message: 'paths is required' } const folderId = (await resolveOptionalFolderId( workspaceId, params.destinationPath ?? payload?.destinationPath )) ?? nullableStringValue(params.folderId ?? payload?.folderId) ?? null assertServerToolNotAborted(context) const result = await performMoveWorkspaceFileItems({ workspaceId, userId: context.userId, fileIds, targetFolderId: folderId, }) if (!result.success || !result.movedItems) { return { success: false, message: result.error || 'Failed to move files' } } logger.info('Files moved via move_file', { workspaceId, fileIds, folderId, movedFiles: result.movedItems.files, userId: context.userId, }) return { success: result.movedItems.files > 0, message: folderId ? `Moved ${result.movedItems.files} file${result.movedItems.files === 1 ? '' : 's'}` : `Moved ${result.movedItems.files} file${result.movedItems.files === 1 ? '' : 's'} to root`, data: result.movedItems, } } catch (error) { return { success: false, message: toError(error).message } } }, }