Skip to content

Commit bfec5b8

Browse files
feat(api): complete the v2 workflows resource with versions and CRUD
Adds version listing/detail plus create, update, and delete to the v2 workflows surface, which previously covered only execution and deployment. - GET /api/v2/workflows/[id]/versions — cursor-paginated, newest first - GET /api/v2/workflows/[id]/versions/[version] — version + pinned state - POST /api/v2/workflows, PATCH and DELETE /api/v2/workflows/[id] All six delegate to the existing orchestration and persistence helpers; no new domain logic.
1 parent 5df4c75 commit bfec5b8

13 files changed

Lines changed: 2190 additions & 101 deletions

File tree

apps/docs/openapi-v2-workflows.json

Lines changed: 730 additions & 35 deletions
Large diffs are not rendered by default.

apps/sim/app/api/v1/middleware.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export type ApiEndpoint =
3030
| 'workflow-detail'
3131
| 'workflow-deploy'
3232
| 'workflow-rollback'
33+
| 'workflow-versions'
34+
| 'workflow-version-detail'
3335
| 'workflow-export'
3436
| 'workflow-import'
3537
| 'audit-logs'
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Public v2 workflow update/delete: the 404 mask on an access failure (the
5+
* caller never names a workspace, so a 403 would confirm the workflow exists),
6+
* the 423 a workflow mutation lock produces, and the orchestration failure
7+
* codes rendered in the v2 error envelope.
8+
*/
9+
import { NextRequest } from 'next/server'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
12+
const {
13+
mockCheckRateLimit,
14+
mockResolveWorkspaceAccess,
15+
mockGetActiveWorkflowRecord,
16+
mockPerformUpdateWorkflow,
17+
mockPerformDeleteWorkflow,
18+
mockAssertWorkflowMutable,
19+
mockAssertFolderMutable,
20+
WorkflowLockedErrorMock,
21+
FolderLockedErrorMock,
22+
} = vi.hoisted(() => ({
23+
mockCheckRateLimit: vi.fn(),
24+
mockResolveWorkspaceAccess: vi.fn(),
25+
mockGetActiveWorkflowRecord: vi.fn(),
26+
mockPerformUpdateWorkflow: vi.fn(),
27+
mockPerformDeleteWorkflow: vi.fn(),
28+
mockAssertWorkflowMutable: vi.fn(),
29+
mockAssertFolderMutable: vi.fn(),
30+
WorkflowLockedErrorMock: class WorkflowLockedError extends Error {
31+
status = 423
32+
},
33+
FolderLockedErrorMock: class FolderLockedError extends Error {
34+
status = 423
35+
},
36+
}))
37+
38+
vi.mock('@/app/api/v1/middleware', () => ({
39+
checkRateLimit: mockCheckRateLimit,
40+
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
41+
}))
42+
43+
vi.mock('@/lib/workflows/orchestration', () => ({
44+
performUpdateWorkflow: mockPerformUpdateWorkflow,
45+
performDeleteWorkflow: mockPerformDeleteWorkflow,
46+
}))
47+
48+
vi.mock('@sim/platform-authz/workflow', () => ({
49+
getActiveWorkflowRecord: mockGetActiveWorkflowRecord,
50+
assertWorkflowMutable: mockAssertWorkflowMutable,
51+
assertFolderMutable: mockAssertFolderMutable,
52+
WorkflowLockedError: WorkflowLockedErrorMock,
53+
FolderLockedError: FolderLockedErrorMock,
54+
}))
55+
56+
vi.mock('@/lib/workflows/input-format', () => ({
57+
extractInputFieldsFromBlocks: vi.fn().mockReturnValue([]),
58+
}))
59+
60+
vi.mock('@/app/api/v2/lib/gate', () => ({
61+
v2ApiGateError: vi.fn().mockResolvedValue(null),
62+
}))
63+
64+
import { DELETE, PATCH } from '@/app/api/v2/workflows/[id]/route'
65+
66+
const RATE_LIMIT_OK = {
67+
allowed: true,
68+
userId: 'user-1',
69+
keyType: 'workspace',
70+
limit: 100,
71+
remaining: 99,
72+
resetAt: new Date('2024-01-01T01:00:00Z'),
73+
}
74+
75+
const RATE_LIMIT_DENIED = {
76+
allowed: false,
77+
limit: 100,
78+
remaining: 0,
79+
resetAt: new Date('2024-01-01T01:00:00Z'),
80+
retryAfterMs: 1000,
81+
}
82+
83+
const ACCESS_DENIED = { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
84+
85+
const WORKFLOW_RECORD = {
86+
id: 'wf-1',
87+
name: 'Support Agent',
88+
description: 'Handles tickets',
89+
folderId: null,
90+
workspaceId: 'workspace-1',
91+
isDeployed: true,
92+
deployedAt: new Date('2024-01-03T00:00:00Z'),
93+
runCount: 12,
94+
lastRunAt: new Date('2024-01-04T00:00:00Z'),
95+
locked: false,
96+
forkSyncExcluded: false,
97+
createdAt: new Date('2024-01-01T00:00:00Z'),
98+
updatedAt: new Date('2024-01-02T00:00:00Z'),
99+
}
100+
101+
const UPDATED = {
102+
id: 'wf-1',
103+
name: 'Support Agent v2',
104+
description: 'Handles tickets',
105+
workspaceId: 'workspace-1',
106+
folderId: null,
107+
sortOrder: 0,
108+
locked: false,
109+
forkSyncExcluded: false,
110+
createdAt: new Date('2024-01-01T00:00:00Z'),
111+
updatedAt: new Date('2024-01-05T00:00:00Z'),
112+
archivedAt: null,
113+
}
114+
115+
const routeContext = () => ({ params: Promise.resolve({ id: 'wf-1' }) })
116+
117+
function callPatch(body: unknown) {
118+
return PATCH(
119+
new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', {
120+
method: 'PATCH',
121+
headers: { 'Content-Type': 'application/json' },
122+
body: JSON.stringify(body),
123+
}),
124+
routeContext()
125+
)
126+
}
127+
128+
const callDelete = () =>
129+
DELETE(
130+
new NextRequest('http://localhost:3000/api/v2/workflows/wf-1', { method: 'DELETE' }),
131+
routeContext()
132+
)
133+
134+
describe('PATCH /api/v2/workflows/[id]', () => {
135+
beforeEach(() => {
136+
vi.clearAllMocks()
137+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
138+
mockResolveWorkspaceAccess.mockResolvedValue(null)
139+
mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
140+
mockAssertWorkflowMutable.mockResolvedValue(undefined)
141+
mockAssertFolderMutable.mockResolvedValue(undefined)
142+
mockPerformUpdateWorkflow.mockResolvedValue({ success: true, workflow: UPDATED })
143+
})
144+
145+
it('returns 404 when the v2 API surface flag is off', async () => {
146+
const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
147+
const { v2Error } = await import('@/app/api/v2/lib/response')
148+
vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
149+
150+
const res = await callPatch({ name: 'Support Agent v2' })
151+
152+
expect(res.status).toBe(404)
153+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
154+
})
155+
156+
it('400s when no field to change is supplied', async () => {
157+
const res = await callPatch({})
158+
expect(res.status).toBe(400)
159+
expect((await res.json()).error.code).toBe('BAD_REQUEST')
160+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
161+
})
162+
163+
it('masks an access-denied failure as 404 so existence is not leaked', async () => {
164+
mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
165+
const res = await callPatch({ name: 'Support Agent v2' })
166+
expect(res.status).toBe(404)
167+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
168+
})
169+
170+
it('returns the rate-limit response when denied', async () => {
171+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
172+
const res = await callPatch({ name: 'Support Agent v2' })
173+
expect(res.status).toBe(429)
174+
expect((await res.json()).error.code).toBe('RATE_LIMITED')
175+
})
176+
177+
it('404s when the workflow does not exist or is archived', async () => {
178+
mockGetActiveWorkflowRecord.mockResolvedValue(null)
179+
const res = await callPatch({ name: 'Support Agent v2' })
180+
expect(res.status).toBe(404)
181+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
182+
})
183+
184+
it('423s the denial when the workflow is locked rather than failing with a 500', async () => {
185+
mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked'))
186+
const res = await callPatch({ name: 'Support Agent v2' })
187+
expect(res.status).toBe(423)
188+
expect((await res.json()).error.code).toBe('LOCKED')
189+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
190+
})
191+
192+
it('423s when the destination folder is locked', async () => {
193+
mockAssertFolderMutable.mockRejectedValue(new FolderLockedErrorMock('Folder is locked'))
194+
const res = await callPatch({ folderId: 'fld-1' })
195+
expect(res.status).toBe(423)
196+
expect(mockPerformUpdateWorkflow).not.toHaveBeenCalled()
197+
})
198+
199+
it('409s when the target name is taken in the destination folder', async () => {
200+
mockPerformUpdateWorkflow.mockResolvedValue({
201+
success: false,
202+
error: 'A workflow named "Support Agent v2" already exists in this folder',
203+
errorCode: 'conflict',
204+
})
205+
const res = await callPatch({ name: 'Support Agent v2' })
206+
expect(res.status).toBe(409)
207+
expect((await res.json()).error.code).toBe('CONFLICT')
208+
})
209+
210+
it('updates the workflow and carries the untouched deployment counters through', async () => {
211+
const res = await callPatch({ name: 'Support Agent v2' })
212+
const body = await res.json()
213+
214+
expect(res.status).toBe(200)
215+
expect(body).toEqual({
216+
data: {
217+
id: 'wf-1',
218+
name: 'Support Agent v2',
219+
description: 'Handles tickets',
220+
folderId: null,
221+
workspaceId: 'workspace-1',
222+
isDeployed: true,
223+
deployedAt: '2024-01-03T00:00:00.000Z',
224+
runCount: 12,
225+
lastRunAt: '2024-01-04T00:00:00.000Z',
226+
createdAt: '2024-01-01T00:00:00.000Z',
227+
updatedAt: '2024-01-05T00:00:00.000Z',
228+
},
229+
})
230+
expect(mockPerformUpdateWorkflow).toHaveBeenCalledWith(
231+
expect.objectContaining({
232+
workflowId: 'wf-1',
233+
userId: 'user-1',
234+
workspaceId: 'workspace-1',
235+
currentName: 'Support Agent',
236+
currentFolderId: null,
237+
name: 'Support Agent v2',
238+
})
239+
)
240+
})
241+
})
242+
243+
describe('DELETE /api/v2/workflows/[id]', () => {
244+
beforeEach(() => {
245+
vi.clearAllMocks()
246+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
247+
mockResolveWorkspaceAccess.mockResolvedValue(null)
248+
mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
249+
mockAssertWorkflowMutable.mockResolvedValue(undefined)
250+
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
251+
})
252+
253+
it('returns 404 when the v2 API surface flag is off', async () => {
254+
const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
255+
const { v2Error } = await import('@/app/api/v2/lib/response')
256+
vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))
257+
258+
const res = await callDelete()
259+
260+
expect(res.status).toBe(404)
261+
expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
262+
})
263+
264+
it('masks an access-denied failure as 404 so existence is not leaked', async () => {
265+
mockResolveWorkspaceAccess.mockResolvedValue(ACCESS_DENIED)
266+
const res = await callDelete()
267+
expect(res.status).toBe(404)
268+
expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
269+
})
270+
271+
it('returns the rate-limit response when denied', async () => {
272+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
273+
const res = await callDelete()
274+
expect(res.status).toBe(429)
275+
expect((await res.json()).error.code).toBe('RATE_LIMITED')
276+
})
277+
278+
it('404s when the workflow does not exist or is already archived', async () => {
279+
mockGetActiveWorkflowRecord.mockResolvedValue(null)
280+
const res = await callDelete()
281+
expect(res.status).toBe(404)
282+
expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
283+
})
284+
285+
it('423s the denial when the workflow is locked rather than failing with a 500', async () => {
286+
mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedErrorMock('Workflow is locked'))
287+
const res = await callDelete()
288+
expect(res.status).toBe(423)
289+
expect((await res.json()).error.code).toBe('LOCKED')
290+
expect(mockPerformDeleteWorkflow).not.toHaveBeenCalled()
291+
})
292+
293+
it('400s when it is the last workflow in the workspace', async () => {
294+
mockPerformDeleteWorkflow.mockResolvedValue({
295+
success: false,
296+
error: 'Cannot delete the only workflow in the workspace',
297+
errorCode: 'validation',
298+
})
299+
const res = await callDelete()
300+
expect(res.status).toBe(400)
301+
expect((await res.json()).error.message).toContain('only workflow')
302+
})
303+
304+
it('archives the workflow and acknowledges the delete', async () => {
305+
const res = await callDelete()
306+
expect(res.status).toBe(200)
307+
expect(await res.json()).toEqual({ data: { id: 'wf-1', deleted: true } })
308+
expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith(
309+
expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1' })
310+
)
311+
})
312+
})

0 commit comments

Comments
 (0)