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,68 @@
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import type { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
|
||||
|
||||
type WorkflowRecord = NonNullable<Awaited<ReturnType<typeof getWorkflowById>>>
|
||||
|
||||
export async function ensureWorkflowAccess(
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
action: 'read' | 'write' | 'admin' = 'read'
|
||||
): Promise<{
|
||||
workflow: WorkflowRecord
|
||||
workspaceId?: string | null
|
||||
}> {
|
||||
const result = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action,
|
||||
})
|
||||
|
||||
if (!result.workflow) {
|
||||
throw new Error(`Workflow ${workflowId} not found`)
|
||||
}
|
||||
|
||||
if (!result.allowed) {
|
||||
throw new Error(result.message || 'Unauthorized workflow access')
|
||||
}
|
||||
|
||||
return { workflow: result.workflow, workspaceId: result.workflow.workspaceId }
|
||||
}
|
||||
|
||||
export async function getDefaultWorkspaceId(userId: string): Promise<string> {
|
||||
const accessibleRows = await listAccessibleWorkspaceRowsForUser(userId)
|
||||
const mostRecent = accessibleRows
|
||||
.map((row) => row.workspace)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]
|
||||
|
||||
if (!mostRecent) {
|
||||
throw new Error('No workspace found for user')
|
||||
}
|
||||
|
||||
return mostRecent.id
|
||||
}
|
||||
|
||||
export async function ensureWorkspaceAccess(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
level: 'read' | 'write' | 'admin' = 'read'
|
||||
): Promise<void> {
|
||||
const access = await checkWorkspaceAccess(workspaceId, userId)
|
||||
if (!access.exists || !access.hasAccess) {
|
||||
throw new Error(`Workspace ${workspaceId} not found`)
|
||||
}
|
||||
|
||||
if (level === 'read') return
|
||||
|
||||
if (level === 'admin') {
|
||||
if (!access.canAdmin) {
|
||||
throw new Error('Admin access required for this workspace')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!access.canWrite) {
|
||||
throw new Error('Write or admin access required for this workspace')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ExecutionContext } from '@/lib/copilot/request/types'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
export async function prepareExecutionContext(
|
||||
userId: string,
|
||||
workflowId: string,
|
||||
chatId?: string,
|
||||
options?: {
|
||||
workspaceId?: string
|
||||
decryptedEnvVars?: Record<string, string>
|
||||
}
|
||||
): Promise<ExecutionContext> {
|
||||
const workspaceId =
|
||||
options?.workspaceId ?? (await getWorkflowById(workflowId))?.workspaceId ?? undefined
|
||||
const decryptedEnvVars =
|
||||
options?.decryptedEnvVars ?? (await getEffectiveDecryptedEnv(userId, workspaceId))
|
||||
|
||||
return {
|
||||
userId,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
chatId,
|
||||
decryptedEnvVars,
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockIsFeatureEnabled,
|
||||
mockGetTableById,
|
||||
mockListTables,
|
||||
mockQueryRows,
|
||||
mockGetOrCreateTableSnapshot,
|
||||
mockDownloadFile,
|
||||
mockGeneratePresignedDownloadUrl,
|
||||
mockHasCloudStorage,
|
||||
mockExecuteTool,
|
||||
mockListWorkspaceFiles,
|
||||
mockFindWorkspaceFileRecord,
|
||||
mockFetchWorkspaceFileBuffer,
|
||||
mockGetSandboxWorkspaceFilePath,
|
||||
mockListWorkspaceFileFolders,
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsFeatureEnabled: vi.fn(),
|
||||
mockGetTableById: vi.fn(),
|
||||
mockListTables: vi.fn(),
|
||||
mockQueryRows: vi.fn(),
|
||||
mockGetOrCreateTableSnapshot: vi.fn(),
|
||||
mockDownloadFile: vi.fn(),
|
||||
mockGeneratePresignedDownloadUrl: vi.fn(),
|
||||
mockHasCloudStorage: vi.fn(),
|
||||
mockExecuteTool: vi.fn(),
|
||||
mockListWorkspaceFiles: vi.fn(),
|
||||
mockFindWorkspaceFileRecord: vi.fn(),
|
||||
mockFetchWorkspaceFileBuffer: vi.fn(),
|
||||
mockGetSandboxWorkspaceFilePath: vi.fn(),
|
||||
mockListWorkspaceFileFolders: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled }))
|
||||
vi.mock('@/lib/table/service', () => ({
|
||||
getTableById: mockGetTableById,
|
||||
listTables: mockListTables,
|
||||
}))
|
||||
vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows }))
|
||||
vi.mock('@/lib/table/snapshot-cache', () => ({
|
||||
getOrCreateTableSnapshot: mockGetOrCreateTableSnapshot,
|
||||
SNAPSHOT_MAX_BYTES: 500 * 1024 * 1024,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => ({
|
||||
downloadFile: mockDownloadFile,
|
||||
generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl,
|
||||
hasCloudStorage: mockHasCloudStorage,
|
||||
}))
|
||||
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer,
|
||||
findWorkspaceFileRecord: mockFindWorkspaceFileRecord,
|
||||
getSandboxWorkspaceFilePath: mockGetSandboxWorkspaceFilePath,
|
||||
listWorkspaceFiles: mockListWorkspaceFiles,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
|
||||
listWorkspaceFileFolders: mockListWorkspaceFileFolders,
|
||||
}))
|
||||
vi.mock('@/lib/copilot/vfs/path-utils', () => ({
|
||||
decodeVfsPathSegments: (p: string) => p.split('/'),
|
||||
encodeVfsPathSegments: (s: string[]) => s.join('/'),
|
||||
}))
|
||||
vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({
|
||||
resolveWorkflowAliasForWorkspace: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
vi.mock('@/lib/copilot/vfs/workflow-aliases', () => ({
|
||||
isPlanAliasPath: () => false,
|
||||
workflowAliasSandboxPath: (p: string) => p,
|
||||
}))
|
||||
|
||||
import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute'
|
||||
|
||||
const table = {
|
||||
id: 'tbl_1',
|
||||
workspaceId: 'ws_1',
|
||||
rowCount: 1000,
|
||||
schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] },
|
||||
}
|
||||
|
||||
const context = { workspaceId: 'ws_1', userId: 'u1' }
|
||||
|
||||
function mountedFiles() {
|
||||
const params = mockExecuteTool.mock.calls[0][1] as {
|
||||
_sandboxFiles?: Array<{ path: string; type?: string; content?: string; url?: string }>
|
||||
}
|
||||
return params._sandboxFiles ?? []
|
||||
}
|
||||
|
||||
const snapshotCacheOn = (flag: string) => Promise.resolve(flag === 'table-snapshot-cache')
|
||||
|
||||
describe('executeFunctionExecute table mounts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteTool.mockResolvedValue({ success: true })
|
||||
mockGetTableById.mockResolvedValue(table)
|
||||
mockIsFeatureEnabled.mockResolvedValue(false)
|
||||
// Row data is keyed by stable column id at rest, not display name.
|
||||
mockQueryRows.mockResolvedValue({ rows: [{ data: { col_name: 'Ada' } }] })
|
||||
mockHasCloudStorage.mockReturnValue(true)
|
||||
mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/presigned?sig=abc')
|
||||
})
|
||||
|
||||
it('flag OFF: drains the table inline via queryRows (existing path)', async () => {
|
||||
await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
|
||||
|
||||
expect(mockQueryRows).toHaveBeenCalledTimes(1)
|
||||
expect(mockGetOrCreateTableSnapshot).not.toHaveBeenCalled()
|
||||
const files = mountedFiles()
|
||||
expect(files[0].path).toBe('/home/user/tables/tbl_1.csv')
|
||||
expect(files[0].content).toBe('name\nAda')
|
||||
})
|
||||
|
||||
it('mounts CSV with display-name headers and id-keyed values, never column ids', async () => {
|
||||
mockGetTableById.mockResolvedValue({
|
||||
id: 'tbl_2',
|
||||
workspaceId: 'ws_1',
|
||||
rowCount: 2,
|
||||
schema: {
|
||||
columns: [
|
||||
{ id: 'col_name', name: 'name', type: 'string' },
|
||||
{ id: 'col_company', name: 'company', type: 'string' },
|
||||
],
|
||||
},
|
||||
})
|
||||
mockQueryRows.mockResolvedValue({
|
||||
rows: [
|
||||
{ data: { col_name: 'Ada', col_company: 'Analytical Engine' } },
|
||||
{ data: { col_name: 'Grace', col_company: 'Navy, Inc' } },
|
||||
],
|
||||
})
|
||||
|
||||
await executeFunctionExecute({ inputTables: ['tbl_2'] }, context as never)
|
||||
|
||||
const csv = mountedFiles()[0].content as string
|
||||
const lines = csv.split('\n')
|
||||
expect(lines[0]).toBe('name,company')
|
||||
expect(lines[1]).toBe('Ada,Analytical Engine')
|
||||
// Value containing a comma is quoted.
|
||||
expect(lines[2]).toBe('Grace,"Navy, Inc"')
|
||||
// No stable column id leaks into the mounted file.
|
||||
expect(csv).not.toContain('col_name')
|
||||
expect(csv).not.toContain('col_company')
|
||||
})
|
||||
|
||||
it('reads values by column id for legacy name-keyed rows too', async () => {
|
||||
// Legacy column with no id: getColumnId falls back to name, so name-keyed data is correct.
|
||||
mockGetTableById.mockResolvedValue({
|
||||
id: 'tbl_legacy',
|
||||
workspaceId: 'ws_1',
|
||||
rowCount: 1,
|
||||
schema: { columns: [{ name: 'email', type: 'string' }] },
|
||||
})
|
||||
mockQueryRows.mockResolvedValue({ rows: [{ data: { email: 'a@b.com' } }] })
|
||||
|
||||
await executeFunctionExecute({ inputTables: ['tbl_legacy'] }, context as never)
|
||||
|
||||
expect(mountedFiles()[0].content).toBe('email\na@b.com')
|
||||
})
|
||||
|
||||
it('flag ON + cloud storage: mounts by presigned URL, no bytes through web', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
|
||||
mockGetOrCreateTableSnapshot.mockResolvedValue({
|
||||
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
|
||||
size: 9,
|
||||
version: 5,
|
||||
})
|
||||
|
||||
await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
|
||||
|
||||
expect(mockGetOrCreateTableSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(mockQueryRows).not.toHaveBeenCalled()
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith(
|
||||
'table-snapshots/ws_1/tbl_1/v5.csv',
|
||||
'execution',
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(mountedFiles()[0]).toEqual({
|
||||
type: 'url',
|
||||
path: '/home/user/tables/tbl_1.csv',
|
||||
url: 'https://s3.example/presigned?sig=abc',
|
||||
})
|
||||
})
|
||||
|
||||
it('flag ON + local storage: falls back to a buffered content mount', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
|
||||
mockHasCloudStorage.mockReturnValue(false)
|
||||
mockGetOrCreateTableSnapshot.mockResolvedValue({
|
||||
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
|
||||
size: 9,
|
||||
version: 5,
|
||||
})
|
||||
mockDownloadFile.mockResolvedValue(Buffer.from('name\nAda\n'))
|
||||
|
||||
await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
|
||||
|
||||
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'table-snapshots/ws_1/tbl_1/v5.csv', context: 'execution' })
|
||||
)
|
||||
const file = mountedFiles()[0]
|
||||
expect(file.path).toBe('/home/user/tables/tbl_1.csv')
|
||||
expect(file.content).toBe('name\nAda\n')
|
||||
expect(file.type).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flag ON but small table stays on the inline path', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
|
||||
mockGetTableById.mockResolvedValue({ ...table, rowCount: 10 })
|
||||
|
||||
await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
|
||||
|
||||
expect(mockGetOrCreateTableSnapshot).not.toHaveBeenCalled()
|
||||
expect(mockQueryRows).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('flag ON + cloud: throws when the snapshot exceeds the table mount limit', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
|
||||
mockGetOrCreateTableSnapshot.mockResolvedValue({
|
||||
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
|
||||
size: 600 * 1024 * 1024,
|
||||
version: 5,
|
||||
})
|
||||
|
||||
await expect(
|
||||
executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
|
||||
).rejects.toThrow(/table mount limit/)
|
||||
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flag ON + local: throws when the snapshot exceeds the per-file mount limit', async () => {
|
||||
mockIsFeatureEnabled.mockImplementation(snapshotCacheOn)
|
||||
mockHasCloudStorage.mockReturnValue(false)
|
||||
mockGetOrCreateTableSnapshot.mockResolvedValue({
|
||||
key: 'table-snapshots/ws_1/tbl_1/v5.csv',
|
||||
size: 20 * 1024 * 1024,
|
||||
version: 5,
|
||||
})
|
||||
|
||||
await expect(
|
||||
executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
|
||||
).rejects.toThrow(/per-file mount limit/)
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a table that belongs to another workspace (tenant isolation)', async () => {
|
||||
mockGetTableById.mockResolvedValue({ ...table, workspaceId: 'ws_2' })
|
||||
|
||||
await expect(
|
||||
executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never)
|
||||
).rejects.toThrow(/Input table not found/)
|
||||
expect(mockGetOrCreateTableSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const fileRecord = {
|
||||
id: 'file_1',
|
||||
workspaceId: 'ws_1',
|
||||
name: 'data.csv',
|
||||
key: 'workspace/ws_1/data.csv',
|
||||
path: '/api/files/serve/workspace%2Fws_1%2Fdata.csv',
|
||||
size: 100,
|
||||
type: 'text/csv',
|
||||
storageContext: 'workspace' as const,
|
||||
}
|
||||
|
||||
describe('executeFunctionExecute file mounts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteTool.mockResolvedValue({ success: true })
|
||||
mockIsFeatureEnabled.mockResolvedValue(false)
|
||||
mockHasCloudStorage.mockReturnValue(true)
|
||||
mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/file?sig=abc')
|
||||
mockListWorkspaceFiles.mockResolvedValue([fileRecord])
|
||||
mockFindWorkspaceFileRecord.mockReturnValue(fileRecord)
|
||||
mockGetSandboxWorkspaceFilePath.mockReturnValue('/home/user/files/data.csv')
|
||||
})
|
||||
|
||||
it('cloud storage: mounts by presigned URL with the record context, no bytes through web', async () => {
|
||||
await executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never)
|
||||
|
||||
expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled()
|
||||
expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith(
|
||||
'workspace/ws_1/data.csv',
|
||||
'workspace',
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(mountedFiles()[0]).toEqual({
|
||||
type: 'url',
|
||||
path: '/home/user/files/data.csv',
|
||||
url: 'https://s3.example/file?sig=abc',
|
||||
})
|
||||
})
|
||||
|
||||
it('local storage: falls back to a buffered inline content mount', async () => {
|
||||
mockHasCloudStorage.mockReturnValue(false)
|
||||
mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('name\nAda\n'))
|
||||
|
||||
await executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never)
|
||||
|
||||
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
|
||||
const file = mountedFiles()[0]
|
||||
expect(file.path).toBe('/home/user/files/data.csv')
|
||||
expect(file.content).toBe('name\nAda\n')
|
||||
expect(file.type).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => {
|
||||
mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 600 * 1024 * 1024 })
|
||||
|
||||
await expect(
|
||||
executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never)
|
||||
).rejects.toThrow(/per-file mount limit/)
|
||||
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cloud storage: throws when mounts exceed the aggregate URL mount limit', async () => {
|
||||
// Each file is at the 500MB per-file cap; the 5th pushes the running total past 2GB.
|
||||
mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 500 * 1024 * 1024 })
|
||||
const paths = Array.from({ length: 5 }, (_, i) => `files/big-${i}.csv`)
|
||||
|
||||
await expect(executeFunctionExecute({ inputFiles: paths }, context as never)).rejects.toThrow(
|
||||
/total mount limit/
|
||||
)
|
||||
expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('throws when the inputFiles list exceeds the mounted-file count cap', async () => {
|
||||
const paths = Array.from({ length: 501 }, (_, i) => `files/f-${i}.csv`)
|
||||
|
||||
await expect(executeFunctionExecute({ inputFiles: paths }, context as never)).rejects.toThrow(
|
||||
/Too many input files/
|
||||
)
|
||||
expect(mockListWorkspaceFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cloud storage: mounts each directory descendant by presigned URL', async () => {
|
||||
mockListWorkspaceFileFolders.mockResolvedValue([{ path: 'Reports' }])
|
||||
const descendant = {
|
||||
...fileRecord,
|
||||
name: 'q1.csv',
|
||||
key: 'workspace/ws_1/q1.csv',
|
||||
folderPath: 'Reports',
|
||||
}
|
||||
mockListWorkspaceFiles.mockResolvedValue([descendant])
|
||||
|
||||
await executeFunctionExecute({ inputs: { directories: ['files/Reports'] } }, context as never)
|
||||
|
||||
expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled()
|
||||
expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith(
|
||||
'workspace/ws_1/q1.csv',
|
||||
'workspace',
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(mountedFiles()[0]).toEqual({
|
||||
type: 'url',
|
||||
path: '/home/user/files/Reports/q1.csv',
|
||||
url: 'https://s3.example/file?sig=abc',
|
||||
})
|
||||
})
|
||||
|
||||
it('local storage: buffers directory descendants via inline content', async () => {
|
||||
mockHasCloudStorage.mockReturnValue(false)
|
||||
mockListWorkspaceFileFolders.mockResolvedValue([{ path: 'Reports' }])
|
||||
const descendant = {
|
||||
...fileRecord,
|
||||
name: 'q1.csv',
|
||||
key: 'workspace/ws_1/q1.csv',
|
||||
folderPath: 'Reports',
|
||||
}
|
||||
mockListWorkspaceFiles.mockResolvedValue([descendant])
|
||||
mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('a,b\n1,2\n'))
|
||||
|
||||
await executeFunctionExecute({ inputs: { directories: ['files/Reports'] } }, context as never)
|
||||
|
||||
expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled()
|
||||
const file = mountedFiles()[0]
|
||||
expect(file.path).toBe('/home/user/files/Reports/q1.csv')
|
||||
expect(file.content).toBe('a,b\n1,2\n')
|
||||
expect(file.type).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,467 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
|
||||
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
|
||||
import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases'
|
||||
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { getColumnId } from '@/lib/table/column-keys'
|
||||
import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
|
||||
import { queryRows } from '@/lib/table/rows/service'
|
||||
import { getTableById, listTables } from '@/lib/table/service'
|
||||
import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache'
|
||||
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
|
||||
import {
|
||||
fetchWorkspaceFileBuffer,
|
||||
findWorkspaceFileRecord,
|
||||
getSandboxWorkspaceFilePath,
|
||||
listWorkspaceFiles,
|
||||
type WorkspaceFileRecord,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import {
|
||||
downloadFile,
|
||||
generatePresignedDownloadUrl,
|
||||
hasCloudStorage,
|
||||
} from '@/lib/uploads/core/storage-service'
|
||||
import { executeTool as executeAppTool } from '@/tools'
|
||||
import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types'
|
||||
|
||||
const logger = createLogger('CopilotFunctionExecute')
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_TOTAL_SIZE = 50 * 1024 * 1024
|
||||
const MAX_MOUNTED_FILES = 500
|
||||
|
||||
/**
|
||||
* Below this row count a table mounts via the direct inline CSV path — the version-keyed snapshot
|
||||
* cache (storage round-trip) only pays off for larger/hot tables. Behind the feature flag either
|
||||
* way; this just keeps tiny one-shot tables on the cheaper path.
|
||||
*/
|
||||
const SNAPSHOT_MIN_ROWS = 500
|
||||
|
||||
/**
|
||||
* Lifetime of a presigned URL handed to the sandbox to fetch a mounted object (table snapshot or
|
||||
* workspace file). Long enough to download a large file at sandbox startup; the URL grants read to
|
||||
* only that one object.
|
||||
*/
|
||||
const MOUNT_URL_TTL_SECONDS = 600
|
||||
|
||||
/**
|
||||
* Per-file ceiling for URL-mounted workspace files. The bytes never transit the web process — the
|
||||
* sandbox curls them straight from storage — so the bound is sandbox disk, not web heap (unlike the
|
||||
* inline MAX_FILE_SIZE path).
|
||||
*/
|
||||
const MOUNT_URL_MAX_BYTES = 500 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Aggregate ceiling across all URL-mounted files in one request. URL mounts bypass the web heap (so
|
||||
* they don't count against MAX_TOTAL_SIZE), but the sandbox still curls every byte onto its disk —
|
||||
* this rejects an oversized request up front instead of filling the sandbox disk one slow curl at a
|
||||
* time. Generous vs MAX_TOTAL_SIZE since the bytes never transit web memory.
|
||||
*/
|
||||
const MAX_TOTAL_URL_BYTES = 2 * 1024 * 1024 * 1024
|
||||
|
||||
type SandboxFile =
|
||||
| { type?: 'content'; path: string; content: string; encoding?: 'base64' }
|
||||
| { type: 'url'; path: string; url: string }
|
||||
|
||||
/**
|
||||
* Running byte totals for one resolveInputFiles call. `buffered` bytes pass through the web process
|
||||
* (capped by MAX_TOTAL_SIZE); `url` bytes are curled straight into the sandbox (capped by
|
||||
* MAX_TOTAL_URL_BYTES). Tracked separately because the two ceilings protect different resources —
|
||||
* web heap vs sandbox disk.
|
||||
*/
|
||||
interface MountedBytes {
|
||||
buffered: number
|
||||
url: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a stored workspace file into the sandbox and records its bytes against the running totals.
|
||||
* With cloud storage the sandbox fetches the bytes itself from a presigned URL (no web-heap transit,
|
||||
* per-file ceiling MOUNT_URL_MAX_BYTES, aggregate ceiling MAX_TOTAL_URL_BYTES); with local storage a
|
||||
* presigned URL is an app-internal serve path a remote sandbox can't reach, so we buffer the bytes
|
||||
* through the web process under the inline MAX_FILE_SIZE / MAX_TOTAL_SIZE guards.
|
||||
*/
|
||||
async function pushWorkspaceFileMount(
|
||||
sandboxFiles: SandboxFile[],
|
||||
record: WorkspaceFileRecord,
|
||||
mountPath: string,
|
||||
mounted: MountedBytes
|
||||
): Promise<void> {
|
||||
if (hasCloudStorage()) {
|
||||
if (record.size > MOUNT_URL_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Input file "${mountPath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MOUNT_URL_MAX_BYTES / 1024 / 1024}MB per-file mount limit.`
|
||||
)
|
||||
}
|
||||
if (mounted.url + record.size > MAX_TOTAL_URL_BYTES) {
|
||||
throw new Error(
|
||||
`Mounting "${mountPath}" would exceed the ${MAX_TOTAL_URL_BYTES / 1024 / 1024 / 1024}GB total mount limit. Mount fewer or smaller files.`
|
||||
)
|
||||
}
|
||||
const url = await generatePresignedDownloadUrl(
|
||||
record.key,
|
||||
record.storageContext ?? 'workspace',
|
||||
MOUNT_URL_TTL_SECONDS
|
||||
)
|
||||
sandboxFiles.push({ type: 'url', path: mountPath, url })
|
||||
mounted.url += record.size
|
||||
return
|
||||
}
|
||||
|
||||
if (record.size > MAX_FILE_SIZE) {
|
||||
throw new Error(
|
||||
`Input file "${mountPath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
|
||||
)
|
||||
}
|
||||
if (mounted.buffered + record.size > MAX_TOTAL_SIZE) {
|
||||
throw new Error(
|
||||
`Mounting "${mountPath}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller files.`
|
||||
)
|
||||
}
|
||||
const buffer = await fetchWorkspaceFileBuffer(record)
|
||||
const isText = /^text\/|application\/json|application\/xml|application\/csv/.test(
|
||||
record.type || ''
|
||||
)
|
||||
sandboxFiles.push({
|
||||
path: mountPath,
|
||||
content: isText ? buffer.toString('utf-8') : buffer.toString('base64'),
|
||||
encoding: isText ? undefined : 'base64',
|
||||
})
|
||||
mounted.buffered += buffer.length
|
||||
}
|
||||
|
||||
interface CanonicalFileInput {
|
||||
path: string
|
||||
sandboxPath?: string
|
||||
}
|
||||
|
||||
interface CanonicalDirectoryInput {
|
||||
path: string
|
||||
sandboxPath?: string
|
||||
}
|
||||
|
||||
interface CanonicalTableInput {
|
||||
tableId?: string
|
||||
path?: string
|
||||
sandboxPath?: string
|
||||
}
|
||||
|
||||
function tableNameFromVfsPath(tableRef: string): string | null {
|
||||
if (!tableRef.startsWith('tables/')) return null
|
||||
const segments = decodeVfsPathSegments(tableRef)
|
||||
const metaIndex = segments.lastIndexOf('meta.json')
|
||||
return segments[metaIndex > 0 ? metaIndex - 1 : segments.length - 1] ?? null
|
||||
}
|
||||
|
||||
async function resolveTableRef(
|
||||
tableRef: string,
|
||||
tablePathLookup?: Map<string, Awaited<ReturnType<typeof listTables>>[number]>
|
||||
) {
|
||||
if (!tableRef.startsWith('tables/')) {
|
||||
return getTableById(tableRef)
|
||||
}
|
||||
|
||||
const tableName = tableNameFromVfsPath(tableRef)
|
||||
if (!tableName) return null
|
||||
return tablePathLookup?.get(tableName) ?? null
|
||||
}
|
||||
|
||||
export async function resolveInputFiles(
|
||||
workspaceId: string,
|
||||
inputFiles?: unknown[],
|
||||
inputTables?: unknown[],
|
||||
inputDirectories?: unknown[]
|
||||
): Promise<SandboxFile[]> {
|
||||
const sandboxFiles: SandboxFile[] = []
|
||||
const mounted: MountedBytes = { buffered: 0, url: 0 }
|
||||
const betaEnabled = await isFeatureEnabled('mothership-beta')
|
||||
|
||||
if (inputFiles?.length && workspaceId) {
|
||||
if (inputFiles.length > MAX_MOUNTED_FILES) {
|
||||
throw new Error(
|
||||
`Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.`
|
||||
)
|
||||
}
|
||||
const allFiles = await listWorkspaceFiles(workspaceId, {
|
||||
includeReservedSystemFiles: betaEnabled,
|
||||
})
|
||||
for (const fileRef of inputFiles) {
|
||||
const filePath =
|
||||
typeof fileRef === 'string'
|
||||
? fileRef
|
||||
: fileRef && typeof fileRef === 'object'
|
||||
? (fileRef as CanonicalFileInput).path
|
||||
: undefined
|
||||
if (!filePath) continue
|
||||
const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath })
|
||||
if (!alias && isPlanAliasPath(filePath)) {
|
||||
logger.warn('Unsupported plan alias input file path', { filePath })
|
||||
continue
|
||||
}
|
||||
if (alias?.kind === 'plans_dir') {
|
||||
logger.warn('Input file is a plan alias directory', { filePath })
|
||||
continue
|
||||
}
|
||||
const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath)
|
||||
if (!record) {
|
||||
if (filePath.startsWith('uploads/')) {
|
||||
throw new Error(
|
||||
`Cannot mount "${filePath}": uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").`
|
||||
)
|
||||
}
|
||||
const explicitSandboxPath =
|
||||
typeof fileRef === 'object' && fileRef !== null
|
||||
? (fileRef as CanonicalFileInput).sandboxPath
|
||||
: undefined
|
||||
const mountPath =
|
||||
explicitSandboxPath ||
|
||||
(alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record))
|
||||
await pushWorkspaceFileMount(sandboxFiles, record, mountPath, mounted)
|
||||
}
|
||||
}
|
||||
|
||||
if (inputDirectories?.length && workspaceId) {
|
||||
const folders = await listWorkspaceFileFolders(workspaceId, {
|
||||
includeReservedSystemFolders: betaEnabled,
|
||||
})
|
||||
const allFiles = await listWorkspaceFiles(workspaceId, {
|
||||
folders,
|
||||
includeReservedSystemFiles: betaEnabled,
|
||||
})
|
||||
for (const dirRef of inputDirectories) {
|
||||
const dirPath =
|
||||
typeof dirRef === 'string'
|
||||
? dirRef
|
||||
: dirRef && typeof dirRef === 'object'
|
||||
? (dirRef as CanonicalDirectoryInput).path
|
||||
: undefined
|
||||
if (!dirPath) continue
|
||||
const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: dirPath })
|
||||
if (alias && alias.kind !== 'plans_dir') {
|
||||
throw new Error(`Input directory is a plan alias file, not a directory: ${dirPath}`)
|
||||
}
|
||||
if (!alias && isPlanAliasPath(dirPath)) {
|
||||
throw new Error(`Unsupported plan alias directory: ${dirPath}`)
|
||||
}
|
||||
const backingDirPath = alias?.backingPath ?? dirPath
|
||||
const folderSegments = decodeVfsPathSegments(backingDirPath.replace(/^\/?files\/?/, ''))
|
||||
const folderDisplayPath = folderSegments.join('/')
|
||||
const folder = folders.find((candidate) => candidate.path === folderDisplayPath)
|
||||
if (!folder) {
|
||||
throw new Error(`Input directory not found: ${dirPath}`)
|
||||
}
|
||||
const mountRoot =
|
||||
typeof dirRef === 'object' &&
|
||||
dirRef !== null &&
|
||||
(dirRef as CanonicalDirectoryInput).sandboxPath
|
||||
? (dirRef as CanonicalDirectoryInput).sandboxPath!
|
||||
: alias
|
||||
? workflowAliasSandboxPath(alias.aliasPath)
|
||||
: `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}`
|
||||
const descendants = allFiles.filter((file) => {
|
||||
if (!file.folderPath) return false
|
||||
return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`)
|
||||
})
|
||||
if (descendants.length > MAX_MOUNTED_FILES) {
|
||||
throw new Error(
|
||||
`Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.`
|
||||
)
|
||||
}
|
||||
logger.info('Mounting workspace directory for function_execute', {
|
||||
vfsPath: dirPath,
|
||||
sandboxPath: mountRoot,
|
||||
fileCount: descendants.length,
|
||||
})
|
||||
const childFolders = folders.filter(
|
||||
(candidate) =>
|
||||
candidate.path !== folder.path && candidate.path.startsWith(`${folder.path}/`)
|
||||
)
|
||||
if (descendants.length === 0 && childFolders.length === 0) {
|
||||
sandboxFiles.push({ path: `${mountRoot}/.keep`, content: '' })
|
||||
continue
|
||||
}
|
||||
for (const childFolder of childFolders) {
|
||||
const hasFiles = descendants.some((file) => {
|
||||
if (!file.folderPath) return false
|
||||
return (
|
||||
file.folderPath === childFolder.path ||
|
||||
file.folderPath.startsWith(`${childFolder.path}/`)
|
||||
)
|
||||
})
|
||||
if (!hasFiles) {
|
||||
const relativeFolder = childFolder.path.slice(folder.path.length).replace(/^\/+/, '')
|
||||
sandboxFiles.push({ path: `${mountRoot}/${relativeFolder}/.keep`, content: '' })
|
||||
}
|
||||
}
|
||||
for (const record of descendants) {
|
||||
const relativeFolder =
|
||||
record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? ''
|
||||
const relativePath = alias
|
||||
? encodeVfsPathSegments(
|
||||
[relativeFolder, record.name].filter(Boolean).join('/').split('/')
|
||||
)
|
||||
: [relativeFolder, record.name].filter(Boolean).join('/')
|
||||
await pushWorkspaceFileMount(sandboxFiles, record, `${mountRoot}/${relativePath}`, mounted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputTables?.length) {
|
||||
const hasTablePathRefs = inputTables.some((tableRef) => {
|
||||
const tableId =
|
||||
typeof tableRef === 'string'
|
||||
? tableRef
|
||||
: tableRef && typeof tableRef === 'object'
|
||||
? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path
|
||||
: undefined
|
||||
return typeof tableId === 'string' && tableId.startsWith('tables/')
|
||||
})
|
||||
const tablePathLookup = hasTablePathRefs
|
||||
? new Map((await listTables(workspaceId)).map((table) => [table.name, table]))
|
||||
: undefined
|
||||
const snapshotCacheEnabled = await isFeatureEnabled('table-snapshot-cache')
|
||||
for (const tableRef of inputTables) {
|
||||
const tableId =
|
||||
typeof tableRef === 'string'
|
||||
? tableRef
|
||||
: tableRef && typeof tableRef === 'object'
|
||||
? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path
|
||||
: undefined
|
||||
if (!tableId) continue
|
||||
const table = await resolveTableRef(tableId, tablePathLookup)
|
||||
if (!table || table.workspaceId !== workspaceId) {
|
||||
throw new Error(
|
||||
`Input table not found: "${tableId}". Pass the table id (tbl_...) from tables/{name}/meta.json, or a tables/{name}/meta.json path.`
|
||||
)
|
||||
}
|
||||
const sandboxPath =
|
||||
typeof tableRef === 'object' && tableRef !== null
|
||||
? (tableRef as CanonicalTableInput).sandboxPath
|
||||
: undefined
|
||||
const mountPath = sandboxPath || `/home/user/tables/${table.id}.csv`
|
||||
|
||||
// Large/hot tables mount by reference from a version-keyed CSV snapshot in object storage.
|
||||
if (snapshotCacheEnabled && table.rowCount >= SNAPSHOT_MIN_ROWS) {
|
||||
const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec')
|
||||
|
||||
if (hasCloudStorage()) {
|
||||
// Mount by reference: the sandbox fetches the snapshot straight from storage via a
|
||||
// presigned URL, so the bytes never pass through the web process — the only ceiling is
|
||||
// sandbox disk (enforced at materialization by SNAPSHOT_MAX_BYTES).
|
||||
if (snapshot.size > SNAPSHOT_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${SNAPSHOT_MAX_BYTES / 1024 / 1024}MB table mount limit.`
|
||||
)
|
||||
}
|
||||
const url = await generatePresignedDownloadUrl(
|
||||
snapshot.key,
|
||||
'execution',
|
||||
MOUNT_URL_TTL_SECONDS
|
||||
)
|
||||
sandboxFiles.push({ type: 'url', path: mountPath, url })
|
||||
continue
|
||||
}
|
||||
|
||||
// Local storage: a presigned URL is an app-internal serve path a remote sandbox can't
|
||||
// reach, so fall back to buffering the bytes through the web process (file-mount guards).
|
||||
if (snapshot.size > MAX_FILE_SIZE) {
|
||||
throw new Error(
|
||||
`Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.`
|
||||
)
|
||||
}
|
||||
if (mounted.buffered + snapshot.size > MAX_TOTAL_SIZE) {
|
||||
throw new Error(
|
||||
`Mounting "${tableId}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller tables.`
|
||||
)
|
||||
}
|
||||
const buffer = await downloadFile({
|
||||
key: snapshot.key,
|
||||
context: 'execution',
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
})
|
||||
mounted.buffered += buffer.length
|
||||
sandboxFiles.push({ path: mountPath, content: buffer.toString('utf-8') })
|
||||
continue
|
||||
}
|
||||
|
||||
const rows = await queryRows(table, {}, 'copilot-fn-exec')
|
||||
|
||||
const columns = table.schema.columns
|
||||
const csvLines = [toCsvRow(columns.map((column) => neutralizeCsvFormula(column.name)))]
|
||||
for (const row of rows.rows) {
|
||||
csvLines.push(
|
||||
toCsvRow(columns.map((column) => formatCsvValue(row.data[getColumnId(column)])))
|
||||
)
|
||||
}
|
||||
const csvContent = csvLines.join('\n')
|
||||
sandboxFiles.push({ path: mountPath, content: csvContent })
|
||||
}
|
||||
}
|
||||
|
||||
return sandboxFiles
|
||||
}
|
||||
|
||||
export async function executeFunctionExecute(
|
||||
params: Record<string, unknown>,
|
||||
context: ToolExecutionContext
|
||||
): Promise<ToolExecutionResult> {
|
||||
const enrichedParams = { ...params }
|
||||
|
||||
if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) {
|
||||
enrichedParams.envVars = {
|
||||
...context.decryptedEnvVars,
|
||||
...((enrichedParams.envVars as Record<string, string>) || {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (context.workspaceId) {
|
||||
const inputs = enrichedParams.inputs as
|
||||
| {
|
||||
files?: CanonicalFileInput[]
|
||||
directories?: CanonicalDirectoryInput[]
|
||||
tables?: CanonicalTableInput[]
|
||||
}
|
||||
| undefined
|
||||
const inputFiles = [
|
||||
...((enrichedParams.inputFiles as unknown[] | undefined) ?? []),
|
||||
...(inputs?.files ?? []),
|
||||
]
|
||||
const inputDirectories = inputs?.directories ?? []
|
||||
const inputTables = [
|
||||
...((enrichedParams.inputTables as unknown[] | undefined) ?? []),
|
||||
...(inputs?.tables ?? []),
|
||||
]
|
||||
|
||||
if (inputFiles?.length || inputTables?.length || inputDirectories.length) {
|
||||
const resolved = await resolveInputFiles(
|
||||
context.workspaceId,
|
||||
inputFiles,
|
||||
inputTables,
|
||||
inputDirectories
|
||||
)
|
||||
if (resolved.length > 0) {
|
||||
const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || []
|
||||
enrichedParams._sandboxFiles = [...existing, ...resolved]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enrichedParams._context = {
|
||||
...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null
|
||||
? (enrichedParams._context as object)
|
||||
: {}),
|
||||
userId: context.userId,
|
||||
workflowId: context.workflowId,
|
||||
workspaceId: context.workspaceId,
|
||||
chatId: context.chatId,
|
||||
executionId: context.executionId,
|
||||
runId: context.runId,
|
||||
enforceCredentialAccess: true,
|
||||
}
|
||||
|
||||
return executeAppTool('function_execute', enrichedParams)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility'
|
||||
import {
|
||||
filterExposedIntegrationTools,
|
||||
getExposedIntegrationTools,
|
||||
} from '@/lib/copilot/integration-tools'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { stripVersionSuffix } from '@/tools/utils'
|
||||
|
||||
export async function executeListIntegrationTools(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const raw = typeof params.integration === 'string' ? params.integration.trim() : ''
|
||||
if (!raw) {
|
||||
return { success: false, error: "Missing required parameter 'integration'" }
|
||||
}
|
||||
|
||||
// The exposed set is the ungated universe — project it for this viewer so
|
||||
// gated (preview / kill-switched) integrations stay undiscoverable.
|
||||
const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId)
|
||||
const all = filterExposedIntegrationTools(getExposedIntegrationTools(), vis)
|
||||
const service = stripVersionSuffix(raw.toLowerCase())
|
||||
const matches = all.filter((tool) => tool.service === service)
|
||||
|
||||
if (matches.length === 0) {
|
||||
const services = Array.from(new Set(all.map((tool) => tool.service))).sort()
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown integration "${raw}". Available integrations: ${services.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
integration: service,
|
||||
note: 'Call load_integration_tool({tool_ids: ["<id>"]}) with the exact id before invoking an operation.',
|
||||
tools: matches.map((tool) => ({
|
||||
id: tool.toolId,
|
||||
operation: tool.operation,
|
||||
name: tool.config.name,
|
||||
description: tool.config.description,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats, workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import {
|
||||
performCompleteJob,
|
||||
performCreateJob,
|
||||
performDeleteJob,
|
||||
performUpdateJob,
|
||||
} from '@/lib/workflows/schedules/orchestration'
|
||||
|
||||
const logger = createLogger('JobTools')
|
||||
|
||||
const ACTIVE_JOB_CONDITION = (workspaceId: string) =>
|
||||
and(
|
||||
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
|
||||
eq(workflowSchedule.sourceType, 'job'),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
|
||||
const JobLifecycleSchema = z.enum(['persistent', 'until_complete'])
|
||||
|
||||
const CreateJobParamsSchema = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
cron: z.string().optional(),
|
||||
time: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
lifecycle: JobLifecycleSchema.optional(),
|
||||
successCondition: z.string().optional(),
|
||||
maxRuns: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const ManageJobArgsSchema = z
|
||||
.object({
|
||||
jobId: z.string().optional(),
|
||||
jobIds: z.array(z.string()).optional(),
|
||||
title: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
cron: z.string().optional(),
|
||||
time: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
lifecycle: z.string().optional(),
|
||||
successCondition: z.string().optional(),
|
||||
maxRuns: z.number().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const ManageJobParamsSchema = z
|
||||
.object({
|
||||
operation: z.string().optional(),
|
||||
args: ManageJobArgsSchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
type CreateJobParams = z.infer<typeof CreateJobParamsSchema>
|
||||
type ManageJobParams = z.infer<typeof ManageJobParamsSchema>
|
||||
|
||||
export async function executeCreateJob(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const parsedParams = CreateJobParamsSchema.safeParse(params)
|
||||
if (!parsedParams.success) {
|
||||
return { success: false, error: 'Invalid create job parameters' }
|
||||
}
|
||||
|
||||
const rawParams: CreateJobParams = parsedParams.data
|
||||
const timezone = rawParams.timezone || context.userTimezone || 'UTC'
|
||||
const { title, prompt, cron, time, lifecycle, successCondition, maxRuns } = rawParams
|
||||
|
||||
if (!prompt) {
|
||||
return { success: false, error: 'prompt is required' }
|
||||
}
|
||||
|
||||
if (!cron && !time) {
|
||||
return { success: false, error: 'At least one of cron or time must be provided' }
|
||||
}
|
||||
|
||||
if (!context.userId || !context.workspaceId) {
|
||||
return { success: false, error: 'Missing user or workspace context' }
|
||||
}
|
||||
|
||||
let taskName: string | null = null
|
||||
if (context.chatId) {
|
||||
try {
|
||||
const [chat] = await db
|
||||
.select({ title: copilotChats.title })
|
||||
.from(copilotChats)
|
||||
.where(eq(copilotChats.id, context.chatId))
|
||||
.limit(1)
|
||||
taskName = chat?.title || null
|
||||
} catch (err) {
|
||||
logger.warn('Failed to look up chat title for job', {
|
||||
chatId: context.chatId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performCreateJob({
|
||||
workspaceId: context.workspaceId,
|
||||
userId: context.userId,
|
||||
title,
|
||||
prompt,
|
||||
cronExpression: cron,
|
||||
time,
|
||||
timezone,
|
||||
lifecycle,
|
||||
successCondition,
|
||||
maxRuns,
|
||||
sourceChatId: context.chatId,
|
||||
sourceTaskName: taskName,
|
||||
})
|
||||
if (!result.success || !result.schedule) {
|
||||
return { success: false, error: result.error || 'Failed to create job' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
jobId: result.schedule.id,
|
||||
title: result.schedule.jobTitle,
|
||||
schedule: result.humanReadable,
|
||||
nextRunAt: result.schedule.nextRunAt?.toISOString(),
|
||||
message: `Job created successfully. ${result.humanReadable}`,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to create job', {
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: 'Failed to create job' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeManageJob(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const parsedParams = ManageJobParamsSchema.safeParse(params)
|
||||
if (!parsedParams.success) {
|
||||
return { success: false, error: 'Invalid manage job parameters' }
|
||||
}
|
||||
|
||||
const rawParams: ManageJobParams = parsedParams.data
|
||||
const { operation, args } = rawParams
|
||||
|
||||
if (!context.userId || !context.workspaceId) {
|
||||
return { success: false, error: 'Missing user or workspace context' }
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'create': {
|
||||
return executeCreateJob(
|
||||
{
|
||||
title: args?.title,
|
||||
prompt: args?.prompt,
|
||||
cron: args?.cron,
|
||||
time: args?.time,
|
||||
timezone: args?.timezone,
|
||||
lifecycle: args?.lifecycle,
|
||||
successCondition: args?.successCondition,
|
||||
maxRuns: args?.maxRuns,
|
||||
} as Record<string, unknown>,
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
try {
|
||||
const jobs = await db
|
||||
.select({
|
||||
id: workflowSchedule.id,
|
||||
jobTitle: workflowSchedule.jobTitle,
|
||||
prompt: workflowSchedule.prompt,
|
||||
cronExpression: workflowSchedule.cronExpression,
|
||||
timezone: workflowSchedule.timezone,
|
||||
status: workflowSchedule.status,
|
||||
lifecycle: workflowSchedule.lifecycle,
|
||||
successCondition: workflowSchedule.successCondition,
|
||||
maxRuns: workflowSchedule.maxRuns,
|
||||
runCount: workflowSchedule.runCount,
|
||||
nextRunAt: workflowSchedule.nextRunAt,
|
||||
lastRanAt: workflowSchedule.lastRanAt,
|
||||
sourceTaskName: workflowSchedule.sourceTaskName,
|
||||
createdAt: workflowSchedule.createdAt,
|
||||
})
|
||||
.from(workflowSchedule)
|
||||
.where(ACTIVE_JOB_CONDITION(context.workspaceId))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
jobs: jobs.map((j) => ({
|
||||
id: j.id,
|
||||
title: j.jobTitle,
|
||||
prompt: j.prompt,
|
||||
cronExpression: j.cronExpression,
|
||||
timezone: j.timezone,
|
||||
status: j.status,
|
||||
lifecycle: j.lifecycle,
|
||||
successCondition: j.successCondition,
|
||||
maxRuns: j.maxRuns,
|
||||
runCount: j.runCount,
|
||||
nextRunAt: j.nextRunAt?.toISOString(),
|
||||
lastRanAt: j.lastRanAt?.toISOString(),
|
||||
sourceTaskName: j.sourceTaskName,
|
||||
createdAt: j.createdAt.toISOString(),
|
||||
})),
|
||||
count: jobs.length,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to list jobs', {
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: 'Failed to list jobs' }
|
||||
}
|
||||
}
|
||||
|
||||
case 'get': {
|
||||
if (!args?.jobId) {
|
||||
return { success: false, error: 'jobId is required for get operation' }
|
||||
}
|
||||
|
||||
try {
|
||||
const [job] = await db
|
||||
.select()
|
||||
.from(workflowSchedule)
|
||||
.where(
|
||||
and(eq(workflowSchedule.id, args.jobId), ACTIVE_JOB_CONDITION(context.workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!job) {
|
||||
return { success: false, error: `Job not found: ${args.jobId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: job.id,
|
||||
title: job.jobTitle,
|
||||
prompt: job.prompt,
|
||||
cronExpression: job.cronExpression,
|
||||
timezone: job.timezone,
|
||||
status: job.status,
|
||||
lifecycle: job.lifecycle,
|
||||
successCondition: job.successCondition,
|
||||
maxRuns: job.maxRuns,
|
||||
runCount: job.runCount,
|
||||
nextRunAt: job.nextRunAt?.toISOString(),
|
||||
lastRanAt: job.lastRanAt?.toISOString(),
|
||||
sourceTaskName: job.sourceTaskName,
|
||||
sourceChatId: job.sourceChatId,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to get job', {
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: 'Failed to get job' }
|
||||
}
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
if (!args?.jobId) {
|
||||
return { success: false, error: 'jobId is required for update operation' }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await performUpdateJob({
|
||||
jobId: args.jobId,
|
||||
workspaceId: context.workspaceId,
|
||||
userId: context.userId,
|
||||
title: args.title,
|
||||
prompt: args.prompt,
|
||||
cronExpression: args.cron,
|
||||
time: args.time,
|
||||
timezone: args.timezone,
|
||||
status: args.status,
|
||||
lifecycle: args.lifecycle,
|
||||
successCondition: args.successCondition,
|
||||
maxRuns: args.maxRuns,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to update job' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
jobId: args.jobId,
|
||||
updated: result.updatedFields || [],
|
||||
message: 'Job updated successfully',
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to update job', {
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: 'Failed to update job' }
|
||||
}
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const jobIds = args?.jobIds ?? (args?.jobId ? [args.jobId] : [])
|
||||
if (jobIds.length === 0) {
|
||||
return { success: false, error: 'jobId or jobIds is required for delete operation' }
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted: string[] = []
|
||||
const notFound: string[] = []
|
||||
|
||||
for (const jobId of jobIds) {
|
||||
const result = await performDeleteJob({
|
||||
jobId,
|
||||
workspaceId: context.workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
if (!result.success) {
|
||||
notFound.push(jobId)
|
||||
continue
|
||||
}
|
||||
deleted.push(jobId)
|
||||
}
|
||||
|
||||
return {
|
||||
success: deleted.length > 0,
|
||||
output: { deleted, notFound },
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete job', {
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: 'Failed to delete job' }
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, error: `Unknown operation: ${operation}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCompleteJob(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const { jobId } = params as { jobId?: string }
|
||||
|
||||
if (!jobId) {
|
||||
return { success: false, error: 'jobId is required' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!context.workspaceId) {
|
||||
return { success: false, error: 'Missing workspace context' }
|
||||
}
|
||||
|
||||
const result = await performCompleteJob({
|
||||
jobId,
|
||||
workspaceId: context.workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to complete job' }
|
||||
}
|
||||
if (result.alreadyCompleted) {
|
||||
return {
|
||||
success: true,
|
||||
output: { jobId, message: 'Job is already completed' },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: { jobId, message: 'Job marked as completed. No further executions will occur.' },
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to complete job', {
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: 'Failed to complete job' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeUpdateJobHistory(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const { jobId, summary } = params as { jobId?: string; summary?: string }
|
||||
|
||||
if (!jobId || !summary) {
|
||||
return { success: false, error: 'jobId and summary are required' }
|
||||
}
|
||||
|
||||
if (!context.workspaceId) {
|
||||
return { success: false, error: 'Missing workspace context' }
|
||||
}
|
||||
|
||||
try {
|
||||
const [job] = await db
|
||||
.select({
|
||||
id: workflowSchedule.id,
|
||||
jobHistory: workflowSchedule.jobHistory,
|
||||
})
|
||||
.from(workflowSchedule)
|
||||
.where(and(eq(workflowSchedule.id, jobId), ACTIVE_JOB_CONDITION(context.workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
if (!job) {
|
||||
return { success: false, error: `Job not found: ${jobId}` }
|
||||
}
|
||||
|
||||
const existing = (job.jobHistory || []) as Array<{ timestamp: string; summary: string }>
|
||||
const updated = [...existing, { timestamp: new Date().toISOString(), summary }].slice(-50)
|
||||
|
||||
await db
|
||||
.update(workflowSchedule)
|
||||
.set({ jobHistory: updated, updatedAt: new Date() })
|
||||
.where(and(eq(workflowSchedule.id, jobId), isNull(workflowSchedule.archivedAt)))
|
||||
|
||||
logger.info('Job history updated', { jobId, entryCount: updated.length })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: { jobId, entryCount: updated.length, message: 'History entry recorded.' },
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to update job history', {
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: 'Failed to update job history' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration'
|
||||
|
||||
export function executeManageCredential(
|
||||
rawParams: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const params = rawParams as {
|
||||
operation: string
|
||||
credentialId?: string
|
||||
credentialIds?: string[]
|
||||
displayName?: string
|
||||
}
|
||||
const { operation, displayName } = params
|
||||
return (async () => {
|
||||
try {
|
||||
if (!context?.userId) {
|
||||
return { success: false, error: 'Authentication required' }
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'rename': {
|
||||
const credentialId = params.credentialId
|
||||
if (!credentialId) return { success: false, error: 'credentialId is required for rename' }
|
||||
if (!displayName) return { success: false, error: 'displayName is required for rename' }
|
||||
|
||||
const result = await performUpdateCredential({
|
||||
credentialId,
|
||||
userId: context.userId,
|
||||
displayName,
|
||||
allowedTypes: ['oauth'],
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to rename credential' }
|
||||
}
|
||||
return { success: true, output: { credentialId, displayName } }
|
||||
}
|
||||
case 'delete': {
|
||||
const ids: string[] =
|
||||
params.credentialIds ?? (params.credentialId ? [params.credentialId] : [])
|
||||
if (ids.length === 0)
|
||||
return { success: false, error: 'credentialId or credentialIds is required for delete' }
|
||||
|
||||
const deleted: string[] = []
|
||||
const failed: string[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
const result = await performDeleteCredential({
|
||||
credentialId: id,
|
||||
userId: context.userId,
|
||||
allowedTypes: ['oauth'],
|
||||
reason: 'copilot_delete',
|
||||
})
|
||||
if (!result.success) {
|
||||
failed.push(id)
|
||||
continue
|
||||
}
|
||||
deleted.push(id)
|
||||
}
|
||||
|
||||
return {
|
||||
success: deleted.length > 0,
|
||||
output: { deleted, failed },
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown operation: ${operation}. Use "rename" or "delete".`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
deleteCustomTool,
|
||||
getCustomToolById,
|
||||
listCustomTools,
|
||||
upsertCustomTools,
|
||||
} from '@/lib/workflows/custom-tools/operations'
|
||||
|
||||
const logger = createLogger('CopilotToolExecutor')
|
||||
|
||||
type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list'
|
||||
|
||||
interface ManageCustomToolSchema {
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
interface ManageCustomToolParams {
|
||||
operation?: string
|
||||
toolId?: string
|
||||
toolIds?: string[]
|
||||
schema?: ManageCustomToolSchema
|
||||
code?: string
|
||||
title?: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export async function executeManageCustomTool(
|
||||
rawParams: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const params = rawParams as ManageCustomToolParams
|
||||
const operation = String(params.operation || '').toLowerCase() as ManageCustomToolOperation
|
||||
const workspaceId = params.workspaceId || context.workspaceId
|
||||
|
||||
if (!operation) {
|
||||
return { success: false, error: "Missing required 'operation' argument" }
|
||||
}
|
||||
|
||||
const writeOps: string[] = ['add', 'edit', 'delete']
|
||||
if (
|
||||
writeOps.includes(operation) &&
|
||||
context.userPermission &&
|
||||
context.userPermission !== 'write' &&
|
||||
context.userPermission !== 'admin'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Permission denied: '${operation}' on manage_custom_tool requires write access. You have '${context.userPermission}' permission.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (operation === 'list') {
|
||||
const toolsForUser = await listCustomTools({
|
||||
userId: context.userId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
tools: toolsForUser,
|
||||
count: toolsForUser.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'add') {
|
||||
if (!workspaceId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "workspaceId is required for operation 'add'",
|
||||
}
|
||||
}
|
||||
if (!params.schema || !params.code) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Both 'schema' and 'code' are required for operation 'add'",
|
||||
}
|
||||
}
|
||||
|
||||
const title = params.title || params.schema.function?.name
|
||||
if (!title) {
|
||||
return { success: false, error: "Missing tool title or schema.function.name for 'add'" }
|
||||
}
|
||||
|
||||
const resultTools = await upsertCustomTools({
|
||||
tools: [{ title, schema: params.schema, code: params.code }],
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
const created = resultTools.find((tool) => tool.title === title)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: context.userId,
|
||||
action: AuditAction.CUSTOM_TOOL_CREATED,
|
||||
resourceType: AuditResourceType.CUSTOM_TOOL,
|
||||
resourceId: created?.id,
|
||||
resourceName: title,
|
||||
description: `Created custom tool "${title}"`,
|
||||
metadata: { source: 'tool_input' },
|
||||
})
|
||||
if (created?.id) {
|
||||
captureServerEvent(
|
||||
context.userId,
|
||||
'custom_tool_saved',
|
||||
{
|
||||
tool_id: created.id,
|
||||
workspace_id: workspaceId,
|
||||
tool_name: title,
|
||||
source: 'tool_input',
|
||||
},
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
toolId: created?.id,
|
||||
title,
|
||||
message: `Created custom tool "${title}"`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'edit') {
|
||||
if (!workspaceId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "workspaceId is required for operation 'edit'",
|
||||
}
|
||||
}
|
||||
if (!params.toolId) {
|
||||
return { success: false, error: "'toolId' is required for operation 'edit'" }
|
||||
}
|
||||
if (!params.schema && !params.code) {
|
||||
return {
|
||||
success: false,
|
||||
error: "At least one of 'schema' or 'code' is required for operation 'edit'",
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await getCustomToolById({
|
||||
toolId: params.toolId,
|
||||
userId: context.userId,
|
||||
workspaceId,
|
||||
})
|
||||
if (!existing) {
|
||||
return { success: false, error: `Custom tool not found: ${params.toolId}` }
|
||||
}
|
||||
|
||||
const mergedSchema = params.schema || (existing.schema as ManageCustomToolSchema)
|
||||
const mergedCode = params.code || existing.code
|
||||
const title = params.title || mergedSchema.function?.name || existing.title
|
||||
|
||||
await upsertCustomTools({
|
||||
tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }],
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: context.userId,
|
||||
action: AuditAction.CUSTOM_TOOL_UPDATED,
|
||||
resourceType: AuditResourceType.CUSTOM_TOOL,
|
||||
resourceId: params.toolId,
|
||||
resourceName: title,
|
||||
description: `Updated custom tool "${title}"`,
|
||||
metadata: { source: 'tool_input' },
|
||||
})
|
||||
captureServerEvent(
|
||||
context.userId,
|
||||
'custom_tool_saved',
|
||||
{
|
||||
tool_id: params.toolId,
|
||||
workspace_id: workspaceId,
|
||||
tool_name: title,
|
||||
source: 'tool_input',
|
||||
},
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
toolId: params.toolId,
|
||||
title,
|
||||
message: `Updated custom tool "${title}"`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
const toolIds: string[] = params.toolIds ?? (params.toolId ? [params.toolId] : [])
|
||||
if (toolIds.length === 0) {
|
||||
return { success: false, error: "'toolId' or 'toolIds' is required for operation 'delete'" }
|
||||
}
|
||||
|
||||
const deleted: string[] = []
|
||||
const notFound: string[] = []
|
||||
|
||||
for (const toolId of toolIds) {
|
||||
const result = await deleteCustomTool({
|
||||
toolId,
|
||||
userId: context.userId,
|
||||
workspaceId,
|
||||
})
|
||||
if (result) {
|
||||
deleted.push(toolId)
|
||||
} else {
|
||||
notFound.push(toolId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const toolId of deleted) {
|
||||
recordAudit({
|
||||
workspaceId: workspaceId ?? null,
|
||||
actorId: context.userId,
|
||||
action: AuditAction.CUSTOM_TOOL_DELETED,
|
||||
resourceType: AuditResourceType.CUSTOM_TOOL,
|
||||
resourceId: toolId,
|
||||
description: 'Deleted custom tool',
|
||||
metadata: { source: 'tool_input' },
|
||||
})
|
||||
if (workspaceId) {
|
||||
captureServerEvent(
|
||||
context.userId,
|
||||
'custom_tool_deleted',
|
||||
{ tool_id: toolId, workspace_id: workspaceId, source: 'tool_input' },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: deleted.length > 0,
|
||||
output: {
|
||||
success: deleted.length > 0,
|
||||
operation,
|
||||
deleted,
|
||||
notFound,
|
||||
message: `Deleted ${deleted.length} custom tool(s)`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Unsupported operation for manage_custom_tool: ${operation}`,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
context.messageId
|
||||
? `manage_custom_tool execution failed [messageId:${context.messageId}]`
|
||||
: 'manage_custom_tool execution failed',
|
||||
{
|
||||
operation,
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
error: toError(error).message,
|
||||
}
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to manage custom tool'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import {
|
||||
performCreateMcpServer,
|
||||
performDeleteMcpServer,
|
||||
performUpdateMcpServer,
|
||||
} from '@/lib/mcp/orchestration'
|
||||
|
||||
const logger = createLogger('CopilotToolExecutor')
|
||||
|
||||
type ManageMcpToolOperation = 'add' | 'edit' | 'delete' | 'list'
|
||||
|
||||
interface ManageMcpToolConfig {
|
||||
name?: string
|
||||
transport?: string
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface ManageMcpToolParams {
|
||||
operation?: string
|
||||
serverId?: string
|
||||
config?: ManageMcpToolConfig
|
||||
}
|
||||
|
||||
export async function executeManageMcpTool(
|
||||
rawParams: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const params = rawParams as ManageMcpToolParams
|
||||
const operation = String(params.operation || '').toLowerCase() as ManageMcpToolOperation
|
||||
const workspaceId = context.workspaceId
|
||||
|
||||
if (!operation) {
|
||||
return { success: false, error: "Missing required 'operation' argument" }
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
|
||||
const writeOps: string[] = ['add', 'edit', 'delete']
|
||||
if (
|
||||
writeOps.includes(operation) &&
|
||||
context.userPermission &&
|
||||
context.userPermission !== 'write' &&
|
||||
context.userPermission !== 'admin'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Permission denied: '${operation}' on manage_mcp_tool requires write access. You have '${context.userPermission}' permission.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (operation === 'list') {
|
||||
const servers = await db
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
servers: servers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
url: s.url,
|
||||
transport: s.transport,
|
||||
enabled: s.enabled,
|
||||
connectionStatus: s.connectionStatus,
|
||||
})),
|
||||
count: servers.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'add') {
|
||||
const config = params.config
|
||||
if (!config?.name || !config?.url) {
|
||||
return { success: false, error: "config.name and config.url are required for 'add'" }
|
||||
}
|
||||
|
||||
const result = await performCreateMcpServer({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
name: config.name,
|
||||
description: '',
|
||||
transport: config.transport || 'streamable-http',
|
||||
url: config.url,
|
||||
headers: config.headers,
|
||||
timeout: config.timeout,
|
||||
retries: 3,
|
||||
enabled: config.enabled,
|
||||
source: 'tool_input',
|
||||
})
|
||||
if (!result.success || !result.serverId) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || `Failed to add MCP server "${config.name}"`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
serverId: result.serverId,
|
||||
name: config.name,
|
||||
message: result.updated
|
||||
? `Updated existing MCP server "${config.name}"`
|
||||
: `Added MCP server "${config.name}"`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'edit') {
|
||||
if (!params.serverId) {
|
||||
return { success: false, error: "'serverId' is required for 'edit'" }
|
||||
}
|
||||
const config = params.config
|
||||
if (!config) {
|
||||
return { success: false, error: "'config' is required for 'edit'" }
|
||||
}
|
||||
|
||||
const result = await performUpdateMcpServer({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
serverId: params.serverId,
|
||||
name: config.name,
|
||||
transport: config.transport,
|
||||
url: config.url,
|
||||
headers: config.headers,
|
||||
timeout: config.timeout,
|
||||
enabled: config.enabled,
|
||||
})
|
||||
if (!result.success || !result.server) {
|
||||
return { success: false, error: `MCP server not found: ${params.serverId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
serverId: params.serverId,
|
||||
name: result.server.name,
|
||||
message: `Updated MCP server "${result.server.name}"`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
if (!params.serverId) {
|
||||
return { success: false, error: "'serverId' is required for 'delete'" }
|
||||
}
|
||||
|
||||
const result = await performDeleteMcpServer({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
serverId: params.serverId,
|
||||
source: 'tool_input',
|
||||
})
|
||||
if (!result.success || !result.server) {
|
||||
return { success: false, error: `MCP server not found: ${params.serverId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
serverId: params.serverId,
|
||||
message: `Deleted MCP server "${result.server.name}"`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: `Unsupported operation for manage_mcp_tool: ${operation}` }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
context.messageId
|
||||
? `manage_mcp_tool execution failed [messageId:${context.messageId}]`
|
||||
: 'manage_mcp_tool execution failed',
|
||||
{
|
||||
operation,
|
||||
workspaceId,
|
||||
error: toError(error).message,
|
||||
}
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to manage MCP server'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations'
|
||||
|
||||
const logger = createLogger('CopilotToolExecutor')
|
||||
|
||||
type ManageSkillOperation = 'add' | 'edit' | 'delete' | 'list'
|
||||
|
||||
interface ManageSkillParams {
|
||||
operation?: string
|
||||
skillId?: string
|
||||
name?: string
|
||||
description?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export async function executeManageSkill(
|
||||
rawParams: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const params = rawParams as ManageSkillParams
|
||||
const operation = String(params.operation || '').toLowerCase() as ManageSkillOperation
|
||||
const workspaceId = context.workspaceId
|
||||
|
||||
if (!operation) {
|
||||
return { success: false, error: "Missing required 'operation' argument" }
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
|
||||
const writeOps: string[] = ['add', 'edit', 'delete']
|
||||
if (
|
||||
writeOps.includes(operation) &&
|
||||
context.userPermission &&
|
||||
context.userPermission !== 'write' &&
|
||||
context.userPermission !== 'admin'
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Permission denied: '${operation}' on manage_skill requires write access. You have '${context.userPermission}' permission.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (operation === 'list') {
|
||||
const skills = await listSkills({ workspaceId })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
skills: skills.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
count: skills.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'add') {
|
||||
if (!params.name || !params.description || !params.content) {
|
||||
return {
|
||||
success: false,
|
||||
error: "'name', 'description', and 'content' are required for 'add'",
|
||||
}
|
||||
}
|
||||
|
||||
const { skills: resultSkills } = await upsertSkills({
|
||||
skills: [{ name: params.name, description: params.description, content: params.content }],
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
const created = resultSkills.find((s) => s.name === params.name)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: context.userId,
|
||||
action: AuditAction.SKILL_CREATED,
|
||||
resourceType: AuditResourceType.SKILL,
|
||||
resourceId: created?.id,
|
||||
resourceName: params.name,
|
||||
description: `Created skill "${params.name}"`,
|
||||
metadata: { source: 'tool_input' },
|
||||
})
|
||||
if (created?.id) {
|
||||
captureServerEvent(
|
||||
context.userId,
|
||||
'skill_created',
|
||||
{
|
||||
skill_id: created.id,
|
||||
skill_name: params.name,
|
||||
workspace_id: workspaceId,
|
||||
source: 'tool_input',
|
||||
},
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
skillId: created?.id,
|
||||
name: params.name,
|
||||
message: `Created skill "${params.name}"`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'edit') {
|
||||
if (!params.skillId) {
|
||||
return { success: false, error: "'skillId' is required for 'edit'" }
|
||||
}
|
||||
if (!params.name && !params.description && !params.content) {
|
||||
return {
|
||||
success: false,
|
||||
error: "At least one of 'name', 'description', or 'content' is required for 'edit'",
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await listSkills({ workspaceId })
|
||||
const found = existing.find((s) => s.id === params.skillId)
|
||||
if (!found) {
|
||||
return { success: false, error: `Skill not found: ${params.skillId}` }
|
||||
}
|
||||
|
||||
await upsertSkills({
|
||||
skills: [
|
||||
{
|
||||
id: params.skillId,
|
||||
name: params.name || found.name,
|
||||
description: params.description || found.description,
|
||||
content: params.content || found.content,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
const updatedName = params.name || found.name
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: context.userId,
|
||||
action: AuditAction.SKILL_UPDATED,
|
||||
resourceType: AuditResourceType.SKILL,
|
||||
resourceId: params.skillId,
|
||||
resourceName: updatedName,
|
||||
description: `Updated skill "${updatedName}"`,
|
||||
metadata: { source: 'tool_input' },
|
||||
})
|
||||
captureServerEvent(
|
||||
context.userId,
|
||||
'skill_updated',
|
||||
{
|
||||
skill_id: params.skillId,
|
||||
skill_name: updatedName,
|
||||
workspace_id: workspaceId,
|
||||
source: 'tool_input',
|
||||
},
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
skillId: params.skillId,
|
||||
name: params.name || found.name,
|
||||
message: `Updated skill "${params.name || found.name}"`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
if (!params.skillId) {
|
||||
return { success: false, error: "'skillId' is required for 'delete'" }
|
||||
}
|
||||
|
||||
const deleted = await deleteSkill({ skillId: params.skillId, workspaceId })
|
||||
if (!deleted) {
|
||||
return { success: false, error: `Skill not found: ${params.skillId}` }
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: context.userId,
|
||||
action: AuditAction.SKILL_DELETED,
|
||||
resourceType: AuditResourceType.SKILL,
|
||||
resourceId: params.skillId,
|
||||
description: 'Deleted skill',
|
||||
metadata: { source: 'tool_input' },
|
||||
})
|
||||
captureServerEvent(
|
||||
context.userId,
|
||||
'skill_deleted',
|
||||
{ skill_id: params.skillId, workspace_id: workspaceId, source: 'tool_input' },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
operation,
|
||||
skillId: params.skillId,
|
||||
message: 'Deleted skill',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: `Unsupported operation for manage_skill: ${operation}` }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
context.messageId
|
||||
? `manage_skill execution failed [messageId:${context.messageId}]`
|
||||
: 'manage_skill execution failed',
|
||||
{
|
||||
operation,
|
||||
workspaceId,
|
||||
error: toError(error).message,
|
||||
}
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Failed to manage skill'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockFindUpload } = vi.hoisted(() => ({
|
||||
mockFindUpload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({
|
||||
findMothershipUploadRowByChatAndName: mockFindUpload,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
getServePathPrefix: () => '/api/files/serve/',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
fetchWorkspaceFileBuffer: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/vfs/path-utils', () => ({
|
||||
canonicalWorkspaceFilePath: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/operations/import-export', () => ({ parseWorkflowJson: vi.fn() }))
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({ saveWorkflowToNormalizedTables: vi.fn() }))
|
||||
vi.mock('@/lib/workflows/utils', () => ({ deduplicateWorkflowName: vi.fn() }))
|
||||
vi.mock('@/app/api/v1/admin/types', () => ({ extractWorkflowMetadata: vi.fn() }))
|
||||
|
||||
import type { ExecutionContext } from '@/lib/copilot/request/types'
|
||||
import { executeMaterializeFile } from '@/lib/copilot/tools/handlers/materialize-file'
|
||||
|
||||
const context = {
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
workflowId: 'wf-1',
|
||||
} as ExecutionContext
|
||||
|
||||
describe('executeMaterializeFile - unsupported operation', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('rejects the table operation and points to the table subagent', async () => {
|
||||
const result = await executeMaterializeFile(
|
||||
{ fileNames: ['data.csv'], operation: 'table' },
|
||||
context
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Unsupported materialize_file operation "table"')
|
||||
expect(result.error).toContain('table subagent')
|
||||
expect(mockFindUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects the knowledge_base operation and points to the knowledge subagent', async () => {
|
||||
const result = await executeMaterializeFile(
|
||||
{ fileNames: ['data.csv'], operation: 'knowledge_base' },
|
||||
context
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Unsupported materialize_file operation "knowledge_base"')
|
||||
expect(result.error).toContain('knowledge subagent')
|
||||
expect(mockFindUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader'
|
||||
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
|
||||
import { getServePathPrefix } from '@/lib/uploads'
|
||||
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('MaterializeFile')
|
||||
|
||||
function toFileRecord(row: typeof workspaceFiles.$inferSelect) {
|
||||
const pathPrefix = getServePathPrefix()
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId || '',
|
||||
name: row.displayName ?? row.originalName,
|
||||
key: row.key,
|
||||
path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`,
|
||||
size: row.size,
|
||||
type: row.contentType,
|
||||
uploadedBy: row.userId,
|
||||
deletedAt: row.deletedAt,
|
||||
uploadedAt: row.uploadedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
storageContext: 'mothership' as const,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSave(fileName: string, chatId: string): Promise<ToolCallResult> {
|
||||
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
|
||||
if (!row) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(workspaceFiles)
|
||||
.set({ context: 'workspace', chatId: null, originalName: row.displayName ?? row.originalName })
|
||||
.where(and(eq(workspaceFiles.id, row.id), isNull(workspaceFiles.deletedAt)))
|
||||
.returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName })
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Materialized file', { fileName, fileId: updated.id, chatId })
|
||||
|
||||
// Canonical, per-segment-encoded path — matches how the workspace VFS serves
|
||||
// the file (files/<encoded>), rather than echoing the raw display name.
|
||||
const canonicalPath = canonicalWorkspaceFilePath({
|
||||
folderPath: null,
|
||||
name: updated.originalName,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `File "${updated.originalName}" materialized. It is now available at ${canonicalPath} and will persist independently of this chat.`,
|
||||
fileId: updated.id,
|
||||
path: canonicalPath,
|
||||
},
|
||||
resources: [{ type: 'file', id: updated.id, title: updated.originalName }],
|
||||
}
|
||||
}
|
||||
|
||||
async function executeImport(
|
||||
fileName: string,
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<ToolCallResult> {
|
||||
const row = await findMothershipUploadRowByChatAndName(chatId, fileName)
|
||||
if (!row) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`,
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = await fetchWorkspaceFileBuffer(toFileRecord(row))
|
||||
const content = buffer.toString('utf-8')
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
return { success: false, error: `"${fileName}" is not valid JSON.` }
|
||||
}
|
||||
|
||||
const { data: workflowData, errors } = parseWorkflowJson(content)
|
||||
if (!workflowData || errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid workflow JSON: ${errors.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
const { name: rawName, description: workflowDescription } = extractWorkflowMetadata(parsed)
|
||||
|
||||
const workflowId = generateId()
|
||||
const now = new Date()
|
||||
const dedupedName = await deduplicateWorkflowName(rawName, workspaceId, null)
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId,
|
||||
workspaceId,
|
||||
folderId: null,
|
||||
name: dedupedName,
|
||||
description: workflowDescription,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
|
||||
if (!saveResult.success) {
|
||||
await db.delete(workflow).where(eq(workflow.id, workflowId))
|
||||
return { success: false, error: `Failed to save workflow state: ${saveResult.error}` }
|
||||
}
|
||||
|
||||
if (workflowData.variables && Array.isArray(workflowData.variables)) {
|
||||
const variablesRecord: Record<
|
||||
string,
|
||||
{ id: string; name: string; type: string; value: unknown }
|
||||
> = {}
|
||||
for (const v of workflowData.variables) {
|
||||
const varId = (v as { id?: string }).id || generateId()
|
||||
const variable = v as { name: string; type?: string; value: unknown }
|
||||
variablesRecord[varId] = {
|
||||
id: varId,
|
||||
name: variable.name,
|
||||
type: variable.type || 'string',
|
||||
value: variable.value,
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({ variables: variablesRecord, updatedAt: new Date() })
|
||||
.where(eq(workflow.id, workflowId))
|
||||
}
|
||||
|
||||
logger.info('Imported workflow from upload', {
|
||||
fileName,
|
||||
workflowId,
|
||||
workflowName: dedupedName,
|
||||
chatId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.WORKFLOW_CREATED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: workflowId,
|
||||
resourceName: dedupedName,
|
||||
description: `Imported workflow "${dedupedName}" from file`,
|
||||
metadata: { fileName, source: 'copilot-import' },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Workflow "${dedupedName}" imported successfully. It is now available in the workspace and can be edited or run.`,
|
||||
workflowId,
|
||||
workflowName: dedupedName,
|
||||
},
|
||||
resources: [{ type: 'workflow', id: workflowId, title: dedupedName }],
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeMaterializeFile(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const fileNames: string[] =
|
||||
(params.fileNames as string[] | undefined) ??
|
||||
([params.fileName as string | undefined].filter(Boolean) as string[])
|
||||
|
||||
if (fileNames.length === 0) {
|
||||
return { success: false, error: "Missing required parameter 'fileNames'" }
|
||||
}
|
||||
|
||||
if (!context.chatId) {
|
||||
return { success: false, error: 'No chat context available for materialize_file' }
|
||||
}
|
||||
|
||||
if (!context.workspaceId) {
|
||||
return { success: false, error: 'No workspace context available for materialize_file' }
|
||||
}
|
||||
|
||||
const operation = (params.operation as string | undefined) || 'save'
|
||||
// Only save/import are implemented. Reject anything else with guidance instead of
|
||||
// silently falling back to save (table/knowledge_base are handled by their subagents).
|
||||
if (operation !== 'save' && operation !== 'import') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Unsupported materialize_file operation "${operation}". Use "save" or "import". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`,
|
||||
}
|
||||
}
|
||||
const succeeded: string[] = []
|
||||
const failed: Array<{ fileName: string; error: string }> = []
|
||||
const resources: NonNullable<ToolCallResult['resources']> = []
|
||||
|
||||
for (const fileName of fileNames) {
|
||||
try {
|
||||
let result: ToolCallResult
|
||||
if (operation === 'import') {
|
||||
result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId)
|
||||
} else {
|
||||
result = await executeSave(fileName, context.chatId)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
succeeded.push(fileName)
|
||||
if (result.resources) resources.push(...result.resources)
|
||||
} else {
|
||||
failed.push({ fileName, error: result.error ?? 'Failed to materialize file' })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('materialize_file failed', {
|
||||
fileName,
|
||||
operation,
|
||||
chatId: context.chatId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
failed.push({
|
||||
fileName,
|
||||
error: getErrorMessage(err, 'Failed to materialize file'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: succeeded.length > 0,
|
||||
output: { succeeded, failed },
|
||||
error:
|
||||
failed.length > 0
|
||||
? `Failed to materialize: ${failed.map((f) => f.fileName).join(', ')}`
|
||||
: undefined,
|
||||
resources: resources.length > 0 ? resources : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getAllOAuthServices } from '@/lib/oauth/utils'
|
||||
|
||||
export async function executeOAuthGetAuthLink(
|
||||
rawParams: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const providerName = String(rawParams.providerName || rawParams.provider_name || '')
|
||||
const baseUrl = getBaseUrl()
|
||||
try {
|
||||
if (!context.workspaceId || !context.userId) {
|
||||
throw new Error('workspaceId and userId are required to generate an OAuth link')
|
||||
}
|
||||
await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write')
|
||||
const result = await generateOAuthLink(
|
||||
context.workspaceId,
|
||||
context.workflowId,
|
||||
context.chatId,
|
||||
providerName,
|
||||
baseUrl
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Authorization URL generated for ${result.serviceName}.`,
|
||||
oauth_url: result.url,
|
||||
instructions: `Open this URL in your browser to connect ${result.serviceName}: ${result.url}`,
|
||||
provider: result.serviceName,
|
||||
providerId: result.providerId,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
const workspaceUrl = context.workspaceId
|
||||
? `${baseUrl}/workspace/${context.workspaceId}`
|
||||
: `${baseUrl}/workspace`
|
||||
return {
|
||||
success: false,
|
||||
error: toError(err).message,
|
||||
output: {
|
||||
message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`,
|
||||
oauth_url: workspaceUrl,
|
||||
error: toError(err).message,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeOAuthRequestAccess(
|
||||
rawParams: Record<string, unknown>,
|
||||
_context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const providerName = String(rawParams.providerName || rawParams.provider_name || 'the provider')
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
status: 'requested',
|
||||
providerName,
|
||||
message: `Requested ${providerName} OAuth connection.`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a human-friendly provider name to a providerId and returns a
|
||||
* browser-initiated authorize URL the user opens to connect the service.
|
||||
*
|
||||
* Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL.
|
||||
* That endpoint (not this server-side handler) creates the credential draft and
|
||||
* calls Better Auth, so the draft's TTL starts at click and the signed `state`
|
||||
* cookie is planted in the user's browser and the OAuth callback's state check
|
||||
* passes.
|
||||
*/
|
||||
async function generateOAuthLink(
|
||||
workspaceId: string | undefined,
|
||||
workflowId: string | undefined,
|
||||
chatId: string | undefined,
|
||||
providerName: string,
|
||||
baseUrl: string
|
||||
): Promise<{ url: string; providerId: string; serviceName: string }> {
|
||||
if (!workspaceId) {
|
||||
throw new Error('workspaceId is required to generate an OAuth link')
|
||||
}
|
||||
|
||||
const allServices = getAllOAuthServices()
|
||||
const normalizedInput = providerName.toLowerCase().trim()
|
||||
|
||||
const matched =
|
||||
allServices.find((s) => s.providerId === normalizedInput) ||
|
||||
allServices.find((s) => s.name.toLowerCase() === normalizedInput) ||
|
||||
allServices.find(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(normalizedInput) ||
|
||||
normalizedInput.includes(s.name.toLowerCase())
|
||||
) ||
|
||||
allServices.find(
|
||||
(s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId)
|
||||
)
|
||||
|
||||
if (!matched) {
|
||||
const available = allServices.map((s) => s.name).join(', ')
|
||||
throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`)
|
||||
}
|
||||
|
||||
const { providerId, name: serviceName } = matched
|
||||
const callbackURL =
|
||||
workflowId && workspaceId
|
||||
? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}`
|
||||
: chatId && workspaceId
|
||||
? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}`
|
||||
: `${baseUrl}/workspace/${workspaceId}`
|
||||
|
||||
if (providerId === 'trello') {
|
||||
return { url: `${baseUrl}/api/auth/trello/authorize`, providerId, serviceName }
|
||||
}
|
||||
if (providerId === 'shopify') {
|
||||
const returnUrl = encodeURIComponent(callbackURL)
|
||||
return {
|
||||
url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`,
|
||||
providerId,
|
||||
serviceName,
|
||||
}
|
||||
}
|
||||
|
||||
// Hand back a browser-initiated authorize URL rather than calling
|
||||
// oAuth2LinkAccount here. Generating the link server-side would set Better
|
||||
// Auth's signed `state` cookie on this server-to-server response instead of the
|
||||
// user's browser, so the OAuth callback would fail with `state_mismatch`. The
|
||||
// authorize endpoint runs the link inside the user's browser, planting the
|
||||
// cookie correctly while keeping the callback's state check enabled.
|
||||
//
|
||||
// The pending credential draft is created by that authorize endpoint at click
|
||||
// time (not here), so the draft's TTL starts when the user actually initiates
|
||||
// the connect and reliably outlives the OAuth round-trip.
|
||||
const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`)
|
||||
authorizeUrl.searchParams.set('providerId', providerId)
|
||||
authorizeUrl.searchParams.set('workspaceId', workspaceId)
|
||||
authorizeUrl.searchParams.set('callbackURL', callbackURL)
|
||||
|
||||
return { url: authorizeUrl.toString(), providerId, serviceName }
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Typed parameter interfaces for tool executor functions.
|
||||
* Replaces Record<string, any> with specific shapes based on actual property access.
|
||||
*/
|
||||
|
||||
import type { MothershipResourceType } from '@/lib/copilot/resources/types'
|
||||
|
||||
// === Workflow Query Params ===
|
||||
|
||||
export interface GetWorkflowDataParams {
|
||||
workflowId?: string
|
||||
data_type?: string
|
||||
dataType?: string
|
||||
}
|
||||
|
||||
export interface GetWorkflowRunOptionsParams {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export interface GetBlockOutputsParams {
|
||||
workflowId?: string
|
||||
blockIds?: string[]
|
||||
}
|
||||
|
||||
export interface GetBlockUpstreamReferencesParams {
|
||||
workflowId?: string
|
||||
blockIds: string[]
|
||||
}
|
||||
|
||||
// === Workflow Mutation Params ===
|
||||
|
||||
export interface CreateWorkflowParams {
|
||||
name?: string
|
||||
workspaceId?: string
|
||||
folderId?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface CreateFolderParams {
|
||||
name?: string
|
||||
workspaceId?: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
export interface RunWorkflowParams {
|
||||
workflowId?: string
|
||||
workflow_input?: unknown
|
||||
input?: unknown
|
||||
/** Optional trigger block ID when the workflow has multiple entrypoints and the caller wants a specific one. */
|
||||
triggerBlockId?: string
|
||||
/** When true, run with the resolved trigger's generated mock payload instead of workflow_input. */
|
||||
useMockPayload?: boolean
|
||||
/** Reuse the recorded input from a past execution of this workflow instead of supplying workflow_input. */
|
||||
inputFromExecutionId?: string
|
||||
/** When true, runs the deployed version instead of the draft. Default: false (draft). */
|
||||
useDeployedState?: boolean
|
||||
}
|
||||
|
||||
export interface RunWorkflowUntilBlockParams {
|
||||
workflowId?: string
|
||||
workflow_input?: unknown
|
||||
input?: unknown
|
||||
/** Optional trigger block ID when the workflow has multiple entrypoints and the caller wants a specific one. */
|
||||
triggerBlockId?: string
|
||||
/** When true, run with the resolved trigger's generated mock payload instead of workflow_input. */
|
||||
useMockPayload?: boolean
|
||||
/** Reuse the recorded input from a past execution of this workflow instead of supplying workflow_input. */
|
||||
inputFromExecutionId?: string
|
||||
/** The block ID to stop after. Execution halts once this block completes. */
|
||||
stopAfterBlockId: string
|
||||
/** When true, runs the deployed version instead of the draft. Default: false (draft). */
|
||||
useDeployedState?: boolean
|
||||
}
|
||||
|
||||
export interface RunFromBlockParams {
|
||||
workflowId?: string
|
||||
/** The block ID to start execution from. */
|
||||
startBlockId: string
|
||||
/** Optional execution ID to load the snapshot from. If omitted, uses the latest execution. */
|
||||
executionId?: string
|
||||
workflow_input?: unknown
|
||||
input?: unknown
|
||||
useDeployedState?: boolean
|
||||
}
|
||||
|
||||
export interface RunBlockParams {
|
||||
workflowId?: string
|
||||
/** The block ID to run. Only this block executes using cached upstream outputs. */
|
||||
blockId: string
|
||||
/** Optional execution ID to load the snapshot from. If omitted, uses the latest execution. */
|
||||
executionId?: string
|
||||
workflow_input?: unknown
|
||||
input?: unknown
|
||||
useDeployedState?: boolean
|
||||
}
|
||||
|
||||
export interface GetDeployedWorkflowStateParams {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export interface GenerateApiKeyParams {
|
||||
name: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export interface VariableOperation {
|
||||
name: string
|
||||
operation: 'add' | 'edit' | 'delete'
|
||||
value?: unknown
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface SetGlobalWorkflowVariablesParams {
|
||||
workflowId?: string
|
||||
operations?: VariableOperation[]
|
||||
}
|
||||
|
||||
export interface SetBlockEnabledParams {
|
||||
workflowId?: string
|
||||
blockId: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// === Deployment Params ===
|
||||
|
||||
export interface DeployApiParams {
|
||||
workflowId?: string
|
||||
action?: 'deploy' | 'undeploy'
|
||||
/** Description of what changed in this deployment version. Required when action is 'deploy'. */
|
||||
versionDescription?: string
|
||||
/** Short human-readable name/label for this deployment version. Required when action is 'deploy'. */
|
||||
versionName?: string
|
||||
}
|
||||
|
||||
export interface DeployChatParams {
|
||||
workflowId?: string
|
||||
action?: 'deploy' | 'undeploy' | 'update'
|
||||
identifier?: string
|
||||
title?: string
|
||||
description?: string
|
||||
/** Description of what changed in this deployment version (distinct from the chat-facing `description`). Required when action is 'deploy'. */
|
||||
versionDescription?: string
|
||||
/** Short human-readable name/label for this deployment version. Required when action is 'deploy'. */
|
||||
versionName?: string
|
||||
welcomeMessage?: string
|
||||
customizations?: {
|
||||
primaryColor?: string
|
||||
secondaryColor?: string
|
||||
welcomeMessage?: string
|
||||
imageUrl?: string
|
||||
/** @deprecated Prefer imageUrl for compatibility with chat deploy APIs. */
|
||||
iconUrl?: string
|
||||
}
|
||||
authType?: 'password' | 'public' | 'email' | 'sso'
|
||||
password?: string
|
||||
subdomain?: string
|
||||
allowedEmails?: string[]
|
||||
outputConfigs?: unknown[]
|
||||
}
|
||||
|
||||
export interface DeployMcpParams {
|
||||
workflowId?: string
|
||||
action?: 'deploy' | 'undeploy'
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
serverId?: string
|
||||
/**
|
||||
* Per-parameter descriptions as `[{ name, description }]`. Overlaid onto the
|
||||
* workflow's input format before generating the tool schema — the same path
|
||||
* the deploy modal uses. Parameter names/types/required come from the
|
||||
* workflow's input trigger, not from this tool.
|
||||
*/
|
||||
parameterDescriptions?: Array<{ name: string; description: string }>
|
||||
}
|
||||
|
||||
export interface DeployCustomBlockParams {
|
||||
workflowId?: string
|
||||
action?: 'deploy' | 'undeploy'
|
||||
/** Block display name (max 60 chars). Required on first publish. */
|
||||
name?: string
|
||||
/** Block-picker description (max 280 chars). */
|
||||
description?: string
|
||||
/** Icon image URL; omit for the organization's default icon. */
|
||||
iconUrl?: string
|
||||
/**
|
||||
* Per-input placeholder overrides keyed by the input trigger field's stable id.
|
||||
* The field set itself is always derived from the workflow's deployment.
|
||||
*/
|
||||
inputs?: Array<{ id: string; placeholder?: string }>
|
||||
/** Curated outputs; omit to expose the terminal block's whole result. */
|
||||
exposedOutputs?: Array<{ blockId: string; path: string; name: string }>
|
||||
}
|
||||
|
||||
export interface CheckDeploymentStatusParams {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export interface UpdateDeploymentVersionParams {
|
||||
workflowId?: string
|
||||
version: number | string
|
||||
/** New name/label for the version. Provide name and/or description. */
|
||||
name?: string
|
||||
/** New description for the version. Provide name and/or description. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GetDeploymentLogParams {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export interface DiffWorkflowsParams {
|
||||
workflowId?: string
|
||||
/** Base/previous side: a version number, "live", or "draft". */
|
||||
ref1: number | string
|
||||
/** Target/current side: a version number, "live", or "draft". */
|
||||
ref2: number | string
|
||||
}
|
||||
|
||||
export interface LoadDeploymentParams {
|
||||
workflowId?: string
|
||||
/** Version number to load, or "live" for the active deployment. */
|
||||
version: number | string
|
||||
}
|
||||
|
||||
export interface PromoteToLiveParams {
|
||||
workflowId?: string
|
||||
/** Version number to promote to live. */
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ListWorkspaceMcpServersParams {
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceMcpServerParams {
|
||||
workspaceId?: string
|
||||
name?: string
|
||||
description?: string
|
||||
isPublic?: boolean
|
||||
workflowIds?: string[]
|
||||
}
|
||||
|
||||
// === Workflow Organization Params ===
|
||||
|
||||
export interface RenameWorkflowParams {
|
||||
workflowId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface UpdateWorkflowParams {
|
||||
workflowId: string
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface DeleteWorkflowParams {
|
||||
workflowIds: string[]
|
||||
}
|
||||
|
||||
export interface MoveWorkflowParams {
|
||||
workflowIds: string[]
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
export interface MoveFolderParams {
|
||||
folderId: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
export interface RenameFolderParams {
|
||||
folderId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DeleteFolderParams {
|
||||
folderIds: string[]
|
||||
}
|
||||
|
||||
export interface ManageFolderParams {
|
||||
operation: string
|
||||
path?: string
|
||||
folderId?: string
|
||||
name?: string
|
||||
destinationPath?: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceMcpServerParams {
|
||||
serverId: string
|
||||
name?: string
|
||||
description?: string
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
export interface DeleteWorkspaceMcpServerParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export type OpenResourceType = MothershipResourceType
|
||||
|
||||
export interface OpenResourceItem {
|
||||
type?: OpenResourceType
|
||||
id?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface OpenResourceParams {
|
||||
resources?: OpenResourceItem[]
|
||||
type?: OpenResourceType
|
||||
id?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface ValidOpenResourceParams {
|
||||
type: OpenResourceType
|
||||
id?: string
|
||||
path?: string
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Static content for the get_platform_actions tool.
|
||||
* Contains the Sim platform quick reference and keyboard shortcuts.
|
||||
*/
|
||||
export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboard Shortcuts
|
||||
|
||||
## Keyboard Shortcuts
|
||||
**Mod** = Cmd (macOS) / Ctrl (Windows/Linux). Shortcuts work when canvas is focused.
|
||||
|
||||
### Workflow Actions
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Mod+Enter | Run workflow (or cancel if running) |
|
||||
| Mod+Z | Undo |
|
||||
| Mod+Shift+Z | Redo |
|
||||
| Mod+C | Copy selected blocks |
|
||||
| Mod+V | Paste blocks |
|
||||
| Delete/Backspace | Delete selected blocks or edges |
|
||||
| Shift+L | Auto-layout canvas |
|
||||
| Mod+Shift+F | Fit to view |
|
||||
| Mod+Shift+Enter | Accept Copilot changes |
|
||||
|
||||
### Panel Navigation
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| C | Focus Copilot tab |
|
||||
| T | Focus Toolbar tab |
|
||||
| E | Focus Editor tab |
|
||||
| Mod+F | Open workflow search and replace |
|
||||
| Mod+Alt+F | Focus Toolbar search |
|
||||
|
||||
### Global Navigation
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Mod+K | Open search |
|
||||
| Mod+Shift+A | Add new agent workflow |
|
||||
| Mod+L | Go to logs |
|
||||
|
||||
### Utility
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Mod+D | Clear terminal console |
|
||||
|
||||
### Mouse Controls
|
||||
| Action | Control |
|
||||
|--------|---------|
|
||||
| Pan/move canvas | Left-drag on empty space, scroll, or trackpad |
|
||||
| Select multiple blocks | Right-drag to draw selection box |
|
||||
| Drag block | Left-drag on block header |
|
||||
| Add to selection | Mod+Click on blocks |
|
||||
|
||||
## Quick Reference — Workspaces
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Create workspace | Click workspace dropdown → New Workspace |
|
||||
| Switch workspaces | Click workspace dropdown → Select workspace |
|
||||
| Invite teammates | Sidebar → Invite |
|
||||
| Rename/Duplicate/Export/Delete workspace | Right-click workspace → action |
|
||||
|
||||
## Quick Reference — Workflows
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Create workflow | Click + button in sidebar |
|
||||
| Reorder/move workflows | Drag workflow up/down or onto a folder |
|
||||
| Import workflow | Click import button in sidebar → Select file |
|
||||
| Multi-select workflows | Mod+Click or Shift+Click workflows in sidebar |
|
||||
| Open in new tab | Right-click workflow → Open in New Tab |
|
||||
| Rename/Duplicate/Export/Delete | Right-click workflow → action |
|
||||
|
||||
## Quick Reference — Blocks
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Add a block | Drag from Toolbar panel, or right-click canvas → Add Block |
|
||||
| Multi-select blocks | Mod+Click additional blocks, or shift-drag selection box |
|
||||
| Copy/Paste blocks | Mod+C / Mod+V |
|
||||
| Duplicate/Delete blocks | Right-click → action |
|
||||
| Rename a block | Click block name in header |
|
||||
| Enable/Disable block | Right-click → Enable/Disable |
|
||||
| Lock/Unlock block | Hover block → Click lock icon (Admin only) |
|
||||
| Toggle handle orientation | Right-click → Toggle Handles |
|
||||
| Configure a block | Select block → use Editor panel on right |
|
||||
|
||||
## Quick Reference — Connections
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Create connection | Drag from output handle to input handle |
|
||||
| Delete connection | Click edge to select → Delete key |
|
||||
| Use output in another block | Drag connection tag into input field |
|
||||
|
||||
## Quick Reference — Running & Testing
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Run workflow | Click Run Workflow button or Mod+Enter |
|
||||
| Stop workflow | Click Stop button or Mod+Enter while running |
|
||||
| Test with chat | Use Chat panel on the right side |
|
||||
| Run from block | Hover block → Click play button, or right-click → Run from block |
|
||||
| Run until block | Right-click block → Run until block |
|
||||
| View execution logs | Open terminal panel at bottom, or Mod+L |
|
||||
| Filter/Search/Copy/Clear logs | Terminal panel controls |
|
||||
|
||||
## Quick Reference — Deployment
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Deploy workflow | Click Deploy button in panel |
|
||||
| Update deployment | Click Update when changes are detected |
|
||||
| Revert deployment | Previous versions in Deploy tab → Promote to live |
|
||||
| Copy API endpoint | Deploy tab → API → Copy API cURL |
|
||||
|
||||
## Quick Reference — Variables
|
||||
| Action | How |
|
||||
|--------|-----|
|
||||
| Add/Edit/Delete workflow variable | Panel → Variables → Add Variable |
|
||||
| Add environment variable | Settings → Environment Variables → Add |
|
||||
| Reference workflow variable | Use <blockName.itemName> syntax |
|
||||
| Reference environment variable | Use {{ENV_VAR}} syntax |
|
||||
`
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { PLATFORM_ACTIONS_CONTENT } from './platform-actions'
|
||||
|
||||
export async function executeGetPlatformActions(
|
||||
_rawParams: Record<string, unknown>,
|
||||
_context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
return { success: true, output: { content: PLATFORM_ACTIONS_CONTENT } }
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(() => ({
|
||||
getWorkspaceFileMock: vi.fn(),
|
||||
resolveWorkspaceFileReferenceMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
getWorkspaceFile: getWorkspaceFileMock,
|
||||
resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/table/service', () => ({
|
||||
getTableById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/knowledge/service', () => ({
|
||||
getKnowledgeBaseById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/service', () => ({
|
||||
getLogById: vi.fn(),
|
||||
}))
|
||||
|
||||
import { executeOpenResource } from './resources'
|
||||
|
||||
describe('executeOpenResource', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens workspace files with canonical non-UUID file ids', async () => {
|
||||
getWorkspaceFileMock.mockResolvedValue({
|
||||
id: 'wf_qL_cfff-FskMsXtOdm599',
|
||||
name: 'MAC_Brand_Guidelines_May_2021 (1).docx',
|
||||
folderPath: null,
|
||||
})
|
||||
|
||||
const result = await executeOpenResource(
|
||||
{
|
||||
resources: [{ type: 'file', id: 'wf_qL_cfff-FskMsXtOdm599' }],
|
||||
},
|
||||
{ userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
|
||||
expect(getWorkspaceFileMock).toHaveBeenCalledWith('workspace-1', 'wf_qL_cfff-FskMsXtOdm599')
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
output: { opened: 1, errors: [] },
|
||||
resources: [
|
||||
{
|
||||
type: 'file',
|
||||
id: 'wf_qL_cfff-FskMsXtOdm599',
|
||||
title: 'MAC_Brand_Guidelines_May_2021 (1).docx',
|
||||
path: 'files/MAC_Brand_Guidelines_May_2021%20(1).docx',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('opens workspace files by canonical VFS path', async () => {
|
||||
resolveWorkspaceFileReferenceMock.mockResolvedValue({
|
||||
id: 'wf_qL_cfff-FskMsXtOdm599',
|
||||
name: 'MAC_Brand_Guidelines_May_2021 (1).docx',
|
||||
folderPath: 'Docs',
|
||||
})
|
||||
|
||||
const result = await executeOpenResource(
|
||||
{
|
||||
resources: [{ type: 'file', path: 'files/Docs/MAC_Brand_Guidelines.docx' }],
|
||||
},
|
||||
{ userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
|
||||
expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith(
|
||||
'workspace-1',
|
||||
'files/Docs/MAC_Brand_Guidelines.docx'
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
output: { opened: 1, errors: [] },
|
||||
resources: [
|
||||
{
|
||||
type: 'file',
|
||||
id: 'wf_qL_cfff-FskMsXtOdm599',
|
||||
title: 'MAC_Brand_Guidelines_May_2021 (1).docx',
|
||||
path: 'files/Docs/MAC_Brand_Guidelines_May_2021%20(1).docx',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('opens workflow alias file paths through workspace file reference resolution', async () => {
|
||||
resolveWorkspaceFileReferenceMock.mockResolvedValue({
|
||||
id: 'wf_plan_file',
|
||||
name: 'implementation.md',
|
||||
folderPath: 'system/workflows/My Workflow/.plans',
|
||||
})
|
||||
|
||||
const result = await executeOpenResource(
|
||||
{
|
||||
resources: [{ type: 'file', path: 'workflows/My%20Workflow/.plans/implementation.md' }],
|
||||
},
|
||||
{ userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
|
||||
expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith(
|
||||
'workspace-1',
|
||||
'workflows/My%20Workflow/.plans/implementation.md'
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
resources: [
|
||||
{
|
||||
type: 'file',
|
||||
id: 'wf_plan_file',
|
||||
title: 'implementation.md',
|
||||
path: 'files/system/workflows/My%20Workflow/.plans/implementation.md',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('opens root plan alias file paths through workspace file reference resolution', async () => {
|
||||
resolveWorkspaceFileReferenceMock.mockResolvedValue({
|
||||
id: 'wf_root_plan',
|
||||
name: 'root.md',
|
||||
folderPath: 'system/.plans',
|
||||
})
|
||||
|
||||
const result = await executeOpenResource(
|
||||
{
|
||||
resources: [{ type: 'file', path: '.plans/root.md' }],
|
||||
},
|
||||
{ userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' }
|
||||
)
|
||||
|
||||
expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', '.plans/root.md')
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
resources: [
|
||||
{
|
||||
type: 'file',
|
||||
id: 'wf_root_plan',
|
||||
title: 'root.md',
|
||||
path: 'files/system/.plans/root.md',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowSchedule } from '@sim/db/schema'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types'
|
||||
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
|
||||
import { getKnowledgeBaseById } from '@/lib/knowledge/service'
|
||||
import { getLogById } from '@/lib/logs/service'
|
||||
import { getTableById } from '@/lib/table/service'
|
||||
import {
|
||||
getWorkspaceFile,
|
||||
resolveWorkspaceFileReference,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types'
|
||||
|
||||
const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType))
|
||||
|
||||
async function resolveResource(
|
||||
item: ValidOpenResourceParams,
|
||||
context: ExecutionContext
|
||||
): Promise<MothershipResource | { error: string }> {
|
||||
const resourceType = item.type
|
||||
let resourceId = item.id ?? ''
|
||||
let title: string = resourceType
|
||||
|
||||
if (resourceType === 'file') {
|
||||
if (!context.workspaceId)
|
||||
return { error: 'Opening a workspace file requires workspace context.' }
|
||||
const fileRef = item.path || item.id || ''
|
||||
const record = item.path
|
||||
? await resolveWorkspaceFileReference(context.workspaceId, item.path)
|
||||
: item.id
|
||||
? await getWorkspaceFile(context.workspaceId, item.id)
|
||||
: null
|
||||
if (!record) return { error: `No workspace file found for "${fileRef}".` }
|
||||
resourceId = record.id
|
||||
title = record.name
|
||||
return {
|
||||
type: resourceType,
|
||||
id: resourceId,
|
||||
title,
|
||||
path: canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }),
|
||||
}
|
||||
}
|
||||
if (resourceType === 'workflow') {
|
||||
if (!item.id) return { error: 'workflow resources require `id`.' }
|
||||
const wf = await getWorkflowById(item.id)
|
||||
if (!wf) return { error: `No workflow with id "${item.id}".` }
|
||||
if (context.workspaceId && wf.workspaceId !== context.workspaceId)
|
||||
return { error: `Workflow not found in the current workspace.` }
|
||||
resourceId = wf.id
|
||||
title = wf.name
|
||||
}
|
||||
if (resourceType === 'table') {
|
||||
if (!item.id) return { error: 'table resources require `id`.' }
|
||||
const tbl = await getTableById(item.id)
|
||||
if (!tbl) return { error: `No table with id "${item.id}".` }
|
||||
if (context.workspaceId && tbl.workspaceId !== context.workspaceId)
|
||||
return { error: `Table not found in the current workspace.` }
|
||||
resourceId = tbl.id
|
||||
title = tbl.name
|
||||
}
|
||||
if (resourceType === 'knowledgebase') {
|
||||
if (!item.id) return { error: 'knowledgebase resources require `id`.' }
|
||||
const kb = await getKnowledgeBaseById(item.id)
|
||||
if (!kb) return { error: `No knowledge base with id "${item.id}".` }
|
||||
if (context.workspaceId && kb.workspaceId !== context.workspaceId)
|
||||
return { error: `Knowledge base not found in the current workspace.` }
|
||||
resourceId = kb.id
|
||||
title = kb.name
|
||||
}
|
||||
if (resourceType === 'log') {
|
||||
if (!item.id) return { error: 'log resources require `id`.' }
|
||||
const logRecord = await getLogById(item.id)
|
||||
if (!logRecord) return { error: `No log with id "${item.id}".` }
|
||||
if (context.workspaceId && logRecord.workspaceId !== context.workspaceId)
|
||||
return { error: `Log not found in the current workspace.` }
|
||||
resourceId = logRecord.id
|
||||
const workflowName = logRecord.workflowName ?? 'Unknown Workflow'
|
||||
const timestamp = logRecord.startedAt.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
title = `${workflowName} — ${timestamp}`
|
||||
}
|
||||
if (resourceType === 'scheduledtask') {
|
||||
if (!item.id) return { error: 'scheduledtask resources require `id`.' }
|
||||
if (!context.workspaceId)
|
||||
return { error: 'Opening a scheduled task requires workspace context.' }
|
||||
const [schedule] = await db
|
||||
.select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle })
|
||||
.from(workflowSchedule)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowSchedule.id, item.id),
|
||||
eq(workflowSchedule.sourceWorkspaceId, context.workspaceId),
|
||||
eq(workflowSchedule.sourceType, 'job'),
|
||||
isNull(workflowSchedule.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!schedule) return { error: `No scheduled task with id "${item.id}".` }
|
||||
resourceId = schedule.id
|
||||
title = schedule.jobTitle || 'Scheduled Task'
|
||||
}
|
||||
|
||||
return { type: resourceType, id: resourceId, title }
|
||||
}
|
||||
|
||||
export async function executeOpenResource(
|
||||
rawParams: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const params = rawParams as OpenResourceParams
|
||||
|
||||
const items: OpenResourceItem[] =
|
||||
params.resources ??
|
||||
(params.type && (params.id || params.path)
|
||||
? [{ type: params.type, id: params.id, path: params.path }]
|
||||
: [])
|
||||
|
||||
if (items.length === 0) {
|
||||
return { success: false, error: 'resources array is required' }
|
||||
}
|
||||
|
||||
const resources: MothershipResource[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const validated = validateOpenResourceItem(item)
|
||||
if (!validated.success) {
|
||||
errors.push(validated.error)
|
||||
continue
|
||||
}
|
||||
const result = await resolveResource(validated.params, context)
|
||||
if ('error' in result) {
|
||||
errors.push(result.error)
|
||||
} else {
|
||||
resources.push(result)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: resources.length > 0,
|
||||
output: { opened: resources.length, errors },
|
||||
resources,
|
||||
}
|
||||
}
|
||||
|
||||
function validateOpenResourceItem(
|
||||
item: OpenResourceItem
|
||||
): { success: true; params: ValidOpenResourceParams } | { success: false; error: string } {
|
||||
if (!item.type) {
|
||||
return { success: false, error: 'type is required' }
|
||||
}
|
||||
if (!VALID_OPEN_RESOURCE_TYPES.has(item.type)) {
|
||||
return { success: false, error: `Invalid resource type: ${item.type}` }
|
||||
}
|
||||
if (!item.id && !(item.type === 'file' && item.path)) {
|
||||
return { success: false, error: `${item.type} resources require \`id\`` }
|
||||
}
|
||||
return { success: true, params: { type: item.type, id: item.id, path: item.path } }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { performRestoreResource, type RestorableResourceType } from '@/lib/resources/orchestration'
|
||||
|
||||
const VALID_TYPES = new Set(['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'])
|
||||
|
||||
export async function executeRestoreResource(
|
||||
rawParams: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const type = rawParams.type as string | undefined
|
||||
const id = rawParams.id as string | undefined
|
||||
|
||||
if (!type || !VALID_TYPES.has(type)) {
|
||||
return { success: false, error: `Invalid type. Must be one of: ${[...VALID_TYPES].join(', ')}` }
|
||||
}
|
||||
if (!id) {
|
||||
return { success: false, error: 'id is required' }
|
||||
}
|
||||
if (!context.workspaceId) {
|
||||
return { success: false, error: 'Workspace context required' }
|
||||
}
|
||||
|
||||
return performRestoreResource({
|
||||
type: type as RestorableResourceType,
|
||||
id,
|
||||
userId: context.userId,
|
||||
workspaceId: context.workspaceId,
|
||||
}) as Promise<ToolCallResult>
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
const { mockReadFileRecord } = vi.hoisted(() => ({
|
||||
mockReadFileRecord: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/vfs/file-reader', () => ({
|
||||
readFileRecord: mockReadFileRecord,
|
||||
}))
|
||||
|
||||
import {
|
||||
findMothershipUploadRowByChatAndName,
|
||||
listChatUploads,
|
||||
readChatUpload,
|
||||
} from './upload-file-reader'
|
||||
|
||||
const CHAT_ID = '11111111-1111-1111-1111-111111111111'
|
||||
const NOW = new Date('2026-05-05T00:00:00.000Z')
|
||||
|
||||
function makeRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'wf_1',
|
||||
key: 'mothership/abc/123-image.png',
|
||||
userId: 'user_1',
|
||||
workspaceId: 'ws_1',
|
||||
context: 'mothership',
|
||||
chatId: CHAT_ID,
|
||||
originalName: 'image.png',
|
||||
displayName: 'image.png',
|
||||
contentType: 'image/png',
|
||||
size: 1024,
|
||||
deletedAt: null,
|
||||
uploadedAt: NOW,
|
||||
updatedAt: NOW,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolver chain is `.where().orderBy(...).limit(1)`. The default chain mock makes
|
||||
* `orderBy` a terminal, so we wire a chainable `{limit}` for each call manually.
|
||||
*/
|
||||
function mockOrderByThenLimit(rows: unknown) {
|
||||
dbChainMockFns.orderBy.mockReturnValueOnce({ limit: dbChainMockFns.limit } as never)
|
||||
dbChainMockFns.limit.mockResolvedValueOnce(rows as never)
|
||||
}
|
||||
|
||||
describe('findMothershipUploadRowByChatAndName', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('matches by displayName for the first occurrence', async () => {
|
||||
const row = makeRow({ id: 'wf_1', displayName: 'image.png' })
|
||||
mockOrderByThenLimit([row])
|
||||
|
||||
const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image.png')
|
||||
|
||||
expect(result).toEqual(row)
|
||||
})
|
||||
|
||||
it('matches by suffixed displayName for collision-disambiguated rows', async () => {
|
||||
const row = makeRow({ id: 'wf_2', displayName: 'image (2).png' })
|
||||
mockOrderByThenLimit([row])
|
||||
|
||||
const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image (2).png')
|
||||
|
||||
expect(result?.id).toBe('wf_2')
|
||||
expect(result?.displayName).toBe('image (2).png')
|
||||
})
|
||||
|
||||
it('prefers the most recent row when legacy rows share the same originalName', async () => {
|
||||
// Pre-displayName legacy rows have displayName=null. Resolver's ORDER BY uploaded_at
|
||||
// DESC ensures the newest upload wins, fixing read("uploads/<name>") for legacy data.
|
||||
const newer = makeRow({
|
||||
id: 'wf_new',
|
||||
displayName: null,
|
||||
originalName: 'image.png',
|
||||
uploadedAt: new Date('2026-05-05T12:00:00.000Z'),
|
||||
})
|
||||
mockOrderByThenLimit([newer])
|
||||
|
||||
const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image.png')
|
||||
|
||||
expect(result?.id).toBe('wf_new')
|
||||
})
|
||||
|
||||
it('returns null when no row matches and the fallback scan is empty', async () => {
|
||||
// First query: .where().orderBy().limit() returns [].
|
||||
mockOrderByThenLimit([])
|
||||
// Second query: .where().orderBy(...) (no .limit) — orderBy is the terminal.
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([] as never)
|
||||
|
||||
const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'missing.png')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to normalized segment match when exact lookup misses (macOS U+202F)', async () => {
|
||||
// Model passes ASCII space; DB row was saved with U+202F (narrow no-break space).
|
||||
const macosName = 'Screenshot 2026-05-05 at 9.41.00 AM.png'
|
||||
const asciiName = 'Screenshot 2026-05-05 at 9.41.00 AM.png'
|
||||
const row = makeRow({ id: 'wf_3', displayName: macosName })
|
||||
|
||||
mockOrderByThenLimit([])
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([row] as never)
|
||||
|
||||
const result = await findMothershipUploadRowByChatAndName(CHAT_ID, asciiName)
|
||||
|
||||
expect(result?.id).toBe('wf_3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listChatUploads', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('returns rows in upload order with name set to displayName', async () => {
|
||||
const rows = [
|
||||
makeRow({ id: 'a', displayName: 'image.png' }),
|
||||
makeRow({ id: 'b', displayName: 'image (2).png' }),
|
||||
makeRow({ id: 'c', displayName: 'image (3).png' }),
|
||||
]
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce(rows)
|
||||
|
||||
const result = await listChatUploads(CHAT_ID)
|
||||
|
||||
expect(result.map((r) => r.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(result.map((r) => r.name)).toEqual(['image.png', 'image (2).png', 'image (3).png'])
|
||||
expect(result.every((r) => r.storageContext === 'mothership')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns [] and does not throw when the DB query fails', async () => {
|
||||
dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('boom'))
|
||||
const result = await listChatUploads(CHAT_ID)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readChatUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockReadFileRecord.mockReset()
|
||||
})
|
||||
|
||||
it('reads the row resolved by the suffixed displayName', async () => {
|
||||
const row = makeRow({ id: 'wf_2', displayName: 'image (2).png' })
|
||||
mockOrderByThenLimit([row])
|
||||
mockReadFileRecord.mockResolvedValueOnce({ content: 'PNGDATA', totalLines: 1 })
|
||||
|
||||
const result = await readChatUpload('image (2).png', CHAT_ID)
|
||||
|
||||
expect(result).toEqual({ content: 'PNGDATA', totalLines: 1 })
|
||||
expect(mockReadFileRecord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'wf_2', name: 'image (2).png', storageContext: 'mothership' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when no row matches', async () => {
|
||||
mockOrderByThenLimit([])
|
||||
dbChainMockFns.orderBy.mockResolvedValueOnce([] as never)
|
||||
|
||||
const result = await readChatUpload('nope.png', CHAT_ID)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockReadFileRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workspaceFiles } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { and, asc, desc, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
|
||||
import {
|
||||
type GrepCountEntry,
|
||||
type GrepMatch,
|
||||
type GrepOptions,
|
||||
grepReadResult,
|
||||
WorkspaceFileGrepError,
|
||||
} from '@/lib/copilot/vfs/operations'
|
||||
import { decodeVfsSegment, encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
|
||||
import { getServePathPrefix } from '@/lib/uploads'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
|
||||
const logger = createLogger('UploadFileReader')
|
||||
|
||||
/**
|
||||
* Canonical comparison key for an upload's VFS name. Accepts both the raw display
|
||||
* name and a percent-encoded segment (decode first — a no-op for raw names —
|
||||
* then re-encode to the canonical `files/`-style form) so either spelling
|
||||
* resolves the same row. Raw names containing a literal `%` cannot be decoded;
|
||||
* fall back to encoding the raw name.
|
||||
*/
|
||||
function canonicalUploadKey(name: string): string {
|
||||
let decoded = name
|
||||
try {
|
||||
decoded = decodeVfsSegment(name)
|
||||
} catch {
|
||||
decoded = name
|
||||
}
|
||||
try {
|
||||
return encodeVfsSegment(decoded)
|
||||
} catch {
|
||||
return name.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/** VFS-visible name. Coalesces to originalName for legacy rows that predate displayName. */
|
||||
function vfsName(row: typeof workspaceFiles.$inferSelect): string {
|
||||
return row.displayName ?? row.originalName
|
||||
}
|
||||
|
||||
function toWorkspaceFileRecord(row: typeof workspaceFiles.$inferSelect): WorkspaceFileRecord {
|
||||
const pathPrefix = getServePathPrefix()
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspaceId || '',
|
||||
name: vfsName(row),
|
||||
key: row.key,
|
||||
path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`,
|
||||
size: row.size,
|
||||
type: row.contentType,
|
||||
uploadedBy: row.userId,
|
||||
deletedAt: row.deletedAt,
|
||||
uploadedAt: row.uploadedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
storageContext: 'mothership',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mothership upload row by VFS name (the collision-disambiguated `displayName`
|
||||
* for new rows, or `originalName` for legacy rows that predate the column). Prefers an
|
||||
* exact DB match; falls back to a normalized scan when the model passes a visually
|
||||
* equivalent name (e.g. macOS U+202F vs ASCII space in screenshot filenames).
|
||||
*
|
||||
* On ambiguity (multiple legacy rows sharing the same originalName in one chat — the
|
||||
* pre-displayName collision case), returns the most recent upload. New rows are unique
|
||||
* by index so this only affects pre-fix data.
|
||||
*/
|
||||
export async function findMothershipUploadRowByChatAndName(
|
||||
chatId: string,
|
||||
fileName: string
|
||||
): Promise<typeof workspaceFiles.$inferSelect | null> {
|
||||
const exactRows = await db
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
or(
|
||||
eq(workspaceFiles.displayName, fileName),
|
||||
and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, fileName))
|
||||
),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id))
|
||||
.limit(1)
|
||||
|
||||
if (exactRows[0]) {
|
||||
return exactRows[0]
|
||||
}
|
||||
|
||||
const allRows = await db
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id))
|
||||
|
||||
const segmentKey = canonicalUploadKey(fileName)
|
||||
return allRows.find((r) => canonicalUploadKey(vfsName(r)) === segmentKey) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* List all chat-scoped uploads for a given chat in upload order.
|
||||
*/
|
||||
export async function listChatUploads(chatId: string): Promise<WorkspaceFileRecord[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceFiles.chatId, chatId),
|
||||
eq(workspaceFiles.context, 'mothership'),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(workspaceFiles.uploadedAt), asc(workspaceFiles.id))
|
||||
|
||||
return rows.map(toWorkspaceFileRecord)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to list chat uploads', {
|
||||
chatId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a specific uploaded file by display name within a chat session.
|
||||
* Resolves names with `normalizeVfsSegment` so macOS screenshot spacing (e.g. U+202F)
|
||||
* matches when the model passes a visually equivalent path.
|
||||
*/
|
||||
export async function readChatUpload(
|
||||
filename: string,
|
||||
chatId: string
|
||||
): Promise<FileReadResult | null> {
|
||||
try {
|
||||
const row = await findMothershipUploadRowByChatAndName(chatId, filename)
|
||||
if (!row) return null
|
||||
return readFileRecord(toWorkspaceFileRecord(row))
|
||||
} catch (err) {
|
||||
logger.warn('Failed to read chat upload', {
|
||||
filename,
|
||||
chatId,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grep the content of a single chat upload (`uploads/<name>`), mirroring
|
||||
* {@link WorkspaceVFS.grepFile} for the chat-scoped uploads namespace. Resolves
|
||||
* the upload by name (raw or percent-encoded), reads its text per file type, and
|
||||
* greps it. Throws {@link WorkspaceFileGrepError} when the upload is missing or
|
||||
* has no searchable text (image/binary/too-large) so the caller surfaces the
|
||||
* message verbatim.
|
||||
*/
|
||||
export async function grepChatUpload(
|
||||
filename: string,
|
||||
chatId: string,
|
||||
pattern: string,
|
||||
options?: GrepOptions
|
||||
): Promise<GrepMatch[] | string[] | GrepCountEntry[]> {
|
||||
const row = await findMothershipUploadRowByChatAndName(chatId, filename)
|
||||
if (!row) {
|
||||
throw new WorkspaceFileGrepError(
|
||||
`Upload not found: "${filename}". Use glob("uploads/*") to list available uploads.`
|
||||
)
|
||||
}
|
||||
const record = toWorkspaceFileRecord(row)
|
||||
const result = await readFileRecord(record)
|
||||
if (!result) {
|
||||
throw new WorkspaceFileGrepError(`Upload content not found for "${filename}".`)
|
||||
}
|
||||
const uploadsPath = `uploads/${canonicalUploadKey(record.name)}`
|
||||
return grepReadResult(uploadsPath, result, pattern, uploadsPath, options)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants'
|
||||
|
||||
const { getOrMaterializeVFS } = vi.hoisted(() => ({
|
||||
getOrMaterializeVFS: vi.fn(),
|
||||
}))
|
||||
|
||||
const { readChatUpload, listChatUploads, grepChatUpload } = vi.hoisted(() => ({
|
||||
readChatUpload: vi.fn(),
|
||||
listChatUploads: vi.fn(),
|
||||
grepChatUpload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/vfs', () => ({
|
||||
getOrMaterializeVFS,
|
||||
}))
|
||||
vi.mock('./upload-file-reader', () => ({
|
||||
readChatUpload,
|
||||
listChatUploads,
|
||||
grepChatUpload,
|
||||
}))
|
||||
|
||||
import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations'
|
||||
import { executeVfsGlob, executeVfsGrep, executeVfsRead } from './vfs'
|
||||
|
||||
const OVERSIZED_INLINE_CONTENT = 'x'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1)
|
||||
|
||||
function makeVfs() {
|
||||
return {
|
||||
grep: vi.fn(),
|
||||
grepFile: vi.fn(),
|
||||
glob: vi.fn().mockReturnValue([]),
|
||||
read: vi.fn(),
|
||||
readFileContent: vi.fn(),
|
||||
suggestSimilar: vi.fn().mockReturnValue([]),
|
||||
}
|
||||
}
|
||||
|
||||
const GREP_CTX = { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
const GREP_CTX_CHAT = { ...GREP_CTX, chatId: 'chat-1' }
|
||||
|
||||
describe('vfs handlers oversize policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fails oversized grep results with narrowing guidance', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grep.mockReturnValue([{ path: 'files/a.txt', line: 1, content: OVERSIZED_INLINE_CONTENT }])
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsGrep(
|
||||
{ pattern: 'foo', output_mode: 'content' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('more specific pattern')
|
||||
expect(result.error).toContain('context window')
|
||||
})
|
||||
|
||||
it('fails oversized read results from VFS with grep guidance', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue(null)
|
||||
vfs.read.mockReturnValue({ content: OVERSIZED_INLINE_CONTENT, totalLines: 1 })
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'workflows/My Workflow/state.json' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Use grep')
|
||||
expect(result.error).toContain('offset/limit')
|
||||
expect(result.error).toContain('context window')
|
||||
})
|
||||
|
||||
it('fails file-backed oversized read placeholders with original message', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: '[File too large to display inline: big.txt (6000000 bytes, limit 5242880)]',
|
||||
totalLines: 1,
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/big.txt/content' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('File too large to display inline')
|
||||
expect(result.error).toContain('big.txt')
|
||||
})
|
||||
|
||||
it('passes through image reads with attachment even when oversized', async () => {
|
||||
const vfs = makeVfs()
|
||||
const largeBase64 = 'A'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1)
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: 'Image: chess.png (500.0KB, image/png)',
|
||||
totalLines: 1,
|
||||
attachment: {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: largeBase64 },
|
||||
},
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/chess.png/content' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('image')
|
||||
})
|
||||
|
||||
it('passes through compiled file attachments even when oversized', async () => {
|
||||
const vfs = makeVfs()
|
||||
const largeBase64 = 'A'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1)
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: 'Compiled file: report.pdf (500000 bytes, application/pdf)',
|
||||
totalLines: 1,
|
||||
attachment: {
|
||||
type: 'file',
|
||||
name: 'report.pdf',
|
||||
source: { type: 'base64', media_type: 'application/pdf', data: largeBase64 },
|
||||
},
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/reports/report.pdf/compiled' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('file')
|
||||
})
|
||||
|
||||
it('fails oversized image placeholder when image exceeds size limit', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: '[Image too large: huge.png (10.0MB, limit 5MB)]',
|
||||
totalLines: 1,
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/huge.png/content' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('too large')
|
||||
})
|
||||
|
||||
it('reads canonical file leaf metadata without fetching dynamic content', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.read.mockReturnValue({
|
||||
content: '{"id":"wf_123","vfsPath":"files/report.csv"}',
|
||||
totalLines: 1,
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/report.csv' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(vfs.readFileContent).not.toHaveBeenCalled()
|
||||
expect(vfs.read).toHaveBeenCalledWith('files/report.csv', undefined, undefined)
|
||||
})
|
||||
|
||||
it('uses dynamic file reads for canonical style paths', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: '{"format":"docx"}',
|
||||
totalLines: 1,
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/reports/brief.docx/style' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.docx/style')
|
||||
expect(vfs.read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses dynamic file reads for canonical compiled paths', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: 'Compiled file: brief.pdf (1000 bytes, application/pdf)',
|
||||
totalLines: 1,
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/reports/brief.pdf/compiled' },
|
||||
{ userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' }
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.pdf/compiled')
|
||||
expect(vfs.read).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vfs grep workspace-file routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('routes a single workspace file leaf to grepFile (content search)', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grepFile.mockResolvedValue([{ path: 'files/report.csv', line: 2, content: 'revenue,100' }])
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsGrep(
|
||||
{ pattern: 'revenue', path: 'files/report.csv', output_mode: 'content' },
|
||||
GREP_CTX
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(vfs.grepFile).toHaveBeenCalledWith(
|
||||
'files/report.csv',
|
||||
'revenue',
|
||||
expect.objectContaining({ outputMode: 'content', maxResults: 50 })
|
||||
)
|
||||
expect(vfs.grep).not.toHaveBeenCalled()
|
||||
expect((result.output as { matches: unknown[] }).matches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('routes a files/<leaf>/content path to grepFile', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grepFile.mockResolvedValue([])
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
await executeVfsGrep({ pattern: 'x', path: 'files/reports/brief.pdf/content' }, GREP_CTX)
|
||||
|
||||
expect(vfs.grepFile).toHaveBeenCalledWith(
|
||||
'files/reports/brief.pdf/content',
|
||||
'x',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(vfs.grep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the VFS map grep for non-file paths', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grep.mockReturnValue([])
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
await executeVfsGrep({ pattern: 'slack', path: 'workflows/' }, GREP_CTX)
|
||||
|
||||
expect(vfs.grep).toHaveBeenCalledWith('slack', 'workflows/', expect.any(Object))
|
||||
expect(vfs.grepFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the VFS map grep when no path is given', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grep.mockReturnValue([])
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
await executeVfsGrep({ pattern: 'slack' }, GREP_CTX)
|
||||
|
||||
expect(vfs.grep).toHaveBeenCalledWith('slack', undefined, expect.any(Object))
|
||||
expect(vfs.grepFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a workspace-file grep scope error verbatim', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grepFile.mockRejectedValue(
|
||||
new WorkspaceFileGrepError(
|
||||
'Grep over workspace file content must target a single workspace file (e.g. path: "files/report.csv"). "files/" is not a single workspace file.'
|
||||
)
|
||||
)
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsGrep({ pattern: 'x', path: 'files/' }, GREP_CTX)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('single workspace file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vfs uploads are opt-in (like recently-deleted/)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not search uploads for an unscoped grep', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grep.mockReturnValue([])
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
await executeVfsGrep({ pattern: 'secret' }, GREP_CTX_CHAT)
|
||||
|
||||
expect(grepChatUpload).not.toHaveBeenCalled()
|
||||
expect(vfs.grep).toHaveBeenCalledWith('secret', undefined, expect.any(Object))
|
||||
})
|
||||
|
||||
it('does not search uploads for a files/ grep', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.grepFile.mockResolvedValue([])
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
await executeVfsGrep({ pattern: 'secret', path: 'files/report.csv' }, GREP_CTX_CHAT)
|
||||
|
||||
expect(grepChatUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes an explicit uploads/<file> path to grepChatUpload', async () => {
|
||||
grepChatUpload.mockResolvedValue([{ path: 'uploads/report.json', line: 1, content: 'hit' }])
|
||||
|
||||
const result = await executeVfsGrep(
|
||||
{ pattern: 'hit', path: 'uploads/report.json' },
|
||||
GREP_CTX_CHAT
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(grepChatUpload).toHaveBeenCalledWith(
|
||||
'report.json',
|
||||
'chat-1',
|
||||
'hit',
|
||||
expect.objectContaining({ maxResults: 50 })
|
||||
)
|
||||
expect(getOrMaterializeVFS).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a bare uploads/ folder grep (no cross-folder search)', async () => {
|
||||
const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/' }, GREP_CTX_CHAT)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('single upload')
|
||||
expect(grepChatUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('errors when grepping uploads without chat context', async () => {
|
||||
const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/report.json' }, GREP_CTX)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('No chat context')
|
||||
expect(grepChatUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces an upload-not-found grep error verbatim', async () => {
|
||||
grepChatUpload.mockRejectedValue(
|
||||
new WorkspaceFileGrepError(
|
||||
'Upload not found: "ghost.json". Use glob("uploads/*") to list available uploads.'
|
||||
)
|
||||
)
|
||||
|
||||
const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/ghost.json' }, GREP_CTX_CHAT)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Upload not found')
|
||||
})
|
||||
|
||||
it('lists uploads only when scoped, with percent-encoded paths', async () => {
|
||||
const vfs = makeVfs()
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
listChatUploads.mockResolvedValue([{ name: 'My Report.json' }, { name: 'data.csv' }])
|
||||
|
||||
const scoped = await executeVfsGlob({ pattern: 'uploads/*' }, GREP_CTX_CHAT)
|
||||
expect((scoped.output as { files: string[] }).files).toEqual(
|
||||
expect.arrayContaining(['uploads/My%20Report.json', 'uploads/data.csv'])
|
||||
)
|
||||
|
||||
listChatUploads.mockClear()
|
||||
const broad = await executeVfsGlob({ pattern: '**' }, GREP_CTX_CHAT)
|
||||
expect(listChatUploads).not.toHaveBeenCalled()
|
||||
expect((broad.output as { files: string[] }).files).not.toContain('uploads/My%20Report.json')
|
||||
})
|
||||
|
||||
it('reads an upload directly, tolerating a spurious /content suffix', async () => {
|
||||
const vfs = makeVfs()
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
readChatUpload.mockResolvedValue({ content: 'hello upload', totalLines: 1 })
|
||||
|
||||
const bare = await executeVfsRead({ path: 'uploads/report.csv' }, GREP_CTX_CHAT)
|
||||
expect(bare.success).toBe(true)
|
||||
expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1')
|
||||
|
||||
// The model adds /content out of habit (from files/) — it must still resolve.
|
||||
const withContent = await executeVfsRead({ path: 'uploads/report.csv/content' }, GREP_CTX_CHAT)
|
||||
expect(withContent.success).toBe(true)
|
||||
expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1')
|
||||
})
|
||||
|
||||
it('tolerates a trailing /content on an uploads grep path', async () => {
|
||||
grepChatUpload.mockResolvedValue([])
|
||||
|
||||
await executeVfsGrep({ pattern: 'x', path: 'uploads/report.json/content' }, GREP_CTX_CHAT)
|
||||
|
||||
expect(grepChatUpload).toHaveBeenCalledWith('report.json', 'chat-1', 'x', expect.any(Object))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility'
|
||||
import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { getOrMaterializeVFS } from '@/lib/copilot/vfs'
|
||||
import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations'
|
||||
import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations'
|
||||
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
|
||||
import { withBlockVisibility } from '@/blocks/visibility/server-context'
|
||||
import { grepChatUpload, listChatUploads, readChatUpload } from './upload-file-reader'
|
||||
|
||||
const logger = createLogger('VfsTools')
|
||||
|
||||
/**
|
||||
* Materialize the workspace VFS inside the viewer's block-visibility context so
|
||||
* the static component files stamped into it exclude blocks gated for this
|
||||
* viewer (unrevealed previews, kill-switched types). Visibility is memoized per
|
||||
* (userId, workspaceId), so repeated tool calls in one turn resolve once.
|
||||
*/
|
||||
async function getGatedVFS(workspaceId: string, userId: string) {
|
||||
const vis = await getBlockVisibilityForCopilot(userId, workspaceId)
|
||||
return withBlockVisibility(vis, () => getOrMaterializeVFS(workspaceId, userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a chat-upload display name as a single canonical VFS path segment so
|
||||
* `uploads/` paths follow the same percent-encoded convention as `files/`.
|
||||
* Falls back to the raw name if the segment cannot be encoded (so a listing
|
||||
* never fails wholesale over one odd name).
|
||||
*/
|
||||
function encodeUploadSegment(name: string): string {
|
||||
try {
|
||||
return encodeVfsSegment(name)
|
||||
} catch {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a grep `path` targets the workspace files tree (`files/` or
|
||||
* `recently-deleted/files/`). Such greps search a single file's content via
|
||||
* {@link WorkspaceVFS.grepFile}; every other path searches the VFS map.
|
||||
*/
|
||||
function isWorkspaceFileGrepPath(path: string | undefined): path is string {
|
||||
if (!path) return false
|
||||
return /^(recently-deleted\/)?files(\/|$)/.test(path.replace(/^\/+/, ''))
|
||||
}
|
||||
|
||||
/** True when a grep `path` targets the chat-scoped uploads namespace. */
|
||||
function isChatUploadGrepPath(path: string | undefined): path is string {
|
||||
if (!path) return false
|
||||
return /^uploads(\/|$)/.test(path.replace(/^\/+/, ''))
|
||||
}
|
||||
|
||||
function serializedResultSize(value: unknown): number {
|
||||
try {
|
||||
return JSON.stringify(value).length
|
||||
} catch {
|
||||
return String(value).length
|
||||
}
|
||||
}
|
||||
|
||||
function isOversizedReadPlaceholder(content: string): boolean {
|
||||
return (
|
||||
content.startsWith('[File too large to display inline:') ||
|
||||
content.startsWith('[Image too large:') ||
|
||||
content.startsWith('[Compiled artifact too large:')
|
||||
)
|
||||
}
|
||||
|
||||
function hasModelAttachment(result: unknown): boolean {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return false
|
||||
}
|
||||
const attachment = (result as { attachment?: { type?: string } }).attachment
|
||||
return (
|
||||
attachment?.type === 'image' || attachment?.type === 'file' || attachment?.type === 'document'
|
||||
)
|
||||
}
|
||||
|
||||
export async function executeVfsGrep(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const pattern = params.pattern as string | undefined
|
||||
if (!pattern) {
|
||||
return { success: false, error: "Missing required parameter 'pattern'" }
|
||||
}
|
||||
const outputMode = (params.output_mode as string) ?? 'content'
|
||||
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'No workspace context available' }
|
||||
}
|
||||
|
||||
const rawPath = typeof params.path === 'string' ? params.path : undefined
|
||||
|
||||
try {
|
||||
const grepOptions = {
|
||||
maxResults: (params.maxResults as number) ?? 50,
|
||||
outputMode: outputMode as 'content' | 'files_with_matches' | 'count',
|
||||
ignoreCase: (params.ignoreCase as boolean) ?? false,
|
||||
lineNumbers: (params.lineNumbers as boolean) ?? true,
|
||||
context: (params.context as number) ?? 0,
|
||||
}
|
||||
|
||||
// Routing mirrors read/glob:
|
||||
// - uploads/<file> -> grep one chat upload's content (chat-scoped)
|
||||
// - files/<file> -> grep one workspace file's content (one file only)
|
||||
// - everything else -> grep the in-memory VFS map (workflow JSON, metadata)
|
||||
// Chat uploads are opt-in like recently-deleted/: they are never in the VFS
|
||||
// map, so an unscoped grep can't touch them — only an explicit uploads/<file>
|
||||
// path does, and only one upload at a time.
|
||||
let result: GrepMatch[] | string[] | GrepCountEntry[]
|
||||
if (isChatUploadGrepPath(rawPath)) {
|
||||
if (!context.chatId) {
|
||||
return { success: false, error: 'No chat context available for uploads/' }
|
||||
}
|
||||
// The upload is the first segment after uploads/; any trailing segment
|
||||
// (e.g. a /content suffix) is ignored, mirroring the uploads read path.
|
||||
const filename = rawPath
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^uploads\/?/, '')
|
||||
.split('/')[0]
|
||||
if (!filename) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Grep over chat uploads must target a single upload (e.g. path: "uploads/report.json"). Use glob("uploads/*") to list uploads.',
|
||||
}
|
||||
}
|
||||
result = await grepChatUpload(filename, context.chatId, pattern, grepOptions)
|
||||
} else {
|
||||
const vfs = await getGatedVFS(workspaceId, context.userId)
|
||||
result = isWorkspaceFileGrepPath(rawPath)
|
||||
? await vfs.grepFile(rawPath, pattern, grepOptions)
|
||||
: await vfs.grep(pattern, rawPath, grepOptions)
|
||||
}
|
||||
const key =
|
||||
outputMode === 'files_with_matches' ? 'files' : outputMode === 'count' ? 'counts' : 'matches'
|
||||
const matchCount = Array.isArray(result)
|
||||
? result.length
|
||||
: typeof result === 'object'
|
||||
? Object.keys(result).length
|
||||
: 0
|
||||
const output = { [key]: result }
|
||||
if (serializedResultSize(output) > TOOL_RESULT_MAX_INLINE_CHARS) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Grep result too large to return inline. Retry grep with a more specific pattern or narrower path, and reduce context or maxResults. Avoid catch-all greps because smaller searches save context window and make follow-up reads cheaper.',
|
||||
}
|
||||
}
|
||||
logger.debug('vfs_grep result', { pattern, path: rawPath, outputMode, matchCount })
|
||||
return { success: true, output }
|
||||
} catch (err) {
|
||||
// Expected single-file scoping / no-text / too-large conditions: surface the
|
||||
// message verbatim instead of logging an internal failure.
|
||||
if (err instanceof WorkspaceFileGrepError) {
|
||||
logger.debug('vfs_grep workspace file rejected', {
|
||||
pattern,
|
||||
path: rawPath,
|
||||
error: err.message,
|
||||
})
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
logger.error('vfs_grep failed', {
|
||||
pattern,
|
||||
path: rawPath,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: getErrorMessage(err, 'vfs_grep failed') }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeVfsGlob(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const pattern = params.pattern as string | undefined
|
||||
if (!pattern) {
|
||||
return { success: false, error: "Missing required parameter 'pattern'" }
|
||||
}
|
||||
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'No workspace context available' }
|
||||
}
|
||||
|
||||
try {
|
||||
const vfs = await getGatedVFS(workspaceId, context.userId)
|
||||
let files = vfs.glob(pattern)
|
||||
|
||||
if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) {
|
||||
const uploads = await listChatUploads(context.chatId)
|
||||
// Encode per segment so uploads/ paths match the files/ convention; the
|
||||
// upload resolver accepts both the encoded path and the raw display name.
|
||||
const uploadPaths = uploads.map((f) => `uploads/${encodeUploadSegment(f.name)}`)
|
||||
files = [...files, ...uploadPaths]
|
||||
}
|
||||
|
||||
logger.debug('vfs_glob result', { pattern, fileCount: files.length })
|
||||
return { success: true, output: { files } }
|
||||
} catch (err) {
|
||||
logger.error('vfs_glob failed', {
|
||||
pattern,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: getErrorMessage(err, 'vfs_glob failed') }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeVfsRead(
|
||||
params: Record<string, unknown>,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
const path = params.path as string | undefined
|
||||
if (!path) {
|
||||
return { success: false, error: "Missing required parameter 'path'" }
|
||||
}
|
||||
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'No workspace context available' }
|
||||
}
|
||||
|
||||
try {
|
||||
const parseOptionalNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const offset = parseOptionalNumber(params.offset)
|
||||
const limit = parseOptionalNumber(params.limit)
|
||||
const applyWindow = <T extends { content: string; totalLines: number }>(result: T): T => {
|
||||
if (offset === undefined && limit === undefined) return result
|
||||
const lines = result.content.split('\n')
|
||||
const start = Math.max(0, Math.min(result.totalLines, offset ?? 0))
|
||||
const endRaw = limit !== undefined ? start + Math.max(0, limit) : result.totalLines
|
||||
const end = Math.max(start, Math.min(result.totalLines, endRaw))
|
||||
return {
|
||||
...result,
|
||||
content: lines.slice(start, end).join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat-scoped uploads via the uploads/ virtual prefix.
|
||||
// Uploads are flat and have no metadata/content split like files/ — the upload
|
||||
// IS the first path segment after uploads/. Any trailing segment (e.g. a
|
||||
// /content suffix added out of habit) is ignored so the read resolves either way.
|
||||
if (path.startsWith('uploads/')) {
|
||||
if (!context.chatId) {
|
||||
return { success: false, error: 'No chat context available for uploads/' }
|
||||
}
|
||||
const filename = path.slice('uploads/'.length).split('/')[0]
|
||||
const uploadResult = await readChatUpload(filename, context.chatId)
|
||||
if (uploadResult) {
|
||||
const isAttachment = hasModelAttachment(uploadResult)
|
||||
if (
|
||||
!isAttachment &&
|
||||
(isOversizedReadPlaceholder(uploadResult.content) ||
|
||||
serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS)
|
||||
) {
|
||||
logger.warn('Upload read result too large', {
|
||||
path,
|
||||
hasAttachment: isAttachment,
|
||||
contentLength: uploadResult.content.length,
|
||||
serializedSize: serializedResultSize(uploadResult),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: isOversizedReadPlaceholder(uploadResult.content)
|
||||
? uploadResult.content
|
||||
: 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.',
|
||||
}
|
||||
}
|
||||
const windowedUpload = applyWindow(uploadResult)
|
||||
logger.debug('vfs_read resolved chat upload', {
|
||||
path,
|
||||
totalLines: uploadResult.totalLines,
|
||||
hasAttachment: isAttachment,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
return { success: true, output: windowedUpload }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Upload not found: ${path}. Use glob("uploads/*") to list available uploads.`,
|
||||
}
|
||||
}
|
||||
|
||||
const vfs = await getGatedVFS(workspaceId, context.userId)
|
||||
|
||||
// Plain canonical file leaves are metadata resources. Dynamic file content
|
||||
// and inspection paths use explicit suffixes like /content, /style,
|
||||
// /compiled-check, or /compiled.
|
||||
const shouldReadDynamicFileContent =
|
||||
/^recently-deleted\/files\/.+\/content$/.test(path) ||
|
||||
/^files\/.+\/(?:content|style|compiled-check|compiled|render|extract)$/.test(path)
|
||||
const fileContent = shouldReadDynamicFileContent ? await vfs.readFileContent(path) : null
|
||||
if (fileContent) {
|
||||
const isAttachment = hasModelAttachment(fileContent)
|
||||
if (
|
||||
!isAttachment &&
|
||||
(isOversizedReadPlaceholder(fileContent.content) ||
|
||||
serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS)
|
||||
) {
|
||||
logger.warn('File read result too large', {
|
||||
path,
|
||||
hasAttachment: isAttachment,
|
||||
contentLength: fileContent.content.length,
|
||||
serializedSize: serializedResultSize(fileContent),
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: isOversizedReadPlaceholder(fileContent.content)
|
||||
? fileContent.content
|
||||
: 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.',
|
||||
}
|
||||
}
|
||||
const windowedFileContent = applyWindow(fileContent)
|
||||
logger.debug('vfs_read resolved workspace file', {
|
||||
path,
|
||||
totalLines: fileContent.totalLines,
|
||||
hasAttachment: isAttachment,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
output: windowedFileContent,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await vfs.read(path, offset, limit)
|
||||
if (!result) {
|
||||
const suggestions = vfs.suggestSimilar(path)
|
||||
logger.warn('vfs_read file not found', { path, suggestions })
|
||||
const hint =
|
||||
suggestions.length > 0
|
||||
? ` Did you mean: ${suggestions.join(', ')}?`
|
||||
: ' Use glob to discover available paths.'
|
||||
return { success: false, error: `File not found: ${path}.${hint}` }
|
||||
}
|
||||
if (
|
||||
!hasModelAttachment(result) &&
|
||||
(isOversizedReadPlaceholder(result.content) ||
|
||||
serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.',
|
||||
}
|
||||
}
|
||||
logger.debug('vfs_read result', { path, totalLines: result.totalLines, offset, limit })
|
||||
return {
|
||||
success: true,
|
||||
output: result,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('vfs_read failed', {
|
||||
path,
|
||||
error: toError(err).message,
|
||||
})
|
||||
return { success: false, error: getErrorMessage(err, 'vfs_read failed') }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEnvMock, workflowAuthzMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ensureWorkflowAccessMock,
|
||||
setWorkflowVariablesMock,
|
||||
recordAuditMock,
|
||||
executeWorkflowMock,
|
||||
getExecutionStateForWorkflowMock,
|
||||
getLatestExecutionStateWithExecutionIdMock,
|
||||
} = vi.hoisted(() => ({
|
||||
ensureWorkflowAccessMock: vi.fn(),
|
||||
setWorkflowVariablesMock: vi.fn(),
|
||||
recordAuditMock: vi.fn(),
|
||||
executeWorkflowMock: vi.fn(),
|
||||
getExecutionStateForWorkflowMock: vi.fn(),
|
||||
getLatestExecutionStateWithExecutionIdMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => ({
|
||||
AuditAction: { WORKFLOW_VARIABLES_UPDATED: 'WORKFLOW_VARIABLES_UPDATED' },
|
||||
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
|
||||
recordAudit: recordAuditMock,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {},
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-key/orchestration', () => ({
|
||||
performCreateWorkspaceApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => createEnvMock({ INTERNAL_API_SECRET: 'secret' }))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: () => 'request-1',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getSocketServerUrl: () => 'http://socket.test',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/executor/execution-state', () => ({
|
||||
getExecutionStateForWorkflow: getExecutionStateForWorkflowMock,
|
||||
getLatestExecutionStateWithExecutionId: getLatestExecutionStateWithExecutionIdMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/orchestration', () => ({
|
||||
performCreateFolder: vi.fn(),
|
||||
performCreateWorkflow: vi.fn(),
|
||||
performDeleteFolder: vi.fn(),
|
||||
performDeleteWorkflow: vi.fn(),
|
||||
performUpdateFolder: vi.fn(),
|
||||
performUpdateWorkflow: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn(),
|
||||
saveWorkflowToNormalizedTables: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({
|
||||
sanitizeForCopilot: vi.fn((state) => state),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
listFolders: vi.fn(),
|
||||
setWorkflowVariables: setWorkflowVariablesMock,
|
||||
verifyFolderWorkspace: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/utils/errors', () => ({
|
||||
hasExecutionResult: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock('../access', () => ({
|
||||
ensureWorkflowAccess: ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccess: vi.fn(),
|
||||
getDefaultWorkspaceId: vi.fn(),
|
||||
}))
|
||||
|
||||
import { performUpdateWorkflow } from '@/lib/workflows/orchestration'
|
||||
import { verifyFolderWorkspace } from '@/lib/workflows/utils'
|
||||
import {
|
||||
executeMoveWorkflow,
|
||||
executeRunFromBlock,
|
||||
executeSetGlobalWorkflowVariables,
|
||||
} from './mutations'
|
||||
|
||||
const performUpdateWorkflowMock = vi.mocked(performUpdateWorkflow)
|
||||
const verifyFolderWorkspaceMock = vi.mocked(verifyFolderWorkspace)
|
||||
|
||||
describe('executeSetGlobalWorkflowVariables', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: {
|
||||
id: 'workflow-1',
|
||||
variables: {},
|
||||
},
|
||||
})
|
||||
setWorkflowVariablesMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('persists variable changes and notifies clients that workflow state changed', async () => {
|
||||
const result = await executeSetGlobalWorkflowVariables(
|
||||
{
|
||||
workflowId: 'workflow-1',
|
||||
operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }],
|
||||
},
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const [, variables] = setWorkflowVariablesMock.mock.calls[0]
|
||||
expect(Object.values(variables)).toEqual([
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-1',
|
||||
name: 'threshold',
|
||||
type: 'number',
|
||||
value: 5,
|
||||
}),
|
||||
])
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://socket.test/api/workflow-updated', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': 'secret',
|
||||
},
|
||||
body: JSON.stringify({ workflowId: 'workflow-1' }),
|
||||
})
|
||||
expect(recordAuditMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lock enforcement', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) as typeof fetch
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
|
||||
workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('does not persist variable changes when the workflow is locked', async () => {
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'workflow-1', variables: {} },
|
||||
})
|
||||
workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValueOnce(
|
||||
new Error('Workflow is locked')
|
||||
)
|
||||
|
||||
const result = await executeSetGlobalWorkflowVariables(
|
||||
{
|
||||
workflowId: 'workflow-1',
|
||||
operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }],
|
||||
},
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Workflow is locked')
|
||||
expect(setWorkflowVariablesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not move a workflow into a locked target folder', async () => {
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workspaceId: 'workspace-1',
|
||||
workflow: { id: 'workflow-1', name: 'WF', folderId: null },
|
||||
})
|
||||
verifyFolderWorkspaceMock.mockResolvedValue(true)
|
||||
workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
|
||||
new Error('Folder is locked')
|
||||
)
|
||||
|
||||
const result = await executeMoveWorkflow(
|
||||
{ workflowIds: ['workflow-1'], folderId: 'locked-folder' },
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Folder is locked')
|
||||
expect(performUpdateWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeRunFromBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: {
|
||||
id: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
workspaceId: 'workspace-1',
|
||||
variables: {},
|
||||
},
|
||||
})
|
||||
executeWorkflowMock.mockResolvedValue({
|
||||
success: true,
|
||||
output: {},
|
||||
logs: [],
|
||||
metadata: { executionId: 'new-execution-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('passes source execution lineage for stored run-from-block snapshots', async () => {
|
||||
const sourceSnapshot = {
|
||||
blockStates: {
|
||||
upstream: {
|
||||
output: {
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id: 'lv_ABCDEFGHIJKL',
|
||||
kind: 'object',
|
||||
size: 10,
|
||||
key: 'execution/workspace-1/workflow-1/source-execution-1/large-value-lv_ABCDEFGHIJKL.json',
|
||||
executionId: 'source-execution-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
executedBlocks: [],
|
||||
blockLogs: [],
|
||||
decisions: {},
|
||||
completedLoops: [],
|
||||
activeExecutionPath: [],
|
||||
}
|
||||
getExecutionStateForWorkflowMock.mockResolvedValue(sourceSnapshot)
|
||||
|
||||
const result = await executeRunFromBlock(
|
||||
{
|
||||
workflowId: 'workflow-1',
|
||||
startBlockId: 'agent-1',
|
||||
executionId: 'source-execution-1',
|
||||
},
|
||||
{ userId: 'user-1' } as any
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(executeWorkflowMock).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
'request-1',
|
||||
undefined,
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
runFromBlock: {
|
||||
startBlockId: 'agent-1',
|
||||
sourceSnapshot,
|
||||
sourceExecutionId: 'source-execution-1',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
workflowsPersistenceUtilsMock,
|
||||
workflowsPersistenceUtilsMockFns,
|
||||
workflowsUtilsMock,
|
||||
workflowsUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ensureWorkflowAccessMock,
|
||||
getEffectiveBlockOutputPathsMock,
|
||||
hasTriggerCapabilityMock,
|
||||
getBlockMock,
|
||||
} = vi.hoisted(() => ({
|
||||
ensureWorkflowAccessMock: vi.fn(),
|
||||
getEffectiveBlockOutputPathsMock: vi.fn(),
|
||||
hasTriggerCapabilityMock: vi.fn(),
|
||||
getBlockMock: vi.fn(),
|
||||
}))
|
||||
|
||||
const loadWorkflowFromNormalizedTablesMock =
|
||||
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables
|
||||
const getWorkflowByIdMock = workflowsUtilsMockFns.mockGetWorkflowById
|
||||
|
||||
vi.mock('../access', () => ({
|
||||
ensureWorkflowAccess: ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccess: vi.fn(),
|
||||
getDefaultWorkspaceId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
|
||||
|
||||
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
|
||||
getEffectiveBlockOutputPaths: getEffectiveBlockOutputPathsMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({
|
||||
hasTriggerCapability: hasTriggerCapabilityMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: getBlockMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
import { executeGetBlockOutputs } from './queries'
|
||||
|
||||
describe('executeGetBlockOutputs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', userId: 'user-1', workspaceId: 'ws-1' },
|
||||
})
|
||||
getWorkflowByIdMock.mockResolvedValue({ variables: {} })
|
||||
getBlockMock.mockReturnValue({ category: 'core' })
|
||||
hasTriggerCapabilityMock.mockReturnValue(false)
|
||||
getEffectiveBlockOutputPathsMock.mockReturnValue(['content'])
|
||||
})
|
||||
|
||||
it('returns display outputs and block-relative outputs for chat deployment', async () => {
|
||||
loadWorkflowFromNormalizedTablesMock.mockResolvedValue({
|
||||
blocks: {
|
||||
'agent-1': {
|
||||
type: 'agent',
|
||||
name: 'Support Agent',
|
||||
subBlocks: {},
|
||||
},
|
||||
'loop-1': {
|
||||
type: 'loop',
|
||||
name: 'Items Loop',
|
||||
},
|
||||
},
|
||||
loops: {
|
||||
'loop-1': {
|
||||
loopType: 'forEach',
|
||||
},
|
||||
},
|
||||
parallels: {},
|
||||
})
|
||||
|
||||
const result = await executeGetBlockOutputs({ blockIds: ['agent-1', 'loop-1'] }, {
|
||||
workflowId: 'wf-1',
|
||||
userId: 'user-1',
|
||||
} as any)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
blockId: 'agent-1',
|
||||
blockName: 'Support Agent',
|
||||
blockType: 'agent',
|
||||
outputs: ['supportagent.content'],
|
||||
relativeOutputs: ['content'],
|
||||
triggerMode: undefined,
|
||||
},
|
||||
{
|
||||
blockId: 'loop-1',
|
||||
blockName: 'Items Loop',
|
||||
blockType: 'loop',
|
||||
outputs: [],
|
||||
relativeOutputs: [],
|
||||
insideSubflowOutputs: ['itemsloop.index', 'itemsloop.currentItem', 'itemsloop.items'],
|
||||
outsideSubflowOutputs: ['itemsloop.results'],
|
||||
relativeInsideSubflowOutputs: ['index', 'currentItem', 'items'],
|
||||
relativeOutsideSubflowOutputs: ['results'],
|
||||
triggerMode: undefined,
|
||||
},
|
||||
],
|
||||
variables: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,540 @@
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
|
||||
import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags'
|
||||
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
import {
|
||||
loadDeployedWorkflowState,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { listUserWorkspaces } from '@/lib/workspaces/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
import { ensureWorkflowAccess } from '../access'
|
||||
import type {
|
||||
GetBlockOutputsParams,
|
||||
GetBlockUpstreamReferencesParams,
|
||||
GetDeployedWorkflowStateParams,
|
||||
GetWorkflowDataParams,
|
||||
GetWorkflowRunOptionsParams,
|
||||
} from '../param-types'
|
||||
|
||||
export async function executeListUserWorkspaces(
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workspaces = await listUserWorkspaces(context.userId)
|
||||
|
||||
return { success: true, output: { workspaces } }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetWorkflowRunOptions(
|
||||
params: GetWorkflowRunOptionsParams,
|
||||
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 normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return { success: false, error: `Workflow ${workflowId} has no saved state` }
|
||||
}
|
||||
|
||||
const merged = mergeSubblockStateWithValues(normalized.blocks)
|
||||
const options = resolveTriggerRunOptions(merged, normalized.edges)
|
||||
|
||||
if (options.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
default: null,
|
||||
triggers: [],
|
||||
message:
|
||||
'No runnable trigger blocks found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const guidanceFor = (kind: string): string => {
|
||||
switch (kind) {
|
||||
case 'fields':
|
||||
return 'Build workflow_input matching inputSchema. Copy mockPayload only if you have no better values.'
|
||||
case 'event_payload':
|
||||
return 'Construct an event payload matching inputSchema, or run with useMockPayload: true if you cannot build one.'
|
||||
case 'chat':
|
||||
return 'Provide workflow_input shaped like { "input": "<message>" }.'
|
||||
default:
|
||||
return 'No input required.'
|
||||
}
|
||||
}
|
||||
|
||||
const triggers = options.map((option) => {
|
||||
const pub = toPublicRunOption(option)
|
||||
const callExample =
|
||||
pub.inputKind === 'none'
|
||||
? { triggerBlockId: pub.triggerBlockId }
|
||||
: { triggerBlockId: pub.triggerBlockId, workflow_input: pub.mockPayload }
|
||||
return { ...pub, guidance: guidanceFor(pub.inputKind), callExample }
|
||||
})
|
||||
|
||||
const defaultOption = options.find((option) => option.isDefault)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
default: defaultOption
|
||||
? {
|
||||
triggerBlockId: defaultOption.triggerBlockId,
|
||||
reason: `Highest-priority trigger (${defaultOption.blockName})`,
|
||||
}
|
||||
: null,
|
||||
triggers,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetWorkflowData(
|
||||
params: GetWorkflowDataParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
const dataType = params.data_type || params.dataType || ''
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (!dataType) {
|
||||
return { success: false, error: 'data_type is required' }
|
||||
}
|
||||
|
||||
const { workflow: workflowRecord, workspaceId } = await ensureWorkflowAccess(
|
||||
workflowId,
|
||||
context.userId
|
||||
)
|
||||
|
||||
if (dataType === 'global_variables') {
|
||||
const variablesRecord = (workflowRecord.variables as Record<string, unknown>) || {}
|
||||
const variables = Object.values(variablesRecord).map((v) => {
|
||||
const variable = v as Record<string, unknown> | null
|
||||
return {
|
||||
id: String(variable?.id || ''),
|
||||
name: String(variable?.name || ''),
|
||||
value: variable?.value,
|
||||
}
|
||||
})
|
||||
return { success: true, output: { variables } }
|
||||
}
|
||||
|
||||
if (dataType === 'custom_tools') {
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
const toolsRows = await listCustomTools({
|
||||
userId: context.userId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const customToolsData = toolsRows.map((tool) => {
|
||||
const schema = tool.schema as Record<string, unknown> | null
|
||||
const fn = (schema?.function ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(tool.id || ''),
|
||||
title: String(tool.title || ''),
|
||||
functionName: String(fn.name || ''),
|
||||
description: String(fn.description || ''),
|
||||
parameters: fn.parameters,
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, output: { customTools: customToolsData } }
|
||||
}
|
||||
|
||||
if (dataType === 'mcp_tools') {
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
const tools = await mcpService.discoverTools(context.userId, workspaceId, false)
|
||||
const mcpTools = tools.map((tool) => ({
|
||||
name: String(tool.name || ''),
|
||||
serverId: String(tool.serverId || ''),
|
||||
serverName: String(tool.serverName || ''),
|
||||
description: String(tool.description || ''),
|
||||
inputSchema: tool.inputSchema,
|
||||
}))
|
||||
return { success: true, output: { mcpTools } }
|
||||
}
|
||||
|
||||
if (dataType === 'files') {
|
||||
if (!workspaceId) {
|
||||
return { success: false, error: 'workspaceId is required' }
|
||||
}
|
||||
const files = await listWorkspaceFiles(workspaceId)
|
||||
const fileResults = files.map((file) => ({
|
||||
id: String(file.id || ''),
|
||||
name: String(file.name || ''),
|
||||
key: String(file.key || ''),
|
||||
path: String(file.path || ''),
|
||||
size: Number(file.size || 0),
|
||||
type: String(file.type || ''),
|
||||
uploadedAt: String(file.uploadedAt || ''),
|
||||
}))
|
||||
return { success: true, output: { files: fileResults } }
|
||||
}
|
||||
|
||||
return { success: false, error: `Unknown data_type: ${dataType}` }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetBlockOutputs(
|
||||
params: GetBlockOutputsParams,
|
||||
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 normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return { success: false, error: 'Workflow has no normalized data' }
|
||||
}
|
||||
|
||||
const blocks = normalized.blocks || {}
|
||||
const loops = normalized.loops || {}
|
||||
const parallels = normalized.parallels || {}
|
||||
const blockIds =
|
||||
Array.isArray(params.blockIds) && params.blockIds.length > 0
|
||||
? params.blockIds
|
||||
: Object.keys(blocks)
|
||||
|
||||
const results: Array<{
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
outputs: string[]
|
||||
relativeOutputs?: string[]
|
||||
insideSubflowOutputs?: string[]
|
||||
outsideSubflowOutputs?: string[]
|
||||
relativeInsideSubflowOutputs?: string[]
|
||||
relativeOutsideSubflowOutputs?: string[]
|
||||
triggerMode?: boolean
|
||||
}> = []
|
||||
|
||||
for (const blockId of blockIds) {
|
||||
const block = blocks[blockId]
|
||||
if (!block?.type) continue
|
||||
const blockName = block.name || block.type
|
||||
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
|
||||
results.push({
|
||||
blockId,
|
||||
blockName,
|
||||
blockType: block.type,
|
||||
outputs: [],
|
||||
relativeOutputs: [],
|
||||
insideSubflowOutputs: formatOutputsForDisplay(insidePaths, blockName),
|
||||
outsideSubflowOutputs: formatOutputsForDisplay(['results'], blockName),
|
||||
relativeInsideSubflowOutputs: insidePaths,
|
||||
relativeOutsideSubflowOutputs: ['results'],
|
||||
triggerMode: block.triggerMode,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(block.triggerMode && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputPaths(block.type, block.subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
})
|
||||
results.push({
|
||||
blockId,
|
||||
blockName,
|
||||
blockType: block.type,
|
||||
outputs: formatOutputsForDisplay(outputs, blockName),
|
||||
relativeOutputs: outputs,
|
||||
triggerMode: block.triggerMode,
|
||||
})
|
||||
}
|
||||
|
||||
const variables = await getWorkflowVariablesForTool(workflowId)
|
||||
|
||||
const payload = { blocks: results, variables }
|
||||
return { success: true, output: payload }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGetBlockUpstreamReferences(
|
||||
params: GetBlockUpstreamReferencesParams,
|
||||
context: ExecutionContext
|
||||
): Promise<ToolCallResult> {
|
||||
try {
|
||||
const workflowId = params.workflowId || context.workflowId
|
||||
if (!workflowId) {
|
||||
return { success: false, error: 'workflowId is required' }
|
||||
}
|
||||
if (!Array.isArray(params.blockIds) || params.blockIds.length === 0) {
|
||||
return { success: false, error: 'blockIds array is required' }
|
||||
}
|
||||
await ensureWorkflowAccess(workflowId, context.userId)
|
||||
|
||||
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalized) {
|
||||
return { success: false, error: 'Workflow has no normalized data' }
|
||||
}
|
||||
|
||||
const blocks = normalized.blocks || {}
|
||||
const edges = normalized.edges || []
|
||||
const loops = normalized.loops || {}
|
||||
const parallels = normalized.parallels || {}
|
||||
|
||||
const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target }))
|
||||
const variableOutputs = await getWorkflowVariablesForTool(workflowId)
|
||||
|
||||
interface AccessibleBlockEntry {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
outputs: string[]
|
||||
triggerMode?: boolean
|
||||
accessContext?: 'inside' | 'outside'
|
||||
}
|
||||
|
||||
interface UpstreamReferenceResult {
|
||||
blockId: string
|
||||
blockName: string
|
||||
blockType: string
|
||||
accessibleBlocks: AccessibleBlockEntry[]
|
||||
insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }>
|
||||
variables: Array<{ id: string; name: string; type: string; tag: string }>
|
||||
}
|
||||
|
||||
const results: UpstreamReferenceResult[] = []
|
||||
|
||||
for (const blockId of params.blockIds) {
|
||||
const targetBlock = blocks[blockId]
|
||||
if (!targetBlock) continue
|
||||
|
||||
const insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> = []
|
||||
const containingLoopIds = new Set<string>()
|
||||
const containingParallelIds = new Set<string>()
|
||||
|
||||
Object.values(loops).forEach((loop) => {
|
||||
if (loop?.nodes?.includes(blockId)) {
|
||||
containingLoopIds.add(loop.id)
|
||||
const loopBlock = blocks[loop.id]
|
||||
if (loopBlock) {
|
||||
insideSubflows.push({
|
||||
blockId: loop.id,
|
||||
blockName: loopBlock.name || loopBlock.type,
|
||||
blockType: 'loop',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Object.values(parallels).forEach((parallel) => {
|
||||
if (parallel?.nodes?.includes(blockId)) {
|
||||
containingParallelIds.add(parallel.id)
|
||||
const parallelBlock = blocks[parallel.id]
|
||||
if (parallelBlock) {
|
||||
insideSubflows.push({
|
||||
blockId: parallel.id,
|
||||
blockName: parallelBlock.name || parallelBlock.type,
|
||||
blockType: 'parallel',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId)
|
||||
const accessibleIds = new Set<string>(ancestorIds)
|
||||
accessibleIds.add(blockId)
|
||||
|
||||
containingLoopIds.forEach((loopId) => accessibleIds.add(loopId))
|
||||
|
||||
containingParallelIds.forEach((parallelId) => accessibleIds.add(parallelId))
|
||||
|
||||
const accessibleBlocks: AccessibleBlockEntry[] = []
|
||||
|
||||
for (const accessibleBlockId of accessibleIds) {
|
||||
const block = blocks[accessibleBlockId]
|
||||
if (!block?.type) continue
|
||||
const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop'
|
||||
if (accessibleBlockId === blockId && !canSelfReference) continue
|
||||
|
||||
const blockName = block.name || block.type
|
||||
let accessContext: 'inside' | 'outside' | undefined
|
||||
|
||||
let formattedOutputs: string[]
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
const isInside =
|
||||
(block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) ||
|
||||
(block.type === 'parallel' && containingParallelIds.has(accessibleBlockId))
|
||||
accessContext = isInside ? 'inside' : 'outside'
|
||||
const outputPaths = isInside
|
||||
? getSubflowInsidePaths(block.type, accessibleBlockId, loops, parallels)
|
||||
: ['results']
|
||||
formattedOutputs = formatOutputsForDisplay(outputPaths, blockName)
|
||||
} else {
|
||||
formattedOutputs = getBlockReferenceTags({
|
||||
block: {
|
||||
id: accessibleBlockId,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
triggerMode: block.triggerMode,
|
||||
subBlocks: block.subBlocks,
|
||||
},
|
||||
currentBlockId: blockId,
|
||||
})
|
||||
}
|
||||
const entry: AccessibleBlockEntry = {
|
||||
blockId: accessibleBlockId,
|
||||
blockName,
|
||||
blockType: block.type,
|
||||
outputs: formattedOutputs,
|
||||
...(block.triggerMode ? { triggerMode: true } : {}),
|
||||
...(accessContext ? { accessContext } : {}),
|
||||
}
|
||||
accessibleBlocks.push(entry)
|
||||
}
|
||||
|
||||
results.push({
|
||||
blockId,
|
||||
blockName: targetBlock.name || targetBlock.type,
|
||||
blockType: targetBlock.type,
|
||||
accessibleBlocks,
|
||||
insideSubflows,
|
||||
variables: variableOutputs,
|
||||
})
|
||||
}
|
||||
|
||||
const payload = { results }
|
||||
return { success: true, output: payload }
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorkflowVariablesForTool(
|
||||
workflowId: string
|
||||
): Promise<Array<{ id: string; name: string; type: string; tag: string }>> {
|
||||
const workflowRecord = await getWorkflowById(workflowId)
|
||||
|
||||
const variablesRecord = (workflowRecord?.variables as Record<string, unknown>) || {}
|
||||
return Object.values(variablesRecord)
|
||||
.filter((v): v is Record<string, unknown> => {
|
||||
if (!v || typeof v !== 'object') return false
|
||||
const variable = v as Record<string, unknown>
|
||||
return !!variable.name && String(variable.name).trim() !== ''
|
||||
})
|
||||
.map((v) => ({
|
||||
id: String(v.id || ''),
|
||||
name: String(v.name || ''),
|
||||
type: String(v.type || 'plain'),
|
||||
tag: `variable.${normalizeName(String(v.name || ''))}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function getSubflowInsidePaths(
|
||||
blockType: 'loop' | 'parallel',
|
||||
blockId: string,
|
||||
loops: Record<string, Loop>,
|
||||
parallels: Record<string, Parallel>
|
||||
): string[] {
|
||||
const paths = ['index']
|
||||
if (blockType === 'loop') {
|
||||
const loopType = loops[blockId]?.loopType || 'for'
|
||||
if (loopType === 'forEach') {
|
||||
paths.push('currentItem', 'items')
|
||||
}
|
||||
} else {
|
||||
const parallelType = parallels[blockId]?.parallelType || 'count'
|
||||
if (parallelType === 'collection') {
|
||||
paths.push('currentItem', 'items')
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function formatOutputsForDisplay(paths: string[], blockName: string): string[] {
|
||||
const normalizedName = normalizeName(blockName)
|
||||
return paths.map((path) => `${normalizedName}.${path}`)
|
||||
}
|
||||
|
||||
export async function executeGetDeployedWorkflowState(
|
||||
params: GetDeployedWorkflowStateParams,
|
||||
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)
|
||||
|
||||
try {
|
||||
const deployedState = await loadDeployedWorkflowState(workflowId)
|
||||
const formatted = formatNormalizedWorkflowForCopilot({
|
||||
blocks: deployedState.blocks,
|
||||
edges: deployedState.edges,
|
||||
loops: deployedState.loops as Record<string, Loop>,
|
||||
parallels: deployedState.parallels as Record<string, Parallel>,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
workflowName: workflowRecord.name || '',
|
||||
isDeployed: true,
|
||||
deploymentVersionId: deployedState.deploymentVersionId,
|
||||
deployedState: formatted,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
workflowId,
|
||||
workflowName: workflowRecord.name || '',
|
||||
isDeployed: false,
|
||||
message: 'Workflow has not been deployed yet.',
|
||||
},
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: toError(error).message }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user