diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index af90c4fedeb..8088df80d29 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -12,17 +12,14 @@ import { isDev } from '@/lib/core/config/env-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { getWorkflowDeploymentSummary, performChatUndeploy, performFullDeploy, } from '@/lib/workflows/orchestration' import { checkChatAccess } from '@/app/api/chat/utils' -import { - checkNeedsRedeployment, - createErrorResponse, - createSuccessResponse, -} from '@/app/api/workflows/utils' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { ChatDeployAuthNotAllowedError, validateChatDeployAuth, diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts index 049527d5fda..a00bc304b68 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts @@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, @@ -78,21 +80,9 @@ describe('/api/v2/workflows/[id]/deploy route definitions', () => { expect(v2DeployWorkflowContract.response.schema.parse(body)).toEqual(body) }) - it('keeps product analytics on the v2 adapter', async () => { - const result = { workflowId: 'workflow-1', workspaceId: 'workspace-1' } - await Reflect.get( - POST, - 'onSuccess' - )({ - principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, - result, - }) - expect(mocks.capture).toHaveBeenCalledWith( - 'user-1', - 'workflow_deployed', - { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, - expect.objectContaining({ groups: { workspace: 'workspace-1' } }) - ) + it('defers deploy analytics to durable activation', () => { + expect(Reflect.get(POST, 'onSuccess')).toBeUndefined() + expect(mocks.capture).not.toHaveBeenCalled() }) it('keeps undeploy on the authorized operation and declared response schema', () => { diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 4827ea5e8e0..d4a3051c075 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -43,20 +43,6 @@ export const POST = defineV2JsonRoute({ latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }), - onSuccess: ({ principal, result }) => { - if (principal.kind !== 'personal_api_key') { - throw new Error('Admin deployment unexpectedly admitted a workspace API key') - } - captureServerEvent( - principal.userId, - 'workflow_deployed', - { workflow_id: result.workflowId, workspace_id: result.workspaceId }, - { - groups: { workspace: result.workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) - }, }) export const DELETE = defineV2JsonRoute({ diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index f5de09391d9..5cce4b1bad5 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -236,7 +236,22 @@ describe('POST /api/v2/workflows/[id]/execute', () => { rateLimitSubscription: null, keyType: 'workspace', }) - dbChainMockFns.limit.mockResolvedValue([applicationContext]) + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + .mockResolvedValueOnce([ + { + id: applicationContext.workspaceId, + organizationId: applicationContext.workspaceOrganizationId, + allowPersonalApiKeys: applicationContext.allowPersonalApiKeys, + billedAccountUserId: applicationContext.billedAccountUserId, + }, + ]) mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ key: `workflow-execution-id:${executionId}`, @@ -420,9 +435,23 @@ describe('POST /api/v2/workflows/[id]/execute', () => { rateLimitSubscription: null, keyType: 'personal', }) - dbChainMockFns.limit.mockResolvedValueOnce([ - { ...applicationContext, allowPersonalApiKeys: false }, - ]) + dbChainMockFns.limit.mockReset() + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + .mockResolvedValueOnce([ + { + id: applicationContext.workspaceId, + organizationId: applicationContext.workspaceOrganizationId, + allowPersonalApiKeys: false, + billedAccountUserId: applicationContext.billedAccountUserId, + }, + ]) const res = await callExecute({ input: {} }) @@ -487,12 +516,16 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('runs the anonymous public path sync but refuses async', async () => { + dbChainMockFns.limit.mockReset() dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) const okRes = await callPublicExecute({ input: {} }) expect(okRes.status).toBe(200) + expect(mockCheckPreAuthRate.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[0] + ) expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() expect(mockCheckOperationRate).not.toHaveBeenCalled() expect(mockPreprocessExecution).toHaveBeenCalledWith( @@ -506,7 +539,24 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect(asyncRes.status).toBe(400) }) + it('rejects anonymous abuse before looking up the workflow', async () => { + mockCheckPreAuthRate.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-08-08T05:00:00Z'), + retryAfterMs: 10_000, + }) + + const response = await callPublicExecute({ input: {} }) + + expect(response.status).toBe(429) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mockValidatePublicApiAllowed).not.toHaveBeenCalled() + expect(mockAuthenticateV2ApiKey).not.toHaveBeenCalled() + }) + it('401s non-public workflows without a key', async () => { + dbChainMockFns.limit.mockReset() dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) @@ -532,6 +582,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('returns a safe error when canonical workflow lookup fails', async () => { + dbChainMockFns.limit.mockReset() dbChainMockFns.limit.mockRejectedValueOnce(new Error('database connection details')) const response = await callExecute({ input: {} }) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 5c7a78cad99..6b7b120aaab 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -11,7 +11,7 @@ import { } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' import { - admitV2Request, + admitOptionalV2Request, V2RouteInfrastructureError, v2ApiKeyAuth, v2RateLimits, @@ -112,14 +112,15 @@ export const POST = withRouteHandler( let isPublicApiAccess = false let apiKeyPrincipal: V2ApiKeyPrincipal | undefined - if (req.headers.has('x-api-key')) { - const admission = await admitV2Request( - req, - workflowOperations.execute, - v2ApiKeyAuth, - v2RateLimits.publicApi - ) - if (!admission.success) return admission.response + const admission = await admitOptionalV2Request( + req, + workflowOperations.execute, + v2ApiKeyAuth, + v2RateLimits.publicApi + ) + if (!admission.success) return admission.response + + if (admission.auth) { apiKeyPrincipal = admission.auth.principal userId = admission.auth.rolloutUserId } else { diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts index 34d98dd6f1e..41996501de0 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.test.ts @@ -5,16 +5,16 @@ import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition), - capture: vi.fn(), })) vi.mock('@/lib/api/server/routes', () => ({ + createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })), + createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })), defineV2JsonRoute: mocks.defineRoute, v2ApiKeyAuth: { kind: 'v2-api-key' }, v2RateLimits: { publicApi: { kind: 'public-api' } }, v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, })) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' @@ -75,19 +75,7 @@ describe('/api/v2/workflows/[id]/rollback route definition', () => { expect(v2RollbackWorkflowContract.response.schema.parse(body)).toEqual(body) }) - it('keeps activation analytics on the v2 adapter', async () => { - await Reflect.get( - POST, - 'onSuccess' - )({ - principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, - result: { workflowId: 'workflow-1', workspaceId: 'workspace-1', version: 1 }, - }) - expect(mocks.capture).toHaveBeenCalledWith( - 'user-1', - 'deployment_version_activated', - { workflow_id: 'workflow-1', workspace_id: 'workspace-1', version: 1 }, - { groups: { workspace: 'workspace-1' } } - ) + it('defers activation analytics to durable activation', () => { + expect(Reflect.get(POST, 'onSuccess')).toBeUndefined() }) }) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index 42c1ef2e58e..ae0e1a3519f 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -1,7 +1,6 @@ import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { generateRequestId } from '@/lib/core/utils/request' -import { captureServerEvent } from '@/lib/posthog/server' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -40,19 +39,4 @@ export const POST = defineV2JsonRoute({ latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, }, }), - onSuccess: ({ principal, result }) => { - if (principal.kind !== 'personal_api_key') { - throw new Error('Admin activation unexpectedly admitted a workspace API key') - } - captureServerEvent( - principal.userId, - 'deployment_version_activated', - { - workflow_id: result.workflowId, - workspace_id: result.workspaceId, - version: result.version, - }, - { groups: { workspace: result.workspaceId } } - ) - }, }) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 30960639858..35da4ac6eef 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,275 +1,132 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db, workflow } from '@sim/db' -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { updatePublicApiContract } from '@/lib/api/contracts/deployments' -import { parseRequest } from '@/lib/api/server' -import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { + deployWorkflowContract, + getDeploymentInfoContract, + undeployWorkflowContract, + updatePublicApiContract, +} from '@/lib/api/contracts/deployments' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' -import { getWorkflowDeploymentSummary } from '@/lib/workflows/orchestration' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { - checkNeedsRedeployment, - createErrorResponse, - createSuccessResponse, -} from '@/app/api/workflows/utils' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' import { - PublicApiNotAllowedError, - validatePublicApiAllowed, -} from '@/ee/access-control/utils/permission-check' - -const logger = createLogger('WorkflowDeployAPI') + deployWorkflow, + readWorkflowDeploymentStatus, + undeployWorkflow, +} from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { updateWorkflowPublicApi } from '@/lib/workflows/application/update-workflow-deployment-settings' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - try { - const { error, workflow: workflowData } = await validateWorkflowPermissions( - id, - requestId, - 'read' - ) - if (error) { - return createErrorResponse(error.message, error.status) - } - - /** - * A workflow is deployed only when an active version snapshot exists — - * the same definition POST and the v1 routes use. The legacy - * `workflow.isDeployed` flag is deliberately not consulted: when it - * disagrees with the version table the workflow cannot actually serve - * traffic, so reporting it as live would be untruthful. - */ - const deploymentSummary = await getWorkflowDeploymentSummary(id) - const isDeployed = deploymentSummary.activeDeployment !== null - - if (!isDeployed) { - logger.info(`[${requestId}] Workflow is not deployed: ${id}`) - return createSuccessResponse({ - isDeployed: false, - deployedAt: null, - apiKey: null, - needsRedeployment: false, - isPublicApi: workflowData.isPublicApi ?? false, - activeDeployment: deploymentSummary.activeDeployment, - latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, - warnings: deploymentSummary.warnings, - }) - } - - const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status - const needsRedeployment = - attemptStatus === 'preparing' || attemptStatus === 'activating' - ? false - : await checkNeedsRedeployment(id) - - logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) - - const responseApiKeyInfo = workflowData.workspaceId - ? 'Workspace API keys' - : 'Personal API keys' - - return createSuccessResponse({ - apiKey: responseApiKeyInfo, - isDeployed, - deployedAt: deploymentSummary.activeDeployment?.deployedAt ?? workflowData.deployedAt, - needsRedeployment, - isPublicApi: workflowData.isPublicApi ?? false, - activeDeployment: deploymentSummary.activeDeployment, - latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, - warnings: deploymentSummary.warnings, - }) - } catch (error: unknown) { - logger.error(`[${requestId}] Error fetching deployment info: ${id}`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to fetch deployment information', 500) - } - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - try { - const principal = await internalSessionAuth.authenticate() - const result = await deployWorkflow.execute({ - principal, - input: { workflowId: id, requestId }, - request, - }) - - const isDeployed = Boolean(result.activeDeployment) - const attemptActivated = result.latestDeploymentAttempt?.status === 'active' - logger.info( - `[${requestId}] Workflow deployment ${attemptActivated ? 'activated' : 'accepted for preparation'}: ${id}` - ) - - captureServerEvent( - principal.userId, - 'workflow_deployed', - { workflow_id: result.workflowId, workspace_id: result.workspaceId }, - { - groups: { workspace: result.workspaceId }, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) - - return createSuccessResponse({ - apiKey: 'Workspace API keys', - isDeployed, - deployedAt: result.deployedAt, - warnings: result.warnings, - activeDeployment: result.activeDeployment, - latestDeploymentAttempt: result.latestDeploymentAttempt, - }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) - } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error(`[${requestId}] Error deploying workflow: ${id}`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to deploy workflow', 500) - } - } -) - -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(updatePublicApiContract, request, context, { - validationErrorResponse: () => - createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { isPublicApi } = parsed.data.body - - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - await assertWorkflowMutable(id) - - if (isPublicApi) { - try { - await validatePublicApiAllowed(session?.user?.id, workflowData?.workspaceId ?? undefined) - } catch (err) { - if (err instanceof PublicApiNotAllowedError) { - return createErrorResponse('Public API access is disabled', 403) - } - throw err - } - } - - await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id)) - - logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`) - - const wsId = workflowData?.workspaceId - - recordAudit({ - workspaceId: wsId ?? null, - actorId: session!.user.id, - action: AuditAction.WORKFLOW_PUBLIC_API_TOGGLED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: id, - resourceName: workflowData?.name ?? undefined, - description: `${isPublicApi ? 'Enabled' : 'Disabled'} public API for workflow "${workflowData?.name ?? id}"`, - metadata: { isPublicApi }, - request, - }) - - captureServerEvent( - session!.user.id, - 'workflow_public_api_toggled', - { workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi }, - wsId ? { groups: { workspace: wsId } } : undefined - ) - - return createSuccessResponse({ isPublicApi }) - } catch (error: unknown) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) - } - logger.error(`[${requestId}] Error updating deployment settings`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to update deployment settings', 500) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const { id } = await params - - try { - const principal = await internalSessionAuth.authenticate() - const result = await undeployWorkflow.execute({ - principal, - input: { workflowId: id, requestId }, - request, - }) - captureServerEvent( - principal.userId, - 'workflow_undeployed', - { workflow_id: result.workflowId, workspace_id: result.workspaceId }, - { groups: { workspace: result.workspaceId } } - ) - - return createSuccessResponse({ +const NO_INTERNAL_RATE_LIMIT = internalRateLimits.none({ + reason: + 'Authenticated workspace UI deployment operations retain their existing admission policy.', +}) + +export const GET = defineInternalJsonRoute({ + contract: getDeploymentInfoContract, + operation: workflowOperations.read, + useCase: readWorkflowDeploymentStatus, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to fetch deployment information'), + mapInput: ({ params }) => ({ workflowId: params.id }), + present: (result) => { + if (!result.isDeployed) { + return { isDeployed: false, deployedAt: null, apiKey: null, + needsRedeployment: false, + isPublicApi: result.workflow.isPublicApi ?? false, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings, - }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return createErrorResponse('Failed to undeploy workflow', 500) } - } -) + return { + apiKey: result.workflow.workspaceId ? 'Workspace API keys' : 'Personal API keys', + isDeployed: true, + deployedAt: result.activeDeployment?.deployedAt ?? result.workflow.deployedAt?.toISOString(), + needsRedeployment: result.needsRedeployment, + isPublicApi: result.workflow.isPublicApi ?? false, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, + warnings: result.warnings, + } + }, +}) + +export const POST = defineInternalJsonRoute({ + contract: deployWorkflowContract, + operation: workflowOperations.deploy, + useCase: deployWorkflow, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to deploy workflow'), + mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), + present: (result) => ({ + apiKey: 'Workspace API keys', + isDeployed: Boolean(result.activeDeployment), + deployedAt: result.deployedAt?.toISOString(), + warnings: result.warnings, + activeDeployment: result.activeDeployment, + latestDeploymentAttempt: result.latestDeploymentAttempt, + }), +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updatePublicApiContract, + operation: workflowOperations.updatePublicApi, + useCase: updateWorkflowPublicApi, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to update deployment settings'), + mapInput: ({ params, body }) => ({ + workflowId: params.id, + isPublicApi: body.isPublicApi, + }), + present: (result) => ({ isPublicApi: result.isPublicApi }), + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'workflow_public_api_toggled', + { + workflow_id: result.workflowId, + workspace_id: result.workspaceId, + is_public: result.isPublicApi, + }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: undeployWorkflowContract, + operation: workflowOperations.undeploy, + useCase: undeployWorkflow, + auth: internalSessionAuth, + rateLimit: NO_INTERNAL_RATE_LIMIT, + errorPolicy: createInternalWorkflowErrorPolicy('Failed to undeploy workflow'), + mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }), + present: (result) => ({ + isDeployed: false, + deployedAt: null, + apiKey: null, + warnings: result.warnings, + }), + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'workflow_undeployed', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts index a8854c4afdf..374b99edaa5 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts @@ -1,39 +1,47 @@ /** - * Tests for the workflow deployed-state API route. - * Covers internal-JWT authorization (acting user required + workspace read - * permission) and the unchanged session path. - * * @vitest-environment node */ - -import { - workflowAuthzMockFns, - workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, - workflowsUtilsMock, - workflowsUtilsMockFns, -} from '@sim/testing' +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockVerifyInternalToken } = vi.hoisted(() => ({ - mockVerifyInternalToken: vi.fn(), +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const { + InvalidDelegationTokenError, + mockBindExecutorDelegation, + mockReadWorkflowDefinition, + mockVerifyDelegationToken, +} = vi.hoisted(() => ({ + InvalidDelegationTokenError: class InvalidDelegationTokenError extends Error {}, + mockBindExecutorDelegation: vi.fn(), + mockReadWorkflowDefinition: vi.fn(), + mockVerifyDelegationToken: vi.fn(), })) vi.mock('@/lib/auth/internal', () => ({ - verifyInternalToken: mockVerifyInternalToken, + InvalidInternalDelegationTokenError: InvalidDelegationTokenError, + verifyInternalDelegationToken: mockVerifyDelegationToken, })) -vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindExecutorDelegation, + InvalidInternalDelegationBindingError: class InvalidInternalDelegationBindingError extends Error {}, +})) -import { GET } from './route' +vi.mock('@/lib/workflows/application/read-workflow-definition', () => { + const operation = { + id: 'workflows.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], + } as const + return { + readWorkflowDefinition: { operation, execute: mockReadWorkflowDefinition }, + } +}) -const mockAuthorizeWorkflowByWorkspacePermission = - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission -const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState -const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions +import { GET } from '@/app/api/workflows/[id]/deployed/route' const DEPLOYED_STATE = { blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, @@ -43,160 +51,119 @@ const DEPLOYED_STATE = { variables: {}, } -function createRequest(options?: { bearerToken?: string }) { - const headers: Record = {} - if (options?.bearerToken) { - headers.Authorization = `Bearer ${options.bearerToken}` - } - return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers }) +const SESSION = { + user: { id: 'user-123' }, + session: { id: 'session-123' }, +} + +const EXECUTOR_PRINCIPAL = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-123', + workspaceId: 'workspace-456', + delegationId: 'delegation-123', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-08T00:00:00.000Z'), + expiresAt: new Date('2999-08-08T00:00:00.000Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: 'origin-workflow', + executionId: 'origin-run', + }, +} + +function createRequest(bearerToken?: string) { + return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { + headers: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : undefined, + }) } const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) }) +function readResult(state: typeof DEPLOYED_STATE | null = DEPLOYED_STATE) { + return { + workflow: { id: 'workflow-123' }, + workspaceId: 'workspace-456', + state, + } +} + describe('GET /api/workflows/[id]/deployed', () => { beforeEach(() => { vi.clearAllMocks() - mockVerifyInternalToken.mockResolvedValue({ valid: false }) - mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE) + authMockFns.mockGetSession.mockResolvedValue(SESSION) + mockReadWorkflowDefinition.mockResolvedValue(readResult()) + mockVerifyDelegationToken.mockResolvedValue({ + subjectUserId: 'user-123', + workflowId: 'origin-workflow', + executionId: 'origin-run', + }) + mockBindExecutorDelegation.mockResolvedValue(EXECUTOR_PRINCIPAL) }) - describe('internal JWT path', () => { - it('returns 200 when the token carries a user with read permission', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, - workspacePermission: 'read', - }) - - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.deployedState).toEqual(DEPLOYED_STATE) - expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ - workflowId: 'workflow-123', - userId: 'user-123', - action: 'read', - }) - expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled() - }) + it('passes the authenticated session principal through the application use case', async () => { + const response = await GET(createRequest(), routeParams()) - it('returns 403 when the acting user lacks read permission', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to read this workflow', - workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, - workspacePermission: null, + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ deployedState: DEPLOYED_STATE }) + expect(mockReadWorkflowDefinition).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-123', sessionId: 'session-123' }, + input: { workflowId: 'workflow-123', state: 'deployed' }, }) + ) + }) - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to read this workflow') - expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() - }) - - it('returns 403 when the token carries no acting user (fail closed)', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined }) - - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Forbidden') - expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() - expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() - }) + it('accepts only the canonically bound executor principal for Bearer requests', async () => { + const response = await GET(createRequest('signed-token'), routeParams()) - it('returns 404 when the workflow does not exist', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 404, - message: 'Workflow not found', - workflow: null, - workspacePermission: null, + expect(response.status).toBe(200) + expect(mockBindExecutorDelegation).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'origin-workflow', executionId: 'origin-run' }), + { audience: 'sim:workflows', resourceScope: undefined } + ) + expect(mockReadWorkflowDefinition).toHaveBeenCalledWith( + expect.objectContaining({ + principal: EXECUTOR_PRINCIPAL, + input: { workflowId: 'workflow-123', state: 'deployed' }, }) - - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) - - expect(response.status).toBe(404) - const data = await response.json() - expect(data.error).toBe('Workflow not found') - expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() - }) + ) + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() }) - describe('session path', () => { - it('returns 200 when session permissions validate', async () => { - mockValidateWorkflowPermissions.mockResolvedValue({ - error: null, - session: { user: { id: 'user-123' } }, - workflow: { id: 'workflow-123' }, - }) - - const response = await GET(createRequest(), routeParams()) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.deployedState).toEqual(DEPLOYED_STATE) - expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith( - 'workflow-123', - expect.any(String), - 'read' - ) - expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() - }) - - it('propagates validateWorkflowPermissions errors unchanged', async () => { - mockValidateWorkflowPermissions.mockResolvedValue({ - error: { message: 'Unauthorized', status: 401 }, - session: null, - workflow: null, - }) + it('fails closed when a Bearer delegation cannot be verified', async () => { + mockVerifyDelegationToken.mockRejectedValue(new InvalidDelegationTokenError()) - const response = await GET(createRequest(), routeParams()) + const response = await GET(createRequest('invalid-token'), routeParams()) - expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') - }) + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Authentication required' }) + expect(authMockFns.mockGetSession).not.toHaveBeenCalled() + expect(mockReadWorkflowDefinition).not.toHaveBeenCalled() + }) - it('falls back to session validation when the bearer token is not a valid internal token', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: false }) - mockValidateWorkflowPermissions.mockResolvedValue({ - error: { message: 'Unauthorized', status: 401 }, - session: null, - workflow: null, - }) + it('projects application authorization failures without loading state in the route', async () => { + mockReadWorkflowDefinition.mockRejectedValue( + new OrchestrationError('forbidden', 'Delegated workflow access is no longer valid') + ) - const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams()) + const response = await GET(createRequest('signed-token'), routeParams()) - expect(response.status).toBe(401) - expect(mockValidateWorkflowPermissions).toHaveBeenCalled() - expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Delegated workflow access is no longer valid', }) }) - it('returns null deployedState when loading the snapshot fails', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, - workspacePermission: 'admin', - }) - mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment')) + it('preserves null deployed state and disables caching', async () => { + mockReadWorkflowDefinition.mockResolvedValue(readResult(null)) - const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + const response = await GET(createRequest(), routeParams()) expect(response.status).toBe(200) - const data = await response.json() - expect(data.deployedState).toBeNull() + await expect(response.json()).resolves.toEqual({ deployedState: null }) + expect(response.headers.get('cache-control')).toBe( + 'no-store, no-cache, must-revalidate, max-age=0' + ) }) }) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index 60e8feaf7ec..6a9ad7d1bc4 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -1,108 +1,46 @@ import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import type { NextRequest, NextResponse } from 'next/server' -import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments' -import { parseRequest } from '@/lib/api/server' -import { verifyInternalToken } from '@/lib/auth/internal' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + deployedWorkflowStateSchema, + getDeployedWorkflowStateContract, +} from '@/lib/api/contracts/deployments' +import { + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalWorkflowSessionOrExecutorAuth } from '@/lib/workflows/api' +import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' const logger = createLogger('WorkflowDeployedStateAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -function addNoCacheHeaders(response: NextResponse): NextResponse { - response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - return response -} - -/** - * GET /api/workflows/[id]/deployed - * Returns the active deployed state snapshot for a workflow. - * - * Internal (server-to-server) calls must carry the acting user in the internal - * JWT payload (`generateInternalToken(userId)` — the executor's - * `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that - * user with the same workspace-read semantics as the sibling - * `/api/workflows/[id]` route. Internal calls without a user id are rejected - * (fail closed). Session calls are authorized via - * `validateWorkflowPermissions` as before. - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const parsed = await parseRequest(getDeployedWorkflowStateContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - try { - const authHeader = request.headers.get('authorization') - let isInternalCall = false - let internalCallUserId: string | undefined - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1] - const verification = await verifyInternalToken(token) - isInternalCall = verification.valid - internalCallUserId = verification.userId - } - - if (isInternalCall) { - if (!internalCallUserId) { - logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`) - return addNoCacheHeaders(createErrorResponse('Forbidden', 403)) - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: id, - userId: internalCallUserId, - action: 'read', +export const GET = defineInternalJsonRoute({ + contract: getDeployedWorkflowStateContract, + auth: internalWorkflowSessionOrExecutorAuth, + operation: readWorkflowDefinition.operation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal workflow read behavior', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.id, state: 'deployed' as const }), + useCase: readWorkflowDefinition, + present: ({ state }) => ({ + deployedState: state + ? deployedWorkflowStateSchema.parse({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + parallels: state.parallels, + variables: 'variables' in state ? (state.variables ?? {}) : {}, }) - if (!authorization.workflow) { - logger.warn(`[${requestId}] Workflow ${id} not found for internal call`) - return addNoCacheHeaders(createErrorResponse('Workflow not found', 404)) - } - if (!authorization.allowed) { - logger.warn( - `[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}` - ) - return addNoCacheHeaders( - createErrorResponse(authorization.message || 'Access denied', authorization.status) - ) - } - } else { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - const response = createErrorResponse(error.message, error.status) - return addNoCacheHeaders(response) - } - } - - let deployedState = null - try { - const data = await loadDeployedWorkflowState(id) - deployedState = { - blocks: data.blocks, - edges: data.edges, - loops: data.loops, - parallels: data.parallels, - variables: data.variables, - } - } catch (error) { - logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error }) - deployedState = null - } - - const response = createSuccessResponse({ deployedState }) - return addNoCacheHeaders(response) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error) - const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500) - return addNoCacheHeaders(response) - } - } -) + : null, + }), + onSuccess: ({ input, result }) => { + if (!result.state) logger.warn('Workflow has no active deployed state', input) + }, + responseHeaders: () => ({ + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + }), +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 1b2746f525f..7b03ec104b1 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,70 +1,41 @@ -import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import type { NextRequest } from 'next/server' -import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRevertToVersion } from '@/lib/workflows/orchestration' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('RevertToDeploymentVersionAPI') +import { revertToDeploymentVersionContract } from '@/lib/api/contracts/deployments' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' +import { revertWorkflowVersion } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const POST = withRouteHandler( - async ( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } - ) => { - const requestId = generateRequestId() - const { id, version } = await params - - try { - const { - error, - session, - workflow: workflowRecord, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } - await assertWorkflowMutable(id) - - const versionValidation = workflowDeploymentVersionParamSchema.safeParse(version) - if (!versionValidation.success) { - return createErrorResponse('Invalid version', 400) - } - - const result = await performRevertToVersion({ - workflowId: id, - version: versionValidation.data, - userId: session!.user.id, - workflow: (workflowRecord ?? {}) as Record, - request, - actorName: session!.user.name ?? undefined, - actorEmail: session!.user.email ?? undefined, - }) - - if (!result.success) { - return createErrorResponse( - result.error || 'Failed to revert', - result.errorCode === 'not_found' ? 404 : 500 - ) - } - - return createSuccessResponse({ - message: 'Reverted to deployment version', - lastSaved: result.lastSaved, - }) - } catch (error: any) { - if (error instanceof WorkflowLockedError) { - return createErrorResponse(error.message, error.status) - } - - logger.error('Error reverting to deployment version', error) - return createErrorResponse(error.message || 'Failed to revert', 500) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: revertToDeploymentVersionContract, + operation: workflowOperations.revertVersion, + useCase: revertWorkflowVersion, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI version reverts retain their existing admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to revert'), + mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), + present: (result) => ({ + message: 'Reverted to deployment version', + lastSaved: result.lastSaved, + }), + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'workflow_deployment_reverted', + { + workflow_id: result.workflowId, + workspace_id: result.workspaceId, + version: String(result.version), + }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts new file mode 100644 index 00000000000..7dece65c9bc --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + activate: vi.fn(), + parseRequest: vi.fn(), + read: vi.fn(), + session: vi.fn(), + update: vi.fn(), +})) + +vi.mock('@/lib/api/server', () => ({ + getValidationErrorMessage: vi.fn(), + parseRequest: mocks.parseRequest, +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: vi.fn(() => vi.fn()), + InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, + internalSessionAuth: { authenticate: mocks.session }, +})) + +vi.mock('@/lib/workflows/api', () => ({ + createInternalWorkflowErrorPolicy: vi.fn(() => ({ + project: vi.fn(), + unhandled: vi.fn(), + })), +})) + +vi.mock('@/lib/core/utils/with-route-handler', () => ({ + withRouteHandler: (handler: unknown) => handler, +})) + +vi.mock('@/lib/workflows/application/deployments', () => ({ + activateWorkflowVersion: { execute: mocks.activate }, + updateWorkflowVersion: { execute: mocks.update }, +})) + +vi.mock('@/lib/workflows/application/read-workflow-version', () => ({ + readWorkflowVersion: { execute: mocks.read }, +})) + +import { PATCH } from '@/app/api/workflows/[id]/deployments/[version]/route' + +describe('workflow deployment version PATCH', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + mocks.activate.mockResolvedValue({ + deployedAt: new Date('2026-01-01T00:00:00Z'), + warnings: undefined, + activeDeployment: null, + latestDeploymentAttempt: null, + name: 'Release 2', + description: 'Production', + }) + mocks.update.mockResolvedValue({ name: 'Release 2', description: 'Production' }) + }) + + it('sends activation and optional metadata through one application command', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { + params: { id: 'workflow-1', version: 2 }, + body: { isActive: true, name: 'Release 2', description: 'Production' }, + }, + }) + + const response = await PATCH( + createMockRequest( + 'PATCH', + undefined, + {}, + 'http://localhost/api/workflows/workflow-1/deployments/2' + ), + { params: Promise.resolve({ id: 'workflow-1', version: '2' }) } + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + success: true, + name: 'Release 2', + description: 'Production', + }) + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + workflowId: 'workflow-1', + version: 2, + name: 'Release 2', + description: 'Production', + }), + }) + ) + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('keeps metadata-only edits on the existing update-version operation', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { + params: { id: 'workflow-1', version: 2 }, + body: { isActive: false, name: 'Release 2' }, + }, + }) + + const response = await PATCH( + createMockRequest( + 'PATCH', + undefined, + {}, + 'http://localhost/api/workflows/workflow-1/deployments/2' + ), + { params: Promise.resolve({ id: 'workflow-1', version: '2' }) } + ) + + expect(response.status).toBe(200) + expect(mocks.update).toHaveBeenCalledOnce() + expect(mocks.activate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 42516bd676a..e19432df48d 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -1,18 +1,26 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments' +import { + getDeploymentVersionStateContract, + updateDeploymentVersionMetadataContract, +} from '@/lib/api/contracts/deployments' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' +import { + defineInternalJsonRoute, + InternalUnauthenticatedError, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { activateWorkflowVersion } from '@/lib/workflows/application/deployments' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' +import { + activateWorkflowVersion, + updateWorkflowVersion, +} from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' -import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeploymentVersionAPI') @@ -21,48 +29,18 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const GET = withRouteHandler( - async ( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } - ) => { - const requestId = generateRequestId() - const { id, version } = await params - - try { - const principal = await internalSessionAuth.authenticate() - - const versionNum = Number(version) - if (!Number.isFinite(versionNum)) { - return createErrorResponse('Invalid version', 400) - } - - const { version: row } = await readWorkflowVersion.execute({ - principal, - input: { workflowId: id, version: versionNum }, - request, - }) - - return createSuccessResponse({ deployedState: row.state }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) - } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error( - `[${requestId}] Error fetching deployment version ${version} for workflow ${id}`, - { error } - ) - return createErrorResponse('Failed to fetch deployment version', 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getDeploymentVersionStateContract, + operation: workflowOperations.readVersion, + useCase: readWorkflowVersion, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI version reads retain their existing admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to fetch deployment version'), + mapInput: ({ params }) => ({ workflowId: params.id, version: params.version }), + present: ({ version }) => ({ deployedState: version.state }), +}) export const PATCH = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { @@ -85,84 +63,41 @@ export const PATCH = withRouteHandler( if (isActive) { const activateResult = await activateWorkflowVersion.execute({ principal, - input: { workflowId: id, version: versionNum, transition: 'activate', requestId }, + input: { + workflowId: id, + version: versionNum, + transition: 'activate', + requestId, + name, + description, + }, request, }) - let updatedName: string | null | undefined - let updatedDescription: string | null | undefined if (name !== undefined || description !== undefined) { - const activationUpdateData: { name?: string; description?: string | null } = {} - if (name !== undefined) { - activationUpdateData.name = name - } - if (description !== undefined) { - activationUpdateData.description = description - } - - const [updated] = await db - .update(workflowDeploymentVersion) - .set(activationUpdateData) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) - ) - ) - .returning({ - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - }) - - if (updated) { - updatedName = updated.name - updatedDescription = updated.description - logger.info( - `[${requestId}] Updated deployment version ${version} metadata during activation`, - { name: activationUpdateData.name, description: activationUpdateData.description } - ) - } + logger.info( + `[${requestId}] Updated deployment version ${version} metadata during activation`, + { name, description } + ) } - captureServerEvent( - principal.userId, - 'deployment_version_activated', - { - workflow_id: activateResult.workflowId, - workspace_id: activateResult.workspaceId, - version: versionNum, - }, - { groups: { workspace: activateResult.workspaceId } } - ) - return createSuccessResponse({ success: true, deployedAt: activateResult.deployedAt ?? null, warnings: activateResult.warnings, activeDeployment: activateResult.activeDeployment ?? null, latestDeploymentAttempt: activateResult.latestDeploymentAttempt ?? null, - ...(updatedName !== undefined && { name: updatedName }), - ...(updatedDescription !== undefined && { description: updatedDescription }), + ...(name !== undefined && { name: activateResult.name ?? null }), + ...(description !== undefined && { description: activateResult.description ?? null }), }) } - const { error } = await validateWorkflowPermissions(id, requestId, 'write') - if (error) { - return createErrorResponse(error.message, error.status) - } - - // Handle name/description updates (shared with the update_deployment_version copilot tool) - const updated = await updateDeploymentVersionMetadata({ - workflowId: id, - version: versionNum, - name, - description, + const updated = await updateWorkflowVersion.execute({ + principal, + input: { workflowId: id, version: versionNum, name, description }, + request, }) - if (!updated) { - return createErrorResponse('Deployment version not found', 404) - } - logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, { name, description, diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index f958f5b5de3..feeb094bba9 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -1,53 +1,31 @@ -import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' import { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments' -import { parseRequest } from '@/lib/api/server' -import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api' import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('WorkflowDeploymentsListAPI') +import { workflowOperations } from '@/lib/workflows/application/operations' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const principal = await internalSessionAuth.authenticate() - const parsed = await parseRequest(listDeploymentVersionsContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - const { versions: rows } = await listWorkflowVersions.execute({ - principal, - input: { workflowId: id }, - request, - }) - const versions = rows.map(({ deployedByName, ...version }) => ({ - ...version, - deployedBy: deployedByName, - })) - - return createSuccessResponse({ versions }) - } catch (error: unknown) { - if (error instanceof InternalUnauthenticatedError) { - return createErrorResponse(error.message, 401) - } - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return createErrorResponse( - orchestrationError.message, - statusForOrchestrationError(orchestrationError.code) - ) - } - logger.error(`[${requestId}] Error listing workflow deployments`, { error }) - return createErrorResponse('Failed to list deployments', 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: listDeploymentVersionsContract, + operation: workflowOperations.listVersions, + useCase: listWorkflowVersions, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated workspace UI version lists retain their existing admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to list deployments'), + mapInput: ({ params }) => ({ workflowId: params.id }), + present: ({ versions }) => ({ + versions: versions.map(({ deployedByName, ...version }) => ({ + ...version, + createdAt: version.createdAt.toISOString(), + deployedBy: deployedByName, + })), + }), +}) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index cc61e399dd8..0db40b5ee81 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -121,7 +121,7 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) -vi.mock('@/app/api/workflows/utils', () => ({ +vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mockCheckNeedsRedeployment, })) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 839d8f5f595..2943bb76423 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -98,6 +98,7 @@ import { hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { enqueueWorkflowExecution } from '@/lib/workflows/executor/enqueue-execution' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' @@ -139,7 +140,6 @@ import { } from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { PublicApiNotAllowedError, diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 9b9be85824c..e65dbfecf03 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -1,943 +1,171 @@ /** - * Integration tests for workflow by ID API route - * Tests the new centralized permissions system - * * @vitest-environment node */ - -import { - auditMock, - dbChainMockFns, - hybridAuthMockFns, - resetDbChainMock, - telemetryMock, - workflowAuthzMockFns, - workflowsOrchestrationMock, - workflowsOrchestrationMockFns, - workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, - workflowsUtilsMock, - workflowsUtilsMockFns, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows' - -const mockLoadWorkflowFromNormalizedTables = - workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables -const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById -const mockAuthorizeWorkflowByWorkspacePermission = - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission -const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow -const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow - -/** - * Helper to set mock auth state consistently across getSession and hybrid auth. - */ -function mockGetSession(session: { user: { id: string } } | null) { - if (session) { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ - success: true, - userId: session.user.id, - }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: session.user.id, - }) - } else { - hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: false }) - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - } +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + capture: vi.fn(), + defineRoute: vi.fn((definition) => definition), + deleteWorkflow: vi.fn(), + parseRequest: vi.fn(), + readWorkflow: vi.fn(), + updatePolicy: vi.fn(), + updateWorkflow: vi.fn(), +})) + +vi.mock('@/lib/api/server', () => ({ parseRequest: mocks.parseRequest })) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: mocks.defineRoute, + InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {}, + internalPlainOrchestrationErrorPolicy: { kind: 'plain-orchestration' }, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +vi.mock('@/lib/workflows/api', () => ({ + internalWorkflowSessionOrExecutorAuth: { authenticate: mocks.auth }, +})) + +vi.mock('@/lib/workflows/application/read-workflow-definition', () => ({ + readWorkflowDefinition: { + operation: { id: 'workflows.read' }, + execute: mocks.readWorkflow, + }, +})) + +vi.mock('@/lib/workflows/application/delete-workflow', () => ({ + deleteWorkflow: { + operation: { id: 'workflows.delete' }, + execute: mocks.deleteWorkflow, + }, +})) + +vi.mock('@/lib/workflows/application/update-workflow', () => ({ + updateWorkflow: { + operation: { id: 'workflows.update' }, + execute: mocks.updateWorkflow, + }, + updateWorkflowPolicy: { + operation: { id: 'workflows.policy.update' }, + execute: mocks.updatePolicy, + }, +})) + +import { DELETE, GET, PUT } from '@/app/api/workflows/[id]/route' + +const sessionPrincipal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', } -vi.mock('@/lib/core/telemetry', () => telemetryMock) - -vi.mock('@sim/audit', () => auditMock) - -vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) - -vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) - -import { DELETE, GET, PUT } from './route' - -describe('Workflow By ID API Route', () => { - afterAll(() => { - resetDbChainMock() - }) - +describe('/api/workflows/[id] application adapters', () => { beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - - vi.stubGlobal('crypto', { - randomUUID: vi.fn().mockReturnValue('mock-request-id-12345678'), - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null) - mockPerformUpdateWorkflow.mockImplementation(async (params) => ({ - success: true, - workflow: { - id: params.workflowId, - name: params.name ?? params.currentName, - description: params.description ?? null, - workspaceId: params.workspaceId, - folderId: params.folderId ?? params.currentFolderId ?? null, - sortOrder: params.sortOrder ?? null, - locked: params.locked ?? null, - forkSyncExcluded: params.forkSyncExcluded ?? null, - createdAt: new Date(), - updatedAt: new Date(), - archivedAt: null, - }, - })) - }) - - describe('GET /api/workflows/[id]', () => { - it('should return 401 when user is not authenticated', async () => { - mockGetSession(null) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(401) - const data = await response.json() - expect(data.error).toBe('Unauthorized') - }) - - it('should return 404 when workflow does not exist', async () => { - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(null) - - const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent') - const params = Promise.resolve({ id: 'nonexistent' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(404) - const data = await response.json() - expect(data.error).toBe('Workflow not found') - }) - - it.concurrent('should allow access when user has admin workspace permission', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const mockNormalizedData = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - isFromNormalizedTables: true, - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.id).toBe('workflow-123') - }) - - it('omits null workflow description from state metadata so response validates', async () => { - const mockWorkflow = { - id: 'workflow-null-description', - userId: 'user-123', - name: 'No Description Workflow', - description: null, - workspaceId: 'workspace-456', - folderId: null, - sortOrder: 0, - color: '#3972F6', - lastSynced: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - isDeployed: false, - deployedAt: null, - isPublicApi: false, - locked: false, - runCount: 0, - lastRunAt: null, - archivedAt: null, - variables: {}, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - mockLoadWorkflowFromNormalizedTables.mockResolvedValue({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-null-description') - const params = Promise.resolve({ id: 'workflow-null-description' }) - - const response = await GET(req, { params }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data.state.metadata).toEqual({ name: 'No Description Workflow' }) - expect(getWorkflowResponseDataSchema.safeParse(data.data).success).toBe(true) - }) - - it.concurrent('should allow access when user has workspace permissions', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const mockNormalizedData = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - isFromNormalizedTables: true, - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'read', - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.id).toBe('workflow-123') + mocks.auth.mockResolvedValue(sessionPrincipal) + mocks.updateWorkflow.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Renamed', locked: false, forkSyncExcluded: false }, + workspaceId: 'workspace-1', + changes: ['name'], }) - - it('should deny access when user has no workspace permissions', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to read this workflow', - workflow: mockWorkflow, - workspacePermission: null, - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to read this workflow') - }) - - it.concurrent('should use normalized tables when available', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const mockNormalizedData = { - blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, - edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], - loops: {}, - parallels: {}, - isFromNormalizedTables: true, - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.data.state.blocks).toEqual(mockNormalizedData.blocks) - expect(data.data.state.edges).toEqual(mockNormalizedData.edges) + mocks.updatePolicy.mockResolvedValue({ + workflow: { id: 'workflow-1', name: 'Workflow', locked: true, forkSyncExcluded: false }, + workspaceId: 'workspace-1', + changes: ['locked'], }) }) - describe('DELETE /api/workflows/[id]', () => { - it('should allow admin to delete workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.success).toBe(true) - expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-123', - userId: 'user-123', - }) - ) + it('binds GET and DELETE directly to fixed application use cases', () => { + expect(GET).toMatchObject({ + operation: { id: 'workflows.read' }, + useCase: { operation: { id: 'workflows.read' } }, }) - - it('should allow admin to delete workspace workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.success).toBe(true) + expect(Reflect.get(GET, 'mapInput')({ params: { id: 'workflow-1' } })).toEqual({ + workflowId: 'workflow-1', + state: 'draft', }) - it('should prevent deletion of the last workflow in workspace', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ - success: false, - error: 'Cannot delete the only workflow in the workspace', - errorCode: 'validation', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Cannot delete the only workflow in the workspace') + expect(DELETE).toMatchObject({ + operation: { id: 'workflows.delete' }, + useCase: { operation: { id: 'workflows.delete' } }, }) - - it('should allow user with write permission to delete workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - mockPerformDeleteWorkflow.mockResolvedValue({ success: true }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.success).toBe(true) - expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'workflow-123', action: 'write' }) - ) - }) - - it.concurrent('should deny deletion for read-only users', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to write this workflow', - workflow: mockWorkflow, - workspacePermission: 'read', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await DELETE(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to write this workflow') + expect(Reflect.get(DELETE, 'mapInput')({ params: { id: 'workflow-1' } })).toEqual({ + workflowId: 'workflow-1', }) }) - describe('PUT /api/workflows/[id]', () => { - it('should allow user with write permission to update workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const updateData = { name: 'Updated Workflow' } - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(updateData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.name).toBe('Updated Workflow') + it('keeps human delete analytics surface-specific and no-op aware', async () => { + const onSuccess = Reflect.get(DELETE, 'onSuccess') + await onSuccess({ + principal: sessionPrincipal, + result: { archived: false, workflowId: 'workflow-1', workspaceId: 'workspace-1' }, }) + expect(mocks.capture).not.toHaveBeenCalled() - it('should allow users with write permission to update workflow', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const updateData = { name: 'Updated Workflow' } - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(updateData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.name).toBe('Updated Workflow') + await onSuccess({ + principal: sessionPrincipal, + result: { archived: true, workflowId: 'workflow-1', workspaceId: 'workspace-1' }, }) + expect(mocks.capture).toHaveBeenCalledOnce() + }) - it('should deny update for users with only read permission', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'other-user', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - const updateData = { name: 'Updated Workflow' } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: false, - status: 403, - message: 'Unauthorized: Access denied to write this workflow', - workflow: mockWorkflow, - workspacePermission: 'read', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(updateData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Unauthorized: Access denied to write this workflow') - }) - - it.concurrent('should validate request data', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const invalidData = { name: '' } - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify(invalidData), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(400) - const data = await response.json() - expect(data.error).toBe('Validation error') - }) - - it('should reject rename when duplicate name exists in same folder', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Original Name', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - mockPerformUpdateWorkflow.mockResolvedValueOnce({ - success: false, - error: 'A workflow named "Duplicate Name" already exists in this folder', - errorCode: 'conflict', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ name: 'Duplicate Name' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(409) - const data = await response.json() - expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder') - }) - - it('should reject rename when duplicate name exists at root level', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Original Name', - folderId: null, - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - mockPerformUpdateWorkflow.mockResolvedValueOnce({ - success: false, - error: 'A workflow named "Duplicate Name" already exists in this folder', - errorCode: 'conflict', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ name: 'Duplicate Name' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(409) - const data = await response.json() - expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder') - }) - - it('should allow rename when no duplicate exists in same folder', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Original Name', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ name: 'Unique Name' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.name).toBe('Unique Name') - }) - - it('should allow same name in different folders', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'My Workflow', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ folderId: 'folder-2' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.folderId).toBe('folder-2') + it('selects one fixed update command without route-owned resource work', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } }, }) - it('should reject moving to a folder where same name already exists', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'My Workflow', - folderId: 'folder-1', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - mockPerformUpdateWorkflow.mockResolvedValueOnce({ - success: false, - error: 'A workflow named "My Workflow" already exists in this folder', - errorCode: 'conflict', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ folderId: 'folder-2' }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(409) - const data = await response.json() - expect(data.error).toBe('A workflow named "My Workflow" already exists in this folder') + const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), { + params: Promise.resolve({ id: 'workflow-1' }), }) - it('should skip duplicate check when only updating non-name/non-folder fields', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ description: 'Updated description' }), + expect(response.status).toBe(200) + expect(mocks.updateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', name: 'Renamed' }, }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) + ) + expect(mocks.updatePolicy).not.toHaveBeenCalled() + }) - expect(response.status).toBe(200) - expect(dbChainMockFns.select).not.toHaveBeenCalled() + it('uses the dedicated policy command and emits only human product analytics', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { locked: true } }, }) - it('should deny forkSyncExcluded update for non-admin users', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - forkSyncExcluded: false, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'write', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ forkSyncExcluded: true }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(403) - const data = await response.json() - expect(data.error).toBe('Admin access required to exclude workflows from sync') - expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled() + const response = await PUT(createMockRequest('PUT', { locked: true }), { + params: Promise.resolve({ id: 'workflow-1' }), }) - it('should allow admin to toggle forkSyncExcluded and carry it on the response', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - forkSyncExcluded: false, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ forkSyncExcluded: true }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) + expect(response.status).toBe(200) + expect(mocks.updatePolicy).toHaveBeenCalledOnce() + expect(mocks.updateWorkflow).not.toHaveBeenCalled() + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'workflow_lock_toggled', + expect.objectContaining({ workflow_id: 'workflow-1', locked: true }), + expect.any(Object) + ) + }) - expect(response.status).toBe(200) - const data = await response.json() - expect(data.workflow.forkSyncExcluded).toBe(true) - expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'workflow-123', - forkSyncExcluded: true, - currentForkSyncExcluded: false, - }) - ) + it('projects unknown update failures safely', async () => { + mocks.parseRequest.mockResolvedValue({ + success: true, + data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } }, }) + mocks.updateWorkflow.mockRejectedValueOnce(new Error('postgres password=secret')) - it('should skip the mutability check for an exclusion-only update (locked workflow stays togglable)', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: 'workspace-456', - locked: true, - forkSyncExcluded: false, - } - - mockGetSession({ user: { id: 'user-123' } }) - mockGetWorkflowById.mockResolvedValue(mockWorkflow) - mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: mockWorkflow, - workspacePermission: 'admin', - }) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'PUT', - body: JSON.stringify({ forkSyncExcluded: true }), - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await PUT(req, { params }) - - expect(response.status).toBe(200) - expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled() + const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), { + params: Promise.resolve({ id: 'workflow-1' }), }) - }) - - describe('Error handling', () => { - it.concurrent('should handle database errors gracefully', async () => { - mockGetSession({ user: { id: 'user-123' } }) - - mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout')) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123') - const params = Promise.resolve({ id: 'workflow-123' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(500) - const data = await response.json() - expect(data.error).toBe('Internal server error') - }) + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ error: 'Internal server error' }) }) }) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index ec30ce24829..0cbb072b5a1 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,359 +1,157 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { - assertFolderMutable, - assertWorkflowMutable, - authorizeWorkflowByWorkspacePermission, - FolderLockedError, - WorkflowLockedError, -} from '@sim/platform-authz/workflow' -import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkflowContract } from '@/lib/api/contracts/workflows' + deleteWorkflowContract, + getWorkflowResponseDataSchema, + getWorkflowStateContract, + updateWorkflowContract, +} from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' -import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' +import { + defineInternalJsonRoute, + InternalUnauthenticatedError, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' -import { getWorkflowById } from '@/lib/workflows/utils' +import { internalWorkflowSessionOrExecutorAuth } from '@/lib/workflows/api' +import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' +import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' +import { updateWorkflow, updateWorkflowPolicy } from '@/lib/workflows/application/update-workflow' const logger = createLogger('WorkflowByIdAPI') -/** - * GET /api/workflows/[id] - * Fetch a single workflow by ID - * Uses hybrid approach: try normalized tables first, fallback to JSON blob - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success) { - logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const isInternalCall = auth.authType === AuthType.INTERNAL_JWT - const userId = auth.userId || null - - let workflowData = await getWorkflowById(workflowId) - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) - } - - if (isInternalCall && !userId) { - // Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId. - // These are already authenticated via internal JWT; allow read access. - logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`) - } else if (!userId) { - logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } else { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'read', - }) - if (!authorization.workflow) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - workflowData = authorization.workflow - if (!authorization.allowed) { - logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } - ) - } - } - - const snapshot = await loadWorkflowReadSnapshot(workflowId) - const responseWorkflowData = snapshot.workflowRecord ?? workflowData - - // Stamp `workflowId` from the path param on each variable so the - // global client-side variables store can filter by workflow without - // requiring persisted variables to carry a redundant `workflowId`. - // The persisted blob may or may not include `workflowId` depending on - // when the variable was last written; the path param is authoritative. - const persistedVariables = - (responseWorkflowData.variables as Record>) || {} - const stampedVariables: Record> = {} - for (const [variableId, variable] of Object.entries(persistedVariables)) { - if (variable && typeof variable === 'object') { - stampedVariables[variableId] = { ...variable, workflowId } - } - } - const workflowStateMetadata = { - name: responseWorkflowData.name, - ...(typeof responseWorkflowData.description === 'string' - ? { description: responseWorkflowData.description } - : {}), - } - - if (snapshot.normalizedData) { - const finalWorkflowData = { - ...responseWorkflowData, - state: { - blocks: snapshot.normalizedData.blocks, - edges: snapshot.normalizedData.edges, - loops: snapshot.normalizedData.loops, - parallels: snapshot.normalizedData.parallels, - lastSaved: Date.now(), - isDeployed: responseWorkflowData.isDeployed || false, - deployedAt: responseWorkflowData.deployedAt, - metadata: workflowStateMetadata, - }, - variables: stampedVariables, - } - - logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`) - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`) - - return NextResponse.json({ data: finalWorkflowData }, { status: 200 }) +const workflowInternalRateLimit = internalRateLimits.none({ + reason: 'Preserve existing internal workflow CRUD behavior', +}) + +export const GET = defineInternalJsonRoute({ + contract: getWorkflowStateContract, + auth: internalWorkflowSessionOrExecutorAuth, + operation: readWorkflowDefinition.operation, + rateLimit: workflowInternalRateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.id, state: 'draft' as const }), + useCase: readWorkflowDefinition, + present: ({ workflow: workflowData, state }) => { + const persistedVariables = + (workflowData.variables as Record>) || {} + const stampedVariables: Record> = {} + for (const [variableId, variable] of Object.entries(persistedVariables)) { + if (variable && typeof variable === 'object') { + stampedVariables[variableId] = { ...variable, workflowId: workflowData.id } } - - const emptyWorkflowData = { - ...responseWorkflowData, + } + const workflowStateMetadata = { + name: workflowData.name, + ...(typeof workflowData.description === 'string' + ? { description: workflowData.description } + : {}), + } + return { + data: getWorkflowResponseDataSchema.parse({ + ...workflowData, state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, + blocks: state?.blocks ?? {}, + edges: state?.edges ?? [], + loops: state?.loops ?? {}, + parallels: state?.parallels ?? {}, lastSaved: Date.now(), - isDeployed: responseWorkflowData.isDeployed || false, - deployedAt: responseWorkflowData.deployedAt, + isDeployed: workflowData.isDeployed || false, + deployedAt: workflowData.deployedAt, metadata: workflowStateMetadata, }, variables: stampedVariables, - } - - return NextResponse.json({ data: emptyWorkflowData }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -/** - * DELETE /api/workflows/[id] - * Delete a workflow by ID - */ -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = auth.userId - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const canDelete = authorization.allowed - - if (!canDelete) { - logger.warn( - `[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } - - await assertWorkflowMutable(workflowId) - - const result = await performDeleteWorkflow({ - workflowId, - userId, - requestId, - }) - - if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: result.error }, { status }) - } - - captureServerEvent( - userId, - 'workflow_deleted', - { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, - workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined - ) - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error: any) { - if (error instanceof WorkflowLockedError) { - return NextResponse.json({ error: error.message }, { status: error.status }) - } - - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }), } - } -) + }, + onSuccess: ({ result }) => { + logger.info('Successfully fetched workflow', { workflowId: result.workflow.id }) + }, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkflowContract, + auth: internalWorkflowSessionOrExecutorAuth, + operation: deleteWorkflow.operation, + rateLimit: workflowInternalRateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: deleteWorkflow, + present: () => ({ success: true as const }), + onSuccess: ({ principal, result }) => { + if (principal.kind !== 'session' || !result.archived) return + captureServerEvent( + principal.userId, + 'workflow_deleted', + { workflow_id: result.workflowId, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, +}) -/** - * PUT /api/workflows/[id] - * Update workflow metadata (name, description, folderId) - */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await context.params - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = auth.userId - - const parsed = await parseRequest(updateWorkflowContract, request, context) - if (!parsed.success) return parsed.response - const updates = parsed.data.body - - // Fetch the workflow to check ownership/access - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', + const rawParams = await context.params + const principal = await internalWorkflowSessionOrExecutorAuth.authenticate(request, rawParams) + const parsed = await parseRequest(updateWorkflowContract, request, { + params: Promise.resolve(rawParams), }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const canUpdate = authorization.allowed - - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to update workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } + if (!parsed.success) return parsed.response - if (updates.locked !== undefined && authorization.workspacePermission !== 'admin') { - logger.warn( - `[${requestId}] User ${userId} denied permission to lock workflow ${workflowId}` - ) - return NextResponse.json( - { error: 'Admin access required to lock workflows' }, - { status: 403 } + const input = { workflowId: parsed.data.params.id, ...parsed.data.body } + const isPolicyUpdate = input.locked !== undefined || input.forkSyncExcluded !== undefined + const result = isPolicyUpdate + ? await updateWorkflowPolicy.execute({ principal, input, request }) + : await updateWorkflow.execute({ principal, input, request }) + + if (principal.kind === 'session' && result.changes.includes('locked')) { + captureServerEvent( + principal.userId, + 'workflow_lock_toggled', + { + workflow_id: result.workflow.id, + workspace_id: result.workspaceId, + locked: result.workflow.locked === true, + }, + { groups: { workspace: result.workspaceId } } ) } - - if (updates.forkSyncExcluded !== undefined && authorization.workspacePermission !== 'admin') { - logger.warn( - `[${requestId}] User ${userId} denied permission to change sync exclusion for workflow ${workflowId}` - ) - return NextResponse.json( - { error: 'Admin access required to exclude workflows from sync' }, - { status: 403 } + if (principal.kind === 'session' && result.changes.includes('forkSyncExcluded')) { + captureServerEvent( + principal.userId, + 'workflow_fork_sync_exclusion_toggled', + { + workflow_id: result.workflow.id, + workspace_id: result.workspaceId, + fork_sync_excluded: result.workflow.forkSyncExcluded === true, + }, + { groups: { workspace: result.workspaceId } } ) } - // Policy flags (lock, sync exclusion) don't modify content, so a locked workflow - // may still have them toggled; everything else requires mutability. - const hasNonPolicyUpdate = Object.keys(updates).some( - (key) => key !== 'locked' && key !== 'forkSyncExcluded' - ) - if (hasNonPolicyUpdate) { - await assertWorkflowMutable(workflowId) - } - if (updates.folderId !== undefined) { - await assertFolderMutable(updates.folderId) - } - - if (!workflowData.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - - const result = await performUpdateWorkflow({ - workflowId, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - currentLocked: workflowData.locked, - currentForkSyncExcluded: workflowData.forkSyncExcluded, - ...updates, - requestId, + logger.info('Successfully updated workflow', { + workflowId: result.workflow.id, + changes: result.changes, }) - - if (!result.success || !result.workflow) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json({ workflow: result.workflow }) + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) } - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates, - }) - - return NextResponse.json({ workflow: result.workflow }, { status: 200 }) - } catch (error: any) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - return NextResponse.json({ error: error.message }, { status: error.status }) + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return NextResponse.json( + { error: orchestrationError.message }, + { status: statusForOrchestrationError(orchestrationError.code) } + ) } - - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) + logger.error('Failed to update workflow', { error: getErrorMessage(error) }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index 4c1d56357d2..4c7a1860906 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -1,45 +1,24 @@ -import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' import { getWorkflowStatusContract } from '@/lib/api/contracts/workflows' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { - checkNeedsRedeployment, - createErrorResponse, - createSuccessResponse, -} from '@/app/api/workflows/utils' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { createInternalWorkflowErrorPolicy, internalWorkflowReadAuth } from '@/lib/workflows/api' +import { readWorkflowDeploymentStatus } from '@/lib/workflows/application/deployments' +import { workflowOperations } from '@/lib/workflows/application/operations' -const logger = createLogger('WorkflowStatusAPI') - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const parsed = await parseRequest(getWorkflowStatusContract, request, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - try { - const validation = await validateWorkflowAccess(request, id, false) - if (validation.error) { - logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) - return createErrorResponse(validation.error.message, validation.error.status) - } - - const needsRedeployment = validation.workflow.isDeployed - ? await checkNeedsRedeployment(id) - : false - - return createSuccessResponse({ - isDeployed: validation.workflow.isDeployed, - deployedAt: validation.workflow.deployedAt, - isPublished: validation.workflow.isPublished, - needsRedeployment, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting status for workflow: ${id}`, error) - return createErrorResponse('Failed to get status', 500) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: getWorkflowStatusContract, + auth: internalWorkflowReadAuth, + operation: workflowOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Workflow status retains its existing authenticated admission policy.', + }), + errorPolicy: createInternalWorkflowErrorPolicy('Failed to get status'), + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: readWorkflowDeploymentStatus, + present: (result) => ({ + isDeployed: result.isDeployed, + deployedAt: result.activeDeployment?.deployedAt + ? new Date(result.activeDeployment.deployedAt) + : result.workflow.deployedAt, + needsRedeployment: result.needsRedeployment, + }), +}) diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index d966621a67c..a6646d39505 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -1,11 +1,6 @@ -import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, desc, eq, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' -import { hasWorkflowChanged } from '@/lib/workflows/comparison' -import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowUtils') @@ -23,47 +18,6 @@ export function createSuccessResponse(data: any) { return NextResponse.json(data) } -/** - * Checks whether a deployed workflow has changes that require redeployment. - * Compares the current persisted state (from normalized tables) against the - * active deployment version state. - * - * This is the single source of truth for redeployment detection — used by - * both the /deploy and /status endpoints to ensure consistent results. - */ -/** - * Pure redeployment-change comparison shared by checkNeedsRedeployment and the - * VFS deployment serializer so both surfaces agree. Returns false when either - * side is missing. - */ -export function computeNeedsRedeployment( - currentSnapshot: WorkflowState | null | undefined, - activeState: WorkflowState | null | undefined -): boolean { - if (!activeState || !currentSnapshot) return false - return hasWorkflowChanged(currentSnapshot, activeState) -} - -export async function checkNeedsRedeployment(workflowId: string): Promise { - return db.transaction(async (tx) => { - await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) - const [active] = await tx - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1) - - const currentState = await loadWorkflowDeploymentSnapshot(workflowId, tx) - return computeNeedsRedeployment(currentState, (active?.state as WorkflowState) ?? null) - }) -} - /** * Verifies user's workspace permissions using the permissions table * @param userId User ID to check diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index ea7dfcc04d3..31cfe2bc7c8 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -43,6 +43,7 @@ const { mockSetExecutionDeadlineAt, mockSetTraceLargeValueAccess, mockDispose, + mockBuildExecutorDelegationHeaders, executorOptions, loggingSessionArgs, } = vi.hoisted(() => ({ @@ -62,6 +63,7 @@ const { mockSetExecutionDeadlineAt: vi.fn(), mockSetTraceLargeValueAccess: vi.fn(), mockDispose: vi.fn(), + mockBuildExecutorDelegationHeaders: vi.fn(), executorOptions: [] as Array>, loggingSessionArgs: [] as Array, })) @@ -182,7 +184,7 @@ vi.mock('@/lib/auth/internal', () => ({ })) vi.mock('@/executor/utils/http', () => ({ - buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }), + buildExecutorDelegationHeaders: mockBuildExecutorDelegationHeaders, buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')), extractAPIErrorMessage: vi.fn(async (response: Response) => { const defaultMessage = `API request failed with status ${response.status}` @@ -226,6 +228,7 @@ describe('WorkflowBlockHandler', () => { mockContext = { workflowId: 'parent-workflow-id', + userId: 'user-1', blockStates: new Map(), blockLogs: [], metadata: { duration: 0 }, @@ -250,6 +253,10 @@ describe('WorkflowBlockHandler', () => { mockSafeStart.mockResolvedValue(true) mockAdmitCustomBlockChildExecution.mockResolvedValue(undefined) mockBuildTraceSpans.mockReturnValue({ traceSpans: [], totalDuration: 0 }) + mockBuildExecutorDelegationHeaders.mockResolvedValue({ + 'Content-Type': 'application/json', + Authorization: 'Bearer executor-token', + }) // Setup default fetch mock mockFetch.mockResolvedValue({ @@ -342,7 +349,11 @@ describe('WorkflowBlockHandler', () => { const inputs = { workflowId: 'child-workflow-id' } it('should fail a cross-workspace child in the draft loader path', async () => { - const ctx = { ...mockContext, workspaceId: 'workspace-parent' } + const ctx = { + ...mockContext, + workspaceId: 'workspace-parent', + executionId: 'parent-execution-id', + } mockFetch.mockResolvedValueOnce({ ok: true, @@ -361,6 +372,11 @@ describe('WorkflowBlockHandler', () => { ) expect(mockCreateSnapshot).not.toHaveBeenCalled() expect(mockExecutorExecute).not.toHaveBeenCalled() + expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'user-1', + workflowId: 'parent-workflow-id', + executionId: 'parent-execution-id', + }) }) it('should fail a cross-workspace child in the deployed loader path', async () => { @@ -554,6 +570,10 @@ describe('WorkflowBlockHandler', () => { await handler.execute(ctx, customBlock, {}) + expect(mockBuildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'owner-9', + workflowId: 'source-workflow-id', + }) expect(mockResolveBillingAttribution).toHaveBeenCalledWith({ actorUserId: 'owner-9', workspaceId: 'workspace-source', @@ -991,7 +1011,7 @@ describe('WorkflowBlockHandler', () => { text: () => Promise.resolve(''), }) - const result = await (handler as any).loadChildWorkflow(workflowId) + const result = await (handler as any).loadChildWorkflow(workflowId, {}) expect(result).toBeNull() }) @@ -1010,7 +1030,7 @@ describe('WorkflowBlockHandler', () => { }), }) - await expect((handler as any).loadChildWorkflow(workflowId)).rejects.toThrow( + await expect((handler as any).loadChildWorkflow(workflowId, {})).rejects.toThrow( 'Child workflow invalid-workflow has invalid state' ) }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index c3cd00b84d6..ab10a3f1aa7 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -42,7 +42,7 @@ import { type StreamingExecution, } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' -import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' +import { buildAPIUrl, buildExecutorDelegationHeaders } from '@/executor/utils/http' import { getIterationContext } from '@/executor/utils/iteration-context' import { parseJSON } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' @@ -281,12 +281,21 @@ export class WorkflowBlockHandler implements BlockHandler { /** Settled in `finally` once the child is fully done — see `trackChildRun`. */ let settleChildRun: (() => void) | undefined try { + if (!loadUserId) { + throw new Error('Workflow child loading requires a human execution subject') + } + const workflowReadHeaders = await buildExecutorDelegationHeaders({ + subjectUserId: loadUserId, + workflowId: isCustomBlock ? workflowId : ctx.workflowId, + ...(!isCustomBlock && ctx.executionId ? { executionId: ctx.executionId } : {}), + }) + // A custom block runs the source's latest deployment; if the source has been // undeployed there's nothing to run. `BoundarySafeError` marks the message as // safe to cross the invocation boundary verbatim (it names no source // internals), so the catch forwards it instead of the generic failure. if (isCustomBlock) { - const deployed = await this.checkChildDeployment(workflowId, loadUserId) + const deployed = await this.checkChildDeployment(workflowId, workflowReadHeaders) if (!deployed) { throw new BoundarySafeError({ errorType: 'not_deployed', @@ -296,7 +305,7 @@ export class WorkflowBlockHandler implements BlockHandler { } if (useDeployed && !isCustomBlock) { - const hasActiveDeployment = await this.checkChildDeployment(workflowId, loadUserId) + const hasActiveDeployment = await this.checkChildDeployment(workflowId, workflowReadHeaders) if (!hasActiveDeployment) { throw new Error( `Child workflow is not deployed. Please deploy the workflow before invoking it.` @@ -305,8 +314,8 @@ export class WorkflowBlockHandler implements BlockHandler { } const childWorkflow = useDeployed - ? await this.loadChildWorkflowDeployed(workflowId, loadUserId) - : await this.loadChildWorkflow(workflowId, ctx.userId) + ? await this.loadChildWorkflowDeployed(workflowId, workflowReadHeaders) + : await this.loadChildWorkflow(workflowId, workflowReadHeaders) if (!childWorkflow) { throw new Error(`Child workflow ${workflowId} not found`) @@ -950,8 +959,7 @@ export class WorkflowBlockHandler implements BlockHandler { } } - private async loadChildWorkflow(workflowId: string, userId?: string) { - const headers = await buildAuthHeaders(userId) + private async loadChildWorkflow(workflowId: string, headers: Record) { const url = buildAPIUrl(`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) @@ -1012,9 +1020,11 @@ export class WorkflowBlockHandler implements BlockHandler { } } - private async checkChildDeployment(workflowId: string, userId?: string): Promise { + private async checkChildDeployment( + workflowId: string, + headers: Record + ): Promise { try { - const headers = await buildAuthHeaders(userId) const url = buildAPIUrl(`/api/workflows/${workflowId}/deployed`) const response = await fetch(url.toString(), { @@ -1035,8 +1045,7 @@ export class WorkflowBlockHandler implements BlockHandler { } } - private async loadChildWorkflowDeployed(workflowId: string, userId?: string) { - const headers = await buildAuthHeaders(userId) + private async loadChildWorkflowDeployed(workflowId: string, headers: Record) { const deployedUrl = buildAPIUrl(`/api/workflows/${workflowId}/deployed`) const deployedRes = await fetch(deployedUrl.toString(), { diff --git a/apps/sim/lib/api-key/application/create-api-key.ts b/apps/sim/lib/api-key/application/create-api-key.ts new file mode 100644 index 00000000000..cfd659b1174 --- /dev/null +++ b/apps/sim/lib/api-key/application/create-api-key.ts @@ -0,0 +1,55 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { apiKeyOperations } from '@/lib/api-key/application/operations' +import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface CreateCopilotWorkspaceApiKeyInput { + workspaceId: string + name: string +} + +export const createCopilotWorkspaceApiKey = defineAuthorizedWorkspaceUseCase({ + operation: apiKeyOperations.createFromCopilot, + resolveContext: async ({ input }: { input: CreateCopilotWorkspaceApiKeyInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { + delegation: { + audience: 'sim:api-keys', + isWithinScope: (principal, context) => principal.workspaceId === context.workspaceId, + }, + }, + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performCreateWorkspaceApiKey({ + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + name: input.name, + source: 'copilot', + projectLegacyAudit: false, + captureAnalytics: false, + }) + if (!result.success || !result.key) { + if (result.errorCode === 'conflict') { + throw new OrchestrationError('conflict', result.error ?? 'API key name already exists') + } + throw new Error('Failed to create workspace API key') + } + return { key: result.key, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: result.key.id, + resourceName: result.key.name, + description: `Created API key "${result.key.name}"`, + metadata: { keyName: result.key.name, keyType: 'workspace', source: 'copilot' }, + }), +}) diff --git a/apps/sim/lib/api-key/application/operations.ts b/apps/sim/lib/api-key/application/operations.ts new file mode 100644 index 00000000000..7494ae377ef --- /dev/null +++ b/apps/sim/lib/api-key/application/operations.ts @@ -0,0 +1,13 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const apiKeyOperations = { + createFromCopilot: defineWorkspaceOperation({ + id: 'api_keys.copilot.create', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), +} as const + +export type ApiKeyOperation = (typeof apiKeyOperations)[keyof typeof apiKeyOperations] diff --git a/apps/sim/lib/api-key/orchestration/index.ts b/apps/sim/lib/api-key/orchestration/index.ts index 934766fa4e4..f285bf0fe3f 100644 --- a/apps/sim/lib/api-key/orchestration/index.ts +++ b/apps/sim/lib/api-key/orchestration/index.ts @@ -15,6 +15,8 @@ export interface PerformCreateWorkspaceApiKeyParams { source?: string actorName?: string | null actorEmail?: string | null + projectLegacyAudit?: boolean + captureAnalytics?: boolean } export interface PerformCreateWorkspaceApiKeyResult { @@ -39,12 +41,14 @@ export async function performCreateWorkspaceApiKey( name: params.name, }) - try { - PlatformEvents.apiKeyGenerated({ - userId: params.userId, - keyName: params.name, - }) - } catch {} + if (params.captureAnalytics !== false) { + try { + PlatformEvents.apiKeyGenerated({ + userId: params.userId, + keyName: params.name, + }) + } catch {} + } logger.info('Created workspace API key', { workspaceId: params.workspaceId, @@ -52,22 +56,23 @@ export async function performCreateWorkspaceApiKey( name: params.name, }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: key.id, - resourceName: params.name, - description: `Created API key "${params.name}"`, - metadata: { - keyName: params.name, - keyType: 'workspace', - source: params.source ?? 'settings', - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: key.id, + resourceName: params.name, + description: `Created API key "${params.name}"`, + metadata: { + keyName: params.name, + keyType: 'workspace', + source: params.source ?? 'settings', + }, + }) return { success: true, key } } catch (error) { diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index f75fe366a79..03951db9c67 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -16,6 +16,7 @@ export { export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { defineV2BodyLifecycleRoute } from '@/lib/api/server/routes/v2-body-lifecycle-route' export { + admitOptionalV2Request, admitV2Request, defineV2JsonRoute, type V2ErrorPolicy, diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 283a1c6d64b..c750be3c1a2 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -110,27 +110,26 @@ export const v2OrchestrationErrorPolicy = { }, } satisfies V2ErrorPolicy -export async function admitV2Request( - request: NextRequest, - operation: ApplicationOperation, - authPolicy: typeof v2ApiKeyAuth, - rateLimitPolicy: V2RateLimitPolicy -): Promise< - { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } -> { +async function enforceV2PreAuthIpLimit(request: NextRequest): Promise { const ip = getClientIp(request) const abuseLimit = await rateLimiter.checkRateLimitDirect( `v2:preauth:ip:${ip}`, V2_PREAUTH_IP_LIMIT, { failClosed: true } ) - if (!abuseLimit.allowed) { - return { - success: false, - response: v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }), - } - } + return abuseLimit.allowed + ? null + : v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }) +} +async function admitAuthenticatedV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { let auth: V2ApiKeyAuthContext try { auth = await authPolicy.authenticate(request) @@ -153,6 +152,33 @@ export async function admitV2Request( return limited ? { success: false, response: limited } : { success: true, auth } } +export async function admitV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + const preAuthResponse = await enforceV2PreAuthIpLimit(request) + if (preAuthResponse) return { success: false, response: preAuthResponse } + return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) +} + +export async function admitOptionalV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth?: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + const preAuthResponse = await enforceV2PreAuthIpLimit(request) + if (preAuthResponse) return { success: false, response: preAuthResponse } + if (!request.headers.has('x-api-key')) return { success: true } + return admitAuthenticatedV2Request(request, operation, authPolicy, rateLimitPolicy) +} + interface V2JsonRouteOptions extends JsonRouteDefinition { auth: typeof v2ApiKeyAuth diff --git a/apps/sim/lib/copilot/application/execute-api-key-use-case.ts b/apps/sim/lib/copilot/application/execute-api-key-use-case.ts new file mode 100644 index 00000000000..d1ec9402336 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-api-key-use-case.ts @@ -0,0 +1,13 @@ +import { apiKeyOperations } from '@/lib/api-key/application/operations' +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' + +export const executeCopilotApiKeyUseCase = createCopilotApplicationAdapter({ + domain: 'API key', + delegation: { + audience: 'sim:api-keys', + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: apiKeyOperations, +}) diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts index f453b071688..7905253ac0d 100644 --- a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts @@ -9,10 +9,16 @@ vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ resolveWorkflowOutputs: { execute: mocks.execute }, })) -import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' +import { + executeCopilotResolveWorkflowOutputs, + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { workflowOperations } from '@/lib/workflows/application/operations' const trustedContext = { - userId: 'user-1', + userId: 'trusted-user', workspaceId: 'workspace-1', chatId: 'chat-1', executionId: 'execution-1', @@ -20,7 +26,7 @@ const trustedContext = { copilotToolExecution: true, } as const -describe('executeCopilotResolveWorkflowOutputs', () => { +describe('Copilot Workflow application adapter', () => { afterEach(() => { vi.clearAllMocks() vi.useRealTimers() @@ -46,7 +52,7 @@ describe('executeCopilotResolveWorkflowOutputs', () => { principal: { kind: 'delegated', serviceId: 'copilot', - subjectUserId: 'user-1', + subjectUserId: 'trusted-user', workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-call-1', audience: 'sim:workflows', @@ -58,7 +64,69 @@ describe('executeCopilotResolveWorkflowOutputs', () => { }) }) - it('rejects untrusted context before Workflow application execution', () => { + it('derives identity only from trusted adapter context', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + const useCase = { operation: workflowOperations.update, execute } + + await expect( + executeCopilotWorkflowUseCase(trustedContext, useCase, { + workflowId: 'workflow-1', + userId: 'forged-user', + }) + ).resolves.toEqual({ ok: true }) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'trusted-user', + workspaceId: 'workspace-1', + audience: 'sim:workflows', + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }), + input: { workflowId: 'workflow-1', userId: 'forged-user' }, + }) + }) + + it('supports workspace-scoped operations without inventing workflow scope', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + await executeCopilotWorkflowUseCase( + trustedContext, + { operation: workflowOperations.create, execute }, + { workspaceId: 'workspace-1', name: 'New workflow' } + ) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }), + input: { workspaceId: 'workspace-1', name: 'New workflow' }, + }) + }) + + it('rejects forged contexts and unregistered operations before execution', () => { + const execute = vi.fn() + expect(() => + executeCopilotWorkflowUseCase( + { ...trustedContext, copilotToolExecution: false }, + { operation: workflowOperations.read, execute }, + { workflowId: 'workflow-1' } + ) + ).toThrow('trusted Copilot execution context') + + expect(() => + executeCopilotWorkflowUseCase( + trustedContext, + { + operation: { ...workflowOperations.read, id: 'workflows.unregistered' }, + execute, + }, + { workflowId: 'workflow-1' } + ) + ).toThrow('Unregistered Copilot workflow operation') + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects untrusted context before fixed Workflow application execution', () => { expect(() => executeCopilotResolveWorkflowOutputs( { ...trustedContext, copilotToolExecution: false }, @@ -67,4 +135,13 @@ describe('executeCopilotResolveWorkflowOutputs', () => { ).toThrow('trusted Copilot execution context') expect(mocks.execute).not.toHaveBeenCalled() }) + + it('presents typed application errors and conceals unknown causes', () => { + expect( + messageForCopilotWorkflowError(new OrchestrationError('forbidden', 'Access denied')) + ).toBe('Access denied') + expect(messageForCopilotWorkflowError(new Error('database password'))).toBe( + 'Workflow operation failed' + ) + }) }) diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts index a91acba9560..706e68bb9e8 100644 --- a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts @@ -1,10 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, type CopilotExecutionContext, createCopilotApplicationPrincipal, requireTrustedCopilotExecutionContext, } from '@/lib/copilot/auth/application-delegation' +import type { OperationUseCase } from '@/lib/core/application' import { workflowDelegationPolicy } from '@/lib/workflows/application/authorization' +import { type WorkflowOperation, workflowOperations } from '@/lib/workflows/application/operations' import { type ResolveWorkflowOutputsInput, type ResolveWorkflowOutputsResult, @@ -20,6 +24,21 @@ const workflowDelegation = { `copilot-tool:${context.toolCallId}`, } as const +const executeWorkflowUseCase = createCopilotApplicationAdapter({ + domain: 'workflow', + delegation: workflowDelegation, + operations: workflowOperations, +}) + +/** Enters a registered workflow use case with identity derived only from trusted tool context. */ +export function executeCopilotWorkflowUseCase( + context: CopilotWorkflowDelegationContext | undefined, + useCase: OperationUseCase, + input: I +): Promise { + return executeWorkflowUseCase(context, useCase, input) +} + /** Resolves workflow output metadata through one fixed authorized Workflow command. */ export function executeCopilotResolveWorkflowOutputs( context: CopilotWorkflowDelegationContext | undefined, @@ -33,3 +52,11 @@ export function executeCopilotResolveWorkflowOutputs( input, }) } + +/** Projects actionable application errors without exposing infrastructure details to the model. */ +export function messageForCopilotWorkflowError( + error: unknown, + fallback = 'Workflow operation failed' +): string { + return messageForCopilotApplicationError(error, fallback) +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index e67b7fd1761..8573ac186b9 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1248,14 +1248,16 @@ export const Cp: ToolCatalogEntry = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', @@ -3714,9 +3716,10 @@ export const Mkdir: ToolCatalogEntry = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', @@ -3739,14 +3742,16 @@ export const Mv: ToolCatalogEntry = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', @@ -4179,9 +4184,10 @@ export const Rm: ToolCatalogEntry = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', - items: { type: 'string' }, + items: { type: 'string', maxLength: 4096 }, }, toolTitle: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 81188db33ab..7c470624386 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1112,15 +1112,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { @@ -3593,10 +3596,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { @@ -3615,15 +3620,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { destination: { type: 'string', + maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { @@ -4059,10 +4067,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { paths: { type: 'array', + maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', items: { type: 'string', + maxLength: 4096, }, }, toolTitle: { diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts index abcd14d5ef9..379c8c267fd 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts @@ -26,13 +26,13 @@ vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ }, })) +import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' +import type { ExecutionContext } from '@/lib/copilot/request/types' import { - applyCreateWorkflowOutputToContext, prepareWorkflowExecutionAdmission, resolveWorkflowExecutionBillingAttribution, WorkflowExecutionAdmissionError, -} from '@/lib/copilot/request/tools/workflow-context' -import type { ExecutionContext } from '@/lib/copilot/request/types' +} from '@/lib/workflows/execution-admission' const billingAttribution: BillingAttributionSnapshot = { actorUserId: 'user-1', diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.ts b/apps/sim/lib/copilot/request/tools/workflow-context.ts index 0e8fa1523d6..cf49411b723 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-context.ts @@ -1,43 +1,21 @@ import { isRecordLike } from '@sim/utils/object' -import { - reserveExecutionSlot, - UsageReservationUnavailableError, -} from '@/lib/billing/calculations/usage-reservation' -import { - type BillingAttributionSnapshot, - checkAttributedUsageLimits, - resolveBillingAttribution, -} from '@/lib/billing/core/billing-attribution' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { - getReservationDenialDescriptor, - type ReservationDenialReason, -} from '@/lib/core/admission/transient-failure' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' function getCreateWorkflowOutput( output: unknown ): { workflowId?: string; workspaceId?: string } | undefined { - if (!isRecordLike(output)) { - return undefined - } + if (!isRecordLike(output)) return undefined const workflowId = typeof output.workflowId === 'string' ? output.workflowId : undefined const workspaceId = typeof output.workspaceId === 'string' ? output.workspaceId : undefined - if (!workflowId && !workspaceId) { - return undefined - } - + if (!workflowId && !workspaceId) return undefined return { ...(workflowId ? { workflowId } : {}), ...(workspaceId ? { workspaceId } : {}), } } -/** - * Adopts a workflow returned by create_workflow only when it belongs to the - * ambient workspace and the Copilot lifecycle is not already workflow-rooted. - */ +/** Adopts a same-workspace workflow created by the current unrooted Copilot lifecycle. */ export function applyCreateWorkflowOutputToContext( output: unknown, context: ExecutionContext @@ -51,136 +29,5 @@ export function applyCreateWorkflowOutputToContext( ) { return } - context.workflowId = createdWorkflow.workflowId } - -/** - * Selects billing for one hosted workflow execution. Same-workspace work - * keeps the root snapshot; cross-workspace work gets a fresh child snapshot - * without mutating or implicitly replacing the root lifecycle attribution. - */ -export async function resolveWorkflowExecutionBillingAttribution( - context: ExecutionContext, - targetWorkspaceId: string -): Promise { - const rootAttribution = context.billingAttribution - if (!rootAttribution) { - return undefined - } - - if (rootAttribution.workspaceId === targetWorkspaceId) { - return rootAttribution - } - - const childAttribution = await resolveBillingAttribution({ - actorUserId: context.userId, - workspaceId: targetWorkspaceId, - }) - if ( - childAttribution.actorUserId !== context.userId || - childAttribution.workspaceId !== targetWorkspaceId - ) { - throw new Error('Resolved workflow billing attribution does not match its actor and workspace') - } - - return childAttribution -} - -export interface WorkflowExecutionAdmission { - billingAttribution: BillingAttributionSnapshot | undefined - targetReservation: boolean -} - -type ReservationDenialDescriptor = ReturnType - -export class WorkflowExecutionAdmissionError extends Error { - readonly code: ReservationDenialDescriptor['code'] - readonly statusCode: ReservationDenialDescriptor['statusCode'] - readonly retryable: ReservationDenialDescriptor['retryable'] - - constructor(message: string, descriptor: ReservationDenialDescriptor) { - super(message) - this.name = 'WorkflowExecutionAdmissionError' - this.code = descriptor.code - this.statusCode = descriptor.statusCode - this.retryable = descriptor.retryable - } -} - -const TARGET_RESERVATION_DENIAL_MESSAGE = { - payer_concurrency: 'Target workspace execution concurrency is currently exhausted', - payer_headroom: 'Target workspace payer usage headroom is currently exhausted', - member_headroom: 'Target workspace member usage headroom is currently exhausted', -} as const satisfies Record - -/** - * Admits one direct Copilot workflow execution. Same-workspace runs reuse the - * root lifecycle admission without another usage read or reservation. - * Cross-workspace runs use their separately frozen target snapshot and perform - * exactly one attributed usage check followed by one atomic reservation. - */ -export async function prepareWorkflowExecutionAdmission( - context: ExecutionContext, - targetWorkspaceId: string, - childExecutionId: string -): Promise { - const billingAttribution = await resolveWorkflowExecutionBillingAttribution( - context, - targetWorkspaceId - ) - const rootAttribution = context.billingAttribution - const isCrossWorkspace = - rootAttribution !== undefined && rootAttribution.workspaceId !== targetWorkspaceId - - if (!billingAttribution || !isCrossWorkspace) { - return { billingAttribution, targetReservation: false } - } - - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { - const descriptor = getReservationDenialDescriptor( - usage.scope === 'member' ? 'member_headroom' : 'payer_headroom' - ) - throw new WorkflowExecutionAdmissionError( - usage.message ?? 'Target workspace usage limit exceeded', - descriptor - ) - } - if (isHosted && isBillingEnabled && !usage.payerUsage) { - throw new UsageReservationUnavailableError( - 'Target workspace usage admission is temporarily unavailable. Please retry.' - ) - } - - const payerUsage = usage.payerUsage ?? { currentUsage: 0, limit: 0 } - const reservation = await reserveExecutionSlot({ - billingEntity: billingAttribution.billingEntity, - executionId: childExecutionId, - plan: billingAttribution.payerSubscription?.plan, - enterpriseConcurrencyLimit: billingAttribution.payerSubscription?.enterpriseConcurrencyLimit, - currentUsage: payerUsage.currentUsage, - limit: payerUsage.limit, - ...(billingAttribution.organizationId && - usage.memberUsage?.limit !== null && - usage.memberUsage?.limit !== undefined - ? { - member: { - organizationId: billingAttribution.organizationId, - actorUserId: billingAttribution.actorUserId, - currentUsage: usage.memberUsage.currentUsage, - limit: usage.memberUsage.limit, - }, - } - : {}), - }) - if (!reservation.reserved) { - const descriptor = getReservationDenialDescriptor(reservation.reason) - throw new WorkflowExecutionAdmissionError( - TARGET_RESERVATION_DENIAL_MESSAGE[reservation.reason], - descriptor - ) - } - - return { billingAttribution, targetReservation: true } -} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 916149e9b04..e37f623a234 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -51,8 +51,12 @@ vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock, })) -vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ + resolveCopilotWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + executeCopilotFileUseCase: vi.fn( + async (_context, _useCase, input: { fileId: string; maxBytes: number }) => + readWorkspaceFileContentMock(input) + ), })) vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ readWorkspaceFileContent: { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index ca51f6bafcd..1e948a911f6 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' @@ -29,6 +30,7 @@ import type { DeployCustomBlockParams } from '../param-types' const MAX_ICON_BYTES = 5 * 1024 * 1024 const MAX_INPUT_ENTRIES = 50 const MAX_OUTPUT_ENTRIES = 50 +const logger = createLogger('CopilotCustomBlockDeployment') /** * Resolve the agent-supplied icon reference to a publicly servable URL. A VFS @@ -139,7 +141,7 @@ export async function executeDeployCustomBlock( } catch (error) { const message = toError(error).message if (message.includes('not found')) { - return { success: false, error: message } + return { success: false, error: 'Workflow not found' } } return { success: false, @@ -301,6 +303,7 @@ export async function executeDeployCustomBlock( if (error instanceof CustomBlockValidationError) { return { success: false, error: error.message } } - return { success: false, error: toError(error).message } + logger.error('Custom block deployment failed', { error }) + return { success: false, error: 'Custom block deployment failed due to a system error' } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts index 3f67f333488..01727a9c9d0 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' +import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -11,6 +12,8 @@ const { mockPerformDeleteWorkflowMcpTool, mockPerformFullDeploy, mockPerformFullUndeploy, + mockExecuteCopilotMcpServerUseCase, + mockExecuteCopilotWorkflowUseCase, } = vi.hoisted(() => ({ mockCheckChatAccess: vi.fn(), mockEnsureWorkflowAccess: vi.fn(), @@ -18,6 +21,18 @@ const { mockPerformDeleteWorkflowMcpTool: vi.fn(), mockPerformFullDeploy: vi.fn(), mockPerformFullUndeploy: vi.fn(), + mockExecuteCopilotMcpServerUseCase: vi.fn(), + mockExecuteCopilotWorkflowUseCase: vi.fn(), +})) + +vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ + executeCopilotMcpServerUseCase: mockExecuteCopilotMcpServerUseCase, +})) + +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: mockExecuteCopilotWorkflowUseCase, + messageForCopilotWorkflowError: (error: unknown, fallback: string) => + getErrorMessage(error, fallback), })) vi.mock('@/lib/workflows/orchestration', () => ({ @@ -74,7 +89,7 @@ describe('deployment handlers', () => { }) it('undeploys the API without approval context when permission gating is disabled', async () => { - mockPerformFullUndeploy.mockResolvedValue({ success: true }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true }) const result = await executeDeployApi( { workflowId: 'workflow-1', action: 'undeploy' }, @@ -86,14 +101,15 @@ describe('deployment handlers', () => { ) expect(result.success).toBe(true) - expect(mockPerformFullUndeploy).toHaveBeenCalledWith({ - workflowId: 'workflow-1', - userId: 'user-1', - }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.undeploy' }) }), + expect.objectContaining({ workflowId: 'workflow-1' }) + ) }) it('uses the execution and deployment intent for semantic retry idempotency', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { status: 'preparing' }, @@ -114,7 +130,9 @@ describe('deployment handlers', () => { } ) - expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), expect.objectContaining({ idempotencyKey: 'copilot:execution-1:operation:deploy_api', }) @@ -122,7 +140,7 @@ describe('deployment handlers', () => { }) it('does not report an admitted deployment as successful before its version is active', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, version: 12, deploymentVersionId: 'version-12', @@ -148,7 +166,9 @@ describe('deployment handlers', () => { success: false, error: expect.stringContaining('not active'), }) - expect(mockPerformFullDeploy).toHaveBeenCalledWith( + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), expect.objectContaining({ idempotencyKey: 'copilot:execution-1:operation:deploy_api', }) @@ -156,7 +176,7 @@ describe('deployment handlers', () => { }) it('reports success only when the version admitted by this call is active', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, version: 12, deploymentVersionId: 'version-12', @@ -191,7 +211,7 @@ describe('deployment handlers', () => { }) it('rejects a replay whose active deployment attempt became historical', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { status: 'active', isCurrent: false }, @@ -219,7 +239,7 @@ describe('deployment handlers', () => { }) it('does not report a historical active attempt as a successful redeploy', async () => { - mockPerformFullDeploy.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { status: 'active', isCurrent: false }, @@ -246,8 +266,8 @@ describe('deployment handlers', () => { }) it('undeploys chat without approval context when permission gating is disabled', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([ - { + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + deployment: { id: 'chat-1', identifier: 'production-helper', title: 'Production Helper', @@ -259,9 +279,7 @@ describe('deployment handlers', () => { includeToolCalls: false, customizations: null, }, - ]) - mockCheckChatAccess.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1' }) - mockPerformChatUndeploy.mockResolvedValue({ success: true }) + }) const result = await executeDeployChat( { workflowId: 'workflow-1', action: 'undeploy' }, @@ -273,18 +291,21 @@ describe('deployment handlers', () => { ) expect(result.success).toBe(true) - expect(mockPerformChatUndeploy).toHaveBeenCalledWith({ - chatId: 'chat-1', - userId: 'user-1', - workspaceId: 'workspace-1', - }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.chat.undeploy' }), + }), + expect.objectContaining({ workflowId: 'workflow-1' }) + ) }) it('undeploys MCP without approval context when permission gating is disabled', async () => { - dbChainMockFns.limit - .mockResolvedValueOnce([{ id: 'server-1', name: 'Production MCP' }]) - .mockResolvedValueOnce([{ id: 'tool-1' }]) - mockPerformDeleteWorkflowMcpTool.mockResolvedValue({ success: true }) + mockExecuteCopilotMcpServerUseCase.mockResolvedValue({ + server: { id: 'server-1', name: 'Production MCP' }, + tool: { id: 'tool-1', toolName: 'run_workflow' }, + workflow: { id: 'workflow-1' }, + }) const result = await executeDeployMcp( { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' }, @@ -296,11 +317,14 @@ describe('deployment handlers', () => { ) expect(result.success).toBe(true) - expect(mockPerformDeleteWorkflowMcpTool).toHaveBeenCalledWith({ - serverId: 'server-1', - toolId: 'tool-1', - workspaceId: 'workspace-1', - userId: 'user-1', - }) + expect(mockExecuteCopilotMcpServerUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ + id: 'mcp_servers.workflow_deployments.undeploy_tool', + }), + }), + { serverId: 'server-1', workflowId: 'workflow-1' } + ) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 3c7aeb53435..5fd15db45d7 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -1,32 +1,20 @@ -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 { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' +import { + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { generateRequestId } from '@/lib/core/utils/request' 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, - sanitizeToolName, -} from '@/lib/mcp/workflow-tool-schema' + deployWorkflowMcpTool, + undeployWorkflowMcpTool, +} from '@/lib/mcp/application/workflow-deployments' import { - performChatDeploy, - performChatUndeploy, - performFullDeploy, - performFullUndeploy, -} from '@/lib/workflows/orchestration' -import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' -import { - ChatDeployAuthNotAllowedError, - validateChatDeployAuth, -} from '@/ee/access-control/utils/permission-check' -import { ensureWorkflowAccess } from '../access' + deployWorkflowChat, + undeployWorkflowChat, +} from '@/lib/workflows/application/chat-deployments' +import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' @@ -100,7 +88,7 @@ function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { /** Returns an error until this call's admitted version is the active production version. */ function getUnconfirmedDeploymentError( - result: Awaited>, + result: Awaited>, action: string ): string | null { const attempt = result.latestDeploymentAttempt @@ -162,14 +150,12 @@ export async function executeDeployApi( 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 }) + const result = await executeCopilotWorkflowUseCase(context, undeployWorkflow, { + workflowId, + assertedWorkspaceId: context.workspaceId, + requestId: generateRequestId(), + }) if (!result.success) { return { success: false, error: result.error || 'Failed to undeploy workflow' } } @@ -219,11 +205,12 @@ export async function executeDeployApi( } } - const result = await performFullDeploy({ + const result = await executeCopilotWorkflowUseCase(context, deployWorkflow, { workflowId, - userId: context.userId, - versionDescription, - versionName, + assertedWorkspaceId: context.workspaceId, + description: versionDescription, + name: versionName, + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'), }) if (!result.success) { @@ -277,7 +264,10 @@ export async function executeDeployApi( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update API deployment'), + } } } @@ -293,33 +283,14 @@ export async function executeDeployChat( const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' if (action === 'undeploy') { + const { deployment } = await executeCopilotWorkflowUseCase(context, undeployWorkflowChat, { + workflowId, + assertedWorkspaceId: context.workspaceId, + }) 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: { @@ -338,25 +309,25 @@ export async function executeDeployChat( }, chat: { isDeployed: false, - identifier: existing[0].identifier, - title: existing[0].title, + identifier: deployment.identifier, + title: deployment.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[]) || [], + identifier: deployment.identifier, + title: deployment.title, + description: deployment.description || '', + authType: deployment.authType, + allowedEmails: (deployment.allowedEmails as string[]) || [], outputConfigs: - (existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [], - includeThinking: existing[0].includeThinking ?? false, - includeToolCalls: existing[0].includeToolCalls ?? false, + (deployment.outputConfigs as Array<{ blockId: string; path: string }>) || [], + includeThinking: deployment.includeThinking ?? false, + includeToolCalls: deployment.includeToolCalls ?? false, welcomeMessage: - (existing[0].customizations as { welcomeMessage?: string } | null) - ?.welcomeMessage || 'Hi there! How can I help you today?', + (deployment.customizations as { welcomeMessage?: string } | null)?.welcomeMessage || + 'Hi there! How can I help you today?', }, }, examples: { @@ -368,26 +339,6 @@ export async function executeDeployChat( } } - 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 { @@ -406,102 +357,29 @@ export async function executeDeployChat( } } - 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 resolvedIncludeThinking = - typeof params.includeThinking === 'boolean' - ? params.includeThinking - : (existingDeployment?.includeThinking ?? false) - const resolvedIncludeToolCalls = - typeof params.includeToolCalls === 'boolean' - ? params.includeToolCalls - : (existingDeployment?.includeToolCalls ?? false) - const welcomeMessage = - typeof params.welcomeMessage === 'string' - ? params.welcomeMessage - : params.customizations?.welcomeMessage || existingCustomizations.welcomeMessage - const imageUrl = - params.customizations?.imageUrl || - params.customizations?.iconUrl || - existingCustomizations.imageUrl - - // Enforce the permission group's chat auth-mode allow-list, but only when the - // mode actually changes (or on a first deploy) so an existing grandfathered - // mode can be re-saved. - if (workflowRecord.workspaceId && resolvedAuthType !== existingDeployment?.authType) { - try { - await validateChatDeployAuth(context.userId, workflowRecord.workspaceId, resolvedAuthType) - } catch (error) { - if (error instanceof ChatDeployAuthNotAllowedError) { - return { success: false, error: error.message } - } - throw error - } - } - - const result = await performChatDeploy({ + const result = await executeCopilotWorkflowUseCase(context, deployWorkflowChat, { workflowId, - userId: context.userId, - identifier, - title, - description: resolvedDescription, + assertedWorkspaceId: context.workspaceId, + identifier: params.identifier, + title: params.title, + description: params.description, versionDescription, versionName, customizations: { - primaryColor: - params.customizations?.primaryColor || - existingCustomizations.primaryColor || - 'var(--brand-hover)', - welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', - ...(imageUrl ? { imageUrl } : {}), + primaryColor: params.customizations?.primaryColor, + welcomeMessage: params.welcomeMessage ?? params.customizations?.welcomeMessage, + imageUrl: params.customizations?.imageUrl ?? params.customizations?.iconUrl, }, - authType: resolvedAuthType, + authType: params.authType, password: params.password, - allowedEmails: resolvedAllowedEmails, - outputConfigs: resolvedOutputConfigs, - includeThinking: resolvedIncludeThinking, - includeToolCalls: resolvedIncludeToolCalls, - workspaceId: workflowRecord.workspaceId, + allowedEmails: params.allowedEmails, + outputConfigs: params.outputConfigs, + includeThinking: params.includeThinking, + includeToolCalls: params.includeToolCalls, + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_chat'), }) - 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) @@ -514,7 +392,7 @@ export async function executeDeployChat( action: 'deploy', isDeployed: true, isChatDeployed: true, - identifier, + identifier: result.identifier, chatUrl: result.chatUrl, apiEndpoint, baseUrl, @@ -530,31 +408,26 @@ export async function executeDeployChat( }, chat: { isDeployed: true, - identifier, + identifier: result.identifier, chatUrl: result.chatUrl, - title, - description: resolvedDescription, - authType: resolvedAuthType, + title: result.title, + description: result.description, + authType: result.authType, }, }, deploymentConfig: { api: apiConfig, chat: { - identifier, + identifier: result.identifier, chatUrl: result.chatUrl, - title, - description: resolvedDescription, - authType: resolvedAuthType, - allowedEmails: resolvedAllowedEmails, - outputConfigs: resolvedOutputConfigs, - includeThinking: resolvedIncludeThinking, - includeToolCalls: resolvedIncludeToolCalls, - welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', - primaryColor: - params.customizations?.primaryColor || - existingCustomizations.primaryColor || - 'var(--brand-hover)', - ...(imageUrl ? { imageUrl } : {}), + title: result.title, + description: result.description, + authType: result.authType, + allowedEmails: result.allowedEmails, + outputConfigs: result.outputConfigs, + includeThinking: result.includeThinking, + includeToolCalls: result.includeToolCalls, + ...result.customizations, }, }, examples: { @@ -568,7 +441,10 @@ export async function executeDeployChat( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update chat deployment'), + } } } @@ -582,16 +458,6 @@ export async function executeDeployMcp( 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 { @@ -599,58 +465,17 @@ export async function executeDeployMcp( 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({ + const result = await executeCopilotMcpServerUseCase(context, undeployWorkflowMcpTool, { serverId, - toolId: existingTool.id, - workspaceId, - userId: context.userId, + workflowId, }) - if (!deleteResult.success) { - return { success: false, error: deleteResult.error || 'Failed to undeploy MCP tool' } - } - return { success: true, output: { workflowId, serverId, - serverName: serverRecord.name, + serverName: result.server.name, action: 'undeploy', removed: true, deploymentType: 'mcp', @@ -658,135 +483,27 @@ export async function executeDeployMcp( mcp: { isDeployed: false, serverId, - serverName: serverRecord.name, + serverName: result.server.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() || `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({ + const result = await executeCopilotMcpServerUseCase(context, deployWorkflowMcpTool, { serverId, - workspaceId, - userId: context.userId, workflowId, - toolName, - toolDescription, - parameterDescriptionOverrides, + toolName: params.toolName, + toolDescription: params.toolDescription, + parameterDescriptions: params.parameterDescriptions, }) - if (!createResult.success || !createResult.tool) { - return { success: false, error: createResult.error || 'Failed to deploy MCP tool' } - } - const toolId = createResult.tool.id + const baseUrl = getBaseUrl() + const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}` + const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) + const clientExamples = buildMcpClientExamples(result.server.name, mcpServerUrl) + const toolId = result.tool.id + const toolName = result.tool.toolName + const toolDescription = result.tool.toolDescription return { success: true, @@ -794,11 +511,11 @@ export async function executeDeployMcp( toolId, toolName, toolDescription, - updated: false, + updated: result.updated, mcpServerUrl, baseUrl, serverId, - serverName: serverRecord.name, + serverName: result.server.name, deploymentType: 'mcp', apiEndpoint, deploymentStatus: { @@ -809,21 +526,21 @@ export async function executeDeployMcp( mcp: { isDeployed: true, serverId, - serverName: serverRecord.name, + serverName: result.server.name, toolId, toolName, - updated: false, + updated: result.updated, }, }, deploymentConfig: { mcp: { serverId, - serverName: serverRecord.name, + serverName: result.server.name, serverUrl: mcpServerUrl, toolId, toolName, toolDescription, - parameterSchema, + parameterSchema: result.parameterSchema, authentication: { type: 'api_key', header: 'X-API-Key: YOUR_API_KEY', @@ -836,7 +553,10 @@ export async function executeDeployMcp( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update MCP deployment'), + } } } @@ -865,13 +585,12 @@ export async function executeRedeploy( 'versionName is required. Provide a short human-readable label for this deployment version.', } } - await ensureWorkflowAccess(workflowId, context.userId, 'admin') - - const result = await performFullDeploy({ + const result = await executeCopilotWorkflowUseCase(context, deployWorkflow, { workflowId, - userId: context.userId, - versionDescription, - versionName, + assertedWorkspaceId: context.workspaceId, + description: versionDescription, + name: versionName, + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'), }) if (!result.success) { @@ -924,6 +643,9 @@ export async function executeRedeploy( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to redeploy workflow'), + } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 55ec5455a9e..f5e5081d156 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -4,18 +4,25 @@ import { auditMock, - queueTableRows, resetDbChainMock, - schemaMock, workflowsOrchestrationMock, workflowsOrchestrationMockFns, } from '@sim/testing' +import { getErrorMessage } from '@sim/utils/errors' import { afterAll, 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 { ensureWorkflowAccessMock, checkNeedsRedeploymentMock, mockExecuteCopilotWorkflowUseCase } = + vi.hoisted(() => ({ + ensureWorkflowAccessMock: vi.fn(), + checkNeedsRedeploymentMock: vi.fn(), + mockExecuteCopilotWorkflowUseCase: vi.fn(), + })) + +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: mockExecuteCopilotWorkflowUseCase, + messageForCopilotWorkflowError: (error: unknown, fallback: string) => + getErrorMessage(error, fallback), })) const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion @@ -58,6 +65,7 @@ vi.mock('../access', () => ({ vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) vi.mock('./state-refs', () => ({ + parseWorkflowRef: (value: number | string) => (value === 'live' ? 'active' : value), resolveWorkflowStateRef: resolveWorkflowStateRefMock, })) @@ -65,7 +73,7 @@ vi.mock('@/lib/workflows/comparison', () => ({ generateWorkflowDiffSummary: generateWorkflowDiffSummaryMock, })) -vi.mock('@/app/api/workflows/utils', () => ({ +vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: checkNeedsRedeploymentMock, })) @@ -95,20 +103,20 @@ describe('executeLoadDeployment', () => { }) it('loads a version into the draft via performRevertToVersion', async () => { - performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 12345 }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ 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(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.revert' }), + }), + expect.objectContaining({ workflowId: 'wf-1', version: 7 }) + ) expect(result).toEqual({ success: true, output: { @@ -120,14 +128,16 @@ describe('executeLoadDeployment', () => { }) it('maps "live" to the active version', async () => { - performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 1 }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ lastSaved: 1 }) await executeLoadDeployment({ workflowId: 'wf-1', version: 'live' }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) - expect(performRevertToVersionMock).toHaveBeenCalledWith( + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), expect.objectContaining({ version: 'active' }) ) }) @@ -143,10 +153,7 @@ describe('executeLoadDeployment', () => { }) it('returns shared helper failures directly', async () => { - performRevertToVersionMock.mockResolvedValue({ - success: false, - error: 'Deployment version not found', - }) + mockExecuteCopilotWorkflowUseCase.mockRejectedValue(new Error('Deployment version not found')) const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, { userId: 'user-1', @@ -166,7 +173,7 @@ describe('executePromoteToLive', () => { }) it('promotes a version via performActivateVersion', async () => { - performActivateVersionMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, deployedAt: new Date('2026-05-30T00:00:00.000Z'), activeDeployment: { @@ -195,13 +202,17 @@ describe('executePromoteToLive', () => { toolCallId: 'call-1', } as ExecutionContext) - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') - expect(performActivateVersionMock).toHaveBeenCalledWith({ - workflowId: 'wf-1', - version: 3, - userId: 'user-1', - idempotencyKey: 'copilot:execution-1:operation:promote_to_live', - }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.activate' }), + }), + expect.objectContaining({ + workflowId: 'wf-1', + version: 3, + idempotencyKey: 'copilot:execution-1:operation:promote_to_live', + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ workflowId: 'wf-1', @@ -213,7 +224,7 @@ describe('executePromoteToLive', () => { }) it('does not report a historical active operation as a successful promotion', async () => { - performActivateVersionMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true, activeDeployment: null, latestDeploymentAttempt: { @@ -263,7 +274,7 @@ describe('executeGetDeploymentLog', () => { }) it('returns versions from the shared listWorkflowVersions helper', async () => { - listWorkflowVersionsMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ versions: [ { id: 'v2', @@ -293,7 +304,13 @@ describe('executeGetDeploymentLog', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(listWorkflowVersionsMock).toHaveBeenCalledWith('wf-1') + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.list' }), + }), + expect.objectContaining({ workflowId: 'wf-1' }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ workflowId: 'wf-1', @@ -312,9 +329,12 @@ describe('executeDiffWorkflows', () => { }) 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 }) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + references: [ + { state: { base: true }, ref: '1', version: 1, isActive: false }, + { state: { target: true }, ref: 'live', version: 2, isActive: true }, + ], + }) const summary = { addedBlocks: [], @@ -340,8 +360,13 @@ describe('executeDiffWorkflows', () => { workflowId: 'wf-1', } as ExecutionContext) - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 1, 'user-1') - expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 'live', 'user-1') + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.versions.compare_references' }), + }), + expect.objectContaining({ workflowId: 'wf-1', references: [1, 'active'] }) + ) // ref1 = base/previous, ref2 = target/current. expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith({ target: true }, { base: true }) expect(result.success).toBe(true) @@ -366,11 +391,18 @@ describe('executeCheckDeploymentStatus', () => { activeDeployment: null, latestDeploymentAttempt: null, warnings: [], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, }) }) it('uses the shared redeployment freshness helper for deployed APIs', async () => { - getWorkflowDeploymentSummaryMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: new Date('2026-05-28') }, + workspaceId: 'ws-1', + isDeployed: true, + needsRedeployment: true, activeDeployment: { deploymentVersionId: 'dv-1', version: 1, @@ -378,16 +410,22 @@ describe('executeCheckDeploymentStatus', () => { }, latestDeploymentAttempt: null, warnings: [], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, }) - queueTableRows(schemaMock.workflow, [{ deployedAt: new Date('2026-05-28') }]) - checkNeedsRedeploymentMock.mockResolvedValueOnce(true) - const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) - expect(checkNeedsRedeploymentMock).toHaveBeenCalledWith('wf-1') + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.deployment_overview.read' }), + }), + expect.objectContaining({ workflowId: 'wf-1' }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ isDeployed: true, @@ -399,7 +437,18 @@ describe('executeCheckDeploymentStatus', () => { }) it('does not check redeployment freshness for undeployed APIs', async () => { - queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: null }, + workspaceId: 'ws-1', + isDeployed: false, + needsRedeployment: false, + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, + }) const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { userId: 'user-1', @@ -418,7 +467,11 @@ describe('executeCheckDeploymentStatus', () => { }) it('separates a historical active attempt from the current undeployed state', async () => { - getWorkflowDeploymentSummaryMock.mockResolvedValue({ + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: null }, + workspaceId: 'ws-1', + isDeployed: false, + needsRedeployment: false, activeDeployment: null, latestDeploymentAttempt: { id: 'op-historical', @@ -433,9 +486,10 @@ describe('executeCheckDeploymentStatus', () => { error: null, }, warnings: ['The latest successful deployment attempt is historical.'], + chatDeployment: null, + mcpTools: [], + mcpToolsTruncated: false, }) - queueTableRows(schemaMock.workflow, [{ deployedAt: null }]) - const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { userId: 'user-1', workflowId: 'wf-1', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index c2e13b77b14..0ec758912f6 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -1,25 +1,26 @@ -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 { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' import { - performCreateWorkflowMcpServer, - performDeleteWorkflowMcpServer, - performUpdateWorkflowMcpServer, -} from '@/lib/mcp/orchestration' -import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { generateRequestId } from '@/lib/core/utils/request' import { - getWorkflowDeploymentSummary, - performActivateVersion, - performRevertToVersion, -} from '@/lib/workflows/orchestration' + createWorkflowMcpDeploymentServer, + deleteWorkflowMcpDeploymentServer, + listWorkflowMcpDeployments, + updateWorkflowMcpDeploymentServer, +} from '@/lib/mcp/application/workflow-deployments' import { - listWorkflowVersions, - updateDeploymentVersionMetadata, -} from '@/lib/workflows/persistence/utils' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' -import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access' + activateWorkflowVersion, + revertWorkflowVersion, + updateWorkflowVersion, +} from '@/lib/workflows/application/deployments' +import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' +import { readWorkflowDeploymentOverview } from '@/lib/workflows/application/read-workflow-deployment-overview' +import { readWorkflowStateReferences } from '@/lib/workflows/application/read-workflow-state-references' +import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' import type { CheckDeploymentStatusParams, CreateWorkspaceMcpServerParams, @@ -33,7 +34,7 @@ import type { UpdateWorkspaceMcpServerParams, } from '../param-types' import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' -import { resolveWorkflowStateRef } from './state-refs' +import { parseWorkflowRef } from './state-refs' export async function executeCheckDeploymentStatus( params: CheckDeploymentStatusParams, @@ -44,77 +45,58 @@ export async function executeCheckDeploymentStatus( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) - const workspaceId = workflowRecord.workspaceId - - const [apiDeploy, chatDeploy, deploymentSummary] = await Promise.all([ - db - .select({ 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, - includeThinking: chat.includeThinking, - includeToolCalls: chat.includeToolCalls, - password: chat.password, - customizations: chat.customizations, - }) - .from(chat) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) - .limit(1), - getWorkflowDeploymentSummary(workflowId), - ]) + const deployment = await executeCopilotWorkflowUseCase( + context, + readWorkflowDeploymentOverview, + { + workflowId, + assertedWorkspaceId: context.workspaceId, + } + ) + const workflowRecord = deployment.workflow /** * Deployed means an active version snapshot exists; the legacy * `workflow.isDeployed` flag is not consulted so this can never * contradict the attached `activeDeployment` summary. */ - const isApiDeployed = deploymentSummary.activeDeployment !== null - const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false - const currentDeploymentAttempt = deploymentSummary.latestDeploymentAttempt?.isCurrent - ? deploymentSummary.latestDeploymentAttempt + const isApiDeployed = deployment.isDeployed + const currentDeploymentAttempt = deployment.latestDeploymentAttempt?.isCurrent + ? deployment.latestDeploymentAttempt : null const apiDetails = { isDeployed: isApiDeployed, - deployedAt: apiDeploy[0]?.deployedAt || null, + deployedAt: workflowRecord.deployedAt || null, endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', - needsRedeployment, - activeDeployment: deploymentSummary.activeDeployment, - latestDeploymentAttempt: deploymentSummary.latestDeploymentAttempt, + needsRedeployment: deployment.needsRedeployment, + activeDeployment: deployment.activeDeployment, + latestDeploymentAttempt: deployment.latestDeploymentAttempt, currentDeploymentAttempt, - warnings: deploymentSummary.warnings ?? [], + warnings: deployment.warnings ?? [], } - const isChatDeployed = !!chatDeploy[0] + const chatDeploy = deployment.chatDeployment + const isChatDeployed = chatDeploy !== null const chatCustomizations = - (chatDeploy[0]?.customizations as + (chatDeploy?.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, - includeThinking: chatDeploy[0]?.includeThinking ?? false, - includeToolCalls: chatDeploy[0]?.includeToolCalls ?? false, + chatId: chatDeploy?.id || null, + identifier: chatDeploy?.identifier || null, + chatUrl: isChatDeployed ? `/chat/${chatDeploy?.identifier}` : null, + title: chatDeploy?.title || null, + description: chatDeploy?.description || null, + authType: chatDeploy?.authType || null, + allowedEmails: chatDeploy?.allowedEmails || null, + outputConfigs: chatDeploy?.outputConfigs || null, + includeThinking: chatDeploy?.includeThinking ?? false, + includeToolCalls: chatDeploy?.includeToolCalls ?? false, welcomeMessage: chatCustomizations.welcomeMessage || null, primaryColor: chatCustomizations.primaryColor || null, - hasPassword: Boolean(chatDeploy[0]?.password), + hasPassword: Boolean(chatDeploy?.password), } const mcpDetails: { @@ -127,25 +109,15 @@ export async function executeCheckDeploymentStatus( 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 - } + truncated: boolean + } = { + isDeployed: false, + servers: [], + truncated: deployment.mcpToolsTruncated, + } + if (deployment.mcpTools.length > 0) { + mcpDetails.isDeployed = true + mcpDetails.servers = deployment.mcpTools } const isDeployed = apiDetails.isDeployed || chatDetails.isDeployed || mcpDetails.isDeployed @@ -154,7 +126,10 @@ export async function executeCheckDeploymentStatus( output: { isDeployed, api: apiDetails, chat: chatDetails, mcp: mcpDetails }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to check deployment status'), + } } } @@ -163,61 +138,23 @@ export async function executeListWorkspaceMcpServers( context: ExecutionContext ): Promise { 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 - } - + const workspaceId = params.workspaceId || context.workspaceId 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 = {} - for (const tool of tools) { - if (!toolNamesByServer[tool.serverId]) { - toolNamesByServer[tool.serverId] = [] - } - toolNamesByServer[tool.serverId].push(tool.toolName) + const result = await executeCopilotMcpServerUseCase(context, listWorkflowMcpDeployments, { + workspaceId, + }) + return { + success: true, + output: { + servers: result.servers, + count: result.servers.length, + truncated: result.truncated, + }, } - - 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 } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -226,43 +163,31 @@ export async function executeCreateWorkspaceMcpServer( context: ExecutionContext ): Promise { 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 - } - + const workspaceId = params.workspaceId || context.workspaceId 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' } - } + const result = await executeCopilotMcpServerUseCase( + context, + createWorkflowMcpDeploymentServer, + { + workspaceId, + name, + description: params.description, + isPublic: params.isPublic, + workflowIds: params.workflowIds, + } + ) - return { success: true, output: { server: result.server, addedTools: result.addedTools || [] } } + return { success: true, output: { server: result.server, addedTools: result.addedTools } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -293,34 +218,14 @@ export async function executeUpdateWorkspaceMcpServer( 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({ + await executeCopilotMcpServerUseCase(context, updateWorkflowMcpDeploymentServer, { 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 } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -334,34 +239,17 @@ export async function executeDeleteWorkspaceMcpServer( 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' } - } + const result = await executeCopilotMcpServerUseCase( + context, + deleteWorkflowMcpDeploymentServer, + { + serverId, + } + ) - return { success: true, output: { serverId, name: existing.name, deleted: true } } + return { success: true, output: { serverId, name: result.server.name, deleted: true } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotApplicationError(error) } } } @@ -374,9 +262,10 @@ export async function executeGetDeploymentLog( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - await ensureWorkflowAccess(workflowId, context.userId) - - const { versions: rows } = await listWorkflowVersions(workflowId) + const { versions: rows } = await executeCopilotWorkflowUseCase(context, listWorkflowVersions, { + workflowId, + assertedWorkspaceId: context.workspaceId, + }) const versions = rows.map((r) => ({ id: r.id, @@ -391,7 +280,10 @@ export async function executeGetDeploymentLog( return { success: true, output: { workflowId, count: versions.length, versions } } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to list deployment versions'), + } } } @@ -424,11 +316,16 @@ export async function executeDiffWorkflows( 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), - ]) + const { references } = await executeCopilotWorkflowUseCase( + context, + readWorkflowStateReferences, + { + workflowId, + assertedWorkspaceId: context.workspaceId, + references: [parseWorkflowRef(params.ref1), parseWorkflowRef(params.ref2)], + } + ) + const [side1, side2] = references // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. const summary = generateWorkflowDiffSummary(side2.state, side1.state) @@ -454,7 +351,10 @@ export async function executeDiffWorkflows( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to compare workflow versions'), + } } } @@ -496,22 +396,12 @@ export async function executeLoadDeployment( return { success: false, error: target.error } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) - const result = await performRevertToVersion({ + const result = await executeCopilotWorkflowUseCase(context, revertWorkflowVersion, { workflowId, + assertedWorkspaceId: context.workspaceId, version: target.version, - userId: context.userId, - workflow: workflowRecord as Record, }) - 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, @@ -522,7 +412,10 @@ export async function executeLoadDeployment( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to load deployment'), + } } } @@ -553,15 +446,12 @@ export async function executePromoteToLive( } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'admin' - ) - const result = await performActivateVersion({ + const result = await executeCopilotWorkflowUseCase(context, activateWorkflowVersion, { workflowId, + assertedWorkspaceId: context.workspaceId, version, - userId: context.userId, + transition: 'activate', + requestId: generateRequestId(), idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'promote_to_live'), }) @@ -598,7 +488,10 @@ export async function executePromoteToLive( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to promote deployment version'), + } } } @@ -629,23 +522,21 @@ export async function executeUpdateDeploymentVersion( return { success: false, error: 'Provide a name and/or description to update' } } - await ensureWorkflowAccess(workflowId, context.userId, 'write') - - const updated = await updateDeploymentVersionMetadata({ + const updated = await executeCopilotWorkflowUseCase(context, updateWorkflowVersion, { workflowId, + assertedWorkspaceId: context.workspaceId, 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 } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to update deployment version'), + } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts index 8dde36ba6f0..84774331fd9 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts @@ -1,22 +1,12 @@ -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' +import type { + ResolvedWorkflowStateReference, + WorkflowStateReference, +} from '@/lib/workflows/application/read-workflow-state-references' /** 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 -} +export type WorkflowRef = WorkflowStateReference +export type ResolvedWorkflowRef = ResolvedWorkflowStateReference /** * Parse a raw ref param into a canonical WorkflowRef. @@ -33,63 +23,3 @@ export function parseWorkflowRef(raw: unknown): WorkflowRef { } 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 { - 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(), - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 4dd79f4fffc..6d2b9e98eb6 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -1,14 +1,12 @@ /** * @vitest-environment node */ -import { - dbChainMock, - queueTableRows, - resetDbChainMock, - schemaMock, - workflowAuthzMockFns, -} from '@sim/testing' +import { dbChainMock, resetDbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { tableOperations } from '@/lib/table/application/operations' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' const mocks = vi.hoisted(() => ({ ensureWorkspaceAccess: vi.fn(), @@ -27,8 +25,10 @@ const mocks = vi.hoisted(() => ({ performUpdateWorkspaceFileFolder: vi.fn(), performCreateFolder: vi.fn(), performUpdateFolder: vi.fn(), - performUpdateWorkflow: vi.fn(), - duplicateWorkflow: vi.fn(), + moveWorkflowVfs: vi.fn(), + copyWorkflowVfs: vi.fn(), + createWorkflowVfsFolders: vi.fn(), + deleteWorkflowVfs: vi.fn(), listFolders: vi.fn(), verifyFolderWorkspace: vi.fn(), listTables: vi.fn(), @@ -37,6 +37,13 @@ const mocks = vi.hoisted(() => ({ updateKnowledgeBase: vi.fn(), deleteKnowledgeBase: vi.fn(), knowledgeBaseDeleted: vi.fn(), + createFileVfsFolders: vi.fn(), + relocateFileVfsItems: vi.fn(), + deleteFileVfsItems: vi.fn(), + renameTableVfs: vi.fn(), + deleteTableVfs: vi.fn(), + renameKnowledgeVfs: vi.fn(), + deleteKnowledgeVfs: vi.fn(), })) vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) @@ -78,41 +85,28 @@ vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ moveWorkspaceFileItemsOperation: { - operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.move, execute: mocks.moveWorkspaceFileItems, }, })) -vi.mock('@/lib/workspace-files/application/operations', () => ({ - fileOperations: { - move: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, - rename: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, - delete: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, - updateFolder: { - id: 'files.folders.update', - minimumRole: 'write', - workspaceApiKey: 'allow', - }, - }, -})) - vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ updateWorkspaceFileFolderOperation: { - operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.updateFolder, execute: mocks.updateWorkspaceFileFolder, }, })) vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ deleteWorkspaceFileOperation: { - operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.delete, execute: mocks.deleteWorkspaceFile, }, })) vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ archiveWorkspaceFileItemsOperation: { - operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.delete, execute: mocks.deleteWorkspaceFile, }, })) @@ -121,30 +115,65 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({})) vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ renameWorkspaceFile: { - operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + operation: fileOperations.rename, execute: mocks.renameWorkspaceFile, }, })) -vi.mock('@/lib/folders/orchestration', () => ({ - createFolder: mocks.performCreateFolder, - deleteFolder: vi.fn(), - updateFolder: mocks.performUpdateFolder, +vi.mock('@/lib/workflows/application/workflow-vfs', () => ({ + moveWorkflowVfsItems: { + operation: workflowOperations.moveVfsItems, + execute: mocks.moveWorkflowVfs, + }, + copyWorkflowVfsItems: { + operation: workflowOperations.copyVfsItems, + execute: mocks.copyWorkflowVfs, + }, + createWorkflowVfsFolders: { + operation: workflowOperations.createVfsFolders, + execute: mocks.createWorkflowVfsFolders, + }, + deleteWorkflowVfsItems: { + operation: workflowOperations.deleteVfsItems, + execute: mocks.deleteWorkflowVfs, + }, })) -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateFolder: mocks.performCreateFolder, - performUpdateFolder: mocks.performUpdateFolder, - performUpdateWorkflow: mocks.performUpdateWorkflow, +vi.mock('@/lib/workspace-files/application/workspace-file-vfs', () => ({ + createWorkspaceFileVfsFolders: { + operation: fileOperations.createVfsFolders, + execute: mocks.createFileVfsFolders, + }, + relocateWorkspaceFileVfsItems: { + operation: fileOperations.relocateVfsItems, + execute: mocks.relocateFileVfsItems, + }, + deleteWorkspaceFileVfsItems: { + operation: fileOperations.deleteVfsItems, + execute: mocks.deleteFileVfsItems, + }, })) -vi.mock('@/lib/workflows/persistence/duplicate', () => ({ - duplicateWorkflow: mocks.duplicateWorkflow, +vi.mock('@/lib/table/application/table-vfs', () => ({ + renameTableByVfsPath: { + operation: tableOperations.renameByVfsPath, + execute: mocks.renameTableVfs, + }, + deleteTableByVfsPath: { + operation: tableOperations.deleteByVfsPath, + execute: mocks.deleteTableVfs, + }, })) -vi.mock('@/lib/workflows/utils', () => ({ - listFolders: mocks.listFolders, - verifyFolderWorkspace: mocks.verifyFolderWorkspace, +vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ + renameKnowledgeBaseByVfsPath: { + operation: knowledgeOperations.renameByVfsPath, + execute: mocks.renameKnowledgeVfs, + }, + deleteKnowledgeBaseByVfsPath: { + operation: knowledgeOperations.deleteByVfsPath, + execute: mocks.deleteKnowledgeVfs, + }, })) vi.mock('@/lib/table/service', () => ({ @@ -154,15 +183,15 @@ vi.mock('@/lib/table/service', () => ({ vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ listKnowledgeBases: { - operation: { id: 'knowledge.list' }, + operation: knowledgeOperations.list, execute: mocks.listKnowledgeBases, }, updateKnowledgeBaseOperation: { - operation: { id: 'knowledge.update' }, + operation: knowledgeOperations.update, execute: mocks.updateKnowledgeBase, }, deleteKnowledgeBaseOperation: { - operation: { id: 'knowledge.delete' }, + operation: knowledgeOperations.delete, execute: mocks.deleteKnowledgeBase, }, })) @@ -192,6 +221,10 @@ describe('vfs mv/cp', () => { workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) mocks.verifyFolderWorkspace.mockResolvedValue(true) mocks.listFolders.mockResolvedValue([]) + mocks.moveWorkflowVfs.mockResolvedValue({ outcomes: [] }) + mocks.copyWorkflowVfs.mockResolvedValue({ outcomes: [] }) + mocks.createWorkflowVfsFolders.mockResolvedValue({ outcomes: [] }) + mocks.deleteWorkflowVfs.mockResolvedValue({ outcomes: [] }) mocks.getWorkspaceFileByName.mockResolvedValue(null) mocks.resolveWorkspaceFileReference.mockImplementation(async ({ reference }) => { const segments = reference.split('/').slice(1) @@ -216,6 +249,27 @@ describe('vfs mv/cp', () => { mocks.renameWorkspaceFile.mockResolvedValue({ file: { id: 'file-1', name: 'renamed.md' }, }) + mocks.createFileVfsFolders.mockResolvedValue({ outcomes: [] }) + mocks.relocateFileVfsItems.mockResolvedValue({ outcomes: [] }) + mocks.deleteFileVfsItems.mockResolvedValue({ outcomes: [] }) + mocks.renameTableVfs.mockResolvedValue({ + id: 'tbl-1', + name: 'Customers', + previousName: 'Leads', + workspaceId: 'ws-1', + }) + mocks.renameKnowledgeVfs.mockResolvedValue({ + id: 'kb-1', + name: 'Product Docs', + previousName: 'Docs', + workspaceId: 'ws-1', + }) + mocks.deleteKnowledgeVfs.mockResolvedValue({ + id: 'kb-1', + name: 'Docs', + workspaceId: 'ws-1', + deleted: true, + }) }) afterAll(() => { @@ -270,13 +324,15 @@ describe('vfs mv/cp', () => { describe('files', () => { it('routes a same-folder rename through the delegated file use case', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue({ - id: 'file-1', - name: 'draft.md', - folderId: null, - }) - mocks.renameWorkspaceFile.mockResolvedValue({ - file: { id: 'file-1', name: 'final.md' }, + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/draft.md', + targetSegments: ['final.md'], + resourceType: 'file', + resourceId: 'file-1', + }, + ], }) const result = await executeVfsMv( @@ -284,18 +340,17 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.renameWorkspaceFile).toHaveBeenCalledWith({ + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith({ principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1', workspaceId: 'ws-1', delegationId: 'copilot-tool:tool-call-1', - resourceScope: expect.objectContaining({ fileId: 'file-1' }), }), input: { - fileId: 'file-1', - assertedWorkspaceId: 'ws-1', - name: 'final.md', + workspaceId: 'ws-1', + sources: [{ source: 'files/draft.md', segments: ['draft.md'] }], + destination: { segments: ['final.md'], trailingSlash: false }, }, }) expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() @@ -306,9 +361,15 @@ describe('vfs mv/cp', () => { }) it('moves and renames a file in one call, auto-creating destination folders', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) - mocks.renameWorkspaceFile.mockResolvedValue({ - file: { id: 'file-1', name: 'final.md' }, + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/draft.md', + targetSegments: ['Reports', '2026', 'final.md'], + resourceType: 'file', + resourceId: 'file-1', + }, + ], }) const result = await executeVfsMv( @@ -316,16 +377,11 @@ describe('vfs mv/cp', () => { context ) - expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { - folderId: null, - }) - expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ - 'Reports', - '2026', - ]) - expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ targetFolderId: 'ensured-folder' }), + input: expect.objectContaining({ + destination: { segments: ['Reports', '2026', 'final.md'], trailingSlash: false }, + }), }) ) expect(result.success).toBe(true) @@ -335,25 +391,48 @@ describe('vfs mv/cp', () => { }) it('moves into an existing folder keeping the name without creating anything', async () => { - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/a.png', + targetSegments: ['Images', 'a.png'], + resourceType: 'file', + resourceId: 'file-1', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/a.png'], destination: 'files/Images' }, context ) - expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( expect.objectContaining({ - input: expect.objectContaining({ targetFolderId: 'folder-images' }), + input: expect.objectContaining({ + destination: { segments: ['Images'], trailingSlash: false }, + }), }) ) - expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) }) it('requires a folder destination for multiple sources', async () => { + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/a.png', + resourceType: 'file', + error: 'Destination must be a folder when moving multiple sources', + }, + { + source: 'files/b.png', + resourceType: 'file', + error: 'Destination must be a folder when moving multiple sources', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/a.png', 'files/b.png'], destination: 'files/Images/c.png' }, context @@ -363,8 +442,15 @@ describe('vfs mv/cp', () => { }) it('resolves sources at their exact path only — no cross-folder name fallback', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/report.pdf', + resourceType: 'file', + error: 'Not found at files/report.pdf', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/report.pdf'], destination: 'files/Archive/' }, @@ -373,8 +459,7 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('Not found') - expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() - expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() + expect(mocks.relocateFileVfsItems).toHaveBeenCalledOnce() }) it('rejects copying workspace files — cp is workflows-only', async () => { @@ -391,21 +476,26 @@ describe('vfs mv/cp', () => { }) it('moves and renames a file folder via the shared folder operation', async () => { - mocks.findWorkspaceFileFolderIdByPath - .mockResolvedValueOnce(null) // destination is not an existing folder - .mockResolvedValueOnce('folder-src') // source resolves as folder + mocks.relocateFileVfsItems.mockResolvedValue({ + outcomes: [ + { + source: 'files/Reports', + targetSegments: ['Archive', 'Reports 2025'], + resourceType: 'folder', + resourceId: 'folder-src', + }, + ], + }) const result = await executeVfsMv( { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, context ) - expect(mocks.updateWorkspaceFileFolder).toHaveBeenCalledWith( + expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ - folderId: 'folder-src', - name: 'Reports 2025', - parentId: 'ensured-folder', + destination: { segments: ['Archive', 'Reports 2025'], trailingSlash: false }, }), }) ) @@ -414,48 +504,97 @@ describe('vfs mv/cp', () => { }) describe('workflows', () => { - it('renames a workflow at root', async () => { - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Old Name', folderId: null }]) - mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + it('routes an encoded rename through one bounded workflow VFS command', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Old%20Name', + targetSegments: ['New Name'], + resourceType: 'workflow', + resourceId: 'wf-1', + }, + ], + }) const result = await executeVfsMv( { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, context ) - expect(workflowAuthzMockFns.mockAssertWorkflowMutable).toHaveBeenCalledWith('wf-1') - expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', name: 'New Name', folderId: null }) + expect(mocks.moveWorkflowVfs).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ + serviceId: 'copilot', + workspaceId: 'ws-1', + }), + input: { + workspaceId: 'ws-1', + sources: [{ source: 'workflows/Old%20Name', segments: ['Old Name'] }], + destination: { segments: ['New Name'], trailingSlash: false }, + }, + }) ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'workflows/New%20Name' }] }) }) - it('moves a workflow into an existing folder keeping its name', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Archive', parentId: null }, - ]) - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'My Workflow', folderId: null }]) - mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) + it('passes a multi-source move to the application once', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/One', + targetSegments: ['Archive', 'One'], + resourceType: 'workflow', + resourceId: 'wf-1', + }, + { + source: 'workflows/Two', + targetSegments: ['Archive', 'Two'], + resourceType: 'workflow', + resourceId: 'wf-2', + }, + ], + }) const result = await executeVfsMv( - { sources: ['workflows/My%20Workflow'], destination: 'workflows/Archive' }, + { sources: ['workflows/One', 'workflows/Two'], destination: 'workflows/Archive/' }, context ) - expect(workflowAuthzMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('fold-1') - expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', name: undefined, folderId: 'fold-1' }) - ) + expect(mocks.moveWorkflowVfs).toHaveBeenCalledOnce() expect(result.success).toBe(true) }) - it('surfaces locked-workflow rejections per item', async () => { - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Locked One', folderId: null }]) - workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( - new Error('Workflow is locked') + it('preserves safe workflow application validation errors', async () => { + mocks.moveWorkflowVfs.mockRejectedValueOnce( + new OrchestrationError( + 'validation', + 'With multiple sources the destination must be a folder' + ) ) + const result = await executeVfsMv( + { sources: ['workflows/One', 'workflows/Two'], destination: 'workflows/Renamed' }, + context + ) + + expect(result).toEqual({ + success: false, + error: 'With multiple sources the destination must be a folder', + }) + }) + + it('surfaces locked-workflow rejections per item', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Locked%20One', + resourceType: 'workflow', + error: 'Workflow is locked', + }, + ], + }) + const result = await executeVfsMv( { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, context @@ -466,8 +605,16 @@ describe('vfs mv/cp', () => { }) it('duplicates a workflow with cp (locked source allowed)', async () => { - queueTableRows(schemaMock.workflow, [{ id: 'wf-1', name: 'Template', folderId: null }]) - mocks.duplicateWorkflow.mockResolvedValue({ id: 'wf-2', name: 'My Copy' }) + mocks.copyWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Template', + targetSegments: ['My Copy'], + resourceType: 'workflow', + resourceId: 'wf-2', + }, + ], + }) const result = await executeVfsCp( { sources: ['workflows/Template'], destination: 'workflows/My Copy' }, @@ -475,22 +622,21 @@ describe('vfs mv/cp', () => { ) expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled() - expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - sourceWorkflowId: 'wf-1', - workspaceId: 'ws-1', - folderId: null, - name: 'My Copy', - }) - ) + expect(mocks.copyWorkflowVfs).toHaveBeenCalledOnce() expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'workflows/My%20Copy', id: 'wf-2' }] }) }) it('rejects copying workflow folders', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Projects', parentId: null }, - ]) + mocks.copyWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Projects', + resourceType: 'folder', + error: 'Workflow folders cannot be copied.', + }, + ], + }) const result = await executeVfsCp( { sources: ['workflows/Projects'], destination: 'workflows/Projects Copy' }, context @@ -500,54 +646,130 @@ describe('vfs mv/cp', () => { }) it('moves and renames a workflow folder', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Q1', parentId: null }, - { folderId: 'fold-2', folderName: 'Archive', parentId: null }, - ]) - mocks.performUpdateFolder.mockResolvedValue({ success: true }) + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Q1', + targetSegments: ['Archive', 'Q1 2026'], + resourceType: 'folder', + resourceId: 'fold-1', + }, + ], + }) const result = await executeVfsMv( { sources: ['workflows/Q1'], destination: 'workflows/Archive/Q1 2026' }, context ) - expect(mocks.performUpdateFolder).toHaveBeenCalledWith( - expect.objectContaining({ folderId: 'fold-1', name: 'Q1 2026', parentId: 'fold-2' }) + expect(mocks.moveWorkflowVfs).toHaveBeenCalledOnce() + expect(result.success).toBe(true) + }) + + it('does not expose workflow application infrastructure errors', async () => { + mocks.moveWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Old%20Name', + resourceType: 'workflow', + error: 'Workflow mutation failed', + }, + ], + }) + + const result = await executeVfsMv( + { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, + context ) + + expect(result).toMatchObject({ + success: false, + error: 'Workflow mutation failed', + output: { results: [expect.objectContaining({ error: 'Workflow mutation failed' })] }, + }) + }) + + it('deletes an encoded workflow alias through the workflow application operation', async () => { + mocks.deleteWorkflowVfs.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Old%20Name', + resourceType: 'workflow', + resourceId: 'wf-1', + }, + ], + }) + + const result = await executeVfsRm({ paths: ['workflows/Old%20Name'] }, context) + expect(result.success).toBe(true) + expect(mocks.deleteWorkflowVfs).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ + serviceId: 'copilot', + workspaceId: 'ws-1', + }), + input: { + workspaceId: 'ws-1', + paths: [{ source: 'workflows/Old%20Name', segments: ['Old Name'] }], + }, + }) + ) }) }) describe('mkdir', () => { it('creates a nested file folder chain', async () => { + mocks.createFileVfsFolders.mockResolvedValue({ + outcomes: [ + { + source: 'files/Reports/2026', + targetSegments: ['Reports', '2026'], + resourceType: 'folder', + resourceId: 'folder-2026', + }, + ], + }) const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) - expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ - 'Reports', - '2026', - ]) + expect(mocks.createFileVfsFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + paths: [{ source: 'files/Reports/2026', segments: ['Reports', '2026'] }], + }, + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], }) }) - it('creates a workflow folder via performCreateFolder', async () => { - mocks.listFolders.mockResolvedValue([]) - mocks.performCreateFolder.mockResolvedValue({ success: true, folder: { id: 'fold-new' } }) - - const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) - - expect(mocks.performCreateFolder).toHaveBeenCalledWith({ - resourceType: 'workflow', - workspaceId: 'ws-1', - userId: 'user-1', - name: 'Archive', - parentId: undefined, + it('creates a workflow folder through the workflow application operation', async () => { + mocks.createWorkflowVfsFolders.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Project Plans', + targetSegments: ['Project Plans'], + resourceType: 'folder', + resourceId: 'fold-new', + }, + ], }) + const result = await executeVfsMkdir({ paths: ['workflows/Project Plans'] }, context) + + expect(mocks.createWorkflowVfsFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + paths: [{ source: 'workflows/Project Plans', segments: ['Project Plans'] }], + }, + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ - results: [{ to: 'workflows/Archive', kind: 'workflow_folder', id: 'fold-new' }], + results: [{ to: 'workflows/Project%20Plans', kind: 'workflow_folder', id: 'fold-new' }], }) }) @@ -561,28 +783,36 @@ describe('vfs mv/cp', () => { }) it('rejects creation inside a locked workflow folder', async () => { - mocks.listFolders.mockResolvedValue([]) - workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValue(new Error('Folder is locked')) + mocks.createWorkflowVfsFolders.mockResolvedValue({ + outcomes: [ + { + source: 'workflows/Locked/Sub', + resourceType: 'folder', + error: 'Folder is locked', + }, + ], + }) const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) expect(result.success).toBe(false) expect(result.error).toContain('locked') - expect(mocks.performCreateFolder).not.toHaveBeenCalled() + expect(mocks.createWorkflowVfsFolders).toHaveBeenCalledOnce() }) }) describe('tables and knowledge bases (flat namespaces)', () => { it('renames a table', async () => { - mocks.listTables.mockResolvedValue([{ id: 'tbl-1', name: 'Leads' }]) - mocks.renameTable.mockResolvedValue({ id: 'tbl-1', name: 'Customers' }) - const result = await executeVfsMv( { sources: ['tables/Leads'], destination: 'tables/Customers' }, context ) - expect(mocks.renameTable).toHaveBeenCalledWith('tbl-1', 'Customers', expect.any(String)) + expect(mocks.renameTableVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: 'ws-1', sourceName: 'Leads', newName: 'Customers' }, + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) }) @@ -607,20 +837,12 @@ describe('vfs mv/cp', () => { }) it('renames a knowledge base through trusted application operations', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.updateKnowledgeBase.mockResolvedValue({ - knowledgeBase: { id: 'kb-1', name: 'Product Docs' }, - folderPath: '/', - }) - const result = await executeVfsMv( { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, context ) - expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( + expect(mocks.renameKnowledgeVfs).toHaveBeenCalledWith( expect.objectContaining({ principal: expect.objectContaining({ kind: 'delegated', @@ -629,10 +851,9 @@ describe('vfs mv/cp', () => { delegationId: 'tool-call-1', }), input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: 'ws-1', - name: 'Product Docs', - source: 'agent', + workspaceId: 'ws-1', + sourceName: 'Docs', + newName: 'Product Docs', }, }) ) @@ -640,7 +861,7 @@ describe('vfs mv/cp', () => { }) it('propagates knowledge application infrastructure failures', async () => { - mocks.listKnowledgeBases.mockRejectedValueOnce(new Error('knowledge database unavailable')) + mocks.renameKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) await expect( executeVfsMv( @@ -651,10 +872,7 @@ describe('vfs mv/cp', () => { }) it('preserves an actionable knowledge rename conflict', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.updateKnowledgeBase.mockRejectedValue( + mocks.renameKnowledgeVfs.mockRejectedValue( new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists') ) @@ -679,35 +897,25 @@ describe('vfs mv/cp', () => { }) it('deletes a knowledge base through the trusted application operation', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.deleteKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Docs' }) - const result = await executeVfsRm({ paths: ['knowledgebases/Docs'] }, context) expect(result).toMatchObject({ success: true, output: { results: [{ from: 'knowledgebases/Docs', id: 'kb-1' }] }, }) - expect(mocks.deleteKnowledgeBase).toHaveBeenCalledWith( + expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith( expect.objectContaining({ principal: expect.objectContaining({ delegationId: 'tool-call-1' }), input: { - knowledgeBaseId: 'kb-1', - assertedWorkspaceId: 'ws-1', - source: 'agent', + workspaceId: 'ws-1', + sourceName: 'Docs', }, }) ) - expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledWith({ knowledgeBaseId: 'kb-1' }) }) it('preserves an actionable knowledge delete failure', async () => { - mocks.listKnowledgeBases.mockResolvedValue({ - knowledgeBases: [{ knowledgeBase: { id: 'kb-1', name: 'Docs' }, folderPath: '/' }], - }) - mocks.deleteKnowledgeBase.mockRejectedValue( + mocks.deleteKnowledgeVfs.mockRejectedValue( new OrchestrationError('not_found', 'Knowledge base no longer exists') ) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 961545fb299..6516fe35f15 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -1,53 +1,41 @@ -import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' -import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { - executeCopilotFileUseCase, - resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { executeCopilotKnowledgeUseCase, messageForCopilotKnowledgeError, - resolveCopilotKnowledgePrincipal, } from '@/lib/copilot/application/execute-knowledge-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import { - ensureCopilotFileFolderPath, - requireCopilotWorkspace, -} from '@/lib/copilot/tools/server/files/file-folder-application' + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/files/file-folder-application' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import { - buildVfsFolderPathMap, - canonicalWorkflowVfsDir, - decodeVfsPathSegments, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' -import { generateRequestId } from '@/lib/core/utils/request' -import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { - deleteKnowledgeBaseOperation, - listKnowledgeBases, - updateKnowledgeBaseOperation, -} from '@/lib/knowledge/application/knowledge-bases' -import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' -import { listTables } from '@/lib/table/service' -import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' -import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' -import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' -import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' -import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { updateWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' + deleteKnowledgeBaseByVfsPath, + renameKnowledgeBaseByVfsPath, +} from '@/lib/knowledge/application/knowledge-vfs' +import { captureServerEvent } from '@/lib/posthog/server' +import { deleteTableByVfsPath, renameTableByVfsPath } from '@/lib/table/application/table-vfs' +import { VfsPathLimitError, validateVfsPathBatch } from '@/lib/vfs/limits' +import { + copyWorkflowVfsItems, + createWorkflowVfsFolders, + deleteWorkflowVfsItems, + moveWorkflowVfsItems, + type WorkflowVfsOutcome, +} from '@/lib/workflows/application/workflow-vfs' +import { + createWorkspaceFileVfsFolders, + deleteWorkspaceFileVfsItems, + relocateWorkspaceFileVfsItems, + type WorkspaceFileVfsOutcome, +} from '@/lib/workspace-files/application/workspace-file-vfs' const logger = createLogger('VfsMutateTools') @@ -98,6 +86,18 @@ function messageForKnowledgeVfsError(error: unknown, forbiddenMessage: string): return classified.code === 'forbidden' ? forbiddenMessage : messageForCopilotKnowledgeError(error) } +function messageForExpectedWorkflowVfsError(error: unknown, fallback: string): string { + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + return messageForCopilotWorkflowError(error, fallback) +} + +function messageForExpectedTableVfsError(error: unknown): string { + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + return messageForCopilotTableError(error) +} + /** Top-level VFS segment of a raw (possibly encoded) path. */ function topLevelSegment(path: string): string { return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' @@ -175,12 +175,47 @@ export async function executeVfsMkdir( if (paths.length === 0) { return { success: false, error: 'paths is required (an array of folder VFS paths)' } } + validateVfsPathBatch(paths) const workspaceId = requireCopilotWorkspace(context) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) - let ensureWorkflowFolder: ((segments: string[]) => Promise) | undefined + const filePaths = paths.filter((path) => topLevelSegment(path) === 'files') + const fileOutcomes = new Map() + if (filePaths.length > 0) { + const result = await executeCopilotFileUseCase(context, createWorkspaceFileVfsFolders, { + workspaceId, + paths: filePaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + fileOutcomes.set(outcome.source, presentFileVfsOutcome(outcome)) + } + } + + const workflowPaths = paths.filter((path) => topLevelSegment(path) === 'workflows') + const workflowOutcomes = new Map() + if (workflowPaths.length > 0) { + try { + const result = await executeCopilotWorkflowUseCase(context, createWorkflowVfsFolders, { + workspaceId, + paths: workflowPaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + workflowOutcomes.set(outcome.source, presentWorkflowVfsOutcome(outcome)) + } + } catch (error) { + const message = messageForExpectedWorkflowVfsError(error, 'Workflow folder creation failed') + for (const path of workflowPaths) { + workflowOutcomes.set(path, { from: path, kind: 'workflow_folder', error: message }) + } + } + } const outcomes: VfsMutateOutcome[] = [] for (const path of paths) { @@ -203,43 +238,37 @@ export async function executeVfsMkdir( } try { assertMutationNotAborted(context) - let folderId: string | null if (top === 'files') { - folderId = await ensureCopilotFileFolderPath(context, workspaceId, segments) + outcomes.push( + fileOutcomes.get(path) ?? { + from: path, + kind: 'file_folder', + error: 'File folder creation failed', + } + ) } else { - ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( - workspaceId, - context.userId, - await loadWorkflowFolderIndex(workspaceId) + outcomes.push( + workflowOutcomes.get(path) ?? { + from: path, + kind: 'workflow_folder', + error: 'Workflow folder creation failed', + } ) - folderId = await ensureWorkflowFolder(segments) } - outcomes.push({ - from: path, - to: `${top}/${encodeVfsPathSegments(segments)}`, - kind, - id: folderId ?? undefined, - }) } catch (error) { - outcomes.push({ - from: path, - kind, - error: - top === 'files' - ? messageForCopilotFileError(error, 'File folder creation failed') - : toError(error).message, - }) + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + outcomes.push({ from: path, kind, error: classified.message }) } } return buildResult('mkdir', outcomes) } catch (error) { - return { - success: false, - error: context.abortSignal?.aborted - ? 'Request aborted before the mutation could be applied.' - : 'Mutation failed', + if (context.abortSignal?.aborted) { + return { success: false, error: 'Request aborted before the mutation could be applied.' } } + if (error instanceof VfsPathLimitError) return { success: false, error: error.message } + throw error } } @@ -257,18 +286,14 @@ async function executeVfsMutate( if (!destination) { return { success: false, error: 'destination is required' } } + validateVfsPathBatch([...sources, destination]) const workspaceId = requireCopilotWorkspace(context) - if (topLevelSegment(sources[0]) === 'knowledgebases') { - resolveCopilotKnowledgePrincipal(context) - } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) const classified = classifyCategory(sources[0]) if ('error' in classified) return { success: false, error: classified.error } const { category } = classified - for (const source of sources.slice(1)) { const other = classifyCategory(source) if ('error' in other) return { success: false, error: other.error } @@ -300,96 +325,11 @@ async function executeVfsMutate( if (error instanceof KnowledgeVfsInfrastructureError) { throw error.infrastructureCause } - return { - success: false, - error: context.abortSignal?.aborted - ? 'Request aborted before the mutation could be applied.' - : 'Mutation failed', + if (context.abortSignal?.aborted) { + return { success: false, error: 'Request aborted before the mutation could be applied.' } } - } -} - -interface DestinationPlan { - /** True when sources move INTO the destination folder keeping their names. */ - dirMode: boolean - /** Decoded display-name segments of the destination folder. */ - folderSegments: string[] - /** New leaf name; set only when `dirMode` is false. */ - leafName?: string - /** - * Resolve the destination folder id, creating missing folders on first call. - * Deferred and memoized so nothing is created until a source is confirmed - * valid — a fully-failed mv/cp must not leave folders behind. - */ - ensureFolderId: () => Promise -} - -/** - * Shared destination interpretation for every category with folders: an - * existing folder (or a trailing "/") means move/copy INTO it keeping names; - * otherwise the last segment is the new name and the preceding segments are - * the target folder. Folder creation is deferred to `ensureFolderId`. - */ -async function planDestination(args: { - destination: string - sourceCount: number - lookupFolder: (segments: string[]) => Promise - ensureFolderPath: (segments: string[]) => Promise -}): Promise { - const rest = decodeVfsPathSegments(args.destination).slice(1) - const plan = ( - dirMode: boolean, - folderSegments: string[], - leafName?: string, - knownFolderId?: string | null - ): DestinationPlan => { - let memo: Promise | undefined - return { - dirMode, - folderSegments, - leafName, - ensureFolderId: () => - (memo ??= - knownFolderId !== undefined - ? Promise.resolve(knownFolderId) - : folderSegments.length > 0 - ? args.ensureFolderPath(folderSegments) - : Promise.resolve(null)), - } - } - - if (rest.length === 0) return plan(true, [], undefined, null) - if (hasTrailingSlash(args.destination)) return plan(true, rest) - const existing = await args.lookupFolder(rest) - if (existing) return plan(true, rest, undefined, existing) - if (args.sourceCount > 1) { - return { - error: `With multiple sources the destination must be a folder. "${args.destination}" does not exist — end it with "/" to create it.`, - } - } - return plan(false, rest.slice(0, -1), rest.at(-1) as string) -} - -/** - * Resolve a `files/...` source to the file at EXACTLY that path (folder- - * anchored). Deliberately not the lenient read-side resolver — on a - * destructive path a bare-name fallback could match a file in a different - * folder than the one named. - */ -async function resolveFileAtExactPath( - workspaceId: string, - segments: string[], - context: ExecutionContext -): Promise { - try { - return await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { - workspaceId, - reference: `files/${encodeVfsPathSegments(segments)}`, - }) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code !== 'not_found') throw error - return null + if (error instanceof VfsPathLimitError) return { success: false, error: error.message } + throw error } } @@ -406,228 +346,43 @@ async function mutateWorkspaceFiles( error: 'Workspace files cannot be copied — cp only duplicates workflows.', } } - const dest = await planDestination({ - destination, - sourceCount: sources.length, - lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), - ensureFolderPath: (segments) => ensureCopilotFileFolderPath(context, workspaceId, segments), + assertMutationNotAborted(context) + const result = await executeCopilotFileUseCase(context, relocateWorkspaceFileVfsItems, { + workspaceId, + sources: sources.map((source) => ({ + source, + segments: decodeVfsPathSegments(source).slice(1), + })), + destination: { + segments: decodeVfsPathSegments(destination).slice(1), + trailingSlash: hasTrailingSlash(destination), + }, }) - if ('error' in dest) return { success: false, error: dest.error } - - // Resolve every source read-only before mutating anything, so a fully - // invalid call cannot create destination folders as a side effect. - type SourceRef = - | { source: string; file: WorkspaceFileRecord } - | { source: string; folderId: string } - | { source: string; error: string } - const refs: SourceRef[] = [] - for (const source of sources) { - const segments = decodeVfsPathSegments(source).slice(1) - if (segments.length === 0) { - refs.push({ source, error: 'Source must name a file or folder under files/' }) - continue - } - const file = await resolveFileAtExactPath(workspaceId, segments, context) - if (file) { - refs.push({ source, file }) - continue - } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) - if (folderId) refs.push({ source, folderId }) - else refs.push({ source, error: `Not found: ${source}` }) - } - - const outcomes: VfsMutateOutcome[] = [] - for (const ref of refs) { - if ('error' in ref) { - outcomes.push({ from: ref.source, kind: 'file', error: ref.error }) - continue - } - - if ('file' in ref) { - assertMutationNotAborted(context) - const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.file.folderId) { - try { - const result = await executeCopilotFileUseCase( - context, - renameWorkspaceFile, - { - fileId: ref.file.id, - assertedWorkspaceId: workspaceId, - name: targetName, - }, - { fileId: ref.file.id } - ) - outcomes.push({ - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, - kind: 'file', - id: ref.file.id, - }) - } catch (error) { - outcomes.push({ - from: ref.source, - kind: 'file', - error: messageForCopilotFileError(error), - }) - } - continue - } - try { - await executeCopilotFileUseCase( - context, - moveWorkspaceFileItemsOperation, - { workspaceId, fileIds: [ref.file.id], targetFolderId }, - { fileId: ref.file.id } - ) - let finalName = ref.file.name - if (targetName !== ref.file.name) { - const renamed = await executeCopilotFileUseCase( - context, - renameWorkspaceFile, - { - fileId: ref.file.id, - assertedWorkspaceId: workspaceId, - name: targetName, - }, - { fileId: ref.file.id } - ) - finalName = renamed.file.name - } - outcomes.push({ - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, finalName])}`, - kind: 'file', - id: ref.file.id, - }) - } catch (error) { - outcomes.push({ - from: ref.source, - kind: 'file', - error: messageForCopilotFileError(error, 'Failed to move file'), - }) - } - continue - } - - assertMutationNotAborted(context) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.folderId) { - outcomes.push({ - from: ref.source, - kind: 'file_folder', - error: 'Cannot move a folder into itself', - }) - continue - } - try { - const result = await executeCopilotFileUseCase(context, updateWorkspaceFileFolderOperation, { - workspaceId, - folderId: ref.folderId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - outcomes.push({ - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, - kind: 'file_folder', - id: ref.folderId, - }) - } catch (error) { - outcomes.push({ - from: ref.source, - kind: 'file_folder', - error: messageForCopilotFileError(error, 'Failed to move folder'), - }) - } - } - - return buildResult(verb, outcomes) -} - -interface WorkflowFolderIndex { - folderPathById: Map - folderIdByPath: Map -} - -async function loadWorkflowFolderIndex(workspaceId: string): Promise { - const folderPathById = buildVfsFolderPathMap(await listFolders(workspaceId)) - const folderIdByPath = new Map() - for (const [id, path] of folderPathById.entries()) folderIdByPath.set(path, id) - return { folderPathById, folderIdByPath } + return buildResult(verb, result.outcomes.map(presentFileVfsOutcome)) } -/** - * mkdir -p for workflow folders: resolves each segment against the index, - * creating missing ones (locked parents rejected) and keeping the index maps - * current so later paths in the same call see the new folders. - */ -function makeWorkflowFolderEnsurer( - workspaceId: string, - userId: string, - index: WorkflowFolderIndex -): (segments: string[]) => Promise { - return async (segments) => { - let parentId: string | null = null - let pathSoFar = '' - for (const segment of segments) { - pathSoFar = pathSoFar - ? `${pathSoFar}/${encodeVfsPathSegments([segment])}` - : encodeVfsPathSegments([segment]) - const existing = index.folderIdByPath.get(pathSoFar) - if (existing) { - parentId = existing - continue - } - await assertFolderMutable(parentId) - const created = await createFolder({ - resourceType: 'workflow', - workspaceId, - userId, - name: segment, - parentId: parentId ?? undefined, - }) - if (!created.success || !created.folder) { - throw new Error(created.error || `Failed to create workflow folder "${segment}"`) - } - index.folderIdByPath.set(pathSoFar, created.folder.id) - index.folderPathById.set(created.folder.id, pathSoFar) - parentId = created.folder.id - } - return parentId +function presentFileVfsOutcome(outcome: WorkspaceFileVfsOutcome): VfsMutateOutcome { + return { + from: outcome.source, + ...(outcome.targetSegments + ? { to: `files/${encodeVfsPathSegments(outcome.targetSegments)}` } + : {}), + kind: outcome.resourceType === 'file' ? 'file' : 'file_folder', + id: outcome.resourceId, + error: outcome.error, } } -interface WorkflowRow { - id: string - name: string - folderId: string | null -} - -/** - * Every workflow in the workspace keyed by its canonical VFS directory, so a - * path resolves without a query per path. Shared by mv/cp and rm, which ask the - * same question of a workflows/ path: is this a workflow or a folder? - */ -async function loadWorkflowsByVfsPath( - workspaceId: string, - folderPathById: Map -): Promise> { - const rows = await db - .select({ id: workflowTable.id, name: workflowTable.name, folderId: workflowTable.folderId }) - .from(workflowTable) - .where(eq(workflowTable.workspaceId, workspaceId)) - const byPath = new Map() - for (const row of rows) { - const dir = canonicalWorkflowVfsDir({ - name: row.name, - folderPath: row.folderId ? folderPathById.get(row.folderId) : null, - }) - if (!byPath.has(dir)) byPath.set(dir, row) +function presentWorkflowVfsOutcome(outcome: WorkflowVfsOutcome): VfsMutateOutcome { + return { + from: outcome.source, + ...(outcome.targetSegments + ? { to: `workflows/${encodeVfsPathSegments(outcome.targetSegments)}` } + : {}), + kind: outcome.resourceType === 'workflow' ? 'workflow' : 'workflow_folder', + id: outcome.resourceId, + error: outcome.error, } - return byPath } async function mutateWorkflows( @@ -637,170 +392,31 @@ async function mutateWorkflows( context: ExecutionContext, workspaceId: string ): Promise { - const index = await loadWorkflowFolderIndex(workspaceId) - const { folderPathById, folderIdByPath } = index - - const workflowByPath = await loadWorkflowsByVfsPath(workspaceId, folderPathById) - - const ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) - - const dest = await planDestination({ - destination, - sourceCount: sources.length, - lookupFolder: async (segments) => folderIdByPath.get(encodeVfsPathSegments(segments)) ?? null, - ensureFolderPath: ensureWorkflowFolderPath, - }) - if ('error' in dest) return { success: false, error: dest.error } - if (!dest.dirMode && (dest.leafName as string).length > 200) { - return { success: false, error: 'Workflow name must be 200 characters or less' } - } - - // Resolve every source against the in-memory maps before mutating anything. - type SourceRef = - | { source: string; workflow: WorkflowRow } - | { source: string; folderId: string } - | { source: string; error: string } - const refs: SourceRef[] = [] - for (const source of sources) { - const segments = decodeVfsPathSegments(source).slice(1) - if (segments.length === 0) { - refs.push({ source, error: 'Source must name a workflow or folder under workflows/' }) - continue - } - const encoded = encodeVfsPathSegments(segments) - const wf = workflowByPath.get(`workflows/${encoded}`) - if (wf) { - refs.push({ source, workflow: wf }) - continue - } - const folderId = folderIdByPath.get(encoded) - if (folderId) refs.push({ source, folderId }) - else refs.push({ source, error: `Not found: ${source}` }) + assertMutationNotAborted(context) + const input = { + workspaceId, + sources: sources.map((source) => ({ + source, + segments: decodeVfsPathSegments(source).slice(1), + })), + destination: { + segments: decodeVfsPathSegments(destination).slice(1), + trailingSlash: hasTrailingSlash(destination), + }, } - - const outcomes: VfsMutateOutcome[] = [] - for (const ref of refs) { - if ('error' in ref) { - outcomes.push({ from: ref.source, kind: 'workflow', error: ref.error }) - continue - } - - if ('workflow' in ref) { - const wf = ref.workflow - const targetName = dest.dirMode ? wf.name : (dest.leafName as string) - try { - assertMutationNotAborted(context) - if (verb === 'cp') { - const targetFolderId = await dest.ensureFolderId() - const duplicated = await duplicateWorkflow({ - sourceWorkflowId: wf.id, - userId: context.userId, - workspaceId, - folderId: targetFolderId, - name: targetName, - requestId: generateRequestId(), - }) - outcomes.push({ - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, duplicated.name])}`, - kind: 'workflow', - id: duplicated.id, - }) - } else { - await ensureWorkflowAccess(wf.id, context.userId, 'write') - await assertWorkflowMutable(wf.id) - const targetFolderId = await dest.ensureFolderId() - await assertFolderMutable(targetFolderId) - if (targetFolderId && !(await verifyFolderWorkspace(targetFolderId, workspaceId))) { - outcomes.push({ - from: ref.source, - kind: 'workflow', - error: 'Destination folder not found', - }) - continue - } - const result = await performUpdateWorkflow({ - workflowId: wf.id, - userId: context.userId, - workspaceId, - currentName: wf.name, - currentFolderId: wf.folderId, - name: dest.dirMode ? undefined : targetName, - folderId: targetFolderId, - }) - outcomes.push( - result.success - ? { - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, targetName])}`, - kind: 'workflow', - id: wf.id, - } - : { - from: ref.source, - kind: 'workflow', - error: result.error || 'Failed to move workflow', - } - ) - } - } catch (error) { - outcomes.push({ from: ref.source, kind: 'workflow', error: toError(error).message }) - } - continue - } - - if (verb === 'cp') { - outcomes.push({ - from: ref.source, - kind: 'workflow_folder', - error: 'Workflow folders cannot be copied.', - }) - continue - } - try { - assertMutationNotAborted(context) - await assertFolderMutable(ref.folderId) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.folderId) { - outcomes.push({ - from: ref.source, - kind: 'workflow_folder', - error: 'Cannot move a folder into itself', - }) - continue - } - await assertFolderMutable(targetFolderId) - const result = await updateFolder({ - resourceType: 'workflow', - folderId: ref.folderId, - workspaceId, - userId: context.userId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - const finalLeaf = dest.dirMode - ? (decodeVfsPathSegments(ref.source).slice(1).at(-1) ?? '') - : (dest.leafName as string) - outcomes.push( - result.success - ? { - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, finalLeaf])}`, - kind: 'workflow_folder', - id: ref.folderId, - } - : { - from: ref.source, - kind: 'workflow_folder', - error: result.error || 'Failed to move folder', - } - ) - } catch (error) { - outcomes.push({ from: ref.source, kind: 'workflow_folder', error: toError(error).message }) + try { + const result = + verb === 'cp' + ? await executeCopilotWorkflowUseCase(context, copyWorkflowVfsItems, input) + : await executeCopilotWorkflowUseCase(context, moveWorkflowVfsItems, input) + return buildResult(verb, result.outcomes.map(presentWorkflowVfsOutcome)) + } catch (error) { + if (context.abortSignal?.aborted) throw error + return { + success: false, + error: messageForExpectedWorkflowVfsError(error, 'Workflow mutation failed'), } } - - return buildResult(verb, outcomes) } async function renameFlatResource( @@ -832,76 +448,58 @@ async function renameFlatResource( const sourceName = sourceSegments[0] const newName = destSegments[0] - const canonicalSource = normalizeVfsSegment(sourceName) if (category === 'tables') { - const tables = await listTables(workspaceId) - const match = tables.find((t) => normalizeVfsSegment(t.name) === canonicalSource) - if (!match) { - return { success: false, error: `Table not found at ${sources[0]}` } - } - assertMutationNotAborted(context) - const renameOutcome = await performRenameTable({ - table: match, - newName, - userId: context.userId, - requestId: generateRequestId(), - }) - if (!renameOutcome.success) { - return { success: false, error: renameOutcome.error ?? 'Failed to rename table' } + try { + const renamed = await executeCopilotTableUseCase( + context, + renameTableByVfsPath, + { workspaceId, sourceName, newName }, + {} + ) + return buildResult(verb, [ + { + from: sources[0], + to: `tables/${normalizeVfsSegment(renamed.name)}`, + kind, + id: renamed.id, + }, + ]) + } catch (error) { + return { success: false, error: messageForExpectedTableVfsError(error) } } - return buildResult(verb, [ - { - from: sources[0], - to: `tables/${normalizeVfsSegment(newName)}`, - kind, - id: match.id, - }, - ]) } if (newName.toLowerCase() === 'connectors') { return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } - let knowledgeBases: Awaited>['knowledgeBases'] try { - const result = await executeCopilotKnowledgeUseCase(context, listKnowledgeBases, { + const renamed = await executeCopilotKnowledgeUseCase(context, renameKnowledgeBaseByVfsPath, { workspaceId, + sourceName, + newName, }) - knowledgeBases = result.knowledgeBases - } catch (error) { - return { - success: false, - error: messageForKnowledgeVfsError(error, 'Write access required to rename knowledge bases'), - } - } - const match = knowledgeBases - .map(({ knowledgeBase }) => knowledgeBase) - .find((kb) => normalizeVfsSegment(kb.name) === canonicalSource) - if (!match) { - return { success: false, error: `Knowledge base not found at ${sources[0]}` } - } - assertMutationNotAborted(context) - try { - await executeCopilotKnowledgeUseCase(context, updateKnowledgeBaseOperation, { - knowledgeBaseId: match.id, - assertedWorkspaceId: workspaceId, - name: newName, - source: 'agent', + logger.info('Renamed knowledge base via mv', { + knowledgeBaseId: renamed.id, + workspaceId, }) + return buildResult(verb, [ + { + from: sources[0], + to: `knowledgebases/${normalizeVfsSegment(renamed.name)}`, + kind, + id: renamed.id, + }, + ]) } catch (error) { return { success: false, error: messageForKnowledgeVfsError( error, - `Write access required to rename knowledge base "${match.name}"` + `Write access required to rename knowledge base "${sourceName}"` ), } } - logger.info('Renamed knowledge base via mv', { knowledgeBaseId: match.id, workspaceId }) - return buildResult(verb, [ - { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, - ]) } /** @@ -922,17 +520,47 @@ export async function executeVfsRm( if (paths.length === 0) { return { success: false, error: 'paths is required (an array of VFS paths to delete)' } } + validateVfsPathBatch(paths) const workspaceId = requireCopilotWorkspace(context) - if (paths.some((path) => topLevelSegment(path) === 'knowledgebases')) { - resolveCopilotKnowledgePrincipal(context) - } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) - // Loaded at most once, and only when a workflows/ path in this call needs it. - let workflowIndex: Promise | undefined - const getWorkflowIndex = () => (workflowIndex ??= loadWorkflowRemoveIndex(workspaceId)) + const filePaths = paths.filter((path) => topLevelSegment(path) === 'files') + const fileOutcomes = new Map() + if (filePaths.length > 0) { + const result = await executeCopilotFileUseCase(context, deleteWorkspaceFileVfsItems, { + workspaceId, + paths: filePaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + fileOutcomes.set(outcome.source, presentFileVfsOutcome(outcome)) + } + } + + const workflowPaths = paths.filter((path) => topLevelSegment(path) === 'workflows') + const workflowOutcomes = new Map() + if (workflowPaths.length > 0) { + try { + const result = await executeCopilotWorkflowUseCase(context, deleteWorkflowVfsItems, { + workspaceId, + paths: workflowPaths.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + }) + for (const outcome of result.outcomes) { + workflowOutcomes.set(outcome.source, presentWorkflowVfsOutcome(outcome)) + } + } catch (error) { + const message = messageForExpectedWorkflowVfsError(error, 'Workflow deletion failed') + for (const path of workflowPaths) { + workflowOutcomes.set(path, { from: path, kind: 'workflow', error: message }) + } + } + } const outcomes: VfsMutateOutcome[] = [] for (const path of paths) { @@ -943,19 +571,32 @@ export async function executeVfsRm( } try { assertMutationNotAborted(context) - outcomes.push( - await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) - ) + if (classified.category === 'workflows') { + outcomes.push( + workflowOutcomes.get(path) ?? { + from: path, + kind: 'workflow', + error: 'Workflow deletion failed', + } + ) + } else if (classified.category === 'files') { + outcomes.push( + fileOutcomes.get(path) ?? { from: path, kind: 'file', error: 'File deletion failed' } + ) + } else { + outcomes.push(await removeOne(classified.category, path, context, workspaceId)) + } } catch (error) { if (error instanceof KnowledgeVfsInfrastructureError) throw error - outcomes.push({ - from: path, - kind: defaultKindFor(path), - error: - classified.category === 'files' - ? messageForCopilotFileError(error, 'File deletion failed') - : toError(error).message, - }) + if (classified.category === 'workflows') { + outcomes.push({ + from: path, + kind: defaultKindFor(path), + error: messageForExpectedWorkflowVfsError(error, 'Workflow deletion failed'), + }) + continue + } + throw error } } @@ -964,12 +605,11 @@ export async function executeVfsRm( if (error instanceof KnowledgeVfsInfrastructureError) { throw error.infrastructureCause } - return { - success: false, - error: context.abortSignal?.aborted - ? 'Request aborted before the mutation could be applied.' - : 'Delete failed', + if (context.abortSignal?.aborted) { + return { success: false, error: 'Request aborted before the mutation could be applied.' } } + if (error instanceof VfsPathLimitError) return { success: false, error: error.message } + throw error } } @@ -988,17 +628,12 @@ function defaultKindFor(path: string): VfsMutateOutcome['kind'] { } function removeOne( - category: MutateCategory, + category: Exclude, path: string, context: ExecutionContext, - workspaceId: string, - getWorkflowIndex: () => Promise + workspaceId: string ): Promise { switch (category) { - case 'files': - return removeWorkspaceFilePath(path, context, workspaceId) - case 'workflows': - return removeWorkflowPath(path, context, workspaceId, getWorkflowIndex) case 'tables': return removeTablePath(path, context, workspaceId) case 'knowledgebases': @@ -1006,140 +641,11 @@ function removeOne( } } -/** - * A files/ path is either a leaf file or a folder, and the two cannot collide, - * so resolving the file first and falling back to the folder is unambiguous. - * Both go through performDeleteWorkspaceFileItems — deleting a folder archives - * the files and subfolders inside it. - */ -async function removeWorkspaceFilePath( - path: string, - context: ExecutionContext, - workspaceId: string -): Promise { - let file: WorkspaceFileRecord | undefined - try { - file = await resolveCopilotWorkspaceFileReference(context, fileOperations.delete, { - workspaceId, - reference: path, - }) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code !== 'not_found') throw error - } - if (file) { - await executeCopilotFileUseCase( - context, - deleteWorkspaceFileOperation, - { fileId: file.id, assertedWorkspaceId: workspaceId }, - { fileId: file.id } - ) - logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) - return { from: path, kind: 'file', id: file.id } - } - - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length === 0) { - return { from: path, kind: 'file', error: 'Path must name a file or folder under files/' } - } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) - if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } - - try { - const result = await executeCopilotFileUseCase(context, archiveWorkspaceFileItemsOperation, { - workspaceId, - folderIds: [folderId], - }) - logger.info('Deleted file folder via rm', { folderId, workspaceId }) - return { from: path, kind: 'file_folder', id: folderId } - } catch (error) { - return { - from: path, - kind: 'file_folder', - id: folderId, - error: messageForCopilotFileError(error, 'Failed to delete'), - } - } -} - -interface WorkflowRemoveIndex { - workflowByPath: Map - folderIdByPath: Map -} - -async function loadWorkflowRemoveIndex(workspaceId: string): Promise { - const { folderPathById, folderIdByPath } = await loadWorkflowFolderIndex(workspaceId) - return { - workflowByPath: await loadWorkflowsByVfsPath(workspaceId, folderPathById), - folderIdByPath, - } -} - -/** - * Workflow first, then folder — the same resolution order mv uses. The lock - * assertions are what make a locked workflow (or one inside a locked folder) - * fail here rather than silently archiving. - */ -async function removeWorkflowPath( - path: string, - context: ExecutionContext, - workspaceId: string, - getWorkflowIndex: () => Promise -): Promise { - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length === 0) { - return { - from: path, - kind: 'workflow', - error: 'Path must name a workflow or folder under workflows/', - } - } - const encoded = encodeVfsPathSegments(segments) - const { workflowByPath, folderIdByPath } = await getWorkflowIndex() - - const workflow = workflowByPath.get(`workflows/${encoded}`) - if (workflow) { - await assertWorkflowMutable(workflow.id) - const result = await performDeleteWorkflow({ workflowId: workflow.id, userId: context.userId }) - if (!result.success) { - return { - from: path, - kind: 'workflow', - id: workflow.id, - error: result.error || 'Failed to delete workflow', - } - } - logger.info('Deleted workflow via rm', { workflowId: workflow.id, workspaceId }) - return { from: path, kind: 'workflow', id: workflow.id } - } - - const folderId = folderIdByPath.get(encoded) - if (!folderId) return { from: path, kind: 'workflow', error: `Not found: ${path}` } - - await assertFolderMutable(folderId) - const result = await deleteFolder({ - resourceType: 'workflow', - folderId, - workspaceId, - userId: context.userId, - }) - if (!result.success) { - return { - from: path, - kind: 'workflow_folder', - id: folderId, - error: result.error || 'Failed to delete folder', - } - } - logger.info('Deleted workflow folder via rm', { folderId, workspaceId }) - return { from: path, kind: 'workflow_folder', id: folderId } -} - /** Resolves a flat tables/{name} or knowledgebases/{name} path to its single segment. */ -function flatResourceName(path: string, category: 'tables' | 'knowledgebases'): string | null { +function flatResourceName(path: string): string | null { const segments = decodeVfsPathSegments(path).slice(1) if (segments.length !== 1) return null - return normalizeVfsSegment(segments[0]) + return segments[0] } async function removeTablePath( @@ -1147,29 +653,32 @@ async function removeTablePath( context: ExecutionContext, workspaceId: string ): Promise { - const canonical = flatResourceName(path, 'tables') - if (!canonical) { + const sourceName = flatResourceName(path) + if (!sourceName) { return { from: path, kind: 'table', error: 'tables/ is a flat namespace — rm takes a single name, e.g. rm(["tables/Leads"]).', } } - const match = (await listTables(workspaceId)).find( - (table) => normalizeVfsSegment(table.name) === canonical - ) - if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` } - - const outcome = await performDeleteTable({ - table: match, - userId: context.userId, - requestId: generateRequestId(), - }) - if (!outcome.success) { - return { from: path, kind: 'table', error: outcome.error ?? 'Failed to archive table' } + try { + const deleted = await executeCopilotTableUseCase( + context, + deleteTableByVfsPath, + { workspaceId, sourceName }, + {} + ) + captureServerEvent( + context.userId, + 'table_deleted', + { table_id: deleted.id, workspace_id: deleted.workspaceId }, + { groups: { workspace: deleted.workspaceId } } + ) + logger.info('Archived table via rm', { tableId: deleted.id, workspaceId }) + return { from: path, kind: 'table', id: deleted.id } + } catch (error) { + return { from: path, kind: 'table', error: messageForExpectedTableVfsError(error) } } - logger.info('Archived table via rm', { tableId: match.id, workspaceId }) - return { from: path, kind: 'table', id: match.id } } async function removeKnowledgeBasePath( @@ -1177,8 +686,8 @@ async function removeKnowledgeBasePath( context: ExecutionContext, workspaceId: string ): Promise { - const canonical = flatResourceName(path, 'knowledgebases') - if (!canonical) { + const sourceName = flatResourceName(path) + if (!sourceName) { return { from: path, kind: 'knowledge_base', @@ -1186,50 +695,32 @@ async function removeKnowledgeBasePath( 'knowledgebases/ is a flat namespace — rm takes a single name, e.g. rm(["knowledgebases/support-docs"]).', } } - if (canonical === normalizeVfsSegment('connectors')) { + if (sourceName.toLowerCase() === 'connectors') { return { from: path, kind: 'knowledge_base', error: '"knowledgebases/connectors" is a reserved path, not a knowledge base.', } } - let knowledgeBases: Awaited>['knowledgeBases'] try { - const result = await executeCopilotKnowledgeUseCase(context, listKnowledgeBases, { + const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseByVfsPath, { workspaceId, + sourceName, }) - knowledgeBases = result.knowledgeBases - } catch (error) { - return { - from: path, - kind: 'knowledge_base', - error: messageForKnowledgeVfsError(error, 'Write access required to delete knowledge bases'), - } - } - const match = knowledgeBases - .map(({ knowledgeBase }) => knowledgeBase) - .find((kb) => normalizeVfsSegment(kb.name) === canonical) - if (!match) - return { from: path, kind: 'knowledge_base', error: `Knowledge base not found at ${path}` } - - try { - await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseOperation, { - knowledgeBaseId: match.id, - assertedWorkspaceId: workspaceId, - source: 'agent', + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: deleted.id }) + logger.info('Deleted knowledge base via rm', { + knowledgeBaseId: deleted.id, + workspaceId, }) + return { from: path, kind: 'knowledge_base', id: deleted.id } } catch (error) { return { from: path, kind: 'knowledge_base', - id: match.id, error: messageForKnowledgeVfsError( error, - `Write access required to delete knowledge base "${match.name}"` + `Write access required to delete knowledge base "${sourceName}"` ), } } - PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: match.id }) - logger.info('Deleted knowledge base via rm', { knowledgeBaseId: match.id, workspaceId }) - return { from: path, kind: 'knowledge_base', id: match.id } } diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index fa9dc2465e7..49939478d95 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -1,1146 +1,260 @@ /** * @vitest-environment node */ -import { - dbChainMock, - requestUtilsMockFns, - resetEnvMock, - schemaMock, - setEnv, - workflowAuthzMockFns, -} from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' - -beforeAll(() => { - setEnv({ INTERNAL_API_SECRET: 'secret', SOCKET_SERVER_URL: 'http://socket.test' }) - requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') -}) - -afterAll(() => { - resetEnvMock() - requestUtilsMockFns.mockGenerateRequestId.mockReset() -}) - -import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -import { - ANONYMOUS_SECRET_TRACE_REPLACEMENT, - ResolvedSecretTraceRegistry, -} from '@/executor/utils/resolved-secret-trace-registry' - -const { - ensureWorkflowAccessMock, - ensureWorkspaceAccessMock, - setWorkflowVariablesMock, - recordAuditMock, - performCreateWorkflowMock, - executeWorkflowMock, - getExecutionStateForWorkflowMock, - getLatestExecutionStateWithExecutionIdMock, - loadWorkflowFromNormalizedTablesMock, - resolveBillingAttributionMock, - resolveTriggerRunOptionsMock, - checkAttributedUsageLimitsMock, - reserveExecutionSlotMock, - releaseExecutionSlotMock, - decryptSecretMock, -} = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - ensureWorkspaceAccessMock: vi.fn(), - setWorkflowVariablesMock: vi.fn(), - recordAuditMock: vi.fn(), - performCreateWorkflowMock: vi.fn(), - executeWorkflowMock: vi.fn(), - getExecutionStateForWorkflowMock: vi.fn(), - getLatestExecutionStateWithExecutionIdMock: vi.fn(), - loadWorkflowFromNormalizedTablesMock: vi.fn(), - resolveBillingAttributionMock: vi.fn(), - resolveTriggerRunOptionsMock: vi.fn(), - checkAttributedUsageLimitsMock: vi.fn(), - reserveExecutionSlotMock: vi.fn(), - releaseExecutionSlotMock: vi.fn(), - decryptSecretMock: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { WORKFLOW_VARIABLES_UPDATED: 'WORKFLOW_VARIABLES_UPDATED' }, - AuditResourceType: { WORKFLOW: 'WORKFLOW' }, - recordAudit: recordAuditMock, -})) -vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) - -vi.mock('@/lib/api-key/orchestration', () => ({ - performCreateWorkspaceApiKey: vi.fn(), -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - checkAttributedUsageLimits: checkAttributedUsageLimitsMock, - resolveBillingAttribution: resolveBillingAttributionMock, -})) - -vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ - releaseExecutionSlot: releaseExecutionSlotMock, - reserveExecutionSlot: reserveExecutionSlotMock, - UsageReservationUnavailableError: class UsageReservationUnavailableError extends Error {}, -})) - -vi.mock('@/lib/core/security/encryption', () => ({ - decryptSecret: decryptSecretMock, - encryptSecret: vi.fn(), -})) - -vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ - executeWorkflow: executeWorkflowMock, +const { mocks } = vi.hoisted(() => ({ + mocks: { + apiKey: vi.fn(), + defaultWorkspace: vi.fn(), + executeWorkflowUseCase: vi.fn(), + hasExecutionResult: vi.fn(), + }, })) -vi.mock('@/lib/workflows/executor/execution-state', () => ({ - getExecutionStateForWorkflow: getExecutionStateForWorkflowMock, - getLatestExecutionStateWithExecutionId: getLatestExecutionStateWithExecutionIdMock, +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: mocks.executeWorkflowUseCase, + messageForCopilotWorkflowError: (_error: unknown, fallback = 'Workflow operation failed') => + fallback, })) -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateFolder: vi.fn(), - performCreateWorkflow: performCreateWorkflowMock, - performDeleteFolder: vi.fn(), - performDeleteWorkflow: vi.fn(), - performUpdateFolder: vi.fn(), - performUpdateWorkflow: vi.fn(), +vi.mock('@/lib/copilot/application/execute-api-key-use-case', () => ({ + executeCopilotApiKeyUseCase: mocks.apiKey, })) -vi.mock('@/lib/workflows/persistence/utils', () => ({ - loadWorkflowFromNormalizedTables: loadWorkflowFromNormalizedTablesMock, - saveWorkflowToNormalizedTables: vi.fn(), +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + getDefaultWorkspaceId: mocks.defaultWorkspace, })) vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({ sanitizeForCopilot: vi.fn((state) => state), })) -vi.mock('@/lib/workflows/triggers/run-options', () => ({ - resolveTriggerRunOptions: resolveTriggerRunOptionsMock, - validateTriggerInput: vi.fn(), -})) - -vi.mock('@/lib/workflows/utils', () => ({ - listFolders: vi.fn(), - setWorkflowVariables: setWorkflowVariablesMock, - verifyFolderWorkspace: vi.fn(), -})) - vi.mock('@/executor/utils/errors', () => ({ - hasExecutionResult: vi.fn(() => false), + hasExecutionResult: mocks.hasExecutionResult, })) -vi.mock('../access', () => ({ - ensureWorkflowAccess: ensureWorkflowAccessMock, - ensureWorkspaceAccess: ensureWorkspaceAccessMock, - getDefaultWorkspaceId: vi.fn(), +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { apiKeyGenerated: vi.fn() }, })) -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' -import { performUpdateWorkflow } from '@/lib/workflows/orchestration' -import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' import { executeCreateWorkflow, + executeGenerateApiKey, executeMoveWorkflow, executeRunBlock, executeRunFromBlock, executeRunWorkflow, executeRunWorkflowUntilBlock, executeSetGlobalWorkflowVariables, -} from './mutations' +} from '@/lib/copilot/tools/handlers/workflow/mutations' -const performUpdateWorkflowMock = vi.mocked(performUpdateWorkflow) -const listFoldersMock = vi.mocked(listFolders) -const verifyFolderWorkspaceMock = vi.mocked(verifyFolderWorkspace) -const billingAttribution: BillingAttributionSnapshot = { - actorUserId: 'user-1', - workspaceId: 'workspace-1', - organizationId: null, - billedAccountUserId: 'owner-1', - billingEntity: { type: 'user', id: 'owner-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, -} -const childBillingAttribution: BillingAttributionSnapshot = Object.freeze({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - organizationId: 'organization-2', - billedAccountUserId: 'owner-2', - billingEntity: { type: 'organization', id: 'organization-2' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, -}) -const executionContext: ExecutionContext = { +const context = { userId: 'user-1', - workflowId: 'workflow-1', workspaceId: 'workspace-1', - billingAttribution, -} - -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() - }) -}) + workflowId: 'workflow-1', + toolCallId: 'tool-call-1', + billingAttribution: { workspaceId: 'workspace-1' }, +} as ExecutionContext -describe('executeCreateWorkflow billing attribution', () => { +describe('workflow mutation Copilot adapters', () => { beforeEach(() => { vi.clearAllMocks() - ensureWorkspaceAccessMock.mockResolvedValue(undefined) - workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) - loadWorkflowFromNormalizedTablesMock.mockResolvedValue({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }) - resolveTriggerRunOptionsMock.mockReturnValue([ - { - triggerBlockId: 'trigger-1', - blockName: 'Start', - mockPayload: { source: 'copilot' }, - }, - ]) - executeWorkflowMock.mockResolvedValue({ - success: true, - output: {}, - logs: [], - metadata: { executionId: 'new-execution-1' }, - }) - checkAttributedUsageLimitsMock.mockResolvedValue({ - isExceeded: false, - payerUsage: { currentUsage: 1, limit: 10 }, - }) - reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true }) - decryptSecretMock.mockResolvedValue({ decrypted: 'secret-value' }) - listFoldersMock.mockResolvedValue([]) + mocks.defaultWorkspace.mockResolvedValue('workspace-1') + mocks.hasExecutionResult.mockReturnValue(false) }) - it('ignores legacy description input instead of persisting it', async () => { - performCreateWorkflowMock.mockResolvedValue({ - success: true, - workflow: { - id: 'created-workflow', - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderId: null, - }, - }) - const legacyParams = { - name: 'Created Workflow', - workspaceId: 'workspace-1', - description: 'PRIVATE WORKFLOW DESCRIPTION', - } as Parameters[0] - - const result = await executeCreateWorkflow(legacyParams, executionContext) - - expect(result.success).toBe(true) - expect(performCreateWorkflowMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Created Workflow', - folderId: null, - }) - }) - - it('canonicalizes a workflow-folder VFS path and resolves its internal ID', async () => { - listFoldersMock.mockResolvedValue([ - { folderId: 'folder-dream', folderName: 'Dream', parentId: null }, - { - folderId: 'folder-launch-plans', - folderName: 'Launch Plans', - parentId: 'folder-dream', - }, - ]) - performCreateWorkflowMock.mockResolvedValue({ - success: true, + it('maps encoded folder aliases into one create application command', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ workflow: { - id: 'created-workflow', - name: 'Created Workflow', + id: 'workflow-new', + name: 'New Workflow', workspaceId: 'workspace-1', - folderId: 'folder-launch-plans', + folderId: 'folder-1', }, + normalizedState: { blocks: {}, edges: [], loops: {}, parallels: {} }, }) const result = await executeCreateWorkflow( - { - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderPath: 'workflows/Dream/Launch%20Plans', - }, - executionContext + { name: ' New Workflow ', folderPath: 'workflows/Launch%20Plans' }, + context ) expect(result.success).toBe(true) - expect(performCreateWorkflowMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Created Workflow', - folderId: 'folder-launch-plans', - }) - expect(workflowAuthzMockFns.mockAssertFolderMutable).toHaveBeenCalledWith('folder-launch-plans') - }) - - it('fails clearly when a workflow-folder VFS path does not exist', async () => { - listFoldersMock.mockResolvedValue([ - { folderId: 'folder-existing', folderName: 'Existing', parentId: null }, - ]) - - const result = await executeCreateWorkflow( + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.create' }) }), { - name: 'Created Workflow', workspaceId: 'workspace-1', - folderPath: 'workflows/Dream', - }, - executionContext + name: 'New Workflow', + folderPath: '/Launch%20Plans', + } ) - - expect(result).toEqual({ - success: false, - error: 'Folder not found at workflows/Dream', - }) - expect(performCreateWorkflowMock).not.toHaveBeenCalled() }) - it('rejects canonically ambiguous workflow-folder VFS paths', async () => { - listFoldersMock.mockResolvedValue([ - { folderId: 'folder-cafe-nfc', folderName: 'Caf\u00e9', parentId: null }, - { folderId: 'folder-cafe-nfd', folderName: 'Cafe\u0301', parentId: null }, - ]) + it('calls the compound variable command once', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ updated: 2 }) + const operations = [ + { operation: 'add' as const, name: 'threshold', type: 'number', value: '5' }, + ] - const result = await executeCreateWorkflow( - { - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderPath: 'workflows/Caf%C3%A9', - }, - executionContext - ) - - expect(result).toEqual({ - success: false, - error: - 'Folder path is ambiguous after canonicalization: workflows/Caf%C3%A9. Rename one of the conflicting folders and retry.', - }) - expect(performCreateWorkflowMock).not.toHaveBeenCalled() - expect(workflowAuthzMockFns.mockAssertFolderMutable).not.toHaveBeenCalled() - }) - - it('keeps same-workspace creation and subsequent execution on the immutable payer', async () => { - const context: ExecutionContext = { ...executionContext, workflowId: '' } - performCreateWorkflowMock.mockResolvedValue({ - success: true, - workflow: { - id: 'created-workflow', - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderId: null, - }, - }) - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'created-workflow', - userId: 'owner-1', - workspaceId: 'workspace-1', - variables: {}, - }, - }) - - const createResult = await executeCreateWorkflow( - { name: 'Created Workflow', workspaceId: 'workspace-1' }, - context - ) - - expect(createResult.success).toBe(true) - applyCreateWorkflowOutputToContext(createResult.output, context) - expect(context).toMatchObject({ - userId: 'user-1', - workflowId: 'created-workflow', - workspaceId: 'workspace-1', - billingAttribution, - }) - expect(context.billingAttribution).toBe(billingAttribution) - expect(performCreateWorkflowMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-1' }) - ) - - const runResult = await executeRunWorkflow({ useMockPayload: true }, context) - - expect(runResult.success).toBe(true) - expect(executeWorkflowMock.mock.calls[0]?.[3]).toBe('user-1') - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).not.toHaveBeenCalled() - expect(reserveExecutionSlotMock).not.toHaveBeenCalled() - expect(resolveBillingAttributionMock).not.toHaveBeenCalled() - }) - - it('keeps cross-workspace creation scoped while allowing explicit subsequent execution', async () => { - const context: ExecutionContext = { ...executionContext, workflowId: '' } - performCreateWorkflowMock.mockResolvedValue({ - success: true, - workflow: { - id: 'created-workflow', - name: 'Other Workspace Workflow', - workspaceId: 'workspace-2', - folderId: null, - }, - }) - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'created-workflow', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - - const createResult = await executeCreateWorkflow( - { name: 'Other Workspace Workflow', workspaceId: 'workspace-2' }, - context - ) - - expect(createResult.success).toBe(true) - applyCreateWorkflowOutputToContext(createResult.output, context) - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('workspace-2', 'user-1', 'write') - expect(performCreateWorkflowMock).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1', workspaceId: 'workspace-2' }) - ) - expect(context).toMatchObject({ - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - billingAttribution, - }) - expect(context.billingAttribution).toBe(billingAttribution) - const createOutput = createResult.output as { workflowId: string; workspaceId: string } - expect(createOutput).toEqual( - expect.objectContaining({ workflowId: 'created-workflow', workspaceId: 'workspace-2' }) - ) - - const runResult = await executeRunWorkflow( - { workflowId: createOutput.workflowId, useMockPayload: true }, + const result = await executeSetGlobalWorkflowVariables( + { workflowId: 'workflow-1', operations }, context ) - expect(runResult.success).toBe(true) - expect(resolveBillingAttributionMock).toHaveBeenCalledOnce() - expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - }) - expect(executeWorkflowMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ id: 'created-workflow', workspaceId: 'workspace-2' }) - ) - expect(executeWorkflowMock.mock.calls[0]?.[3]).toBe('user-1') - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution: childBillingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledWith(childBillingAttribution) - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( + expect(result).toEqual({ success: true, output: { updated: 2 } }) + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledOnce() + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, expect.objectContaining({ - billingEntity: childBillingAttribution.billingEntity, - executionId: executeWorkflowMock.mock.calls[0]?.[5], - }) - ) - expect(context.billingAttribution).toBe(billingAttribution) - }) -}) - -describe('Copilot workflow execution billing attribution', () => { - const sourceSnapshot = { - blockStates: {}, - executedBlocks: [], - blockLogs: [], - decisions: {}, - completedLoops: [], - activeExecutionPath: [], - } - - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-1', - userId: 'owner-1', - workspaceId: 'workspace-1', - variables: {}, - }, - }) - loadWorkflowFromNormalizedTablesMock.mockResolvedValue({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }) - resolveTriggerRunOptionsMock.mockReturnValue([ - { - triggerBlockId: 'trigger-1', - blockName: 'Start', - mockPayload: { source: 'copilot' }, - }, - ]) - getExecutionStateForWorkflowMock.mockResolvedValue(sourceSnapshot) - executeWorkflowMock.mockResolvedValue({ - success: true, - output: {}, - logs: [], - metadata: { executionId: 'new-execution-1' }, - }) - checkAttributedUsageLimitsMock.mockResolvedValue({ - isExceeded: false, - payerUsage: { currentUsage: 1, limit: 10 }, - }) - reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true }) - decryptSecretMock.mockResolvedValue({ decrypted: 'secret-value' }) - }) - - async function expectBillingAttributionForwarded( - run: () => Promise<{ success: boolean }> - ): Promise { - const result = await run() - - expect(result.success).toBe(true) - expect(executeWorkflowMock).toHaveBeenCalledTimes(1) - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).not.toHaveBeenCalled() - expect(reserveExecutionSlotMock).not.toHaveBeenCalled() - } - - it('passes immutable attribution when running a workflow', async () => { - await expectBillingAttributionForwarded(() => - executeRunWorkflow({ workflowId: 'workflow-1', useMockPayload: true }, executionContext) - ) - }) - - it('passes only input-crossing parent provenance to the child execution', async () => { - const registry = new ResolvedSecretTraceRegistry( - [ - { - name: 'INPUT_SECRET', - plaintext: 'input-secret', - encryptedValue: 'input-ciphertext', - }, - { - name: 'UNRELATED_SECRET', - plaintext: 'unrelated-secret', - encryptedValue: 'unrelated-ciphertext', - }, - ], - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - registry.recordResolved('INPUT_SECRET', 'input-secret') - registry.recordResolved('UNRELATED_SECRET', 'unrelated-secret') - resolveTriggerRunOptionsMock.mockReturnValueOnce([ + operation: expect.objectContaining({ id: 'workflows.variables.apply_operations' }), + }), { - triggerBlockId: 'trigger-1', - blockName: 'Start', - mockPayload: { value: 'input-secret' }, - }, - ]) - executeWorkflowMock.mockResolvedValueOnce({ - success: true, - output: { ok: true }, - logs: [], - metadata: { executionId: 'new-execution-1' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }, - }) - - const result = await executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, - { ...executionContext, resolvedSecretTraceRegistry: registry } - ) - - expect(result.success).toBe(true) - expect(executeWorkflowMock.mock.calls[0]?.[2]).toEqual({ value: 'input-secret' }) - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ - trustedInitialResolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [{ name: 'INPUT_SECRET', encryptedValue: 'input-ciphertext' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }) + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + operations, + } ) - expect(JSON.stringify(result)).not.toContain('input-ciphertext') - expect(JSON.stringify(result)).not.toContain('unrelated-ciphertext') }) - it('imports child provenance without returning private metadata to the model', async () => { - const registry = new ResolvedSecretTraceRegistry([], { - userId: 'user-1', - workspaceId: 'workspace-1', - }) - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - executeWorkflowMock.mockResolvedValueOnce({ + it('projects one run command result without exposing binary payloads', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ success: true, - output: { value: 'secret-value' }, + output: { file: { base64: 'secret-bytes', name: 'report.pdf' } }, logs: [], - metadata: { executionId: 'new-execution-1' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }, + metadata: { executionId: 'execution-1' }, }) const result = await executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, + { workflowId: 'workflow-1', workflow_input: { query: 'hello' } }, context ) expect(result).toMatchObject({ success: true, - output: { output: { value: 'secret-value' } }, - }) - expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, - ]) - expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance') - expect(JSON.stringify(result)).not.toContain('encrypted-secret') - }) - - it('keeps unrelated tool-result projection available while child provenance is pending', async () => { - const registry = new ResolvedSecretTraceRegistry([], { - userId: 'user-1', - workspaceId: 'workspace-1', - }) - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - let resolveExecution!: (value: unknown) => void - let markExecutionStarted!: () => void - const executionStarted = new Promise((resolve) => { - markExecutionStarted = resolve - }) - executeWorkflowMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveExecution = resolve - markExecutionStarted() - }) - ) - - const execution = executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, - context - ) - await executionStarted - - expect(registry.isComplete()).toBe(false) - expect( - projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) - ).toMatchObject({ output: { value: 'secret-value' } }) - - resolveExecution({ - success: true, - output: { value: 'secret-value' }, - logs: [], - metadata: { executionId: 'new-execution-1' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted-secret' }], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }, - }, - }) - - await expect(execution).resolves.toMatchObject({ success: true }) - expect(registry.isComplete()).toBe(true) - expect( - projectToolResultForCopilot({ success: true, output: { value: 'secret-value' } }, registry) - ).toMatchObject({ output: { value: '{{API_KEY}}' } }) - }) - - it('marks provenance incomplete when child execution returns no trusted state', async () => { - const registry = new ResolvedSecretTraceRegistry() - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - - const result = await executeRunWorkflow( - { workflowId: 'workflow-1', useMockPayload: true }, - context - ) - - expect(result.success).toBe(true) - expect(registry.isComplete()).toBe(false) - }) - - it('filters and anonymizes cross-workspace child provenance to values that cross back', async () => { - const registry = new ResolvedSecretTraceRegistry([], { - userId: 'user-1', - workspaceId: 'workspace-1', - }) - const context: ExecutionContext = { - ...executionContext, - resolvedSecretTraceRegistry: registry, - } - ensureWorkflowAccessMock.mockResolvedValueOnce({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValueOnce(childBillingAttribution) - decryptSecretMock.mockImplementation(async (encryptedValue: string) => ({ - decrypted: encryptedValue === 'used-ciphertext' ? 'used-secret' : 'workspace-only-secret', - })) - executeWorkflowMock.mockResolvedValueOnce({ - success: true, - output: { value: 'used-secret' }, - logs: [], - metadata: { executionId: 'child-execution' }, - executionState: { - resolvedSecretTraceProvenance: { - version: 1, - complete: true, - entries: [ - { name: 'USED', encryptedValue: 'used-ciphertext' }, - { name: 'WORKSPACE_ONLY', encryptedValue: 'workspace-only-ciphertext' }, - ], - scope: { userId: 'user-1', workspaceId: 'workspace-2' }, - }, + output: { + executionId: 'execution-1', + output: { file: { name: 'report.pdf' } }, }, }) - - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context - ) - - expect(result).toMatchObject({ - success: true, - output: { output: { value: 'used-secret' } }, - }) - expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'used-secret', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, - ]) - expect(JSON.stringify(result)).not.toContain('workspace-only-ciphertext') - expect(JSON.stringify(result)).not.toContain('__resolvedSecretTraceProvenance') - }) - - it('passes immutable attribution when running until a block', async () => { - await expectBillingAttributionForwarded(() => - executeRunWorkflowUntilBlock( - { - workflowId: 'workflow-1', - stopAfterBlockId: 'agent-1', - useMockPayload: true, - }, - executionContext - ) - ) - }) - - it('passes immutable attribution when running from a block', async () => { - await expectBillingAttributionForwarded(() => - executeRunFromBlock( - { - workflowId: 'workflow-1', - startBlockId: 'agent-1', - executionId: 'source-execution-1', - }, - executionContext - ) - ) - }) - - it('passes immutable attribution when running one block', async () => { - await expectBillingAttributionForwarded(() => - executeRunBlock( - { - workflowId: 'workflow-1', - blockId: 'agent-1', - executionId: 'source-execution-1', - }, - executionContext - ) + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.copilot.run' }), + }), + expect.objectContaining({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + workflowInput: { query: 'hello' }, + hasWorkflowInput: true, + lifecycle: expect.objectContaining({ billingAttribution: context.billingAttribution }), + }) ) }) it.each([ { - mode: 'a workflow', - run: (context: ExecutionContext) => - executeRunWorkflow({ workflowId: 'workflow-2', useMockPayload: true }, context), - }, - { - mode: 'until a block', - run: (context: ExecutionContext) => + label: 'until', + operationId: 'workflows.copilot.run_until', + run: () => executeRunWorkflowUntilBlock( - { - workflowId: 'workflow-2', - stopAfterBlockId: 'agent-1', - useMockPayload: true, - }, + { workflowId: 'workflow-1', stopAfterBlockId: 'agent-1', useMockPayload: true }, context ), + input: expect.objectContaining({ stopAfterBlockId: 'agent-1' }), }, { - mode: 'from a block', - run: (context: ExecutionContext) => + label: 'from block', + operationId: 'workflows.copilot.run_from_block', + run: () => executeRunFromBlock( { - workflowId: 'workflow-2', + workflowId: 'workflow-1', startBlockId: 'agent-1', - executionId: 'source-execution-1', + executionId: 'source-1', }, context ), + input: expect.objectContaining({ blockId: 'agent-1', sourceExecutionId: 'source-1' }), }, { - mode: 'one block', - run: (context: ExecutionContext) => + label: 'one block', + operationId: 'workflows.copilot.run_block', + run: () => executeRunBlock( - { - workflowId: 'workflow-2', - blockId: 'agent-1', - executionId: 'source-execution-1', - }, + { workflowId: 'workflow-1', blockId: 'agent-1', executionId: 'source-1' }, context ), + input: expect.objectContaining({ blockId: 'agent-1', sourceExecutionId: 'source-1' }), }, - ])('resolves a child snapshot when running $mode cross-workspace', async ({ run }) => { - const context: ExecutionContext = { - ...executionContext, - workflowId: 'workflow-2', - workspaceId: 'workspace-2', - } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - - const result = await run(context) - - expect(result.success).toBe(true) - expect(resolveBillingAttributionMock).toHaveBeenCalledOnce() - expect(resolveBillingAttributionMock).toHaveBeenCalledWith({ - actorUserId: 'user-1', - workspaceId: 'workspace-2', - }) - expect(executeWorkflowMock.mock.calls[0]?.[4]).toEqual( - expect.objectContaining({ billingAttribution: childBillingAttribution }) - ) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledWith(childBillingAttribution) - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( - expect.objectContaining({ - executionId: executeWorkflowMock.mock.calls[0]?.[5], - }) - ) - expect(context.billingAttribution).toBe(billingAttribution) - }) - - it('blocks a cross-workspace run before execution when target usage is exhausted', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - checkAttributedUsageLimitsMock.mockResolvedValue({ - isExceeded: true, - scope: 'member', - message: 'Member limit reached', - payerUsage: { currentUsage: 1, limit: 10 }, - memberUsage: { currentUsage: 2, limit: 2 }, + ])('uses one fixed $label application command', async ({ operationId, run, input }) => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + success: true, + output: {}, + logs: [], + metadata: { executionId: 'execution-1' }, }) - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context - ) - - expect(result).toEqual({ success: false, error: 'Member limit reached' }) - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).not.toHaveBeenCalled() - expect(executeWorkflowMock).not.toHaveBeenCalled() - }) + await run() - it('blocks a cross-workspace run when its atomic target reservation is full', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - reserveExecutionSlotMock.mockResolvedValue({ - reserved: false, - reason: 'payer_concurrency', - }) - - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledOnce() + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ operation: expect.objectContaining({ id: operationId }) }), + input ) - - expect(result.success).toBe(false) - expect(result.error).toContain('concurrency') - expect(checkAttributedUsageLimitsMock).toHaveBeenCalledOnce() - expect(reserveExecutionSlotMock).toHaveBeenCalledOnce() - expect(executeWorkflowMock).not.toHaveBeenCalled() }) - it('releases the child reservation when direct target execution throws', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, + it('passes a bounded move batch to one bulk command', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + moved: [{ workflowId: 'workflow-1' }], + failed: [{ workflowId: 'workflow-2', error: 'Workflow is locked' }], + folderId: 'folder-1', }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - executeWorkflowMock.mockRejectedValue(new Error('direct execution failed')) - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, + const result = await executeMoveWorkflow( + { workflowIds: ['workflow-1', 'workflow-2'], folderId: 'folder-1' }, context ) - const childExecutionId = executeWorkflowMock.mock.calls[0]?.[5] - expect(result).toEqual({ success: false, error: 'direct execution failed' }) - expect(reserveExecutionSlotMock).toHaveBeenCalledWith( - expect.objectContaining({ executionId: childExecutionId }) + expect(result.success).toBe(true) + expect(mocks.executeWorkflowUseCase).toHaveBeenCalledWith( + context, + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.bulk.move' }), + }), + { + workspaceId: 'workspace-1', + workflowIds: ['workflow-1', 'workflow-2'], + folderId: 'folder-1', + } ) - expect(releaseExecutionSlotMock).toHaveBeenCalledOnce() - expect(releaseExecutionSlotMock).toHaveBeenCalledWith(childExecutionId) }) - it('leaves pause release to durable pause persistence', async () => { - const context: ExecutionContext = { ...executionContext, workspaceId: 'workspace-2' } - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { - id: 'workflow-2', - userId: 'owner-2', - workspaceId: 'workspace-2', - variables: {}, - }, - }) - resolveBillingAttributionMock.mockResolvedValue(childBillingAttribution) - executeWorkflowMock.mockResolvedValue({ - success: true, - status: 'paused', - output: {}, - logs: [], - metadata: { executionId: 'child-execution' }, + it('uses the fixed API-key application command', async () => { + mocks.apiKey.mockResolvedValue({ + key: { id: 'key-1', name: 'Copilot key', key: 'secret-key' }, }) - const result = await executeRunWorkflow( - { workflowId: 'workflow-2', useMockPayload: true }, - context - ) + const result = await executeGenerateApiKey({ name: ' Copilot key ' }, context) expect(result.success).toBe(true) - expect(releaseExecutionSlotMock).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' }, - }) + expect(mocks.apiKey).toHaveBeenCalledWith( + context, + expect.objectContaining({ + operation: expect.objectContaining({ id: 'api_keys.copilot.create' }), + }), + { workspaceId: 'workspace-1', name: 'Copilot key' } + ) }) - 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) + it('logs the full unknown run failure but returns a generic model-visible error', async () => { + mocks.executeWorkflowUseCase.mockRejectedValueOnce(new Error('postgres password=secret')) - const result = await executeRunFromBlock( - { - workflowId: 'workflow-1', - startBlockId: 'agent-1', - executionId: 'source-execution-1', - }, - { userId: 'user-1' } as any - ) + const result = await executeRunWorkflow({ workflowId: 'workflow-1' }, context) - 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', - }, - }), - expect.any(String) - ) + expect(result).toEqual({ success: false, error: 'Workflow execution failed' }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 260bf73fbd2..0769954cad9 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -1,49 +1,31 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' -import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' -import { eq } from 'drizzle-orm' -import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' -import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' -import { prepareWorkflowExecutionAdmission } from '@/lib/copilot/request/tools/workflow-context' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { - buildVfsFolderPathMap, - decodeVfsPathSegments, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' -import { env } from '@/lib/core/config/env' -import { generateRequestId } from '@/lib/core/utils/request' -import { getSocketServerUrl } from '@/lib/core/utils/urls' +import { createCopilotWorkspaceApiKey } from '@/lib/api-key/application/create-api-key' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotApiKeyUseCase } from '@/lib/copilot/application/execute-api-key-use-case' import { - type ExecuteWorkflowOptions, - executeWorkflow, - type WorkflowInfo, -} from '@/lib/workflows/executor/execute-workflow' + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { PlatformEvents } from '@/lib/core/telemetry' +import { createWorkflow } from '@/lib/workflows/application/create-workflow' +import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' import { - getExecutionInputForWorkflow, - getExecutionStateForWorkflow, - getLatestExecutionStateWithExecutionId, -} from '@/lib/workflows/executor/execution-state' -import { performCreateWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' + runBlockFromCopilot, + runFromBlockFromCopilot, + runWorkflowFromCopilot, + runWorkflowUntilBlockFromCopilot, +} from '@/lib/workflows/application/run-workflow-from-copilot' +import { updateWorkflow } from '@/lib/workflows/application/update-workflow' import { - loadDeployedWorkflowState, - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/persistence/utils' + applyWorkflowVariableOperations, + setWorkflowBlockEnabled, +} from '@/lib/workflows/application/update-workflow-content' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { - resolveTriggerRunOptions, - validateTriggerInput, -} from '@/lib/workflows/triggers/run-options' -import { listFolders, setWorkflowVariables, verifyFolderWorkspace } from '@/lib/workflows/utils' -import type { SerializableExecutionState } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { getDefaultWorkspaceId } from '../access' function stripBinaryFields(value: unknown): unknown { if (value === null || value === undefined) return value @@ -80,108 +62,18 @@ function buildExecutionOutput( } } -async function executeCopilotWorkflowTarget(params: { - workflow: WorkflowInfo - input: unknown - context: ExecutionContext - options: Omit -}) { - const childExecutionId = generateId() - if (!params.workflow.workspaceId) { - throw new Error(`Workflow ${params.workflow.id} has no workspaceId`) - } - const admission = await prepareWorkflowExecutionAdmission( - params.context, - params.workflow.workspaceId, - childExecutionId - ) - const trustedInitialResolvedSecretTraceProvenance = - params.context.resolvedSecretTraceRegistry?.exportProvenanceForValue(params.input) - const completePendingActivation = - params.context.resolvedSecretTraceRegistry?.beginPendingActivation() - - try { - const result = await executeWorkflow( - params.workflow, - generateRequestId(), - params.input, - params.context.userId, - { - ...params.options, - billingAttribution: admission.billingAttribution, - ...(trustedInitialResolvedSecretTraceProvenance - ? { trustedInitialResolvedSecretTraceProvenance } - : {}), - }, - childExecutionId - ) - if (params.context.resolvedSecretTraceRegistry) { - await params.context.resolvedSecretTraceRegistry.importCrossingProvenance( - result.executionState?.resolvedSecretTraceProvenance, - { output: result.output, logs: result.logs, error: result.error }, - { trusted: true } - ) - } - return result - } catch (error) { - if (params.context.resolvedSecretTraceRegistry) { - const executionResult = hasExecutionResult(error) ? error.executionResult : undefined - await params.context.resolvedSecretTraceRegistry.importCrossingProvenance( - executionResult?.executionState?.resolvedSecretTraceProvenance, - { - output: executionResult?.output, - logs: executionResult?.logs, - error: executionResult?.error, - thrownMessage: toError(error).message, - }, - { trusted: true } - ) - } - if (admission.targetReservation) { - await releaseExecutionSlot(childExecutionId) - } - throw error - } finally { - completePendingActivation?.() - } -} - function buildExecutionError(error: unknown): ToolCallResult { - const message = toError(error).message if (hasExecutionResult(error)) { return buildExecutionOutput({ ...error.executionResult, success: false, - error: error.executionResult.error || message, + error: error.executionResult.error || 'Workflow execution failed', }) } - return { success: false, error: message } -} - -async function resolveRunFromBlockSnapshot( - workflowId: string, - executionId?: string -): Promise< - | { - executionId: string - snapshot: SerializableExecutionState - } - | undefined -> { - const sourceExecution = executionId - ? { - executionId, - state: await getExecutionStateForWorkflow(executionId, workflowId), - } - : await getLatestExecutionStateWithExecutionId(workflowId) - - if (!sourceExecution?.state) { - return undefined - } - + logger.error('Copilot workflow execution command failed', { error }) return { - executionId: sourceExecution.executionId, - snapshot: sourceExecution.state, + success: false, + error: messageForCopilotWorkflowError(error, 'Workflow execution failed'), } } @@ -201,174 +93,16 @@ function resolveRunTriggerBlockId(params: { triggerBlockId?: unknown }): string : undefined } -interface PreparedTriggerRun { - triggerBlockId: string - input: unknown +function resolveInputFromExecutionId(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined } -/** - * Resolves which trigger a copilot run targets and validates the input against - * it. There are no fallbacks: an invalid trigger id, an ambiguous workflow, or - * input that doesn't match the trigger's schema returns an error string so the - * agent fixes it and retries. The resolved triggerBlockId is returned so the - * caller pins the executed entry to the validated one. - */ -async function resolveValidatedTriggerRun( - workflowId: string, - useDraftState: boolean, - params: { - triggerBlockId?: unknown - workflow_input?: unknown - input?: unknown - useMockPayload?: unknown - inputFromExecutionId?: unknown - } -): Promise { - const state = useDraftState - ? await loadWorkflowFromNormalizedTables(workflowId) - : await loadDeployedWorkflowState(workflowId) - - if (!state?.blocks) { - return { - error: `Workflow ${workflowId} has no ${useDraftState ? 'saved draft' : 'deployed'} state to run.`, - } - } - - const merged = mergeSubblockStateWithValues(state.blocks) - const options = resolveTriggerRunOptions(merged, state.edges) - - if (options.length === 0) { - return { - error: - 'No runnable trigger found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.', - } - } - - const listTriggers = () => - options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') - - const requestedId = resolveRunTriggerBlockId(params) - let option = options[0] - if (requestedId) { - const match = options.find((o) => o.triggerBlockId === requestedId) - if (!match) { - return { - error: `triggerBlockId "${requestedId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. Call get_workflow_run_options to inspect them.`, - } - } - option = match - } else if (options.length > 1) { - return { - error: `This workflow has multiple triggers — pass triggerBlockId to choose one: ${listTriggers()}. Call get_workflow_run_options for each trigger's input shape.`, - } - } - - const providedInput = resolveRunWorkflowInput(params) - const hasProvidedInput = providedInput !== undefined - const useMock = params.useMockPayload === true - const fromExecutionId = - typeof params.inputFromExecutionId === 'string' && params.inputFromExecutionId.trim().length > 0 - ? params.inputFromExecutionId.trim() - : undefined - - const sourceCount = (hasProvidedInput ? 1 : 0) + (useMock ? 1 : 0) + (fromExecutionId ? 1 : 0) - if (sourceCount > 1) { - return { - error: - 'Provide only one input source: workflow_input, useMockPayload: true, or inputFromExecutionId.', - } - } - - // Mock payload is generated to match the trigger, so it bypasses validation. - if (useMock) { - return { triggerBlockId: option.triggerBlockId, input: option.mockPayload } - } - - let inputToValidate = providedInput - if (fromExecutionId) { - const past = await getExecutionInputForWorkflow(fromExecutionId, workflowId) - if (!past.found) { - return { - error: `No execution "${fromExecutionId}" found for this workflow to reuse input from.`, - } - } - if (past.input === undefined) { - return { error: `Execution "${fromExecutionId}" has no recorded input to reuse.` } - } - inputToValidate = past.input - } - - const validation = validateTriggerInput(option, inputToValidate) - if (!validation.ok) { - return { error: validation.error || 'workflow_input is invalid for the target trigger.' } - } - - return { triggerBlockId: option.triggerBlockId, input: inputToValidate } -} - -function isBlockProtected(blockId: string, blocksById: Record): boolean { - const block = blocksById[blockId] - if (!block) return false - if (block.locked) return true - - const visited = new Set() - let parentId = block.data?.parentId - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocksById[parentId]?.locked) return true - parentId = blocksById[parentId]?.data?.parentId - } - - return false -} - -function hasDisabledAncestor(blockId: string, blocksById: Record): boolean { - const visited = new Set() - let parentId = blocksById[blockId]?.data?.parentId - - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - const parent = blocksById[parentId] - if (!parent) return false - if (parent.enabled === false) return true - parentId = parent.data?.parentId - } - - return false -} - -function findDescendants(containerId: string, blocksById: Record): string[] { - const descendants: string[] = [] - const stack = [containerId] - const visited = new Set() - - while (stack.length > 0) { - const current = stack.pop()! - if (visited.has(current)) continue - visited.add(current) - - for (const [blockId, block] of Object.entries(blocksById)) { - if (block.data?.parentId === current) { - descendants.push(blockId) - stack.push(blockId) - } - } +function copilotRunLifecycle(context: ExecutionContext) { + return { + billingAttribution: context.billingAttribution, + resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, + abortSignal: context.abortSignal, } - - return descendants -} - -function notifyWorkflowUpdated(workflowId: string): void { - fetch(`${getSocketServerUrl()}/api/workflow-updated`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId }), - }).catch((error) => { - logger.warn('Failed to notify socket server of workflow update', { workflowId, error }) - }) } import type { @@ -411,57 +145,30 @@ export async function executeCreateWorkflow( const workspaceId = params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - const folderPath = typeof params?.folderPath === 'string' ? params.folderPath.trim() : '' - let folderId = + const folderId = typeof params?.folderId === 'string' && params.folderId.trim() ? params.folderId.trim() : null + let canonicalFolderPath: string | undefined if (folderPath) { const relativePath = workflowFolderRelativePath(folderPath) - if (!relativePath) { - folderId = null - } else { - const target = resolveFolderIdByPath(folderPath, await loadFolderPathIndex(workspaceId)) - if ('error' in target) return { success: false, error: target.error } - folderId = target.folderId - } + canonicalFolderPath = relativePath + ? `/${encodeVfsPathSegments(decodeVfsPathSegments(relativePath))}` + : '/' } - await assertFolderMutable(folderId) assertWorkflowMutationNotAborted(context) - const result = await performCreateWorkflow({ - userId: context.userId, + const result = await executeCopilotWorkflowUseCase(context, createWorkflow, { workspaceId, name, - folderId, + ...(canonicalFolderPath !== undefined ? { folderPath: canonicalFolderPath } : { folderId }), }) - if (!result.success || !result.workflow) { - return { success: false, error: result.error || 'Failed to create workflow' } - } - - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.workflowCreated({ - workflowId: result.workflow.id, - name: result.workflow.name, - workspaceId, - folderId: folderId ?? undefined, - }) - } catch (_e) { - // Telemetry is best-effort - } - - const normalized = await loadWorkflowFromNormalizedTables(result.workflow.id) - let copilotSanitizedWorkflowState: unknown - if (normalized) { - copilotSanitizedWorkflowState = sanitizeForCopilot({ - blocks: normalized.blocks || {}, - edges: normalized.edges || [], - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - } as WorkflowState) - } + const copilotSanitizedWorkflowState = sanitizeForCopilot({ + blocks: result.normalizedState.blocks || {}, + edges: result.normalizedState.edges || [], + loops: result.normalizedState.loops || {}, + parallels: result.normalizedState.parallels || {}, + } as WorkflowState) return { success: true, @@ -474,7 +181,10 @@ export async function executeCreateWorkflow( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to create workflow'), + } } } @@ -488,33 +198,18 @@ export async function executeRunWorkflow( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - const useDraftState = !params.useDeployedState - - const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) - if ('error' in prepared) { - return { success: false, error: prepared.error } - } - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: prepared.input, - context, - options: { - enabled: true, - useDraftState, - workflowTriggerType: 'copilot', - triggerBlockId: prepared.triggerBlockId, - }, + const workflowInput = resolveRunWorkflowInput(params) + const result = await executeCopilotWorkflowUseCase(context, runWorkflowFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + triggerBlockId: resolveRunTriggerBlockId(params), + workflowInput, + hasWorkflowInput: workflowInput !== undefined, + useMockPayload: params.useMockPayload === true, + inputFromExecutionId: resolveInputFromExecutionId(params.inputFromExecutionId), + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result) @@ -535,113 +230,17 @@ export async function executeSetGlobalWorkflowVariables( const operations: VariableOperation[] = Array.isArray(params.operations) ? params.operations : [] - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - await assertWorkflowMutable(workflowId) - - interface WorkflowVariable { - id: string - workflowId?: string - name: string - type: string - value?: unknown - } - const currentVarsRecord = (workflowRecord.variables as Record) || {} - const byName: Record = {} - Object.values(currentVarsRecord).forEach((v) => { - if (v && typeof v === 'object' && 'id' in v && 'name' in v) { - const variable = v as WorkflowVariable - byName[String(variable.name)] = variable - } - }) - - for (const op of operations) { - const key = String(op?.name || '') - if (!key) continue - const nextType = op?.type || byName[key]?.type || 'plain' - const coerceValue = (value: unknown, type: string): unknown => { - if (value === undefined) return value - if (type === 'number') { - const n = Number(value) - return Number.isNaN(n) ? value : n - } - if (type === 'boolean') { - const v = String(value).trim().toLowerCase() - if (v === 'true') return true - if (v === 'false') return false - return value - } - if (type === 'array' || type === 'object') { - try { - const parsed = JSON.parse(String(value)) - if (type === 'array' && Array.isArray(parsed)) return parsed - if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) - return parsed - } catch (error) { - logger.warn('Failed to parse JSON value for variable coercion', { - error: toError(error).message, - }) - } - return value - } - return value - } - - if (op.operation === 'delete') { - delete byName[key] - continue - } - const typedValue = coerceValue(op.value, nextType) - if (op.operation === 'add') { - byName[key] = { - id: generateId(), - workflowId, - name: key, - type: nextType, - value: typedValue, - } - continue - } - if (op.operation === 'edit') { - if (!byName[key]) { - byName[key] = { - id: generateId(), - workflowId, - name: key, - type: nextType, - value: typedValue, - } - } else { - byName[key] = { - ...byName[key], - type: nextType, - value: typedValue, - } - } - } - } - - const nextVarsRecord = Object.fromEntries(Object.values(byName).map((v) => [String(v.id), v])) assertWorkflowMutationNotAborted(context) - await setWorkflowVariables(workflowId, nextVarsRecord) - notifyWorkflowUpdated(workflowId) - - recordAudit({ - actorId: context.userId, - action: AuditAction.WORKFLOW_VARIABLES_UPDATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - description: `Updated workflow variables`, - metadata: { operationCount: operations.length, source: 'copilot' }, + const result = await executeCopilotWorkflowUseCase(context, applyWorkflowVariableOperations, { + workflowId, + assertedWorkspaceId: context.workspaceId, + operations, }) - return { success: true, output: { updated: Object.values(byName).length } } + return { success: true, output: { updated: result.updated } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -662,27 +261,19 @@ export async function executeRenameWorkflow( return { success: false, error: 'Workflow name must be 200 characters or less' } } - const current = await ensureWorkflowAccess(workflowId, context.userId, 'write') - await assertWorkflowMutable(workflowId) assertWorkflowMutationNotAborted(context) - if (!current.workspaceId) { - return { success: false, error: 'Workflow workspace is required' } - } - const result = await performUpdateWorkflow({ + await executeCopilotWorkflowUseCase(context, updateWorkflow, { workflowId, - userId: context.userId, - workspaceId: current.workspaceId, - currentName: current.workflow.name, - currentFolderId: current.workflow.folderId, + assertedWorkspaceId: context.workspaceId, name, }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename workflow' } - } return { success: true, output: { workflowId, name } } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to rename workflow'), + } } } @@ -695,53 +286,23 @@ export async function executeMoveWorkflow( if (!workflowIds || workflowIds.length === 0) { return { success: false, error: 'workflowIds is required' } } + if (!context.workspaceId) { + return { success: false, error: 'Workspace context is required' } + } - const folderId = params.folderId || null - const moved: string[] = [] - const failed: string[] = [] - - await assertFolderMutable(folderId) + assertWorkflowMutationNotAborted(context) + const result = await executeCopilotWorkflowUseCase(context, moveWorkflowsBulk, { + workspaceId: context.workspaceId, + workflowIds, + folderId: params.folderId || null, + }) - for (const workflowId of workflowIds) { - try { - const { workspaceId, workflow } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - if (!workspaceId) { - failed.push(workflowId) - continue - } - if (folderId) { - if (!workspaceId || !(await verifyFolderWorkspace(folderId, workspaceId))) { - failed.push(workflowId) - continue - } - } - await assertWorkflowMutable(workflowId) - assertWorkflowMutationNotAborted(context) - const result = await performUpdateWorkflow({ - workflowId, - userId: context.userId, - workspaceId, - currentName: workflow.name, - currentFolderId: workflow.folderId, - folderId, - }) - if (!result.success) { - failed.push(workflowId) - continue - } - moved.push(workflowId) - } catch { - failed.push(workflowId) - } + return { + success: result.moved.length > 0, + output: { moved: result.moved, failed: result.failed, folderId: result.folderId }, } - - return { success: moved.length > 0, output: { moved, failed, folderId } } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -758,34 +319,19 @@ export async function executeRunWorkflowUntilBlock( return { success: false, error: 'stopAfterBlockId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - const useDraftState = !params.useDeployedState - - const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) - if ('error' in prepared) { - return { success: false, error: prepared.error } - } - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: prepared.input, - context, - options: { - enabled: true, - useDraftState, - stopAfterBlockId: params.stopAfterBlockId, - workflowTriggerType: 'copilot', - triggerBlockId: prepared.triggerBlockId, - }, + const workflowInput = resolveRunWorkflowInput(params) + const result = await executeCopilotWorkflowUseCase(context, runWorkflowUntilBlockFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + triggerBlockId: resolveRunTriggerBlockId(params), + workflowInput, + hasWorkflowInput: workflowInput !== undefined, + useMockPayload: params.useMockPayload === true, + inputFromExecutionId: resolveInputFromExecutionId(params.inputFromExecutionId), + stopAfterBlockId: params.stopAfterBlockId, + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result, { stoppedAfterBlockId: params.stopAfterBlockId }) @@ -809,17 +355,16 @@ export async function executeGenerateApiKey( const workspaceId = params.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'admin') assertWorkflowMutationNotAborted(context) - const result = await performCreateWorkspaceApiKey({ + const result = await executeCopilotApiKeyUseCase(context, createCopilotWorkspaceApiKey, { workspaceId, - userId: context.userId, name, - source: 'copilot', }) - if (!result.success || !result.key) { - return { success: false, error: result.error || 'Failed to generate API key' } + try { + PlatformEvents.apiKeyGenerated({ userId: context.userId, keyName: result.key.name }) + } catch (error) { + logger.warn('Failed to capture Copilot API key analytics', { error }) } return { @@ -833,7 +378,11 @@ export async function executeGenerateApiKey( }, } } catch (error) { - return { success: false, error: toError(error).message } + logger.error('Copilot API key creation failed', { error }) + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to create API key'), + } } } @@ -850,43 +399,15 @@ export async function executeRunFromBlock( return { success: false, error: 'startBlockId is required' } } - const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId) - - if (!sourceSnapshot) { - return { - success: false, - error: params.executionId - ? `No execution state found for execution ${params.executionId}. Run the full workflow first.` - : `No execution state found for workflow ${workflowId}. Run the full workflow first to create a snapshot.`, - } - } - - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) const useDraftState = !params.useDeployedState - - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: resolveRunWorkflowInput(params), - context, - options: { - enabled: true, - useDraftState, - workflowTriggerType: 'copilot', - runFromBlock: { - startBlockId: params.startBlockId, - sourceSnapshot: sourceSnapshot.snapshot, - sourceExecutionId: sourceSnapshot.executionId, - }, - }, + const result = await executeCopilotWorkflowUseCase(context, runFromBlockFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + blockId: params.startBlockId, + workflowInput: resolveRunWorkflowInput(params), + sourceExecutionId: params.executionId, + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result, { startBlockId: params.startBlockId }) @@ -911,119 +432,33 @@ export async function executeSetBlockEnabled( return { success: false, error: 'enabled must be a boolean' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) - await assertWorkflowMutable(workflowId) - assertWorkflowMutationNotAborted(context) - - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - return { success: false, error: `Workflow ${workflowId} has no normalized state` } - } - - const currentState: WorkflowState = { - blocks: normalized.blocks as Record, - edges: normalized.edges || [], - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - lastSaved: Date.now(), - } - - const currentBlocks = currentState.blocks - const targetBlock = currentBlocks[params.blockId] - if (!targetBlock) { - return { - success: false, - error: `Block ${params.blockId} not found in workflow ${workflowId}`, - } - } - if (isBlockProtected(params.blockId, currentBlocks)) { - return { - success: false, - error: `Block ${params.blockId} is locked or inside a locked container and cannot be updated`, - } - } - if (targetBlock.enabled === params.enabled) { - return { - success: true, - output: { - workflowId, - workflowName: workflowRecord.name, - blockId: params.blockId, - enabled: params.enabled, - affectedBlockIds: [params.blockId], - workflowState: currentState, - copilotSanitizedWorkflowState: sanitizeForCopilot(currentState), - message: `Block ${params.blockId} is already ${params.enabled ? 'enabled' : 'disabled'}`, - }, - } - } - if (params.enabled && hasDisabledAncestor(params.blockId, currentBlocks)) { - return { - success: false, - error: `Cannot enable block ${params.blockId} while one of its parent containers is disabled. Enable the parent first.`, - } - } - - const affectedBlockIds = new Set([params.blockId]) - if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { - for (const descendantId of findDescendants(params.blockId, currentBlocks)) { - if (!isBlockProtected(descendantId, currentBlocks)) { - affectedBlockIds.add(descendantId) - } - } - } - - const nextBlocks: Record = { ...currentBlocks } - for (const blockId of affectedBlockIds) { - nextBlocks[blockId] = { - ...nextBlocks[blockId], - enabled: params.enabled, - } - } - - const nextState: WorkflowState = { - ...currentState, - blocks: nextBlocks, - lastSaved: Date.now(), - } - assertWorkflowMutationNotAborted(context) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, nextState) - if (!saveResult.success) { - return { - success: false, - error: saveResult.error || `Failed to persist enabled state for block ${params.blockId}`, - } - } - - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowTable.id, workflowId)) - - notifyWorkflowUpdated(workflowId) + const result = await executeCopilotWorkflowUseCase(context, setWorkflowBlockEnabled, { + workflowId, + assertedWorkspaceId: context.workspaceId, + blockId: params.blockId, + enabled: params.enabled, + }) return { success: true, output: { workflowId, - workflowName: workflowRecord.name, + workflowName: result.workflowName, blockId: params.blockId, enabled: params.enabled, - affectedBlockIds: Array.from(affectedBlockIds), - workflowState: nextState, - copilotSanitizedWorkflowState: sanitizeForCopilot(nextState), + affectedBlockIds: result.affectedBlockIds, + workflowState: result.state, + copilotSanitizedWorkflowState: sanitizeForCopilot(result.state), + ...(!result.changed + ? { + message: `Block ${params.blockId} is already ${params.enabled ? 'enabled' : 'disabled'}`, + } + : {}), }, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -1038,47 +473,6 @@ function workflowFolderRelativePath(rawPath: string): string { return trimmed.startsWith('workflows/') ? trimmed.slice('workflows/'.length) : trimmed } -type FolderPathIndex = Map - -/** - * Load an index from each canonical encoded VFS path to its folder id. A null - * value records that multiple folder ids collapse to the same canonical path, - * so callers can reject the ambiguous path instead of silently choosing one. - */ -async function loadFolderPathIndex(workspaceId: string): Promise { - const byPath: FolderPathIndex = new Map() - for (const [folderId, encodedPath] of buildVfsFolderPathMap( - await listFolders(workspaceId) - ).entries()) { - if (!byPath.has(encodedPath)) { - byPath.set(encodedPath, folderId) - } else if (byPath.get(encodedPath) !== folderId) { - byPath.set(encodedPath, null) - } - } - return byPath -} - -function resolveFolderIdByPath( - rawPath: string, - byPath: FolderPathIndex, - label = 'Folder' -): { folderId: string } | { error: string } { - const relative = workflowFolderRelativePath(rawPath) - if (!relative) return { error: `${label} not found at ${rawPath}` } - - const canonicalPath = encodeVfsPathSegments(decodeVfsPathSegments(relative)) - if (!byPath.has(canonicalPath)) return { error: `${label} not found at ${rawPath}` } - - const folderId = byPath.get(canonicalPath) - if (!folderId) { - return { - error: `${label} path is ambiguous after canonicalization: ${rawPath}. Rename one of the conflicting folders and retry.`, - } - } - return { folderId } -} - export async function executeRunBlock( params: RunBlockParams, context: ExecutionContext @@ -1092,44 +486,15 @@ export async function executeRunBlock( return { success: false, error: 'blockId is required' } } - const sourceSnapshot = await resolveRunFromBlockSnapshot(workflowId, params.executionId) - - if (!sourceSnapshot) { - return { - success: false, - error: params.executionId - ? `No execution state found for execution ${params.executionId}. Run the full workflow first.` - : `No execution state found for workflow ${workflowId}. Run the full workflow first to create a snapshot.`, - } - } - - const { workflow: workflowRecord } = await ensureWorkflowAccess( - workflowId, - context.userId, - 'write' - ) const useDraftState = !params.useDeployedState - - const result = await executeCopilotWorkflowTarget({ - workflow: { - id: workflowRecord.id, - userId: workflowRecord.userId, - workspaceId: workflowRecord.workspaceId, - variables: workflowRecord.variables || {}, - }, - input: resolveRunWorkflowInput(params), - context, - options: { - enabled: true, - useDraftState, - workflowTriggerType: 'copilot', - runFromBlock: { - startBlockId: params.blockId, - sourceSnapshot: sourceSnapshot.snapshot, - sourceExecutionId: sourceSnapshot.executionId, - }, - stopAfterBlockId: params.blockId, - }, + const result = await executeCopilotWorkflowUseCase(context, runBlockFromCopilot, { + workflowId, + assertedWorkspaceId: context.workspaceId, + useDraftState, + blockId: params.blockId, + workflowInput: resolveRunWorkflowInput(params), + sourceExecutionId: params.executionId, + lifecycle: copilotRunLifecycle(context), }) return buildExecutionOutput(result, { blockId: params.blockId }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts index 86fe5c9e63a..f8f33c8289e 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts @@ -1,91 +1,26 @@ -import { - workflowsPersistenceUtilsMock, - workflowsPersistenceUtilsMockFns, - workflowsUtilsMock, - workflowsUtilsMockFns, -} from '@sim/testing' +import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionContext } from '@/lib/copilot/request/types' -const { - ensureWorkflowAccessMock, - getEffectiveBlockOutputPathsMock, - hasTriggerCapabilityMock, - getBlockMock, -} = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - getEffectiveBlockOutputPathsMock: vi.fn(), - hasTriggerCapabilityMock: vi.fn(), - getBlockMock: vi.fn(), +const { executeWorkflowUseCaseMock } = vi.hoisted(() => ({ + executeWorkflowUseCaseMock: 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('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: executeWorkflowUseCaseMock, + messageForCopilotWorkflowError: (error: unknown) => + getErrorMessage(error, 'Workflow operation failed'), })) -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({ + const applicationResult = { blocks: [ { blockId: 'agent-1', @@ -109,6 +44,29 @@ describe('executeGetBlockOutputs', () => { }, ], variables: [], - }) + } + executeWorkflowUseCaseMock.mockResolvedValue(applicationResult) + + const result = await executeGetBlockOutputs({ blockIds: ['agent-1', 'loop-1'] }, { + workflowId: 'wf-1', + workspaceId: 'ws-1', + userId: 'user-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } as ExecutionContext) + + expect(result.success).toBe(true) + expect(result.output).toEqual(applicationResult) + expect(executeWorkflowUseCaseMock).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'wf-1', workspaceId: 'ws-1' }), + expect.objectContaining({ + operation: expect.objectContaining({ id: 'workflows.copilot.block_outputs.read' }), + }), + { + workflowId: 'wf-1', + assertedWorkspaceId: 'ws-1', + blockIds: ['agent-1', 'loop-1'], + } + ) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index fb250666e14..8861dbc98d6 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,27 +1,25 @@ -import { toError } from '@sim/utils/errors' -import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { createLogger } from '@sim/logger' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' +import { + executeCopilotWorkflowUseCase, + messageForCopilotWorkflowError, +} from '@/lib/copilot/application/execute-workflow-use-case' 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 { 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 { listAvailableCustomToolsUseCase } from '@/lib/custom-tools/application/use-cases' +import { discoverMcpToolsUseCase } from '@/lib/mcp/application/use-cases' import { - loadDeployedWorkflowState, - loadWorkflowFromNormalizedTables, - NoActiveDeploymentError, -} 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' + readCopilotWorkflowBlockOutputs, + readCopilotWorkflowRunOptions, + readCopilotWorkflowUpstreamReferences, +} from '@/lib/workflows/application/read-workflow-copilot-metadata' +import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' 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, @@ -30,6 +28,8 @@ import type { GetWorkflowRunOptionsParams, } from '../param-types' +const logger = createLogger('WorkflowQueries') + export async function executeListUserWorkspaces( context: ExecutionContext ): Promise { @@ -38,7 +38,11 @@ export async function executeListUserWorkspaces( return { success: true, output: { workspaces } } } catch (error) { - return { success: false, error: toError(error).message } + logger.error('Failed to list user workspaces for Copilot', { error }) + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to list workspaces'), + } } } @@ -52,15 +56,11 @@ export async function executeGetWorkflowRunOptions( 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) + const { options } = await executeCopilotWorkflowUseCase( + context, + readCopilotWorkflowRunOptions, + { workflowId, assertedWorkspaceId: context.workspaceId } + ) if (options.length === 0) { return { @@ -89,12 +89,11 @@ export async function executeGetWorkflowRunOptions( } 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 } + option.inputKind === 'none' + ? { triggerBlockId: option.triggerBlockId } + : { triggerBlockId: option.triggerBlockId, workflow_input: option.mockPayload } + return { ...option, guidance: guidanceFor(option.inputKind), callExample } }) const defaultOption = options.find((option) => option.isDefault) @@ -113,7 +112,7 @@ export async function executeGetWorkflowRunOptions( }, } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -131,12 +130,12 @@ export async function executeGetWorkflowData( return { success: false, error: 'data_type is required' } } - const { workflow: workflowRecord, workspaceId } = await ensureWorkflowAccess( - workflowId, - context.userId - ) - if (dataType === 'global_variables') { + const { workflow: workflowRecord } = await executeCopilotWorkflowUseCase( + context, + readWorkflowDefinition, + { workflowId, assertedWorkspaceId: context.workspaceId, state: 'draft' } + ) const variablesRecord = (workflowRecord.variables as Record) || {} const variables = Object.values(variablesRecord).map((v) => { const variable = v as Record | null @@ -149,14 +148,18 @@ export async function executeGetWorkflowData( return { success: true, output: { variables } } } + const workspaceId = context.workspaceId if (dataType === 'custom_tools') { if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const toolsRows = await listCustomTools({ - userId: context.userId, - workspaceId, - }) + const { tools: toolsRows } = await executeCopilotCustomToolUseCase( + context, + listAvailableCustomToolsUseCase, + { + workspaceId, + } + ) const customToolsData = toolsRows.map((tool) => { const schema = tool.schema as Record | null @@ -177,7 +180,10 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const tools = await mcpService.discoverTools(context.userId, workspaceId, false) + const { tools } = await executeCopilotMcpServerUseCase(context, discoverMcpToolsUseCase, { + workspaceId, + refresh: false, + }) const mcpTools = tools.map((tool) => ({ name: String(tool.name || ''), serverId: String(tool.serverId || ''), @@ -210,7 +216,7 @@ export async function executeGetWorkflowData( return { success: false, error: `Unknown data_type: ${dataType}` } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -223,79 +229,14 @@ export async function executeGetBlockOutputs( 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 } + const payload = await executeCopilotWorkflowUseCase(context, readCopilotWorkflowBlockOutputs, { + workflowId, + assertedWorkspaceId: context.workspaceId, + blockIds: params.blockIds, + }) return { success: true, output: payload } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } @@ -311,192 +252,17 @@ export async function executeGetBlockUpstreamReferences( 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() - const containingParallelIds = new Set() - - 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(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 } + const payload = await executeCopilotWorkflowUseCase( + context, + readCopilotWorkflowUpstreamReferences, + { workflowId, assertedWorkspaceId: context.workspaceId, blockIds: params.blockIds } + ) return { success: true, output: payload } } catch (error) { - return { success: false, error: toError(error).message } + return { success: false, error: messageForCopilotWorkflowError(error) } } } -async function getWorkflowVariablesForTool( - workflowId: string -): Promise> { - const workflowRecord = await getWorkflowById(workflowId) - - const variablesRecord = (workflowRecord?.variables as Record) || {} - return Object.values(variablesRecord) - .filter((v): v is Record => { - if (!v || typeof v !== 'object') return false - const variable = v as Record - 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, - parallels: Record -): 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 @@ -507,10 +273,12 @@ export async function executeGetDeployedWorkflowState( return { success: false, error: 'workflowId is required' } } - const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId) - - try { - const deployedState = await loadDeployedWorkflowState(workflowId) + const { workflow: workflowRecord, state: deployedState } = await executeCopilotWorkflowUseCase( + context, + readWorkflowDefinition, + { workflowId, assertedWorkspaceId: context.workspaceId, state: 'deployed' } + ) + if (deployedState) { const formatted = formatNormalizedWorkflowForCopilot({ blocks: deployedState.blocks, edges: deployedState.edges, @@ -524,25 +292,22 @@ export async function executeGetDeployedWorkflowState( workflowId, workflowName: workflowRecord.name || '', isDeployed: true, - deploymentVersionId: deployedState.deploymentVersionId, + deploymentVersionId: + 'deploymentVersionId' in deployedState ? deployedState.deploymentVersionId : undefined, deployedState: formatted, }, } - } catch (error) { - if (!(error instanceof NoActiveDeploymentError)) { - return { success: false, error: toError(error).message } - } - return { - success: true, - output: { - workflowId, - workflowName: workflowRecord.name || '', - isDeployed: false, - message: 'Workflow has not been deployed yet.', - }, - } + } + 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 } + return { success: false, error: messageForCopilotWorkflowError(error) } } } diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/copilot/vfs/path-utils.ts index daeb5c0a631..490c0bf0bfb 100644 --- a/apps/sim/lib/copilot/vfs/path-utils.ts +++ b/apps/sim/lib/copilot/vfs/path-utils.ts @@ -1,37 +1,18 @@ -const CONTROL_CHARS = /[\x00-\x1f\x7f]/g -const WHITESPACE = /\s+/g - -export class VfsPathError extends Error { - constructor(message: string) { - super(message) - this.name = 'VfsPathError' - } -} - -function normalizeDisplaySegment(segment: string): string { - return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') -} +import { + canonicalizeVfsPath as canonicalizeNeutralVfsPath, + decodeVfsPathSegments as decodeNeutralVfsPathSegments, + decodeVfsSegment as decodeNeutralVfsSegment, + decodeVfsSegmentSafe as decodeNeutralVfsSegmentSafe, + encodeVfsPathSegments as encodeNeutralVfsPathSegments, + encodeVfsSegment as encodeNeutralVfsSegment, +} from '@/lib/vfs/path' export function encodeVfsSegment(segment: string): string { - const normalized = normalizeDisplaySegment(segment) - if (!normalized || normalized === '.' || normalized === '..') { - throw new VfsPathError('VFS path segment cannot be empty or a dot segment') - } - return encodeURIComponent(normalized) + return encodeNeutralVfsSegment(segment) } export function decodeVfsSegment(segment: string): string { - try { - const decoded = decodeURIComponent(segment) - const normalized = normalizeDisplaySegment(decoded) - if (!normalized || normalized === '.' || normalized === '..') { - throw new VfsPathError('VFS path segment cannot be empty or a dot segment') - } - return normalized - } catch (error) { - if (error instanceof VfsPathError) throw error - throw new VfsPathError(`Invalid encoded VFS path segment: ${segment}`) - } + return decodeNeutralVfsSegment(segment) } /** @@ -39,25 +20,19 @@ export function decodeVfsSegment(segment: string): string { * it is not valid encoding (e.g. a literal "%" that was never encoded). */ export function decodeVfsSegmentSafe(segment: string): string { - try { - return decodeVfsSegment(segment) - } catch { - return segment - } + return decodeNeutralVfsSegmentSafe(segment) } export function encodeVfsPathSegments(segments: string[]): string { - return segments.map(encodeVfsSegment).join('/') + return encodeNeutralVfsPathSegments(segments) } export function decodeVfsPathSegments(path: string): string[] { - const trimmed = path.trim().replace(/^\/+|\/+$/g, '') - if (!trimmed) return [] - return trimmed.split('/').map(decodeVfsSegment) + return decodeNeutralVfsPathSegments(path) } export function canonicalizeVfsPath(path: string): string { - return encodeVfsPathSegments(decodeVfsPathSegments(path)) + return canonicalizeNeutralVfsPath(path) } export function canonicalWorkspaceFilePath(parts: { diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 60f9f033c6e..95784c2e315 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -126,6 +126,7 @@ import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/worksp import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' @@ -139,7 +140,6 @@ import { getWorkspaceWithOwner, hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { BlockConfig, BlockIcon } from '@/blocks/types' diff --git a/apps/sim/lib/knowledge/application/knowledge-vfs.ts b/apps/sim/lib/knowledge/application/knowledge-vfs.ts new file mode 100644 index 00000000000..da538dae88e --- /dev/null +++ b/apps/sim/lib/knowledge/application/knowledge-vfs.ts @@ -0,0 +1,105 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + type KnowledgeWorkspaceContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + deleteKnowledgeBase, + getWorkspaceKnowledgeBases, + updateKnowledgeBase, +} from '@/lib/knowledge/service' +import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' + +interface KnowledgeVfsReferenceInput { + workspaceId: string + sourceName: string +} + +export interface RenameKnowledgeBaseByVfsPathInput extends KnowledgeVfsReferenceInput { + newName: string +} + +export type DeleteKnowledgeBaseByVfsPathInput = KnowledgeVfsReferenceInput + +async function resolveKnowledgeBaseByVfsName( + context: KnowledgeWorkspaceContext, + sourceName: string +): Promise { + const rows = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { + search: sourceName, + }) + const matches = rows.filter((row) => row.name === sourceName) + if (matches.length > 1) { + throw new OrchestrationError( + 'conflict', + `Knowledge base path is ambiguous: knowledgebases/${sourceName}` + ) + } + const knowledgeBase = matches[0] + if (!knowledgeBase) { + throw new OrchestrationError( + 'not_found', + `Knowledge base not found at knowledgebases/${sourceName}` + ) + } + return knowledgeBase +} + +export const renameKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.renameByVfsPath, + resolveContext: ({ input }: { input: RenameKnowledgeBaseByVfsPathInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ input, context }) { + const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const updated = await updateKnowledgeBase( + knowledgeBase.id, + { name: input.newName }, + generateRequestId(), + { assertedWorkspaceId: context.workspaceId } + ) + return { + id: updated.id, + name: updated.name, + previousName: knowledgeBase.name, + workspaceId: context.workspaceId, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.id, + resourceName: result.name, + description: `Renamed knowledge base to "${result.name}"`, + metadata: { source: 'copilot_vfs', previousName: result.previousName, updatedFields: ['name'] }, + }), +}) + +export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.deleteByVfsPath, + resolveContext: ({ input }: { input: DeleteKnowledgeBaseByVfsPathInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ input, context }) { + const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + await deleteKnowledgeBase(knowledgeBase.id, generateRequestId(), { + assertedWorkspaceId: context.workspaceId, + }) + return { + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: context.workspaceId, + deleted: true as const, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.id, + resourceName: result.name, + description: `Deleted knowledge base "${result.name}"`, + metadata: { source: 'copilot_vfs', knowledgeBaseName: result.name }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 4bacaa7a01b..c0181d2f8e9 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -4,6 +4,10 @@ const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const COPILOT_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const const ALL_PRINCIPAL_WITH_EXECUTOR_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -67,6 +71,18 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + renameByVfsPath: defineWorkspaceOperation({ + id: 'knowledge.vfs.rename', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), + deleteByVfsPath: defineWorkspaceOperation({ + id: 'knowledge.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), search: defineWorkspaceOperation({ id: 'knowledge.search', minimumRole: 'read', diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index e615dcfe77a..4e89347a41a 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -12,6 +12,54 @@ export const mcpServerOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + discoverTools: defineWorkspaceOperation({ + id: 'mcp_servers.tools.discover', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), + listWorkflowDeployments: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + createWorkflowDeploymentServer: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.create_server', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + updateWorkflowDeploymentServer: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.update_server', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + deleteWorkflowDeploymentServer: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.delete_server', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + deployWorkflowTool: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.deploy_tool', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + undeployWorkflowTool: defineWorkspaceOperation({ + id: 'mcp_servers.workflow_deployments.undeploy_tool', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), read: defineWorkspaceOperation({ id: 'mcp_servers.read', minimumRole: 'read', diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index 9137d97cbb0..b656ea30cf5 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' import { getPostgresErrorCode } from '@sim/utils/errors' import type { ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' @@ -20,6 +20,7 @@ import { type McpServerRow, type McpServerSortBy, } from '@/lib/mcp/queries' +import { mcpService } from '@/lib/mcp/service' import type { McpAuthType } from '@/lib/mcp/types' import { generateMcpServerId } from '@/lib/mcp/utils' import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' @@ -94,6 +95,26 @@ export const listMcpServersUseCase = defineAuthorizedWorkspaceUseCase({ }, }) +export interface DiscoverMcpToolsInput { + workspaceId: string + refresh?: boolean +} + +export const discoverMcpToolsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.discoverTools, + resolveContext: ({ input }: { input: DiscoverMcpToolsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const tools = await mcpService.discoverTools( + requirePrincipalSubjectUserId(principal), + context.workspaceId, + input.refresh ?? false + ) + return { tools } + }, +}) + export interface GetMcpServerInput { workspaceId: string serverId: string diff --git a/apps/sim/lib/mcp/application/workflow-deployments.test.ts b/apps/sim/lib/mcp/application/workflow-deployments.test.ts new file mode 100644 index 00000000000..4957911976c --- /dev/null +++ b/apps/sim/lib/mcp/application/workflow-deployments.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + audit: vi.fn(), + loadWorkspace: vi.fn(), + permission: vi.fn(), + publish: vi.fn(), + updateServer: vi.fn(), + }, +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + }, + AuditResourceType: { MCP_SERVER: 'mcp_server' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/mcp/orchestration', () => ({ + performCreateWorkflowMcpServer: vi.fn(), + performCreateWorkflowMcpTool: vi.fn(), + performDeleteWorkflowMcpServer: vi.fn(), + performDeleteWorkflowMcpTool: vi.fn(), + performUpdateWorkflowMcpServer: mocks.updateServer, + performUpdateWorkflowMcpTool: vi.fn(), +})) + +vi.mock('@/lib/mcp/pubsub', () => ({ + mcpPubSub: { publishWorkflowToolsChanged: mocks.publish }, +})) + +vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ + getDeployedWorkflowInputFormat: vi.fn(), +})) + +vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ + applyDescriptionOverrides: vi.fn(), + generateToolInputSchema: vi.fn(), + sanitizeToolName: vi.fn((name: string) => name), +})) + +import { updateWorkflowMcpDeploymentServer } from '@/lib/mcp/application/workflow-deployments' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:mcp-servers', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +const server = { + id: 'server-1', + workspaceId: 'workspace-1', + name: 'Production MCP', + description: null, + isPublic: false, + deletedAt: null, +} + +describe('workflow MCP deployment application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockImplementation(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })) + mocks.permission.mockResolvedValue('write') + mocks.updateServer.mockResolvedValue({ + success: true, + server: { ...server, name: 'Renamed MCP' }, + updatedFields: ['name'], + }) + }) + + it('derives workspace authorization canonically from the server id', async () => { + queueTableRows(schemaMock.workflowMcpServer, [{ ...server, workspaceId: 'workspace-2' }]) + + await expect( + updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).toHaveBeenCalledWith('workspace-2') + expect(mocks.updateServer).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rechecks the delegated subject permission before mutation', async () => { + queueTableRows(schemaMock.workflowMcpServer, [server]) + mocks.permission.mockResolvedValueOnce(null) + + await expect( + updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.updateServer).not.toHaveBeenCalled() + }) + + it('owns mutation attribution and semantic audit', async () => { + queueTableRows(schemaMock.workflowMcpServer, [server]) + + const result = await updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + + expect(result.server.name).toBe('Renamed MCP') + expect(mocks.updateServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverId: server.id, + workspaceId: server.workspaceId, + userId: principal.subjectUserId, + projectLegacyAudit: false, + publishEffects: false, + }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'mcp_server.updated', + resourceId: server.id, + metadata: expect.objectContaining({ + operation: 'mcp_servers.workflow_deployments.update_server', + }), + }) + ) + }) + + it('fails fast with a generic application error for an internal lower-layer result', async () => { + queueTableRows(schemaMock.workflowMcpServer, [server]) + mocks.updateServer.mockResolvedValueOnce({ + success: false, + error: 'postgres password=secret', + errorCode: 'internal', + }) + + await expect( + updateWorkflowMcpDeploymentServer.execute({ + principal, + input: { serverId: server.id, name: 'Renamed MCP' }, + }) + ).rejects.toThrow('Failed to update workflow MCP server') + + expect(mocks.audit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mcp/application/workflow-deployments.ts b/apps/sim/lib/mcp/application/workflow-deployments.ts new file mode 100644 index 00000000000..b0543432e17 --- /dev/null +++ b/apps/sim/lib/mcp/application/workflow-deployments.ts @@ -0,0 +1,431 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' +import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { + performCreateWorkflowMcpServer, + performCreateWorkflowMcpTool, + performDeleteWorkflowMcpServer, + performDeleteWorkflowMcpTool, + performUpdateWorkflowMcpServer, + performUpdateWorkflowMcpTool, +} from '@/lib/mcp/orchestration' +import { mcpPubSub } from '@/lib/mcp/pubsub' +import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync' +import { + applyDescriptionOverrides, + generateToolInputSchema, + sanitizeToolName, +} from '@/lib/mcp/workflow-tool-schema' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const MAX_LISTED_WORKFLOW_MCP_SERVERS = 100 +const MAX_LISTED_WORKFLOW_MCP_TOOLS = 2000 +const MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES = 100 +const authorizationOptions = { delegation: mcpServerDelegationPolicy } + +async function resolveWorkspaceContext(workspaceId: string) { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +async function resolveServerContext(serverId: string) { + const [server] = await db + .select() + .from(workflowMcpServer) + .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) + .limit(1) + if (!server) throw new OrchestrationError('not_found', 'MCP server not found') + const workspace = await resolveWorkspaceContext(server.workspaceId) + return { ...workspace, server } +} + +async function resolveWorkflowToolContext(serverId: string, workflowId: string) { + const context = await resolveServerContext(serverId) + const [workflowRecord] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + if (!workflowRecord) throw new OrchestrationError('not_found', 'Workflow not found') + return { ...context, workflow: workflowRecord } +} + +function throwWorkflowMcpFailure( + result: { + error?: string + errorCode?: 'not_found' | 'validation' | 'forbidden' | 'conflict' | 'internal' + }, + fallback: string +): never { + if (!result.errorCode || result.errorCode === 'internal') throw new Error(fallback) + throw new OrchestrationError(result.errorCode, result.error ?? fallback) +} + +function attribution( + principal: Parameters[0], + billedAccountUserId: string +) { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +export interface ListWorkflowMcpDeploymentsInput { + workspaceId: string +} + +export const listWorkflowMcpDeployments = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.listWorkflowDeployments, + resolveContext: ({ input }: { input: ListWorkflowMcpDeploymentsInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ context }) { + const rows = await db + .select({ + id: workflowMcpServer.id, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.workspaceId, context.workspaceId), + isNull(workflowMcpServer.deletedAt) + ) + ) + .orderBy(asc(workflowMcpServer.id)) + .limit(MAX_LISTED_WORKFLOW_MCP_SERVERS + 1) + const truncated = rows.length > MAX_LISTED_WORKFLOW_MCP_SERVERS + const servers = rows.slice(0, MAX_LISTED_WORKFLOW_MCP_SERVERS) + 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)) + ) + .orderBy(asc(workflowMcpTool.serverId), asc(workflowMcpTool.toolName)) + .limit(MAX_LISTED_WORKFLOW_MCP_TOOLS + 1) + const toolsTruncated = tools.length > MAX_LISTED_WORKFLOW_MCP_TOOLS + const names = new Map() + for (const tool of tools.slice(0, MAX_LISTED_WORKFLOW_MCP_TOOLS)) { + const existing = names.get(tool.serverId) ?? [] + existing.push(tool.toolName) + names.set(tool.serverId, existing) + } + return { + servers: servers.map((server) => ({ + ...server, + toolCount: names.get(server.id)?.length ?? 0, + toolNames: names.get(server.id) ?? [], + })), + truncated: truncated || toolsTruncated, + } + }, +}) + +export interface CreateWorkflowMcpDeploymentServerInput { + workspaceId: string + name: string + description?: string + isPublic?: boolean + workflowIds?: string[] +} + +export const createWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.createWorkflowDeploymentServer, + resolveContext: ({ input }: { input: CreateWorkflowMcpDeploymentServerInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const result = await performCreateWorkflowMcpServer({ + ...input, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.server) { + throwWorkflowMcpFailure(result, 'Failed to create workflow MCP server') + } + return { server: result.server, addedTools: result.addedTools ?? [] } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Published workflow MCP server "${result.server.name}" with ${result.addedTools.length} tool(s)`, + metadata: { + serverName: result.server.name, + isPublic: result.server.isPublic, + toolCount: result.addedTools.length, + toolNames: result.addedTools.map((tool) => tool.toolName), + workflowIds: result.addedTools.map((tool) => tool.workflowId), + }, + }), + afterSuccess: ({ context, result }) => + result.addedTools.length > 0 + ? mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }) + : undefined, +}) + +export interface UpdateWorkflowMcpDeploymentServerInput { + serverId: string + name?: string + description?: string | null + isPublic?: boolean +} + +export const updateWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.updateWorkflowDeploymentServer, + resolveContext: ({ input }: { input: UpdateWorkflowMcpDeploymentServerInput }) => + resolveServerContext(input.serverId), + authorizationOptions, + async execute({ principal, input, context }) { + const result = await performUpdateWorkflowMcpServer({ + ...input, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.server) { + throwWorkflowMcpFailure(result, 'Failed to update workflow MCP server') + } + return { server: result.server, updatedFields: result.updatedFields ?? [] } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Updated workflow MCP server "${result.server.name}"`, + metadata: { + serverName: result.server.name, + isPublic: result.server.isPublic, + updatedFields: result.updatedFields, + }, + }), +}) + +export interface DeleteWorkflowMcpDeploymentServerInput { + serverId: string +} + +export const deleteWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.deleteWorkflowDeploymentServer, + resolveContext: ({ input }: { input: DeleteWorkflowMcpDeploymentServerInput }) => + resolveServerContext(input.serverId), + authorizationOptions, + async execute({ principal, input, context }) { + const result = await performDeleteWorkflowMcpServer({ + serverId: input.serverId, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.server) { + throwWorkflowMcpFailure(result, 'Failed to delete workflow MCP server') + } + return { server: result.server } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Unpublished workflow MCP server "${result.server.name}"`, + metadata: { serverName: result.server.name }, + }), + afterSuccess: ({ context, result }) => + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }), +}) + +export interface DeployWorkflowMcpToolInput { + serverId: string + workflowId: string + toolName?: string + toolDescription?: string + parameterDescriptions?: Array<{ name?: string; description?: string }> +} + +export const deployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.deployWorkflowTool, + resolveContext: ({ input }: { input: DeployWorkflowMcpToolInput }) => + resolveWorkflowToolContext(input.serverId, input.workflowId), + authorizationOptions, + async execute({ principal, input, context }) { + if (!context.workflow.isDeployed) { + throw new OrchestrationError( + 'validation', + 'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.' + ) + } + if ( + input.parameterDescriptions && + input.parameterDescriptions.length > MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES + ) { + throw new OrchestrationError( + 'validation', + `MCP tools cannot override more than ${MAX_MCP_PARAMETER_DESCRIPTION_OVERRIDES} parameter descriptions` + ) + } + const [existing] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, context.server.id), + eq(workflowMcpTool.workflowId, context.workflow.id), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) + const toolName = sanitizeToolName( + input.toolName || context.workflow.name || `workflow_${context.workflow.id}` + ) + const toolDescription = + input.toolDescription?.trim() || `Execute ${context.workflow.name} workflow` + const parameterDescriptionOverrides = Object.fromEntries( + (input.parameterDescriptions ?? []) + .filter((entry) => typeof entry.name === 'string' && entry.name.trim().length > 0) + .map((entry) => [entry.name?.trim() ?? '', entry.description?.trim() ?? '']) + .filter(([, description]) => description.length > 0) + ) + const parameterSchema = applyDescriptionOverrides( + generateToolInputSchema(await getDeployedWorkflowInputFormat(context.workflow.id)), + parameterDescriptionOverrides + ) + const userId = attribution(principal, context.billedAccountUserId) + const result = existing + ? await performUpdateWorkflowMcpTool({ + serverId: context.server.id, + toolId: existing.id, + workspaceId: context.workspaceId, + userId, + toolName, + toolDescription, + parameterDescriptionOverrides, + projectLegacyAudit: false, + publishEffects: false, + }) + : await performCreateWorkflowMcpTool({ + serverId: context.server.id, + workspaceId: context.workspaceId, + userId, + workflowId: context.workflow.id, + toolName, + toolDescription, + parameterDescriptionOverrides, + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.tool) { + throwWorkflowMcpFailure(result, 'Failed to deploy workflow MCP tool') + } + return { + tool: result.tool, + server: context.server, + workflow: context.workflow, + updated: Boolean(existing), + parameterSchema, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `${result.updated ? 'Updated' : 'Added'} tool "${result.tool.toolName}" on MCP server`, + metadata: { + toolId: result.tool.id, + toolName: result.tool.toolName, + workflowId: result.workflow.id, + }, + }), + afterSuccess: ({ context, result }) => + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }), +}) + +export interface UndeployWorkflowMcpToolInput { + serverId: string + workflowId: string +} + +export const undeployWorkflowMcpTool = defineAuthorizedWorkspaceUseCase({ + operation: mcpServerOperations.undeployWorkflowTool, + resolveContext: ({ input }: { input: UndeployWorkflowMcpToolInput }) => + resolveWorkflowToolContext(input.serverId, input.workflowId), + authorizationOptions, + async execute({ principal, context }) { + const [tool] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, context.server.id), + eq(workflowMcpTool.workflowId, context.workflow.id), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) + if (!tool) { + throw new OrchestrationError('not_found', 'Workflow is not deployed to this MCP server') + } + const result = await performDeleteWorkflowMcpTool({ + serverId: context.server.id, + toolId: tool.id, + workspaceId: context.workspaceId, + userId: attribution(principal, context.billedAccountUserId), + projectLegacyAudit: false, + publishEffects: false, + }) + if (!result.success || !result.tool) { + throwWorkflowMcpFailure(result, 'Failed to undeploy workflow MCP tool') + } + return { tool: result.tool, server: context.server, workflow: context.workflow } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: result.server.id, + resourceName: result.server.name, + description: `Removed tool "${result.tool.toolName}" from MCP server`, + metadata: { + toolId: result.tool.id, + toolName: result.tool.toolName, + workflowId: result.workflow.id, + }, + }), + afterSuccess: ({ context, result }) => + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: result.server.id, + workspaceId: context.workspaceId, + }), +}) diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts index df8e03908db..a10d236b473 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts @@ -58,6 +58,8 @@ class WorkflowMcpExpectedError extends Error { interface ActorMetadata { actorName?: string | null actorEmail?: string | null + projectLegacyAudit?: boolean + publishEffects?: boolean } export interface PerformCreateWorkflowMcpServerParams extends ActorMetadata { @@ -497,28 +499,29 @@ export async function performCreateWorkflowMcpServer( return { server: createdServer, addedTools: insertedTools, serverId: newServerId } }) - if (addedTools.length > 0) { + if (addedTools.length > 0 && params.publishEffects !== false) { mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId: params.workspaceId }) } - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: name, - description: `Published workflow MCP server "${name}" with ${addedTools.length} tool(s)`, - metadata: { - serverName: name, - isPublic: params.isPublic ?? false, - toolCount: addedTools.length, - toolNames: addedTools.map((tool) => tool.toolName), - workflowIds: addedTools.map((tool) => tool.workflowId), - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: name, + description: `Published workflow MCP server "${name}" with ${addedTools.length} tool(s)`, + metadata: { + serverName: name, + isPublic: params.isPublic ?? false, + toolCount: addedTools.length, + toolNames: addedTools.map((tool) => tool.toolName), + workflowIds: addedTools.map((tool) => tool.workflowId), + }, + }) return { success: true, server, addedTools } } catch (error) { @@ -565,22 +568,23 @@ export async function performUpdateWorkflowMcpServer( return { success: false, error: 'Server not found', errorCode: 'not_found' } } - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - resourceName: server.name, - description: `Updated workflow MCP server "${server.name}"`, - metadata: { - serverName: server.name, - isPublic: server.isPublic, - updatedFields, - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + resourceName: server.name, + description: `Updated workflow MCP server "${server.name}"`, + metadata: { + serverName: server.name, + isPublic: server.isPublic, + updatedFields, + }, + }) return { success: true, server, updatedFields } } catch (error) { @@ -613,23 +617,25 @@ export async function performDeleteWorkflowMcpServer( return { success: false, error: 'Server not found', errorCode: 'not_found' } } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - resourceName: server.name, - description: `Unpublished workflow MCP server "${server.name}"`, - metadata: { serverName: server.name }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + resourceName: server.name, + description: `Unpublished workflow MCP server "${server.name}"`, + metadata: { serverName: server.name }, + }) return { success: true, server } } catch (error) { @@ -836,28 +842,30 @@ export async function performCreateWorkflowMcpTool( return { success: false, error: 'Failed to add tool', errorCode: 'internal' } } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Added tool "${toolName}" to MCP server`, - metadata: { - toolId, - toolName, - toolDescription, - workflowId: params.workflowId, - workflowName: workflowRecord.name, - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Added tool "${toolName}" to MCP server`, + metadata: { + toolId, + toolName, + toolDescription, + workflowId: params.workflowId, + workflowName: workflowRecord.name, + }, + }) return { success: true, tool } } catch (error) { @@ -1045,27 +1053,29 @@ export async function performUpdateWorkflowMcpTool( if (!tool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Updated tool "${tool.toolName}" in MCP server`, - metadata: { - toolId: params.toolId, - toolName: tool.toolName, - workflowId: tool.workflowId, - updatedFields, - }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Updated tool "${tool.toolName}" in MCP server`, + metadata: { + toolId: params.toolId, + toolName: tool.toolName, + workflowId: tool.workflowId, + updatedFields, + }, + }) return { success: true, tool } } catch (error) { @@ -1117,22 +1127,24 @@ export async function performDeleteWorkflowMcpTool( if (!tool) return { success: false, error: 'Tool not found', errorCode: 'not_found' } - mcpPubSub?.publishWorkflowToolsChanged({ - serverId: params.serverId, - workspaceId: params.workspaceId, - }) + if (params.publishEffects !== false) + mcpPubSub?.publishWorkflowToolsChanged({ + serverId: params.serverId, + workspaceId: params.workspaceId, + }) - recordAudit({ - workspaceId: params.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Removed tool "${tool.toolName}" from MCP server`, - metadata: { toolId: params.toolId, toolName: tool.toolName, workflowId: tool.workflowId }, - }) + if (params.projectLegacyAudit !== false) + recordAudit({ + workspaceId: params.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: params.serverId, + description: `Removed tool "${tool.toolName}" from MCP server`, + metadata: { toolId: params.toolId, toolName: tool.toolName, workflowId: tool.workflowId }, + }) return { success: true, tool } } catch (error) { diff --git a/apps/sim/lib/posthog/server.ts b/apps/sim/lib/posthog/server.ts index c81349e2e79..18274456081 100644 --- a/apps/sim/lib/posthog/server.ts +++ b/apps/sim/lib/posthog/server.ts @@ -36,6 +36,8 @@ function getClient(): PostHog | null { type PersonProperties = Record interface CaptureOptions { + /** Stable event identity used by PostHog to collapse retried server captures. */ + insertId?: string /** * Associate this event with workspace-level group analytics. * Pass `{ workspace: workspaceId }`. @@ -53,6 +55,22 @@ interface CaptureOptions { setOnce?: PersonProperties } +function buildCaptureProperties( + properties: PostHogEventMap[E], + options?: CaptureOptions +): Record { + const contextRequestId = getRequestContext()?.requestId + const props = properties as Record + return { + ...properties, + ...(contextRequestId && !('request_id' in props) ? { request_id: contextRequestId } : {}), + ...(options?.insertId ? { $insert_id: options.insertId } : {}), + ...(options?.groups ? { $groups: options.groups } : {}), + ...(options?.set ? { $set: options.set } : {}), + ...(options?.setOnce ? { $set_once: options.setOnce } : {}), + } +} + /** * Capture a server-side PostHog event. Fire-and-forget — never throws. * @@ -71,20 +89,31 @@ export function captureServerEvent( const client = getClient() if (!client) return - const contextRequestId = getRequestContext()?.requestId - const props = properties as Record client.capture({ distinctId, event, - properties: { - ...properties, - ...(contextRequestId && !('request_id' in props) ? { request_id: contextRequestId } : {}), - ...(options?.groups ? { $groups: options.groups } : {}), - ...(options?.set ? { $set: options.set } : {}), - ...(options?.setOnce ? { $set_once: options.setOnce } : {}), - }, + properties: buildCaptureProperties(properties, options), }) } catch (error) { logger.warn('Failed to capture PostHog server event', { event, error }) } } + +/** Captures and flushes one outbox event before its durable checkpoint advances. */ +export async function deliverOutboxServerEvent( + distinctId: string, + event: E, + properties: PostHogEventMap[E], + options?: CaptureOptions +): Promise<'delivered' | 'skipped'> { + const client = getClient() + if (!client) return 'skipped' + + client.capture({ + distinctId, + event, + properties: buildCaptureProperties(properties, options), + }) + await client.flush() + return 'delivered' +} diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts index 58a6c46f4f7..f1d9846919b 100644 --- a/apps/sim/lib/realtime/notify.ts +++ b/apps/sim/lib/realtime/notify.ts @@ -84,6 +84,66 @@ export async function notifyWorkspaceTablesChanged(workspaceId: string): Promise } } +/** Best-effort fan-out that invalidates open editors for one durably changed workflow. */ +export async function notifyWorkflowUpdated(workflowId: string): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workflow-updated`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workflowId }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workflow-updated notify failed', { workflowId, status: response.status }) + } + } catch (error) { + logger.warn('workflow-updated notify error', { + workflowId, + error: getErrorMessage(error), + }) + } +} + +/** Best-effort fan-out that removes one durably archived workflow from open clients. */ +export async function notifyWorkflowDeleted(workflowId: string): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workflow-deleted`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workflowId }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workflow-deleted notify failed', { workflowId, status: response.status }) + } + } catch (error) { + logger.warn('workflow-deleted notify error', { + workflowId, + error: getErrorMessage(error), + }) + } +} + +/** Best-effort fan-out that replaces an open editor after a deployment is loaded into draft. */ +export async function notifyWorkflowReverted(workflowId: string, timestamp: number): Promise { + try { + const response = await fetch(`${getSocketServerUrl()}/api/workflow-reverted`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify({ workflowId, timestamp }), + signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), + }) + if (!response.ok) { + logger.warn('workflow-reverted notify failed', { workflowId, status: response.status }) + } + } catch (error) { + logger.warn('workflow-reverted notify error', { + workflowId, + error: getErrorMessage(error), + }) + } +} + /** * Folder resource types whose list is kept live by a workspace invalidation room: a folder mutation * (create/rename/move/delete/restore) for one of these must fan out the same list-changed signal as a diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 9d0f6009346..dd590edd4f7 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -4,6 +4,10 @@ const ALL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const COPILOT_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const const ALL_TABLE_TOOL_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], @@ -76,6 +80,18 @@ export const tableOperations = { create: writeOperation('tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), + renameByVfsPath: defineWorkspaceOperation({ + id: 'tables.vfs.rename', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), + deleteByVfsPath: defineWorkspaceOperation({ + id: 'tables.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), listFolders: readOperation('tables.folders.list'), createFolder: writeOperation('tables.folders.create'), updateFolder: writeOperation('tables.folders.update'), diff --git a/apps/sim/lib/table/application/table-vfs.ts b/apps/sim/lib/table/application/table-vfs.ts new file mode 100644 index 00000000000..cba5c91866c --- /dev/null +++ b/apps/sim/lib/table/application/table-vfs.ts @@ -0,0 +1,91 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveTableWorkspaceContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { deleteTable, findActiveTablesByExactName, renameTable } from '@/lib/table/service' +import type { TableDefinition } from '@/lib/table/types' + +interface TableVfsReferenceInput { + workspaceId: string + sourceName: string +} + +export interface RenameTableByVfsPathInput extends TableVfsReferenceInput { + newName: string +} + +export type DeleteTableByVfsPathInput = TableVfsReferenceInput + +async function resolveTableByVfsName( + workspaceId: string, + sourceName: string +): Promise { + const matches = await findActiveTablesByExactName(workspaceId, sourceName) + if (matches.length > 1) { + throw new OrchestrationError('conflict', `Table path is ambiguous: tables/${sourceName}`) + } + const table = matches[0] + if (!table) throw new OrchestrationError('not_found', `Table not found at tables/${sourceName}`) + return table +} + +export const renameTableByVfsPath = defineAuthorizedTableUseCase({ + operation: tableOperations.renameByVfsPath, + resolveContext: ({ input }: { input: RenameTableByVfsPathInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const renamed = await renameTable(table.id, input.newName, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + skipNotify: true, + }) + return { + id: renamed.id, + name: renamed.name, + previousName: table.name, + workspaceId: context.workspaceId, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.id, + resourceName: result.name, + description: `Renamed table to "${result.name}"`, + metadata: { op: 'rename', previousName: result.previousName, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteByVfsPath, + resolveContext: ({ input }: { input: DeleteTableByVfsPathInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const { archived } = await deleteTable(table.id, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + skipNotify: true, + }) + if (!archived) + throw new OrchestrationError('not_found', `Table not found at tables/${input.sourceName}`) + return { + id: table.id, + name: archived.name, + workspaceId: context.workspaceId, + deleted: true as const, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: result.id, + resourceName: result.name, + description: `Archived table "${result.name}"`, + metadata: { source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index e30a4ecb83e..7cb7e35c760 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -338,6 +338,25 @@ export async function listTables( return hydrateTableRows(tables) } +/** Loads at most two active exact-name matches so callers can fail on corrupt ambiguity. */ +export async function findActiveTablesByExactName( + workspaceId: string, + name: string +): Promise { + const rows = await db + .select(TABLE_ROW_SELECT) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + eq(userTableDefinitions.name, name), + isNull(userTableDefinitions.archivedAt) + ) + ) + .limit(2) + return hydrateTableRows(rows) +} + /** * Attaches each table's latest job fields and its order-corrected schema. The * `rowCount` subtracts rows a pending delete has already claimed, so a table @@ -785,7 +804,7 @@ export async function renameTable( tableId: string, newName: string, requestId: string, - options?: { expectedWorkspaceId?: string } + options?: { expectedWorkspaceId?: string; skipNotify?: boolean } ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { @@ -819,7 +838,7 @@ export async function renameTable( logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`) // Live tables list: a rename changes the list result, so everyone viewing refetches. - if (workspaceId) await notifyWorkspaceTablesChanged(workspaceId) + if (workspaceId && !options?.skipNotify) await notifyWorkspaceTablesChanged(workspaceId) return { id: tableId, name: newName } } catch (error: unknown) { diff --git a/apps/sim/lib/vfs/limits.ts b/apps/sim/lib/vfs/limits.ts new file mode 100644 index 00000000000..931531274b3 --- /dev/null +++ b/apps/sim/lib/vfs/limits.ts @@ -0,0 +1,61 @@ +export const MAX_VFS_PATH_ITEMS = 100 +export const MAX_VFS_PATH_LENGTH = 4096 +export const MAX_VFS_TOTAL_PATH_BYTES = 64 * 1024 +export const MAX_VFS_PATH_SEGMENTS = 64 +export const MAX_VFS_SEGMENT_LENGTH = 255 + +export class VfsPathLimitError extends Error { + constructor(message: string) { + super(message) + this.name = 'VfsPathLimitError' + } +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +export function validateVfsPathSegments(segments: readonly string[]): void { + if (segments.length > MAX_VFS_PATH_SEGMENTS) { + throw new VfsPathLimitError(`VFS paths cannot exceed ${MAX_VFS_PATH_SEGMENTS} segments`) + } + for (const segment of segments) { + if (segment.length === 0 || byteLength(segment) > MAX_VFS_SEGMENT_LENGTH) { + throw new VfsPathLimitError( + `VFS path segments must be between 1 and ${MAX_VFS_SEGMENT_LENGTH} bytes` + ) + } + } +} + +export function validateVfsPathBatch(paths: readonly string[]): void { + if (paths.length > MAX_VFS_PATH_ITEMS) { + throw new VfsPathLimitError(`VFS commands cannot exceed ${MAX_VFS_PATH_ITEMS} paths`) + } + let totalBytes = 0 + for (const path of paths) { + const pathBytes = byteLength(path) + totalBytes += pathBytes + if (pathBytes > MAX_VFS_PATH_LENGTH) { + throw new VfsPathLimitError(`VFS paths cannot exceed ${MAX_VFS_PATH_LENGTH} bytes`) + } + const segments = path + .trim() + .replace(/^\/+|\/+$/g, '') + .split('/') + .filter(Boolean) + .map((segment) => { + try { + return decodeURIComponent(segment) + } catch { + return segment + } + }) + validateVfsPathSegments(segments) + } + if (totalBytes > MAX_VFS_TOTAL_PATH_BYTES) { + throw new VfsPathLimitError( + `VFS command paths cannot exceed ${MAX_VFS_TOTAL_PATH_BYTES} total bytes` + ) + } +} diff --git a/apps/sim/lib/vfs/path.ts b/apps/sim/lib/vfs/path.ts new file mode 100644 index 00000000000..2b8848b2ed3 --- /dev/null +++ b/apps/sim/lib/vfs/path.ts @@ -0,0 +1,57 @@ +const CONTROL_CHARS = /[\x00-\x1f\x7f]/g +const WHITESPACE = /\s+/g + +export class VfsPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'VfsPathError' + } +} + +function normalizeDisplaySegment(segment: string): string { + return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') +} + +export function encodeVfsSegment(segment: string): string { + const normalized = normalizeDisplaySegment(segment) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return encodeURIComponent(normalized) +} + +export function decodeVfsSegment(segment: string): string { + try { + const decoded = decodeURIComponent(segment) + const normalized = normalizeDisplaySegment(decoded) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return normalized + } catch (error) { + if (error instanceof VfsPathError) throw error + throw new VfsPathError(`Invalid encoded VFS path segment: ${segment}`) + } +} + +export function decodeVfsSegmentSafe(segment: string): string { + try { + return decodeVfsSegment(segment) + } catch { + return segment + } +} + +export function encodeVfsPathSegments(segments: string[]): string { + return segments.map(encodeVfsSegment).join('/') +} + +export function decodeVfsPathSegments(path: string): string[] { + const trimmed = path.trim().replace(/^\/+|\/+$/g, '') + if (!trimmed) return [] + return trimmed.split('/').map(decodeVfsSegment) +} + +export function canonicalizeVfsPath(path: string): string { + return encodeVfsPathSegments(decodeVfsPathSegments(path)) +} diff --git a/apps/sim/lib/workflows/api/index.ts b/apps/sim/lib/workflows/api/index.ts index ad2a025d322..2d0371da36a 100644 --- a/apps/sim/lib/workflows/api/index.ts +++ b/apps/sim/lib/workflows/api/index.ts @@ -1 +1,6 @@ -export { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' +export { + createInternalWorkflowErrorPolicy, + internalWorkflowReadAuth, + internalWorkflowSessionOrExecutorAuth, + v2WorkflowErrorPolicies, +} from '@/lib/workflows/api/route-policies' diff --git a/apps/sim/lib/workflows/api/route-policies.test.ts b/apps/sim/lib/workflows/api/route-policies.test.ts index 0955c8082a0..3ca683dd509 100644 --- a/apps/sim/lib/workflows/api/route-policies.test.ts +++ b/apps/sim/lib/workflows/api/route-policies.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { DelegatedWorkspaceAuthorizationError, InsufficientWorkspacePermissionsError, @@ -10,7 +11,21 @@ import { WorkspaceApiKeyAuthorizationError, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies' + +const mocks = vi.hoisted(() => ({ + authenticateApiKey: vi.fn(), + updateLastUsed: vi.fn(), +})) + +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: mocks.authenticateApiKey, + updateApiKeyLastUsed: mocks.updateLastUsed, +})) + +import { + internalWorkflowReadAuth, + v2WorkflowErrorPolicies, +} from '@/lib/workflows/api/route-policies' describe('v2 workflow error policies', () => { it.each([ @@ -58,3 +73,48 @@ describe('v2 workflow error policies', () => { }) }) }) + +describe('internal workflow read auth', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('constructs a workspace principal only from the verified API-key result', async () => { + mocks.authenticateApiKey.mockResolvedValue({ + success: true, + keyId: 'key-1', + keyType: 'workspace', + userId: 'forged-route-user', + workspaceId: 'workspace-1', + }) + + const principal = await internalWorkflowReadAuth.authenticate( + new NextRequest('http://localhost/api/workflows/forged/status', { + headers: { 'x-api-key': 'secret-key' }, + }), + { id: 'forged-workflow' } + ) + + expect(principal).toEqual({ + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }) + expect(mocks.updateLastUsed).toHaveBeenCalledWith('key-1') + }) + + it('fails closed when API-key verification does not return a principal identity', async () => { + mocks.authenticateApiKey.mockResolvedValue({ success: false, error: 'Invalid API key' }) + + await expect( + internalWorkflowReadAuth.authenticate( + new NextRequest('http://localhost/api/workflows/workflow-1/status', { + headers: { 'x-api-key': 'invalid' }, + }), + { id: 'workflow-1' } + ) + ).rejects.toMatchObject({ name: 'InternalUnauthenticatedError' }) + + expect(mocks.updateLastUsed).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index 9f17e4708d5..bb5e1ec6930 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -1,8 +1,17 @@ +import type { Principal } from '@sim/auth/principal' import { + createInternalSessionOrExecutorAuth, createV2ResourceConcealmentPolicy, + type InternalAuthPolicy, + type InternalErrorPolicy, + InternalUnauthenticatedError, + internalErrorResponse, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' +import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' import { WorkflowImportError } from '@/lib/workflows/application/workflow-import-error' import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' @@ -23,3 +32,53 @@ export const v2WorkflowErrorPolicies = { notFoundMessage: 'Run not found', }), } as const + +export const internalWorkflowSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: WORKFLOW_DELEGATION_AUDIENCE, +}) + +export const internalWorkflowReadAuth: InternalAuthPolicy = { + async authenticate(request, params) { + const rawApiKey = request.headers.get('x-api-key') + if (!rawApiKey) { + return internalWorkflowSessionOrExecutorAuth.authenticate(request, params) + } + + const result = await authenticateApiKeyFromHeader(rawApiKey) + if (!result.success || !result.keyId || !result.keyType) { + throw new InternalUnauthenticatedError('Unauthorized') + } + await updateApiKeyLastUsed(result.keyId) + + if (result.keyType === 'workspace') { + if (!result.workspaceId) throw new Error('Workspace API key is missing its workspace scope') + return { kind: 'workspace_api_key', workspaceId: result.workspaceId, keyId: result.keyId } + } + if (!result.userId) throw new Error('Personal API key is missing its credential owner') + return { kind: 'personal_api_key', userId: result.userId, keyId: result.keyId } + }, +} + +function legacyWorkflowErrorCode(message: string): string { + return message.toUpperCase().replace(/\s+/g, '_') +} + +export function createInternalWorkflowErrorPolicy(fallback: string): InternalErrorPolicy { + if (!fallback.trim()) throw new Error('Internal workflow error fallback is required') + return { + project(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + code: legacyWorkflowErrorCode(classified.message), + }) + }, + unhandled() { + return internalErrorResponse(500, { + error: fallback, + code: legacyWorkflowErrorCode(fallback), + }) + }, + } +} diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts index f8dffb5ba3e..9fc44c899e3 100644 --- a/apps/sim/lib/workflows/application/authorization.ts +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -18,6 +18,19 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy, context: WorkflowAuthorizationContext ) { - return principal.workspaceId === context.workspaceId + if (principal.workspaceId !== context.workspaceId) return false + if (principal.serviceId === 'copilot') return true + if (principal.serviceId !== 'executor') return false + const delegationContext = (principal as { delegationContext?: unknown }).delegationContext + return ( + typeof delegationContext === 'object' && + delegationContext !== null && + 'kind' in delegationContext && + delegationContext.kind === 'workflow_execution' && + 'workflowId' in delegationContext && + typeof delegationContext.workflowId === 'string' && + delegationContext.workflowId.length > 0 && + context.workflowId === delegationContext.workflowId + ) }, } diff --git a/apps/sim/lib/workflows/application/chat-deployments.ts b/apps/sim/lib/workflows/application/chat-deployments.ts new file mode 100644 index 00000000000..5c6a653681e --- /dev/null +++ b/apps/sim/lib/workflows/application/chat-deployments.ts @@ -0,0 +1,258 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { + type Principal, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, + toPrincipalActor, +} from '@sim/auth/principal' +import { chat, db } from '@sim/db' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' +import { + ChatDeployAuthNotAllowedError, + validateChatDeployAuth, +} from '@/ee/access-control/utils/permission-check' + +type ChatAuthType = 'public' | 'password' | 'email' | 'sso' +type ChatOutputConfig = { blockId: string; path: string } +type ChatCustomizations = { + primaryColor?: string + welcomeMessage?: string + imageUrl?: string +} + +export interface DeployWorkflowChatInput { + workflowId: string + assertedWorkspaceId?: string + identifier?: string + title?: string + description?: string + versionDescription: string + versionName: string + customizations?: ChatCustomizations + authType?: ChatAuthType + password?: string | null + allowedEmails?: string[] + outputConfigs?: unknown[] + includeThinking?: boolean + includeToolCalls?: boolean + requestId: string + idempotencyKey?: string +} + +export interface UndeployWorkflowChatInput { + workflowId: string + assertedWorkspaceId?: string +} + +function parseChatOutputConfigs(value: unknown[] | undefined): ChatOutputConfig[] | undefined { + if (value === undefined) return undefined + if ( + !value.every( + (entry): entry is ChatOutputConfig => + typeof entry === 'object' && + entry !== null && + 'blockId' in entry && + typeof entry.blockId === 'string' && + entry.blockId.length > 0 && + 'path' in entry && + typeof entry.path === 'string' + ) + ) { + throw new OrchestrationError('validation', 'Invalid chat output configuration') + } + return value +} + +function resolveWorkflowContext({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +export const deployWorkflowChat = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deployChat, + resolveContext: resolveWorkflowContext, + async execute({ principal, input, context }) { + const [existingDeployment] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) + .limit(1) + + const identifier = (input.identifier || existingDeployment?.identifier || '').trim() + const title = (input.title || existingDeployment?.title || '').trim() + if (!identifier || !title) { + throw new OrchestrationError('validation', 'Chat identifier and title are required') + } + if (!/^[a-z0-9-]+$/.test(identifier)) { + throw new OrchestrationError( + 'validation', + 'Identifier can only contain lowercase letters, numbers, and hyphens' + ) + } + + const [identifierOwner] = await db + .select({ id: chat.id }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) + if (identifierOwner && identifierOwner.id !== existingDeployment?.id) { + throw new OrchestrationError('conflict', 'Identifier already in use') + } + + const existingCustomizations = + (existingDeployment?.customizations as ChatCustomizations | null) ?? {} + const description = input.description ?? existingDeployment?.description ?? '' + const authType = input.authType ?? (existingDeployment?.authType as ChatAuthType) ?? 'public' + const allowedEmails = + input.allowedEmails ?? (existingDeployment?.allowedEmails as string[] | null) ?? [] + const outputConfigs = + parseChatOutputConfigs(input.outputConfigs) ?? + (existingDeployment?.outputConfigs as ChatOutputConfig[] | null) ?? + [] + const includeThinking = input.includeThinking ?? existingDeployment?.includeThinking ?? false + const includeToolCalls = input.includeToolCalls ?? existingDeployment?.includeToolCalls ?? false + const customizations = { + primaryColor: + input.customizations?.primaryColor ?? + existingCustomizations.primaryColor ?? + 'var(--brand-hover)', + welcomeMessage: + input.customizations?.welcomeMessage ?? + existingCustomizations.welcomeMessage ?? + 'Hi there! How can I help you today?', + ...((input.customizations?.imageUrl ?? existingCustomizations.imageUrl) + ? { imageUrl: input.customizations?.imageUrl ?? existingCustomizations.imageUrl } + : {}), + } + + const subjectUserId = requirePrincipalSubjectUserId(principal) + if (authType !== existingDeployment?.authType) { + try { + await validateChatDeployAuth(subjectUserId, context.workspaceId, authType) + } catch (error) { + if (error instanceof ChatDeployAuthNotAllowedError) { + throw new OrchestrationError('forbidden', error.message) + } + throw error + } + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performChatDeploy({ + workflowId: context.workflowId, + userId: attribution.attributedUserId, + actorId: attribution.attributedUserId, + actor: toPrincipalActor(principal), + identifier, + title, + description, + versionDescription: input.versionDescription, + versionName: input.versionName, + customizations, + authType, + password: input.password, + allowedEmails, + outputConfigs, + includeThinking, + includeToolCalls, + workspaceId: context.workspaceId, + requestId: input.requestId, + idempotencyKey: input.idempotencyKey, + projectLegacyAudit: false, + ...(principal.kind === 'delegated' + ? { captureDeploymentAnalytics: false as const, captureLegacyTelemetry: false } + : {}), + }) + if (!result.success || !result.chatId || !result.chatUrl) { + throw new OrchestrationError('validation', result.error ?? 'Failed to deploy chat') + } + return { + ...result, + chatId: result.chatId, + chatUrl: result.chatUrl, + workflowId: context.workflowId, + identifier, + title, + description, + authType, + allowedEmails, + outputConfigs, + includeThinking, + includeToolCalls, + customizations, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + resourceId: result.chatId, + resourceName: result.title, + description: `Deployed chat "${result.title}"`, + metadata: { + workflowId: result.workflowId, + identifier: result.identifier, + authType: result.authType, + chatUrl: result.chatUrl, + isUpdate: result.isUpdate, + hasOutputConfigs: result.outputConfigs.length > 0, + hasCustomizations: Object.keys(result.customizations).length > 0, + }, + }), +}) + +export const undeployWorkflowChat = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.undeployChat, + resolveContext: resolveWorkflowContext, + async execute({ principal, context }) { + const [deployment] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) + .limit(1) + if (!deployment) { + throw new OrchestrationError('not_found', 'No active chat deployment found for this workflow') + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await performChatUndeploy({ + chatId: deployment.id, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + projectLegacyAudit: false, + }) + if (!result.success) { + throw new OrchestrationError('not_found', result.error ?? 'Failed to undeploy chat') + } + return { workflowId: context.workflowId, deployment } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: result.deployment.id, + resourceName: result.deployment.title || result.deployment.id, + description: `Deleted chat deployment "${result.deployment.title || result.deployment.id}"`, + metadata: { + workflowId: result.workflowId, + identifier: result.deployment.identifier || undefined, + authType: result.deployment.authType || undefined, + }, + }), +}) diff --git a/apps/sim/lib/workflows/application/context.test.ts b/apps/sim/lib/workflows/application/context.test.ts index 5c8f59b0e0c..d30b4afa24a 100644 --- a/apps/sim/lib/workflows/application/context.test.ts +++ b/apps/sim/lib/workflows/application/context.test.ts @@ -1,18 +1,23 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ loadWorkspace: vi.fn() })) +const mocks = vi.hoisted(() => ({ + getJob: vi.fn(), + getJobQueue: vi.fn(), + loadWorkspace: vi.fn(), +})) -vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() })) +vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mocks.getJobQueue })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, })) import { resolveActiveWorkflowApplicationContext, + resolveActiveWorkflowRunApplicationContext, resolveActiveWorkspaceApplicationContext, } from '@/lib/workflows/application/context' @@ -24,11 +29,18 @@ const workspace = { } const workflow = { id: 'workflow-1', workspaceId: 'workspace-1', archivedAt: null } +function queueCanonicalBindings(input: { log?: string; paused?: string; resumed?: string }): void { + queueTableRows(schemaMock.workflowExecutionLogs, input.log ? [{ workflowId: input.log }] : []) + queueTableRows(schemaMock.pausedExecutions, input.paused ? [{ workflowId: input.paused }] : []) + queueTableRows(schemaMock.resumeQueue, input.resumed ? [{ workflowId: input.resumed }] : []) +} + describe('workflow application contexts', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.getJobQueue.mockResolvedValue({ getJob: mocks.getJob }) }) it('uses the canonical loader for workspace-scoped operations', async () => { @@ -86,4 +98,47 @@ describe('workflow application contexts', () => { resolveActiveWorkflowApplicationContext({ workflowId: 'workflow-1' }) ).rejects.toBe(failure) }) + + it('fails hard when durable stores disagree about the canonical workflow binding', async () => { + queueCanonicalBindings({ log: 'workflow-1', paused: 'workflow-2' }) + + await expect(resolveActiveWorkflowRunApplicationContext({ runId: 'run-1' })).rejects.toThrow( + 'Run run-1 has conflicting canonical workflow bindings' + ) + expect(mocks.getJobQueue).not.toHaveBeenCalled() + }) + + it('conceals a caller-asserted workflow that conflicts with the canonical binding', async () => { + queueCanonicalBindings({ log: 'workflow-1' }) + + await expect( + resolveActiveWorkflowRunApplicationContext({ + runId: 'run-1', + assertedWorkflowId: 'workflow-forged', + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Run not found' }) + }) + + it('accepts matching durable bindings and resolves the active canonical workflow', async () => { + queueCanonicalBindings({ log: 'workflow-1', paused: 'workflow-1', resumed: 'workflow-1' }) + queueTableRows(schemaMock.workflow, [ + { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Canonical workflow' }, + workspaceId: 'workspace-1', + }, + ]) + + await expect( + resolveActiveWorkflowRunApplicationContext({ + runId: 'run-1', + assertedWorkflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toMatchObject({ + runId: 'run-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + }) }) diff --git a/apps/sim/lib/workflows/application/create-workflow.ts b/apps/sim/lib/workflows/application/create-workflow.ts index eeac83f1b40..f1123885e2e 100644 --- a/apps/sim/lib/workflows/application/create-workflow.ts +++ b/apps/sim/lib/workflows/application/create-workflow.ts @@ -3,6 +3,9 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -12,6 +15,7 @@ import { workflowFolderPathForId, } from '@/lib/workflows/application/workflow-folders' import { performCreateWorkflowTransition } from '@/lib/workflows/orchestration' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' const logger = createLogger('CreateWorkflow') @@ -20,6 +24,7 @@ export interface CreateWorkflowInput { name: string description?: string | null folderPath?: string + folderId?: string | null } export const createWorkflow = defineAuthorizedWorkflowUseCase({ @@ -27,7 +32,19 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: CreateWorkflowInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ principal, input, context }) { - const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } + const resolution = + input.folderId === undefined + ? await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') + : { + folderId: input.folderId, + index: await loadActiveFolderPathIndex(context.workspaceId, 'workflow'), + } + if (resolution.folderId && !resolution.index.pathById.has(resolution.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } try { await assertFolderMutable(resolution.folderId) } catch (error) { @@ -49,6 +66,8 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ }) requireWorkflowTransition(transition, 'Failed to create workflow') if (!transition.workflow) throw new Error('Successful workflow create returned no workflow') + const normalizedState = await loadWorkflowFromNormalizedTables(transition.workflow.id) + if (!normalizedState) throw new Error('Successful workflow create returned no workflow state') logger.info('Created workflow', { workspaceId: context.workspaceId, @@ -58,6 +77,7 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ return { workflow: transition.workflow, folderPath: workflowFolderPathForId(resolution.index, transition.workflow.folderId), + normalizedState, } }, projectAudit: ({ result }) => ({ @@ -74,4 +94,20 @@ export const createWorkflow = defineAuthorizedWorkflowUseCase({ sortOrder: result.workflow.sortOrder, }, }), + async afterSuccess({ result }) { + await notifyWorkflowUpdated(result.workflow.id) + try { + PlatformEvents.workflowCreated({ + workflowId: result.workflow.id, + name: result.workflow.name, + workspaceId: result.workflow.workspaceId, + folderId: result.workflow.folderId ?? undefined, + }) + } catch (error) { + logger.warn('Failed to capture workflow created telemetry', { + workflowId: result.workflow.id, + error, + }) + } + }, }) diff --git a/apps/sim/lib/workflows/application/delete-workflow.ts b/apps/sim/lib/workflows/application/delete-workflow.ts index f2a41746cc4..cfa01b44119 100644 --- a/apps/sim/lib/workflows/application/delete-workflow.ts +++ b/apps/sim/lib/workflows/application/delete-workflow.ts @@ -3,6 +3,7 @@ import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowDeleted } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -39,6 +40,7 @@ export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ userId: resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, + notifySocket: false, }) requireWorkflowTransition(transition, 'Failed to delete workflow') if (!transition.workflow) throw new Error('Successful workflow delete returned no workflow') @@ -52,6 +54,7 @@ export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ return { workflowId: context.workflowId, workflowName: transition.workflow.name, + workspaceId: context.workspaceId, archived: transition.archived === true, } }, @@ -66,4 +69,6 @@ export const deleteWorkflow = defineAuthorizedWorkflowUseCase({ metadata: { archived: true }, } : [], + afterSuccess: ({ context, result }) => + result.archived ? notifyWorkflowDeleted(context.workflowId) : undefined, }) diff --git a/apps/sim/lib/workflows/application/deployments.ts b/apps/sim/lib/workflows/application/deployments.ts index bffda5ba2b7..4e57d017821 100644 --- a/apps/sim/lib/workflows/application/deployments.ts +++ b/apps/sim/lib/workflows/application/deployments.ts @@ -1,19 +1,33 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution, toPrincipalActor } from '@sim/auth/principal' +import { + type Principal, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, + toPrincipalActor, +} from '@sim/auth/principal' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { notifyWorkflowReverted } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { + getWorkflowDeploymentSummary, performActivateVersion, performFullDeploy, performFullUndeploy, + performRevertToVersion, } from '@/lib/workflows/orchestration' -import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' +import { + findPreviousDeploymentVersion, + updateDeploymentVersionMetadata, +} from '@/lib/workflows/persistence/utils' export interface DeployWorkflowInput { workflowId: string + assertedWorkspaceId?: string name?: string description?: string requestId: string @@ -22,15 +36,51 @@ export interface DeployWorkflowInput { export interface UndeployWorkflowInput { workflowId: string + assertedWorkspaceId?: string requestId: string } export interface ActivateWorkflowVersionInput { workflowId: string + assertedWorkspaceId?: string version?: number transition: 'activate' | 'rollback' requestId: string idempotencyKey?: string + name?: string | null + description?: string | null +} + +export interface ReadWorkflowDeploymentStatusInput { + workflowId: string + assertedWorkspaceId?: string +} + +export interface RevertWorkflowVersionInput { + workflowId: string + assertedWorkspaceId?: string + version: number | 'active' +} + +export interface UpdateWorkflowVersionInput { + workflowId: string + assertedWorkspaceId?: string + version: number + name?: string | null + description?: string | null +} + +function resolveWorkflowContext({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) } function throwDeploymentFailure( @@ -56,8 +106,7 @@ async function requireMutableWorkflow(workflowId: string): Promise { export const deployWorkflow = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.deploy, - resolveContext: ({ input }: { input: DeployWorkflowInput }) => - resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + resolveContext: resolveWorkflowContext, async execute({ principal, input, context }) { await requireMutableWorkflow(context.workflowId) const attribution = resolvePrincipalAttribution(principal, { @@ -68,7 +117,7 @@ export const deployWorkflow = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, actorId: attribution.attributedUserId, actor: toPrincipalActor(principal), - captureAnalytics: false, + ...(principal.kind === 'delegated' ? { captureAnalytics: false as const } : {}), versionName: input.name, versionDescription: input.description, requestId: input.requestId, @@ -85,8 +134,7 @@ export const deployWorkflow = defineAuthorizedWorkflowUseCase({ export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.undeploy, - resolveContext: ({ input }: { input: UndeployWorkflowInput }) => - resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + resolveContext: resolveWorkflowContext, async execute({ principal, input, context }) { if (!context.workflow.isDeployed) { throw new OrchestrationError('validation', 'Workflow is not deployed') @@ -121,8 +169,7 @@ export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.activateVersion, - resolveContext: ({ input }: { input: ActivateWorkflowVersionInput }) => - resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + resolveContext: resolveWorkflowContext, async execute({ principal, input, context }) { if (input.transition === 'rollback' && !context.workflow.isDeployed) { throw new OrchestrationError('validation', 'Workflow is not deployed') @@ -155,9 +202,11 @@ export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ userId: attribution.attributedUserId, actorId: attribution.attributedUserId, actor: toPrincipalActor(principal), - captureAnalytics: false, + ...(principal.kind === 'delegated' ? { captureAnalytics: false as const } : {}), requestId: input.requestId, idempotencyKey: input.idempotencyKey, + name: input.name, + description: input.description, }) if (!result.success) throwDeploymentFailure(result, 'Failed to activate workflow version') return { @@ -168,3 +217,78 @@ export const activateWorkflowVersion = defineAuthorizedWorkflowUseCase({ } }, }) + +export const readWorkflowDeploymentStatus = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: resolveWorkflowContext, + async execute({ context }) { + const deploymentSummary = await getWorkflowDeploymentSummary(context.workflowId) + const isDeployed = deploymentSummary.activeDeployment !== null + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + const needsRedeployment = + isDeployed && attemptStatus !== 'preparing' && attemptStatus !== 'activating' + ? await checkNeedsRedeployment(context.workflowId) + : false + return { + workflow: context.workflow, + workspaceId: context.workspaceId, + isDeployed, + needsRedeployment, + ...deploymentSummary, + } + }, +}) + +export const revertWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.revertVersion, + resolveContext: resolveWorkflowContext, + async execute({ principal, input, context }) { + const userId = requirePrincipalSubjectUserId(principal) + await requireMutableWorkflow(context.workflowId) + const result = await performRevertToVersion({ + workflowId: context.workflowId, + version: input.version, + userId, + actorId: userId, + workflow: context.workflow, + captureAnalytics: false, + projectLegacyAudit: false, + notifyRealtime: false, + }) + if (!result.success) throwDeploymentFailure(result, 'Failed to revert workflow version') + if (result.lastSaved === undefined) { + throw new Error('Successful workflow version revert returned no save timestamp') + } + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + version: input.version, + lastSaved: result.lastSaved, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `Reverted workflow to deployment version ${String(result.version)}`, + metadata: { targetVersion: String(result.version) }, + }), + afterSuccess: ({ result }) => notifyWorkflowReverted(result.workflowId, result.lastSaved), +}) + +export const updateWorkflowVersion = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.updateVersion, + resolveContext: resolveWorkflowContext, + async execute({ input, context }) { + const updated = await updateDeploymentVersionMetadata({ + workflowId: context.workflowId, + version: input.version, + name: input.name, + description: input.description, + }) + if (!updated) throw new OrchestrationError('not_found', 'Deployment version not found') + return { workflowId: context.workflowId, version: input.version, ...updated } + }, +}) diff --git a/apps/sim/lib/workflows/application/duplicate-workflow.ts b/apps/sim/lib/workflows/application/duplicate-workflow.ts new file mode 100644 index 00000000000..fd6f01cf884 --- /dev/null +++ b/apps/sim/lib/workflows/application/duplicate-workflow.ts @@ -0,0 +1,51 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { generateRequestId } from '@/lib/core/utils/request' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { duplicateWorkflow as duplicateWorkflowRecord } from '@/lib/workflows/persistence/duplicate' + +export interface DuplicateWorkflowInput { + sourceWorkflowId: string + assertedWorkspaceId?: string + folderId: string | null + name: string +} + +export const duplicateWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.duplicate, + resolveContext: ({ principal, input }: { principal: Principal; input: DuplicateWorkflowInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.sourceWorkflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + return db.transaction((tx) => + duplicateWorkflowRecord({ + sourceWorkflowId: context.workflowId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + folderId: input.folderId, + name: input.name, + requestId: generateRequestId(), + tx, + }) + ) + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.WORKFLOW_DUPLICATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.id, + resourceName: result.name, + description: `Duplicated workflow "${context.workflow.name}" as "${result.name}"`, + metadata: { sourceWorkflowId: context.workflowId, workspaceId: context.workspaceId }, + }), + afterSuccess: ({ result }) => notifyWorkflowUpdated(result.id), +}) diff --git a/apps/sim/lib/workflows/application/list-workflow-versions.ts b/apps/sim/lib/workflows/application/list-workflow-versions.ts index 86748c0c090..9c65a7ca53a 100644 --- a/apps/sim/lib/workflows/application/list-workflow-versions.ts +++ b/apps/sim/lib/workflows/application/list-workflow-versions.ts @@ -1,12 +1,16 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { isDeploymentOperationStatus } from '@/lib/workflows/deployment-lifecycle' import { listWorkflowVersions as listStoredWorkflowVersions } from '@/lib/workflows/persistence/utils' const logger = createLogger('ListWorkflowVersions') +const MAX_WORKFLOW_VERSION_PAGE_SIZE = 100 +const MAX_UNPAGINATED_WORKFLOW_VERSIONS = 1000 export interface ListWorkflowVersionsInput { workflowId: string @@ -29,12 +33,35 @@ export const listWorkflowVersions = defineAuthorizedWorkflowUseCase({ assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), }), async execute({ principal, input, context }) { + if ( + input.limit !== undefined && + (!Number.isInteger(input.limit) || + input.limit < 1 || + input.limit > MAX_WORKFLOW_VERSION_PAGE_SIZE) + ) { + throw new OrchestrationError( + 'validation', + `Workflow version page size must be between 1 and ${MAX_WORKFLOW_VERSION_PAGE_SIZE}` + ) + } + const resultLimit = input.limit ?? MAX_UNPAGINATED_WORKFLOW_VERSIONS const { versions } = await listStoredWorkflowVersions(context.workflowId, { - limit: input.limit === undefined ? undefined : input.limit + 1, + limit: resultLimit + 1, afterVersion: input.afterVersion, }) - const hasMore = input.limit !== undefined && versions.length > input.limit - const page = input.limit === undefined ? versions : versions.slice(0, input.limit) + if (input.limit === undefined && versions.length > MAX_UNPAGINATED_WORKFLOW_VERSIONS) { + throw new Error( + `Workflow version list exceeds the ${MAX_UNPAGINATED_WORKFLOW_VERSIONS} row limit` + ) + } + const hasMore = input.limit !== undefined && versions.length > resultLimit + const page = versions.slice(0, resultLimit).map((version) => { + const latestOperationStatus = version.latestOperationStatus + if (latestOperationStatus !== null && !isDeploymentOperationStatus(latestOperationStatus)) { + throw new Error('Deployment version contains an invalid operation status') + } + return { ...version, latestOperationStatus } + }) logger.info('Listed workflow versions', { workspaceId: context.workspaceId, workflowId: context.workflowId, diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts new file mode 100644 index 00000000000..d2a83f8eb61 --- /dev/null +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { FolderLockedError, WorkflowLockedError, mocks } = vi.hoisted(() => { + class WorkflowLockedError extends Error {} + class FolderLockedError extends Error {} + return { + WorkflowLockedError, + FolderLockedError, + mocks: { + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + audit: vi.fn(), + notify: vi.fn(), + permission: vi.fn(), + resolveContext: vi.fn(), + updateWorkflow: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UPDATED: 'workflow.updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => actual === required, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + updateWorkflowRecord: mocks.updateWorkflow, +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) + +import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' + +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +describe('moveWorkflowsBulk', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveContext.mockResolvedValue(context) + mocks.permission.mockResolvedValue('write') + mocks.assertFolderMutable.mockResolvedValue(undefined) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + }) + + it('returns bounded best-effort outcomes and audits only authoritative moves', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'One', folderId: null }, + { id: 'workflow-2', name: 'Two', folderId: null }, + ]) + dbChainMockFns.for + .mockResolvedValueOnce([{ id: 'workflow-1', name: 'One', folderId: null }]) + .mockResolvedValueOnce([{ id: 'workflow-2', name: 'Two', folderId: null }]) + mocks.updateWorkflow + .mockResolvedValueOnce({ + success: true, + workflow: { id: 'workflow-1', name: 'One', folderId: 'folder-1' }, + }) + .mockResolvedValueOnce({ success: false, error: 'Workflow is locked', errorCode: 'locked' }) + + const result = await moveWorkflowsBulk.execute({ + principal, + input: { + workspaceId: 'workspace-1', + workflowIds: ['workflow-1', 'workflow-2', 'workflow-1'], + folderId: 'folder-1', + }, + }) + + expect(result).toMatchObject({ + moved: ['workflow-1'], + failed: ['workflow-2'], + folderId: 'folder-1', + }) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ operation: 'workflows.bulk.move' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + expect(mocks.notify).not.toHaveBeenCalledWith('workflow-2') + }) + + it('conceals cross-workspace workflow IDs as failed items', async () => { + queueTableRows(schemaMock.workflow, []) + + await expect( + moveWorkflowsBulk.execute({ + principal, + input: { + workspaceId: 'workspace-1', + workflowIds: ['workflow-from-workspace-2'], + folderId: null, + }, + }) + ).resolves.toMatchObject({ + moved: [], + failed: ['workflow-from-workspace-2'], + }) + + expect(mocks.updateWorkflow).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects a non-Copilot principal before canonical workspace loading', async () => { + await expect( + moveWorkflowsBulk.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', workflowIds: ['workflow-1'], folderId: null }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.ts new file mode 100644 index 00000000000..62c6acc0e5c --- /dev/null +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.ts @@ -0,0 +1,166 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { updateWorkflowRecord } from '@/lib/workflows/orchestration' + +const MAX_BULK_WORKFLOW_MOVES = 100 + +export interface MoveWorkflowsBulkInput { + workspaceId: string + workflowIds: string[] + folderId: string | null +} + +interface MovedWorkflow { + id: string + name: string + previousFolderId: string | null +} + +export interface MoveWorkflowsBulkResult { + moved: string[] + failed: string[] + folderId: string | null + changes: MovedWorkflow[] +} + +function normalizeWorkflowIds(workflowIds: readonly string[]): string[] { + const normalized = [...new Set(workflowIds.filter((id) => id.length > 0))] + if (normalized.length === 0) { + throw new OrchestrationError('validation', 'workflowIds is required') + } + if (normalized.length > MAX_BULK_WORKFLOW_MOVES) { + throw new OrchestrationError( + 'validation', + `Workflow moves cannot exceed ${MAX_BULK_WORKFLOW_MOVES} items` + ) + } + return normalized +} + +function requireMutable(workflowId: string, folderId: string | null): Promise { + return Promise.all([assertWorkflowMutable(workflowId), assertFolderMutable(folderId)]) + .then(() => undefined) + .catch((error: unknown) => { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + }) +} + +export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.moveBulk, + resolveContext: ({ input }: { input: MoveWorkflowsBulkInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const workflowIds = normalizeWorkflowIds(input.workflowIds) + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId, + }) + .from(workflow) + .where( + and( + inArray(workflow.id, workflowIds), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + const byId = new Map(rows.map((row) => [row.id, row])) + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const moved: string[] = [] + const failed: string[] = [] + const changes: MovedWorkflow[] = [] + + for (const workflowId of workflowIds) { + const indexed = byId.get(workflowId) + if (!indexed) { + failed.push(workflowId) + continue + } + + try { + await requireMutable(workflowId, input.folderId) + const changed = await db.transaction(async (tx) => { + const [current] = await tx + .select({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId, + }) + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!current) throw new OrchestrationError('not_found', 'Workflow not found') + + const transition = await updateWorkflowRecord({ + workflowId, + userId: attribution.attributedUserId, + workspaceId: context.workspaceId, + currentName: current.name, + currentFolderId: current.folderId, + folderId: input.folderId, + tx, + }) + requireWorkflowTransition(transition, 'Failed to move workflow') + return current + }) + moved.push(workflowId) + changes.push({ + id: workflowId, + name: changed.name, + previousFolderId: changed.folderId, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (!classified || classified.code === 'internal') throw error + failed.push(workflowId) + } + } + + return { moved, failed, folderId: input.folderId, changes } + }, + projectAudit: ({ result }) => + result.changes.map((change) => ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Moved workflow "${change.name}"`, + metadata: { + previousFolderId: change.previousFolderId, + folderId: result.folderId, + }, + })), + afterSuccess: async ({ result }) => { + for (const workflowId of result.moved) { + await notifyWorkflowUpdated(workflowId) + } + }, +}) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 8150e81a21d..5cfae2226f7 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -5,11 +5,21 @@ const ALL_WORKFLOW_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +const WORKFLOW_READ_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const + const HUMAN_WORKFLOW_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'delegated'], delegatedServices: ['copilot'], } as const +const COPILOT_WORKFLOW_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const + export const workflowOperations = { list: defineWorkspaceOperation({ id: 'workflows.list', @@ -21,7 +31,31 @@ export const workflowOperations = { id: 'workflows.read', minimumRole: 'read', workspaceApiKey: 'allow', - ...ALL_WORKFLOW_PRINCIPAL_POLICY, + ...WORKFLOW_READ_PRINCIPAL_POLICY, + }), + readDeploymentOverview: defineWorkspaceOperation({ + id: 'workflows.deployment_overview.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + readCopilotRunOptions: defineWorkspaceOperation({ + id: 'workflows.copilot.run_options.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + readCopilotBlockOutputs: defineWorkspaceOperation({ + id: 'workflows.copilot.block_outputs.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + readCopilotUpstreamReferences: defineWorkspaceOperation({ + id: 'workflows.copilot.upstream_references.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'workflows.create', @@ -35,6 +69,85 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + updatePolicy: defineWorkspaceOperation({ + id: 'workflows.policy.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + applyVariableOperations: defineWorkspaceOperation({ + id: 'workflows.variables.apply_operations', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + setBlockEnabled: defineWorkspaceOperation({ + id: 'workflows.blocks.set_enabled', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + moveBulk: defineWorkspaceOperation({ + id: 'workflows.bulk.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + createVfsFolders: defineWorkspaceOperation({ + id: 'workflows.vfs.folders.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + moveVfsItems: defineWorkspaceOperation({ + id: 'workflows.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + copyVfsItems: defineWorkspaceOperation({ + id: 'workflows.vfs.copy', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + deleteVfsItems: defineWorkspaceOperation({ + id: 'workflows.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + duplicate: defineWorkspaceOperation({ + id: 'workflows.duplicate', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), + runFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + runUntilFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run_until', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + runFromBlockFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run_from_block', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), + runBlockFromCopilot: defineWorkspaceOperation({ + id: 'workflows.copilot.run_block', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), delete: defineWorkspaceOperation({ id: 'workflows.delete', minimumRole: 'write', @@ -77,12 +190,42 @@ export const workflowOperations = { workspaceApiKey: 'deny', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + deployChat: defineWorkspaceOperation({ + id: 'workflows.chat.deploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + undeployChat: defineWorkspaceOperation({ + id: 'workflows.chat.undeploy', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + updatePublicApi: defineWorkspaceOperation({ + id: 'workflows.public_api.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', minimumRole: 'admin', workspaceApiKey: 'deny', ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), + revertVersion: defineWorkspaceOperation({ + id: 'workflows.versions.revert', + minimumRole: 'admin', + workspaceApiKey: 'deny', + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, + }), + updateVersion: defineWorkspaceOperation({ + id: 'workflows.versions.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), listVersions: defineWorkspaceOperation({ id: 'workflows.versions.list', minimumRole: 'read', @@ -95,6 +238,12 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + compareReferences: defineWorkspaceOperation({ + id: 'workflows.versions.compare_references', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + }), export: defineWorkspaceOperation({ id: 'workflows.export', minimumRole: 'read', diff --git a/apps/sim/lib/workflows/application/principal-scope.ts b/apps/sim/lib/workflows/application/principal-scope.ts index d5e70577ee9..807bd095e39 100644 --- a/apps/sim/lib/workflows/application/principal-scope.ts +++ b/apps/sim/lib/workflows/application/principal-scope.ts @@ -4,8 +4,8 @@ export function assertedWorkflowWorkspaceId( principal: Principal, assertedWorkspaceId?: string ): string | undefined { - return ( - assertedWorkspaceId ?? - (principal.kind === 'workspace_api_key' ? principal.workspaceId : undefined) - ) + if (principal.kind === 'workspace_api_key' || principal.kind === 'delegated') { + return principal.workspaceId + } + return assertedWorkspaceId } diff --git a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts new file mode 100644 index 00000000000..3510e4ddb4d --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + loadDraft: vi.fn(), + getBlock: vi.fn(), + outputPaths: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.loadDraft, +})) + +vi.mock('@/blocks/registry', () => ({ getBlock: mocks.getBlock })) + +vi.mock('@/lib/workflows/blocks/block-outputs', () => ({ + getEffectiveBlockOutputPaths: mocks.outputPaths, +})) + +vi.mock('@/lib/workflows/blocks/block-path-calculator', () => ({ + BlockPathCalculator: { findAllPathNodes: vi.fn().mockReturnValue([]) }, +})) + +vi.mock('@/lib/workflows/blocks/block-reference-tags', () => ({ + getBlockReferenceTags: vi.fn().mockReturnValue([]), +})) + +vi.mock('@/lib/workflows/triggers/run-options', () => ({ + resolveTriggerRunOptions: vi.fn().mockReturnValue([]), + toPublicRunOption: vi.fn((value) => value), +})) + +vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ + hasTriggerCapability: vi.fn().mockReturnValue(false), +})) + +import { readCopilotWorkflowBlockOutputs } from '@/lib/workflows/application/read-workflow-copilot-metadata' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2999-08-01T00:00:00Z'), +} + +describe('Copilot workflow metadata application queries', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue({ + workflowId: 'workflow-1', + workflow: { + id: 'workflow-1', + workspaceId: 'workspace-1', + variables: { + variable1: { id: 'variable-1', name: 'Customer Name', type: 'plain' }, + }, + }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + mocks.loadDraft.mockResolvedValue({ + blocks: { + 'agent-1': { type: 'agent', name: 'Support Agent', subBlocks: {} }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + mocks.getBlock.mockReturnValue({ category: 'core' }) + mocks.outputPaths.mockReturnValue(['content']) + }) + + it('owns canonical loading and block output computation', async () => { + const result = await readCopilotWorkflowBlockOutputs.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'forged-workspace', + blockIds: ['agent-1'], + }, + }) + + expect(mocks.resolveContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(result).toEqual({ + blocks: [ + { + blockId: 'agent-1', + blockName: 'Support Agent', + blockType: 'agent', + outputs: ['supportagent.content'], + relativeOutputs: ['content'], + triggerMode: undefined, + }, + ], + variables: [ + { + id: 'variable-1', + name: 'Customer Name', + type: 'plain', + tag: 'variable.customername', + }, + ], + }) + }) + + it('rechecks current permission before loading workflow state', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + readCopilotWorkflowBlockOutputs.execute({ + principal, + input: { workflowId: 'workflow-1', blockIds: ['agent-1'] }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadDraft).not.toHaveBeenCalled() + }) + + it('rejects oversized block selections before loading workflow state', async () => { + await expect( + readCopilotWorkflowBlockOutputs.execute({ + principal, + input: { + workflowId: 'workflow-1', + blockIds: Array.from({ length: 101 }, (_, index) => `block-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.loadDraft).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts new file mode 100644 index 00000000000..ef6c2af4151 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts @@ -0,0 +1,294 @@ +import type { Principal } from '@sim/auth/principal' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import type { Loop, Parallel } from '@sim/workflow-types/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +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 { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' +import { getBlock } from '@/blocks/registry' +import { normalizeName } from '@/executor/constants' + +const MAX_COPILOT_BLOCK_IDS = 100 + +interface CopilotWorkflowQueryInput { + workflowId: string + assertedWorkspaceId?: string +} + +interface WorkflowVariableReference { + id: string + name: string + type: string + tag: string +} + +interface AccessibleBlockEntry { + blockId: string + blockName: string + blockType: string + outputs: string[] + triggerMode?: boolean + accessContext?: 'inside' | 'outside' +} + +function resolveWorkflowContext({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +async function loadDraftWorkflow(workflowId: string) { + const state = await loadWorkflowFromNormalizedTables(workflowId) + if (!state) throw new OrchestrationError('not_found', 'Workflow has no saved state') + return state +} + +function workflowVariables(value: unknown): WorkflowVariableReference[] { + const variablesRecord = (value as Record) || {} + return Object.values(variablesRecord) + .filter((variable): variable is Record => { + if (!variable || typeof variable !== 'object') return false + const record = variable as Record + return Boolean(record.name && String(record.name).trim()) + }) + .map((variable) => ({ + id: String(variable.id || ''), + name: String(variable.name || ''), + type: String(variable.type || 'plain'), + tag: `variable.${normalizeName(String(variable.name || ''))}`, + })) +} + +function subflowInsidePaths( + blockType: 'loop' | 'parallel', + blockId: string, + loops: Record, + parallels: Record +): string[] { + const paths = ['index'] + if (blockType === 'loop') { + if ((loops[blockId]?.loopType || 'for') === 'forEach') paths.push('currentItem', 'items') + } else if ((parallels[blockId]?.parallelType || 'count') === 'collection') { + paths.push('currentItem', 'items') + } + return paths +} + +function displayOutputs(paths: string[], blockName: string): string[] { + const normalizedName = normalizeName(blockName) + return paths.map((path) => `${normalizedName}.${path}`) +} + +function assertBlockIdBound(blockIds: string[]): void { + if (blockIds.length > MAX_COPILOT_BLOCK_IDS) { + throw new OrchestrationError( + 'validation', + `blockIds cannot contain more than ${MAX_COPILOT_BLOCK_IDS} entries` + ) + } +} + +export interface ReadCopilotWorkflowRunOptionsInput extends CopilotWorkflowQueryInput {} + +export const readCopilotWorkflowRunOptions = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readCopilotRunOptions, + resolveContext: resolveWorkflowContext, + async execute({ context }) { + const state = await loadDraftWorkflow(context.workflowId) + const merged = mergeSubblockStateWithValues(state.blocks) + const options = resolveTriggerRunOptions(merged, state.edges) + return { + options: options.map((option) => toPublicRunOption(option)), + } + }, +}) + +export interface ReadCopilotWorkflowBlockOutputsInput extends CopilotWorkflowQueryInput { + blockIds?: string[] +} + +export const readCopilotWorkflowBlockOutputs = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readCopilotBlockOutputs, + resolveContext: resolveWorkflowContext, + async execute({ input, context }) { + if (input.blockIds) assertBlockIdBound(input.blockIds) + const state = await loadDraftWorkflow(context.workflowId) + const blocks = state.blocks || {} + const loops = (state.loops || {}) as Record + const parallels = (state.parallels || {}) as Record + const blockIds = input.blockIds?.length ? input.blockIds : Object.keys(blocks) + assertBlockIdBound(blockIds) + + const results = [] + 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 = subflowInsidePaths(block.type, blockId, loops, parallels) + results.push({ + blockId, + blockName, + blockType: block.type, + outputs: [], + relativeOutputs: [], + insideSubflowOutputs: displayOutputs(insidePaths, blockName), + outsideSubflowOutputs: displayOutputs(['results'], blockName), + relativeInsideSubflowOutputs: insidePaths, + relativeOutsideSubflowOutputs: ['results'], + triggerMode: block.triggerMode, + }) + continue + } + + const blockConfig = getBlock(block.type) + const triggerMode = Boolean( + block.triggerMode && blockConfig && hasTriggerCapability(blockConfig) + ) + const outputs = getEffectiveBlockOutputPaths(block.type, block.subBlocks, { + triggerMode, + preferToolOutputs: !triggerMode, + }) + results.push({ + blockId, + blockName, + blockType: block.type, + outputs: displayOutputs(outputs, blockName), + relativeOutputs: outputs, + triggerMode: block.triggerMode, + }) + } + + return { blocks: results, variables: workflowVariables(context.workflow.variables) } + }, +}) + +export interface ReadCopilotWorkflowUpstreamReferencesInput extends CopilotWorkflowQueryInput { + blockIds: string[] +} + +export const readCopilotWorkflowUpstreamReferences = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readCopilotUpstreamReferences, + resolveContext: resolveWorkflowContext, + async execute({ input, context }) { + assertBlockIdBound(input.blockIds) + const state = await loadDraftWorkflow(context.workflowId) + const blocks = state.blocks || {} + const loops = (state.loops || {}) as Record + const parallels = (state.parallels || {}) as Record + const graphEdges = (state.edges || []).map((edge) => ({ + source: edge.source, + target: edge.target, + })) + const variables = workflowVariables(context.workflow.variables) + const results = [] + + for (const blockId of input.blockIds) { + const targetBlock = blocks[blockId] + if (!targetBlock) continue + + const insideSubflows: Array<{ blockId: string; blockName: string; blockType: string }> = [] + const containingLoopIds = new Set() + const containingParallelIds = new Set() + + for (const loop of Object.values(loops)) { + if (!loop?.nodes?.includes(blockId)) continue + containingLoopIds.add(loop.id) + const loopBlock = blocks[loop.id] + if (loopBlock) { + insideSubflows.push({ + blockId: loop.id, + blockName: loopBlock.name || loopBlock.type, + blockType: 'loop', + }) + } + } + + for (const parallel of Object.values(parallels)) { + if (!parallel?.nodes?.includes(blockId)) continue + containingParallelIds.add(parallel.id) + const parallelBlock = blocks[parallel.id] + if (parallelBlock) { + insideSubflows.push({ + blockId: parallel.id, + blockName: parallelBlock.name || parallelBlock.type, + blockType: 'parallel', + }) + } + } + + const accessibleIds = new Set(BlockPathCalculator.findAllPathNodes(graphEdges, blockId)) + accessibleIds.add(blockId) + for (const loopId of containingLoopIds) accessibleIds.add(loopId) + for (const parallelId of containingParallelIds) 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 outputs: 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' + outputs = displayOutputs( + isInside + ? subflowInsidePaths(block.type, accessibleBlockId, loops, parallels) + : ['results'], + blockName + ) + } else { + outputs = getBlockReferenceTags({ + block: { + id: accessibleBlockId, + type: block.type, + name: block.name, + triggerMode: block.triggerMode, + subBlocks: block.subBlocks, + }, + currentBlockId: blockId, + }) + } + accessibleBlocks.push({ + blockId: accessibleBlockId, + blockName, + blockType: block.type, + outputs, + ...(block.triggerMode ? { triggerMode: true } : {}), + ...(accessContext ? { accessContext } : {}), + }) + } + + results.push({ + blockId, + blockName: targetBlock.name || targetBlock.type, + blockType: targetBlock.type, + accessibleBlocks, + insideSubflows, + variables, + }) + } + + return { results } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-definition.ts b/apps/sim/lib/workflows/application/read-workflow-definition.ts new file mode 100644 index 00000000000..0969b0ad563 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-definition.ts @@ -0,0 +1,56 @@ +import type { Principal } from '@sim/auth/principal' +import type { NormalizedWorkflowData } from '@sim/workflow-persistence/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + type DeployedWorkflowData, + loadDeployedWorkflowState, + loadWorkflowFromNormalizedTables, + NoActiveDeploymentError, +} from '@/lib/workflows/persistence/utils' + +export interface ReadWorkflowDefinitionInput { + workflowId: string + assertedWorkspaceId?: string + state: 'draft' | 'deployed' +} + +export interface ReadWorkflowDefinitionResult { + workflow: Awaited>['workflow'] + workspaceId: string + state: NormalizedWorkflowData | DeployedWorkflowData | null +} + +async function loadDefinition(input: ReadWorkflowDefinitionInput, workspaceId: string) { + if (input.state === 'draft') return loadWorkflowFromNormalizedTables(input.workflowId) + try { + return await loadDeployedWorkflowState(input.workflowId, workspaceId) + } catch (error) { + if (error instanceof NoActiveDeploymentError) return null + throw error + } +} + +export const readWorkflowDefinition = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReadWorkflowDefinitionInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ input, context }): Promise { + return { + workflow: context.workflow, + workspaceId: context.workspaceId, + state: await loadDefinition(input, context.workspaceId), + } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts new file mode 100644 index 00000000000..6861fdf160d --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + deploymentSummary: vi.fn(), + loadWorkspace: vi.fn(), + permission: vi.fn(), + redeployment: vi.fn(), + }, +})) + +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.redeployment, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + getWorkflowDeploymentSummary: mocks.deploymentSummary, +})) + +import { + MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES, + MAX_WORKFLOW_MCP_STATUS_TOOLS, + MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES, + readWorkflowDeploymentOverview, +} from '@/lib/workflows/application/read-workflow-deployment-overview' + +const workflowRecord = { + id: 'workflow-1', + workspaceId: 'workspace-1', + name: 'Workflow', + archivedAt: null, +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +describe('readWorkflowDeploymentOverview', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.permission.mockResolvedValue('read') + mocks.deploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + mocks.redeployment.mockResolvedValue(false) + }) + + it('caps workflow MCP status rows and reports truncation', async () => { + queueTableRows(schemaMock.workflow, [ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + queueTableRows(schemaMock.chat, []) + queueTableRows( + schemaMock.workflowMcpTool, + Array.from({ length: MAX_WORKFLOW_MCP_STATUS_TOOLS + 1 }, (_, index) => ({ + serverId: `server-${index}`, + serverName: `Server ${index}`, + toolName: `tool_${index}`, + toolDescription: null, + parameterSchema: {}, + parameterSchemaBytes: 2, + toolId: `tool-${index}`, + })) + ) + + const result = await readWorkflowDeploymentOverview.execute({ + principal, + input: { workflowId: workflowRecord.id }, + }) + + expect(result.mcpTools).toHaveLength(MAX_WORKFLOW_MCP_STATUS_TOOLS) + expect(result.mcpToolsTruncated).toBe(true) + }) + + it('truncates schema materialization at individual and aggregate byte budgets', async () => { + queueTableRows(schemaMock.workflow, [ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + queueTableRows(schemaMock.chat, []) + const aggregateRows = Array.from( + { + length: MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES / MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES, + }, + (_, index) => ({ + serverId: 'server-1', + serverName: 'Server 1', + toolName: `within-budget-${index}`, + toolDescription: null, + parameterSchema: { type: 'object' }, + parameterSchemaBytes: MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES, + toolId: `tool-${index + 2}`, + }) + ) + queueTableRows(schemaMock.workflowMcpTool, [ + { + serverId: 'server-1', + serverName: 'Server 1', + toolName: 'oversized', + toolDescription: null, + parameterSchema: null, + parameterSchemaBytes: MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES + 1, + toolId: 'tool-1', + }, + ...aggregateRows, + { + serverId: 'server-1', + serverName: 'Server 1', + toolName: 'past-budget', + toolDescription: null, + parameterSchema: { type: 'object' }, + parameterSchemaBytes: 1, + toolId: 'tool-last', + }, + ]) + + const result = await readWorkflowDeploymentOverview.execute({ + principal, + input: { workflowId: workflowRecord.id }, + }) + + expect(result.mcpTools).toHaveLength(1 + aggregateRows.length) + expect(result.mcpTools[0].parameterSchema).toEqual({ + truncated: true, + bytes: MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES + 1, + }) + expect(result.mcpToolsTruncated).toBe(true) + }) + + it('rejects a cross-workspace assertion before protected status loads', async () => { + queueTableRows(schemaMock.workflow, [ + { + workflowId: workflowRecord.id, + workflow: workflowRecord, + workspaceId: workflowRecord.workspaceId, + }, + ]) + + await expect( + readWorkflowDeploymentOverview.execute({ + principal: { ...principal, workspaceId: 'workspace-2' }, + input: { workflowId: workflowRecord.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.deploymentSummary).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-deployment-overview.ts b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.ts new file mode 100644 index 00000000000..832d741adc1 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-deployment-overview.ts @@ -0,0 +1,130 @@ +import type { Principal } from '@sim/auth/principal' +import { chat, db, workflowMcpServer, workflowMcpTool } from '@sim/db' +import { and, asc, eq, isNull, sql } from 'drizzle-orm' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { getWorkflowDeploymentSummary } from '@/lib/workflows/orchestration' + +export const MAX_WORKFLOW_MCP_STATUS_TOOLS = 100 +export const MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES = 64 * 1024 +export const MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES = 1024 * 1024 + +export interface ReadWorkflowDeploymentOverviewInput { + workflowId: string + assertedWorkspaceId?: string +} + +function resolveWorkflowContext({ + principal, + input, +}: { + principal: Principal + input: ReadWorkflowDeploymentOverviewInput +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +export const readWorkflowDeploymentOverview = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readDeploymentOverview, + resolveContext: resolveWorkflowContext, + async execute({ context }) { + const [deploymentSummary, chatDeploy, mcpRows] = await Promise.all([ + getWorkflowDeploymentSummary(context.workflowId), + db + .select({ + id: chat.id, + identifier: chat.identifier, + title: chat.title, + description: chat.description, + authType: chat.authType, + allowedEmails: chat.allowedEmails, + outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, + includeToolCalls: chat.includeToolCalls, + password: chat.password, + customizations: chat.customizations, + }) + .from(chat) + .where(and(eq(chat.workflowId, context.workflowId), isNull(chat.archivedAt))) + .limit(1), + db + .select({ + serverId: workflowMcpServer.id, + serverName: workflowMcpServer.name, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchema: sql`CASE + WHEN COALESCE(octet_length(${workflowMcpTool.parameterSchema}::text), 0) + <= ${MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES} + THEN ${workflowMcpTool.parameterSchema} + ELSE NULL + END`, + parameterSchemaBytes: + sql`COALESCE(octet_length(${workflowMcpTool.parameterSchema}::text), 0)`.mapWith( + Number + ), + toolId: workflowMcpTool.id, + }) + .from(workflowMcpTool) + .innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id)) + .where( + and( + eq(workflowMcpTool.workflowId, context.workflowId), + isNull(workflowMcpTool.archivedAt), + isNull(workflowMcpServer.deletedAt) + ) + ) + .orderBy(asc(workflowMcpServer.id), asc(workflowMcpTool.toolName)) + .limit(MAX_WORKFLOW_MCP_STATUS_TOOLS + 1), + ]) + + const isDeployed = deploymentSummary.activeDeployment !== null + const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status + const needsRedeployment = + isDeployed && attemptStatus !== 'preparing' && attemptStatus !== 'activating' + ? await checkNeedsRedeployment(context.workflowId) + : false + let schemaBytes = 0 + let mcpToolsTruncated = mcpRows.length > MAX_WORKFLOW_MCP_STATUS_TOOLS + const mcpTools = [] + for (const row of mcpRows.slice(0, MAX_WORKFLOW_MCP_STATUS_TOOLS)) { + if (!Number.isFinite(row.parameterSchemaBytes) || row.parameterSchemaBytes < 0) { + throw new Error('Workflow MCP status query returned an invalid schema byte count') + } + const schemaOversized = row.parameterSchemaBytes > MAX_WORKFLOW_MCP_STATUS_SCHEMA_BYTES + if ( + !schemaOversized && + schemaBytes + row.parameterSchemaBytes > MAX_WORKFLOW_MCP_STATUS_TOTAL_SCHEMA_BYTES + ) { + mcpToolsTruncated = true + break + } + if (!schemaOversized) schemaBytes += row.parameterSchemaBytes + if (schemaOversized) mcpToolsTruncated = true + const { parameterSchemaBytes: _parameterSchemaBytes, ...tool } = row + mcpTools.push({ + ...tool, + parameterSchema: schemaOversized + ? { truncated: true, bytes: row.parameterSchemaBytes } + : row.parameterSchema, + }) + } + + return { + workflow: context.workflow, + workspaceId: context.workspaceId, + isDeployed, + needsRedeployment, + ...deploymentSummary, + chatDeployment: chatDeploy[0] ?? null, + mcpTools, + mcpToolsTruncated, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-state-references.ts b/apps/sim/lib/workflows/application/read-workflow-state-references.ts new file mode 100644 index 00000000000..6a33cf01f98 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-state-references.ts @@ -0,0 +1,72 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + getWorkflowDeploymentVersion, + loadWorkflowFromNormalizedTables, +} from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +export type WorkflowStateReference = number | 'live' | 'draft' + +export interface ResolvedWorkflowStateReference { + state: WorkflowState + ref: string + version?: number + isActive?: boolean + createdAt?: string +} + +export interface ReadWorkflowStateReferencesInput { + workflowId: string + assertedWorkspaceId?: string + references: [WorkflowStateReference, WorkflowStateReference] +} + +async function loadReference( + workflowId: string, + reference: WorkflowStateReference +): Promise { + if (reference === 'draft') { + const state = await loadWorkflowFromNormalizedTables(workflowId) + if (!state) throw new OrchestrationError('not_found', 'Workflow has no draft state') + return { state: state as WorkflowState, ref: 'draft' } + } + + const row = await getWorkflowDeploymentVersion( + workflowId, + reference === 'live' ? 'active' : reference + ) + if (!row?.state) throw new OrchestrationError('not_found', 'Deployment version not found') + return { + state: row.state as WorkflowState, + ref: reference === 'live' ? 'live' : String(reference), + version: row.version, + isActive: row.isActive, + createdAt: row.createdAt?.toISOString(), + } +} + +export const readWorkflowStateReferences = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.compareReferences, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReadWorkflowStateReferencesInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ input, context }) { + const [first, second] = await Promise.all( + input.references.map((reference) => loadReference(context.workflowId, reference)) + ) + return { references: [first, second] as const } + }, +}) diff --git a/apps/sim/lib/workflows/application/read-workflow-version.ts b/apps/sim/lib/workflows/application/read-workflow-version.ts index bec3ac131af..143aae63cdd 100644 --- a/apps/sim/lib/workflows/application/read-workflow-version.ts +++ b/apps/sim/lib/workflows/application/read-workflow-version.ts @@ -6,13 +6,18 @@ import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/applica import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('ReadWorkflowVersion') +function isWorkflowState(value: unknown): value is WorkflowState { + return typeof value === 'object' && value !== null +} + export interface ReadWorkflowVersionInput { workflowId: string assertedWorkspaceId?: string - version: number + version: number | 'active' } export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({ @@ -33,12 +38,16 @@ export const readWorkflowVersion = defineAuthorizedWorkflowUseCase({ if (!version?.state) { throw new OrchestrationError('not_found', 'Deployment version not found') } + const state = version.state + if (!isWorkflowState(state)) { + throw new Error('Deployment version contains invalid workflow state') + } logger.info('Read workflow version', { workspaceId: context.workspaceId, workflowId: context.workflowId, version: input.version, principalKind: principal.kind, }) - return { version } + return { version: { ...version, state } } }, }) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts new file mode 100644 index 00000000000..18755a4f70f --- /dev/null +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -0,0 +1,243 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => ({ + mocks: { + admission: vi.fn(), + executeWorkflow: vi.fn(), + latestState: vi.fn(), + loadDeployed: vi.fn(), + loadDraft: vi.fn(), + permission: vi.fn(), + resolveContext: vi.fn(), + resolveOptions: vi.fn(), + sourceState: vi.fn(), + validateInput: vi.fn(), + }, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/execution-admission', () => ({ + prepareWorkflowExecutionAdmission: mocks.admission, +})) + +vi.mock('@/lib/workflows/executor/execution-state', () => ({ + getExecutionInputForWorkflow: vi.fn(), + getExecutionStateForWorkflow: mocks.sourceState, + getLatestExecutionStateWithExecutionId: mocks.latestState, +})) + +vi.mock('@/lib/workflows/executor/execute-workflow', () => ({ + executeWorkflow: mocks.executeWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadDeployedWorkflowState: mocks.loadDeployed, + loadWorkflowFromNormalizedTables: mocks.loadDraft, + NoActiveDeploymentError: class NoActiveDeploymentError extends Error {}, +})) + +vi.mock('@/lib/workflows/triggers/run-options', () => ({ + resolveTriggerRunOptions: mocks.resolveOptions, + validateTriggerInput: mocks.validateInput, +})) + +vi.mock('@sim/workflow-persistence/subblocks', () => ({ + mergeSubblockStateWithValues: vi.fn((blocks) => blocks), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'child-execution-1') })) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn(() => 'request-1') })) + +import { + runFromBlockFromCopilot, + runWorkflowFromCopilot, +} from '@/lib/workflows/application/run-workflow-from-copilot' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +const context = { + workflowId: 'workflow-1', + workflow: { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'workspace-1', + variables: {}, + }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const lifecycle = {} + +describe('Copilot workflow run application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.permission.mockResolvedValue('write') + mocks.loadDraft.mockResolvedValue({ blocks: { trigger: {} }, edges: [] }) + mocks.resolveOptions.mockReturnValue([ + { triggerBlockId: 'trigger', blockName: 'Start', mockPayload: { source: 'mock' } }, + ]) + mocks.validateInput.mockReturnValue({ ok: true }) + mocks.admission.mockResolvedValue({ billingAttribution: undefined, targetReservation: false }) + mocks.executeWorkflow.mockResolvedValue({ success: true, output: { ok: true }, logs: [] }) + }) + + it('owns canonical authorization, trigger selection, admission, and execution', async () => { + const result = await runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + + expect(result).toMatchObject({ success: true, output: { ok: true } }) + expect(mocks.resolveContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.permission).toHaveBeenCalledBefore(mocks.loadDraft) + expect(mocks.admission).toHaveBeenCalledWith( + { userId: 'user-1', billingAttribution: undefined }, + 'workspace-1', + 'child-execution-1' + ) + expect(mocks.executeWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ id: 'workflow-1' }), + 'request-1', + { source: 'mock' }, + 'user-1', + expect.objectContaining({ + useDraftState: true, + workflowTriggerType: 'copilot', + triggerBlockId: 'trigger', + }), + 'child-execution-1' + ) + }) + + it('rechecks current permission before loading execution state', async () => { + mocks.permission.mockResolvedValueOnce(null) + + await expect( + runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadDraft).not.toHaveBeenCalled() + expect(mocks.executeWorkflow).not.toHaveBeenCalled() + }) + + it('fails before execution when the selected durable definition is absent', async () => { + mocks.loadDraft.mockResolvedValueOnce(null) + + await expect( + runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.admission).not.toHaveBeenCalled() + }) + + it('owns canonical source snapshot lineage for run-from-block', async () => { + const snapshot = { + blockStates: {}, + executedBlocks: [], + blockLogs: [], + decisions: {}, + completedLoops: [], + activeExecutionPath: [], + } + mocks.sourceState.mockResolvedValueOnce(snapshot) + + await runFromBlockFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + blockId: 'agent-1', + sourceExecutionId: 'source-execution-1', + }, + }) + + expect(mocks.executeWorkflow).toHaveBeenCalledWith( + expect.any(Object), + 'request-1', + undefined, + 'user-1', + expect.objectContaining({ + runFromBlock: { + startBlockId: 'agent-1', + sourceSnapshot: snapshot, + sourceExecutionId: 'source-execution-1', + }, + }), + 'child-execution-1' + ) + }) + + it('propagates unexpected execution infrastructure failures', async () => { + mocks.executeWorkflow.mockRejectedValueOnce(new Error('database unavailable')) + + await expect( + runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + ).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts new file mode 100644 index 00000000000..568b9a31c66 --- /dev/null +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -0,0 +1,358 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { prepareWorkflowExecutionAdmission } from '@/lib/workflows/execution-admission' +import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' +import { + getExecutionInputForWorkflow, + getExecutionStateForWorkflow, + getLatestExecutionStateWithExecutionId, +} from '@/lib/workflows/executor/execution-state' +import { + loadDeployedWorkflowState, + loadWorkflowFromNormalizedTables, + NoActiveDeploymentError, +} from '@/lib/workflows/persistence/utils' +import { + resolveTriggerRunOptions, + validateTriggerInput, +} from '@/lib/workflows/triggers/run-options' +import type { SerializableExecutionState } from '@/executor/execution/types' +import type { ExecutionResult } from '@/executor/types' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +export interface CopilotWorkflowRunLifecycle { + billingAttribution?: BillingAttributionSnapshot + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + abortSignal?: AbortSignal +} + +interface BaseCopilotRunInput { + workflowId: string + assertedWorkspaceId?: string + useDraftState: boolean + lifecycle: CopilotWorkflowRunLifecycle +} + +interface TriggerCopilotRunInput extends BaseCopilotRunInput { + triggerBlockId?: string + workflowInput?: unknown + hasWorkflowInput: boolean + useMockPayload: boolean + inputFromExecutionId?: string +} + +export interface RunWorkflowFromCopilotInput extends TriggerCopilotRunInput {} + +export interface RunWorkflowUntilBlockFromCopilotInput extends TriggerCopilotRunInput { + stopAfterBlockId: string +} + +interface SnapshotCopilotRunInput extends BaseCopilotRunInput { + blockId: string + workflowInput?: unknown + sourceExecutionId?: string +} + +export interface RunFromBlockFromCopilotInput extends SnapshotCopilotRunInput {} +export interface RunBlockFromCopilotInput extends SnapshotCopilotRunInput {} + +function resolveContext({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +async function loadDefinition(input: BaseCopilotRunInput, workspaceId: string) { + if (input.useDraftState) return loadWorkflowFromNormalizedTables(input.workflowId) + try { + return await loadDeployedWorkflowState(input.workflowId, workspaceId) + } catch (error) { + if (error instanceof NoActiveDeploymentError) return null + throw error + } +} + +async function resolveTriggerExecution(params: { + input: TriggerCopilotRunInput + workspaceId: string +}): Promise<{ triggerBlockId: string; input: unknown }> { + const state = await loadDefinition(params.input, params.workspaceId) + if (!state?.blocks) { + throw new OrchestrationError( + 'validation', + `Workflow ${params.input.workflowId} has no ${params.input.useDraftState ? 'saved draft' : 'deployed'} state to run.` + ) + } + const merged = mergeSubblockStateWithValues(state.blocks) + const options = resolveTriggerRunOptions(merged, state.edges) + if (options.length === 0) { + throw new OrchestrationError( + 'validation', + 'No runnable trigger found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.' + ) + } + const listTriggers = () => + options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') + let option = options[0] + if (params.input.triggerBlockId) { + const selected = options.find( + (candidate) => candidate.triggerBlockId === params.input.triggerBlockId + ) + if (!selected) { + throw new OrchestrationError( + 'validation', + `triggerBlockId "${params.input.triggerBlockId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. Call get_workflow_run_options to inspect them.` + ) + } + option = selected + } else if (options.length > 1) { + throw new OrchestrationError( + 'validation', + `This workflow has multiple triggers — pass triggerBlockId to choose one: ${listTriggers()}. Call get_workflow_run_options for each trigger's input shape.` + ) + } + + const sourceCount = + (params.input.hasWorkflowInput ? 1 : 0) + + (params.input.useMockPayload ? 1 : 0) + + (params.input.inputFromExecutionId ? 1 : 0) + if (sourceCount > 1) { + throw new OrchestrationError( + 'validation', + 'Provide only one input source: workflow_input, useMockPayload: true, or inputFromExecutionId.' + ) + } + if (params.input.useMockPayload) { + return { triggerBlockId: option.triggerBlockId, input: option.mockPayload } + } + + let executionInput = params.input.workflowInput + if (params.input.inputFromExecutionId) { + const source = await getExecutionInputForWorkflow( + params.input.inputFromExecutionId, + params.input.workflowId + ) + if (!source.found) { + throw new OrchestrationError( + 'not_found', + `No execution "${params.input.inputFromExecutionId}" found for this workflow to reuse input from.` + ) + } + if (source.input === undefined) { + throw new OrchestrationError( + 'validation', + `Execution "${params.input.inputFromExecutionId}" has no recorded input to reuse.` + ) + } + executionInput = source.input + } + const validation = validateTriggerInput(option, executionInput) + if (!validation.ok) { + throw new OrchestrationError( + 'validation', + validation.error || 'workflow_input is invalid for the target trigger.' + ) + } + return { triggerBlockId: option.triggerBlockId, input: executionInput } +} + +async function resolveSourceSnapshot(input: SnapshotCopilotRunInput): Promise<{ + executionId: string + snapshot: SerializableExecutionState +}> { + if (input.sourceExecutionId) { + const snapshot = await getExecutionStateForWorkflow(input.sourceExecutionId, input.workflowId) + if (snapshot) return { executionId: input.sourceExecutionId, snapshot } + throw new OrchestrationError( + 'not_found', + `No execution state found for execution ${input.sourceExecutionId}. Run the full workflow first.` + ) + } + const latest = await getLatestExecutionStateWithExecutionId(input.workflowId) + if (latest?.state) return { executionId: latest.executionId, snapshot: latest.state } + throw new OrchestrationError( + 'not_found', + `No execution state found for workflow ${input.workflowId}. Run the full workflow first to create a snapshot.` + ) +} + +async function executeCopilotRun(params: { + principal: Principal + input: BaseCopilotRunInput + context: Awaited> + executionInput: unknown + triggerBlockId?: string + stopAfterBlockId?: string + runFromBlock?: { + startBlockId: string + sourceSnapshot: SerializableExecutionState + sourceExecutionId: string + } +}): Promise { + const actorUserId = requirePrincipalSubjectUserId(params.principal) + const childExecutionId = generateId() + const admission = await prepareWorkflowExecutionAdmission( + { + userId: actorUserId, + billingAttribution: params.input.lifecycle.billingAttribution, + }, + params.context.workspaceId, + childExecutionId + ) + const registry = params.input.lifecycle.resolvedSecretTraceRegistry + const trustedInitialResolvedSecretTraceProvenance = registry?.exportProvenanceForValue( + params.executionInput + ) + const completePendingActivation = registry?.beginPendingActivation() + try { + const result = await executeWorkflow( + { + id: params.context.workflowId, + userId: params.context.workflow.userId, + workspaceId: params.context.workspaceId, + variables: params.context.workflow.variables || {}, + }, + generateRequestId(), + params.executionInput, + actorUserId, + { + enabled: true, + useDraftState: params.input.useDraftState, + workflowTriggerType: 'copilot', + triggerBlockId: params.triggerBlockId, + stopAfterBlockId: params.stopAfterBlockId, + runFromBlock: params.runFromBlock, + abortSignal: params.input.lifecycle.abortSignal, + billingAttribution: admission.billingAttribution, + ...(trustedInitialResolvedSecretTraceProvenance + ? { trustedInitialResolvedSecretTraceProvenance } + : {}), + }, + childExecutionId + ) + if (registry) { + await registry.importCrossingProvenance( + result.executionState?.resolvedSecretTraceProvenance, + { output: result.output, logs: result.logs, error: result.error }, + { trusted: true } + ) + } + return result + } catch (error) { + if (registry) { + const executionResult = + typeof error === 'object' && + error !== null && + 'executionResult' in error && + typeof error.executionResult === 'object' + ? (error.executionResult as ExecutionResult) + : undefined + await registry.importCrossingProvenance( + executionResult?.executionState?.resolvedSecretTraceProvenance, + { + output: executionResult?.output, + logs: executionResult?.logs, + error: executionResult?.error, + thrownMessage: toError(error).message, + }, + { trusted: true } + ) + } + if (admission.targetReservation) await releaseExecutionSlot(childExecutionId) + throw error + } finally { + completePendingActivation?.() + } +} + +function defineTriggerRunUseCase( + operation: + | typeof workflowOperations.runFromCopilot + | typeof workflowOperations.runUntilFromCopilot +) { + return defineAuthorizedWorkflowUseCase({ + operation, + resolveContext: resolveContext, + async execute({ principal, input, context }) { + const prepared = await resolveTriggerExecution({ input, workspaceId: context.workspaceId }) + return executeCopilotRun({ + principal, + input, + context, + executionInput: prepared.input, + triggerBlockId: prepared.triggerBlockId, + stopAfterBlockId: input.stopAfterBlockId, + }) + }, + }) +} + +export const runWorkflowFromCopilot = defineTriggerRunUseCase( + workflowOperations.runFromCopilot +) + +export const runWorkflowUntilBlockFromCopilot = + defineTriggerRunUseCase( + workflowOperations.runUntilFromCopilot + ) + +function defineSnapshotRunUseCase( + operation: + | typeof workflowOperations.runFromBlockFromCopilot + | typeof workflowOperations.runBlockFromCopilot, + stopAtStartBlock: boolean +) { + return defineAuthorizedWorkflowUseCase({ + operation, + resolveContext: resolveContext, + async execute({ principal, input, context }) { + const state = await loadDefinition(input, context.workspaceId) + if (!state?.blocks) { + throw new OrchestrationError( + 'validation', + `Workflow ${input.workflowId} has no ${input.useDraftState ? 'saved draft' : 'deployed'} state to run.` + ) + } + const source = await resolveSourceSnapshot(input) + return executeCopilotRun({ + principal, + input, + context, + executionInput: input.workflowInput, + runFromBlock: { + startBlockId: input.blockId, + sourceSnapshot: source.snapshot, + sourceExecutionId: source.executionId, + }, + stopAfterBlockId: stopAtStartBlock ? input.blockId : undefined, + }) + }, + }) +} + +export const runFromBlockFromCopilot = defineSnapshotRunUseCase( + workflowOperations.runFromBlockFromCopilot, + false +) + +export const runBlockFromCopilot = defineSnapshotRunUseCase( + workflowOperations.runBlockFromCopilot, + true +) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.test.ts b/apps/sim/lib/workflows/application/update-workflow-content.test.ts new file mode 100644 index 00000000000..6379ad2e3f7 --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow-content.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) + +import { applyWorkflowVariableOperations } from '@/lib/workflows/application/update-workflow-content' + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +describe('applyWorkflowVariableOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + dbChainMockFns.for.mockResolvedValue([{ variables: {} }]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'workflow-1' }]) + }) + + it('transforms the row locked in the write transaction and projects effects afterward', async () => { + dbChainMockFns.for.mockResolvedValueOnce([ + { + variables: { + concurrent: { + id: 'concurrent', + workflowId: 'workflow-1', + name: 'preserved', + type: 'plain', + value: 'newer write', + }, + }, + }, + ]) + + await expect( + applyWorkflowVariableOperations.execute({ + principal, + input: { + workflowId: 'workflow-1', + operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }], + }, + }) + ).resolves.toMatchObject({ updated: 2, changed: true }) + + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + concurrent: expect.objectContaining({ value: 'newer write' }), + }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.variables_updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ + operation: 'workflows.variables.apply_operations', + operationCount: 1, + source: 'copilot', + }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + expect(dbChainMockFns.returning).toHaveBeenCalledBefore(mocks.notify) + }) + + it('does not write, audit, or notify an authoritative no-op', async () => { + await expect( + applyWorkflowVariableOperations.execute({ + principal, + input: { + workflowId: 'workflow-1', + operations: [{ operation: 'delete', name: 'missing' }], + }, + }) + ).resolves.toEqual({ updated: 0, changed: false }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('rejects a non-Copilot principal before canonical loading', async () => { + await expect( + applyWorkflowVariableOperations.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workflowId: 'workflow-1', operations: [] }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.ts b/apps/sim/lib/workflows/application/update-workflow-content.ts new file mode 100644 index 00000000000..9d60b59d277 --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow-content.ts @@ -0,0 +1,395 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + loadWorkflowFromNormalizedTables, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/persistence/utils' + +const logger = createLogger('UpdateWorkflowContent') +const MAX_WORKFLOW_VARIABLE_OPERATIONS = 100 + +interface WorkflowContentInput { + workflowId: string + assertedWorkspaceId?: string +} + +async function requireMutableWorkflow(workflowId: string): Promise { + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} + +function resolveWorkflowContentContext({ + principal, + input, +}: { + principal: Principal + input: I +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +interface WorkflowVariable { + id: string + workflowId?: string + name: string + type: string + value?: unknown +} + +export interface WorkflowVariableOperation { + name: string + operation: 'add' | 'edit' | 'delete' + value?: unknown + type?: string +} + +export interface ApplyWorkflowVariableOperationsInput extends WorkflowContentInput { + operations: WorkflowVariableOperation[] +} + +function coerceWorkflowVariableValue(value: unknown, type: string): unknown { + if (value === undefined) return value + if (type === 'number') { + const number = Number(value) + return Number.isNaN(number) ? value : number + } + if (type === 'boolean') { + const normalized = String(value).trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + return value + } + if (type !== 'array' && type !== 'object') return value + + try { + const parsed: unknown = JSON.parse(String(value)) + if (type === 'array' && Array.isArray(parsed)) return parsed + if (type === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed + } + } catch (error) { + logger.warn('Failed to parse JSON value for workflow variable coercion', { + error: getErrorMessage(error), + }) + } + return value +} + +function applyVariableOperations( + workflowId: string, + currentVariables: unknown, + operations: readonly WorkflowVariableOperation[] +): { variables: Record; changed: boolean } { + const current = + currentVariables && typeof currentVariables === 'object' && !Array.isArray(currentVariables) + ? (currentVariables as Record) + : {} + const byName = new Map() + for (const value of Object.values(current)) { + if ( + value && + typeof value === 'object' && + 'id' in value && + typeof value.id === 'string' && + 'name' in value && + typeof value.name === 'string' + ) { + byName.set(value.name, { + ...value, + id: value.id, + name: value.name, + type: 'type' in value && typeof value.type === 'string' ? value.type : 'plain', + }) + } + } + + let changed = false + for (const operation of operations) { + const name = String(operation.name || '') + if (!name) continue + const existing = byName.get(name) + if (operation.operation === 'delete') { + changed = byName.delete(name) || changed + continue + } + + const type = operation.type || existing?.type || 'plain' + const value = coerceWorkflowVariableValue(operation.value, type) + if (operation.operation === 'add' || !existing) { + byName.set(name, { id: generateId(), workflowId, name, type, value }) + } else { + byName.set(name, { ...existing, type, value }) + } + changed = true + } + + return { + variables: Object.fromEntries([...byName.values()].map((variable) => [variable.id, variable])), + changed, + } +} + +export const applyWorkflowVariableOperations = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.applyVariableOperations, + resolveContext: resolveWorkflowContentContext, + async execute({ input, context }) { + if (input.operations.length > MAX_WORKFLOW_VARIABLE_OPERATIONS) { + throw new OrchestrationError( + 'validation', + `Workflow variable updates cannot exceed ${MAX_WORKFLOW_VARIABLE_OPERATIONS} operations` + ) + } + await requireMutableWorkflow(context.workflowId) + + return db.transaction(async (tx) => { + const [current] = await tx + .select({ variables: workflow.variables }) + .from(workflow) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!current) throw new OrchestrationError('not_found', 'Workflow not found') + + const transformed = applyVariableOperations( + context.workflowId, + current.variables, + input.operations + ) + if (!transformed.changed) { + return { updated: Object.keys(transformed.variables).length, changed: false } + } + + const [updated] = await tx + .update(workflow) + .set({ variables: transformed.variables, updatedAt: new Date() }) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .returning({ id: workflow.id }) + if (!updated) throw new OrchestrationError('not_found', 'Workflow not found') + return { updated: Object.keys(transformed.variables).length, changed: true } + }) + }, + projectAudit: ({ input, context, result }) => + result.changed + ? { + action: AuditAction.WORKFLOW_VARIABLES_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: context.workflow.name, + description: 'Updated workflow variables', + metadata: { operationCount: input.operations.length, source: 'copilot' }, + } + : [], + afterSuccess: ({ context, result }) => + result.changed ? notifyWorkflowUpdated(context.workflowId) : undefined, +}) + +function isBlockProtected(blockId: string, blocksById: Record): boolean { + const block = blocksById[blockId] + if (!block) return false + if (block.locked) return true + + const visited = new Set() + let parentId = block.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + if (blocksById[parentId]?.locked) return true + parentId = blocksById[parentId]?.data?.parentId + } + return false +} + +function hasDisabledAncestor(blockId: string, blocksById: Record): boolean { + const visited = new Set() + let parentId = blocksById[blockId]?.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = blocksById[parentId] + if (!parent) return false + if (parent.enabled === false) return true + parentId = parent.data?.parentId + } + return false +} + +function findDescendants(containerId: string, blocksById: Record): string[] { + const descendants: string[] = [] + const stack = [containerId] + const visited = new Set() + while (stack.length > 0) { + const current = stack.pop()! + if (visited.has(current)) continue + visited.add(current) + for (const [blockId, block] of Object.entries(blocksById)) { + if (block.data?.parentId === current) { + descendants.push(blockId) + stack.push(blockId) + } + } + } + return descendants +} + +export interface SetWorkflowBlockEnabledInput extends WorkflowContentInput { + blockId: string + enabled: boolean +} + +export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.setBlockEnabled, + resolveContext: resolveWorkflowContentContext, + async execute({ input, context }) { + await requireMutableWorkflow(context.workflowId) + return db.transaction(async (tx) => { + const [active] = await tx + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!active) throw new OrchestrationError('not_found', 'Workflow not found') + + const normalized = await loadWorkflowFromNormalizedTables(context.workflowId, tx) + if (!normalized) { + throw new OrchestrationError( + 'validation', + `Workflow ${context.workflowId} has no normalized state` + ) + } + const currentState: WorkflowState = { + blocks: normalized.blocks as Record, + edges: normalized.edges || [], + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + lastSaved: Date.now(), + } + const targetBlock = currentState.blocks[input.blockId] + if (!targetBlock) { + throw new OrchestrationError( + 'not_found', + `Block ${input.blockId} not found in workflow ${context.workflowId}` + ) + } + if (isBlockProtected(input.blockId, currentState.blocks)) { + throw new OrchestrationError( + 'locked', + `Block ${input.blockId} is locked or inside a locked container and cannot be updated` + ) + } + if (input.enabled && hasDisabledAncestor(input.blockId, currentState.blocks)) { + throw new OrchestrationError( + 'validation', + `Cannot enable block ${input.blockId} while one of its parent containers is disabled. Enable the parent first.` + ) + } + + const affectedBlockIds = new Set([input.blockId]) + if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { + for (const descendantId of findDescendants(input.blockId, currentState.blocks)) { + if (!isBlockProtected(descendantId, currentState.blocks)) { + affectedBlockIds.add(descendantId) + } + } + } + if (targetBlock.enabled === input.enabled) { + return { + changed: false, + workflowName: active.name, + affectedBlockIds: [input.blockId], + state: currentState, + } + } + + const nextBlocks = { ...currentState.blocks } + for (const blockId of affectedBlockIds) { + nextBlocks[blockId] = { ...nextBlocks[blockId], enabled: input.enabled } + } + const nextState: WorkflowState = { + ...currentState, + blocks: nextBlocks, + lastSaved: Date.now(), + } + const saveResult = await saveWorkflowToNormalizedTables(context.workflowId, nextState, tx) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to save workflow state') + } + const [updated] = await tx + .update(workflow) + .set({ lastSynced: new Date(), updatedAt: new Date() }) + .where( + and( + eq(workflow.id, context.workflowId), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .returning({ id: workflow.id }) + if (!updated) throw new OrchestrationError('not_found', 'Workflow not found') + return { + changed: true, + workflowName: active.name, + affectedBlockIds: [...affectedBlockIds], + state: nextState, + } + }) + }, + projectAudit: ({ input, context, result }) => + result.changed + ? { + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflowName, + description: `${input.enabled ? 'Enabled' : 'Disabled'} workflow block "${input.blockId}"`, + metadata: { + op: 'set_block_enabled', + blockId: input.blockId, + enabled: input.enabled, + affectedBlockIds: result.affectedBlockIds, + source: 'copilot', + }, + } + : [], + afterSuccess: ({ context, result }) => + result.changed ? notifyWorkflowUpdated(context.workflowId) : undefined, +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts b/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts new file mode 100644 index 00000000000..8fb3038dcb7 --- /dev/null +++ b/apps/sim/lib/workflows/application/update-workflow-deployment-settings.ts @@ -0,0 +1,77 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { db, workflow } from '@sim/db' +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + PublicApiNotAllowedError, + validatePublicApiAllowed, +} from '@/ee/access-control/utils/permission-check' + +export interface UpdateWorkflowPublicApiInput { + workflowId: string + assertedWorkspaceId?: string + isPublicApi: boolean +} + +export const updateWorkflowPublicApi = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.updatePublicApi, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: UpdateWorkflowPublicApiInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }) { + if (principal.kind !== 'session') { + throw new Error('Workflow public API settings require a session principal') + } + try { + await assertWorkflowMutable(context.workflowId) + if (input.isPublicApi) { + await validatePublicApiAllowed(principal.userId, context.workspaceId) + } + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + if (error instanceof PublicApiNotAllowedError) { + throw new OrchestrationError('forbidden', 'Public API access is disabled') + } + throw error + } + + const [updated] = await db + .update(workflow) + .set({ isPublicApi: input.isPublicApi }) + .where(eq(workflow.id, context.workflowId)) + .returning({ id: workflow.id }) + if (!updated) throw new OrchestrationError('not_found', 'Workflow not found') + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + isPublicApi: input.isPublicApi, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.WORKFLOW_PUBLIC_API_TOGGLED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.workflowId, + resourceName: result.workflowName, + description: `${result.isPublicApi ? 'Enabled' : 'Disabled'} public API for workflow "${result.workflowName}"`, + metadata: { isPublicApi: result.isPublicApi }, + }), + afterSuccess: ({ result }) => notifyWorkflowUpdated(result.workflowId), +}) diff --git a/apps/sim/lib/workflows/application/update-workflow.ts b/apps/sim/lib/workflows/application/update-workflow.ts index 2760ff20c62..d697250a93f 100644 --- a/apps/sim/lib/workflows/application/update-workflow.ts +++ b/apps/sim/lib/workflows/application/update-workflow.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { @@ -6,11 +7,16 @@ import { FolderLockedError, WorkflowLockedError, } from '@sim/platform-authz/workflow' +import type { WorkspaceUseCaseAuditEntry } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' @@ -28,66 +34,238 @@ export interface UpdateWorkflowInput { name?: string description?: string | null folderPath?: string + folderId?: string | null + sortOrder?: number } -export const updateWorkflow = defineAuthorizedWorkflowUseCase({ - operation: workflowOperations.update, - resolveContext: ({ principal, input }: { principal: Principal; input: UpdateWorkflowInput }) => - resolveActiveWorkflowApplicationContext({ - workflowId: input.workflowId, - assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), - }), - async execute({ principal, input, context }) { - const resolution = - input.folderPath === undefined +export interface UpdateWorkflowPolicyInput extends UpdateWorkflowInput { + locked?: boolean + forkSyncExcluded?: boolean +} + +export type AppliedWorkflowUpdate = + | 'name' + | 'description' + | 'folder' + | 'sortOrder' + | 'locked' + | 'forkSyncExcluded' + +interface WorkflowUpdateResult { + workflow: NonNullable>['workflow']> + workspaceId: string + folderPath: string + changes: AppliedWorkflowUpdate[] + deployment: { + isDeployed: boolean + deployedAt: Date | null + runCount: number + lastRunAt: Date | null + } +} + +function resolveWorkflowUpdateContext({ + principal, + input, +}: { + principal: Principal + input: UpdateWorkflowInput +}) { + return resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }) +} + +async function requireMutableWorkflowUpdate( + context: ActiveWorkflowApplicationContext, + input: UpdateWorkflowPolicyInput, + targetFolderId: string | null | undefined +): Promise { + const hasContentUpdate = + input.name !== undefined || + input.description !== undefined || + input.folderPath !== undefined || + input.folderId !== undefined || + input.sortOrder !== undefined + try { + if (hasContentUpdate) await assertWorkflowMutable(context.workflowId) + if (targetFolderId !== undefined) await assertFolderMutable(targetFolderId) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} + +function changedFields( + input: UpdateWorkflowPolicyInput, + context: ActiveWorkflowApplicationContext, + updated: NonNullable>['workflow']> +): AppliedWorkflowUpdate[] { + const changes: AppliedWorkflowUpdate[] = [] + if (input.name !== undefined && updated.name !== context.workflow.name) changes.push('name') + if ( + input.description !== undefined && + updated.description !== (context.workflow.description ?? null) + ) { + changes.push('description') + } + if ( + (input.folderPath !== undefined || input.folderId !== undefined) && + updated.folderId !== (context.workflow.folderId ?? null) + ) { + changes.push('folder') + } + if (input.sortOrder !== undefined && updated.sortOrder !== context.workflow.sortOrder) { + changes.push('sortOrder') + } + if (input.locked !== undefined && updated.locked !== context.workflow.locked) { + changes.push('locked') + } + if ( + input.forkSyncExcluded !== undefined && + updated.forkSyncExcluded !== context.workflow.forkSyncExcluded + ) { + changes.push('forkSyncExcluded') + } + return changes +} + +async function executeWorkflowUpdate(args: { + principal: Principal + input: UpdateWorkflowPolicyInput + context: ActiveWorkflowApplicationContext +}): Promise { + const { principal, input, context } = args + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } + const resolution = + input.folderId !== undefined + ? { + folderId: input.folderId, + index: await loadActiveFolderPathIndex(context.workspaceId, 'workflow'), + } + : input.folderPath === undefined ? undefined : await resolveWorkflowFolderPath(context.workspaceId, input.folderPath) + if (resolution?.folderId && !resolution.index.pathById.has(resolution.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } + await requireMutableWorkflowUpdate(context, input, resolution?.folderId) - try { - await assertWorkflowMutable(context.workflowId) - if (resolution) await assertFolderMutable(resolution.folderId) - } catch (error) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - throw new OrchestrationError('locked', error.message) - } - throw error - } + const transition = await updateWorkflowRecord({ + workflowId: context.workflowId, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + workspaceId: context.workspaceId, + currentName: context.workflow.name, + currentFolderId: context.workflow.folderId, + currentLocked: context.workflow.locked, + currentForkSyncExcluded: context.workflow.forkSyncExcluded, + name: input.name, + description: input.description, + folderId: resolution?.folderId, + sortOrder: input.sortOrder, + locked: input.locked, + forkSyncExcluded: input.forkSyncExcluded, + }) + requireWorkflowTransition(transition, 'Failed to update workflow') + if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') - const transition = await updateWorkflowRecord({ - workflowId: context.workflowId, - userId: resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }).attributedUserId, - workspaceId: context.workspaceId, - currentName: context.workflow.name, - currentFolderId: context.workflow.folderId, - name: input.name, - description: input.description, - folderId: resolution?.folderId, - }) - requireWorkflowTransition(transition, 'Failed to update workflow') - if (!transition.workflow) throw new Error('Successful workflow update returned no workflow') + const folderIndex = + resolution?.index ?? + (await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + })) + const changes = changedFields(input, context, transition.workflow) + logger.info('Updated workflow', { + workspaceId: context.workspaceId, + workflowId: context.workflowId, + principalKind: principal.kind, + changes, + }) + return { + workflow: transition.workflow, + workspaceId: context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, transition.workflow.folderId), + changes, + deployment: { + isDeployed: context.workflow.isDeployed, + deployedAt: context.workflow.deployedAt, + runCount: context.workflow.runCount, + lastRunAt: context.workflow.lastRunAt, + }, + } +} - const folderIndex = - resolution?.index ?? - (await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { - maxRows: MAX_FOLDERS_PER_WORKSPACE, - })) - logger.info('Updated workflow', { - workspaceId: context.workspaceId, - workflowId: context.workflowId, - principalKind: principal.kind, +function projectWorkflowUpdateAudit(args: { + input: UpdateWorkflowPolicyInput + context: ActiveWorkflowApplicationContext + result: WorkflowUpdateResult +}): WorkspaceUseCaseAuditEntry[] { + const { input, context, result } = args + const entries: WorkspaceUseCaseAuditEntry[] = [] + const metadataChanges = result.changes.filter( + (field) => field !== 'locked' && field !== 'forkSyncExcluded' + ) + if (metadataChanges.length > 0) { + entries.push({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `Updated workflow "${result.workflow.name}"`, + metadata: { updatedFields: metadataChanges }, }) - return { - workflow: transition.workflow, - workspaceId: context.workspaceId, - folderPath: workflowFolderPathForId(folderIndex, transition.workflow.folderId), - deployment: { - isDeployed: context.workflow.isDeployed, - deployedAt: context.workflow.deployedAt, - runCount: context.workflow.runCount, - lastRunAt: context.workflow.lastRunAt, - }, - } - }, + } + if (result.changes.includes('locked')) { + entries.push({ + action: input.locked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `${input.locked ? 'Locked' : 'Unlocked'} workflow "${result.workflow.name}"`, + metadata: { locked: input.locked }, + }) + } + if (result.changes.includes('forkSyncExcluded')) { + entries.push({ + action: input.forkSyncExcluded + ? AuditAction.WORKFLOW_FORK_SYNC_EXCLUDED + : AuditAction.WORKFLOW_FORK_SYNC_INCLUDED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `${input.forkSyncExcluded ? 'Excluded' : 'Included'} workflow "${result.workflow.name}" ${input.forkSyncExcluded ? 'from' : 'in'} fork sync`, + metadata: { forkSyncExcluded: input.forkSyncExcluded }, + }) + } + return entries +} + +function notifyAfterWorkflowUpdate(args: { + context: ActiveWorkflowApplicationContext + result: WorkflowUpdateResult +}) { + return args.result.changes.length > 0 ? notifyWorkflowUpdated(args.context.workflowId) : undefined +} + +export const updateWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.update, + resolveContext: resolveWorkflowUpdateContext, + execute: executeWorkflowUpdate, + projectAudit: projectWorkflowUpdateAudit, + afterSuccess: notifyAfterWorkflowUpdate, +}) + +export const updateWorkflowPolicy = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.updatePolicy, + resolveContext: resolveWorkflowUpdateContext, + execute: executeWorkflowUpdate, + projectAudit: projectWorkflowUpdateAudit, + afterSuccess: notifyAfterWorkflowUpdate, }) diff --git a/apps/sim/lib/workflows/application/workflow-crud.test.ts b/apps/sim/lib/workflows/application/workflow-crud.test.ts index 64930d7bce7..162b2e149d9 100644 --- a/apps/sim/lib/workflows/application/workflow-crud.test.ts +++ b/apps/sim/lib/workflows/application/workflow-crud.test.ts @@ -20,6 +20,9 @@ const mocks = vi.hoisted(() => ({ loadFolderIndex: vi.fn(), listVersions: vi.fn(), readVersion: vi.fn(), + loadNormalized: vi.fn(), + notifyWorkflowUpdated: vi.fn(), + workflowCreated: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -81,6 +84,15 @@ vi.mock('@/lib/workflows/input-format', () => ({ vi.mock('@/lib/workflows/persistence/utils', () => ({ listWorkflowVersions: mocks.listVersions, getWorkflowDeploymentVersion: mocks.readVersion, + loadWorkflowFromNormalizedTables: mocks.loadNormalized, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkflowUpdated: mocks.notifyWorkflowUpdated, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { workflowCreated: mocks.workflowCreated }, })) import { createWorkflow } from '@/lib/workflows/application/create-workflow' @@ -88,6 +100,7 @@ import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow' import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' import { readWorkflow } from '@/lib/workflows/application/read-workflow' import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' +import { updateWorkflow } from '@/lib/workflows/application/update-workflow' const WORKSPACE_ID = 'workspace-1' const WORKFLOW_ID = 'workflow-1' @@ -129,6 +142,21 @@ const workspacePrincipal = { workspaceId: WORKSPACE_ID, keyId: 'workspace-key-1', } +const executorPrincipal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'executor-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2999-08-01T00:00:00Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: WORKFLOW_ID, + executionId: 'origin-run', + }, +} describe('authorized workflow CRUD and version reads', () => { beforeEach(() => { @@ -154,11 +182,22 @@ describe('authorized workflow CRUD and version reads', () => { }, }) mocks.loadSnapshot.mockResolvedValue({ workflowRecord, normalizedData: { blocks: {} } }) + mocks.loadNormalized.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + isFromNormalizedTables: true, + }) mocks.deleteRecord.mockResolvedValue({ success: true, archived: true, workflow: { id: WORKFLOW_ID, name: workflowRecord.name, workspaceId: WORKSPACE_ID }, }) + mocks.updateRecord.mockResolvedValue({ + success: true, + workflow: workflowRecord, + }) mocks.listVersions.mockResolvedValue({ versions: [] }) mocks.readVersion.mockResolvedValue({ id: 'version-1', @@ -194,6 +233,10 @@ describe('authorized workflow CRUD and version reads', () => { }), }) ) + expect(mocks.notifyWorkflowUpdated).toHaveBeenCalledWith(WORKFLOW_ID) + expect(mocks.workflowCreated).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: WORKFLOW_ID, workspaceId: WORKSPACE_ID }) + ) }) it('uses the billing owner only for the workspace key legacy user column', async () => { @@ -249,6 +292,81 @@ describe('authorized workflow CRUD and version reads', () => { expect(mocks.loadSnapshot).not.toHaveBeenCalled() }) + it('rejects executor workflow mutations before canonical resource loading', async () => { + const executor = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + delegationContext: { + kind: 'workflow_execution' as const, + workflowId: WORKFLOW_ID, + executionId: 'execution-1', + }, + } + + await expect( + updateWorkflow.execute({ + principal: executor, + input: { workflowId: WORKFLOW_ID, name: 'Forged target' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + it('allows executor reads only after canonical same-workspace binding and permission recheck', async () => { + await readWorkflow.execute({ + principal: executorPrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: WORKFLOW_ID, + assertedWorkspaceId: WORKSPACE_ID, + }) + expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', WORKSPACE_ID, null, undefined, { + forUpdate: undefined, + }) + expect(mocks.loadSnapshot).toHaveBeenCalledWith(WORKFLOW_ID) + }) + + it('rejects executor reads whose canonical target is outside the signed origin workspace', async () => { + mocks.resolveWorkflowContext.mockResolvedValueOnce({ + ...workflowContext, + workspaceId: 'workspace-other', + workflow: { ...workflowRecord, workspaceId: 'workspace-other' }, + }) + + await expect( + readWorkflow.execute({ + principal: executorPrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadSnapshot).not.toHaveBeenCalled() + }) + + it('rechecks current permission for every workflow mutation', async () => { + mocks.resolvePermission.mockResolvedValueOnce('write').mockResolvedValueOnce('read') + + await updateWorkflow.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID, name: 'First update' }, + }) + await expect( + updateWorkflow.execute({ + principal: personalPrincipal, + input: { workflowId: WORKFLOW_ID, name: 'Second update' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.updateRecord).toHaveBeenCalledTimes(1) + }) + it('does not audit an authoritative delete no-op', async () => { mocks.deleteRecord.mockResolvedValue({ success: true, @@ -264,7 +382,7 @@ describe('authorized workflow CRUD and version reads', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) - it('supports bounded v2 and unbounded internal version listing', async () => { + it('bounds both paginated and legacy unpaginated version listing', async () => { await listWorkflowVersions.execute({ principal: workspacePrincipal, input: { workflowId: WORKFLOW_ID, limit: 50 }, @@ -281,9 +399,19 @@ describe('authorized workflow CRUD and version reads', () => { }) ).resolves.toEqual({ versions: [], hasMore: false }) expect(mocks.listVersions).toHaveBeenLastCalledWith(WORKFLOW_ID, { - limit: undefined, + limit: 1001, afterVersion: undefined, }) + + mocks.listVersions.mockResolvedValue({ + versions: Array.from({ length: 1001 }, (_, index) => ({ id: `version-${index}` })), + }) + await expect( + listWorkflowVersions.execute({ + principal: workspacePrincipal, + input: { workflowId: WORKFLOW_ID }, + }) + ).rejects.toThrow('Workflow version list exceeds the 1000 row limit') }) it('reads one version only after canonical workflow authorization', async () => { diff --git a/apps/sim/lib/workflows/application/workflow-deployments.test.ts b/apps/sim/lib/workflows/application/workflow-deployments.test.ts index 3863d2f1a40..add3175f73f 100644 --- a/apps/sim/lib/workflows/application/workflow-deployments.test.ts +++ b/apps/sim/lib/workflows/application/workflow-deployments.test.ts @@ -14,6 +14,8 @@ const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { audit: vi.fn(), deploy: vi.fn(), findPrevious: vi.fn(), + notifyReverted: vi.fn(), + revert: vi.fn(), resolveContext: vi.fn(), resolvePermission: vi.fn(), undeploy: vi.fn(), @@ -22,7 +24,10 @@ const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { }) vi.mock('@sim/audit', () => ({ - AuditAction: { WORKFLOW_UNDEPLOYED: 'workflow.undeployed' }, + AuditAction: { + WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', + WORKFLOW_UNDEPLOYED: 'workflow.undeployed', + }, AuditResourceType: { WORKFLOW: 'workflow' }, recordAudit: mocks.audit, })) @@ -50,15 +55,26 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performActivateVersion: mocks.activate, performFullDeploy: mocks.deploy, performFullUndeploy: mocks.undeploy, + performRevertToVersion: mocks.revert, })) vi.mock('@/lib/workflows/persistence/utils', () => ({ findPreviousDeploymentVersion: mocks.findPrevious, + updateDeploymentVersionMetadata: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkflowReverted: mocks.notifyReverted, +})) + +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: vi.fn(), })) import { activateWorkflowVersion, deployWorkflow, + revertWorkflowVersion, undeployWorkflow, } from '@/lib/workflows/application/deployments' @@ -124,6 +140,7 @@ describe('workflow deployment application use cases', () => { warnings: [], }) mocks.findPrevious.mockResolvedValue({ ok: true, version: 3 }) + mocks.revert.mockResolvedValue({ success: true, lastSaved: 12345 }) }) it.each(adminPrincipals)( @@ -145,7 +162,7 @@ describe('workflow deployment application use cases', () => { workflowId: 'workflow-1', userId: actorUserId, actorId: actorUserId, - captureAnalytics: false, + ...(principal.kind === 'delegated' ? { captureAnalytics: false } : {}), versionName: 'Version 4', versionDescription: 'Production release', requestId: 'request-1', @@ -172,6 +189,32 @@ describe('workflow deployment application use cases', () => { expect(mocks.deploy).not.toHaveBeenCalled() }) + it('rejects executor deployment transitions before canonical lookup', async () => { + await expect( + deployWorkflow.execute({ + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'executor-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-08T00:00:00Z'), + expiresAt: new Date('2999-08-08T00:00:00Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }, + input: { workflowId: 'workflow-1', requestId: 'request-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + expect(mocks.deploy).not.toHaveBeenCalled() + }) + it('requires current admin permission before deployment', async () => { mocks.resolvePermission.mockResolvedValueOnce('write') @@ -211,7 +254,35 @@ describe('workflow deployment application use cases', () => { ) }) - it('activates an explicit version with analytics disabled in orchestration', async () => { + it('projects revert audit and notification exactly once outside legacy orchestration', async () => { + await revertWorkflowVersion.execute({ + principal: adminPrincipals[2].principal, + input: { workflowId: 'workflow-1', version: 3 }, + }) + + expect(mocks.revert).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + version: 3, + userId: 'delegated-user', + captureAnalytics: false, + projectLegacyAudit: false, + notifyRealtime: false, + }) + ) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.deployment_reverted', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ targetVersion: '3' }), + }) + ) + expect(mocks.notifyReverted).toHaveBeenCalledOnce() + expect(mocks.notifyReverted).toHaveBeenCalledWith('workflow-1', 12345) + }) + + it('keeps human activation analytics enabled for durable post-activation capture', async () => { await activateWorkflowVersion.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { @@ -230,17 +301,41 @@ describe('workflow deployment application use cases', () => { version: 2, userId: 'user-1', actorId: 'user-1', - captureAnalytics: false, requestId: 'request-3', idempotencyKey: 'activation-1', }) ) }) + it('forwards optional version metadata through the activation command', async () => { + await activateWorkflowVersion.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + version: 2, + transition: 'activate', + requestId: 'request-metadata', + name: 'Release 2', + description: 'Production', + }, + }) + + expect(mocks.activate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Release 2', + description: 'Production', + }) + ) + }) + it('resolves the previous active version for an implicit rollback', async () => { const result = await activateWorkflowVersion.execute({ principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, - input: { workflowId: 'workflow-1', transition: 'rollback', requestId: 'request-4' }, + input: { + workflowId: 'workflow-1', + transition: 'rollback', + requestId: 'request-4', + }, }) expect(mocks.findPrevious).toHaveBeenCalledWith('workflow-1') diff --git a/apps/sim/lib/workflows/application/workflow-vfs.test.ts b/apps/sim/lib/workflows/application/workflow-vfs.test.ts new file mode 100644 index 00000000000..62af1155e8a --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-vfs.test.ts @@ -0,0 +1,279 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { FolderLockedError, WorkflowLockedError, mocks } = vi.hoisted(() => { + class WorkflowLockedError extends Error {} + class FolderLockedError extends Error {} + return { + WorkflowLockedError, + FolderLockedError, + mocks: { + assertFolderMutable: vi.fn(), + assertWorkflowMutable: vi.fn(), + audit: vi.fn(), + createFolder: vi.fn(), + deleteFolder: vi.fn(), + deleteWorkflow: vi.fn(), + duplicateWorkflow: vi.fn(), + loadFolderIndex: vi.fn(), + logError: vi.fn(), + notifyFolder: vi.fn(), + notifyWorkflow: vi.fn(), + permission: vi.fn(), + relocateFolder: vi.fn(), + resolveContext: vi.fn(), + updateWorkflow: vi.fn(), + }, + } +}) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + WORKFLOW_DELETED: 'workflow.deleted', + WORKFLOW_DUPLICATED: 'workflow.duplicated', + WORKFLOW_UPDATED: 'workflow.updated', + }, + AuditResourceType: { FOLDER: 'folder', WORKFLOW: 'workflow' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + error: mocks.logError, + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@sim/platform-authz/workflow', () => ({ + assertFolderMutable: mocks.assertFolderMutable, + assertWorkflowMutable: mocks.assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.permission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkspaceApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: mocks.loadFolderIndex, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolderAtPath: mocks.createFolder, + deleteFolderByPath: mocks.deleteFolder, + relocateFolderByPath: mocks.relocateFolder, +})) + +vi.mock('@/lib/workflows/orchestration', () => ({ + deleteWorkflowRecord: mocks.deleteWorkflow, + updateWorkflowRecord: mocks.updateWorkflow, +})) + +vi.mock('@/lib/workflows/persistence/duplicate', () => ({ + duplicateWorkflow: mocks.duplicateWorkflow, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyFolderResourceChanged: mocks.notifyFolder, + notifyWorkflowUpdated: mocks.notifyWorkflow, +})) + +import { + createWorkflowVfsFolders, + moveWorkflowVfsItems, +} from '@/lib/workflows/application/workflow-vfs' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} +const emptyIndex = { + rowById: new Map(), + pathById: new Map(), + idByPath: new Map(), +} + +describe('workflow VFS application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.resolveContext.mockResolvedValue(workspaceContext) + mocks.permission.mockResolvedValue('write') + mocks.assertFolderMutable.mockResolvedValue(undefined) + mocks.assertWorkflowMutable.mockResolvedValue(undefined) + mocks.loadFolderIndex.mockResolvedValue(emptyIndex) + }) + + it('rejects a forged cross-workspace delegation before loading the protected VFS index', async () => { + await expect( + moveWorkflowVfsItems.execute({ + principal: { ...principal, workspaceId: 'workspace-2' }, + input: { + workspaceId: 'workspace-1', + sources: [{ source: 'workflows/One', segments: ['One'] }], + destination: { segments: ['Archive'], trailingSlash: true }, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadFolderIndex).not.toHaveBeenCalled() + }) + + it('rechecks current permission before canonical index loading', async () => { + mocks.permission.mockResolvedValueOnce(null) + + await expect( + moveWorkflowVfsItems.execute({ + principal, + input: { + workspaceId: 'workspace-1', + sources: [{ source: 'workflows/One', segments: ['One'] }], + destination: { segments: [], trailingSlash: false }, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadFolderIndex).not.toHaveBeenCalled() + }) + + it('keeps partial failures bounded while auditing and notifying only durable successes', async () => { + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'One', folderId: null }, + { id: 'workflow-2', name: 'Two', folderId: null }, + ]) + queueTableRows(schemaMock.workflow, [{ id: 'workflow-1', name: 'One', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'workflow-2', name: 'Two', folderId: null }]) + mocks.updateWorkflow + .mockResolvedValueOnce({ + success: true, + workflow: { id: 'workflow-1', name: 'One', folderId: null }, + }) + .mockResolvedValueOnce({ success: false, error: 'Workflow is locked', errorCode: 'locked' }) + + const result = await moveWorkflowVfsItems.execute({ + principal, + input: { + workspaceId: 'workspace-1', + sources: [ + { source: 'workflows/One', segments: ['One'] }, + { source: 'workflows/Two', segments: ['Two'] }, + ], + destination: { segments: [], trailingSlash: true }, + }, + }) + + expect(mocks.logError).not.toHaveBeenCalled() + expect(result.outcomes).toEqual([ + expect.objectContaining({ source: 'workflows/One', resourceId: 'workflow-1' }), + expect.objectContaining({ source: 'workflows/Two', error: 'Workflow is locked' }), + ]) + expect(mocks.audit).toHaveBeenCalledOnce() + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ operation: 'workflows.vfs.move' }), + }) + ) + expect(mocks.notifyWorkflow).toHaveBeenCalledWith('workflow-1') + expect(mocks.notifyWorkflow).not.toHaveBeenCalledWith('workflow-2') + }) + + it('propagates an unexpected mutation failure without projecting a partial outcome', async () => { + queueTableRows(schemaMock.workflow, [{ id: 'workflow-1', name: 'One', folderId: null }]) + queueTableRows(schemaMock.workflow, [{ id: 'workflow-1', name: 'One', folderId: null }]) + mocks.updateWorkflow.mockRejectedValueOnce(new Error('postgres password=secret')) + + await expect( + moveWorkflowVfsItems.execute({ + principal, + input: { + workspaceId: 'workspace-1', + sources: [{ source: 'workflows/One', segments: ['One'] }], + destination: { segments: [], trailingSlash: true }, + }, + }) + ).rejects.toThrow('postgres password=secret') + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.notifyWorkflow).not.toHaveBeenCalled() + expect(mocks.notifyFolder).not.toHaveBeenCalled() + }) + + it('owns mkdir path planning and audits only the folder it creates', async () => { + const created = { + id: 'folder-1', + name: 'Project Plans', + parentId: null, + } + const createdIndex = { + rowById: new Map([[created.id, created]]), + pathById: new Map([[created.id, '/Project%20Plans']]), + idByPath: new Map([['/Project%20Plans', created.id]]), + } + mocks.loadFolderIndex.mockResolvedValueOnce(emptyIndex).mockResolvedValueOnce(createdIndex) + mocks.createFolder.mockResolvedValue({ + success: true, + folder: created, + path: '/Project%20Plans', + }) + + const result = await createWorkflowVfsFolders.execute({ + principal, + input: { + workspaceId: 'workspace-1', + paths: [{ source: 'workflows/Project Plans', segments: ['Project Plans'] }], + }, + }) + + expect(result.outcomes).toEqual([ + expect.objectContaining({ resourceId: 'folder-1', targetSegments: ['Project Plans'] }), + ]) + expect(mocks.createFolder).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/Project%20Plans', + effects: false, + throwInfrastructure: true, + }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'folder.created', + resourceId: 'folder-1', + metadata: expect.objectContaining({ operation: 'workflows.vfs.folders.create' }), + }) + ) + expect(mocks.notifyFolder).toHaveBeenCalledWith('workflow', 'workspace-1') + }) +}) diff --git a/apps/sim/lib/workflows/application/workflow-vfs.ts b/apps/sim/lib/workflows/application/workflow-vfs.ts new file mode 100644 index 00000000000..deb32724d46 --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-vfs.ts @@ -0,0 +1,851 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { + assertFolderMutable, + assertWorkflowMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { and, eq, isNull } from 'drizzle-orm' +import { + asOrchestrationError, + OrchestrationError, + type OrchestrationErrorCode, +} from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + createFolderAtPath, + deleteFolderByPath, + relocateFolderByPath, +} from '@/lib/folders/orchestration' +import { + buildFolderPath, + FolderPathError, + type FolderPathIndex, + parseFolderPath, +} from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + notifyFolderResourceChanged, + notifyWorkflowDeleted, + notifyWorkflowUpdated, +} from '@/lib/realtime/notify' +import { VfsPathLimitError, validateVfsPathSegments } from '@/lib/vfs/limits' +import { encodeVfsPathSegments } from '@/lib/vfs/path' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { deleteWorkflowRecord, updateWorkflowRecord } from '@/lib/workflows/orchestration' +import { duplicateWorkflow as duplicateWorkflowRecord } from '@/lib/workflows/persistence/duplicate' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const MAX_WORKFLOW_VFS_ITEMS = 100 +const MAX_WORKFLOW_VFS_INDEX_ROWS = 10_000 +const MAX_WORKFLOW_NAME_LENGTH = 200 + +export interface WorkflowVfsPathReference { + source: string + segments: string[] +} + +export interface WorkflowVfsDestination { + segments: string[] + trailingSlash: boolean +} + +export interface WorkflowVfsOutcome { + source: string + targetSegments?: string[] + resourceType: 'workflow' | 'folder' + resourceId?: string + error?: string +} + +export interface CreateWorkflowVfsFoldersInput { + workspaceId: string + paths: WorkflowVfsPathReference[] +} + +export interface TransferWorkflowVfsItemsInput { + workspaceId: string + sources: WorkflowVfsPathReference[] + destination: WorkflowVfsDestination +} + +export interface DeleteWorkflowVfsItemsInput { + workspaceId: string + paths: WorkflowVfsPathReference[] +} + +interface WorkflowVfsRow { + id: string + name: string + folderId: string | null +} + +interface CreatedFolderChange { + id: string + name: string + path: string +} + +interface MovedWorkflowChange { + id: string + name: string + previousFolderId: string | null + folderId: string | null +} + +interface MovedFolderChange { + id: string + name: string + sourcePath: string + destinationPath: string +} + +interface DuplicatedWorkflowChange { + id: string + name: string + sourceWorkflowId: string +} + +interface DeletedWorkflowChange { + id: string + name: string +} + +interface DeletedFolderChange { + id: string + name: string + path: string + workflows: number + folders: number +} + +interface WorkflowVfsIndexState { + folderIndex: FolderPathIndex + workflows: WorkflowVfsRow[] + createdFolders: CreatedFolderChange[] +} + +interface ResolvedWorkflowSource { + source: string + workflow?: WorkflowVfsRow + folderId?: string + error?: string +} + +interface DestinationPlan { + dirMode: boolean + folderSegments: string[] + leafName?: string + ensureFolderId(): Promise +} + +function canonicalSegmentsKey(segments: readonly string[]): string { + return encodeVfsPathSegments([...segments]) +} + +function normalizeReferences( + references: readonly WorkflowVfsPathReference[] +): WorkflowVfsPathReference[] { + if (references.length > MAX_WORKFLOW_VFS_ITEMS) { + throw new OrchestrationError( + 'validation', + `Workflow VFS commands cannot exceed ${MAX_WORKFLOW_VFS_ITEMS} items` + ) + } + const byPath = new Map() + for (const reference of references) { + try { + validateVfsPathSegments(reference.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + const key = canonicalSegmentsKey(reference.segments) + if (!byPath.has(key)) byPath.set(key, reference) + } + if (byPath.size === 0) throw new OrchestrationError('validation', 'At least one path is required') + return [...byPath.values()] +} + +function validateDestination(destination: WorkflowVfsDestination): void { + try { + validateVfsPathSegments(destination.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } +} + +async function loadWorkflowVfsIndex( + context: ActiveWorkspaceApplicationContext +): Promise { + const [folderIndex, workflows] = await Promise.all([ + loadActiveFolderPathIndex(context.workspaceId, 'workflow', db, { + maxRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }), + db + .select({ id: workflow.id, name: workflow.name, folderId: workflow.folderId }) + .from(workflow) + .where(and(eq(workflow.workspaceId, context.workspaceId), isNull(workflow.archivedAt))) + .limit(MAX_WORKFLOW_VFS_INDEX_ROWS + 1), + ]) + if (workflows.length > MAX_WORKFLOW_VFS_INDEX_ROWS) { + throw new Error(`Workflow VFS index exceeds the ${MAX_WORKFLOW_VFS_INDEX_ROWS} row limit`) + } + return { folderIndex, workflows, createdFolders: [] } +} + +function folderSegmentsForId(index: FolderPathIndex, folderId: string | null): string[] { + if (!folderId) return [] + const path = index.pathById.get(folderId) + if (!path) throw new Error('Workflow references an inactive or missing folder') + return parseFolderPath(path) +} + +function resolveWorkflowSources( + state: WorkflowVfsIndexState, + references: readonly WorkflowVfsPathReference[] +): ResolvedWorkflowSource[] { + const workflowsByPath = new Map() + for (const row of state.workflows) { + const path = canonicalSegmentsKey([ + ...folderSegmentsForId(state.folderIndex, row.folderId), + row.name, + ]) + if (!workflowsByPath.has(path)) workflowsByPath.set(path, row) + } + const foldersByPath = new Map() + for (const [folderId, path] of state.folderIndex.pathById) { + foldersByPath.set(canonicalSegmentsKey(parseFolderPath(path)), folderId) + } + + return references.map((reference) => { + if (reference.segments.length === 0) { + return { + source: reference.source, + error: 'Source must name a workflow or folder under workflows/', + } + } + const key = canonicalSegmentsKey(reference.segments) + const workflowRow = workflowsByPath.get(key) + if (workflowRow) return { source: reference.source, workflow: workflowRow } + const folderId = foldersByPath.get(key) + if (folderId) return { source: reference.source, folderId } + return { source: reference.source, error: `Not found: ${reference.source}` } + }) +} + +function throwFolderFailure(result: { error?: string; errorCode?: OrchestrationErrorCode }): never { + throw new OrchestrationError( + result.errorCode ?? 'internal', + result.errorCode === 'internal' + ? 'Workflow folder mutation failed' + : (result.error ?? 'Folder mutation failed') + ) +} + +async function reloadFolderIndex(state: WorkflowVfsIndexState, workspaceId: string): Promise { + state.folderIndex = await loadActiveFolderPathIndex(workspaceId, 'workflow', db, { + maxRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) +} + +async function ensureWorkflowFolderPath( + state: WorkflowVfsIndexState, + context: ActiveWorkspaceApplicationContext, + userId: string, + segments: readonly string[] +): Promise { + let folderId: string | null = null + for (let position = 0; position < segments.length; position += 1) { + const path = buildFolderPath(segments.slice(0, position + 1)) + const existing = state.folderIndex.idByPath.get(path) + if (existing) { + folderId = existing + continue + } + + const result = await createFolderAtPath({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId, + path, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) + if (!result.success || !result.folder) { + if (result.errorCode === 'conflict') { + await reloadFolderIndex(state, context.workspaceId) + const concurrentlyCreated = state.folderIndex.idByPath.get(path) + if (concurrentlyCreated) { + folderId = concurrentlyCreated + continue + } + } + throwFolderFailure(result) + } + + state.createdFolders.push({ id: result.folder.id, name: result.folder.name, path }) + await reloadFolderIndex(state, context.workspaceId) + folderId = result.folder.id + } + return folderId +} + +function planDestination( + input: TransferWorkflowVfsItemsInput, + state: WorkflowVfsIndexState, + context: ActiveWorkspaceApplicationContext, + userId: string, + sourceCount: number +): DestinationPlan { + const segments = input.destination.segments + const plan = ( + dirMode: boolean, + folderSegments: string[], + leafName?: string, + knownFolderId?: string | null + ): DestinationPlan => { + let memo: Promise | undefined + return { + dirMode, + folderSegments, + leafName, + ensureFolderId: () => + (memo ??= + knownFolderId !== undefined + ? Promise.resolve(knownFolderId) + : folderSegments.length === 0 + ? Promise.resolve(null) + : ensureWorkflowFolderPath(state, context, userId, folderSegments)), + } + } + + if (segments.length === 0) return plan(true, [], undefined, null) + if (input.destination.trailingSlash) return plan(true, segments) + const existingFolderId = state.folderIndex.idByPath.get(buildFolderPath(segments)) + if (existingFolderId) return plan(true, segments, undefined, existingFolderId) + if (sourceCount > 1) { + throw new OrchestrationError( + 'validation', + `With multiple sources the destination must be a folder. "workflows/${canonicalSegmentsKey(segments)}" does not exist — end it with "/" to create it.` + ) + } + return plan(false, segments.slice(0, -1), segments.at(-1)) +} + +function expectedOutcomeMessage(error: unknown): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + if ( + error instanceof WorkflowLockedError || + error instanceof FolderLockedError || + error instanceof FolderPathError + ) { + return error.message + } + throw error +} + +async function moveWorkflowRow(params: { + row: WorkflowVfsRow + targetName?: string + targetFolderId: string | null + context: ActiveWorkspaceApplicationContext + userId: string +}): Promise { + try { + await Promise.all([ + assertWorkflowMutable(params.row.id), + assertFolderMutable(params.targetFolderId), + ]) + } catch (error) { + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + return db.transaction(async (tx) => { + const [current] = await tx + .select({ id: workflow.id, name: workflow.name, folderId: workflow.folderId }) + .from(workflow) + .where( + and( + eq(workflow.id, params.row.id), + eq(workflow.workspaceId, params.context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!current) throw new OrchestrationError('not_found', 'Workflow not found') + + const transition = await updateWorkflowRecord({ + workflowId: current.id, + userId: params.userId, + workspaceId: params.context.workspaceId, + currentName: current.name, + currentFolderId: current.folderId, + name: params.targetName, + folderId: params.targetFolderId, + tx, + }) + requireWorkflowTransition(transition, 'Workflow mutation failed') + if (!transition.workflow) throw new Error('Successful workflow move returned no workflow') + return { + id: transition.workflow.id, + name: transition.workflow.name, + previousFolderId: current.folderId, + folderId: transition.workflow.folderId, + } + }) +} + +function createdFolderAuditEntries(createdFolders: readonly CreatedFolderChange[]) { + return createdFolders.map((folder) => ({ + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created workflow folder "${folder.path}"`, + metadata: { path: folder.path, folderResourceType: 'workflow' }, + })) +} + +export const createWorkflowVfsFolders = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.createVfsFolders, + resolveContext: ({ input }: { input: CreateWorkflowVfsFoldersInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const paths = normalizeReferences(input.paths) + const state = await loadWorkflowVfsIndex(context) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes: WorkflowVfsOutcome[] = [] + + for (const path of paths) { + if (path.segments.length === 0) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: 'Path must include at least one folder segment', + }) + continue + } + try { + const folderId = await ensureWorkflowFolderPath(state, context, userId, path.segments) + outcomes.push({ + source: path.source, + targetSegments: path.segments, + resourceType: 'folder', + resourceId: folderId ?? undefined, + }) + } catch (error) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, createdFolders: state.createdFolders } + }, + projectAudit: ({ result }) => createdFolderAuditEntries(result.createdFolders), + afterSuccess: ({ context, result }) => + result.createdFolders.length > 0 + ? notifyFolderResourceChanged('workflow', context.workspaceId) + : undefined, +}) + +export const moveWorkflowVfsItems = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.moveVfsItems, + resolveContext: ({ input }: { input: TransferWorkflowVfsItemsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const sources = normalizeReferences(input.sources) + validateDestination(input.destination) + const state = await loadWorkflowVfsIndex(context) + const refs = resolveWorkflowSources(state, sources) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const destination = planDestination(input, state, context, userId, sources.length) + if (!destination.dirMode && (destination.leafName?.length ?? 0) > MAX_WORKFLOW_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Workflow name must be ${MAX_WORKFLOW_NAME_LENGTH} characters or less` + ) + } + const outcomes: WorkflowVfsOutcome[] = [] + const movedWorkflows: MovedWorkflowChange[] = [] + const movedFolders: MovedFolderChange[] = [] + + for (const ref of refs) { + if (ref.error) { + outcomes.push({ source: ref.source, resourceType: 'workflow', error: ref.error }) + continue + } + if (ref.workflow) { + const targetName = destination.dirMode + ? ref.workflow.name + : (destination.leafName as string) + try { + const targetFolderId = await destination.ensureFolderId() + const change = await moveWorkflowRow({ + row: ref.workflow, + targetName: destination.dirMode ? undefined : targetName, + targetFolderId, + context, + userId, + }) + movedWorkflows.push(change) + outcomes.push({ + source: ref.source, + targetSegments: [...destination.folderSegments, change.name], + resourceType: 'workflow', + resourceId: change.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + error: expectedOutcomeMessage(error), + }) + } + continue + } + + const folderId = ref.folderId as string + try { + const targetFolderId = await destination.ensureFolderId() + if (targetFolderId === folderId) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: 'Cannot move a folder into itself', + }) + continue + } + const sourcePath = state.folderIndex.pathById.get(folderId) + const sourceRow = state.folderIndex.rowById.get(folderId) + if (!sourcePath || !sourceRow) throw new Error('Workflow folder path index is incomplete') + const finalLeaf = destination.dirMode + ? (sources.find((source) => source.source === ref.source)?.segments.at(-1) ?? '') + : (destination.leafName as string) + const destinationPath = buildFolderPath([...destination.folderSegments, finalLeaf]) + const result = await relocateFolderByPath({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId, + path: sourcePath, + destinationPath, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) + if (!result.success || !result.folder) throwFolderFailure(result) + movedFolders.push({ + id: result.folder.id, + name: result.folder.name, + sourcePath, + destinationPath, + }) + outcomes.push({ + source: ref.source, + targetSegments: [...destination.folderSegments, finalLeaf], + resourceType: 'folder', + resourceId: result.folder.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + + return { outcomes, createdFolders: state.createdFolders, movedWorkflows, movedFolders } + }, + projectAudit: ({ result }) => [ + ...createdFolderAuditEntries(result.createdFolders), + ...result.movedWorkflows.map((change) => ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Moved workflow "${change.name}"`, + metadata: { + previousFolderId: change.previousFolderId, + folderId: change.folderId, + }, + })), + ...result.movedFolders.map((change) => ({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: change.id, + resourceName: change.name, + description: `Moved workflow folder to "${change.destinationPath}"`, + metadata: { + sourcePath: change.sourcePath, + destinationPath: change.destinationPath, + folderResourceType: 'workflow', + }, + })), + ], + afterSuccess: async ({ context, result }) => { + for (const change of result.movedWorkflows) { + await notifyWorkflowUpdated(change.id) + } + if (result.createdFolders.length > 0 || result.movedFolders.length > 0) { + await notifyFolderResourceChanged('workflow', context.workspaceId) + } + }, +}) + +export const copyWorkflowVfsItems = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.copyVfsItems, + resolveContext: ({ input }: { input: TransferWorkflowVfsItemsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const sources = normalizeReferences(input.sources) + validateDestination(input.destination) + const state = await loadWorkflowVfsIndex(context) + const refs = resolveWorkflowSources(state, sources) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const destination = planDestination(input, state, context, userId, sources.length) + if (!destination.dirMode && (destination.leafName?.length ?? 0) > MAX_WORKFLOW_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Workflow name must be ${MAX_WORKFLOW_NAME_LENGTH} characters or less` + ) + } + const outcomes: WorkflowVfsOutcome[] = [] + const duplicatedWorkflows: DuplicatedWorkflowChange[] = [] + + for (const ref of refs) { + if (ref.error) { + outcomes.push({ source: ref.source, resourceType: 'workflow', error: ref.error }) + continue + } + if (!ref.workflow) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: 'Workflow folders cannot be copied.', + }) + continue + } + + try { + const targetFolderId = await destination.ensureFolderId() + const targetName = destination.dirMode + ? ref.workflow.name + : (destination.leafName as string) + const duplicated = await db.transaction(async (tx) => { + const [source] = await tx + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + eq(workflow.id, ref.workflow?.id as string), + eq(workflow.workspaceId, context.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .limit(1) + .for('update') + if (!source) throw new OrchestrationError('not_found', 'Workflow not found') + return duplicateWorkflowRecord({ + sourceWorkflowId: source.id, + userId, + workspaceId: context.workspaceId, + folderId: targetFolderId, + name: targetName, + requestId: generateRequestId(), + tx, + }) + }) + duplicatedWorkflows.push({ + id: duplicated.id, + name: duplicated.name, + sourceWorkflowId: ref.workflow.id, + }) + outcomes.push({ + source: ref.source, + targetSegments: [...destination.folderSegments, duplicated.name], + resourceType: 'workflow', + resourceId: duplicated.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + error: expectedOutcomeMessage(error), + }) + } + } + + return { outcomes, createdFolders: state.createdFolders, duplicatedWorkflows } + }, + projectAudit: ({ context, result }) => [ + ...createdFolderAuditEntries(result.createdFolders), + ...result.duplicatedWorkflows.map((change) => ({ + action: AuditAction.WORKFLOW_DUPLICATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Duplicated workflow as "${change.name}"`, + metadata: { + sourceWorkflowId: change.sourceWorkflowId, + workspaceId: context.workspaceId, + }, + })), + ], + afterSuccess: async ({ context, result }) => { + for (const change of result.duplicatedWorkflows) { + await notifyWorkflowUpdated(change.id) + } + if (result.createdFolders.length > 0) { + await notifyFolderResourceChanged('workflow', context.workspaceId) + } + }, +}) + +export const deleteWorkflowVfsItems = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.deleteVfsItems, + resolveContext: ({ input }: { input: DeleteWorkflowVfsItemsInput }) => + resolveActiveWorkspaceApplicationContext(input.workspaceId), + async execute({ principal, input, context }) { + const paths = normalizeReferences(input.paths) + const state = await loadWorkflowVfsIndex(context) + const refs = resolveWorkflowSources(state, paths) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes: WorkflowVfsOutcome[] = [] + const deletedWorkflows: DeletedWorkflowChange[] = [] + const deletedFolders: DeletedFolderChange[] = [] + + for (const ref of refs) { + if (ref.error) { + outcomes.push({ source: ref.source, resourceType: 'workflow', error: ref.error }) + continue + } + if (ref.workflow) { + try { + await assertWorkflowMutable(ref.workflow.id) + const result = await deleteWorkflowRecord({ + workflowId: ref.workflow.id, + userId, + notifySocket: false, + }) + requireWorkflowTransition(result, 'Workflow deletion failed') + if (!result.workflow || !result.archived) { + throw new OrchestrationError('validation', 'Workflow is already deleted') + } + deletedWorkflows.push({ id: result.workflow.id, name: result.workflow.name }) + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + resourceId: result.workflow.id, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'workflow', + error: expectedOutcomeMessage(error), + }) + } + continue + } + + const folderId = ref.folderId as string + const path = state.folderIndex.pathById.get(folderId) + if (!path) throw new Error('Workflow folder path index is incomplete') + try { + const result = await deleteFolderByPath({ + resourceType: 'workflow', + workspaceId: context.workspaceId, + userId, + path, + recursive: true, + effects: false, + throwInfrastructure: true, + maxFolderRows: MAX_WORKFLOW_VFS_INDEX_ROWS, + }) + if (!result.success || !result.folderId || !result.folderName || !result.deletedItems) { + throwFolderFailure(result) + } + deletedFolders.push({ + id: result.folderId, + name: result.folderName, + path, + workflows: result.deletedItems.workflows ?? 0, + folders: result.deletedItems.folders, + }) + outcomes.push({ + source: ref.source, + resourceType: 'folder', + resourceId: result.folderId, + }) + } catch (error) { + outcomes.push({ + source: ref.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + + return { outcomes, deletedWorkflows, deletedFolders } + }, + projectAudit: ({ result }) => [ + ...result.deletedWorkflows.map((change) => ({ + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: change.id, + resourceName: change.name, + description: `Archived workflow "${change.name}"`, + metadata: { archived: true }, + })), + ...result.deletedFolders.map((change) => ({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: change.id, + resourceName: change.name, + description: `Deleted workflow folder "${change.path}"`, + metadata: { + folderResourceType: 'workflow', + path: change.path, + affected: { + workflows: change.workflows, + subfolders: Math.max(change.folders - 1, 0), + }, + }, + })), + ], + afterSuccess: async ({ context, result }) => { + for (const workflow of result.deletedWorkflows) { + await notifyWorkflowDeleted(workflow.id) + } + if (result.deletedFolders.length > 0) { + await notifyFolderResourceChanged('workflow', context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workflows/deployment-outbox.test.ts b/apps/sim/lib/workflows/deployment-outbox.test.ts index b08db0ce3e9..eee9c74f65a 100644 --- a/apps/sim/lib/workflows/deployment-outbox.test.ts +++ b/apps/sim/lib/workflows/deployment-outbox.test.ts @@ -75,7 +75,7 @@ vi.mock('@/lib/mcp/server-locks', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: mockCaptureServerEvent, + deliverOutboxServerEvent: mockCaptureServerEvent, })) vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ @@ -212,6 +212,7 @@ describe('versioned deployment preparation outbox', () => { mockSyncMcpToolsForWorkflow.mockResolvedValue([{ serverId: 'mcp-server-1' }]) mockSetWorkflowMcpTransactionLockTimeout.mockResolvedValue(undefined) mockEmitWorkflowDeployedEvent.mockResolvedValue(undefined) + mockCaptureServerEvent.mockResolvedValue('delivered') mockMarkDeploymentOperationFailed.mockResolvedValue({ success: true, operation: operation({ status: 'failed' }), @@ -303,6 +304,7 @@ describe('versioned deployment preparation outbox', () => { 'workflow_deployed', { workflow_id: 'workflow-1', workspace_id: 'workspace-1' }, expect.objectContaining({ + insertId: 'event-1', groups: { workspace: 'workspace-1' }, setOnce: expect.objectContaining({ first_workflow_deployed_at: expect.any(String) }), }) @@ -311,6 +313,28 @@ describe('versioned deployment preparation outbox', () => { expect(mockRecordAudit.mock.invocationCallOrder[0]).toBeGreaterThan( mockActivateDeploymentOperation.mock.invocationCallOrder[0] ) + expect(mockCaptureServerEvent.mock.invocationCallOrder[0]).toBeGreaterThan( + mockActivateDeploymentOperation.mock.invocationCallOrder[0] + ) + + mockGetDeploymentOperation.mockResolvedValue(active) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + await handler()( + { + ...payload(), + checkpoints: { + inactiveCleanupCompleted: true, + auditEmitted: true, + analyticsCaptured: true, + socketNotified: true, + workspaceEventEmitted: true, + }, + }, + context() + ) + expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) }) it('ignores a superseded generation without preparing side effects', async () => { @@ -324,6 +348,33 @@ describe('versioned deployment preparation outbox', () => { expect(mockActivateDeploymentOperation).not.toHaveBeenCalled() }) + it('does not checkpoint analytics until durable PostHog delivery resolves', async () => { + const active = operation({ status: 'active', completedAt: NOW }) + mockGetDeploymentOperation.mockResolvedValue(active) + queueTableRows(schemaMock.workflow, [ + { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' }, + ]) + const deliveryFailure = new Error('PostHog flush failed') + mockCaptureServerEvent.mockRejectedValueOnce(deliveryFailure) + const outboxContext = context() + + await expect( + handler()( + { + ...payload(), + checkpoints: { inactiveCleanupCompleted: true, auditEmitted: true }, + }, + outboxContext + ) + ).rejects.toBe(deliveryFailure) + + expect(outboxContext.checkpointPayload).not.toHaveBeenCalledWith( + expect.objectContaining({ + checkpoints: expect.objectContaining({ analyticsCaptured: true }), + }) + ) + }) + it('honors an aborted signal before starting any side effect', async () => { const controller = new AbortController() controller.abort() diff --git a/apps/sim/lib/workflows/deployment-outbox.ts b/apps/sim/lib/workflows/deployment-outbox.ts index a22152e4421..eff2d1db51c 100644 --- a/apps/sim/lib/workflows/deployment-outbox.ts +++ b/apps/sim/lib/workflows/deployment-outbox.ts @@ -22,7 +22,7 @@ import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow, } from '@/lib/mcp/workflow-mcp-sync' -import { captureServerEvent } from '@/lib/posthog/server' +import { deliverOutboxServerEvent } from '@/lib/posthog/server' import { cleanupWebhooksForWorkflow, prepareStableTriggerWebhooksForDeploy, @@ -647,7 +647,7 @@ async function emitPostActivationSideEffects(params: { if (params.payload.captureAnalytics !== false) { const workspaceId = (params.workflow.workspaceId as string) || '' const isVersionActivation = params.operation.action === 'activate' - captureServerEvent( + await deliverOutboxServerEvent( params.payload.userId, isVersionActivation ? 'deployment_version_activated' : 'workflow_deployed', { @@ -656,6 +656,7 @@ async function emitPostActivationSideEffects(params: { ...(isVersionActivation ? { version: params.payload.version } : {}), }, { + insertId: params.context.eventId, groups: workspaceId ? { workspace: workspaceId } : undefined, ...(isVersionActivation ? {} diff --git a/apps/sim/lib/workflows/deployment-status.ts b/apps/sim/lib/workflows/deployment-status.ts new file mode 100644 index 00000000000..3498506a5f0 --- /dev/null +++ b/apps/sim/lib/workflows/deployment-status.ts @@ -0,0 +1,35 @@ +import { db, workflowDeploymentVersion } from '@sim/db' +import { and, desc, eq, sql } from 'drizzle-orm' +import { hasWorkflowChanged } from '@/lib/workflows/comparison' +import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +/** Compares the current durable draft with the active deployment snapshot. */ +export function computeNeedsRedeployment( + currentSnapshot: WorkflowState | null | undefined, + activeState: WorkflowState | null | undefined +): boolean { + if (!activeState || !currentSnapshot) return false + return hasWorkflowChanged(currentSnapshot, activeState) +} + +/** Reads both sides at repeatable-read isolation so the comparison is coherent. */ +export async function checkNeedsRedeployment(workflowId: string): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) + const [active] = await tx + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .orderBy(desc(workflowDeploymentVersion.createdAt)) + .limit(1) + + const currentState = await loadWorkflowDeploymentSnapshot(workflowId, tx) + return computeNeedsRedeployment(currentState, (active?.state as WorkflowState) ?? null) + }) +} diff --git a/apps/sim/lib/workflows/execution-admission.ts b/apps/sim/lib/workflows/execution-admission.ts new file mode 100644 index 00000000000..601d7c70e8f --- /dev/null +++ b/apps/sim/lib/workflows/execution-admission.ts @@ -0,0 +1,132 @@ +import { + reserveExecutionSlot, + UsageReservationUnavailableError, +} from '@/lib/billing/calculations/usage-reservation' +import { + type BillingAttributionSnapshot, + checkAttributedUsageLimits, + resolveBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { + getReservationDenialDescriptor, + type ReservationDenialReason, +} from '@/lib/core/admission/transient-failure' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' + +export interface WorkflowExecutionActorContext { + userId: string + billingAttribution?: BillingAttributionSnapshot +} + +export async function resolveWorkflowExecutionBillingAttribution( + context: WorkflowExecutionActorContext, + targetWorkspaceId: string +): Promise { + const rootAttribution = context.billingAttribution + if (!rootAttribution) return undefined + if (rootAttribution.workspaceId === targetWorkspaceId) return rootAttribution + + const childAttribution = await resolveBillingAttribution({ + actorUserId: context.userId, + workspaceId: targetWorkspaceId, + }) + if ( + childAttribution.actorUserId !== context.userId || + childAttribution.workspaceId !== targetWorkspaceId + ) { + throw new Error('Resolved workflow billing attribution does not match its actor and workspace') + } + return childAttribution +} + +export interface WorkflowExecutionAdmission { + billingAttribution: BillingAttributionSnapshot | undefined + targetReservation: boolean +} + +type ReservationDenialDescriptor = ReturnType + +export class WorkflowExecutionAdmissionError extends Error { + readonly code: ReservationDenialDescriptor['code'] + readonly statusCode: ReservationDenialDescriptor['statusCode'] + readonly retryable: ReservationDenialDescriptor['retryable'] + + constructor(message: string, descriptor: ReservationDenialDescriptor) { + super(message) + this.name = 'WorkflowExecutionAdmissionError' + this.code = descriptor.code + this.statusCode = descriptor.statusCode + this.retryable = descriptor.retryable + } +} + +const TARGET_RESERVATION_DENIAL_MESSAGE = { + payer_concurrency: 'Target workspace execution concurrency is currently exhausted', + payer_headroom: 'Target workspace payer usage headroom is currently exhausted', + member_headroom: 'Target workspace member usage headroom is currently exhausted', +} as const satisfies Record + +export async function prepareWorkflowExecutionAdmission( + context: WorkflowExecutionActorContext, + targetWorkspaceId: string, + childExecutionId: string +): Promise { + const billingAttribution = await resolveWorkflowExecutionBillingAttribution( + context, + targetWorkspaceId + ) + const rootAttribution = context.billingAttribution + const isCrossWorkspace = + rootAttribution !== undefined && rootAttribution.workspaceId !== targetWorkspaceId + + if (!billingAttribution || !isCrossWorkspace) { + return { billingAttribution, targetReservation: false } + } + + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + const descriptor = getReservationDenialDescriptor( + usage.scope === 'member' ? 'member_headroom' : 'payer_headroom' + ) + throw new WorkflowExecutionAdmissionError( + usage.message ?? 'Target workspace usage limit exceeded', + descriptor + ) + } + if (isHosted && isBillingEnabled && !usage.payerUsage) { + throw new UsageReservationUnavailableError( + 'Target workspace usage admission is temporarily unavailable. Please retry.' + ) + } + + const payerUsage = usage.payerUsage ?? { currentUsage: 0, limit: 0 } + const reservation = await reserveExecutionSlot({ + billingEntity: billingAttribution.billingEntity, + executionId: childExecutionId, + plan: billingAttribution.payerSubscription?.plan, + enterpriseConcurrencyLimit: billingAttribution.payerSubscription?.enterpriseConcurrencyLimit, + currentUsage: payerUsage.currentUsage, + limit: payerUsage.limit, + ...(billingAttribution.organizationId && + usage.memberUsage?.limit !== null && + usage.memberUsage?.limit !== undefined + ? { + member: { + organizationId: billingAttribution.organizationId, + actorUserId: billingAttribution.actorUserId, + currentUsage: usage.memberUsage.currentUsage, + limit: usage.memberUsage.limit, + }, + } + : {}), + }) + if (!reservation.reserved) { + const descriptor = getReservationDenialDescriptor(reservation.reason) + throw new WorkflowExecutionAdmissionError( + TARGET_RESERVATION_DENIAL_MESSAGE[reservation.reason], + descriptor + ) + } + + return { billingAttribution, targetReservation: true } +} diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 9ee006b26be..098305930de 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -16,7 +16,7 @@ vi.mock('@/lib/workflows/orchestration/deploy', () => ({ performFullDeploy: mockPerformFullDeploy, })) -vi.mock('@/app/api/workflows/utils', () => ({ +vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mockCheckNeedsRedeployment, })) diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index cdfffe62237..6a28925145b 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { PrincipalActor } from '@sim/auth/principal' import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -7,11 +8,11 @@ import { and, eq, isNull } from 'drizzle-orm' import { chatDeploymentPasswordSchema } from '@/lib/api/contracts/chats' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import { getWorkflowDeploymentSummary, performFullDeploy, } from '@/lib/workflows/orchestration/deploy' -import { checkNeedsRedeployment } from '@/app/api/workflows/utils' const logger = createLogger('ChatDeployOrchestration') @@ -37,6 +38,12 @@ export interface ChatDeployPayload { workspaceId?: string | null /** Stable identity for the underlying workflow deployment operation. */ idempotencyKey?: string + actorId?: string + actor?: PrincipalActor + requestId?: string + captureDeploymentAnalytics?: false + projectLegacyAudit?: boolean + captureLegacyTelemetry?: boolean } export interface PerformChatDeployResult { @@ -45,6 +52,7 @@ export interface PerformChatDeployResult { chatUrl?: string deployedAt?: Date | null version?: number + isUpdate?: boolean error?: string } @@ -114,9 +122,13 @@ export async function performChatDeploy( deployResult = await performFullDeploy({ workflowId, userId, + actorId: params.actorId, + actor: params.actor, + requestId: params.requestId, versionDescription: params.versionDescription, versionName: params.versionName, idempotencyKey: params.idempotencyKey, + captureAnalytics: params.captureDeploymentAnalytics, }) if (!deployResult.success) { return { success: false, error: deployResult.error || 'Failed to deploy workflow' } @@ -230,40 +242,42 @@ export async function performChatDeploy( logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.chatDeployed({ - chatId, - workflowId, - authType, - hasOutputConfigs: outputConfigs.length > 0, - }) - } catch (_e) { - // Telemetry is best-effort + if (params.captureLegacyTelemetry !== false) { + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.chatDeployed({ + chatId, + workflowId, + authType, + hasOutputConfigs: outputConfigs.length > 0, + }) + } catch (_e) {} } - recordAudit({ - workspaceId: params.workspaceId || null, - actorId: userId, - action: AuditAction.CHAT_DEPLOYED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: title, - description: `Deployed chat "${title}"`, - metadata: { - workflowId, - identifier, - authType, - chatUrl, - isUpdate: !!existingDeployment, - hasOutputConfigs: outputConfigs.length > 0, - hasCustomizations: !!( - params.customizations?.primaryColor || - params.customizations?.welcomeMessage || - params.customizations?.imageUrl - ), - }, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: params.workspaceId || null, + actorId: userId, + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: title, + description: `Deployed chat "${title}"`, + metadata: { + workflowId, + identifier, + authType, + chatUrl, + isUpdate: !!existingDeployment, + hasOutputConfigs: outputConfigs.length > 0, + hasCustomizations: !!( + params.customizations?.primaryColor || + params.customizations?.welcomeMessage || + params.customizations?.imageUrl + ), + }, + }) + } return { success: true, @@ -271,6 +285,7 @@ export async function performChatDeploy( chatUrl, deployedAt: deployResult?.deployedAt ?? toDeployedAtDate(deploymentSummary), version: deployResult?.version ?? deploymentSummary.activeDeployment?.version, + isUpdate: Boolean(existingDeployment), } } @@ -284,6 +299,7 @@ export interface PerformChatUndeployParams { chatId: string userId: string workspaceId?: string | null + projectLegacyAudit?: boolean } export interface PerformChatUndeployResult { @@ -320,20 +336,22 @@ export async function performChatUndeploy( logger.info(`Chat "${chatId}" deleted successfully`) - recordAudit({ - workspaceId: workspaceId || null, - actorId: userId, - action: AuditAction.CHAT_DELETED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: chatRecord.title || chatId, - description: `Deleted chat deployment "${chatRecord.title || chatId}"`, - metadata: { - workflowId: chatRecord.workflowId || undefined, - identifier: chatRecord.identifier || undefined, - authType: chatRecord.authType || undefined, - }, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: workspaceId || null, + actorId: userId, + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: chatRecord.title || chatId, + description: `Deleted chat deployment "${chatRecord.title || chatId}"`, + metadata: { + workflowId: chatRecord.workflowId || undefined, + identifier: chatRecord.identifier || undefined, + authType: chatRecord.authType || undefined, + }, + }) + } return { success: true } } diff --git a/apps/sim/lib/workflows/orchestration/deploy.test.ts b/apps/sim/lib/workflows/orchestration/deploy.test.ts index e619944702b..262a45c30d7 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.test.ts @@ -25,6 +25,7 @@ const { mockProcessWorkflowDeploymentOutboxEvent, mockNotifySocketDeploymentChanged, mockLoadWorkflowDeploymentSnapshot, + mockUpdateDeploymentVersionMetadata, mockTx, } = vi.hoisted(() => ({ mockSaveWorkflowToNormalizedTables: vi.fn(), @@ -40,6 +41,7 @@ const { mockProcessWorkflowDeploymentOutboxEvent: vi.fn(), mockNotifySocketDeploymentChanged: vi.fn(), mockLoadWorkflowDeploymentSnapshot: vi.fn(), + mockUpdateDeploymentVersionMetadata: vi.fn(), /** * Sentinel transaction handle the mocked prepare functions hand to the real * onPrepareTransaction callback, which only forwards it into the (mocked) @@ -88,6 +90,7 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowDeploymentSnapshot: mockLoadWorkflowDeploymentSnapshot, saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, undeployWorkflow: vi.fn(), + updateDeploymentVersionMetadata: mockUpdateDeploymentVersionMetadata, })) vi.mock('@/lib/webhooks/deploy', () => ({ @@ -248,6 +251,7 @@ describe('performFullDeploy workspace event emission', () => { }) mockValidateWorkflowSchedules.mockReturnValue({ isValid: true }) mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true }) + mockUpdateDeploymentVersionMetadata.mockResolvedValue({ name: null, description: null }) mockEnqueueWorkflowDeploymentPreparation.mockResolvedValue('prepare-event-default') mockPrepareWorkflowDeployment.mockImplementation(async (input) => { await input.onPrepareTransaction?.(mockTx, operation) @@ -672,6 +676,100 @@ describe('performActivateVersion workspace event emission', () => { expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled() }) + it('commits optional metadata inside activation admission before enqueueing work', async () => { + mockUpdateDeploymentVersionMetadata.mockResolvedValue({ + name: 'Release 2', + description: 'Ready for production', + }) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + name: 'Release 2', + description: 'Ready for production', + }) + + expect(result).toMatchObject({ + success: true, + name: 'Release 2', + description: 'Ready for production', + }) + expect(mockUpdateDeploymentVersionMetadata).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + version: 2, + name: 'Release 2', + description: 'Ready for production', + tx: mockTx, + }) + expect(mockUpdateDeploymentVersionMetadata).toHaveBeenCalledBefore( + mockEnqueueWorkflowDeploymentPreparation + ) + }) + + it('does not enqueue activation when transactional metadata persistence fails', async () => { + mockUpdateDeploymentVersionMetadata.mockRejectedValueOnce(new Error('metadata write failed')) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + name: 'Release 2', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'internal' }) + expect(mockEnqueueWorkflowDeploymentPreparation).not.toHaveBeenCalled() + }) + + it('reports post-admission activation failure while preserving admitted metadata', async () => { + const failedAt = new Date('2026-07-14T08:01:00.000Z') + mockUpdateDeploymentVersionMetadata.mockResolvedValue({ + name: 'Release 2', + description: null, + }) + mockGetWorkflowDeploymentStatus.mockResolvedValue({ + activeDeployment: null, + latestOperation: { + id: 'operation-activate-default', + workflowId: 'workflow-1', + deploymentVersionId: 'dv-2', + version: 2, + previousActiveVersionId: 'dv-1', + action: 'activate', + protocolVersion: 2, + generation: 4, + status: 'failed', + componentReadiness: {}, + errorCode: 'webhook_path_conflict', + errorMessage: 'Webhook path is already in use', + idempotencyKey: 'request-activate-default', + requestHash: 'hash', + actorId: 'user-1', + completedAt: failedAt, + createdAt: failedAt, + updatedAt: failedAt, + }, + }) + + const result = await performActivateVersion({ + workflowId: 'workflow-1', + version: 2, + userId: 'user-1', + name: 'Release 2', + }) + + expect(result).toMatchObject({ + success: false, + error: 'Webhook path is already in use', + errorCode: 'conflict', + name: 'Release 2', + }) + expect(mockUpdateDeploymentVersionMetadata).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Release 2', tx: mockTx }) + ) + expect(mockEnqueueWorkflowDeploymentPreparation).toHaveBeenCalledOnce() + }) + it('keeps the current version active while version activation prepares', async () => { const now = new Date('2026-07-14T08:00:00.000Z') const operation = { diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 1476840245d..5584a52412f 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -41,6 +41,7 @@ import { loadWorkflowDeploymentSnapshot, saveWorkflowToNormalizedTables, undeployWorkflow, + updateDeploymentVersionMetadata, } from '@/lib/workflows/persistence/utils' import { validateWorkflowSchedules } from '@/lib/workflows/schedules' import { emitWorkflowUndeployedEvent } from '@/lib/workspace-events/emitter' @@ -595,6 +596,10 @@ export interface PerformActivateVersionParams { workflowId: string version: number userId: string + /** Metadata committed atomically with activation admission. */ + name?: string | null + /** Metadata committed atomically with activation admission. */ + description?: string | null /** Stable identity for one logical activation operation. */ idempotencyKey?: string /** Correlation ID for logging and outbox tracing. */ @@ -613,6 +618,8 @@ export interface PerformActivateVersionResult { error?: string errorCode?: OrchestrationErrorCode warnings?: string[] + name?: string | null + description?: string | null } export interface PerformRevertToVersionParams { @@ -625,6 +632,9 @@ export interface PerformRevertToVersionParams { actorId?: string actorName?: string actorEmail?: string + captureAnalytics?: false + projectLegacyAudit?: boolean + notifyRealtime?: boolean } export interface PerformRevertToVersionResult { @@ -637,6 +647,10 @@ export interface PerformRevertToVersionResult { /** * Admits an existing version through the v2 prepare/activate protocol. Callers * that can replay a logical operation must provide a stable `idempotencyKey`. + * Optional metadata is committed in the same transaction as a new activation + * attempt. A metadata failure rolls back admission; a later preparation failure + * is returned as a failure even though the already-admitted attempt and its + * metadata remain durable and retryable through the deployment outbox. */ export async function performActivateVersion( params: PerformActivateVersionParams @@ -654,6 +668,8 @@ export async function performActivateVersion( id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state, isActive: workflowDeploymentVersion.isActive, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, }) .from(workflowDeploymentVersion) .where( @@ -669,6 +685,15 @@ export async function performActivateVersion( } if (versionRow.isActive) { + const metadata = await updateDeploymentVersionMetadata({ + workflowId, + version, + name: params.name, + description: params.description, + }) + if (!metadata) { + return { success: false, error: 'Deployment version not found', errorCode: 'not_found' } + } const [workflowDeployment] = await db .select({ deployedAt: workflowTable.deployedAt }) .from(workflowTable) @@ -683,6 +708,7 @@ export async function performActivateVersion( activeDeployment: stableResult.activeDeployment, latestDeploymentAttempt: stableResult.latestDeploymentAttempt, warnings: stableResult.warnings, + ...metadata, } } @@ -721,6 +747,8 @@ export async function performActivateVersion( actorId, actor: params.actor, captureAnalytics: params.captureAnalytics, + name: params.name, + description: params.description, requestId, idempotencyKey, }) @@ -746,6 +774,8 @@ async function performStableVersionActivation(params: { actorId: string actor?: PrincipalActor captureAnalytics?: false + name?: string | null + description?: string | null requestId: string idempotencyKey: string }): Promise { @@ -755,8 +785,11 @@ async function performStableVersionActivation(params: { deploymentVersionId: params.deploymentVersionId, version: params.version, userId: params.userId, + name: params.name, + description: params.description, }) let outboxEventId: string | undefined + let metadata: { name: string | null; description: string | null } | undefined const prepared = await prepareWorkflowVersionActivation({ workflowId: params.workflowId, deploymentVersionId: params.deploymentVersionId, @@ -768,6 +801,15 @@ async function performStableVersionActivation(params: { if (!operation.deploymentVersionId || operation.version === null) { throw new Error('Prepared activation operation is missing its target version') } + metadata = + (await updateDeploymentVersionMetadata({ + workflowId: operation.workflowId, + version: operation.version, + name: params.name, + description: params.description, + tx, + })) ?? undefined + if (!metadata) throw new Error('Deployment version disappeared during activation admission') outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, { protocolVersion: operation.protocolVersion, operationId: operation.id, @@ -792,10 +834,19 @@ async function performStableVersionActivation(params: { } } + metadata ??= + (await updateDeploymentVersionMetadata({ + workflowId: params.workflowId, + version: params.version, + })) ?? undefined + if (!metadata) { + return { success: false, error: 'Deployment version not found', errorCode: 'not_found' } + } + const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId) const status = await getWorkflowDeploymentStatus(params.workflowId) const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, status) - if (inlineFailure) return inlineFailure + if (inlineFailure) return { ...inlineFailure, ...metadata } const result = buildStableDeploymentResult(status, processResult) return { success: result.success, @@ -803,6 +854,7 @@ async function performStableVersionActivation(params: { activeDeployment: result.activeDeployment, latestDeploymentAttempt: result.latestDeploymentAttempt, warnings: result.warnings, + ...metadata, } } @@ -947,46 +999,52 @@ export async function performRevertToVersion( } } - try { - await fetch(`${getSocketServerUrl()}/api/workflow-reverted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId, timestamp: lastSaved }), - }) - } catch (error) { - logger.error('Error sending workflow reverted event to socket server', error) + if (params.notifyRealtime !== false) { + try { + await fetch(`${getSocketServerUrl()}/api/workflow-reverted`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId, timestamp: lastSaved }), + }) + } catch (error) { + logger.error('Error sending workflow reverted event to socket server', error) + } } const workspaceId = (workflow.workspaceId as string) || '' - captureServerEvent( - userId, - 'workflow_deployment_reverted', - { - workflow_id: workflowId, - workspace_id: workspaceId, - version: versionLabel, - }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined - ) + if (params.captureAnalytics !== false) { + captureServerEvent( + userId, + 'workflow_deployment_reverted', + { + workflow_id: workflowId, + workspace_id: workspaceId, + version: versionLabel, + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } - recordAudit({ - workspaceId: workspaceId || null, - actorId, - actorName: params.actorName, - actorEmail: params.actorEmail, - action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: (workflow.name as string) || undefined, - description: `Reverted workflow to deployment version ${versionLabel}`, - metadata: { - targetVersion: versionLabel, - }, - request: params.request, - }) + if (params.projectLegacyAudit !== false) { + recordAudit({ + workspaceId: workspaceId || null, + actorId, + actorName: params.actorName, + actorEmail: params.actorEmail, + action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: (workflow.name as string) || undefined, + description: `Reverted workflow to deployment version ${versionLabel}`, + metadata: { + targetVersion: versionLabel, + }, + request: params.request, + }) + } return { success: true, diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index e8c5d0d1ea3..18210c81e8a 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -8,6 +8,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, min, ne } from 'drizzle-orm' import type { OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import type { DbOrTx } from '@/lib/db/types' import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle' @@ -63,6 +64,7 @@ export interface PerformUpdateWorkflowParams { locked?: boolean forkSyncExcluded?: boolean requestId?: string + tx?: DbOrTx } export interface PerformUpdateWorkflowResult { @@ -92,6 +94,8 @@ export interface PerformDeleteWorkflowParams { skipLastWorkflowGuard?: boolean /** Override the actor ID used in audit logs. Defaults to `userId`. */ actorId?: string + /** Legacy lifecycle notification; application commands project their own semantic event. */ + notifySocket?: boolean } export interface PerformDeleteWorkflowResult { @@ -169,7 +173,9 @@ async function workflowNameExistsInFolder(params: { name: string folderId?: string | null excludeWorkflowId?: string + tx?: DbOrTx }): Promise { + const executor = params.tx ?? db const conditions = [ eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt), @@ -186,7 +192,7 @@ async function workflowNameExistsInFolder(params: { conditions.push(isNull(workflow.folderId)) } - const [duplicateWorkflow] = await db + const [duplicateWorkflow] = await executor .select({ id: workflow.id }) .from(workflow) .where(and(...conditions)) @@ -194,6 +200,27 @@ async function workflowNameExistsInFolder(params: { return Boolean(duplicateWorkflow) } +async function isWorkflowFolderInWorkspace( + folderId: string | null | undefined, + workspaceId: string, + executor: DbOrTx = db +): Promise { + if (!folderId) return true + const [row] = await executor + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + eq(folderTable.id, folderId), + eq(folderTable.workspaceId, workspaceId), + eq(folderTable.resourceType, 'workflow'), + isNull(folderTable.deletedAt) + ) + ) + .limit(1) + return Boolean(row) +} + export async function performCreateWorkflowTransition( params: PerformCreateWorkflowParams ): Promise { @@ -304,6 +331,7 @@ export async function performCreateWorkflow( export async function updateWorkflowRecord( params: PerformUpdateWorkflowParams ): Promise { + const executor = params.tx ?? db const requestId = params.requestId ?? generateRequestId() const targetName = params.name ?? params.currentName const targetFolderId = @@ -311,7 +339,7 @@ export async function updateWorkflowRecord( if ( params.folderId !== undefined && - !(await isFolderInWorkspace(targetFolderId, params.workspaceId)) + !(await isWorkflowFolderInWorkspace(targetFolderId, params.workspaceId, executor)) ) { return { success: false, error: 'Target folder not found', errorCode: 'validation' } } @@ -322,6 +350,7 @@ export async function updateWorkflowRecord( name: targetName, folderId: targetFolderId, excludeWorkflowId: params.workflowId, + tx: executor, }) if (duplicate) { return { @@ -340,7 +369,7 @@ export async function updateWorkflowRecord( if (params.locked !== undefined) updateData.locked = params.locked if (params.forkSyncExcluded !== undefined) updateData.forkSyncExcluded = params.forkSyncExcluded - const [updatedWorkflow] = await db + const [updatedWorkflow] = await executor .update(workflow) .set(updateData) .where( @@ -478,7 +507,10 @@ export async function deleteWorkflowRecord( } } - const archiveResult = await archiveWorkflow(workflowId, { requestId }) + const archiveResult = await archiveWorkflow(workflowId, { + requestId, + notifySocket: params.notifySocket, + }) if (!archiveResult.workflow) { return { success: false, error: 'Workflow not found', errorCode: 'not_found' } } diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index fff063f8ab3..60aac83b1bd 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -618,13 +618,15 @@ export async function updateDeploymentVersionMetadata(params: { version: number name?: string | null description?: string | null + tx?: DbOrTx }): Promise<{ name: string | null; description: string | null } | null> { + const executor = params.tx ?? db const updateData: { name?: string | null; description?: string | null } = {} if (params.name !== undefined) updateData.name = params.name if (params.description !== undefined) updateData.description = params.description if (Object.keys(updateData).length === 0) { - const [row] = await db + const [row] = await executor .select({ name: workflowDeploymentVersion.name, description: workflowDeploymentVersion.description, @@ -640,7 +642,7 @@ export async function updateDeploymentVersionMetadata(params: { return row ?? null } - const [updated] = await db + const [updated] = await executor .update(workflowDeploymentVersion) .set(updateData) .where( @@ -912,7 +914,7 @@ export async function findPreviousDeploymentVersion( */ export async function getWorkflowDeploymentVersion( workflowId: string, - version: number + version: number | 'active' ): Promise<{ id: string version: number @@ -922,6 +924,10 @@ export async function getWorkflowDeploymentVersion( createdAt: Date state: unknown } | null> { + const versionPredicate = + version === 'active' + ? eq(workflowDeploymentVersion.isActive, true) + : eq(workflowDeploymentVersion.version, version) const [row] = await db .select({ id: workflowDeploymentVersion.id, @@ -933,12 +939,7 @@ export async function getWorkflowDeploymentVersion( state: workflowDeploymentVersion.state, }) .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, version) - ) - ) + .where(and(eq(workflowDeploymentVersion.workflowId, workflowId), versionPredicate)) .limit(1) return row ?? null diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index dba3e7da931..4ffa34a5eb3 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -74,6 +74,27 @@ export const fileOperations = { workspaceApiKey: 'allow', ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), + createVfsFolders: defineWorkspaceOperation({ + id: 'files.vfs.folders.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + relocateVfsItems: defineWorkspaceOperation({ + id: 'files.vfs.relocate', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + deleteVfsItems: defineWorkspaceOperation({ + id: 'files.vfs.delete', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), delete: defineWorkspaceOperation({ id: 'files.delete', minimumRole: 'write', diff --git a/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts b/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts new file mode 100644 index 00000000000..6df8c570b94 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-vfs.ts @@ -0,0 +1,486 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { buildFolderPath, FolderPathError } from '@/lib/folders/paths' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + bulkArchiveWorkspaceFileItems, + createWorkspaceFileFolderAtPath, + findWorkspaceFileFolderIdByPath, + getWorkspaceFileByName, + loadWorkspaceFileOperationContext, + moveRenameWorkspaceFile, + relocateWorkspaceFileFolderByPath, + WorkspaceFileFolderConflictError, + WorkspaceFileMoveConflictError, +} from '@/lib/uploads/contexts/workspace' +import { VfsPathLimitError, validateVfsPathSegments } from '@/lib/vfs/limits' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const MAX_FILE_VFS_ITEMS = 100 + +export interface WorkspaceFileVfsPathReference { + source: string + segments: string[] +} + +export interface WorkspaceFileVfsDestination { + segments: string[] + trailingSlash: boolean +} + +export interface WorkspaceFileVfsOutcome { + source: string + targetSegments?: string[] + resourceType: 'file' | 'folder' + resourceId?: string + error?: string +} + +interface CreatedFolder { + id: string + name: string + path: string +} + +interface RelocatedFile { + id: string + name: string + moved: boolean + renamed: boolean +} + +interface RelocatedFolder { + id: string + name: string + sourcePath: string + destinationPath: string +} + +function normalizeReferences( + references: readonly WorkspaceFileVfsPathReference[] +): WorkspaceFileVfsPathReference[] { + if (references.length > MAX_FILE_VFS_ITEMS) { + throw new OrchestrationError( + 'validation', + `File VFS commands cannot exceed ${MAX_FILE_VFS_ITEMS} items` + ) + } + const unique = new Map() + for (const reference of references) { + try { + validateVfsPathSegments(reference.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + const key = buildFolderPath(reference.segments) + if (!unique.has(key)) unique.set(key, reference) + } + if (unique.size === 0) throw new OrchestrationError('validation', 'At least one path is required') + return [...unique.values()] +} + +function expectedOutcomeMessage(error: unknown): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + if ( + error instanceof WorkspaceFileFolderConflictError || + error instanceof WorkspaceFileMoveConflictError || + error instanceof FolderPathError + ) { + return error.message + } + throw error +} + +async function ensureFolderPath(params: { + workspaceId: string + userId: string + segments: readonly string[] + createdFolders: CreatedFolder[] +}): Promise { + let folderId: string | null = null + for (let index = 0; index < params.segments.length; index += 1) { + const segments = params.segments.slice(0, index + 1) + const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, [...segments]) + if (existing) { + folderId = existing + continue + } + const path = buildFolderPath(segments) + try { + const created = await createWorkspaceFileFolderAtPath({ + workspaceId: params.workspaceId, + userId: params.userId, + path, + }) + folderId = created.folder.id + params.createdFolders.push({ + id: created.folder.id, + name: created.folder.name, + path: created.path, + }) + } catch (error) { + if (error instanceof WorkspaceFileFolderConflictError) { + const concurrentlyCreated = await findWorkspaceFileFolderIdByPath(params.workspaceId, [ + ...segments, + ]) + if (concurrentlyCreated) { + folderId = concurrentlyCreated + continue + } + } + throw error + } + } + return folderId +} + +async function resolveSource( + workspaceId: string, + reference: WorkspaceFileVfsPathReference +): Promise< + | { source: string; file: NonNullable>> } + | { source: string; folderId: string } + | { source: string; error: string } +> { + if (reference.segments.length === 0) { + return { source: reference.source, error: 'Source must name a file or folder under files/' } + } + const parentSegments = reference.segments.slice(0, -1) + const folderId = + parentSegments.length === 0 + ? null + : await findWorkspaceFileFolderIdByPath(workspaceId, parentSegments) + if (parentSegments.length === 0 || folderId) { + const file = await getWorkspaceFileByName(workspaceId, reference.segments.at(-1) as string, { + folderId, + }) + if (file) return { source: reference.source, file } + } + const sourceFolderId = await findWorkspaceFileFolderIdByPath(workspaceId, reference.segments) + return sourceFolderId + ? { source: reference.source, folderId: sourceFolderId } + : { source: reference.source, error: `Not found: ${reference.source}` } +} + +function createdFolderAudits(folders: readonly CreatedFolder[]) { + return folders.map((folder) => ({ + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created file folder "${folder.path}"`, + metadata: { path: folder.path, folderResourceType: 'file' }, + })) +} + +export interface CreateWorkspaceFileVfsFoldersInput { + workspaceId: string + paths: WorkspaceFileVfsPathReference[] +} + +export const createWorkspaceFileVfsFolders = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.createVfsFolders, + resolveContext: ({ input }: { input: CreateWorkspaceFileVfsFoldersInput }) => + loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }), + async execute({ principal, input, context }) { + const paths = normalizeReferences(input.paths) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const createdFolders: CreatedFolder[] = [] + const outcomes: WorkspaceFileVfsOutcome[] = [] + for (const path of paths) { + if (path.segments.length === 0) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: 'Path must include at least one folder segment', + }) + continue + } + try { + const folderId = await ensureFolderPath({ + workspaceId: context.workspaceId, + userId, + segments: path.segments, + createdFolders, + }) + outcomes.push({ + source: path.source, + targetSegments: path.segments, + resourceType: 'folder', + resourceId: folderId ?? undefined, + }) + } catch (error) { + outcomes.push({ + source: path.source, + resourceType: 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, createdFolders } + }, + projectAudit: ({ result }) => createdFolderAudits(result.createdFolders), + afterSuccess: ({ context, result }) => + result.createdFolders.length > 0 ? notifyWorkspaceFilesChanged(context.workspaceId) : undefined, +}) + +export interface RelocateWorkspaceFileVfsItemsInput { + workspaceId: string + sources: WorkspaceFileVfsPathReference[] + destination: WorkspaceFileVfsDestination +} + +export const relocateWorkspaceFileVfsItems = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.relocateVfsItems, + resolveContext: ({ input }: { input: RelocateWorkspaceFileVfsItemsInput }) => + loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }), + async execute({ principal, input, context }) { + const sources = normalizeReferences(input.sources) + try { + validateVfsPathSegments(input.destination.segments) + } catch (error) { + if (error instanceof VfsPathLimitError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + const references = [] + for (const source of sources) { + references.push(await resolveSource(context.workspaceId, source)) + } + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const destinationPath = buildFolderPath(input.destination.segments) + const existingDestination = await findWorkspaceFileFolderIdByPath( + context.workspaceId, + input.destination.segments + ) + const dirMode = + input.destination.segments.length === 0 || + input.destination.trailingSlash || + existingDestination !== null + if (!dirMode && sources.length > 1) { + throw new OrchestrationError( + 'validation', + `With multiple sources the destination must be a folder. "${destinationPath}" does not exist — end it with "/" to create it.` + ) + } + const folderSegments = dirMode + ? input.destination.segments + : input.destination.segments.slice(0, -1) + const leafName = dirMode ? undefined : input.destination.segments.at(-1) + const createdFolders: CreatedFolder[] = [] + let targetFolderPromise: Promise | undefined + const targetFolderId = () => + (targetFolderPromise ??= ensureFolderPath({ + workspaceId: context.workspaceId, + userId, + segments: folderSegments, + createdFolders, + })) + const outcomes: WorkspaceFileVfsOutcome[] = [] + const relocatedFiles: RelocatedFile[] = [] + const relocatedFolders: RelocatedFolder[] = [] + + for (const reference of references) { + if ('error' in reference) { + outcomes.push({ source: reference.source, resourceType: 'file', error: reference.error }) + continue + } + try { + const targetId = await targetFolderId() + if ('file' in reference) { + const name = leafName ?? reference.file.name + const result = await moveRenameWorkspaceFile({ + workspaceId: context.workspaceId, + fileId: reference.file.id, + targetFolderId: targetId, + newName: name, + }) + relocatedFiles.push({ + id: result.file.id, + name: result.file.name, + moved: result.moved, + renamed: result.renamed, + }) + outcomes.push({ + source: reference.source, + targetSegments: [...folderSegments, result.file.name], + resourceType: 'file', + resourceId: result.file.id, + }) + continue + } + if (targetId === reference.folderId) { + outcomes.push({ + source: reference.source, + resourceType: 'folder', + error: 'Cannot move a folder into itself', + }) + continue + } + const sourcePath = buildFolderPath( + sources.find((source) => source.source === reference.source)?.segments ?? [] + ) + const name = + leafName ?? sources.find((source) => source.source === reference.source)?.segments.at(-1) + if (!name) throw new OrchestrationError('validation', 'Folder name is required') + const nextPath = buildFolderPath([...folderSegments, name]) + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: context.workspaceId, + path: sourcePath, + destinationPath: nextPath, + }) + relocatedFolders.push({ + id: result.folder.id, + name: result.folder.name, + sourcePath, + destinationPath: result.path, + }) + outcomes.push({ + source: reference.source, + targetSegments: [...folderSegments, result.folder.name], + resourceType: 'folder', + resourceId: result.folder.id, + }) + } catch (error) { + outcomes.push({ + source: reference.source, + resourceType: 'file' in reference ? 'file' : 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, createdFolders, relocatedFiles, relocatedFolders } + }, + projectAudit: ({ result }) => [ + ...createdFolderAudits(result.createdFolders), + ...result.relocatedFiles + .filter((file) => file.moved || file.renamed) + .map((file) => ({ + action: file.moved ? AuditAction.FILE_MOVED : AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Relocated file "${file.name}"`, + metadata: { moved: file.moved, renamed: file.renamed }, + })), + ...result.relocatedFolders.map((folder) => ({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Moved file folder to "${folder.destinationPath}"`, + metadata: { sourcePath: folder.sourcePath, destinationPath: folder.destinationPath }, + })), + ], + afterSuccess: ({ context, result }) => + result.createdFolders.length > 0 || + result.relocatedFiles.some((file) => file.moved || file.renamed) || + result.relocatedFolders.length > 0 + ? notifyWorkspaceFilesChanged(context.workspaceId) + : undefined, +}) + +export interface DeleteWorkspaceFileVfsItemsInput { + workspaceId: string + paths: WorkspaceFileVfsPathReference[] +} + +export const deleteWorkspaceFileVfsItems = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.deleteVfsItems, + resolveContext: ({ input }: { input: DeleteWorkspaceFileVfsItemsInput }) => + loadWorkspaceFileOperationContext(input.workspaceId).then((context) => { + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }), + async execute({ input, context }) { + const paths = normalizeReferences(input.paths) + const references = [] + for (const path of paths) { + references.push(await resolveSource(context.workspaceId, path)) + } + const outcomes: WorkspaceFileVfsOutcome[] = [] + const deletedFiles: Array<{ id: string; name: string }> = [] + const deletedFolders: Array<{ id: string; path: string }> = [] + for (const reference of references) { + if ('error' in reference) { + outcomes.push({ source: reference.source, resourceType: 'file', error: reference.error }) + continue + } + try { + if ('file' in reference) { + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds: [reference.file.id], + }) + if (!archived.fileIds.includes(reference.file.id)) { + throw new OrchestrationError('not_found', 'File not found') + } + deletedFiles.push({ id: reference.file.id, name: reference.file.name }) + outcomes.push({ + source: reference.source, + resourceType: 'file', + resourceId: reference.file.id, + }) + continue + } + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + folderIds: [reference.folderId], + }) + if (!archived.folderIds.includes(reference.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } + deletedFolders.push({ id: reference.folderId, path: reference.source }) + outcomes.push({ + source: reference.source, + resourceType: 'folder', + resourceId: reference.folderId, + }) + } catch (error) { + outcomes.push({ + source: reference.source, + resourceType: 'file' in reference ? 'file' : 'folder', + error: expectedOutcomeMessage(error), + }) + } + } + return { outcomes, deletedFiles, deletedFolders } + }, + projectAudit: ({ result }) => [ + ...result.deletedFiles.map((file) => ({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Archived file "${file.name}"`, + })), + ...result.deletedFolders.map((folder) => ({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + description: `Archived file folder "${folder.path}"`, + metadata: { path: folder.path }, + })), + ], + afterSuccess: ({ context, result }) => + result.deletedFiles.length > 0 || result.deletedFolders.length > 0 + ? notifyWorkspaceFilesChanged(context.workspaceId) + : undefined, +}) diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index c550b19dbcf..e5ade86d4ac 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1,5 +1,16 @@ import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const workflowMetadataMocks = vi.hoisted(() => ({ + buildAPIUrl: vi.fn((path: string) => new URL(path, 'https://sim.local')), + buildExecutorDelegationHeaders: vi.fn(), +})) + +vi.mock('@/executor/utils/http', () => ({ + buildAPIUrl: workflowMetadataMocks.buildAPIUrl, + buildExecutorDelegationHeaders: workflowMetadataMocks.buildExecutorDelegationHeaders, +})) + import { calculateCost, describeModelLevel, @@ -1841,6 +1852,136 @@ describe('prepareToolExecution invoker identity hand-off', () => { }) }) +describe('workflow executor metadata delegation', () => { + const workflowBlock = { + type: 'workflow', + name: 'Workflow', + description: 'Execute a workflow', + inputs: {}, + subBlocks: [], + tools: { access: ['workflow_executor'] }, + } + const workflowTool = { + id: 'workflow_executor', + name: 'Workflow Executor', + description: 'Execute another workflow', + params: { + workflowId: { + type: 'string' as const, + required: true, + visibility: 'user-only' as const, + }, + }, + } + + beforeEach(() => { + vi.clearAllMocks() + workflowMetadataMocks.buildExecutorDelegationHeaders.mockResolvedValue({ + 'Content-Type': 'application/json', + Authorization: 'Bearer delegated-token', + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('binds cross-workflow metadata reads to the target without attaching the parent run', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ data: { name: 'Child Workflow', description: 'Child description' } }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await transformBlockTool( + { type: 'workflow', params: { workflowId: 'child-workflow' } }, + { + getAllBlocks: () => [workflowBlock], + getTool: () => workflowTool, + enrichmentContext: { + workflowId: 'parent-workflow', + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + }, + } + ) + + expect(workflowMetadataMocks.buildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'user-1', + workflowId: 'child-workflow', + }) + expect(fetchMock).toHaveBeenCalledWith('https://sim.local/api/workflows/child-workflow', { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer delegated-token', + }, + }) + expect(result).toMatchObject({ + id: 'workflow_executor_child-workflow', + name: 'Child Workflow', + description: 'Child description', + }) + }) + + it('includes the run binding when the metadata target is the executing workflow', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { name: 'Current Workflow', description: null } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + + await transformBlockTool( + { type: 'workflow', params: { workflowId: 'current-workflow' } }, + { + getAllBlocks: () => [workflowBlock], + getTool: () => workflowTool, + enrichmentContext: { + workflowId: 'current-workflow', + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + }, + } + ) + + expect(workflowMetadataMocks.buildExecutorDelegationHeaders).toHaveBeenCalledWith({ + subjectUserId: 'user-1', + workflowId: 'current-workflow', + executionId: 'execution-1', + }) + }) + + it('does not issue an actorless fallback token without a trusted execution subject', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const result = await transformBlockTool( + { type: 'workflow', params: { workflowId: 'child-workflow' } }, + { + getAllBlocks: () => [workflowBlock], + getTool: () => workflowTool, + } + ) + + expect(workflowMetadataMocks.buildExecutorDelegationHeaders).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ + id: 'workflow_executor_child-workflow', + name: 'Workflow Executor', + description: 'Execute another workflow', + }) + }) +}) + /** * The agent block's tuning-level fields accept variable and environment references, so any * message that echoes a caller-supplied level can otherwise carry whatever that reference diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 7d805394ea8..22e34f528ac 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -77,12 +77,22 @@ function isDefaultWorkflowDescription( * Fetches workflow metadata (name and description) from the API */ async function fetchWorkflowMetadata( - workflowId: string + workflowId: string, + executionContext: WorkflowToolExecutionContext | undefined ): Promise<{ name: string; description: string | null } | null> { try { - const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') - - const headers = await buildAuthHeaders() + if (!executionContext?.userId) { + throw new Error('Workflow metadata enrichment requires a trusted execution subject') + } + const { buildAPIUrl, buildExecutorDelegationHeaders } = await import('@/executor/utils/http') + + const headers = await buildExecutorDelegationHeaders({ + subjectUserId: executionContext.userId, + workflowId, + ...(executionContext.workflowId === workflowId && executionContext.executionId + ? { executionId: executionContext.executionId } + : {}), + }) const url = buildAPIUrl(`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) @@ -787,7 +797,10 @@ export async function transformBlockTool( if (toolId === 'workflow_executor' && resolvedResourceParams.workflowId) { uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.workflowId}` - const workflowMetadata = await fetchWorkflowMetadata(resolvedResourceParams.workflowId) + const workflowMetadata = await fetchWorkflowMetadata( + resolvedResourceParams.workflowId, + enrichmentContext + ) if (workflowMetadata) { toolName = workflowMetadata.name || toolConfig.name if ( diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index cb94f7fa435..f33e8269094 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -185,6 +185,7 @@ export const AuditAction = { // Workflows WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_UPDATED: 'workflow.updated', WORKFLOW_DELETED: 'workflow.deleted', WORKFLOW_RESTORED: 'workflow.restored', WORKFLOW_DEPLOYED: 'workflow.deployed',