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
290 lines
10 KiB
TypeScript
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 }
|
|
}
|
|
}
|