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,289 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user