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
262 lines
8.5 KiB
TypeScript
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,
|
|
}
|
|
}
|