Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4f98438
improvement(ui): restore smooth streaming animation, fix follow-up au…
waleedlatif1 Apr 14, 2026
aba72b8
fix(ui): restore delayed animation, handle tilde fences, fix follow-u…
waleedlatif1 Apr 14, 2026
77f2b27
fix(ui): extract useStreamingReveal to followup, keep cleanup changes
waleedlatif1 Apr 14, 2026
c68263f
fix(ui): restore hydratedStreamingRef for reconnect path order-of-ops
waleedlatif1 Apr 14, 2026
0ecd6bd
fix(ui): restore full hydratedStreamingRef effect for reconnect path
waleedlatif1 Apr 14, 2026
335c295
fix(ui): use hover-hover prefix on CopyCodeButton callers to correctl…
waleedlatif1 Apr 14, 2026
b6ab71d
fix(logs): remove destructive color from cancel execution menu item
waleedlatif1 Apr 14, 2026
03f6e74
feat(logs): optimistic cancelling status on cancel execution
waleedlatif1 Apr 14, 2026
316b6ac
feat(logs): allow cancellation of pending (paused) executions
waleedlatif1 Apr 14, 2026
8b920dc
fix(hitl): cancel paused executions directly in DB
waleedlatif1 Apr 14, 2026
394be59
upgrade turbo
waleedlatif1 Apr 14, 2026
42d17d6
test(hitl): update cancel route tests for paused execution cancellation
waleedlatif1 Apr 14, 2026
85d9fd6
fix(hitl): set endedAt when cancelling paused execution
waleedlatif1 Apr 14, 2026
6780e00
fix(hitl): emit execution:cancelled event to canvas when cancelling p…
waleedlatif1 Apr 14, 2026
15360c3
fix(hitl): isolate cancelPausedExecution failure from successful canc…
waleedlatif1 Apr 14, 2026
abf4b5a
fix(hitl): add .catch() to fire-and-forget event buffer calls in canc…
waleedlatif1 Apr 14, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mockCheckHybridAuth = vi.fn()
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
const mockMarkExecutionCancelled = vi.fn()
const mockAbortManualExecution = vi.fn()

vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
const {
mockCheckHybridAuth,
mockAuthorizeWorkflowByWorkspacePermission,
mockMarkExecutionCancelled,
mockAbortManualExecution,
mockCancelPausedExecution,
mockSetExecutionMeta,
mockWriteEvent,
mockCloseWriter,
} = vi.hoisted(() => ({
mockCheckHybridAuth: vi.fn(),
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockMarkExecutionCancelled: vi.fn(),
mockAbortManualExecution: vi.fn(),
mockCancelPausedExecution: vi.fn(),
mockSetExecutionMeta: vi.fn(),
mockWriteEvent: vi.fn(),
mockCloseWriter: vi.fn(),
}))

vi.mock('@/lib/auth/hybrid', () => ({
Expand All @@ -26,19 +37,48 @@ vi.mock('@/lib/execution/manual-cancellation', () => ({
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
}))

vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
PauseResumeManager: {
cancelPausedExecution: (...args: unknown[]) => mockCancelPausedExecution(...args),
},
}))

vi.mock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: (params: unknown) =>
mockAuthorizeWorkflowByWorkspacePermission(params),
}))

vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: vi.fn(),
}))

vi.mock('@/lib/execution/event-buffer', () => ({
setExecutionMeta: (...args: unknown[]) => mockSetExecutionMeta(...args),
createExecutionEventWriter: () => ({
write: (...args: unknown[]) => mockWriteEvent(...args),
close: () => mockCloseWriter(),
}),
}))

import { POST } from './route'

const makeRequest = () =>
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
})

const makeParams = () => ({ params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }) })

describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
mockAbortManualExecution.mockReturnValue(false)
mockCancelPausedExecution.mockResolvedValue(false)
mockSetExecutionMeta.mockResolvedValue(undefined)
mockWriteEvent.mockResolvedValue({ eventId: 1 })
mockCloseWriter.mockResolvedValue(undefined)
})

it('returns success when cancellation was durably recorded', async () => {
Expand All @@ -47,14 +87,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
reason: 'recorded',
})

const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
const response = await POST(makeRequest(), makeParams())

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
Expand All @@ -63,6 +96,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
pausedCancelled: false,
reason: 'recorded',
})
})
Expand All @@ -73,14 +107,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
reason: 'redis_unavailable',
})

const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
const response = await POST(makeRequest(), makeParams())

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
Expand All @@ -89,6 +116,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: false,
reason: 'redis_unavailable',
})
})
Expand All @@ -99,14 +127,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
reason: 'redis_write_failed',
})

const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
const response = await POST(makeRequest(), makeParams())

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
Expand All @@ -115,6 +136,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
redisAvailable: true,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: false,
reason: 'redis_write_failed',
})
})
Expand All @@ -126,14 +148,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
})
mockAbortManualExecution.mockReturnValue(true)

const response = await POST(
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
}),
{
params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }),
}
)
const response = await POST(makeRequest(), makeParams())

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
Expand All @@ -142,7 +157,50 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
redisAvailable: false,
durablyRecorded: false,
locallyAborted: true,
pausedCancelled: false,
reason: 'redis_unavailable',
})
})

it('returns success when a paused HITL execution is cancelled directly in the database', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockCancelPausedExecution.mockResolvedValue(true)

const response = await POST(makeRequest(), makeParams())

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: true,
reason: 'redis_unavailable',
})
})

