diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts
index e3f373675a0..73b16fdea1c 100644
--- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts
+++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.test.ts
@@ -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', () => ({
@@ -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 () => {
@@ -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({
@@ -63,6 +96,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
+ pausedCancelled: false,
reason: 'recorded',
})
})
@@ -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({
@@ -89,6 +116,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
+ pausedCancelled: false,
reason: 'redis_unavailable',
})
})
@@ -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({
@@ -115,6 +136,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
redisAvailable: true,
durablyRecorded: false,
locallyAborted: false,
+ pausedCancelled: false,
reason: 'redis_write_failed',
})
})
@@ -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({
@@ -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)
+ })
})
diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts
index 4fb045c5816..ec65f693501 100644
--- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts
+++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts
@@ -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')
@@ -49,11 +51,31 @@ 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())
+ .catch(() => {})
} else {
logger.warn('Execution cancellation was not durably recorded', {
executionId,
@@ -61,7 +83,9 @@ export async function POST(
})
}
- if (cancellation.durablyRecorded || locallyAborted) {
+ const success = cancellation.durablyRecorded || locallyAborted || pausedCancelled
+
+ if (success) {
const workspaceId = workflowAuthorization.workflow?.workspaceId
captureServerEvent(
auth.userId,
@@ -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) {
diff --git a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
index 48d4a144bdb..12254f18dd5 100644
--- a/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
+++ b/apps/sim/app/chat/components/message/components/markdown-renderer.tsx
@@ -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 }) {
@@ -100,7 +99,7 @@ function createCustomComponents(LinkComponent: typeof LinkWithPreview) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 72e94545d52..46091cd9ce3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -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 {
@@ -148,7 +147,7 @@ const MARKDOWN_COMPONENTS = {
{language || 'code'}
@@ -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 = {}
- 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)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index 1030d3951ad..fb94ac5a759 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -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) && (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index 9c54cb87f0c..bae0f084183 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -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) => {
@@ -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(null)
@@ -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
}
@@ -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
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index 229b161f727..1687a1973d1 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
+import { Button } from '@/components/emcn'
import { PanelLeft } from '@/components/emcn/icons'
import { useSession } from '@/lib/auth/auth-client'
import {
@@ -33,6 +34,7 @@ export function Home({ chatId }: HomeProps = {}) {
const { data: session } = useSession()
const posthog = usePostHog()
const posthogRef = useRef(posthog)
+ posthogRef.current = posthog
const [initialPrompt, setInitialPrompt] = useState('')
const hasCheckedLandingStorageRef = useRef(false)
const initialViewInputRef = useRef(null)
@@ -99,19 +101,12 @@ export function Home({ chatId }: HomeProps = {}) {
return
}
- // const templateId = LandingTemplateStorage.consume()
- // if (templateId) {
- // logger.info('Retrieved landing page template, redirecting to template detail')
- // router.replace(`/workspace/${workspaceId}/templates/${templateId}?use=true`)
- // return
- // }
-
const prompt = LandingPromptStorage.consume()
if (prompt) {
logger.info('Retrieved landing page prompt, populating home input')
setInitialPrompt(prompt)
}
- }, [createWorkflowFromLandingSeed, workspaceId, router])
+ }, [createWorkflowFromLandingSeed])
const wasSendingRef = useRef(false)
@@ -130,10 +125,6 @@ export function Home({ chatId }: HomeProps = {}) {
setIsResourceCollapsed(true)
}, [clearWidth])
- const expandResource = useCallback(() => {
- setIsResourceCollapsed(false)
- }, [])
-
const handleResourceEvent = useCallback(() => {
if (isResourceCollapsedRef.current) {
setIsResourceCollapsed(false)
@@ -225,8 +216,10 @@ export function Home({ chatId }: HomeProps = {}) {
}, [resources])
useEffect(() => {
- posthogRef.current = posthog
- }, [posthog])
+ if (resources.length === 0 && !isResourceCollapsedRef.current) {
+ collapseResource()
+ }
+ }, [resources, collapseResource])
const handleStopGeneration = useCallback(() => {
captureEvent(posthogRef.current, 'task_generation_aborted', {
@@ -299,7 +292,8 @@ export function Home({ chatId }: HomeProps = {}) {
const handleInitialContextRemove = useCallback(
(context: ChatContext) => {
const resolved = resolveResourceFromContext(context)
- if (resolved) removeResource(resolved.type, resolved.id)
+ if (!resolved) return
+ removeResource(resolved.type, resolved.id)
},
[resolveResourceFromContext, removeResource]
)
@@ -426,14 +420,16 @@ export function Home({ chatId }: HomeProps = {}) {
{isResourceCollapsed && (
-
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx
index db260f21ab6..2daf13aca1f 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx
@@ -48,7 +48,8 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
}: LogRowContextMenuProps) {
const hasExecutionId = Boolean(log?.executionId)
const hasWorkflow = Boolean(log?.workflow?.id || log?.workflowId)
- const isRunning = log?.status === 'running' && hasExecutionId && hasWorkflow
+ const isCancellable =
+ (log?.status === 'running' || log?.status === 'pending') && hasExecutionId && hasWorkflow
return (
!open && onClose()} modal={false}>
@@ -72,9 +73,9 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
- {isRunning && (
+ {isCancellable && (
<>
-
+
Cancel Execution
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
index 6ff5d23e011..1f77a590435 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
@@ -29,7 +29,7 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'
export const DELETED_WORKFLOW_COLOR = 'var(--text-tertiary)'
-export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
+export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' | 'cancelling'
/**
* Maps raw status string to LogStatus for display.
@@ -42,6 +42,8 @@ export function getDisplayStatus(status: string | null | undefined): LogStatus {
return 'running'
case 'pending':
return 'pending'
+ case 'cancelling':
+ return 'cancelling'
case 'cancelled':
return 'cancelled'
case 'failed':
@@ -58,6 +60,7 @@ export const STATUS_CONFIG: Record<
error: { variant: 'red', label: 'Error', color: 'var(--text-error)' },
pending: { variant: 'amber', label: 'Pending', color: '#f59e0b' },
running: { variant: 'amber', label: 'Running', color: '#f59e0b' },
+ cancelling: { variant: 'amber', label: 'Cancelling...', color: '#f59e0b' },
cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316' },
info: { variant: 'gray', label: 'Info', color: 'var(--terminal-status-info-color)' },
}
diff --git a/apps/sim/components/ui/copy-code-button.tsx b/apps/sim/components/emcn/components/code/copy-code-button.tsx
similarity index 74%
rename from apps/sim/components/ui/copy-code-button.tsx
rename to apps/sim/components/emcn/components/code/copy-code-button.tsx
index 87f17764cf0..5ef81dfb8a3 100644
--- a/apps/sim/components/ui/copy-code-button.tsx
+++ b/apps/sim/components/emcn/components/code/copy-code-button.tsx
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
-import { Check, Copy } from '@/components/emcn'
+import { Button, Check, Copy } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface CopyCodeButtonProps {
@@ -19,9 +19,7 @@ export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 2000)
- } catch {
- // Clipboard write can fail when document lacks focus or permission is denied
- }
+ } catch {}
}, [code])
useEffect(
@@ -32,15 +30,13 @@ export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
)
return (
-
+
)
}
diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts
index c9148935ac0..c001fbb77f5 100644
--- a/apps/sim/components/emcn/components/index.ts
+++ b/apps/sim/components/emcn/components/index.ts
@@ -32,6 +32,7 @@ export {
highlight,
languages,
} from './code/code'
+export { CopyCodeButton } from './code/copy-code-button'
export {
Combobox,
type ComboboxOption,
diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts
index 75ad18c1e50..835b5d7f97c 100644
--- a/apps/sim/hooks/queries/logs.ts
+++ b/apps/sim/hooks/queries/logs.ts
@@ -1,4 +1,5 @@
import {
+ type InfiniteData,
keepPreviousData,
type QueryClient,
useInfiniteQuery,
@@ -276,6 +277,8 @@ export function useExecutionSnapshot(executionId: string | undefined) {
})
}
+type LogsPage = { logs: WorkflowLog[]; hasMore: boolean; nextPage: number | undefined }
+
export function useCancelExecution() {
const queryClient = useQueryClient()
return useMutation({
@@ -294,6 +297,33 @@ export function useCancelExecution() {
if (!data.success) throw new Error('Failed to cancel execution')
return data
},
+ onMutate: async ({ executionId }) => {
+ await queryClient.cancelQueries({ queryKey: logKeys.lists() })
+
+ const previousQueries = queryClient.getQueriesData>({
+ queryKey: logKeys.lists(),
+ })
+
+ queryClient.setQueriesData>({ queryKey: logKeys.lists() }, (old) => {
+ if (!old) return old
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ logs: page.logs.map((log) =>
+ log.executionId === executionId ? { ...log, status: 'cancelling' } : log
+ ),
+ })),
+ }
+ })
+
+ return { previousQueries }
+ },
+ onError: (_err, _variables, context) => {
+ for (const [queryKey, data] of context?.previousQueries ?? []) {
+ queryClient.setQueryData(queryKey, data)
+ }
+ },
onSettled: () => {
queryClient.invalidateQueries({ queryKey: logKeys.lists() })
queryClient.invalidateQueries({ queryKey: logKeys.details() })
diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts
index 209416b3949..fddebe7ebc8 100644
--- a/apps/sim/lib/core/utils/browser-storage.ts
+++ b/apps/sim/lib/core/utils/browser-storage.ts
@@ -101,7 +101,6 @@ export class BrowserStorage {
export const STORAGE_KEYS = {
LANDING_PAGE_PROMPT: 'sim_landing_page_prompt',
- LANDING_PAGE_TEMPLATE: 'sim_landing_page_template',
LANDING_PAGE_WORKFLOW_SEED: 'sim_landing_page_workflow_seed',
WORKSPACE_RECENCY: 'sim_workspace_recency',
} as const
@@ -248,56 +247,6 @@ export class LandingPromptStorage {
}
}
-/**
- * Specialized utility for managing a template selection from the landing page.
- * Stores the marketplace template ID so it can be consumed after signup.
- */
-export class LandingTemplateStorage {
- private static readonly KEY = STORAGE_KEYS.LANDING_PAGE_TEMPLATE
-
- /**
- * Store a template ID selected on the landing page
- * @param templateId - The marketplace template UUID
- */
- static store(templateId: string): boolean {
- if (!templateId || templateId.trim().length === 0) {
- return false
- }
-
- return BrowserStorage.setItem(LandingTemplateStorage.KEY, {
- templateId: templateId.trim(),
- timestamp: Date.now(),
- })
- }
-
- /**
- * Retrieve and consume the stored template ID
- * @param maxAge - Maximum age in milliseconds (default: 24 hours)
- */
- static consume(maxAge: number = 24 * 60 * 60 * 1000): string | null {
- const data = BrowserStorage.getItem<{ templateId: string; timestamp: number } | null>(
- LandingTemplateStorage.KEY,
- null
- )
-
- if (!data || !data.templateId || !data.timestamp) {
- return null
- }
-
- if (Date.now() - data.timestamp > maxAge) {
- LandingTemplateStorage.clear()
- return null
- }
-
- LandingTemplateStorage.clear()
- return data.templateId
- }
-
- static clear(): boolean {
- return BrowserStorage.removeItem(LandingTemplateStorage.KEY)
- }
-}
-
export interface LandingWorkflowSeed {
templateId: string
workflowName: string
diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts
index 7a1c8cfb4f4..2ba22b4cc7a 100644
--- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts
+++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts
@@ -193,6 +193,10 @@ export class PauseResumeManager {
throw new Error('Paused execution not found or already resumed')
}
+ if (pausedExecution.status === 'cancelled') {
+ throw new Error('Execution has been cancelled')
+ }
+
const pausePoints = pausedExecution.pausePoints as Record
const pausePoint = pausePoints?.[contextId]
if (!pausePoint) {
@@ -1253,6 +1257,43 @@ export class PauseResumeManager {
})
}
+ /**
+ * Cancels a paused execution by updating both the paused execution record and the
+ * workflow execution log status to 'cancelled'. Returns true if a paused execution
+ * was found and cancelled, false if no paused execution exists for this executionId.
+ */
+ static async cancelPausedExecution(executionId: string): Promise {
+ const now = new Date()
+
+ return await db.transaction(async (tx) => {
+ const pausedExecution = await tx
+ .select({ id: pausedExecutions.id })
+ .from(pausedExecutions)
+ .where(
+ and(eq(pausedExecutions.executionId, executionId), eq(pausedExecutions.status, 'paused'))
+ )
+ .for('update')
+ .limit(1)
+ .then((rows) => rows[0])
+
+ if (!pausedExecution) {
+ return false
+ }
+
+ await tx
+ .update(pausedExecutions)
+ .set({ status: 'cancelled', updatedAt: now })
+ .where(eq(pausedExecutions.id, pausedExecution.id))
+
+ await tx
+ .update(workflowExecutionLogs)
+ .set({ status: 'cancelled', endedAt: now })
+ .where(eq(workflowExecutionLogs.executionId, executionId))
+
+ return true
+ })
+ }
+
static async listPausedExecutions(options: {
workflowId: string
status?: string | string[]
diff --git a/bun.lock b/bun.lock
index 8c8d6491ef4..d5a11f32ad9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -13,7 +14,7 @@
"husky": "9.1.7",
"json-schema-to-typescript": "15.0.4",
"lint-staged": "16.0.0",
- "turbo": "2.9.5",
+ "turbo": "2.9.6",
},
},
"apps/docs": {
@@ -1518,17 +1519,17 @@
"@trigger.dev/sdk": ["@trigger.dev/sdk@4.4.3", "", { "dependencies": { "@opentelemetry/api": "1.9.0", "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/core": "4.4.3", "chalk": "^5.2.0", "cronstrue": "^2.21.0", "debug": "^4.3.4", "evt": "^2.4.13", "slug": "^6.0.0", "ulid": "^2.3.0", "uncrypto": "^0.1.3", "uuid": "^9.0.0", "ws": "^8.11.0" }, "peerDependencies": { "ai": "^4.2.0 || ^5.0.0 || ^6.0.0", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai"] }, "sha512-ghJkak+PTBJJ9HiHMcnahJmzjsgCzYiIHu5Qj5R7I9q5LS6i7mkx169rB/tOE9HLadd4HSu3yYA5DrH4wXhZuw=="],
- "@turbo/darwin-64": ["@turbo/darwin-64@2.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg=="],
+ "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
- "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vkF/9F/l3aWd4bHxTui5Hh0F5xrTZ4e3rbBsc57zA6O8gNbmHN3B6eZ5psAIP2CnJRZ8ZxRjV3WZHeNXMXkPBw=="],
+ "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
- "@turbo/linux-64": ["@turbo/linux-64@2.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-z/Get5NUaUxm5HSGFqVMICDRjFNsCUhSc4wnFa/PP1QD0NXCjr7bu9a2EM6md/KMCBW0Qe393Ac+UM7/ryDDTw=="],
+ "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
- "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-jyBifaNoI5/NheyswomiZXJvjdAdvT7hDRYzQ4meP0DKGvpXUjnqsD+4/J2YSDQ34OHxFkL30FnSCUIVOh2PHw=="],
+ "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
- "@turbo/windows-64": ["@turbo/windows-64@2.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ph24K5uPtvo7UfuyDXnBiB/8XvrO+RQWbbw5zkA/bVNoy9HDiNoIJJj3s62MxT9tjEb6DnPje5PXSz1UR7QAyg=="],
+ "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
- "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-6c5RccT/+iR39SdT1G5HyZaD2n57W77o+l0TTfxG/cVlhV94Acyg2gTQW7zUOhW1BeQpBjHzu9x8yVBZwrHh7g=="],
+ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
@@ -3766,7 +3767,7 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
- "turbo": ["turbo@2.9.5", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.5", "@turbo/darwin-arm64": "2.9.5", "@turbo/linux-64": "2.9.5", "@turbo/linux-arm64": "2.9.5", "@turbo/windows-64": "2.9.5", "@turbo/windows-arm64": "2.9.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-JXNkRe6H6MjSlk5UQRTjyoKX5YN2zlc2632xcSlSFBao5yvbMWTpv9SNolOZlZmUlcDOHuszPLItbKrvcXnnZA=="],
+ "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
diff --git a/package.json b/package.json
index 3b38f2fef10..d78396fbb5c 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"husky": "9.1.7",
"json-schema-to-typescript": "15.0.4",
"lint-staged": "16.0.0",
- "turbo": "2.9.5"
+ "turbo": "2.9.6"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,scss}": [
diff --git a/turbo.json b/turbo.json
index 4469e31e0ae..ebca501d47d 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://v2-9-5.turborepo.dev/schema.json",
+ "$schema": "https://v2-9-6.turborepo.dev/schema.json",
"envMode": "loose",
"tasks": {
"transit": {