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
516 lines
17 KiB
TypeScript
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 }
|
|
}
|
|
},
|
|
}
|