Skip to content

Commit d22e6bd

Browse files
improvement(workflows): centralize v2 application operations (#6411)
1 parent 464bbf6 commit d22e6bd

80 files changed

Lines changed: 6607 additions & 3498 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/resume/resume-handler.ts

Lines changed: 68 additions & 349 deletions
Large diffs are not rendered by default.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
defineRoute: vi.fn((definition) => definition),
8+
capture: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/api/server/routes', () => ({
12+
defineV2JsonRoute: mocks.defineRoute,
13+
v2ApiKeyAuth: { kind: 'v2-api-key' },
14+
v2RateLimits: { publicApi: { kind: 'public-api' } },
15+
v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' },
16+
}))
17+
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))
18+
19+
import {
20+
v2DeployWorkflowContract,
21+
v2UndeployWorkflowContract,
22+
} from '@/lib/api/contracts/v2/workflows'
23+
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
24+
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments'
25+
import { workflowOperations } from '@/lib/workflows/application/operations'
26+
import { DELETE, POST } from '@/app/api/v2/workflows/[id]/deploy/route'
27+
28+
describe('/api/v2/workflows/[id]/deploy route definitions', () => {
29+
it('keeps an omitted deploy body valid and binds the authorized deployment use case', async () => {
30+
expect(v2DeployWorkflowContract.body?.parse(undefined)).toEqual({})
31+
expect(POST).toMatchObject({
32+
operation: workflowOperations.deploy,
33+
useCase: deployWorkflow,
34+
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
35+
parseOptions: { optionalJsonBody: true },
36+
})
37+
expect(Reflect.get(POST, 'mapInput')({ params: { id: 'workflow-1' }, body: {} })).toEqual(
38+
expect.objectContaining({
39+
workflowId: 'workflow-1',
40+
name: undefined,
41+
description: undefined,
42+
})
43+
)
44+
45+
const invalidJsonResponse = Reflect.get(
46+
Reflect.get(POST, 'parseOptions'),
47+
'invalidJsonResponse'
48+
)()
49+
expect(invalidJsonResponse.status).toBe(400)
50+
expect(await invalidJsonResponse.json()).toEqual({
51+
error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' },
52+
})
53+
54+
const payloadTooLargeResponse = Reflect.get(
55+
Reflect.get(POST, 'parseOptions'),
56+
'payloadTooLargeResponse'
57+
)()
58+
expect(payloadTooLargeResponse.status).toBe(413)
59+
expect(await payloadTooLargeResponse.json()).toEqual({
60+
error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' },
61+
})
62+
})
63+
64+
it('presents the full declared deployment lifecycle response', () => {
65+
const body = Reflect.get(
66+
POST,
67+
'present'
68+
)({
69+
workflowId: 'workflow-1',
70+
workspaceId: 'workspace-1',
71+
deployedAt: new Date('2026-01-01T00:00:00.000Z'),
72+
version: 2,
73+
warnings: [],
74+
activeDeployment: null,
75+
latestDeploymentAttempt: null,
76+
})
77+
expect(body.data.isDeployed).toBe(false)
78+
expect(v2DeployWorkflowContract.response.schema.parse(body)).toEqual(body)
79+
})
80+
81+
it('keeps product analytics on the v2 adapter', async () => {
82+
const result = { workflowId: 'workflow-1', workspaceId: 'workspace-1' }
83+
await Reflect.get(
84+
POST,
85+
'onSuccess'
86+
)({
87+
principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' },
88+
result,
89+
})
90+
expect(mocks.capture).toHaveBeenCalledWith(
91+
'user-1',
92+
'workflow_deployed',
93+
{ workflow_id: 'workflow-1', workspace_id: 'workspace-1' },
94+
expect.objectContaining({ groups: { workspace: 'workspace-1' } })
95+
)
96+
})
97+
98+
it('keeps undeploy on the authorized operation and declared response schema', () => {
99+
expect(DELETE).toMatchObject({
100+
operation: workflowOperations.undeploy,
101+
useCase: undeployWorkflow,
102+
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
103+
})
104+
const body = Reflect.get(
105+
DELETE,
106+
'present'
107+
)({
108+
workflowId: 'workflow-1',
109+
workspaceId: 'workspace-1',
110+
warnings: [],
111+
})
112+
expect(v2UndeployWorkflowContract.response.schema.parse(body)).toEqual(body)
113+
})
114+
})
Lines changed: 72 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,91 @@
1-
import { createLogger } from '@sim/logger'
2-
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
3-
import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows'
41
import {
52
v2DeployWorkflowContract,
63
v2UndeployWorkflowContract,
74
} from '@/lib/api/contracts/v2/workflows'
8-
import { parseOptionalJsonBody } from '@/lib/api/server'
5+
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
6+
import { generateRequestId } from '@/lib/core/utils/request'
97
import { captureServerEvent } from '@/lib/posthog/server'
10-
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
11-
import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler'
12-
import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response'
13-
import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils'
14-
15-
const logger = createLogger('V2WorkflowDeployAPI')
8+
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
9+
import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments'
10+
import { workflowOperations } from '@/lib/workflows/application/operations'
11+
import { v2Error } from '@/app/api/v2/lib/response'
1612

