Files
simstudioai--sim/apps/sim/lib/copilot/tools/handlers/function-execute.ts
T
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

468 lines
18 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { getColumnId } from '@/lib/table/column-keys'
import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
import { queryRows } from '@/lib/table/rows/service'
import { getTableById, listTables } from '@/lib/table/service'
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
findWorkspaceFileRecord,
getSandboxWorkspaceFilePath,
listWorkspaceFiles,
type WorkspaceFileRecord,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import {
downloadFile,
generatePresignedDownloadUrl,
hasCloudStorage,
} from '@/lib/uploads/core/storage-service'
import { executeTool as executeAppTool } from '@/tools'
import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types'
const logger = createLogger('CopilotFunctionExecute')
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_TOTAL_SIZE = 50 * 1024 * 1024
const MAX_MOUNTED_FILES = 500
/**
* Below this row count a table mounts via the direct inline CSV path — the version-keyed snapshot
* cache (storage round-trip) only pays off for larger/hot tables. Behind the feature flag either
* way; this just keeps tiny one-shot tables on the cheaper path.
*/
const SNAPSHOT_MIN_ROWS = 500
/**
* Lifetime of a presigned URL handed to the sandbox to fetch a mounted object (table snapshot or
* workspace file). Long enough to download a large file at sandbox startup; the URL grants read to
* only that one object.
*/
const MOUNT_URL_TTL_SECONDS = 600
/**
* Per-file ceiling for URL-mounted workspace files. The bytes never transit the web process — the
* sandbox curls them straight from storage — so the bound is sandbox disk, not web heap (unlike the
* inline MAX_FILE_SIZE path).
*/
const MOUNT_URL_MAX_BYTES = 500 * 1024 * 1024
/**
* Aggregate ceiling across all URL-mounted files in one request. URL mounts bypass the web heap (so
* they don't count against MAX_TOTAL_SIZE), but the sandbox still curls every byte onto its disk —
* this rejects an oversized request up front instead of filling the sandbox disk one slow curl at a
* time. Generous vs MAX_TOTAL_SIZE since the bytes never transit web memory.
*/
const MAX_TOTAL_URL_BYTES = 2 * 1024 * 1024 * 1024
type SandboxFile =
| { type?: 'content'; path: string; content: string; encoding?: 'base64' }
| { type: 'url'; path: string; url: string }
/**
* Running byte totals for one resolveInputFiles call. `buffered` bytes pass through the web process
* (capped by MAX_TOTAL_SIZE); `url` bytes are curled straight into the sandbox (capped by
* MAX_TOTAL_URL_BYTES). Tracked separately because the two ceilings protect different resources —
* web heap vs sandbox disk.
*/
interface MountedBytes {
buffered: number
url: number
}
/**
* Mounts a stored workspace file into the sandbox and records its bytes against the running totals.
* With cloud storage the sandbox fetches the bytes itself from a presigned URL (no web-heap transit,
* per-file ceiling MOUNT_URL_MAX_BYTES, aggregate ceiling MAX_TOTAL_URL_BYTES); with local storage a
* presigned URL is an app-internal serve path a remote sandbox can't reach, so we buffer the bytes
* through the web process under the inline MAX_FILE_SIZE / MAX_TOTAL_SIZE guards.
*/
async function pushWorkspaceFileMount(
sandboxFiles: SandboxFile[],
record: WorkspaceFileRecord,
mountPath: string,
mounted: MountedBytes
): Promise<void> {
if (hasCloudStorage()) {
if (record.size > MOUNT_URL_MAX_BYTES) {
throw new Error(
`Input file "${mountPath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MOUNT_URL_MAX_BYTES / 1024 / 1024}MB per-file mount limit.`
)
}
if (mounted.url + record.size > MAX_TOTAL_URL_BYTES) {
throw new Error(
`Mounting "${mountPath}" would exceed the ${MAX_TOTAL_URL_BYTES / 1024 / 1024 / 1024}GB total mount limit. Mount fewer or smaller files.`
)
}
const url = await generatePresignedDownloadUrl(
record.key,
record.storageContext ?? 'workspace',
MOUNT_URL_TTL_SECONDS
)
sandboxFiles.push({ type: 'url', path: mountPath, url })
mounted.url += record.size
return
}
if (record.size > MAX_FILE_SIZE) {
throw new Error(
`Input file "${mountPath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
)
}
if (mounted.buffered + record.size > MAX_TOTAL_SIZE) {
throw new Error(
`Mounting "${mountPath}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller files.`
)
}
const buffer = await fetchWorkspaceFileBuffer(record)
const isText = /^text\/|application\/json|application\/xml|application\/csv/.test(
record.type || ''
)
sandboxFiles.push({
path: mountPath,
content: isText ? buffer.toString('utf-8') : buffer.toString('base64'),
encoding: isText ? undefined : 'base64',
})
mounted.buffered += buffer.length
}
interface CanonicalFileInput {
path: string
sandboxPath?: string
}
interface CanonicalDirectoryInput {
path: string
sandboxPath?: string
}
interface CanonicalTableInput {
tableId?: string
path?: string
sandboxPath?: string
}
function tableNameFromVfsPath(tableRef: string): string | null {
if (!tableRef.startsWith('tables/')) return null
const segments = decodeVfsPathSegments(tableRef)
const metaIndex = segments.lastIndexOf('meta.json')
return segments[metaIndex > 0 ? metaIndex - 1 : segments.length - 1] ?? null
}
async function resolveTableRef(
tableRef: string,
tablePathLookup?: Map<string, Awaited<ReturnType<typeof listTables>>[number]>
) {
if (!tableRef.startsWith('tables/')) {
return getTableById(tableRef)
}
const tableName = tableNameFromVfsPath(tableRef)
if (!tableName) return null
return tablePathLookup?.get(tableName) ?? null
}
export async function resolveInputFiles(
workspaceId: string,
inputFiles?: unknown[],
inputTables?: unknown[],
inputDirectories?: unknown[]
): Promise<SandboxFile[]> {
const sandboxFiles: SandboxFile[] = []
const mounted: MountedBytes = { buffered: 0, url: 0 }
const betaEnabled = await isFeatureEnabled('mothership-beta')
if (inputFiles?.length && workspaceId) {
if (inputFiles.length > MAX_MOUNTED_FILES) {
throw new Error(
`Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.`
)
}
const allFiles = await listWorkspaceFiles(workspaceId, {
includeReservedSystemFiles: betaEnabled,
})
for (const fileRef of inputFiles) {
const filePath =
typeof fileRef === 'string'
? fileRef
: fileRef && typeof fileRef === 'object'
? (fileRef as CanonicalFileInput).path
: undefined
if (!filePath) continue
const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath })
if (!alias && isPlanAliasPath(filePath)) {
logger.warn('Unsupported plan alias input file path', { filePath })
continue
}
if (alias?.kind === 'plans_dir') {
logger.warn('Input file is a plan alias directory', { filePath })
continue
}
const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath)
if (!record) {
if (filePath.startsWith('uploads/')) {
throw new Error(
`Cannot mount "${filePath}": uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.`
)
}
throw new Error(
`Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").`
)
}
const explicitSandboxPath =
typeof fileRef === 'object' && fileRef !== null
? (fileRef as CanonicalFileInput).sandboxPath
: undefined
const mountPath =
explicitSandboxPath ||
(alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record))
await pushWorkspaceFileMount(sandboxFiles, record, mountPath, mounted)
}
}
if (inputDirectories?.length && workspaceId) {
const folders = await listWorkspaceFileFolders(workspaceId, {
includeReservedSystemFolders: betaEnabled,
})
const allFiles = await listWorkspaceFiles(workspaceId, {
folders,
includeReservedSystemFiles: betaEnabled,
})
for (const dirRef of inputDirectories) {
const dirPath =
typeof dirRef === 'string'
? dirRef
: dirRef && typeof dirRef === 'object'
? (dirRef as CanonicalDirectoryInput).path
: undefined
if (!dirPath) continue
const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: dirPath })
if (alias && alias.kind !== 'plans_dir') {
throw new Error(`Input directory is a plan alias file, not a directory: ${dirPath}`)
}
if (!alias && isPlanAliasPath(dirPath)) {
throw new Error(`Unsupported plan alias directory: ${dirPath}`)
}
const backingDirPath = alias?.backingPath ?? dirPath
const folderSegments = decodeVfsPathSegments(backingDirPath.replace(/^\/?files\/?/, ''))
const folderDisplayPath = folderSegments.join('/')
const folder = folders.find((candidate) => candidate.path === folderDisplayPath)
if (!folder) {
throw new Error(`Input directory not found: ${dirPath}`)
}
const mountRoot =
typeof dirRef === 'object' &&
dirRef !== null &&
(dirRef as CanonicalDirectoryInput).sandboxPath
? (dirRef as CanonicalDirectoryInput).sandboxPath!
: alias
? workflowAliasSandboxPath(alias.aliasPath)
: `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}`
const descendants = allFiles.filter((file) => {
if (!file.folderPath) return false
return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`)
})
if (descendants.length > MAX_MOUNTED_FILES) {
throw new Error(
`Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.`
)
}
logger.info('Mounting workspace directory for function_execute', {
vfsPath: dirPath,
sandboxPath: mountRoot,
fileCount: descendants.length,
})
const childFolders = folders.filter(
(candidate) =>
candidate.path !== folder.path && candidate.path.startsWith(`${folder.path}/`)
)
if (descendants.length === 0 && childFolders.length === 0) {
sandboxFiles.push({ path: `${mountRoot}/.keep`, content: '' })
continue
}
for (const childFolder of childFolders) {
const hasFiles = descendants.some((file) => {
if (!file.folderPath) return false
return (
file.folderPath === childFolder.path ||
file.folderPath.startsWith(`${childFolder.path}/`)
)
})
if (!hasFiles) {
const relativeFolder = childFolder.path.slice(folder.path.length).replace(/^\/+/, '')
sandboxFiles.push({ path: `${mountRoot}/${relativeFolder}/.keep`, content: '' })
}
}
for (const record of descendants) {
const relativeFolder =
record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? ''
const relativePath = alias
? encodeVfsPathSegments(
[relativeFolder, record.name].filter(Boolean).join('/').split('/')
)
: [relativeFolder, record.name].filter(Boolean).join('/')
await pushWorkspaceFileMount(sandboxFiles, record, `${mountRoot}/${relativePath}`, mounted)
}
}
}
if (inputTables?.length) {
const hasTablePathRefs = inputTables.some((tableRef) => {
const tableId =
typeof tableRef === 'string'
? tableRef
: tableRef && typeof tableRef === 'object'
? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path
: undefined
return typeof tableId === 'string' && tableId.startsWith('tables/')
})
const tablePathLookup = hasTablePathRefs
? new Map((await listTables(workspaceId)).map((table) => [table.name, table]))
: undefined
const snapshotCacheEnabled = await isFeatureEnabled('table-snapshot-cache')
for (const tableRef of inputTables) {
const tableId =
typeof tableRef === 'string'
? tableRef
: tableRef && typeof tableRef === 'object'
? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path
: undefined
if (!tableId) continue
const table = await resolveTableRef(tableId, tablePathLookup)
if (!table || table.workspaceId !== workspaceId) {
throw new Error(
`Input table not found: "${tableId}". Pass the table id (tbl_...) from tables/{name}/meta.json, or a tables/{name}/meta.json path.`
)
}
const sandboxPath =
typeof tableRef === 'object' && tableRef !== null
? (tableRef as CanonicalTableInput).sandboxPath
: undefined
const mountPath = sandboxPath || `/home/user/tables/${table.id}.csv`
// Large/hot tables mount by reference from a version-keyed CSV snapshot in object storage.
if (snapshotCacheEnabled && table.rowCount >= SNAPSHOT_MIN_ROWS) {
const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec')
if (hasCloudStorage()) {
// Mount by reference: the sandbox fetches the snapshot straight from storage via a
// presigned URL, so the bytes never pass through the web process — the only ceiling is
// sandbox disk (enforced at materialization by SNAPSHOT_MAX_BYTES).
if (snapshot.size > SNAPSHOT_MAX_BYTES) {
throw new Error(
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${SNAPSHOT_MAX_BYTES / 1024 / 1024}MB table mount limit.`
)
}
const url = await generatePresignedDownloadUrl(
snapshot.key,
'execution',
MOUNT_URL_TTL_SECONDS
)
sandboxFiles.push({ type: 'url', path: mountPath, url })
continue
}
// Local storage: a presigned URL is an app-internal serve path a remote sandbox can't
// reach, so fall back to buffering the bytes through the web process (file-mount guards).
if (snapshot.size > MAX_FILE_SIZE) {
throw new Error(
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
)
}
if (mounted.buffered + snapshot.size > MAX_TOTAL_SIZE) {
throw new Error(
`Mounting "${tableId}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller tables.`
)
}
const buffer = await downloadFile({
key: snapshot.key,
context: 'execution',
maxBytes: MAX_FILE_SIZE,
})
mounted.buffered += buffer.length
sandboxFiles.push({ path: mountPath, content: buffer.toString('utf-8') })
continue
}
const rows = await queryRows(table, {}, 'copilot-fn-exec')
const columns = table.schema.columns
const csvLines = [toCsvRow(columns.map((column) => neutralizeCsvFormula(column.name)))]
for (const row of rows.rows) {
csvLines.push(
toCsvRow(columns.map((column) => formatCsvValue(row.data[getColumnId(column)])))
)
}
const csvContent = csvLines.join('\n')
sandboxFiles.push({ path: mountPath, content: csvContent })
}
}
return sandboxFiles
}
export async function executeFunctionExecute(
params: Record<string, unknown>,
context: ToolExecutionContext
): Promise<ToolExecutionResult> {
const enrichedParams = { ...params }
if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) {
enrichedParams.envVars = {
...context.decryptedEnvVars,
...((enrichedParams.envVars as Record<string, string>) || {}),
}
}
if (context.workspaceId) {
const inputs = enrichedParams.inputs as
| {
files?: CanonicalFileInput[]
directories?: CanonicalDirectoryInput[]
tables?: CanonicalTableInput[]
}
| undefined
const inputFiles = [
...((enrichedParams.inputFiles as unknown[] | undefined) ?? []),
...(inputs?.files ?? []),
]
const inputDirectories = inputs?.directories ?? []
const inputTables = [
...((enrichedParams.inputTables as unknown[] | undefined) ?? []),
...(inputs?.tables ?? []),
]
if (inputFiles?.length || inputTables?.length || inputDirectories.length) {
const resolved = await resolveInputFiles(
context.workspaceId,
inputFiles,
inputTables,
inputDirectories
)
if (resolved.length > 0) {
const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || []
enrichedParams._sandboxFiles = [...existing, ...resolved]
}
}
}
enrichedParams._context = {
...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null
? (enrichedParams._context as object)
: {}),
userId: context.userId,
workflowId: context.workflowId,
workspaceId: context.workspaceId,
chatId: context.chatId,
executionId: context.executionId,
runId: context.runId,
enforceCredentialAccess: true,
}
return executeAppTool('function_execute', enrichedParams)
}