Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
417 changes: 68 additions & 349 deletions apps/sim/app/api/resume/resume-handler.ts

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions apps/sim/app/api/v2/workflows/[id]/deploy/route.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
/**
* @vitest-environment node
*/
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', () => ({
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 {
v2DeployWorkflowContract,
v2UndeployWorkflowContract,
} from '@/lib/api/contracts/v2/workflows'
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments'
import { workflowOperations } from '@/lib/workflows/application/operations'
import { DELETE, POST } from '@/app/api/v2/workflows/[id]/deploy/route'

describe('/api/v2/workflows/[id]/deploy route definitions', () => {
it('keeps an omitted deploy body valid and binds the authorized deployment use case', async () => {
expect(v2DeployWorkflowContract.body?.parse(undefined)).toEqual({})
expect(POST).toMatchObject({
operation: workflowOperations.deploy,
useCase: deployWorkflow,
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
parseOptions: { optionalJsonBody: true },
})
expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual(
expect.objectContaining({
workflowId: 'workflow-1',
name: undefined,
description: undefined,
})
)

const invalidJsonResponse = Reflect.get(
Reflect.get(POST, 'parseOptions'),
'invalidJsonResponse'
)()
expect(invalidJsonResponse.status).toBe(400)
expect(await invalidJsonResponse.json()).toEqual({
error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' },
})

const payloadTooLargeResponse = Reflect.get(
Reflect.get(POST, 'parseOptions'),
'payloadTooLargeResponse'
)()
expect(payloadTooLargeResponse.status).toBe(413)
expect(await payloadTooLargeResponse.json()).toEqual({
error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' },
})
})

it('presents the full declared deployment lifecycle response', () => {
const body = Reflect.get(
POST,
'present'
)({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
deployedAt: new Date('2026-01-01T00:00:00.000Z'),
version: 2,
warnings: [],
activeDeployment: null,
latestDeploymentAttempt: null,
})
expect(body.data.isDeployed).toBe(false)
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('keeps undeploy on the authorized operation and declared response schema', () => {
expect(DELETE).toMatchObject({
operation: workflowOperations.undeploy,
useCase: undeployWorkflow,
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
})
const body = Reflect.get(
DELETE,
'present'
)({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
warnings: [],
})
expect(v2UndeployWorkflowContract.response.schema.parse(body)).toEqual(body)
})
})
192 changes: 72 additions & 120 deletions apps/sim/app/api/v2/workflows/[id]/deploy/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,91 @@
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows'
import {
v2DeployWorkflowContract,
v2UndeployWorkflowContract,
} from '@/lib/api/contracts/v2/workflows'
import { parseOptionalJsonBody } from '@/lib/api/server'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler'
import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response'
import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils'

const logger = createLogger('V2WorkflowDeployAPI')
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments'
import { workflowOperations } from '@/lib/workflows/application/operations'
import { v2Error } from '@/app/api/v2/lib/response'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 120

export const POST = withPublicApiRouteHandler({
export const POST = defineV2JsonRoute({
contract: v2DeployWorkflowContract,
rateLimitEndpoint: 'workflow-deploy',
handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => {
try {
const { id } = input.params

const rawBody = await parseOptionalJsonBody(request)
if (!rawBody.success) {
return rawBody.response.status === 413
? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large')
: v2Error('BAD_REQUEST', 'Request body must be valid JSON')
}
const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {})
if (!body.success) return v2ValidationError(body.error)

const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin')
if (!target) return v2Error('NOT_FOUND', 'Workflow not found')
const { workspaceId } = target

await assertWorkflowMutable(id)

logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId })

const result = await performFullDeploy({
workflowId: id,
userId,
versionName: body.data.name,
versionDescription: body.data.description ?? undefined,
requestId,
})

if (!result.success) {
const code =
result.errorCode === 'not_found'
? 'NOT_FOUND'
: result.errorCode === 'validation'
? 'BAD_REQUEST'
: 'INTERNAL_ERROR'
return v2Error(code, result.error || 'Failed to deploy workflow')
}

captureServerEvent(
userId,
'workflow_deployed',
{ workflow_id: id, workspace_id: workspaceId },
{
groups: { workspace: workspaceId },
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
}
)

return v2Data(
{
id,
isDeployed: true,
deployedAt: result.deployedAt?.toISOString() ?? null,
version: result.version,
warnings: result.warnings ?? [],
},
{ rateLimit }
)
} catch (error) {
if (error instanceof WorkflowLockedError) {
return v2Error('LOCKED', error.message)
}
throw error
auth: v2ApiKeyAuth,
operation: workflowOperations.deploy,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
parseOptions: {
optionalJsonBody: true,
invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'),
payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'),
},
mapInput: ({ params, body }) => ({
workflowId: params.id,
name: body.name,
description: body.description ?? undefined,
requestId: generateRequestId(),
}),
useCase: deployWorkflow,
present: (result) => ({
data: {
id: result.workflowId,
isDeployed: Boolean(result.activeDeployment),
deployedAt: result.deployedAt?.toISOString() ?? null,
version: result.version,
warnings: result.warnings ?? [],
activeDeployment: result.activeDeployment ?? null,
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 = withPublicApiRouteHandler({
export const DELETE = defineV2JsonRoute({
contract: v2UndeployWorkflowContract,
rateLimitEndpoint: 'workflow-deploy',
handler: async ({ input, auth: { requestId, userId, rateLimit } }) => {
try {
const { id } = input.params

const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin')
if (!target) return v2Error('NOT_FOUND', 'Workflow not found')
const { workflow, workspaceId } = target

if (!workflow.isDeployed) {
return v2Error('BAD_REQUEST', 'Workflow is not deployed')
}

await assertWorkflowMutable(id)

logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId })

const result = await performFullUndeploy({ workflowId: id, userId, requestId })
if (!result.success) {
return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow')
}

captureServerEvent(
userId,
'workflow_undeployed',
{ workflow_id: id, workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)

return v2Data(
{
id,
isDeployed: false,
deployedAt: null,
warnings: result.warnings ?? [],
},
{ rateLimit }
)
} catch (error) {
if (error instanceof WorkflowLockedError) {
return v2Error('LOCKED', error.message)
}
throw error
auth: v2ApiKeyAuth,
operation: workflowOperations.undeploy,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }),
useCase: undeployWorkflow,
present: (result) => ({
data: {
id: result.workflowId,
isDeployed: false,
deployedAt: null,
warnings: result.warnings ?? [],
activeDeployment: null,
latestDeploymentAttempt: null,
},
}),
onSuccess: ({ principal, result }) => {
if (principal.kind !== 'personal_api_key') {
throw new Error('Admin undeployment unexpectedly admitted a workspace API key')
}
captureServerEvent(
principal.userId,
'workflow_undeployed',
{ workflow_id: result.workflowId, workspace_id: result.workspaceId },
{ groups: { workspace: result.workspaceId } }
)
},
})
Loading
Loading