Files
simstudioai--sim/apps/sim/lib/copilot/tools/handlers/materialize-file.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

262 lines
8.5 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { getServePathPrefix } from '@/lib/uploads'
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'
const logger = createLogger('MaterializeFile')
function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
const pathPrefix = getServePathPrefix()
return {
id: row.id,
workspaceId: row.workspaceId || '',
name: row.displayName ?? row.originalName,
key: row.key,
path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`,
size: row.size,
type: row.contentType,
uploadedBy: row.userId,
deletedAt: row.deletedAt,
uploadedAt: row.uploadedAt,
updatedAt: row.updatedAt,
storageContext: 'mothership' as const,
}
}
async function executeSave(fileName: string, chatId: string): Promise<ToolCallResult> {
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
if (!row) {
return {
success: false,
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
}
}
const [updated] = await db
.update(workspaceFiles)
.set({ context: 'workspace', chatId: null, originalName: row.displayName ?? row.originalName })
.where(and(eq(workspaceFiles.id, row.id), isNull(workspaceFiles.deletedAt)))
.returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName })
if (!updated) {
return {
success: false,
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
}
}
logger.info('Materialized file', { fileName, fileId: updated.id, chatId })
// Canonical, per-segment-encoded path — matches how the workspace VFS serves
// the file (files/<encoded>), rather than echoing the raw display name.
const canonicalPath = canonicalWorkspaceFilePath({
folderPath: null,
name: updated.originalName,
})
return {
success: true,
output: {
message: `File "${updated.originalName}" materialized. It is now available at ${canonicalPath} and will persist independently of this chat.`,
fileId: updated.id,
path: canonicalPath,
},
resources: [{ type: 'file', id: updated.id, title: updated.originalName }],
}
}
async function executeImport(
fileName: string,
chatId: string,
workspaceId: string,
userId: string
): Promise<ToolCallResult> {
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
if (!row) {
return {
success: false,
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
}
}
const buffer = await fetchWorkspaceFileBuffer(toFileRecord(row))
const content = buffer.toString('utf-8')
let parsed: unknown
try {
parsed = JSON.parse(content)
} catch {
return { success: false, error: `"${fileName}" is not valid JSON.` }
}
const { data: workflowData, errors } = parseWorkflowJson(content)
if (!workflowData || errors.length > 0) {
return {
success: false,
error: `Invalid workflow JSON: ${errors.join(', ')}`,
}
}
const { name: rawName, description: workflowDescription } = extractWorkflowMetadata(parsed)
const workflowId = generateId()
const now = new Date()
const dedupedName = await deduplicateWorkflowName(rawName, workspaceId, null)
await db.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: null,
name: dedupedName,
description: workflowDescription,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
if (!saveResult.success) {
await db.delete(workflow).where(eq(workflow.id, workflowId))
return { success: false, error: `Failed to save workflow state: ${saveResult.error}` }
}
if (workflowData.variables && Array.isArray(workflowData.variables)) {
const variablesRecord: Record<
string,
{ id: string; name: string; type: string; value: unknown }
> = {}
for (const v of workflowData.variables) {
const varId = (v as { id?: string }).id || generateId()
const variable = v as { name: string; type?: string; value: unknown }
variablesRecord[varId] = {
id: varId,
name: variable.name,
type: variable.type || 'string',
value: variable.value,
}
}
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
}
logger.info('Imported workflow from upload', {
fileName,
workflowId,
workflowName: dedupedName,
chatId,
})
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.WORKFLOW_CREATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: dedupedName,
description: `Imported workflow "${dedupedName}" from file`,
metadata: { fileName, source: 'copilot-import' },
})
return {
success: true,
output: {
message: `Workflow "${dedupedName}" imported successfully. It is now available in the workspace and can be edited or run.`,
workflowId,
workflowName: dedupedName,
},
resources: [{ type: 'workflow', id: workflowId, title: dedupedName }],
}
}
export async function executeMaterializeFile(
params: Record<string, unknown>,
context: ExecutionContext
): Promise<ToolCallResult> {
const fileNames: string[] =
(params.fileNames as string[] | undefined) ??
([params.fileName as string | undefined].filter(Boolean) as string[])
if (fileNames.length === 0) {
return { success: false, error: "Missing required parameter 'fileNames'" }
}
if (!context.chatId) {
return { success: false, error: 'No chat context available for materialize_file' }
}
if (!context.workspaceId) {
return { success: false, error: 'No workspace context available for materialize_file' }
}
const operation = (params.operation as string | undefined) || 'save'
// Only save/import are implemented. Reject anything else with guidance instead of
// silently falling back to save (table/knowledge_base are handled by their subagents).
if (operation !== 'save' && operation !== 'import') {
return {
success: false,
error: `Unsupported materialize_file operation "${operation}". Use "save" or "import". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`,
}
}
const succeeded: string[] = []
const failed: Array<{ fileName: string; error: string }> = []
const resources: NonNullable<ToolCallResult['resources']> = []
for (const fileName of fileNames) {
try {
let result: ToolCallResult
if (operation === 'import') {
result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId)
} else {
result = await executeSave(fileName, context.chatId)
}
if (result.success) {
succeeded.push(fileName)
if (result.resources) resources.push(...result.resources)
} else {
failed.push({ fileName, error: result.error ?? 'Failed to materialize file' })
}
} catch (err) {
logger.error('materialize_file failed', {
fileName,
operation,
chatId: context.chatId,
error: toError(err).message,
})
failed.push({
fileName,
error: getErrorMessage(err, 'Failed to materialize file'),
})
}
}
return {
success: succeeded.length > 0,
output: { succeeded, failed },
error:
failed.length > 0
? `Failed to materialize: ${failed.map((f) => f.fileName).join(', ')}`
: undefined,
resources: resources.length > 0 ? resources : undefined,
}
}