it('returns 401 when auth fails', async () => {
mockCheckHybridAuth.mockResolvedValue({ success: false, error: 'Unauthorized' })

const response = await POST(makeRequest(), makeParams())

expect(response.status).toBe(401)
})

it('returns 403 when workflow access is denied', async () => {
mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
message: 'Access denied',
status: 403,
})

const response = await POST(makeRequest(), makeParams())

expect(response.status).toBe(403)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
import { abortManualExecution } from '@/lib/execution/manual-cancellation'
import { captureServerEvent } from '@/lib/posthog/server'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'

const logger = createLogger('CancelExecutionAPI')
Expand Down Expand Up @@ -49,19 +51,41 @@ export async function POST(

const cancellation = await markExecutionCancelled(executionId)
const locallyAborted = abortManualExecution(executionId)
let pausedCancelled = false
try {
pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId)
} catch (error) {
logger.warn('Failed to cancel paused execution in database', { executionId, error })
}

if (cancellation.durablyRecorded) {
logger.info('Execution marked as cancelled in Redis', { executionId })
} else if (locallyAborted) {
logger.info('Execution cancelled via local in-process fallback', { executionId })
} else if (pausedCancelled) {
logger.info('Paused execution cancelled directly in database', { executionId })
void setExecutionMeta(executionId, { status: 'cancelled', workflowId }).catch(() => {})
const writer = createExecutionEventWriter(executionId)
void writer
.write({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { duration: 0 },
})
.then(() => writer.close())
Comment thread
waleedlatif1 marked this conversation as resolved.
.catch(() => {})
} else {
logger.warn('Execution cancellation was not durably recorded', {
executionId,
reason: cancellation.reason,
})
}

if (cancellation.durablyRecorded || locallyAborted) {
const success = cancellation.durablyRecorded || locallyAborted || pausedCancelled

if (success) {
const workspaceId = workflowAuthorization.workflow?.workspaceId
captureServerEvent(
auth.userId,
Expand All @@ -72,11 +96,12 @@ export async function POST(
}

return NextResponse.json({
success: cancellation.durablyRecorded || locallyAborted,
success,
executionId,
redisAvailable: cancellation.reason !== 'redis_unavailable',
durablyRecorded: cancellation.durablyRecorded,
locallyAborted,
pausedCancelled,
reason: cancellation.reason,
})
} catch (error: any) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React, { type HTMLAttributes, memo, type ReactNode, useMemo } from 'react'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
import { Tooltip } from '@/components/emcn'
import { CopyCodeButton } from '@/components/ui/copy-code-button'
import { CopyCodeButton, Tooltip } from '@/components/emcn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'

export function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
Expand Down Expand Up @@ -100,7 +99,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
</span>
<CopyCodeButton
code={extractTextContent(codeContent)}
className='text-gray-400 hover:bg-gray-700 hover:text-gray-200'
className='text-gray-400 hover-hover:bg-gray-700 hover-hover:text-gray-200'
/>
</div>
<pre className='overflow-x-auto p-4 font-mono text-gray-200 dark:text-gray-100'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import '@/components/emcn/components/code/code.css'
import { Checkbox, highlight, languages } from '@/components/emcn'
import { CopyCodeButton } from '@/components/ui/copy-code-button'
import { Checkbox, CopyCodeButton, highlight, languages } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import {
Expand Down Expand Up @@ -148,7 +147,7 @@ const MARKDOWN_COMPONENTS = {
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
<CopyCodeButton
code={codeString}
className='text-[var(--text-tertiary)] hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]'
className='-mr-2 text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
/>
</div>
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
Expand Down Expand Up @@ -265,12 +264,7 @@ export function ChatContent({
useEffect(() => {
const handler = (e: Event) => {
const { type, id, title } = (e as CustomEvent).detail
const RESOURCE_TYPE_MAP: Record<string, string> = {}
onWorkspaceResourceSelectRef.current?.({
type: RESOURCE_TYPE_MAP[type] || type,
id,
title: title || id,
})
onWorkspaceResourceSelectRef.current?.({ type, id, title: title || id })
}
window.addEventListener('wsres-click', handler)
return () => window.removeEventListener('wsres-click', handler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function MothershipChat({
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
isStreaming={isThisStreaming}
onOptionSelect={isLastMessage && !isStreamActive ? onSubmit : undefined}
onOptionSelect={isLastMessage ? onSubmit : undefined}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
/>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export function UserInput({
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending

const valueRef = useRef(value)
valueRef.current = value
const sttPrefixRef = useRef('')

const handleTranscript = useCallback((text: string) => {
Expand Down Expand Up @@ -271,10 +272,6 @@ export function UserInput({
const isSendingRef = useRef(isSending)
isSendingRef.current = isSending

useEffect(() => {
valueRef.current = value
}, [value])

const textareaRef = mentionMenu.textareaRef
const wasSendingRef = useRef(false)
const atInsertPosRef = useRef<number | null>(null)
Expand Down Expand Up @@ -358,9 +355,7 @@ export function UserInput({
}
// Reset after batch so the next non-drop insert uses the cursor position
atInsertPosRef.current = null
} catch {
// Invalid JSON — ignore
}
} catch {}
textareaRef.current?.focus()
return
}
Expand All @@ -372,9 +367,7 @@ export function UserInput({
const resource = JSON.parse(resourceJson) as MothershipResource
handleResourceSelect(resource)
atInsertPosRef.current = null
} catch {
// Invalid JSON — ignore
}
} catch {}
textareaRef.current?.focus()
return
}
Expand Down
Loading
Loading