Files
simstudioai--sim/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.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

290 lines
10 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { toError } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import {
fetchWorkspaceFileBuffer,
listWorkspaceFiles,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { uploadFile } from '@/lib/uploads/core/storage-service'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import {
CustomBlockValidationError,
type CustomBlockWithInputs,
deleteCustomBlock,
getCustomBlockWithInputsByWorkflowId,
publishCustomBlock,
updateCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { ensureWorkflowAccess } from '../access'
import type { DeployCustomBlockParams } from '../param-types'
const MAX_ICON_BYTES = 5 * 1024 * 1024
const MAX_INPUT_ENTRIES = 50
const MAX_OUTPUT_ENTRIES = 50
/**
* Resolve the agent-supplied icon reference to a publicly servable URL. A VFS
* workspace-file path (`files/...`) is ingested: the file is copied into the
* world-readable `workspace-logos` storage context (the same context the icon
* upload UI writes to), because a raw workspace-file URL is membership-gated
* and the block's icon must render for org members in other workspaces. Any
* other non-empty value (an external or already-public URL) passes through.
*/
async function resolveIconUrl(
raw: string | undefined,
userId: string,
workspaceId: string
): Promise<string | undefined> {
const value = raw?.trim()
if (!value) return undefined
if (!value.startsWith('files/')) {
if (!isAllowedCustomBlockIconUrl(value)) {
throw new CustomBlockValidationError(
'iconUrl must be an https URL, an internal /api/files/serve/ path, or a workspace file path (files/...)'
)
}
return value
}
const canonical = canonicalizeVfsPath(value)
const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true })
const record = files.find(
(f) => canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === canonical
)
if (!record) {
throw new CustomBlockValidationError(`Icon file not found in this workspace: ${value}`)
}
if (!isImageFileType(record.type)) {
throw new CustomBlockValidationError(
'Icon file must be an image (PNG, JPEG, GIF, WebP, or SVG)'
)
}
if (record.size > MAX_ICON_BYTES) {
throw new CustomBlockValidationError('Icon file must be 5MB or smaller')
}
const buffer = await fetchWorkspaceFileBuffer(record)
const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_')
const uploaded = await uploadFile({
file: buffer,
fileName: record.name,
contentType: record.type,
context: 'workspace-logos',
customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`,
preserveKey: true,
metadata: { workspaceId, userId, originalName: record.name },
})
return uploaded.path
}
function customBlockOutput(block: CustomBlockWithInputs, action: 'deploy' | 'undeploy') {
const isDeployed = action === 'deploy'
return {
workflowId: block.workflowId,
blockId: block.id,
blockType: block.type,
name: block.name,
action,
isDeployed,
removed: !isDeployed,
deploymentType: 'custom_block',
deploymentStatus: {
customBlock: {
isDeployed,
blockType: block.type,
name: block.name,
enabled: block.enabled,
},
},
deploymentConfig: {
customBlock: {
blockType: block.type,
name: block.name,
description: block.description,
iconUrl: block.iconUrl,
inputFields: block.inputFields,
exposedOutputs: block.exposedOutputs,
organizationId: block.organizationId,
},
},
}
}
export async function executeDeployCustomBlock(
params: DeployCustomBlockParams,
context: ExecutionContext
): Promise<ToolCallResult> {
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
}
const action = params.action === 'undeploy' ? 'undeploy' : 'deploy'
let workflowRecord: Awaited<ReturnType<typeof ensureWorkflowAccess>>['workflow']
try {
workflowRecord = (await ensureWorkflowAccess(workflowId, context.userId, 'admin')).workflow
} catch (error) {
const message = toError(error).message
if (message.includes('not found')) {
return { success: false, error: message }
}
return {
success: false,
error: "Managing a custom block requires admin permission on the workflow's workspace",
}
}
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
return { success: false, error: 'Workflow must belong to a workspace' }
}
const ws = await getWorkspaceWithOwner(workspaceId)
const organizationId = ws?.organizationId
if (!organizationId) {
return {
success: false,
error: 'Publishing a block requires the workspace to belong to an organization',
}
}
if (
!(await isFeatureEnabled('deploy-as-block', {
userId: context.userId,
orgId: organizationId,
}))
) {
return { success: false, error: 'Custom blocks are not enabled for this organization' }
}
const existing = await getCustomBlockWithInputsByWorkflowId(workflowId)
if (action === 'undeploy') {
if (!existing) {
return { success: false, error: 'This workflow is not published as a custom block' }
}
await deleteCustomBlock(existing.id)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.CUSTOM_BLOCK_DELETED,
resourceType: AuditResourceType.CUSTOM_BLOCK,
resourceId: existing.id,
resourceName: existing.name,
description: `Unpublished custom block "${existing.name}"`,
metadata: { organizationId, type: existing.type, workflowId, source: 'copilot' },
})
return { success: true, output: customBlockOutput(existing, 'undeploy') }
}
const name = params.name?.trim()
const description = params.description?.trim()
if (name && name.length > 60) {
return { success: false, error: 'name must be 60 characters or fewer' }
}
if (description && description.length > 280) {
return { success: false, error: 'description must be 280 characters or fewer' }
}
if (params.inputs && params.inputs.length > MAX_INPUT_ENTRIES) {
return { success: false, error: `inputs must be ${MAX_INPUT_ENTRIES} entries or fewer` }
}
if (params.inputs?.some((entry) => !entry?.id?.trim())) {
return { success: false, error: 'each inputs entry requires the trigger field id' }
}
if (params.inputs?.some((entry) => (entry.placeholder?.length ?? 0) > 200)) {
return { success: false, error: 'input placeholders must be 200 characters or fewer' }
}
if (params.exposedOutputs && params.exposedOutputs.length > MAX_OUTPUT_ENTRIES) {
return {
success: false,
error: `exposedOutputs must be ${MAX_OUTPUT_ENTRIES} entries or fewer`,
}
}
if (
params.exposedOutputs?.some(
(entry) => !entry?.blockId?.trim() || !entry?.path?.trim() || !entry?.name?.trim()
)
) {
return {
success: false,
error: 'each exposedOutputs entry requires blockId, path, and name',
}
}
if (params.exposedOutputs?.some((entry) => entry.name.length > 60)) {
return { success: false, error: 'exposed output names must be 60 characters or fewer' }
}
const iconUrl = await resolveIconUrl(params.iconUrl, context.userId, workspaceId)
if (existing) {
await updateCustomBlock(existing.id, {
name: name || undefined,
description,
iconUrl,
inputs: params.inputs,
exposedOutputs: params.exposedOutputs,
})
const updated = await getCustomBlockWithInputsByWorkflowId(workflowId)
if (!updated) {
return { success: false, error: 'Custom block not found after update' }
}
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.CUSTOM_BLOCK_UPDATED,
resourceType: AuditResourceType.CUSTOM_BLOCK,
resourceId: updated.id,
resourceName: updated.name,
description: `Updated custom block "${updated.name}"`,
metadata: { organizationId, type: updated.type, workflowId, source: 'copilot' },
})
return { success: true, output: { ...customBlockOutput(updated, 'deploy'), updated: true } }
}
if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
return { success: false, error: 'Custom blocks require an enterprise plan' }
}
if (!name) {
return { success: false, error: 'name is required when publishing a new custom block' }
}
if (!workflowRecord.isDeployed) {
return {
success: false,
error:
'Workflow must be deployed before publishing as a custom block. Use deploy_api first.',
}
}
const block = await publishCustomBlock({
organizationId,
workspaceId,
workflowId,
userId: context.userId,
name,
description: description ?? '',
iconUrl,
inputs: params.inputs,
exposedOutputs: params.exposedOutputs,
})
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.CUSTOM_BLOCK_PUBLISHED,
resourceType: AuditResourceType.CUSTOM_BLOCK,
resourceId: block.id,
resourceName: block.name,
description: `Published custom block "${block.name}"`,
metadata: { organizationId, type: block.type, workflowId, source: 'copilot' },
})
return { success: true, output: { ...customBlockOutput(block, 'deploy'), updated: false } }
} catch (error) {
if (error instanceof CustomBlockValidationError) {
return { success: false, error: error.message }
}
return { success: false, error: toError(error).message }
}
}