mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(custom-blocks): add deploy_custom_block copilot tool (#5532)
* feat(custom-blocks): add deploy_custom_block copilot tool * feat(copilot): send workspace entitlements to the mothership * chore(copilot): sync tool catalog — plan-neutral deploy trigger text * refactor(copilot): extract entitlements registry with add-an-entitlement recipe * fix(custom-blocks): review fixes — undeploy without enterprise, array bounds, whitespace name * fix(custom-blocks): enforce per-item field limits from the REST contract * fix(custom-blocks): enterprise gate applies to first publish only, matching REST * chore(copilot): sync tool catalog — deploy_custom_block requires name
This commit is contained in:
@@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
|
||||
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
|
||||
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
|
||||
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1TextChannel,
|
||||
@@ -141,25 +142,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content
|
||||
// double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime
|
||||
const agentMentions = contexts as unknown as ChatContext[] | undefined
|
||||
const [workspaceContext, integrationTools, userSkillTool, userPermission, agentContexts] =
|
||||
await Promise.all([
|
||||
generateWorkspaceContext(workspaceId, userId),
|
||||
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
|
||||
buildUserSkillTool(workspaceId),
|
||||
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
|
||||
processContextsServer(
|
||||
agentMentions,
|
||||
userId,
|
||||
lastUserMessage,
|
||||
workspaceId,
|
||||
effectiveChatId
|
||||
).catch((error) => {
|
||||
reqLogger.warn('Failed to resolve agent contexts for execution', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
return []
|
||||
}),
|
||||
])
|
||||
const [
|
||||
workspaceContext,
|
||||
integrationTools,
|
||||
userSkillTool,
|
||||
userPermission,
|
||||
entitlements,
|
||||
agentContexts,
|
||||
] = await Promise.all([
|
||||
generateWorkspaceContext(workspaceId, userId),
|
||||
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
|
||||
buildUserSkillTool(workspaceId),
|
||||
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
|
||||
computeWorkspaceEntitlements(workspaceId, userId),
|
||||
processContextsServer(
|
||||
agentMentions,
|
||||
userId,
|
||||
lastUserMessage,
|
||||
workspaceId,
|
||||
effectiveChatId
|
||||
).catch((error) => {
|
||||
reqLogger.warn('Failed to resolve agent contexts for execution', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
return []
|
||||
}),
|
||||
])
|
||||
const requestPayload: Record<string, unknown> = {
|
||||
messages,
|
||||
responseFormat,
|
||||
@@ -182,6 +190,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
...(integrationTools.length > 0 ? { integrationTools } : {}),
|
||||
...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}),
|
||||
...(userPermission ? { userPermission } : {}),
|
||||
...(entitlements.length > 0 ? { entitlements } : {}),
|
||||
}
|
||||
|
||||
let allowExplicitAbort = true
|
||||
|
||||
@@ -237,4 +237,34 @@ describe('buildCopilotRequestPayload', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes entitlements through and omits the field when empty', async () => {
|
||||
const withEntitlements = await buildCopilotRequestPayload(
|
||||
{
|
||||
message: 'publish as a block',
|
||||
userId: 'user-1',
|
||||
userMessageId: 'msg-1',
|
||||
mode: 'agent',
|
||||
model: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
entitlements: ['custom-blocks'],
|
||||
},
|
||||
{ selectedModel: 'claude-opus-4-8' }
|
||||
)
|
||||
expect(withEntitlements).toEqual(expect.objectContaining({ entitlements: ['custom-blocks'] }))
|
||||
|
||||
const withoutEntitlements = await buildCopilotRequestPayload(
|
||||
{
|
||||
message: 'publish as a block',
|
||||
userId: 'user-1',
|
||||
userMessageId: 'msg-1',
|
||||
mode: 'agent',
|
||||
model: 'claude-opus-4-8',
|
||||
workspaceId: 'ws-1',
|
||||
entitlements: [],
|
||||
},
|
||||
{ selectedModel: 'claude-opus-4-8' }
|
||||
)
|
||||
expect(withoutEntitlements).not.toHaveProperty('entitlements')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +41,8 @@ interface BuildPayloadParams {
|
||||
workspaceContext?: string
|
||||
vfs?: VfsSnapshotV1
|
||||
userPermission?: string
|
||||
/** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */
|
||||
entitlements?: string[]
|
||||
userTimezone?: string
|
||||
userMetadata?: {
|
||||
name?: string
|
||||
@@ -387,6 +389,7 @@ export async function buildCopilotRequestPayload(
|
||||
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
|
||||
...(params.vfs ? { vfs: params.vfs } : {}),
|
||||
...(params.userPermission ? { userPermission: params.userPermission } : {}),
|
||||
...(params.entitlements?.length ? { entitlements: params.entitlements } : {}),
|
||||
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
|
||||
...(params.userMetadata &&
|
||||
(params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
|
||||
import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context'
|
||||
import { chatPubSub } from '@/lib/copilot/chat-status'
|
||||
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants'
|
||||
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
|
||||
import {
|
||||
CopilotChatFinalizeOutcome,
|
||||
CopilotChatPersistOutcome,
|
||||
@@ -175,6 +176,7 @@ type UnifiedChatBranch =
|
||||
contexts: Array<{ type: string; content: string; tag?: string; path?: string }>
|
||||
fileAttachments?: UnifiedChatRequest['fileAttachments']
|
||||
userPermission?: string
|
||||
entitlements?: string[]
|
||||
userTimezone?: string
|
||||
userMetadata?: { name?: string; email?: string; timezone?: string }
|
||||
workflowId: string
|
||||
@@ -212,6 +214,7 @@ type UnifiedChatBranch =
|
||||
contexts: Array<{ type: string; content: string; tag?: string; path?: string }>
|
||||
fileAttachments?: UnifiedChatRequest['fileAttachments']
|
||||
userPermission?: string
|
||||
entitlements?: string[]
|
||||
userTimezone?: string
|
||||
userMetadata?: { name?: string; email?: string; timezone?: string }
|
||||
workspaceContext?: string
|
||||
@@ -624,6 +627,7 @@ async function resolveBranch(params: {
|
||||
workspaceContext: payloadParams.workspaceContext,
|
||||
vfs: payloadParams.vfs,
|
||||
userPermission: payloadParams.userPermission,
|
||||
entitlements: payloadParams.entitlements,
|
||||
userTimezone: payloadParams.userTimezone,
|
||||
userMetadata: payloadParams.userMetadata,
|
||||
},
|
||||
@@ -679,6 +683,7 @@ async function resolveBranch(params: {
|
||||
workspaceContext: payloadParams.workspaceContext,
|
||||
vfs: payloadParams.vfs,
|
||||
userPermission: payloadParams.userPermission,
|
||||
entitlements: payloadParams.entitlements,
|
||||
userTimezone: payloadParams.userTimezone,
|
||||
userMetadata: payloadParams.userMetadata,
|
||||
includeMothershipTools: true,
|
||||
@@ -899,6 +904,9 @@ export async function handleUnifiedChatPost(req: NextRequest) {
|
||||
}
|
||||
)
|
||||
: Promise.resolve(null)
|
||||
const entitlementsPromise = workspaceId
|
||||
? computeWorkspaceEntitlements(workspaceId, authenticatedUserId)
|
||||
: Promise.resolve([])
|
||||
// Wrap the pre-LLM prep work in spans so the trace waterfall shows
|
||||
// where time is going between "request received" and "llm.stream
|
||||
// opens". Previously these ran bare under the root and inflated the
|
||||
@@ -953,10 +961,11 @@ export async function handleUnifiedChatPost(req: NextRequest) {
|
||||
activeOtelRoot.context
|
||||
)
|
||||
|
||||
const [agentContexts, userPermission, workspaceSnapshot, , executionContext] =
|
||||
const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] =
|
||||
await Promise.all([
|
||||
agentContextsPromise,
|
||||
userPermissionPromise,
|
||||
entitlementsPromise,
|
||||
workspaceContextPromise,
|
||||
persistUserMessagePromise,
|
||||
executionContextPromise,
|
||||
@@ -991,6 +1000,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
|
||||
contexts: agentContexts,
|
||||
fileAttachments: body.fileAttachments,
|
||||
userPermission: userPermission ?? undefined,
|
||||
entitlements,
|
||||
userTimezone: body.userTimezone,
|
||||
userMetadata,
|
||||
workflowId: branch.workflowId,
|
||||
@@ -1012,6 +1022,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
|
||||
contexts: agentContexts,
|
||||
fileAttachments: body.fileAttachments,
|
||||
userPermission: userPermission ?? undefined,
|
||||
entitlements,
|
||||
userTimezone: body.userTimezone,
|
||||
userMetadata,
|
||||
workspaceContext,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations'
|
||||
|
||||
const logger = createLogger('CopilotEntitlements')
|
||||
|
||||
/**
|
||||
* Cross-repo contract: the mothership (Go) matches these exact strings against
|
||||
* its `core.Entitlement*` constants to gate agent surfaces.
|
||||
*/
|
||||
export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks'
|
||||
|
||||
/**
|
||||
* Workspace entitlements — plan/flag-gated org capabilities sent to the
|
||||
* mothership as the chat payload's `entitlements` array. The Go side hides the
|
||||
* matching tools, skills, and prompt sections when an entitlement is absent, so
|
||||
* a non-entitled org's agents never hear of the feature.
|
||||
*
|
||||
* Adding an entitlement:
|
||||
* 1. Add the kebab-case name and a fail-closed evaluator here. Every payload
|
||||
* site (interactive chat, headless execute, inbox) picks it up automatically.
|
||||
* 2. Go repo: add the matching `Entitlement*` constant in `internal/core` and
|
||||
* gate surfaces declaratively — `RequiredEntitlement` on tool definitions,
|
||||
* `entitlement:` frontmatter on skills, a conditional section in
|
||||
* `BuildAgentEnvelope`, or a variant swap in `Capabilities`.
|
||||
* 3. Keep enforcement in sim: the Go gating is advertisement-only (the payload
|
||||
* is forgeable), so the sim-side tool handler must re-check the same
|
||||
* predicate at execution time.
|
||||
*/
|
||||
const ENTITLEMENT_EVALUATORS: Record<
|
||||
string,
|
||||
(workspaceId: string, userId?: string) => Promise<boolean>
|
||||
> = {
|
||||
[CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible,
|
||||
}
|
||||
|
||||
const entitlementsCache = new LRUCache<string, Promise<string[]>>({
|
||||
max: 500,
|
||||
ttl: 5_000,
|
||||
})
|
||||
|
||||
/**
|
||||
* The entitlements to send to the mothership for a request in this workspace.
|
||||
* Each evaluator fails closed (an error means the entitlement is absent).
|
||||
* Cached briefly so the several per-message callers collapse to one evaluation.
|
||||
*/
|
||||
export function computeWorkspaceEntitlements(
|
||||
workspaceId: string,
|
||||
userId?: string
|
||||
): Promise<string[]> {
|
||||
const cacheKey = `${workspaceId}:${userId ?? ''}`
|
||||
const cached = entitlementsCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const promise = Promise.all(
|
||||
Object.entries(ENTITLEMENT_EVALUATORS).map(async ([name, evaluate]) => {
|
||||
try {
|
||||
return (await evaluate(workspaceId, userId)) ? name : null
|
||||
} catch (error) {
|
||||
logger.warn('Entitlement evaluation failed; treating as absent', {
|
||||
entitlement: name,
|
||||
workspaceId,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
})
|
||||
).then((names) => names.filter((name): name is string => name !== null))
|
||||
entitlementsCache.set(cacheKey, promise)
|
||||
return promise
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface ToolCatalogEntry {
|
||||
| 'deploy'
|
||||
| 'deploy_api'
|
||||
| 'deploy_chat'
|
||||
| 'deploy_custom_block'
|
||||
| 'deploy_mcp'
|
||||
| 'diff_workflows'
|
||||
| 'download_to_workspace_file'
|
||||
@@ -121,6 +122,7 @@ export interface ToolCatalogEntry {
|
||||
| 'deploy'
|
||||
| 'deploy_api'
|
||||
| 'deploy_chat'
|
||||
| 'deploy_custom_block'
|
||||
| 'deploy_mcp'
|
||||
| 'diff_workflows'
|
||||
| 'download_to_workspace_file'
|
||||
@@ -536,7 +538,7 @@ export const Deploy: ToolCatalogEntry = {
|
||||
properties: {
|
||||
request: {
|
||||
description:
|
||||
'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).',
|
||||
'Detailed deployment instructions. Include the deployment type and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
@@ -772,6 +774,119 @@ export const DeployChat: ToolCatalogEntry = {
|
||||
requiredPermission: 'admin',
|
||||
}
|
||||
|
||||
export const DeployCustomBlock: ToolCatalogEntry = {
|
||||
id: 'deploy_custom_block',
|
||||
name: 'deploy_custom_block',
|
||||
route: 'sim',
|
||||
mode: 'async',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block',
|
||||
enum: ['deploy', 'undeploy'],
|
||||
default: 'deploy',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Short description shown in the block picker, max 280 characters',
|
||||
},
|
||||
exposedOutputs: {
|
||||
type: 'array',
|
||||
description:
|
||||
"Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result",
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
blockId: { type: 'string', description: 'Block UUID inside the workflow' },
|
||||
name: { type: 'string', description: 'Friendly output name shown on the block' },
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Dot-path into that block's output (from get_block_outputs relativeOutputs)",
|
||||
},
|
||||
},
|
||||
required: ['blockId', 'path', 'name'],
|
||||
},
|
||||
},
|
||||
iconUrl: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon',
|
||||
},
|
||||
inputs: {
|
||||
type: 'array',
|
||||
description:
|
||||
"Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here",
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Stable id of the input trigger field' },
|
||||
placeholder: {
|
||||
type: 'string',
|
||||
description: "Placeholder text shown in the block's input field",
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.',
|
||||
},
|
||||
workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
resultSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action performed by the tool, such as "deploy" or "undeploy".',
|
||||
},
|
||||
blockId: { type: 'string', description: 'Custom block record ID.' },
|
||||
blockType: {
|
||||
type: 'string',
|
||||
description: 'Stable block type slug (custom_block_*) used in workflow state.',
|
||||
},
|
||||
deploymentConfig: {
|
||||
type: 'object',
|
||||
description:
|
||||
"Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.",
|
||||
},
|
||||
deploymentStatus: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.',
|
||||
},
|
||||
deploymentType: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".',
|
||||
},
|
||||
isDeployed: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the custom block is published after this tool call.',
|
||||
},
|
||||
name: { type: 'string', description: 'Display name of the custom block.' },
|
||||
removed: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the custom block was unpublished during an undeploy action.',
|
||||
},
|
||||
updated: {
|
||||
type: 'boolean',
|
||||
description: 'Whether an existing custom block was updated instead of created.',
|
||||
},
|
||||
workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' },
|
||||
},
|
||||
required: ['deploymentType', 'deploymentStatus'],
|
||||
},
|
||||
requiredPermission: 'admin',
|
||||
}
|
||||
|
||||
export const DeployMcp: ToolCatalogEntry = {
|
||||
id: 'deploy_mcp',
|
||||
name: 'deploy_mcp',
|
||||
@@ -4581,6 +4696,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
|
||||
[Deploy.id]: Deploy,
|
||||
[DeployApi.id]: DeployApi,
|
||||
[DeployChat.id]: DeployChat,
|
||||
[DeployCustomBlock.id]: DeployCustomBlock,
|
||||
[DeployMcp.id]: DeployMcp,
|
||||
[DiffWorkflows.id]: DiffWorkflows,
|
||||
[DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile,
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry {
|
||||
}
|
||||
|
||||
export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
agent: {
|
||||
['agent']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: {
|
||||
@@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
auth: {
|
||||
['auth']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: {
|
||||
@@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
check_deployment_status: {
|
||||
['check_deployment_status']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
complete_scheduled_task: {
|
||||
['complete_scheduled_task']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
crawl_website: {
|
||||
['crawl_website']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
create_file: {
|
||||
['create_file']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['success', 'message'],
|
||||
},
|
||||
},
|
||||
create_file_folder: {
|
||||
['create_file_folder']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
create_workflow: {
|
||||
['create_workflow']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
create_workspace_mcp_server: {
|
||||
['create_workspace_mcp_server']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
delete_file: {
|
||||
['delete_file']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['success', 'message'],
|
||||
},
|
||||
},
|
||||
delete_file_folder: {
|
||||
['delete_file_folder']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
delete_workflow: {
|
||||
['delete_workflow']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
delete_workspace_mcp_server: {
|
||||
['delete_workspace_mcp_server']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -313,12 +313,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
deploy: {
|
||||
['deploy']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: {
|
||||
description:
|
||||
'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).',
|
||||
'Detailed deployment instructions. Include the deployment type and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
@@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
deploy_api: {
|
||||
['deploy_api']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
],
|
||||
},
|
||||
},
|
||||
deploy_chat: {
|
||||
['deploy_chat']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -570,7 +570,135 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
],
|
||||
},
|
||||
},
|
||||
deploy_mcp: {
|
||||
['deploy_custom_block']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block',
|
||||
enum: ['deploy', 'undeploy'],
|
||||
default: 'deploy',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Short description shown in the block picker, max 280 characters',
|
||||
},
|
||||
exposedOutputs: {
|
||||
type: 'array',
|
||||
description:
|
||||
"Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result",
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
blockId: {
|
||||
type: 'string',
|
||||
description: 'Block UUID inside the workflow',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Friendly output name shown on the block',
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Dot-path into that block's output (from get_block_outputs relativeOutputs)",
|
||||
},
|
||||
},
|
||||
required: ['blockId', 'path', 'name'],
|
||||
},
|
||||
},
|
||||
iconUrl: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon',
|
||||
},
|
||||
inputs: {
|
||||
type: 'array',
|
||||
description:
|
||||
"Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here",
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Stable id of the input trigger field',
|
||||
},
|
||||
placeholder: {
|
||||
type: 'string',
|
||||
description: "Placeholder text shown in the block's input field",
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.',
|
||||
},
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID (defaults to active workflow)',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
resultSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action performed by the tool, such as "deploy" or "undeploy".',
|
||||
},
|
||||
blockId: {
|
||||
type: 'string',
|
||||
description: 'Custom block record ID.',
|
||||
},
|
||||
blockType: {
|
||||
type: 'string',
|
||||
description: 'Stable block type slug (custom_block_*) used in workflow state.',
|
||||
},
|
||||
deploymentConfig: {
|
||||
type: 'object',
|
||||
description:
|
||||
"Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.",
|
||||
},
|
||||
deploymentStatus: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.',
|
||||
},
|
||||
deploymentType: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".',
|
||||
},
|
||||
isDeployed: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the custom block is published after this tool call.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Display name of the custom block.',
|
||||
},
|
||||
removed: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the custom block was unpublished during an undeploy action.',
|
||||
},
|
||||
updated: {
|
||||
type: 'boolean',
|
||||
description: 'Whether an existing custom block was updated instead of created.',
|
||||
},
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID the custom block is bound to.',
|
||||
},
|
||||
},
|
||||
required: ['deploymentType', 'deploymentStatus'],
|
||||
},
|
||||
},
|
||||
['deploy_mcp']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -686,7 +814,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['deploymentType', 'deploymentStatus'],
|
||||
},
|
||||
},
|
||||
diff_workflows: {
|
||||
['diff_workflows']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -710,7 +838,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
download_to_workspace_file: {
|
||||
['download_to_workspace_file']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -759,7 +887,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
edit_content: {
|
||||
['edit_content']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -791,7 +919,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['success', 'message'],
|
||||
},
|
||||
},
|
||||
edit_workflow: {
|
||||
['edit_workflow']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -830,7 +958,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
enrichment_run: {
|
||||
['enrichment_run']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -874,7 +1002,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['matched', 'result'],
|
||||
},
|
||||
},
|
||||
ffmpeg: {
|
||||
['ffmpeg']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1055,7 +1183,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
file: {
|
||||
['file']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
prompt: {
|
||||
@@ -1068,7 +1196,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
function_execute: {
|
||||
['function_execute']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1206,7 +1334,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
generate_api_key: {
|
||||
['generate_api_key']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1224,7 +1352,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
generate_audio: {
|
||||
['generate_audio']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1376,7 +1504,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
generate_image: {
|
||||
['generate_image']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1504,7 +1632,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
generate_video: {
|
||||
['generate_video']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1671,7 +1799,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_block_outputs: {
|
||||
['get_block_outputs']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1692,7 +1820,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_block_upstream_references: {
|
||||
['get_block_upstream_references']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1714,7 +1842,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_deployed_workflow_state: {
|
||||
['get_deployed_workflow_state']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1727,7 +1855,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_deployment_log: {
|
||||
['get_deployment_log']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1740,7 +1868,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_page_contents: {
|
||||
['get_page_contents']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1768,14 +1896,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_platform_actions: {
|
||||
['get_platform_actions']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_scheduled_task_logs: {
|
||||
['get_scheduled_task_logs']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1800,7 +1928,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_workflow_data: {
|
||||
['get_workflow_data']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1819,7 +1947,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
get_workflow_run_options: {
|
||||
['get_workflow_run_options']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1832,7 +1960,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
glob: {
|
||||
['glob']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1851,7 +1979,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
grep: {
|
||||
['grep']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1899,7 +2027,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
knowledge: {
|
||||
['knowledge']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: {
|
||||
@@ -1912,7 +2040,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
knowledge_base: {
|
||||
['knowledge_base']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2105,7 +2233,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['success', 'message'],
|
||||
},
|
||||
},
|
||||
list_file_folders: {
|
||||
['list_file_folders']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2117,7 +2245,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
list_integration_tools: {
|
||||
['list_integration_tools']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
integration: {
|
||||
@@ -2131,14 +2259,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
list_user_workspaces: {
|
||||
['list_user_workspaces']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
list_workspace_mcp_servers: {
|
||||
['list_workspace_mcp_servers']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2151,7 +2279,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
load_deployment: {
|
||||
['load_deployment']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2170,7 +2298,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
load_integration_tool: {
|
||||
['load_integration_tool']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
tool_ids: {
|
||||
@@ -2187,7 +2315,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
manage_credential: {
|
||||
['manage_credential']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2216,7 +2344,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
manage_custom_tool: {
|
||||
['manage_custom_tool']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2296,7 +2424,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
manage_folder: {
|
||||
['manage_folder']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2335,7 +2463,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
manage_mcp_tool: {
|
||||
['manage_mcp_tool']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2387,7 +2515,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
manage_scheduled_task: {
|
||||
['manage_scheduled_task']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2462,7 +2590,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
manage_skill: {
|
||||
['manage_skill']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2495,7 +2623,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
materialize_file: {
|
||||
['materialize_file']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2519,7 +2647,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
media: {
|
||||
['media']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
prompt: {
|
||||
@@ -2532,7 +2660,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
move_file: {
|
||||
['move_file']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2553,7 +2681,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
move_file_folder: {
|
||||
['move_file_folder']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2571,7 +2699,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
move_workflow: {
|
||||
['move_workflow']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2591,7 +2719,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
oauth_get_auth_link: {
|
||||
['oauth_get_auth_link']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2605,7 +2733,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
oauth_request_access: {
|
||||
['oauth_request_access']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2619,7 +2747,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
open_resource: {
|
||||
['open_resource']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2653,7 +2781,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
promote_to_live: {
|
||||
['promote_to_live']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2672,7 +2800,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
query_logs: {
|
||||
['query_logs']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2783,7 +2911,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
read: {
|
||||
['read']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2810,7 +2938,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
redeploy: {
|
||||
['redeploy']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2889,7 +3017,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
],
|
||||
},
|
||||
},
|
||||
rename_file: {
|
||||
['rename_file']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2925,7 +3053,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['success', 'message'],
|
||||
},
|
||||
},
|
||||
rename_file_folder: {
|
||||
['rename_file_folder']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2942,7 +3070,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
rename_workflow: {
|
||||
['rename_workflow']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -2959,7 +3087,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
research: {
|
||||
['research']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
topic: {
|
||||
@@ -2972,7 +3100,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
respond: {
|
||||
['respond']: {
|
||||
parameters: {
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
@@ -2995,7 +3123,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
restore_resource: {
|
||||
['restore_resource']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3013,7 +3141,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
run: {
|
||||
['run']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
context: {
|
||||
@@ -3030,7 +3158,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
run_block: {
|
||||
['run_block']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3062,7 +3190,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
run_from_block: {
|
||||
['run_from_block']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3094,7 +3222,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
run_workflow: {
|
||||
['run_workflow']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3132,7 +3260,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
run_workflow_until_block: {
|
||||
['run_workflow_until_block']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3175,7 +3303,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
scheduled_task: {
|
||||
['scheduled_task']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: {
|
||||
@@ -3188,7 +3316,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
scrape_page: {
|
||||
['scrape_page']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3209,7 +3337,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
search_documentation: {
|
||||
['search_documentation']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3226,7 +3354,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
search_library_docs: {
|
||||
['search_library_docs']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3247,7 +3375,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
search_online: {
|
||||
['search_online']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3287,7 +3415,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
search_patterns: {
|
||||
['search_patterns']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3309,7 +3437,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
set_block_enabled: {
|
||||
['set_block_enabled']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3331,7 +3459,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
set_environment_variables: {
|
||||
['set_environment_variables']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3365,7 +3493,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
set_global_workflow_variables: {
|
||||
['set_global_workflow_variables']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3406,7 +3534,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
superagent: {
|
||||
['superagent']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
task: {
|
||||
@@ -3420,7 +3548,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
table: {
|
||||
['table']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: {
|
||||
@@ -3433,7 +3561,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
update_deployment_version: {
|
||||
['update_deployment_version']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3462,7 +3590,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
update_scheduled_task_history: {
|
||||
['update_scheduled_task_history']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3480,7 +3608,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
update_workspace_mcp_server: {
|
||||
['update_workspace_mcp_server']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3505,7 +3633,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
user_memory: {
|
||||
['user_memory']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3554,7 +3682,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
user_table: {
|
||||
['user_table']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -3917,7 +4045,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
required: ['success', 'message'],
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
['workflow']: {
|
||||
parameters: {
|
||||
properties: {
|
||||
prompt: {
|
||||
@@ -3930,7 +4058,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
resultSchema: undefined,
|
||||
},
|
||||
workspace_file: {
|
||||
['workspace_file']: {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DeleteWorkspaceMcpServer,
|
||||
DeployApi,
|
||||
DeployChat,
|
||||
DeployCustomBlock,
|
||||
DeployMcp,
|
||||
DiffWorkflows,
|
||||
FunctionExecute,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
} from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter'
|
||||
import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router'
|
||||
import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block'
|
||||
import {
|
||||
executeDeployApi,
|
||||
executeDeployChat,
|
||||
@@ -156,6 +158,7 @@ function buildHandlerMap(): Record<string, ToolHandler> {
|
||||
[DeployApi.id]: h(executeDeployApi),
|
||||
[DeployChat.id]: h(executeDeployChat),
|
||||
[DeployMcp.id]: h(executeDeployMcp),
|
||||
[DeployCustomBlock.id]: h(executeDeployCustomBlock),
|
||||
[Redeploy.id]: h(executeRedeploy),
|
||||
[CheckDeploymentStatus.id]: h(executeCheckDeploymentStatus),
|
||||
[ListWorkspaceMcpServers.id]: h(executeListWorkspaceMcpServers),
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { auditMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionContext } from '@/lib/copilot/request/types'
|
||||
|
||||
const {
|
||||
ensureWorkflowAccessMock,
|
||||
getWorkspaceWithOwnerMock,
|
||||
isFeatureEnabledMock,
|
||||
isOrganizationOnEnterprisePlanMock,
|
||||
publishCustomBlockMock,
|
||||
updateCustomBlockMock,
|
||||
deleteCustomBlockMock,
|
||||
getCustomBlockWithInputsByWorkflowIdMock,
|
||||
listWorkspaceFilesMock,
|
||||
fetchWorkspaceFileBufferMock,
|
||||
uploadFileMock,
|
||||
} = vi.hoisted(() => ({
|
||||
ensureWorkflowAccessMock: vi.fn(),
|
||||
getWorkspaceWithOwnerMock: vi.fn(),
|
||||
isFeatureEnabledMock: vi.fn(),
|
||||
isOrganizationOnEnterprisePlanMock: vi.fn(),
|
||||
publishCustomBlockMock: vi.fn(),
|
||||
updateCustomBlockMock: vi.fn(),
|
||||
deleteCustomBlockMock: vi.fn(),
|
||||
getCustomBlockWithInputsByWorkflowIdMock: vi.fn(),
|
||||
listWorkspaceFilesMock: vi.fn(),
|
||||
fetchWorkspaceFileBufferMock: vi.fn(),
|
||||
uploadFileMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('../access', () => ({
|
||||
ensureWorkflowAccess: ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getWorkspaceWithOwner: getWorkspaceWithOwnerMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isFeatureEnabled: isFeatureEnabledMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
listWorkspaceFiles: listWorkspaceFilesMock,
|
||||
fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
uploadFile: uploadFileMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
isImageFileType: (type: string) => type.startsWith('image/'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/custom-blocks/operations', () => {
|
||||
class CustomBlockValidationError extends Error {}
|
||||
return {
|
||||
CustomBlockValidationError,
|
||||
publishCustomBlock: publishCustomBlockMock,
|
||||
updateCustomBlock: updateCustomBlockMock,
|
||||
deleteCustomBlock: deleteCustomBlockMock,
|
||||
getCustomBlockWithInputsByWorkflowId: getCustomBlockWithInputsByWorkflowIdMock,
|
||||
}
|
||||
})
|
||||
|
||||
import { executeDeployCustomBlock } from './custom-block'
|
||||
|
||||
const context = { userId: 'user-1', workflowId: 'wf-1' } as ExecutionContext
|
||||
|
||||
const publishedBlock = {
|
||||
id: 'cb-1',
|
||||
organizationId: 'org-1',
|
||||
workflowId: 'wf-1',
|
||||
workflowName: 'Test Workflow',
|
||||
workspaceId: 'ws-1',
|
||||
workspaceName: 'Workspace',
|
||||
type: 'custom_block_abc123',
|
||||
name: 'Enrich Lead',
|
||||
description: 'Enrich a lead by email',
|
||||
iconUrl: null,
|
||||
enabled: true,
|
||||
inputFields: [{ id: 'f1', name: 'email', type: 'string' }],
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }],
|
||||
}
|
||||
|
||||
describe('executeDeployCustomBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: true },
|
||||
})
|
||||
getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: 'org-1' })
|
||||
isFeatureEnabledMock.mockResolvedValue(true)
|
||||
isOrganizationOnEnterprisePlanMock.mockResolvedValue(true)
|
||||
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('publishes a new custom block', async () => {
|
||||
publishCustomBlockMock.mockResolvedValue(publishedBlock)
|
||||
|
||||
const result = await executeDeployCustomBlock(
|
||||
{
|
||||
name: 'Enrich Lead',
|
||||
description: 'Enrich a lead by email',
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }],
|
||||
},
|
||||
context
|
||||
)
|
||||
|
||||
expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin')
|
||||
expect(publishCustomBlockMock).toHaveBeenCalledWith({
|
||||
organizationId: 'org-1',
|
||||
workspaceId: 'ws-1',
|
||||
workflowId: 'wf-1',
|
||||
userId: 'user-1',
|
||||
name: 'Enrich Lead',
|
||||
description: 'Enrich a lead by email',
|
||||
iconUrl: undefined,
|
||||
inputs: undefined,
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
workflowId: 'wf-1',
|
||||
blockType: 'custom_block_abc123',
|
||||
isDeployed: true,
|
||||
updated: false,
|
||||
deploymentType: 'custom_block',
|
||||
deploymentStatus: { customBlock: { isDeployed: true, name: 'Enrich Lead' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a clean admin-permission error when workflow access is denied', async () => {
|
||||
ensureWorkflowAccessMock.mockRejectedValue(new Error('Unauthorized workflow access'))
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('admin permission')
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces workflow-not-found from access resolution', async () => {
|
||||
ensureWorkflowAccessMock.mockRejectedValue(new Error('Workflow wf-1 not found'))
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('not found')
|
||||
})
|
||||
|
||||
it('requires a name on first publish', async () => {
|
||||
const result = await executeDeployCustomBlock({}, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('name is required')
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires the workflow to be deployed on first publish', async () => {
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: false },
|
||||
})
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('deploy_api')
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates an existing block in place', async () => {
|
||||
getCustomBlockWithInputsByWorkflowIdMock
|
||||
.mockResolvedValueOnce(publishedBlock)
|
||||
.mockResolvedValueOnce({ ...publishedBlock, name: 'Enrich Lead v2' })
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: 'Enrich Lead v2' }, context)
|
||||
|
||||
expect(updateCustomBlockMock).toHaveBeenCalledWith('cb-1', {
|
||||
name: 'Enrich Lead v2',
|
||||
description: undefined,
|
||||
iconUrl: undefined,
|
||||
inputs: undefined,
|
||||
exposedOutputs: undefined,
|
||||
})
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({ updated: true, name: 'Enrich Lead v2' })
|
||||
})
|
||||
|
||||
it('unpublishes the block on undeploy', async () => {
|
||||
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock)
|
||||
|
||||
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
|
||||
|
||||
expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1')
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
isDeployed: false,
|
||||
removed: true,
|
||||
action: 'undeploy',
|
||||
blockType: 'custom_block_abc123',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates an existing block without requiring the enterprise plan', async () => {
|
||||
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
|
||||
getCustomBlockWithInputsByWorkflowIdMock
|
||||
.mockResolvedValueOnce(publishedBlock)
|
||||
.mockResolvedValueOnce(publishedBlock)
|
||||
|
||||
const result = await executeDeployCustomBlock({ description: 'refreshed copy' }, context)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(updateCustomBlockMock).toHaveBeenCalled()
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('undeploys without requiring the enterprise plan', async () => {
|
||||
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
|
||||
getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock)
|
||||
|
||||
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1')
|
||||
})
|
||||
|
||||
it('does not clear the stored name when a republish sends whitespace', async () => {
|
||||
getCustomBlockWithInputsByWorkflowIdMock
|
||||
.mockResolvedValueOnce(publishedBlock)
|
||||
.mockResolvedValueOnce(publishedBlock)
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: ' ' }, context)
|
||||
|
||||
expect(updateCustomBlockMock).toHaveBeenCalledWith(
|
||||
'cb-1',
|
||||
expect.objectContaining({ name: undefined })
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects oversized exposedOutputs and inputs arrays', async () => {
|
||||
const outputs = Array.from({ length: 51 }, (_, i) => ({
|
||||
blockId: `b${i}`,
|
||||
path: 'content',
|
||||
name: `out${i}`,
|
||||
}))
|
||||
const tooManyOutputs = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', exposedOutputs: outputs },
|
||||
context
|
||||
)
|
||||
expect(tooManyOutputs.success).toBe(false)
|
||||
expect(tooManyOutputs.error).toContain('50')
|
||||
|
||||
const inputs = Array.from({ length: 51 }, (_, i) => ({ id: `f${i}` }))
|
||||
const tooManyInputs = await executeDeployCustomBlock({ name: 'Enrich Lead', inputs }, context)
|
||||
expect(tooManyInputs.success).toBe(false)
|
||||
expect(tooManyInputs.error).toContain('50')
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized per-item fields', async () => {
|
||||
const longPlaceholder = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', inputs: [{ id: 'f1', placeholder: 'x'.repeat(201) }] },
|
||||
context
|
||||
)
|
||||
expect(longPlaceholder.success).toBe(false)
|
||||
expect(longPlaceholder.error).toContain('200')
|
||||
|
||||
const longOutputName = await executeDeployCustomBlock(
|
||||
{
|
||||
name: 'Enrich Lead',
|
||||
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'x'.repeat(61) }],
|
||||
},
|
||||
context
|
||||
)
|
||||
expect(longOutputName.success).toBe(false)
|
||||
expect(longOutputName.error).toContain('60')
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects exposedOutputs entries missing required fields', async () => {
|
||||
const result = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', exposedOutputs: [{ blockId: 'b1', path: '', name: 'out' }] },
|
||||
context
|
||||
)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('blockId, path, and name')
|
||||
})
|
||||
|
||||
it('fails undeploy when the workflow is not published as a block', async () => {
|
||||
const result = await executeDeployCustomBlock({ action: 'undeploy' }, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(deleteCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails when the feature flag is off', async () => {
|
||||
isFeatureEnabledMock.mockResolvedValue(false)
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('not enabled')
|
||||
})
|
||||
|
||||
it('fails when the org is not on the enterprise plan', async () => {
|
||||
isOrganizationOnEnterprisePlanMock.mockResolvedValue(false)
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('enterprise')
|
||||
})
|
||||
|
||||
it('ingests a workspace-file icon into public icon storage', async () => {
|
||||
listWorkspaceFilesMock.mockResolvedValue([
|
||||
{
|
||||
name: 'icon.png',
|
||||
folderPath: null,
|
||||
type: 'image/png',
|
||||
size: 1024,
|
||||
key: 'workspace/ws-1/123-abc-icon.png',
|
||||
},
|
||||
])
|
||||
fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes'))
|
||||
uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' })
|
||||
publishCustomBlockMock.mockResolvedValue(publishedBlock)
|
||||
|
||||
const result = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', iconUrl: 'files/icon.png' },
|
||||
context
|
||||
)
|
||||
|
||||
expect(uploadFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: 'workspace-logos',
|
||||
contentType: 'image/png',
|
||||
customKey: expect.stringMatching(/^workspace-logos\/\d+-[A-Za-z0-9_-]+-icon\.png$/),
|
||||
preserveKey: true,
|
||||
metadata: { workspaceId: 'ws-1', userId: 'user-1', originalName: 'icon.png' },
|
||||
})
|
||||
)
|
||||
expect(publishCustomBlockMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ iconUrl: '/api/files/serve/s3/workspace-logos%2Ficon.png' })
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('passes an external icon URL through without ingestion', async () => {
|
||||
publishCustomBlockMock.mockResolvedValue(publishedBlock)
|
||||
|
||||
const result = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', iconUrl: 'https://example.com/icon.png' },
|
||||
context
|
||||
)
|
||||
|
||||
expect(uploadFileMock).not.toHaveBeenCalled()
|
||||
expect(publishCustomBlockMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ iconUrl: 'https://example.com/icon.png' })
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('fails when the icon workspace file does not exist', async () => {
|
||||
listWorkspaceFilesMock.mockResolvedValue([])
|
||||
|
||||
const result = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', iconUrl: 'files/missing.png' },
|
||||
context
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('not found')
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails when the icon workspace file is not an image', async () => {
|
||||
listWorkspaceFilesMock.mockResolvedValue([
|
||||
{ name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' },
|
||||
])
|
||||
|
||||
const result = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', iconUrl: 'files/notes.pdf' },
|
||||
context
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('image')
|
||||
expect(uploadFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails when the workspace has no organization', async () => {
|
||||
getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: null })
|
||||
|
||||
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('organization')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
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/')) 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 }
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,24 @@ export interface DeployMcpParams {
|
||||
parameterDescriptions?: Array<{ name: string; description: string }>
|
||||
}
|
||||
|
||||
export interface DeployCustomBlockParams {
|
||||
workflowId?: string
|
||||
action?: 'deploy' | 'undeploy'
|
||||
/** Block display name (max 60 chars). Required on first publish. */
|
||||
name?: string
|
||||
/** Block-picker description (max 280 chars). */
|
||||
description?: string
|
||||
/** Icon image URL; omit for the organization's default icon. */
|
||||
iconUrl?: string
|
||||
/**
|
||||
* Per-input placeholder overrides keyed by the input trigger field's stable id.
|
||||
* The field set itself is always derived from the workflow's deployment.
|
||||
*/
|
||||
inputs?: Array<{ id: string; placeholder?: string }>
|
||||
/** Curated outputs; omit to expose the terminal block's whole result. */
|
||||
exposedOutputs?: Array<{ blockId: string; path: string; name: string }>
|
||||
}
|
||||
|
||||
export interface CheckDeploymentStatusParams {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/lib/copilot/chat/persisted-message'
|
||||
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
|
||||
import { chatPubSub } from '@/lib/copilot/chat-status'
|
||||
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
|
||||
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
|
||||
import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start'
|
||||
import type { OrchestratorResult } from '@/lib/copilot/request/types'
|
||||
@@ -208,14 +209,21 @@ export async function executeInboxTask(taskId: string): Promise<void> {
|
||||
return { attachments, ...downloaded }
|
||||
}
|
||||
|
||||
const [attachmentResult, workspaceContext, integrationTools, userSkillTool, userPermission] =
|
||||
await Promise.all([
|
||||
fetchAttachments(),
|
||||
generateWorkspaceContext(ws.id, userId),
|
||||
buildIntegrationToolSchemas(userId, undefined, undefined, ws.id),
|
||||
buildUserSkillTool(ws.id),
|
||||
getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null),
|
||||
])
|
||||
const [
|
||||
attachmentResult,
|
||||
workspaceContext,
|
||||
integrationTools,
|
||||
userSkillTool,
|
||||
userPermission,
|
||||
entitlements,
|
||||
] = await Promise.all([
|
||||
fetchAttachments(),
|
||||
generateWorkspaceContext(ws.id, userId),
|
||||
buildIntegrationToolSchemas(userId, undefined, undefined, ws.id),
|
||||
buildUserSkillTool(ws.id),
|
||||
getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null),
|
||||
computeWorkspaceEntitlements(ws.id, userId),
|
||||
])
|
||||
const { attachments, fileAttachments, storedAttachments } = attachmentResult
|
||||
|
||||
const truncatedTask = {
|
||||
@@ -237,6 +245,7 @@ export async function executeInboxTask(taskId: string): Promise<void> {
|
||||
...(integrationTools.length > 0 ? { integrationTools } : {}),
|
||||
...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}),
|
||||
...(userPermission ? { userPermission } : {}),
|
||||
...(entitlements.length > 0 ? { entitlements } : {}),
|
||||
...(fileAttachments.length > 0 ? { fileAttachments } : {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,36 @@ const logger = createLogger('CustomBlocksOperations')
|
||||
* enterprise plan). Applying it in every org-scoped resolver keeps execution, the
|
||||
* copilot VFS, and workspace context from surfacing blocks the API withholds (e.g.
|
||||
* after an org drops off the enterprise plan). Returns `null` when ineligible.
|
||||
*
|
||||
* Pass `userId` when the caller acts for a specific user so per-user flag
|
||||
* targeting matches the REST routes; workspace-scoped resolvers (VFS, context,
|
||||
* executor overlay) omit it and evaluate at org level.
|
||||
*/
|
||||
async function eligibleOrgForWorkspace(workspaceId: string): Promise<string | null> {
|
||||
async function eligibleOrgForWorkspace(
|
||||
workspaceId: string,
|
||||
userId?: string
|
||||
): Promise<string | null> {
|
||||
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
|
||||
if (!ws?.organizationId) return null
|
||||
if (!(await isFeatureEnabled('deploy-as-block', { orgId: ws.organizationId }))) return null
|
||||
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ws.organizationId }))) {
|
||||
return null
|
||||
}
|
||||
if (!(await isOrganizationOnEnterprisePlan(ws.organizationId))) return null
|
||||
return ws.organizationId
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the workspace's org may use custom blocks (`deploy-as-block` flag +
|
||||
* enterprise plan). Feeds the 'custom-blocks' entitlement in
|
||||
* `@/lib/copilot/entitlements` and matches the REST route gates.
|
||||
*/
|
||||
export async function isCustomBlocksEligible(
|
||||
workspaceId: string,
|
||||
userId?: string
|
||||
): Promise<boolean> {
|
||||
return (await eligibleOrgForWorkspace(workspaceId, userId)) !== null
|
||||
}
|
||||
|
||||
/** A persisted custom block plus its live-derived Start input fields. */
|
||||
export interface CustomBlockWithInputs {
|
||||
id: string
|
||||
@@ -170,6 +191,37 @@ export async function listCustomBlockSummariesForWorkspace(
|
||||
.where(and(eq(customBlock.organizationId, organizationId), eq(customBlock.enabled, true)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate a joined custom-block row into the wire shape. Field set derived live
|
||||
* from the deployed Start; stored placeholders merged in. Derive even for a
|
||||
* disabled block — the source workflow's deployment is independent of the block's
|
||||
* enabled flag, and the edit form needs the real fields so a save doesn't
|
||||
* overwrite the block's stored placeholders.
|
||||
*/
|
||||
async function hydrateCustomBlockRow(joined: {
|
||||
block: typeof customBlock.$inferSelect
|
||||
workflowName: string
|
||||
workspaceId: string | null
|
||||
workspaceName: string | null
|
||||
}): Promise<CustomBlockWithInputs> {
|
||||
const { block: row, workflowName, workspaceId, workspaceName } = joined
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
workflowId: row.workflowId,
|
||||
workflowName,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
iconUrl: row.iconUrl,
|
||||
enabled: row.enabled,
|
||||
inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)),
|
||||
exposedOutputs: row.outputs ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/** The org's custom blocks with live-derived input fields (client overlay + list API). */
|
||||
export async function listCustomBlocksWithInputs(
|
||||
organizationId: string
|
||||
@@ -186,27 +238,30 @@ export async function listCustomBlocksWithInputs(
|
||||
.leftJoin(workspace, eq(workspace.id, workflow.workspaceId))
|
||||
.where(eq(customBlock.organizationId, organizationId))
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async ({ block: row, workflowName, workspaceId, workspaceName }) => ({
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
workflowId: row.workflowId,
|
||||
workflowName,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
iconUrl: row.iconUrl,
|
||||
enabled: row.enabled,
|
||||
// Field set derived live from the deployed Start; stored placeholders merged
|
||||
// in. Derive even for a disabled block — the source workflow's deployment is
|
||||
// independent of the block's enabled flag, and the edit form needs the real
|
||||
// fields so a save doesn't overwrite the block's stored placeholders.
|
||||
inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)),
|
||||
exposedOutputs: row.outputs ?? [],
|
||||
}))
|
||||
)
|
||||
return Promise.all(rows.map(hydrateCustomBlockRow))
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom block bound to a workflow (with live-derived input fields), or `null`
|
||||
* when the workflow isn't published as a block. One block per workflow is enforced
|
||||
* at publish time. Used by the copilot deploy_custom_block tool.
|
||||
*/
|
||||
export async function getCustomBlockWithInputsByWorkflowId(
|
||||
workflowId: string
|
||||
): Promise<CustomBlockWithInputs | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
block: customBlock,
|
||||
workflowName: workflow.name,
|
||||
workspaceId: workflow.workspaceId,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
.from(customBlock)
|
||||
.innerJoin(workflow, eq(workflow.id, customBlock.workflowId))
|
||||
.leftJoin(workspace, eq(workspace.id, workflow.workspaceId))
|
||||
.where(eq(customBlock.workflowId, workflowId))
|
||||
.limit(1)
|
||||
return row ? hydrateCustomBlockRow(row) : null
|
||||
}
|
||||
|
||||
/** Fetch a single custom block row by id. */
|
||||
|
||||
Reference in New Issue
Block a user