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,437 @@
|
||||
/**
|
||||
* @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('rejects non-https icon URL schemes on pass-through', async () => {
|
||||
const dataUri = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+' },
|
||||
context
|
||||
)
|
||||
expect(dataUri.success).toBe(false)
|
||||
expect(dataUri.error).toContain('https')
|
||||
|
||||
const plainHttp = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', iconUrl: 'http://example.com/icon.png' },
|
||||
context
|
||||
)
|
||||
expect(plainHttp.success).toBe(false)
|
||||
expect(publishCustomBlockMock).not.toHaveBeenCalled()
|
||||
|
||||
publishCustomBlockMock.mockResolvedValue(publishedBlock)
|
||||
const servePath = await executeDeployCustomBlock(
|
||||
{ name: 'Enrich Lead', iconUrl: '/api/files/serve/workspace-logos%2Ficon.png' },
|
||||
context
|
||||
)
|
||||
expect(servePath.success).toBe(true)
|
||||
})
|
||||
|
||||
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,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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
performCreateWorkflowMcpTool,
|
||||
performDeleteWorkflowMcpTool,
|
||||
performUpdateWorkflowMcpTool,
|
||||
} from '@/lib/mcp/orchestration'
|
||||
import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import {
|
||||
applyDescriptionOverrides,
|
||||
generateToolInputSchema,
|
||||
getMeaningfulWorkflowDescription,
|
||||
sanitizeToolName,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import {
|
||||
performChatDeploy,
|
||||
performChatUndeploy,
|
||||
performFullDeploy,
|
||||
performFullUndeploy,
|
||||
} from '@/lib/workflows/orchestration'
|
||||
import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
|
||||
import { ensureWorkflowAccess } from '../access'
|
||||
import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types'
|
||||
|
||||
function buildWorkflowApiEndpoint(baseUrl: string, workflowId: string): string {
|
||||
return `${baseUrl}/api/workflows/${workflowId}/execute`
|
||||
}
|
||||
|
||||
function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) {
|
||||
return {
|
||||
endpoint: apiEndpoint,
|
||||
authentication: {
|
||||
type: 'api_key',
|
||||
acceptedHeaders: ['X-API-Key: YOUR_API_KEY', 'Authorization: Bearer YOUR_API_KEY'],
|
||||
},
|
||||
modes: {
|
||||
sync: {
|
||||
method: 'POST',
|
||||
transport: 'json',
|
||||
stream: false,
|
||||
body: { input: { key: 'value' } },
|
||||
},
|
||||
stream: {
|
||||
method: 'POST',
|
||||
transport: 'sse',
|
||||
stream: true,
|
||||
body: { stream: true, input: { key: 'value' } },
|
||||
},
|
||||
async: {
|
||||
method: 'POST',
|
||||
transport: 'json',
|
||||
stream: false,
|
||||
headers: { 'X-Execution-Mode': 'async' },
|
||||
body: { input: { key: 'value' } },
|
||||
jobStatusEndpointTemplate: `${baseUrl}/api/jobs/{jobId}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) {
|
||||
return {
|
||||
sync: `curl -X POST "${apiEndpoint}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{"input":{"key":"value"}}'`,
|
||||
stream: `curl -N -X POST "${apiEndpoint}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-d '{"stream":true,"input":{"key":"value"}}'`,
|
||||
async: `curl -X POST "${apiEndpoint}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "X-Execution-Mode: async" \\
|
||||
-d '{"input":{"key":"value"}}'`,
|
||||
poll: `curl "${baseUrl}/api/jobs/JOB_ID" \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`,
|
||||
}
|
||||
}
|
||||
|
||||
function buildMcpClientExamples(serverName: string, serverUrl: string) {
|
||||
return {
|
||||
cursor: {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
url: serverUrl,
|
||||
headers: { 'X-API-Key': 'YOUR_API_KEY' },
|
||||
},
|
||||
},
|
||||
},
|
||||
claudeCode: `claude mcp add ${serverName} --url "${serverUrl}" --header "X-API-Key: YOUR_API_KEY"`,
|
||||
claudeDesktop: {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
command: 'npx',
|
||||
args: ['-y', 'mcp-remote', serverUrl, '--header', 'X-API-Key:YOUR_API_KEY'],
|
||||
},
|
||||
},
|
||||
},
|
||||
vscode: {
|
||||
mcp: {
|
||||
servers: {
|
||||
[serverName]: {
|
||||
type: 'http',
|
||||
url: serverUrl,
|
||||
headers: { 'X-API-Key': 'YOUR_API_KEY' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDeployApi(
|
||||
params: DeployApiParams,
|
||||
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'
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(
|
||||
workflowId,
|
||||
context.userId,
|
||||
'admin'
|
||||
)
|
||||
|
||||
if (action === 'undeploy') {
|
||||
const result = await performFullUndeploy({ workflowId, userId: context.userId })
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to undeploy workflow' }
|
||||
}
|
||||
const baseUrl = getBaseUrl()
|
||||
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
isDeployed: false,
|
||||
apiEndpoint,
|
||||
baseUrl,
|
||||
deploymentType: 'api',
|
||||
deploymentStatus: {
|
||||
api: {
|
||||
isDeployed: false,
|
||||
endpoint: apiEndpoint,
|
||||
},
|
||||
},
|
||||
deploymentConfig: {
|
||||
api: buildWorkflowApiConfig(baseUrl, apiEndpoint),
|
||||
},
|
||||
examples: {
|
||||
api: {
|
||||
curl: buildWorkflowApiExamples(baseUrl, apiEndpoint),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const versionDescription = params.versionDescription?.trim()
|
||||
if (!versionDescription) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).',
|
||||
}
|
||||
}
|
||||
|
||||
const versionName = params.versionName?.trim()
|
||||
if (!versionName) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'versionName is required when deploying. Provide a short human-readable label for this deployment version.',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await performFullDeploy({
|
||||
workflowId,
|
||||
userId: context.userId,
|
||||
workflowName: workflowRecord.name || undefined,
|
||||
versionDescription,
|
||||
versionName,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to deploy workflow' }
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
|
||||
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
|
||||
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
isDeployed: true,
|
||||
deployedAt: result.deployedAt,
|
||||
version: result.version,
|
||||
apiEndpoint,
|
||||
baseUrl,
|
||||
deploymentType: 'api',
|
||||
deploymentStatus: {
|
||||
api: {
|
||||
isDeployed: true,
|
||||
endpoint: apiEndpoint,
|
||||
deployedAt: result.deployedAt,
|
||||
version: result.version,
|
||||
},
|
||||
},
|
||||
deploymentConfig: {
|
||||
api: apiConfig,
|
||||
},
|
||||
examples: {
|
||||
api: {
|
||||
curl: apiExamples,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDeployChat(
|
||||
params: DeployChatParams,
|
||||
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'
|
||||
if (action === 'undeploy') {
|
||||
const baseUrl = getBaseUrl()
|
||||
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
|
||||
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
|
||||
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
if (!existing.length) {
|
||||
return { success: false, error: 'No active chat deployment found for this workflow' }
|
||||
}
|
||||
const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess(
|
||||
existing[0].id,
|
||||
context.userId
|
||||
)
|
||||
if (!hasAccess) {
|
||||
return { success: false, error: 'Unauthorized chat access' }
|
||||
}
|
||||
const undeployResult = await performChatUndeploy({
|
||||
chatId: existing[0].id,
|
||||
userId: context.userId,
|
||||
workspaceId: chatWorkspaceId,
|
||||
})
|
||||
if (!undeployResult.success) {
|
||||
return { success: false, error: undeployResult.error || 'Failed to undeploy chat' }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
success: true,
|
||||
action: 'undeploy',
|
||||
isDeployed: true,
|
||||
isChatDeployed: false,
|
||||
deploymentType: 'chat',
|
||||
apiEndpoint,
|
||||
baseUrl,
|
||||
deploymentStatus: {
|
||||
api: {
|
||||
isDeployed: true,
|
||||
endpoint: apiEndpoint,
|
||||
},
|
||||
chat: {
|
||||
isDeployed: false,
|
||||
identifier: existing[0].identifier,
|
||||
title: existing[0].title,
|
||||
},
|
||||
},
|
||||
deploymentConfig: {
|
||||
api: apiConfig,
|
||||
chat: {
|
||||
identifier: existing[0].identifier,
|
||||
title: existing[0].title,
|
||||
description: existing[0].description || '',
|
||||
authType: existing[0].authType,
|
||||
allowedEmails: (existing[0].allowedEmails as string[]) || [],
|
||||
outputConfigs:
|
||||
(existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [],
|
||||
welcomeMessage:
|
||||
(existing[0].customizations as { welcomeMessage?: string } | null)
|
||||
?.welcomeMessage || 'Hi there! How can I help you today?',
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
api: {
|
||||
curl: apiExamples,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
|
||||
workflowId,
|
||||
context.userId
|
||||
)
|
||||
if (!hasAccess || !workflowRecord) {
|
||||
return { success: false, error: 'Workflow not found or access denied' }
|
||||
}
|
||||
|
||||
const [existingDeployment] = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
const identifier = String(params.identifier || existingDeployment?.identifier || '').trim()
|
||||
const title = String(params.title || existingDeployment?.title || '').trim()
|
||||
if (!identifier || !title) {
|
||||
return { success: false, error: 'Chat identifier and title are required' }
|
||||
}
|
||||
|
||||
const versionDescription = params.versionDescription?.trim()
|
||||
if (!versionDescription) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (distinct from the chat-facing description; call diff_workflows with ref1 "live" and ref2 "draft" if unsure).',
|
||||
}
|
||||
}
|
||||
|
||||
const versionName = params.versionName?.trim()
|
||||
if (!versionName) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'versionName is required when deploying. Provide a short human-readable label for this deployment version (distinct from the chat title).',
|
||||
}
|
||||
}
|
||||
|
||||
const identifierPattern = /^[a-z0-9-]+$/
|
||||
if (!identifierPattern.test(identifier)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Identifier can only contain lowercase letters, numbers, and hyphens',
|
||||
}
|
||||
}
|
||||
|
||||
const existingIdentifier = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
if (existingIdentifier.length > 0 && existingIdentifier[0].id !== existingDeployment?.id) {
|
||||
return { success: false, error: 'Identifier already in use' }
|
||||
}
|
||||
|
||||
const existingCustomizations =
|
||||
(existingDeployment?.customizations as
|
||||
| { primaryColor?: string; welcomeMessage?: string; imageUrl?: string }
|
||||
| undefined) || {}
|
||||
const resolvedDescription = String(params.description || existingDeployment?.description || '')
|
||||
const resolvedAuthType = (params.authType || existingDeployment?.authType || 'public') as
|
||||
| 'public'
|
||||
| 'password'
|
||||
| 'email'
|
||||
| 'sso'
|
||||
const resolvedAllowedEmails =
|
||||
params.allowedEmails || (existingDeployment?.allowedEmails as string[]) || []
|
||||
const resolvedOutputConfigs = (params.outputConfigs ||
|
||||
existingDeployment?.outputConfigs ||
|
||||
[]) as Array<{
|
||||
blockId: string
|
||||
path: string
|
||||
}>
|
||||
const welcomeMessage =
|
||||
typeof params.welcomeMessage === 'string'
|
||||
? params.welcomeMessage
|
||||
: params.customizations?.welcomeMessage || existingCustomizations.welcomeMessage
|
||||
const imageUrl =
|
||||
params.customizations?.imageUrl ||
|
||||
params.customizations?.iconUrl ||
|
||||
existingCustomizations.imageUrl
|
||||
|
||||
const result = await performChatDeploy({
|
||||
workflowId,
|
||||
userId: context.userId,
|
||||
identifier,
|
||||
title,
|
||||
description: resolvedDescription,
|
||||
versionDescription,
|
||||
versionName,
|
||||
customizations: {
|
||||
primaryColor:
|
||||
params.customizations?.primaryColor ||
|
||||
existingCustomizations.primaryColor ||
|
||||
'var(--brand-hover)',
|
||||
welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?',
|
||||
...(imageUrl ? { imageUrl } : {}),
|
||||
},
|
||||
authType: resolvedAuthType,
|
||||
password: params.password,
|
||||
allowedEmails: resolvedAllowedEmails,
|
||||
outputConfigs: resolvedOutputConfigs,
|
||||
workspaceId: workflowRecord.workspaceId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to deploy chat' }
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
|
||||
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
|
||||
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
success: true,
|
||||
action: 'deploy',
|
||||
isDeployed: true,
|
||||
isChatDeployed: true,
|
||||
identifier,
|
||||
chatUrl: result.chatUrl,
|
||||
apiEndpoint,
|
||||
baseUrl,
|
||||
deployedAt: result.deployedAt || null,
|
||||
version: result.version,
|
||||
deploymentType: 'chat',
|
||||
deploymentStatus: {
|
||||
api: {
|
||||
isDeployed: true,
|
||||
endpoint: apiEndpoint,
|
||||
deployedAt: result.deployedAt || null,
|
||||
version: result.version,
|
||||
},
|
||||
chat: {
|
||||
isDeployed: true,
|
||||
identifier,
|
||||
chatUrl: result.chatUrl,
|
||||
title,
|
||||
description: resolvedDescription,
|
||||
authType: resolvedAuthType,
|
||||
},
|
||||
},
|
||||
deploymentConfig: {
|
||||
api: apiConfig,
|
||||
chat: {
|
||||
identifier,
|
||||
chatUrl: result.chatUrl,
|
||||
title,
|
||||
description: resolvedDescription,
|
||||
authType: resolvedAuthType,
|
||||
allowedEmails: resolvedAllowedEmails,
|
||||
outputConfigs: resolvedOutputConfigs,
|
||||
welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?',
|
||||
primaryColor:
|
||||
params.customizations?.primaryColor ||
|
||||
existingCustomizations.primaryColor ||
|
||||
'var(--brand-hover)',
|
||||
...(imageUrl ? { imageUrl } : {}),
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
chat: {
|
||||
open: result.chatUrl,
|
||||
},
|
||||
api: {
|
||||
curl: apiExamples,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDeployMcp(
|
||||
params: DeployMcpParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(
|
||||
workflowId,
|
||||
context.userId,
|
||||
'admin'
|
||||
)
|
||||
const workspaceId = workflowRecord.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
|
||||
const serverId = params.serverId
|
||||
if (!serverId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'serverId is required. Use list_workspace_mcp_servers to get available servers.',
|
||||
}
|
||||
}
|
||||
const [serverRecord] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpServer.id, serverId),
|
||||
eq(workflowMcpServer.workspaceId, workspaceId),
|
||||
isNull(workflowMcpServer.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!serverRecord) {
|
||||
return { success: false, error: 'MCP server not found in this workspace' }
|
||||
}
|
||||
|
||||
// Handle undeploy action — remove workflow from MCP server
|
||||
if (params.action === 'undeploy') {
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.serverId, serverId),
|
||||
eq(workflowMcpTool.workflowId, workflowId),
|
||||
isNull(workflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingTool) {
|
||||
return { success: false, error: 'Workflow is not deployed to this MCP server' }
|
||||
}
|
||||
|
||||
const deleteResult = await performDeleteWorkflowMcpTool({
|
||||
serverId,
|
||||
toolId: existingTool.id,
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
if (!deleteResult.success) {
|
||||
return { success: false, error: deleteResult.error || 'Failed to undeploy MCP tool' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
action: 'undeploy',
|
||||
removed: true,
|
||||
deploymentType: 'mcp',
|
||||
deploymentStatus: {
|
||||
mcp: {
|
||||
isDeployed: false,
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflowRecord.isDeployed) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.',
|
||||
}
|
||||
}
|
||||
|
||||
const existingTool = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.serverId, serverId),
|
||||
eq(workflowMcpTool.workflowId, workflowId),
|
||||
isNull(workflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const toolName = sanitizeToolName(
|
||||
params.toolName || workflowRecord.name || `workflow_${workflowId}`
|
||||
)
|
||||
const toolDescription =
|
||||
params.toolDescription?.trim() ||
|
||||
getMeaningfulWorkflowDescription(workflowRecord.description, workflowRecord.name) ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
/**
|
||||
* Parameter names/types come from the workflow's deployed input trigger; this tool only sets
|
||||
* per-parameter descriptions, sent as sparse overrides. The materialized schema is echoed in the
|
||||
* response for the model's reference.
|
||||
*/
|
||||
const inputFormat = await getDeployedWorkflowInputFormat(workflowId)
|
||||
const parameterDescriptionOverrides = Object.fromEntries(
|
||||
(params.parameterDescriptions ?? [])
|
||||
.filter((entry) => entry && typeof entry.name === 'string' && entry.name.trim() !== '')
|
||||
.map((entry) => [entry.name.trim(), (entry.description ?? '').trim()])
|
||||
.filter(([, description]) => description !== '')
|
||||
)
|
||||
const parameterSchema = applyDescriptionOverrides(
|
||||
generateToolInputSchema(inputFormat),
|
||||
parameterDescriptionOverrides
|
||||
)
|
||||
const baseUrl = getBaseUrl()
|
||||
const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}`
|
||||
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
|
||||
const clientExamples = buildMcpClientExamples(serverRecord.name, mcpServerUrl)
|
||||
|
||||
if (existingTool.length > 0) {
|
||||
const toolId = existingTool[0].id
|
||||
const updateResult = await performUpdateWorkflowMcpTool({
|
||||
serverId,
|
||||
toolId,
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptionOverrides,
|
||||
})
|
||||
if (!updateResult.success || !updateResult.tool) {
|
||||
return { success: false, error: updateResult.error || 'Failed to update MCP tool' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
toolId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
updated: true,
|
||||
mcpServerUrl,
|
||||
baseUrl,
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
deploymentType: 'mcp',
|
||||
apiEndpoint,
|
||||
deploymentStatus: {
|
||||
api: {
|
||||
isDeployed: true,
|
||||
endpoint: apiEndpoint,
|
||||
},
|
||||
mcp: {
|
||||
isDeployed: true,
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
toolId,
|
||||
toolName,
|
||||
updated: true,
|
||||
},
|
||||
},
|
||||
deploymentConfig: {
|
||||
mcp: {
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
serverUrl: mcpServerUrl,
|
||||
toolId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
authentication: {
|
||||
type: 'api_key',
|
||||
header: 'X-API-Key: YOUR_API_KEY',
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
mcp: clientExamples,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createResult = await performCreateWorkflowMcpTool({
|
||||
serverId,
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptionOverrides,
|
||||
})
|
||||
if (!createResult.success || !createResult.tool) {
|
||||
return { success: false, error: createResult.error || 'Failed to deploy MCP tool' }
|
||||
}
|
||||
const toolId = createResult.tool.id
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
toolId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
updated: false,
|
||||
mcpServerUrl,
|
||||
baseUrl,
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
deploymentType: 'mcp',
|
||||
apiEndpoint,
|
||||
deploymentStatus: {
|
||||
api: {
|
||||
isDeployed: true,
|
||||
endpoint: apiEndpoint,
|
||||
},
|
||||
mcp: {
|
||||
isDeployed: true,
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
toolId,
|
||||
toolName,
|
||||
updated: false,
|
||||
},
|
||||
},
|
||||
deploymentConfig: {
|
||||
mcp: {
|
||||
serverId,
|
||||
serverName: serverRecord.name,
|
||||
serverUrl: mcpServerUrl,
|
||||
toolId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
authentication: {
|
||||
type: 'api_key',
|
||||
header: 'X-API-Key: YOUR_API_KEY',
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
mcp: clientExamples,
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeRedeploy(
|
||||
params: { workflowId?: string; versionDescription?: string; versionName?: string },
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
const versionDescription = params.versionDescription?.trim()
|
||||
if (!versionDescription) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'versionDescription is required. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).',
|
||||
}
|
||||
}
|
||||
const versionName = params.versionName?.trim()
|
||||
if (!versionName) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'versionName is required. Provide a short human-readable label for this deployment version.',
|
||||
}
|
||||
}
|
||||
await ensureWorkflowAccess(workflowId, context.userId, 'admin')
|
||||
|
||||
const result = await performFullDeploy({
|
||||
workflowId,
|
||||
userId: context.userId,
|
||||
versionDescription,
|
||||
versionName,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to redeploy workflow' }
|
||||
}
|
||||
const baseUrl = getBaseUrl()
|
||||
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
|
||||
const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint)
|
||||
const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
isDeployed: true,
|
||||
deployedAt: result.deployedAt || null,
|
||||
version: result.version,
|
||||
apiEndpoint,
|
||||
baseUrl,
|
||||
deploymentType: 'api',
|
||||
deploymentStatus: {
|
||||
api: {
|
||||
isDeployed: true,
|
||||
endpoint: apiEndpoint,
|
||||
deployedAt: result.deployedAt || null,
|
||||
version: result.version,
|
||||
},
|
||||
},
|
||||
deploymentConfig: {
|
||||
api: apiConfig,
|
||||
},
|
||||
examples: {
|
||||
api: {
|
||||
curl: apiExamples,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { auditMock, workflowsOrchestrationMock, workflowsOrchestrationMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionContext } from '@/lib/copilot/request/types'
|
||||
|
||||
const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() => ({
|
||||
ensureWorkflowAccessMock: vi.fn(),
|
||||
checkNeedsRedeploymentMock: vi.fn(),
|
||||
}))
|
||||
|
||||
const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion
|
||||
const performActivateVersionMock = workflowsOrchestrationMockFns.mockPerformActivateVersion
|
||||
|
||||
const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock, listWorkflowVersionsMock } =
|
||||
vi.hoisted(() => ({
|
||||
resolveWorkflowStateRefMock: vi.fn(),
|
||||
generateWorkflowDiffSummaryMock: vi.fn(),
|
||||
listWorkflowVersionsMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
chat: {},
|
||||
workflow: {},
|
||||
workflowDeploymentVersion: {},
|
||||
workflowMcpServer: {},
|
||||
workflowMcpTool: {},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/mcp/pubsub', () => ({
|
||||
mcpPubSub: {
|
||||
publishWorkflowToolsChanged: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
|
||||
generateParameterSchemaForWorkflow: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/mcp/workflow-tool-schema', () => ({
|
||||
sanitizeToolName: vi.fn((value: string) => value),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/triggers/trigger-utils.server', () => ({
|
||||
hasValidStartBlock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../access', () => ({
|
||||
ensureWorkflowAccess: ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
|
||||
|
||||
vi.mock('./state-refs', () => ({
|
||||
resolveWorkflowStateRef: resolveWorkflowStateRefMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/comparison', () => ({
|
||||
generateWorkflowDiffSummary: generateWorkflowDiffSummaryMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/workflows/utils', () => ({
|
||||
checkNeedsRedeployment: checkNeedsRedeploymentMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
listWorkflowVersions: listWorkflowVersionsMock,
|
||||
updateDeploymentVersionMetadata: vi.fn(),
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
executeCheckDeploymentStatus,
|
||||
executeDiffWorkflows,
|
||||
executeGetDeploymentLog,
|
||||
executeLoadDeployment,
|
||||
executePromoteToLive,
|
||||
} from './manage'
|
||||
|
||||
function selectChain(result: unknown[], resolveOnWhere = false) {
|
||||
const chain = {
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
where: vi.fn(() => (resolveOnWhere ? Promise.resolve(result) : chain)),
|
||||
orderBy: vi.fn(() => Promise.resolve(result)),
|
||||
limit: vi.fn(() => Promise.resolve(result)),
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
describe('executeLoadDeployment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads a version into the draft via performRevertToVersion', async () => {
|
||||
performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 12345 })
|
||||
|
||||
const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin')
|
||||
expect(performRevertToVersionMock).toHaveBeenCalledWith({
|
||||
workflowId: 'wf-1',
|
||||
version: 7,
|
||||
userId: 'user-1',
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
|
||||
})
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
output: {
|
||||
workflowId: 'wf-1',
|
||||
message: 'Loaded version 7 into the workflow draft',
|
||||
lastSaved: 12345,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('maps "live" to the active version', async () => {
|
||||
performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 1 })
|
||||
|
||||
await executeLoadDeployment({ workflowId: 'wf-1', version: 'live' }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(performRevertToVersionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ version: 'active' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects "draft"', async () => {
|
||||
const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 'draft' }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(performRevertToVersionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns shared helper failures directly', async () => {
|
||||
performRevertToVersionMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Deployment version not found',
|
||||
})
|
||||
|
||||
const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Deployment version not found' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('executePromoteToLive', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
|
||||
})
|
||||
})
|
||||
|
||||
it('promotes a version via performActivateVersion', async () => {
|
||||
performActivateVersionMock.mockResolvedValue({
|
||||
success: true,
|
||||
deployedAt: new Date('2026-05-30T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin')
|
||||
expect(performActivateVersionMock).toHaveBeenCalledWith({
|
||||
workflowId: 'wf-1',
|
||||
version: 3,
|
||||
userId: 'user-1',
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
workflowId: 'wf-1',
|
||||
version: 3,
|
||||
message: 'Promoted version 3 to live',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-numeric version like "live"', async () => {
|
||||
const result = await executePromoteToLive({ workflowId: 'wf-1', version: 'live' as never }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(performActivateVersionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeGetDeploymentLog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns versions from the shared listWorkflowVersions helper', async () => {
|
||||
listWorkflowVersionsMock.mockResolvedValue({
|
||||
versions: [
|
||||
{
|
||||
id: 'v2',
|
||||
version: 2,
|
||||
name: null,
|
||||
description: null,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-05-30T00:00:00.000Z'),
|
||||
createdBy: 'user-1',
|
||||
deployedByName: 'Waleed',
|
||||
},
|
||||
{
|
||||
id: 'v1',
|
||||
version: 1,
|
||||
name: 'first',
|
||||
description: 'initial',
|
||||
isActive: false,
|
||||
createdAt: new Date('2026-05-29T00:00:00.000Z'),
|
||||
createdBy: null,
|
||||
deployedByName: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await executeGetDeploymentLog({ workflowId: 'wf-1' }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(listWorkflowVersionsMock).toHaveBeenCalledWith('wf-1')
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
workflowId: 'wf-1',
|
||||
count: 2,
|
||||
versions: [
|
||||
{ id: 'v2', version: 2, isActive: true },
|
||||
{ id: 'v1', version: 1, name: 'first', description: 'initial', isActive: false },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeDiffWorkflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('diffs ref2 against ref1 and returns the structured summary', async () => {
|
||||
resolveWorkflowStateRefMock
|
||||
.mockResolvedValueOnce({ state: { base: true }, ref: '1', version: 1, isActive: false })
|
||||
.mockResolvedValueOnce({ state: { target: true }, ref: 'live', version: 2, isActive: true })
|
||||
|
||||
const summary = {
|
||||
addedBlocks: [],
|
||||
removedBlocks: [],
|
||||
modifiedBlocks: [],
|
||||
edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] },
|
||||
loopChanges: { added: 0, removed: 0, modified: 0 },
|
||||
parallelChanges: { added: 0, removed: 0, modified: 0 },
|
||||
variableChanges: {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
addedNames: [],
|
||||
removedNames: [],
|
||||
modifiedNames: [],
|
||||
},
|
||||
hasChanges: false,
|
||||
}
|
||||
generateWorkflowDiffSummaryMock.mockReturnValue(summary)
|
||||
|
||||
const result = await executeDiffWorkflows({ workflowId: 'wf-1', ref1: 1, ref2: 'live' }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 1, 'user-1')
|
||||
expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 'live', 'user-1')
|
||||
// ref1 = base/previous, ref2 = target/current.
|
||||
expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith({ target: true }, { base: true })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
workflowId: 'wf-1',
|
||||
ref1: { ref: '1', version: 1 },
|
||||
ref2: { ref: 'live', version: 2, isActive: true },
|
||||
diff: { hasChanges: false },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeCheckDeploymentStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' },
|
||||
})
|
||||
checkNeedsRedeploymentMock.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('uses the shared redeployment freshness helper for deployed APIs', async () => {
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(
|
||||
selectChain([{ isDeployed: true, deployedAt: new Date('2026-05-28') }]) as never
|
||||
)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain([], true) as never)
|
||||
checkNeedsRedeploymentMock.mockResolvedValueOnce(true)
|
||||
|
||||
const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(checkNeedsRedeploymentMock).toHaveBeenCalledWith('wf-1')
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
isDeployed: true,
|
||||
api: {
|
||||
isDeployed: true,
|
||||
needsRedeployment: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not check redeployment freshness for undeployed APIs', async () => {
|
||||
vi.mocked(db.select)
|
||||
.mockReturnValueOnce(selectChain([{ isDeployed: false, deployedAt: null }]) as never)
|
||||
.mockReturnValueOnce(selectChain([]) as never)
|
||||
.mockReturnValueOnce(selectChain([], true) as never)
|
||||
|
||||
const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, {
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext)
|
||||
|
||||
expect(checkNeedsRedeploymentMock).not.toHaveBeenCalled()
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
isDeployed: false,
|
||||
api: {
|
||||
isDeployed: false,
|
||||
needsRedeployment: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,610 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import {
|
||||
performCreateWorkflowMcpServer,
|
||||
performDeleteWorkflowMcpServer,
|
||||
performUpdateWorkflowMcpServer,
|
||||
} from '@/lib/mcp/orchestration'
|
||||
import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison'
|
||||
import { performActivateVersion, performRevertToVersion } from '@/lib/workflows/orchestration'
|
||||
import {
|
||||
listWorkflowVersions,
|
||||
updateDeploymentVersionMetadata,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { checkNeedsRedeployment } from '@/app/api/workflows/utils'
|
||||
import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access'
|
||||
import type {
|
||||
CheckDeploymentStatusParams,
|
||||
CreateWorkspaceMcpServerParams,
|
||||
DeleteWorkspaceMcpServerParams,
|
||||
DiffWorkflowsParams,
|
||||
GetDeploymentLogParams,
|
||||
ListWorkspaceMcpServersParams,
|
||||
LoadDeploymentParams,
|
||||
PromoteToLiveParams,
|
||||
UpdateDeploymentVersionParams,
|
||||
UpdateWorkspaceMcpServerParams,
|
||||
} from '../param-types'
|
||||
import { resolveWorkflowStateRef } from './state-refs'
|
||||
|
||||
export async function executeCheckDeploymentStatus(
|
||||
params: CheckDeploymentStatusParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
|
||||
const workspaceId = workflowRecord.workspaceId
|
||||
|
||||
const [apiDeploy, chatDeploy] = await Promise.all([
|
||||
db
|
||||
.select({ isDeployed: workflow.isDeployed, deployedAt: workflow.deployedAt })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
id: chat.id,
|
||||
identifier: chat.identifier,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
password: chat.password,
|
||||
customizations: chat.customizations,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
const isApiDeployed = apiDeploy[0]?.isDeployed || false
|
||||
const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false
|
||||
const apiDetails = {
|
||||
isDeployed: isApiDeployed,
|
||||
deployedAt: apiDeploy[0]?.deployedAt || null,
|
||||
endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null,
|
||||
apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys',
|
||||
needsRedeployment,
|
||||
}
|
||||
|
||||
const isChatDeployed = !!chatDeploy[0]
|
||||
const chatCustomizations =
|
||||
(chatDeploy[0]?.customizations as
|
||||
| { welcomeMessage?: string; primaryColor?: string }
|
||||
| undefined) || {}
|
||||
const chatDetails = {
|
||||
isDeployed: isChatDeployed,
|
||||
chatId: chatDeploy[0]?.id || null,
|
||||
identifier: chatDeploy[0]?.identifier || null,
|
||||
chatUrl: isChatDeployed ? `/chat/${chatDeploy[0]?.identifier}` : null,
|
||||
title: chatDeploy[0]?.title || null,
|
||||
description: chatDeploy[0]?.description || null,
|
||||
authType: chatDeploy[0]?.authType || null,
|
||||
allowedEmails: chatDeploy[0]?.allowedEmails || null,
|
||||
outputConfigs: chatDeploy[0]?.outputConfigs || null,
|
||||
welcomeMessage: chatCustomizations.welcomeMessage || null,
|
||||
primaryColor: chatCustomizations.primaryColor || null,
|
||||
hasPassword: Boolean(chatDeploy[0]?.password),
|
||||
}
|
||||
|
||||
const mcpDetails: {
|
||||
isDeployed: boolean
|
||||
servers: Array<{
|
||||
serverId: string
|
||||
serverName: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: unknown
|
||||
toolId: string
|
||||
}>
|
||||
} = { isDeployed: false, servers: [] }
|
||||
if (workspaceId) {
|
||||
const servers = await db
|
||||
.select({
|
||||
serverId: workflowMcpServer.id,
|
||||
serverName: workflowMcpServer.name,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
toolId: workflowMcpTool.id,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (servers.length > 0) {
|
||||
mcpDetails.isDeployed = true
|
||||
mcpDetails.servers = servers
|
||||
}
|
||||
}
|
||||
|
||||
const isDeployed = apiDetails.isDeployed || chatDetails.isDeployed || mcpDetails.isDeployed
|
||||
return {
|
||||
success: true,
|
||||
output: { isDeployed, api: apiDetails, chat: chatDetails, mcp: mcpDetails },
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeListWorkspaceMcpServers(
|
||||
params: ListWorkspaceMcpServersParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
let workspaceId = params.workspaceId || context.workspaceId
|
||||
const workflowId = context.workflowId
|
||||
|
||||
if (!workspaceId && workflowId) {
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
|
||||
workspaceId = workflowRecord.workspaceId ?? undefined
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, 'read')
|
||||
|
||||
const servers = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt))
|
||||
)
|
||||
|
||||
const serverIds = servers.map((server) => server.id)
|
||||
const tools =
|
||||
serverIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
serverId: workflowMcpTool.serverId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(inArray(workflowMcpTool.serverId, serverIds), isNull(workflowMcpTool.archivedAt))
|
||||
)
|
||||
: []
|
||||
|
||||
const toolNamesByServer: Record<string, string[]> = {}
|
||||
for (const tool of tools) {
|
||||
if (!toolNamesByServer[tool.serverId]) {
|
||||
toolNamesByServer[tool.serverId] = []
|
||||
}
|
||||
toolNamesByServer[tool.serverId].push(tool.toolName)
|
||||
}
|
||||
|
||||
const serversWithToolNames = servers.map((server) => ({
|
||||
...server,
|
||||
toolCount: toolNamesByServer[server.id]?.length || 0,
|
||||
toolNames: toolNamesByServer[server.id] || [],
|
||||
}))
|
||||
|
||||
return { success: true, output: { servers: serversWithToolNames, count: servers.length } }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCreateWorkspaceMcpServer(
|
||||
params: CreateWorkspaceMcpServerParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
let workspaceId = params.workspaceId || context.workspaceId
|
||||
const workflowId = context.workflowId
|
||||
|
||||
if (!workspaceId && workflowId) {
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(
|
||||
workflowId,
|
||||
context.userId,
|
||||
'write'
|
||||
)
|
||||
workspaceId = workflowRecord.workspaceId ?? undefined
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, 'admin')
|
||||
|
||||
const name = params.name?.trim()
|
||||
if (!name) {
|
||||
return { success: false, error: 'name is required' }
|
||||
}
|
||||
|
||||
const result = await performCreateWorkflowMcpServer({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
name,
|
||||
description: params.description,
|
||||
isPublic: params.isPublic,
|
||||
workflowIds: params.workflowIds,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to create MCP server' }
|
||||
}
|
||||
|
||||
return { success: true, output: { server: result.server, addedTools: result.addedTools || [] } }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeUpdateWorkspaceMcpServer(
|
||||
params: UpdateWorkspaceMcpServerParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const serverId = params.serverId
|
||||
if (!serverId) {
|
||||
return { success: false, error: 'serverId is required' }
|
||||
}
|
||||
|
||||
const updates: { name?: string; description?: string | null; isPublic?: boolean } = {}
|
||||
if (typeof params.name === 'string') {
|
||||
const name = params.name.trim()
|
||||
if (!name) return { success: false, error: 'name cannot be empty' }
|
||||
updates.name = name
|
||||
}
|
||||
if (typeof params.description === 'string') {
|
||||
updates.description = params.description.trim() || null
|
||||
}
|
||||
if (typeof params.isPublic === 'boolean') {
|
||||
updates.isPublic = params.isPublic
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'At least one of name, description, or isPublic is required' }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: 'MCP server not found' }
|
||||
}
|
||||
|
||||
await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'write')
|
||||
|
||||
const result = await performUpdateWorkflowMcpServer({
|
||||
serverId,
|
||||
workspaceId: existing.workspaceId,
|
||||
userId: context.userId,
|
||||
...updates,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to update MCP server' }
|
||||
}
|
||||
|
||||
return { success: true, output: { serverId, ...updates } }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDeleteWorkspaceMcpServer(
|
||||
params: DeleteWorkspaceMcpServerParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const serverId = params.serverId
|
||||
if (!serverId) {
|
||||
return { success: false, error: 'serverId is required' }
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: 'MCP server not found' }
|
||||
}
|
||||
|
||||
await ensureWorkspaceAccess(existing.workspaceId, context.userId, 'admin')
|
||||
|
||||
const result = await performDeleteWorkflowMcpServer({
|
||||
serverId,
|
||||
workspaceId: existing.workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to delete MCP server' }
|
||||
}
|
||||
|
||||
return { success: true, output: { serverId, name: existing.name, deleted: true } }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetDeploymentLog(
|
||||
params: GetDeploymentLogParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
await ensureWorkflowAccess(workflowId, context.userId)
|
||||
|
||||
const { versions: rows } = await listWorkflowVersions(workflowId)
|
||||
|
||||
const versions = rows.map((r) => ({
|
||||
id: r.id,
|
||||
version: r.version,
|
||||
name: r.name ?? undefined,
|
||||
description: r.description ?? undefined,
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
createdBy: r.createdBy ?? undefined,
|
||||
}))
|
||||
|
||||
return { success: true, output: { workflowId, count: versions.length, versions } }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
// Cap individual sub-block before/after values so a large diff can't blow the
|
||||
// tool-result budget. Oversized values are replaced with an elision marker.
|
||||
const MAX_DIFF_VALUE_BYTES = 2000
|
||||
|
||||
function guardDiffValue(value: unknown): unknown {
|
||||
try {
|
||||
const json = JSON.stringify(value)
|
||||
if (json && json.length > MAX_DIFF_VALUE_BYTES) {
|
||||
return { elided: true, bytes: json.length }
|
||||
}
|
||||
} catch {
|
||||
return { elided: true, reason: 'unserializable' }
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export async function executeDiffWorkflows(
|
||||
params: DiffWorkflowsParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (params.ref1 === undefined || params.ref2 === undefined) {
|
||||
return { success: false, error: 'ref1 and ref2 are required' }
|
||||
}
|
||||
|
||||
// resolveWorkflowStateRef enforces read access on the workflow.
|
||||
const [side1, side2] = await Promise.all([
|
||||
resolveWorkflowStateRef(workflowId, params.ref1, context.userId),
|
||||
resolveWorkflowStateRef(workflowId, params.ref2, context.userId),
|
||||
])
|
||||
|
||||
// ref1 = base/previous, ref2 = target/current: added = present in ref2 only.
|
||||
const summary = generateWorkflowDiffSummary(side2.state, side1.state)
|
||||
const diff = {
|
||||
...summary,
|
||||
modifiedBlocks: summary.modifiedBlocks.map((block) => ({
|
||||
...block,
|
||||
changes: block.changes.map((change) => ({
|
||||
field: change.field,
|
||||
oldValue: guardDiffValue(change.oldValue),
|
||||
newValue: guardDiffValue(change.newValue),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
ref1: { ref: side1.ref, version: side1.version, isActive: side1.isActive },
|
||||
ref2: { ref: side2.ref, version: side2.version, isActive: side2.isActive },
|
||||
diff,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLoadVersion(
|
||||
raw: number | string
|
||||
): { ok: true; version: number | 'active' } | { ok: false; error: string } {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return { ok: true, version: raw }
|
||||
if (typeof raw === 'string') {
|
||||
const t = raw.trim().toLowerCase()
|
||||
if (t === 'live' || t === 'active') return { ok: true, version: 'active' }
|
||||
if (t === 'draft' || t === 'current') {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Cannot load "draft" — load_deployment restores a deployed version into the draft',
|
||||
}
|
||||
}
|
||||
if (/^\d+$/.test(t)) return { ok: true, version: Number.parseInt(t, 10) }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid version "${String(raw)}": expected a version number or "live"`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeLoadDeployment(
|
||||
params: LoadDeploymentParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (params.version === undefined || params.version === null) {
|
||||
return { success: false, error: 'version is required' }
|
||||
}
|
||||
const target = resolveLoadVersion(params.version)
|
||||
if (!target.ok) {
|
||||
return { success: false, error: target.error }
|
||||
}
|
||||
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(
|
||||
workflowId,
|
||||
context.userId,
|
||||
'admin'
|
||||
)
|
||||
const result = await performRevertToVersion({
|
||||
workflowId,
|
||||
version: target.version,
|
||||
userId: context.userId,
|
||||
workflow: workflowRecord as Record<string, unknown>,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to load deployment' }
|
||||
}
|
||||
|
||||
const label = target.version === 'active' ? 'the live deployment' : `version ${target.version}`
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
message: `Loaded ${label} into the workflow draft`,
|
||||
lastSaved: result.lastSaved,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePromoteVersion(raw: number | string): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
||||
if (typeof raw === 'string' && /^\d+$/.test(raw.trim())) return Number.parseInt(raw.trim(), 10)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function executePromoteToLive(
|
||||
params: PromoteToLiveParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (params.version === undefined || params.version === null) {
|
||||
return { success: false, error: 'version is required' }
|
||||
}
|
||||
const version = normalizePromoteVersion(params.version)
|
||||
if (version === null) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'version must be a deployment version number (use load_deployment to change the draft; "live" is already live)',
|
||||
}
|
||||
}
|
||||
|
||||
const { workflow: workflowRecord } = await ensureWorkflowAccess(
|
||||
workflowId,
|
||||
context.userId,
|
||||
'admin'
|
||||
)
|
||||
const result = await performActivateVersion({
|
||||
workflowId,
|
||||
version,
|
||||
userId: context.userId,
|
||||
workflow: workflowRecord as Record<string, unknown>,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to promote version' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
version,
|
||||
message: `Promoted version ${version} to live`,
|
||||
deployedAt: result.deployedAt ? new Date(result.deployedAt).toISOString() : undefined,
|
||||
warnings: result.warnings,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeUpdateDeploymentVersion(
|
||||
params: UpdateDeploymentVersionParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (params.version === undefined || params.version === null) {
|
||||
return { success: false, error: 'version is required' }
|
||||
}
|
||||
const version = normalizePromoteVersion(params.version)
|
||||
if (version === null) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'version must be a deployment version number (use get_deployment_log to find it)',
|
||||
}
|
||||
}
|
||||
|
||||
const name = typeof params.name === 'string' ? params.name.trim() : undefined
|
||||
const description =
|
||||
typeof params.description === 'string' ? params.description.trim() : undefined
|
||||
if (name === undefined && description === undefined) {
|
||||
return { success: false, error: 'Provide a name and/or description to update' }
|
||||
}
|
||||
|
||||
await ensureWorkflowAccess(workflowId, context.userId, 'write')
|
||||
|
||||
const updated = await updateDeploymentVersionMetadata({
|
||||
workflowId,
|
||||
version,
|
||||
...(name !== undefined ? { name: name || null } : {}),
|
||||
...(description !== undefined ? { description: description || null } : {}),
|
||||
})
|
||||
if (!updated) {
|
||||
return { success: false, error: `Deployment version ${version} not found` }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: { workflowId, version, name: updated.name, description: updated.description },
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { ensureWorkflowAccess } from '../access'
|
||||
|
||||
/** Canonical workflow-state selector: a deployment version number, the live
|
||||
* (active) deployment, or the current draft. */
|
||||
export type WorkflowRef = number | 'live' | 'draft'
|
||||
|
||||
export interface ResolvedWorkflowRef {
|
||||
state: WorkflowState
|
||||
/** Human-readable ref label: "live", "draft", or the version number as a string. */
|
||||
ref: string
|
||||
version?: number
|
||||
isActive?: boolean
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw ref param into a canonical WorkflowRef.
|
||||
* Accepts a version number, a numeric string, "live"/"active", or "draft"/"current".
|
||||
* Throws on anything else.
|
||||
*/
|
||||
export function parseWorkflowRef(raw: unknown): WorkflowRef {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim().toLowerCase()
|
||||
if (trimmed === 'live' || trimmed === 'active') return 'live'
|
||||
if (trimmed === 'draft' || trimmed === 'current') return 'draft'
|
||||
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10)
|
||||
}
|
||||
throw new Error(`Invalid ref "${String(raw)}": expected a version number, "live", or "draft"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a (workflowId, ref) pair to a WorkflowState for diffing. Raw stored
|
||||
* snapshots are used for version/live (matching checkNeedsRedeployment's baseline),
|
||||
* and loadWorkflowDeploymentSnapshot is used for draft. Requires read access.
|
||||
*/
|
||||
export async function resolveWorkflowStateRef(
|
||||
workflowId: string,
|
||||
rawRef: unknown,
|
||||
userId: string
|
||||
): Promise<ResolvedWorkflowRef> {
|
||||
const ref = parseWorkflowRef(rawRef)
|
||||
await ensureWorkflowAccess(workflowId, userId, 'read')
|
||||
|
||||
if (ref === 'draft') {
|
||||
const state = await loadWorkflowDeploymentSnapshot(workflowId)
|
||||
if (!state) {
|
||||
throw new Error(`Workflow ${workflowId} has no draft state`)
|
||||
}
|
||||
return { state, ref: 'draft' }
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
ref === 'live'
|
||||
? and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
: and(
|
||||
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||
eq(workflowDeploymentVersion.version, ref)
|
||||
)
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
version: workflowDeploymentVersion.version,
|
||||
state: workflowDeploymentVersion.state,
|
||||
isActive: workflowDeploymentVersion.isActive,
|
||||
createdAt: workflowDeploymentVersion.createdAt,
|
||||
})
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(whereClause)
|
||||
.limit(1)
|
||||
|
||||
if (!row?.state) {
|
||||
throw new Error(
|
||||
ref === 'live'
|
||||
? `Workflow ${workflowId} has no active deployment`
|
||||
: `Deployment version ${ref} not found for workflow ${workflowId}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
state: row.state as WorkflowState,
|
||||
ref: ref === 'live' ? 'live' : String(ref),
|
||||
version: row.version,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt?.toISOString(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user