chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user