Files
simstudioai--sim/apps/sim/lib/copilot/tools/server/files/file-folders.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

516 lines
17 KiB
TypeScript

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<string, unknown>
}
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<string, unknown> | 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<string> {
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<string | null | undefined> {
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<string | FileFolderResult> {
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<ListFileFoldersArgs, FileFolderResult> = {
name: ListFileFolders.id,
async execute(
params: ListFileFoldersArgs,
context?: ServerToolContext
): Promise<FileFolderResult> {
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<CreateFileFolderArgs, FileFolderResult> = {
name: CreateFileFolder.id,
async execute(
params: CreateFileFolderArgs,
context?: ServerToolContext
): Promise<FileFolderResult> {
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<RenameFileFolderArgs, FileFolderResult> = {
name: RenameFileFolder.id,
async execute(
params: RenameFileFolderArgs,
context?: ServerToolContext
): Promise<FileFolderResult> {
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<MoveFileFolderArgs, FileFolderResult> = {
name: MoveFileFolder.id,
async execute(
params: MoveFileFolderArgs,
context?: ServerToolContext
): Promise<FileFolderResult> {
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<DeleteFileFolderArgs, FileFolderResult> = {
name: DeleteFileFolder.id,
async execute(
params: DeleteFileFolderArgs,
context?: ServerToolContext
): Promise<FileFolderResult> {
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<MoveFileArgs, FileFolderResult> = {
name: MoveFile.id,
async execute(params: MoveFileArgs, context?: ServerToolContext): Promise<FileFolderResult> {
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 }
}
},
}