Skip to content

Commit d192e21

Browse files
committed
cancel route fixes
1 parent 9f0d229 commit d192e21

31 files changed

Lines changed: 4594 additions & 487 deletions

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

Lines changed: 861 additions & 54 deletions
Large diffs are not rendered by default.

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

Lines changed: 674 additions & 200 deletions
Large diffs are not rendered by default.

apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const LOG: WorkflowLogSummary = {
4747
status: 'running',
4848
duration: null,
4949
trigger: 'api',
50+
executionOrigin: null,
5051
createdAt: '2026-08-03T00:00:00.000Z',
5152
workflow: null,
5253
jobTitle: null,
@@ -134,6 +135,15 @@ describe('LogRowContextMenu cancellation action', () => {
134135
expect(findButton('Stopping…')?.disabled).toBe(true)
135136
})
136137

138+
it.each([
139+
['table trigger', null],
140+
['workflow-group', 'workflow_group' as const],
141+
])('shows cancellation for an active %s execution', (_label, executionOrigin) => {
142+
renderMenu({ log: { ...LOG, trigger: 'table', executionOrigin } })
143+
144+
expect(findButton('Cancel Run')?.disabled).toBe(false)
145+
})
146+
137147
it('only disables the execution matching the pending mutation', () => {
138148
renderMenu({ isCancelPending: true, cancelPendingExecutionId: 'execution-2' })
139149
expect(findButton('Cancel Run')?.disabled).toBe(false)
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { RowExecutionMetadata, TableRow } from '@/lib/table'
6+
import type { TableEvent } from '@/lib/table/events'
7+
import { applyCellEventToRow } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream'
8+
9+
const TABLE_ID = 'table-1'
10+
const ROW_ID = 'row-1'
11+
const GROUP_ID = 'group-1'
12+
13+
function createRow(
14+
executionId: string | null,
15+
status: RowExecutionMetadata['status'],
16+
overrides: Partial<RowExecutionMetadata> = {}
17+
): TableRow {
18+
return {
19+
id: ROW_ID,
20+
data: { result: 'current' },
21+
position: 0,
22+
executions: {
23+
[GROUP_ID]: {
24+
status,
25+
executionId,
26+
jobId: null,
27+
workflowId: 'workflow-1',
28+
error: null,
29+
...overrides,
30+
},
31+
},
32+
}
33+
}
34+
35+
function createCellEvent(
36+
status: Extract<TableEvent, { kind: 'cell' }>['status'],
37+
executionId: string | null,
38+
overrides: Partial<Extract<TableEvent, { kind: 'cell' }>> = {}
39+
): Extract<TableEvent, { kind: 'cell' }> {
40+
return {
41+
kind: 'cell',
42+
tableId: TABLE_ID,
43+
rowId: ROW_ID,
44+
groupId: GROUP_ID,
45+
status,
46+
executionId,
47+
jobId: null,
48+
error: null,
49+
...overrides,
50+
}
51+
}
52+
53+
describe('applyCellEventToRow', () => {
54+
it.each(['completed', 'error', 'cancelled'] as const)(
55+
'ignores a stale %s event from an older execution',
56+
(status) => {
57+
const row = createRow('execution-new', 'running')
58+
const event = createCellEvent(status, 'execution-old', {
59+
outputs: { result: 'stale' },
60+
error: status === 'error' ? 'Old failure' : null,
61+
})
62+
63+
expect(applyCellEventToRow(row, event)).toBeNull()
64+
expect(row.data.result).toBe('current')
65+
expect(row.executions?.[GROUP_ID]?.executionId).toBe('execution-new')
66+
}
67+
)
68+
69+
it.each(['pending', 'queued', 'running'] as const)(
70+
'ignores a stale %s event from an older execution',
71+
(status) => {
72+
const row = createRow('execution-new', 'running')
73+
const event = createCellEvent(status, 'execution-old', {
74+
outputs: { result: 'stale' },
75+
})
76+
77+
expect(applyCellEventToRow(row, event)).toBeNull()
78+
expect(row.data.result).toBe('current')
79+
expect(row.executions?.[GROUP_ID]?.executionId).toBe('execution-new')
80+
}
81+
)
82+
83+
it('ignores an old id-less pre-stamp once an identified attempt is active', () => {
84+
const row = createRow('execution-new', 'running')
85+
86+
expect(applyCellEventToRow(row, createCellEvent('pending', null))).toBeNull()
87+
})
88+
89+
it.each(['queued', 'running'] as const)(
90+
'lets %s claim an id-less pending pre-stamp',
91+
(status) => {
92+
const row = createRow(null, 'pending')
93+
94+
expect(
95+
applyCellEventToRow(row, createCellEvent(status, 'execution-new', { jobId: 'job-new' }))
96+
).toMatchObject({
97+
executions: {
98+
[GROUP_ID]: {
99+
status,
100+
executionId: 'execution-new',
101+
jobId: 'job-new',
102+
workflowId: 'workflow-1',
103+
},
104+
},
105+
})
106+
}
107+
)
108+
109+
it('ignores a delayed id-less pre-stamp after a terminal state', () => {
110+
const row = createRow('execution-old', 'completed')
111+
112+
expect(applyCellEventToRow(row, createCellEvent('pending', null))).toBeNull()
113+
})
114+
115+
it('seeds an id-less pending pre-stamp when the group has no cached attempt', () => {
116+
const row: TableRow = {
117+
id: ROW_ID,
118+
data: { result: 'current' },
119+
position: 0,
120+
executions: {},
121+
}
122+
123+
expect(applyCellEventToRow(row, createCellEvent('pending', null))).toMatchObject({
124+
executions: {
125+
[GROUP_ID]: {
126+
status: 'pending',
127+
executionId: null,
128+
},
129+
},
130+
})
131+
})
132+
133+
it('applies the server pre-stamp over the optimistic pending state', () => {
134+
const row = createRow('execution-old', 'pending', { jobId: null })
135+
136+
expect(applyCellEventToRow(row, createCellEvent('pending', null))).toMatchObject({
137+
executions: {
138+
[GROUP_ID]: {
139+
status: 'pending',
140+
executionId: null,
141+
},
142+
},
143+
})
144+
})
145+
146+
it('preserves the optimistic new-attempt handoff through server pickup', () => {
147+
const optimisticRow = createRow('execution-old', 'pending', { jobId: null })
148+
const preStampedRow = applyCellEventToRow(optimisticRow, createCellEvent('pending', null))
149+
150+
expect(preStampedRow).not.toBeNull()
151+
expect(
152+
applyCellEventToRow(
153+
preStampedRow as TableRow,
154+
createCellEvent('running', 'execution-new', { jobId: 'job-new' })
155+
)
156+
).toMatchObject({
157+
executions: {
158+
[GROUP_ID]: {
159+
status: 'running',
160+
executionId: 'execution-new',
161+
jobId: 'job-new',
162+
},
163+
},
164+
})
165+
})
166+
167+
it('does not replace a real paused attempt with an id-less pre-stamp', () => {
168+
const row = createRow('execution-current', 'pending', {
169+
jobId: 'paused-execution-current',
170+
})
171+
172+
expect(applyCellEventToRow(row, createCellEvent('pending', null))).toBeNull()
173+
})
174+
175+
it('applies a pending transition to its matching active execution', () => {
176+
const row = createRow('execution-1', 'running')
177+
178+
expect(
179+
applyCellEventToRow(
180+
row,
181+
createCellEvent('pending', 'execution-1', { jobId: 'paused-execution-1' })
182+
)
183+
).toMatchObject({
184+
executions: {
185+
[GROUP_ID]: {
186+
status: 'pending',
187+
executionId: 'execution-1',
188+
jobId: 'paused-execution-1',
189+
},
190+
},
191+
})
192+
})
193+
194+
it('does not seed an identified active event without cached ownership', () => {
195+
const row: TableRow = {
196+
id: ROW_ID,
197+
data: { result: 'current' },
198+
position: 0,
199+
executions: {},
200+
}
201+
202+
expect(applyCellEventToRow(row, createCellEvent('running', 'execution-1'))).toBeNull()
203+
})
204+
205+
it.each(['queued', 'running'] as const)(
206+
'does not treat an id-less %s event as a pre-stamp',
207+
(status) => {
208+
const row = createRow(null, 'pending')
209+
210+
expect(applyCellEventToRow(row, createCellEvent(status, null))).toBeNull()
211+
}
212+
)
213+
214+
it('does not regress a terminal attempt with a late matching active event', () => {
215+
const row = createRow('execution-1', 'completed')
216+
217+
expect(applyCellEventToRow(row, createCellEvent('running', 'execution-1'))).toBeNull()
218+
})
219+
220+
it.each(['completed', 'error'] as const)(
221+
'does not replace cancellation with a delayed same-attempt %s event',
222+
(status) => {
223+
const row = createRow('execution-1', 'cancelled')
224+
225+
expect(applyCellEventToRow(row, createCellEvent(status, 'execution-1'))).toBeNull()
226+
}
227+
)
228+
229+
it('allows cancellation to repair an exact worker error', () => {
230+
const row = createRow('execution-1', 'error')
231+
232+
expect(applyCellEventToRow(row, createCellEvent('cancelled', 'execution-1'))).toMatchObject({
233+
executions: {
234+
[GROUP_ID]: {
235+
status: 'cancelled',
236+
executionId: 'execution-1',
237+
},
238+
},
239+
})
240+
})
241+
242+
it.each([
243+
['completed', 'error'],
244+
['error', 'completed'],
245+
] as const)(
246+
'does not replace a terminal %s state with a delayed same-attempt %s event',
247+
(currentStatus, delayedStatus) => {
248+
const row = createRow('execution-1', currentStatus)
249+
250+
expect(applyCellEventToRow(row, createCellEvent(delayedStatus, 'execution-1'))).toBeNull()
251+
}
252+
)
253+
254+
it('does not apply an identified terminal event over an unclaimed pending attempt', () => {
255+
const row = createRow(null, 'pending')
256+
257+
expect(applyCellEventToRow(row, createCellEvent('cancelled', 'execution-old'))).toBeNull()
258+
})
259+
260+
it('applies an unclaimed terminal event only to the matching unclaimed attempt', () => {
261+
const row = createRow(null, 'pending')
262+
263+
expect(
264+
applyCellEventToRow(row, createCellEvent('error', null, { error: 'Failed to enqueue run' }))
265+
).toMatchObject({
266+
executions: {
267+
[GROUP_ID]: {
268+
status: 'error',
269+
executionId: null,
270+
error: 'Failed to enqueue run',
271+
},
272+
},
273+
})
274+
})
275+
276+
it('applies a terminal event and outputs to its matching execution', () => {
277+
const row = createRow('execution-1', 'running')
278+
279+
expect(
280+
applyCellEventToRow(
281+
row,
282+
createCellEvent('completed', 'execution-1', { outputs: { result: 'finished' } })
283+
)
284+
).toMatchObject({
285+
data: { result: 'finished' },
286+
executions: {
287+
[GROUP_ID]: {
288+
status: 'completed',
289+
executionId: 'execution-1',
290+
},
291+
},
292+
})
293+
})
294+
295+
it('does not recreate a terminal execution when the cache has no current group state', () => {
296+
const row: TableRow = {
297+
id: ROW_ID,
298+
data: { result: 'current' },
299+
position: 0,
300+
executions: {},
301+
}
302+
303+
expect(applyCellEventToRow(row, createCellEvent('cancelled', 'execution-old'))).toBeNull()
304+
})
305+
})

0 commit comments

Comments
 (0)