Skip to content

Commit 6b8806c

Browse files
fix(api): address v2 review findings
1 parent 28269b5 commit 6b8806c

18 files changed

Lines changed: 216 additions & 45 deletions

File tree

apps/docs/openapi-v2-workflows.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,7 +1358,7 @@
13581358
"get": {
13591359
"operationId": "listWorkflowExecutionsV2",
13601360
"summary": "List workflow executions",
1361-
"description": "List the durable executions belonging to one workflow. This lifecycle collection is intentionally lightweight; fetch one execution for output and pause detail, or fetch `/api/v2/logs/{executionId}` for diagnostic trace data.",
1361+
"description": "List the durable executions belonging to one workflow. Freshly queued runs are available through their execution status URL but do not enter this history until durable execution logging begins. This lifecycle collection is intentionally lightweight; fetch one execution for output and pause detail, or fetch `/api/v2/logs/{executionId}` for diagnostic trace data.",
13621362
"tags": ["Workflows"],
13631363
"security": [
13641364
{
@@ -1375,7 +1375,7 @@
13751375
"required": false,
13761376
"schema": {
13771377
"type": "string",
1378-
"enum": ["queued", "pending", "running", "completed", "failed", "cancelled", "paused"]
1378+
"enum": ["pending", "running", "completed", "failed", "cancelled", "paused"]
13791379
}
13801380
},
13811381
{
@@ -2881,7 +2881,7 @@
28812881
},
28822882
"status": {
28832883
"type": "string",
2884-
"enum": ["queued", "pending", "running", "completed", "failed", "cancelled", "paused"]
2884+
"enum": ["pending", "running", "completed", "failed", "cancelled", "paused"]
28852885
},
28862886
"trigger": {
28872887
"type": "string"

apps/sim/app/api/v1/audit-logs/auth.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,17 @@ describe('enterprise audit access', () => {
9999
expect(result.success).toBe(false)
100100
})
101101

102-
it('still requires organization membership', async () => {
102+
it('names the requested organization when target membership is missing', async () => {
103103
setEnvFlags({ isAuditLogsEnabled: true })
104104
queueTableRows(schemaMock.member, [])
105105

106-
const result = await validateEnterpriseAuditAccess('viewer')
106+
const result = await validateEnterpriseAuditAccess('viewer', 'organization-route')
107107

108-
expect(result.success).toBe(false)
108+
if (result.success) throw new Error('Expected organization membership to be rejected')
109+
expect(result.response.status).toBe(403)
110+
await expect(result.response.json()).resolves.toEqual({
111+
error: 'Not a member of the requested organization',
112+
})
109113
})
110114
})
111115
})

apps/sim/app/api/v1/audit-logs/auth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ export async function resolveEnterpriseAuditAccess(
6565
.limit(1)
6666

6767
if (!membership) {
68-
return { success: false, status: 403, message: 'Not a member of any organization' }
68+
return {
69+
success: false,
70+
status: 403,
71+
message: targetOrganizationId
72+
? 'Not a member of the requested organization'
73+
: 'Not a member of any organization',
74+
}
6975
}
7076

7177
if (membership.role !== 'admin' && membership.role !== 'owner') {

apps/sim/app/api/v1/logs/executions/[executionId]/route.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import {
1313

1414
const logger = createLogger('V1ExecutionAPI')
1515

16+
function countWorkflowStateBlocks(workflowState: unknown): number {
17+
if (!workflowState || typeof workflowState !== 'object' || Array.isArray(workflowState)) return 0
18+
const blocks = (workflowState as Record<string, unknown>).blocks
19+
if (!blocks || typeof blocks !== 'object' || Array.isArray(blocks)) return 0
20+
return Object.keys(blocks).length
21+
}
22+
1623
export const GET = withRouteHandler(
1724
async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => {
1825
try {
@@ -64,7 +71,7 @@ export const GET = withRouteHandler(
6471

6572
logger.debug(`Successfully fetched execution data for: ${executionId}`)
6673
logger.debug(
67-
`Workflow state contains ${Object.keys((workflowLog.workflowState as any)?.blocks || {}).length} blocks`
74+
`Workflow state contains ${countWorkflowStateBlocks(workflowLog.workflowState)} blocks`
6875
)
6976

7077
// Get user's workflow execution limits and usage

apps/sim/app/api/v1/logs/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5454
},
5555
})
5656

57-
const decodedCursor = params.cursor ? decodePublicLogCursor(params.cursor) : null
57+
const decodedCursor = params.cursor
58+
? decodePublicLogCursor(params.cursor, params.order ?? 'desc')
59+
: null
5860
if (params.cursor && !decodedCursor) {
5961
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
6062
}

apps/sim/app/api/v2/logs/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7070
)
7171
const includesRoot = resolvedFolderIds?.includes(null) ?? false
7272

73-
const decodedCursor = params.cursor ? decodePublicLogCursor(params.cursor) : null
73+
const decodedCursor = params.cursor
74+
? decodePublicLogCursor(params.cursor, params.order ?? 'desc')
75+
: null
7476
if (params.cursor && !decodedCursor) return v2Error('BAD_REQUEST', 'Invalid cursor')
7577
const cursor = decodedCursor ?? undefined
7678

apps/sim/app/api/v2/workflows/[id]/executions/route.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ describe('GET /api/v2/workflows/[id]/executions', () => {
9797

9898
expect(body.data).toHaveLength(2)
9999
expect(body.nextCursor).toEqual(expect.any(String))
100+
expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual({
101+
sort: 'startedAt:desc',
102+
keys: ['2026-08-05T00:01:00.000Z', 'row-1'],
103+
})
100104
})
101105

102106
it('rejects an invalid cursor', async () => {
@@ -106,6 +110,27 @@ describe('GET /api/v2/workflows/[id]/executions', () => {
106110
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
107111
})
108112

113+
it('rejects a cursor minted under a different order', async () => {
114+
const cursor = Buffer.from(
115+
JSON.stringify({
116+
sort: 'startedAt:desc',
117+
keys: ['2026-08-05T00:01:00.000Z', 'row-1'],
118+
})
119+
).toString('base64')
120+
121+
const response = await callGet(`?order=asc&cursor=${encodeURIComponent(cursor)}`)
122+
123+
expect(response.status).toBe(400)
124+
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
125+
})
126+
127+
it('rejects queued as a durable-history filter', async () => {
128+
const response = await callGet('?status=queued')
129+
130+
expect(response.status).toBe(400)
131+
expect(dbChainMockFns.limit).not.toHaveBeenCalled()
132+
})
133+
109134
it('authorizes the workflow before validating filters', async () => {
110135
mockResolveV2WorkflowAccess.mockResolvedValue({
111136
ok: false,

apps/sim/app/api/v2/workflows/[id]/executions/route.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import type { NextRequest } from 'next/server'
44
import {
55
type V2WorkflowExecutionListItem,
66
v2ListWorkflowExecutionsContract,
7-
v2WorkflowExecutionStatusValueSchema,
7+
v2WorkflowExecutionListStatusValueSchema,
88
} from '@/lib/api/contracts/v2/workflows'
99
import { parseRequest } from '@/lib/api/server'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { listWorkflowExecutions } from '@/lib/workflows/executor/execution-queries'
1212
import {
13-
decodeCursor,
14-
encodeCursor,
13+
cursorSortKey,
14+
decodeSortedCursor,
15+
encodeSortedCursor,
1516
v2CursorList,
17+
v2CursorSortError,
1618
v2Error,
1719
v2ValidationError,
1820
} from '@/app/api/v2/lib/response'
@@ -23,11 +25,6 @@ const logger = createLogger('V2WorkflowExecutionsAPI')
2325
export const dynamic = 'force-dynamic'
2426
export const revalidate = 0
2527

26-
interface EncodedWorkflowExecutionCursor {
27-
startedAt: string
28-
rowId: string
29-
}
30-
3128
/** List the durable executions belonging to one workflow. */
3229
export const GET = withRouteHandler(
3330
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -41,13 +38,19 @@ export const GET = withRouteHandler(
4138
if (!parsed.success) return parsed.response
4239

4340
const { status, trigger, startDate, endDate, limit, cursor, order } = parsed.data.query
44-
const decodedCursor = cursor ? decodeCursor<EncodedWorkflowExecutionCursor>(cursor) : null
45-
const cursorDate = decodedCursor ? new Date(decodedCursor.startedAt) : null
41+
const sort = cursorSortKey('startedAt', order)
42+
const decodedCursor = decodeSortedCursor(cursor, sort)
43+
if (decodedCursor.status === 'invalid') return v2CursorSortError()
44+
const [cursorStartedAt, cursorRowId] = decodedCursor.status === 'ok' ? decodedCursor.keys : []
45+
const cursorDate = typeof cursorStartedAt === 'string' ? new Date(cursorStartedAt) : null
4646
if (
47-
cursor &&
48-
(!decodedCursor || !decodedCursor.rowId || !cursorDate || Number.isNaN(cursorDate.getTime()))
47+
decodedCursor.status === 'ok' &&
48+
(decodedCursor.keys.length !== 2 ||
49+
!cursorDate ||
50+
Number.isNaN(cursorDate.getTime()) ||
51+
typeof cursorRowId !== 'string')
4952
) {
50-
return v2Error('BAD_REQUEST', 'Invalid cursor')
53+
return v2CursorSortError()
5154
}
5255

5356
try {
@@ -59,16 +62,16 @@ export const GET = withRouteHandler(
5962
endDate: endDate ? new Date(endDate) : undefined,
6063
limit,
6164
cursor:
62-
decodedCursor && cursorDate
63-
? { startedAt: cursorDate, rowId: decodedCursor.rowId }
65+
decodedCursor.status === 'ok' && cursorDate && typeof cursorRowId === 'string'
66+
? { startedAt: cursorDate, rowId: cursorRowId }
6467
: undefined,
6568
order,
6669
})
6770

6871
const data: V2WorkflowExecutionListItem[] = result.data.map((row) => ({
6972
executionId: row.executionId,
7073
workflowId: row.workflowId ?? workflowId,
71-
status: v2WorkflowExecutionStatusValueSchema.parse(row.status),
74+
status: v2WorkflowExecutionListStatusValueSchema.parse(row.status),
7275
trigger: row.trigger,
7376
startedAt: row.startedAt.toISOString(),
7477
endedAt: row.endedAt?.toISOString() ?? null,
@@ -77,10 +80,10 @@ export const GET = withRouteHandler(
7780
}))
7881

7982
const nextCursor = result.nextCursor
80-
? encodeCursor({
81-
startedAt: result.nextCursor.startedAt.toISOString(),
82-
rowId: result.nextCursor.rowId,
83-
})
83+
? encodeSortedCursor(sort, [
84+
result.nextCursor.startedAt.toISOString(),
85+
result.nextCursor.rowId,
86+
])
8487
: null
8588

8689
return v2CursorList(data, nextCursor)

apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const {
2525
mockWriteEvent,
2626
mockWriteTerminalEvent,
2727
mockWorkflowExecutionBelongsToWorkflow,
28+
mockGetJobQueue,
29+
mockGetJob,
30+
mockCancelJob,
2831
} = vi.hoisted(() => ({
2932
mockMarkExecutionCancelled: vi.fn(),
3033
mockAbortManualExecution: vi.fn(),
@@ -38,6 +41,13 @@ const {
3841
mockWriteEvent: vi.fn(),
3942
mockWriteTerminalEvent: vi.fn(),
4043
mockWorkflowExecutionBelongsToWorkflow: vi.fn(),
44+
mockGetJobQueue: vi.fn(),
45+
mockGetJob: vi.fn(),
46+
mockCancelJob: vi.fn(),
47+
}))
48+
49+
vi.mock('@/lib/core/async-jobs', () => ({
50+
getJobQueue: mockGetJobQueue,
4151
}))
4252

4353
vi.mock('@/lib/workflows/executor/execution-queries', () => ({
@@ -106,6 +116,9 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
106116
mockWriteEvent.mockResolvedValue({ eventId: 1 })
107117
mockWriteTerminalEvent.mockResolvedValue({ eventId: 1 })
108118
mockWorkflowExecutionBelongsToWorkflow.mockResolvedValue(true)
119+
mockGetJob.mockResolvedValue(null)
120+
mockCancelJob.mockResolvedValue(undefined)
121+
mockGetJobQueue.mockResolvedValue({ getJob: mockGetJob, cancelJob: mockCancelJob })
109122
})
110123

111124
it('returns success when cancellation was durably recorded', async () => {
@@ -148,6 +161,29 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
148161
})
149162
})
150163

164+
it('durably cancels a queued execution through its queue run', async () => {
165+
mockMarkExecutionCancelled.mockResolvedValue({
166+
durablyRecorded: false,
167+
reason: 'redis_unavailable',
168+
})
169+
mockGetJob.mockResolvedValue({ id: 'run-1', status: 'pending' })
170+
171+
const response = await POST(makeRequest(), makeParams())
172+
173+
expect(response.status).toBe(200)
174+
await expect(response.json()).resolves.toEqual({
175+
success: true,
176+
executionId: 'ex-1',
177+
redisAvailable: false,
178+
durablyRecorded: true,
179+
locallyAborted: false,
180+
pausedCancelled: false,
181+
reason: 'recorded',
182+
})
183+
expect(mockGetJob).toHaveBeenCalledWith('workflow-execution:ex-1')
184+
expect(mockCancelJob).toHaveBeenCalledWith('run-1')
185+
})
186+
151187
it('returns unsuccessful response when Redis persistence fails', async () => {
152188
mockMarkExecutionCancelled.mockResolvedValue({
153189
durablyRecorded: false,

apps/sim/lib/api/contracts/v2/workflows.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,9 +472,18 @@ export const v2WorkflowExecutionStatusValueSchema = z.enum([
472472
'paused',
473473
])
474474

475+
export const v2WorkflowExecutionListStatusValueSchema = z.enum([
476+
'pending',
477+
'running',
478+
'completed',
479+
'failed',
480+
'cancelled',
481+
'paused',
482+
])
483+
475484
export const v2ListWorkflowExecutionsQuerySchema = z
476485
.object({
477-
status: v2WorkflowExecutionStatusValueSchema.optional(),
486+
status: v2WorkflowExecutionListStatusValueSchema.optional(),
478487
trigger: z.string().min(1, 'trigger cannot be empty').optional(),
479488
startDate: z.string().datetime().optional(),
480489
endDate: z.string().datetime().optional(),
@@ -499,7 +508,7 @@ export type V2ListWorkflowExecutionsQuery = z.output<typeof v2ListWorkflowExecut
499508
export const v2WorkflowExecutionListItemSchema = z.object({
500509
executionId: z.string(),
501510
workflowId: z.string(),
502-
status: v2WorkflowExecutionStatusValueSchema,
511+
status: v2WorkflowExecutionListStatusValueSchema,
503512
trigger: z.string(),
504513
startedAt: z.string(),
505514
endedAt: z.string().nullable(),

0 commit comments

Comments
 (0)