1713
export const dynamic = 'force-dynamic'
1814
export const runtime = 'nodejs'
1915
export const maxDuration = 120
2016

21-
export const POST = withPublicApiRouteHandler({
17+
export const POST = defineV2JsonRoute({
2218
contract: v2DeployWorkflowContract,
23-
rateLimitEndpoint: 'workflow-deploy',
24-
handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => {
25-
try {
26-
const { id } = input.params
27-
28-
const rawBody = await parseOptionalJsonBody(request)
29-
if (!rawBody.success) {
30-
return rawBody.response.status === 413
31-
? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large')
32-
: v2Error('BAD_REQUEST', 'Request body must be valid JSON')
33-
}
34-
const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {})
35-
if (!body.success) return v2ValidationError(body.error)
36-
37-
const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin')
38-
if (!target) return v2Error('NOT_FOUND', 'Workflow not found')
39-
const { workspaceId } = target
40-
41-
await assertWorkflowMutable(id)
42-
43-
logger.info(`[${requestId}] Deploying workflow ${id} via v2 API`, { userId })
44-
45-
const result = await performFullDeploy({
46-
workflowId: id,
47-
userId,
48-
versionName: body.data.name,
49-
versionDescription: body.data.description ?? undefined,
50-
requestId,
51-
})
52-
53-
if (!result.success) {
54-
const code =
55-
result.errorCode === 'not_found'
56-
? 'NOT_FOUND'
57-
: result.errorCode === 'validation'
58-
? 'BAD_REQUEST'
59-
: 'INTERNAL_ERROR'
60-
return v2Error(code, result.error || 'Failed to deploy workflow')
61-
}
62-
63-
captureServerEvent(
64-
userId,
65-
'workflow_deployed',
66-
{ workflow_id: id, workspace_id: workspaceId },
67-
{
68-
groups: { workspace: workspaceId },
69-
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
70-
}
71-
)
72-
73-
return v2Data(
74-
{
75-
id,
76-
isDeployed: true,
77-
deployedAt: result.deployedAt?.toISOString() ?? null,
78-
version: result.version,
79-
warnings: result.warnings ?? [],
80-
},
81-
{ rateLimit }
82-
)
83-
} catch (error) {
84-
if (error instanceof WorkflowLockedError) {
85-
return v2Error('LOCKED', error.message)
86-
}
87-
throw error
19+
auth: v2ApiKeyAuth,
20+
operation: workflowOperations.deploy,
21+
rateLimit: v2RateLimits.publicApi,
22+
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
23+
parseOptions: {
24+
optionalJsonBody: true,
25+
invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'),
26+
payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'),
27+
},
28+
mapInput: ({ params, body }) => ({
29+
workflowId: params.id,
30+
name: body.name,
31+
description: body.description ?? undefined,
32+
requestId: generateRequestId(),
33+
}),
34+
useCase: deployWorkflow,
35+
present: (result) => ({
36+
data: {
37+
id: result.workflowId,
38+
isDeployed: Boolean(result.activeDeployment),
39+
deployedAt: result.deployedAt?.toISOString() ?? null,
40+
version: result.version,
41+
warnings: result.warnings ?? [],
42+
activeDeployment: result.activeDeployment ?? null,
43+
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
44+
},
45+
}),
46+
onSuccess: ({ principal, result }) => {
47+
if (principal.kind !== 'personal_api_key') {
48+
throw new Error('Admin deployment unexpectedly admitted a workspace API key')
8849
}
50+
captureServerEvent(
51+
principal.userId,
52+
'workflow_deployed',
53+
{ workflow_id: result.workflowId, workspace_id: result.workspaceId },
54+
{
55+
groups: { workspace: result.workspaceId },
56+
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
57+
}
58+
)
8959
},
9060
})
9161

92-
export const DELETE = withPublicApiRouteHandler({
62+
export const DELETE = defineV2JsonRoute({
9363
contract: v2UndeployWorkflowContract,
94-
rateLimitEndpoint: 'workflow-deploy',
95-
handler: async ({ input, auth: { requestId, userId, rateLimit } }) => {
96-
try {
97-
const { id } = input.params
98-
99-
const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin')
100-
if (!target) return v2Error('NOT_FOUND', 'Workflow not found')
101-
const { workflow, workspaceId } = target
102-
103-
if (!workflow.isDeployed) {
104-
return v2Error('BAD_REQUEST', 'Workflow is not deployed')
105-
}
106-
107-
await assertWorkflowMutable(id)
108-
109-
logger.info(`[${requestId}] Undeploying workflow ${id} via v2 API`, { userId })
110-
111-
const result = await performFullUndeploy({ workflowId: id, userId, requestId })
112-
if (!result.success) {
113-
return v2Error('INTERNAL_ERROR', result.error || 'Failed to undeploy workflow')
114-
}
115-
116-
captureServerEvent(
117-
userId,
118-
'workflow_undeployed',
119-
{ workflow_id: id, workspace_id: workspaceId },
120-
{ groups: { workspace: workspaceId } }
121-
)
122-
123-
return v2Data(
124-
{
125-
id,
126-
isDeployed: false,
127-
deployedAt: null,
128-
warnings: result.warnings ?? [],
129-
},
130-
{ rateLimit }
131-
)
132-
} catch (error) {
133-
if (error instanceof WorkflowLockedError) {
134-
return v2Error('LOCKED', error.message)
135-
}
136-
throw error
64+
auth: v2ApiKeyAuth,
65+
operation: workflowOperations.undeploy,
66+
rateLimit: v2RateLimits.publicApi,
67+
errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
68+
mapInput: ({ params }) => ({ workflowId: params.id, requestId: generateRequestId() }),
69+
useCase: undeployWorkflow,
70+
present: (result) => ({
71+
data: {
72+
id: result.workflowId,
73+
isDeployed: false,
74+
deployedAt: null,
75+
warnings: result.warnings ?? [],
76+
activeDeployment: null,
77+
latestDeploymentAttempt: null,
78+
},
79+
}),
80+
onSuccess: ({ principal, result }) => {
81+
if (principal.kind !== 'personal_api_key') {
82+
throw new Error('Admin undeployment unexpectedly admitted a workspace API key')
13783
}
84+
captureServerEvent(
85+
principal.userId,
86+
'workflow_undeployed',
87+
{ workflow_id: result.workflowId, workspace_id: result.workspaceId },
88+
{ groups: { workspace: result.workspaceId } }
89+
)
13890
},
13991
})

0 commit comments

Comments
 (0)