fix(terminal): terminal console update for child spans + hitl state machine (#4450)

* fix(terminal): terminal console update for child spans"

* address comments

* fix hitl state machine

* address comments

* address greptile
This commit is contained in:
Vikhyath Mondreti
2026-05-05 19:37:03 -07:00
committed by GitHub
parent d721dc3358
commit cef351fa25
34 changed files with 4551 additions and 953 deletions
@@ -10,7 +10,6 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { setExecutionMeta } from '@/lib/execution/event-buffer'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
@@ -157,12 +156,6 @@ export const POST = withRouteHandler(
})
}
await setExecutionMeta(enqueueResult.resumeExecutionId, {
status: 'active',
userId,
workflowId,
})
const resumeArgs = {
resumeEntryId: enqueueResult.resumeEntryId,
resumeExecutionId: enqueueResult.resumeExecutionId,
@@ -249,6 +242,14 @@ export const POST = withRouteHandler(
error: toError(dispatchError).message,
resumeExecutionId: enqueueResult.resumeExecutionId,
})
await PauseResumeManager.markResumeAttemptFailed({
resumeEntryId: enqueueResult.resumeEntryId,
pausedExecutionId: enqueueResult.pausedExecution.id,
parentExecutionId: executionId,
contextId: enqueueResult.contextId,
failureReason: 'Failed to queue async resume execution',
})
await PauseResumeManager.processQueuedResumes(executionId)
return NextResponse.json(
{ error: 'Failed to queue resume execution. Please try again.' },
{ status: 503 }
+146 -85
View File
@@ -25,7 +25,12 @@ import {
SIM_VIA_HEADER,
validateCallChain,
} from '@/lib/execution/call-chain'
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
import {
createExecutionEventWriter,
flushExecutionStreamReplayBuffer,
initializeExecutionStreamMeta,
type TerminalExecutionStreamStatus,
} from '@/lib/execution/event-buffer'
import { processInputFileFields } from '@/lib/execution/files'
import {
registerManualExecutionAborter,
@@ -868,11 +873,17 @@ async function handleExecutePost(
let isManualAbortRegistered = false
const eventWriter = createExecutionEventWriter(executionId)
setExecutionMeta(executionId, {
status: 'active',
const metaInitialized = await initializeExecutionStreamMeta(executionId, {
userId: actorUserId,
workflowId,
}).catch(() => {})
})
if (!metaInitialized) {
timeoutController.cleanup()
return NextResponse.json(
{ error: 'Run buffer temporarily unavailable' },
{ status: 503, headers: { 'X-Execution-Id': executionId } }
)
}
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
@@ -881,12 +892,18 @@ async function handleExecutePost(
registerManualExecutionAborter(executionId, timeoutController.abort)
isManualAbortRegistered = true
let localEventSeq = 0
const sendEvent = (event: ExecutionEvent) => {
let terminalEventPublished = false
const sendEvent = async (
event: ExecutionEvent,
terminalStatus?: TerminalExecutionStreamStatus
) => {
const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done'
if (isBuffered) {
localEventSeq++
event.eventId = localEventSeq
const entry = terminalStatus
? await eventWriter.writeTerminal(event, terminalStatus)
: await eventWriter.write(event)
event.eventId = entry.eventId
terminalEventPublished ||= Boolean(terminalStatus)
}
if (!isStreamClosed) {
try {
@@ -895,15 +912,12 @@ async function handleExecutePost(
isStreamClosed = true
}
}
if (isBuffered) {
eventWriter.write(event).catch(() => {})
}
}
try {
const startTime = new Date()
sendEvent({
await sendEvent({
type: 'execution:started',
timestamp: startTime.toISOString(),
executionId,
@@ -922,7 +936,7 @@ async function handleExecutePost(
childWorkflowContext?: ChildWorkflowContext
) => {
reqLogger.info('onBlockStart called', { blockId, blockName, blockType })
sendEvent({
await sendEvent({
type: 'block:started',
timestamp: new Date().toISOString(),
executionId,
@@ -976,7 +990,7 @@ async function handleExecutePost(
blockType,
error: callbackData.output.error,
})
sendEvent({
await sendEvent({
type: 'block:error',
timestamp: new Date().toISOString(),
executionId,
@@ -1010,7 +1024,7 @@ async function handleExecutePost(
blockName,
blockType,
})
sendEvent({
await sendEvent({
type: 'block:completed',
timestamp: new Date().toISOString(),
executionId,
@@ -1053,7 +1067,7 @@ async function handleExecutePost(
if (done) break
const chunk = decoder.decode(value, { stream: true })
sendEvent({
await sendEvent({
type: 'stream:chunk',
timestamp: new Date().toISOString(),
executionId,
@@ -1062,7 +1076,7 @@ async function handleExecutePost(
})
}
sendEvent({
await sendEvent({
type: 'stream:done',
timestamp: new Date().toISOString(),
executionId,
@@ -1107,13 +1121,14 @@ async function handleExecutePost(
selectedOutputs
)
const onChildWorkflowInstanceReady = (
const onChildWorkflowInstanceReady = async (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => {
sendEvent({
await sendEvent({
type: 'block:childWorkflowStarted',
timestamp: new Date().toISOString(),
executionId,
@@ -1123,7 +1138,16 @@ async function handleExecutePost(
childWorkflowInstanceId,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
...(iterationContext.parentIterations?.length && {
parentIterations: iterationContext.parentIterations,
}),
}),
...(childWorkflowContext && {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
childWorkflowName: childWorkflowContext.workflowName,
}),
...(executionOrder !== undefined && { executionOrder }),
},
@@ -1157,32 +1181,38 @@ async function handleExecutePost(
await loggingSession.markAsFailed(timeoutErrorMessage)
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: timeoutErrorMessage,
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
},
})
finalMetaStatus = 'error'
await sendEvent(
{
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: timeoutErrorMessage,
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
},
},
'error'
)
} else {
reqLogger.info('Workflow execution was cancelled')
sendEvent({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
},
})
finalMetaStatus = 'cancelled'
await sendEvent(
{
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
},
},
'cancelled'
)
}
return
}
@@ -1196,35 +1226,43 @@ async function handleExecutePost(
: result.output
if (result.status === 'paused') {
sendEvent({
type: 'execution:paused',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
output: sseOutput,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
finalMetaStatus = 'complete'
await sendEvent(
{
type: 'execution:paused',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
output: sseOutput,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: result.logs,
},
},
})
'complete'
)
} else {
sendEvent({
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
success: result.success,
output: sseOutput,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: result.logs,
finalMetaStatus = 'complete'
await sendEvent(
{
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
success: result.success,
output: sseOutput,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || startTime.toISOString(),
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: result.logs,
},
},
})
'complete'
)
}
finalMetaStatus = 'complete'
} catch (error: unknown) {
const isTimeout = isTimeoutError(error) || timeoutController.isTimedOut()
const errorMessage = isTimeout
@@ -1237,32 +1275,55 @@ async function handleExecutePost(
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
sendEvent({
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: executionResult?.error || errorMessage,
duration: executionResult?.metadata?.duration || 0,
finalBlockLogs: executionResult?.logs,
},
})
finalMetaStatus = 'error'
await sendEvent(
{
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: executionResult?.error || errorMessage,
duration: executionResult?.metadata?.duration || 0,
finalBlockLogs: executionResult?.logs,
},
},
'error'
)
} finally {
if (isManualAbortRegistered) {
unregisterManualExecutionAborter(executionId)
isManualAbortRegistered = false
}
try {
await eventWriter.close()
} catch (closeError) {
reqLogger.warn('Failed to close event writer', {
error: toError(closeError).message,
if (finalMetaStatus && !terminalEventPublished) {
const replayBufferFlushed = await flushExecutionStreamReplayBuffer(
executionId,
eventWriter
)
reqLogger.error('Failed to publish terminal execution event durably', {
executionId,
status: finalMetaStatus,
replayBufferFlushed,
})
}
if (finalMetaStatus) {
setExecutionMeta(executionId, { status: finalMetaStatus }).catch(() => {})
if (!isStreamClosed) {
controller.error(new Error('Run buffer terminal event publish failed'))
isStreamClosed = true
}
} else if (terminalEventPublished) {
await eventWriter.close().catch((closeError) => {
reqLogger.warn('Failed to close execution event writer after terminal publish', {
executionId,
error: closeError instanceof Error ? closeError.message : String(closeError),
})
})
} else {
try {
await eventWriter.close()
} catch (closeError) {
reqLogger.warn('Failed to close event writer', {
error: toError(closeError).message,
})
}
}
timeoutController.cleanup()
if (executionId) {
@@ -15,17 +15,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockMarkExecutionCancelled,
mockAbortManualExecution,
mockCancelPausedExecution,
mockSetExecutionMeta,
mockBeginPausedCancellation,
mockBlockQueuedResumesForCancellation,
mockClearPausedCancellationIntent,
mockCompletePausedCancellation,
mockGetPausedCancellationStatus,
mockFinalizeExecutionStream,
mockReadExecutionMetaState,
mockWriteEvent,
mockCloseWriter,
mockWriteTerminalEvent,
} = vi.hoisted(() => ({
mockMarkExecutionCancelled: vi.fn(),
mockAbortManualExecution: vi.fn(),
mockCancelPausedExecution: vi.fn(),
mockSetExecutionMeta: vi.fn(),
mockBeginPausedCancellation: vi.fn(),
mockBlockQueuedResumesForCancellation: vi.fn(),
mockClearPausedCancellationIntent: vi.fn(),
mockCompletePausedCancellation: vi.fn(),
mockGetPausedCancellationStatus: vi.fn(),
mockFinalizeExecutionStream: vi.fn(),
mockReadExecutionMetaState: vi.fn(),
mockWriteEvent: vi.fn(),
mockCloseWriter: vi.fn(),
mockWriteTerminalEvent: vi.fn(),
}))
vi.mock('@/lib/execution/cancellation', () => ({
@@ -38,7 +48,13 @@ vi.mock('@/lib/execution/manual-cancellation', () => ({
vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
PauseResumeManager: {
cancelPausedExecution: (...args: unknown[]) => mockCancelPausedExecution(...args),
beginPausedCancellation: (...args: unknown[]) => mockBeginPausedCancellation(...args),
blockQueuedResumesForCancellation: (...args: unknown[]) =>
mockBlockQueuedResumesForCancellation(...args),
clearPausedCancellationIntent: (...args: unknown[]) =>
mockClearPausedCancellationIntent(...args),
completePausedCancellation: (...args: unknown[]) => mockCompletePausedCancellation(...args),
getPausedCancellationStatus: (...args: unknown[]) => mockGetPausedCancellationStatus(...args),
},
}))
@@ -47,10 +63,12 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@/lib/execution/event-buffer', () => ({
setExecutionMeta: (...args: unknown[]) => mockSetExecutionMeta(...args),
finalizeExecutionStream: (...args: unknown[]) => mockFinalizeExecutionStream(...args),
readExecutionMetaState: (...args: unknown[]) => mockReadExecutionMetaState(...args),
createExecutionEventWriter: () => ({
write: (...args: unknown[]) => mockWriteEvent(...args),
close: () => mockCloseWriter(),
writeTerminal: (...args: unknown[]) => mockWriteTerminalEvent(...args),
close: vi.fn().mockResolvedValue(undefined),
}),
}))
@@ -71,10 +89,15 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
allowed: true,
})
mockAbortManualExecution.mockReturnValue(false)
mockCancelPausedExecution.mockResolvedValue(false)
mockSetExecutionMeta.mockResolvedValue(undefined)
mockBeginPausedCancellation.mockResolvedValue(false)
mockBlockQueuedResumesForCancellation.mockResolvedValue(false)
mockClearPausedCancellationIntent.mockResolvedValue(undefined)
mockCompletePausedCancellation.mockResolvedValue(false)
mockGetPausedCancellationStatus.mockResolvedValue(null)
mockFinalizeExecutionStream.mockResolvedValue(true)
mockReadExecutionMetaState.mockResolvedValue({ status: 'missing' })
mockWriteEvent.mockResolvedValue({ eventId: 1 })
mockCloseWriter.mockResolvedValue(undefined)
mockWriteTerminalEvent.mockResolvedValue({ eventId: 1 })
})
it('returns success when cancellation was durably recorded', async () => {
@@ -159,11 +182,8 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
})
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)
mockBeginPausedCancellation.mockResolvedValue(true)
mockCompletePausedCancellation.mockResolvedValue(true)
const response = await POST(makeRequest(), makeParams())
@@ -171,12 +191,77 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
pausedCancelled: true,
reason: 'recorded',
})
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
expect(mockWriteTerminalEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'execution:cancelled',
executionId: 'ex-1',
workflowId: 'wf-1',
}),
'cancelled'
)
expect(mockFinalizeExecutionStream).not.toHaveBeenCalled()
})
it('publishes paused cancellation event even when Redis cancellation is recorded', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
mockCompletePausedCancellation.mockResolvedValue(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
executionId: 'ex-1',
durablyRecorded: true,
pausedCancelled: true,
})
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
expect(mockWriteTerminalEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'execution:cancelled',
executionId: 'ex-1',
workflowId: 'wf-1',
}),
'cancelled'
)
expect(mockFinalizeExecutionStream).not.toHaveBeenCalled()
})
it('does not confirm paused cancellation when terminal event publication fails', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
mockCompletePausedCancellation.mockResolvedValue(true)
mockWriteTerminalEvent.mockRejectedValue(new Error('Redis unavailable'))
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: true,
reason: 'redis_unavailable',
pausedCancelled: false,
reason: 'paused_event_publish_failed',
})
expect(mockMarkExecutionCancelled).not.toHaveBeenCalled()
expect(mockCompletePausedCancellation).not.toHaveBeenCalled()
expect(mockWriteTerminalEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'execution:cancelled',
executionId: 'ex-1',
workflowId: 'wf-1',
}),
'cancelled'
)
expect(mockFinalizeExecutionStream).not.toHaveBeenCalled()
})
it('returns 401 when auth fails', async () => {
@@ -241,11 +326,7 @@ describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
})
it('does not update execution log status in DB when only paused execution was cancelled', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockCancelPausedExecution.mockResolvedValue(true)
mockBeginPausedCancellation.mockResolvedValue(true)
await POST(makeRequest(), makeParams())
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -8,13 +9,83 @@ import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
import {
type ExecutionCancellationRecordResult,
markExecutionCancelled,
} from '@/lib/execution/cancellation'
import { createExecutionEventWriter, readExecutionMetaState } 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'
const logger = createLogger('CancelExecutionAPI')
const PAUSED_CANCELLATION_DB_ATTEMPTS = 3
const PAUSED_CANCELLATION_DB_RETRY_MS = 200
async function completePausedCancellationWithRetry(executionId: string): Promise<boolean> {
for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) {
try {
const cancelled = await PauseResumeManager.completePausedCancellation(executionId)
if (cancelled) {
logger.info('Paused execution cancelled in database', { executionId, attempt })
return true
}
logger.warn('Paused execution cancellation could not be completed in database', {
executionId,
attempt,
})
return false
} catch (error) {
logger.warn('Failed to complete paused execution cancellation in database', {
executionId,
attempt,
error,
})
if (attempt < PAUSED_CANCELLATION_DB_ATTEMPTS) {
await sleep(PAUSED_CANCELLATION_DB_RETRY_MS)
}
}
}
return false
}
async function ensurePausedCancellationEventPublished(
executionId: string,
workflowId: string
): Promise<boolean> {
const metaState = await readExecutionMetaState(executionId)
if (metaState.status === 'found' && metaState.meta.status === 'cancelled') {
return true
}
const writer = createExecutionEventWriter(executionId)
try {
await writer.writeTerminal(
{
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: { duration: 0 },
},
'cancelled'
)
return true
} catch (error) {
logger.warn('Failed to publish paused execution cancellation event', {
executionId,
error,
})
return false
} finally {
await writer.close().catch((error) => {
logger.warn('Failed to close paused cancellation event writer', {
executionId,
error,
})
})
}
}
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -55,40 +126,102 @@ export const POST = withRouteHandler(
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
const cancellation = await markExecutionCancelled(executionId)
const locallyAborted = abortManualExecution(executionId)
let pausedCancellationStarted = false
let pausedCancelled = false
try {
pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId)
pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation(executionId)
} catch (error) {
logger.warn('Failed to cancel paused execution in database', { executionId, error })
logger.warn('Failed to begin paused execution cancellation in database', {
executionId,
error,
})
}
const pendingPausedCancellation = pausedCancellationStarted
? null
: await PauseResumeManager.getPausedCancellationStatus(executionId)
const isPausedCancellationPath =
pausedCancellationStarted || pendingPausedCancellation !== null
if (cancellation.durablyRecorded) {
const cancellation: ExecutionCancellationRecordResult = isPausedCancellationPath
? { durablyRecorded: false, reason: 'redis_unavailable' }
: await markExecutionCancelled(executionId)
const locallyAborted = isPausedCancellationPath ? false : abortManualExecution(executionId)
if (pausedCancellationStarted) {
logger.info('Paused execution cancellation reserved in database', { executionId })
} else 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 {
} else if (!pausedCancellationStarted) {
logger.warn('Execution cancellation was not durably recorded', {
executionId,
reason: cancellation.reason,
})
}
if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) {
await PauseResumeManager.blockQueuedResumesForCancellation(executionId).catch((error) => {
logger.warn('Failed to block queued paused resumes after cancellation', {
executionId,
error,
})
})
} else if (!isPausedCancellationPath) {
await PauseResumeManager.clearPausedCancellationIntent(executionId).catch((error) => {
logger.warn(
'Failed to clear paused cancellation intent after unsuccessful cancellation',
{
executionId,
error,
}
)
})
}
let pausedCancellationPublished = false
let pausedCancellationPublishFailed = false
if (pausedCancellationStarted) {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId
)
pausedCancellationPublishFailed = !pausedCancellationPublished
if (pausedCancellationPublished) {
pausedCancelled = await completePausedCancellationWithRetry(executionId)
}
} else {
if (pendingPausedCancellation === 'cancelled') {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId
)
pausedCancellationPublishFailed = !pausedCancellationPublished
pausedCancelled = pausedCancellationPublished
} else if (pendingPausedCancellation === 'cancelling') {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId
)
pausedCancellationPublishFailed = !pausedCancellationPublished
if (pausedCancellationPublished) {
pausedCancelled = await completePausedCancellationWithRetry(executionId)
}
}
}
if (
pausedCancellationPublishFailed &&
(pausedCancellationStarted || pendingPausedCancellation === 'cancelling')
) {
await PauseResumeManager.clearPausedCancellationIntent(executionId).catch((error) => {
logger.warn('Failed to clear paused cancellation intent after publish failure', {
executionId,
error,
})
})
}
if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) {
try {
await db
@@ -108,7 +241,10 @@ export const POST = withRouteHandler(
}
}
const success = cancellation.durablyRecorded || locallyAborted || pausedCancelled
const success =
(isPausedCancellationPath
? pausedCancelled && pausedCancellationPublished
: cancellation.durablyRecorded) || locallyAborted
if (success) {
const workspaceId = workflowAuthorization.workflow?.workspaceId
@@ -120,14 +256,30 @@ export const POST = withRouteHandler(
)
}
const durablyRecorded = isPausedCancellationPath
? pausedCancellationPublished
: pausedCancelled || cancellation.durablyRecorded
const reason = pausedCancellationPublishFailed
? 'paused_event_publish_failed'
: !pausedCancelled && isPausedCancellationPath
? 'paused_database_cancel_failed'
: pausedCancelled && !pausedCancellationPublished
? 'paused_event_publish_failed'
: pausedCancelled || isPausedCancellationPath
? 'recorded'
: cancellation.reason
return NextResponse.json({
success,
executionId,
redisAvailable: cancellation.reason !== 'redis_unavailable',
durablyRecorded: cancellation.durablyRecorded,
redisAvailable:
isPausedCancellationPath || pausedCancelled
? pausedCancellationPublished
: cancellation.reason !== 'redis_unavailable',
durablyRecorded,
locallyAborted,
pausedCancelled,
reason: cancellation.reason,
reason,
})
} catch (error: any) {
logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message })
@@ -0,0 +1,266 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionEventEntry } from '@/lib/execution/event-buffer'
const {
mockAuthorizeWorkflowByWorkspacePermission,
mockGetSession,
mockReadExecutionEventsState,
mockReadExecutionMetaState,
} = vi.hoisted(() => ({
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
mockGetSession: vi.fn(),
mockReadExecutionEventsState: vi.fn(),
mockReadExecutionMetaState: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@sim/workflow-authz', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
}))
vi.mock('@/lib/execution/event-buffer', () => ({
readExecutionEventsState: mockReadExecutionEventsState,
readExecutionMetaState: mockReadExecutionMetaState,
}))
import { GET } from './route'
function completedEntry(eventId: number): ExecutionEventEntry {
return {
eventId,
executionId: 'exec-1',
event: {
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
success: true,
output: {},
duration: 10,
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
finalBlockLogs: [],
},
},
}
}
describe('execution stream reconnect route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true })
mockReadExecutionMetaState.mockResolvedValue({
status: 'found',
meta: { status: 'active', workflowId: 'wf-1' },
})
mockReadExecutionEventsState.mockResolvedValue({ status: 'ok', events: [] })
})
it('drains final events after terminal meta before sending DONE', async () => {
mockReadExecutionMetaState
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'active', workflowId: 'wf-1' },
})
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'complete', workflowId: 'wf-1' },
})
mockReadExecutionEventsState
.mockResolvedValueOnce({ status: 'ok', events: [] })
.mockResolvedValueOnce({ status: 'ok', events: [completedEntry(4)] })
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
const body = await response.text()
const completedIndex = body.indexOf('"type":"execution:completed"')
const doneIndex = body.indexOf('data: [DONE]')
expect(completedIndex).toBeGreaterThanOrEqual(0)
expect(doneIndex).toBeGreaterThan(completedIndex)
expect(mockReadExecutionEventsState).toHaveBeenNthCalledWith(1, 'exec-1', 3)
expect(mockReadExecutionEventsState).toHaveBeenNthCalledWith(2, 'exec-1', 3)
})
it('errors when terminal metadata has no terminal event to replay', async () => {
mockReadExecutionMetaState
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'active', workflowId: 'wf-1' },
})
.mockResolvedValueOnce({
status: 'found',
meta: { status: 'complete', workflowId: 'wf-1' },
})
mockReadExecutionEventsState
.mockResolvedValueOnce({ status: 'ok', events: [] })
.mockResolvedValueOnce({ status: 'ok', events: [] })
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow(
'Execution reached terminal metadata without a terminal event'
)
})
it('allows replay event id gaps from reserved but unused writer ids', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'ok',
events: [completedEntry(101)],
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('"eventId":101')
expect(body).toContain('data: [DONE]')
})
it('errors when replay events are not strictly increasing', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'ok',
events: [completedEntry(3)],
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow(
'Execution event replay order violation: previous 3, received 3'
)
})
it('returns unavailable when metadata cannot be read', async () => {
mockReadExecutionMetaState.mockResolvedValueOnce({
status: 'unavailable',
error: 'redis unavailable',
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(503)
await expect(response.json()).resolves.toEqual({
error: 'Run buffer temporarily unavailable',
})
})
it('stops after replaying a terminal event even when metadata is still active', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'ok',
events: [completedEntry(4)],
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('"type":"execution:completed"')
expect(body).toContain('data: [DONE]')
expect(mockReadExecutionEventsState).toHaveBeenCalledTimes(1)
expect(mockReadExecutionMetaState).toHaveBeenCalledTimes(1)
})
it('errors the stream when replay events cannot be read', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'unavailable',
error: 'redis read failed',
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow('Execution events unavailable: redis read failed')
})
it('errors the stream when requested events were pruned', async () => {
mockReadExecutionEventsState.mockResolvedValueOnce({
status: 'pruned',
earliestEventId: 10,
})
const req = createMockRequest(
'GET',
undefined,
undefined,
'http://localhost/api/workflows/wf-1/executions/exec-1/stream?from=3'
)
const response = await GET(req, {
params: Promise.resolve({ id: 'wf-1', executionId: 'exec-1' }),
})
expect(response.status).toBe(200)
await expect(response.text()).rejects.toThrow(
'Execution events pruned before requested event id'
)
})
})
@@ -9,10 +9,12 @@ import { getSession } from '@/lib/auth'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
type ExecutionEventEntry,
type ExecutionStreamStatus,
getExecutionMeta,
readExecutionEvents,
readExecutionEventsState,
readExecutionMetaState,
} from '@/lib/execution/event-buffer'
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
import { formatSSEEvent } from '@/lib/workflows/executor/execution-events'
const logger = createLogger('ExecutionStreamReconnectAPI')
@@ -24,6 +26,15 @@ function isTerminalStatus(status: ExecutionStreamStatus): boolean {
return status === 'complete' || status === 'error' || status === 'cancelled'
}
function isTerminalEvent(event: ExecutionEvent): boolean {
return (
event.type === 'execution:completed' ||
event.type === 'execution:error' ||
event.type === 'execution:cancelled' ||
event.type === 'execution:paused'
)
}
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -52,10 +63,14 @@ export const GET = withRouteHandler(
)
}
const meta = await getExecutionMeta(executionId)
if (!meta) {
const metaResult = await readExecutionMetaState(executionId)
if (metaResult.status === 'unavailable') {
return NextResponse.json({ error: 'Run buffer temporarily unavailable' }, { status: 503 })
}
if (metaResult.status === 'missing') {
return NextResponse.json({ error: 'Run buffer not found or expired' }, { status: 404 })
}
const { meta } = metaResult
if (meta.workflowId && meta.workflowId !== workflowId) {
return NextResponse.json({ error: 'Run does not belong to this workflow' }, { status: 403 })
@@ -86,19 +101,68 @@ export const GET = withRouteHandler(
}
}
try {
const events = await readExecutionEvents(executionId, lastEventId)
const readEventsOrThrow = async (
afterEventId: number
): Promise<ExecutionEventEntry[]> => {
const result = await readExecutionEventsState(executionId, afterEventId)
if (result.status === 'unavailable') {
throw new Error(`Execution events unavailable: ${result.error}`)
}
if (result.status === 'pruned') {
throw new Error(
`Execution events pruned before requested event id: earliest retained event is ${result.earliestEventId}`
)
}
let previousEventId = afterEventId
for (const entry of result.events) {
if (entry.eventId <= previousEventId) {
throw new Error(
`Execution event replay order violation: previous ${previousEventId}, received ${entry.eventId}`
)
}
previousEventId = entry.eventId
}
return result.events
}
const enqueueEvents = (events: ExecutionEventEntry[]) => {
let sawTerminalEvent = false
for (const entry of events) {
if (closed) return
if (closed) break
entry.event.eventId = entry.eventId
enqueue(formatSSEEvent(entry.event))
lastEventId = entry.eventId
sawTerminalEvent ||= isTerminalEvent(entry.event)
}
return sawTerminalEvent
}
const closeWithDone = () => {
enqueue('data: [DONE]\n\n')
if (!closed) controller.close()
}
const closeAfterTerminalEvent = (events: ExecutionEventEntry[]) => {
if (!enqueueEvents(events)) {
throw new Error('Execution reached terminal metadata without a terminal event')
}
closeWithDone()
}
try {
const events = await readEventsOrThrow(lastEventId)
if (enqueueEvents(events)) {
closeWithDone()
return
}
const currentMeta = await getExecutionMeta(executionId)
if (!currentMeta || isTerminalStatus(currentMeta.status)) {
enqueue('data: [DONE]\n\n')
if (!closed) controller.close()
const currentMeta = await readExecutionMetaState(executionId)
if (currentMeta.status === 'unavailable') {
throw new Error(`Execution metadata unavailable: ${currentMeta.error}`)
}
if (currentMeta.status === 'missing' || isTerminalStatus(currentMeta.meta.status)) {
const finalEvents = await readEventsOrThrow(lastEventId)
closeAfterTerminalEvent(finalEvents)
return
}
@@ -106,33 +170,26 @@ export const GET = withRouteHandler(
await sleep(POLL_INTERVAL_MS)
if (closed) return
const newEvents = await readExecutionEvents(executionId, lastEventId)
for (const entry of newEvents) {
if (closed) return
entry.event.eventId = entry.eventId
enqueue(formatSSEEvent(entry.event))
lastEventId = entry.eventId
const newEvents = await readEventsOrThrow(lastEventId)
if (enqueueEvents(newEvents)) {
closeWithDone()
return
}
const polledMeta = await getExecutionMeta(executionId)
if (!polledMeta || isTerminalStatus(polledMeta.status)) {
const finalEvents = await readExecutionEvents(executionId, lastEventId)
for (const entry of finalEvents) {
if (closed) return
entry.event.eventId = entry.eventId
enqueue(formatSSEEvent(entry.event))
lastEventId = entry.eventId
}
enqueue('data: [DONE]\n\n')
if (!closed) controller.close()
const polledMeta = await readExecutionMetaState(executionId)
if (polledMeta.status === 'unavailable') {
throw new Error(`Execution metadata unavailable: ${polledMeta.error}`)
}
if (polledMeta.status === 'missing' || isTerminalStatus(polledMeta.meta.status)) {
const finalEvents = await readEventsOrThrow(lastEventId)
closeAfterTerminalEvent(finalEvents)
return
}
}
if (!closed) {
logger.warn('Reconnection stream poll deadline reached', { executionId })
enqueue('data: [DONE]\n\n')
controller.close()
throw new Error('Execution stream ended before a terminal event was available')
}
} catch (error) {
logger.error('Error in reconnection stream', {
@@ -141,7 +198,7 @@ export const GET = withRouteHandler(
})
if (!closed) {
try {
controller.close()
controller.error(error)
} catch {}
}
}
@@ -4596,7 +4596,7 @@ export function useChat(
}).catch(() => {})
}
consoleStore.cancelRunningEntries(workflowId)
consoleStore.cancelRunningEntries(workflowId, executionId ?? undefined)
const now = new Date()
consoleStore.addConsole({
input: {},
@@ -10,6 +10,7 @@ import { requestJson } from '@/lib/api/client/request'
import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events'
import {
extractTriggerMockPayload,
selectBestTrigger,
@@ -37,7 +38,12 @@ import { hasExecutionResult } from '@/executor/utils/errors'
import { coerceValue } from '@/executor/utils/start-block'
import { subscriptionKeys } from '@/hooks/queries/subscription'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { isExecutionStreamHttpError, useExecutionStream } from '@/hooks/use-execution-stream'
import {
isExecutionStreamHttpError,
SSEEventHandlerError,
SSEStreamInterruptedError,
useExecutionStream,
} from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
@@ -63,10 +69,12 @@ const logger = createLogger('useWorkflowExecution')
*/
const activeReconnections = new Set<string>()
function isReconnectTerminal(error: unknown): boolean {
function isReconnectNonRetryable(error: unknown): boolean {
const message = error instanceof Error ? error.message : ''
return (
isExecutionStreamHttpError(error) &&
(error.httpStatus === 404 || error.httpStatus === 403 || error.httpStatus === 401)
message.includes('Execution events pruned before requested event id') ||
(isExecutionStreamHttpError(error) &&
(error.httpStatus === 404 || error.httpStatus === 403 || error.httpStatus === 401))
)
}
@@ -77,10 +85,25 @@ interface DebugValidationResult {
const WORKFLOW_EXECUTION_FAILURE_MESSAGE = 'Workflow execution failed'
async function persistExecutionPointerProgress(
workflowId: string,
executionId: string,
lastEventId: number
): Promise<void> {
await consolePersistence.persist()
await saveExecutionPointer({ workflowId, executionId, lastEventId })
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isRecoverableStreamRecoveryError(
error: unknown
): error is SSEEventHandlerError | SSEStreamInterruptedError {
return error instanceof SSEEventHandlerError || error instanceof SSEStreamInterruptedError
}
function sanitizeMessage(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
@@ -120,16 +143,23 @@ export function useWorkflowExecution() {
const queryClient = useQueryClient()
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } =
useTerminalConsoleStore(
useShallow((s) => ({
toggleConsole: s.toggleConsole,
addConsole: s.addConsole,
updateConsole: s.updateConsole,
cancelRunningEntries: s.cancelRunningEntries,
clearExecutionEntries: s.clearExecutionEntries,
}))
)
const {
toggleConsole,
addConsole,
updateConsole,
cancelRunningEntries,
finishRunningEntries,
clearExecutionEntries,
} = useTerminalConsoleStore(
useShallow((s) => ({
toggleConsole: s.toggleConsole,
addConsole: s.addConsole,
updateConsole: s.updateConsole,
cancelRunningEntries: s.cancelRunningEntries,
finishRunningEntries: s.finishRunningEntries,
clearExecutionEntries: s.clearExecutionEntries,
}))
)
const hasHydrated = useTerminalConsoleStore((s) => s._hasHydrated)
const { getVariablesByWorkflowId, variables } = useVariablesStore(
useShallow((s) => ({
@@ -157,10 +187,15 @@ export function useWorkflowExecution() {
const setIsExecuting = useCallback(
(workflowId: string, executing: boolean) => {
const wasExecuting = useExecutionStore.getState().getWorkflowExecution(workflowId).isExecuting
if (executing) {
consolePersistence.executionStarted()
if (!wasExecuting) {
consolePersistence.executionStarted()
}
} else {
consolePersistence.executionEnded()
if (wasExecuting) {
consolePersistence.executionEnded()
}
clearExecutionPointer(workflowId)
}
rawSetIsExecuting(workflowId, executing)
@@ -178,8 +213,10 @@ export function useWorkflowExecution() {
const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot)
const clearLastExecutionSnapshot = useExecutionStore((s) => s.clearLastExecutionSnapshot)
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
const [reconnectAttemptNonce, setReconnectAttemptNonce] = useState(0)
const executionStream = useExecutionStream()
const currentChatExecutionIdRef = useRef<string | null>(null)
const runFromBlockOwnerRef = useRef<string | null>(null)
const lastSeenEventIdRef = useRef<number>(0)
const isViewingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
const addNotification = useNotificationStore((state) => state.addNotification)
@@ -438,6 +475,7 @@ export function useWorkflowExecution() {
if (isChatExecution) {
let isCancelled = false
const executionId = generateId()
let preserveChatExecutionForRecovery = false
currentChatExecutionIdRef.current = executionId
const stream = new ReadableStream({
async start(controller) {
@@ -712,6 +750,15 @@ export function useWorkflowExecution() {
// Note: Logs are already persisted server-side via execution-core.ts
}
} catch (error: any) {
if (isRecoverableStreamRecoveryError(error)) {
preserveChatExecutionForRecovery = true
logger.warn('Chat workflow stream interrupted; waiting for reconnect replay', {
workflowId: activeWorkflowId,
executionId: error.executionId,
error: error.message,
})
return
}
// Create a proper error result for logging
const errorResult = {
success: false,
@@ -733,7 +780,10 @@ export function useWorkflowExecution() {
if (!isCancelled) {
controller.close()
}
if (currentChatExecutionIdRef.current === executionId) {
if (
!preserveChatExecutionForRecovery &&
currentChatExecutionIdRef.current === executionId
) {
setIsExecuting(activeWorkflowId, false)
setIsDebugging(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
@@ -764,6 +814,10 @@ export function useWorkflowExecution() {
}
return result
} catch (error: any) {
if (isRecoverableStreamRecoveryError(error)) {
handleExecutionError(error, { executionId: manualExecutionId })
throw error
}
const errorResult = handleExecutionError(error, { executionId: manualExecutionId })
return errorResult
}
@@ -1047,6 +1101,7 @@ export function useWorkflowExecution() {
loops: latestWorkflowState.loops,
parallels: latestWorkflowState.parallels,
}
let executionFinished = false
await executionStream.execute({
workflowId: activeWorkflowId,
@@ -1073,8 +1128,16 @@ export function useWorkflowExecution() {
})
},
callbacks: {
onEventId: (eventId) => {
onEventId: async (eventId) => {
if (executionFinished) return
lastSeenEventIdRef.current = eventId
if (eventId % 5 === 0 && activeWorkflowId && executionIdRef.current) {
await persistExecutionPointerProgress(
activeWorkflowId,
executionIdRef.current,
eventId
)
}
},
onExecutionStarted: (data) => {
@@ -1121,6 +1184,7 @@ export function useWorkflowExecution() {
},
onExecutionCompleted: (data) => {
executionFinished = true
if (
activeWorkflowId &&
executionIdRef.current &&
@@ -1137,7 +1201,7 @@ export function useWorkflowExecution() {
executionIdRef.current,
data.finalBlockLogs
)
cancelRunningEntries(activeWorkflowId)
finishRunningEntries(activeWorkflowId, executionIdRef.current)
}
executionResult = {
@@ -1216,7 +1280,52 @@ export function useWorkflowExecution() {
}
},
onExecutionPaused: (data: ExecutionPausedData) => {
executionFinished = true
if (
activeWorkflowId &&
executionIdRef.current &&
useExecutionStore.getState().getCurrentExecutionId(activeWorkflowId) !==
executionIdRef.current
)
return
if (activeWorkflowId) {
setCurrentExecutionId(activeWorkflowId, null)
reconcileFinalBlockLogs(
updateConsole,
activeWorkflowId,
executionIdRef.current,
data.finalBlockLogs
)
finishRunningEntries(activeWorkflowId, executionIdRef.current)
}
executionResult = {
success: true,
output: data.output,
metadata: {
duration: data.duration,
startTime: data.startTime,
endTime: data.endTime,
},
logs: accumulatedBlockLogs,
}
const workflowExecState = activeWorkflowId
? useExecutionStore.getState().getWorkflowExecution(activeWorkflowId)
: null
if (activeWorkflowId && !workflowExecState?.isDebugging) {
setExecutionResult(executionResult)
if (!isExecutingFromChat) {
setIsExecuting(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
}
}
},
onExecutionError: (data) => {
executionFinished = true
if (
activeWorkflowId &&
executionIdRef.current &&
@@ -1258,6 +1367,7 @@ export function useWorkflowExecution() {
},
onExecutionCancelled: (data) => {
executionFinished = true
if (
activeWorkflowId &&
executionIdRef.current &&
@@ -1288,6 +1398,10 @@ export function useWorkflowExecution() {
return executionResult
} catch (error: any) {
if (isRecoverableStreamRecoveryError(error)) {
handleExecutionError(error, { executionId: executionIdRef.current })
throw error
}
if (error.name === 'AbortError' || error.message?.includes('aborted')) {
logger.info('Execution aborted by user')
return executionResult
@@ -1345,6 +1459,12 @@ export function useWorkflowExecution() {
blockName: error.blockName || 'Workflow',
blockType: error.blockType || 'serializer',
})
} else if (isRecoverableStreamRecoveryError(error)) {
logger.warn('Execution stream needs reconnect without authoritative terminal state', {
workflowId: activeWorkflowId,
executionId: error.executionId ?? options?.executionId,
error: error.message,
})
} else {
sharedAddExecutionErrorConsoleEntry(storeAddConsole, {
workflowId: activeWorkflowId || '',
@@ -1365,6 +1485,13 @@ export function useWorkflowExecution() {
}
}
if (isRecoverableStreamRecoveryError(error)) {
if (activeWorkflowId) {
setReconnectAttemptNonce((nonce) => nonce + 1)
}
return errorResult
}
setExecutionResult(errorResult)
if (activeWorkflowId) {
setIsExecuting(activeWorkflowId, false)
@@ -1545,21 +1672,41 @@ export function useWorkflowExecution() {
const storedExecutionId = getCurrentExecutionId(activeWorkflowId)
if (storedExecutionId) {
setCurrentExecutionId(activeWorkflowId, null)
requestJson(cancelWorkflowExecutionContract, {
void requestJson(cancelWorkflowExecutionContract, {
params: { id: activeWorkflowId, executionId: storedExecutionId },
}).catch(() => {})
handleExecutionCancelledConsole({
workflowId: activeWorkflowId,
executionId: storedExecutionId,
})
}
.then((result) => {
if (!result.success) {
logger.warn('Workflow execution cancellation was not confirmed', {
workflowId: activeWorkflowId,
executionId: storedExecutionId,
reason: result.reason,
})
return
}
executionStream.cancel(activeWorkflowId)
currentChatExecutionIdRef.current = null
setIsExecuting(activeWorkflowId, false)
setIsDebugging(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
const currentId = getCurrentExecutionId(activeWorkflowId)
if (currentId !== storedExecutionId) return
logger.info('Workflow execution cancellation confirmed; awaiting terminal event', {
workflowId: activeWorkflowId,
executionId: storedExecutionId,
})
})
.catch((error) => {
logger.warn('Failed to request workflow execution cancellation', {
workflowId: activeWorkflowId,
executionId: storedExecutionId,
error,
})
})
} else {
executionStream.cancel(activeWorkflowId)
currentChatExecutionIdRef.current = null
setIsExecuting(activeWorkflowId, false)
setIsDebugging(activeWorkflowId, false)
setActiveBlocks(activeWorkflowId, new Set())
}
if (isDebugging) {
resetDebugState()
@@ -1573,8 +1720,6 @@ export function useWorkflowExecution() {
setActiveBlocks,
activeWorkflowId,
getCurrentExecutionId,
setCurrentExecutionId,
handleExecutionCancelledConsole,
])
/**
@@ -1674,13 +1819,29 @@ export function useWorkflowExecution() {
}
setIsExecuting(workflowId, true)
const runOwnerId = generateId()
runFromBlockOwnerRef.current = runOwnerId
const executionIdRef = { current: '' }
const accumulatedBlockLogs: BlockLog[] = []
const accumulatedBlockStates = new Map<string, BlockState>()
const executedBlockIds = new Set<string>()
const activeBlocksSet = new Set<string>()
const activeBlockRefCounts = new Map<string, number>()
const isCurrentRunFromBlockExecution = () => {
return (
Boolean(executionIdRef.current) &&
getCurrentExecutionId(workflowId) === executionIdRef.current
)
}
const clearRunFromBlockExecutionState = () => {
if (!isCurrentRunFromBlockExecution()) return false
setCurrentExecutionId(workflowId, null)
setIsExecuting(workflowId, false)
setActiveBlocks(workflowId, new Set())
return true
}
let preserveExecutionForRecovery = false
try {
const blockHandlers = buildBlockEventHandlers({
workflowId,
@@ -1700,23 +1861,33 @@ export function useWorkflowExecution() {
sourceSnapshot: effectiveSnapshot,
input: workflowInput,
onExecutionId: (id) => {
if (runFromBlockOwnerRef.current !== runOwnerId) return
executionIdRef.current = id
setCurrentExecutionId(workflowId, id)
saveExecutionPointer({
workflowId,
executionId: id,
lastEventId: 0,
})
},
callbacks: {
onEventId: async (eventId) => {
if (executionIdRef.current && !isCurrentRunFromBlockExecution()) return
if (eventId % 5 === 0 && executionIdRef.current) {
await persistExecutionPointerProgress(workflowId, executionIdRef.current, eventId)
}
},
onBlockStarted: blockHandlers.onBlockStarted,
onBlockCompleted: blockHandlers.onBlockCompleted,
onBlockError: blockHandlers.onBlockError,
onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted,
onExecutionCompleted: (data) => {
reconcileFinalBlockLogs(
updateConsole,
workflowId,
executionIdRef.current,
data.finalBlockLogs
)
cancelRunningEntries(workflowId)
if (!isCurrentRunFromBlockExecution()) return
const executionId = executionIdRef.current
reconcileFinalBlockLogs(updateConsole, workflowId, executionId, data.finalBlockLogs)
finishRunningEntries(workflowId, executionId)
if (data.success) {
executedBlockIds.add(blockId)
@@ -1743,12 +1914,31 @@ export function useWorkflowExecution() {
setLastExecutionSnapshot(workflowId, updatedSnapshot)
}
setCurrentExecutionId(workflowId, null)
setIsExecuting(workflowId, false)
setActiveBlocks(workflowId, new Set())
clearRunFromBlockExecutionState()
},
onExecutionPaused: (data) => {
if (!isCurrentRunFromBlockExecution()) return
const executionId = executionIdRef.current
reconcileFinalBlockLogs(updateConsole, workflowId, executionId, data.finalBlockLogs)
finishRunningEntries(workflowId, executionId)
clearRunFromBlockExecutionState()
setExecutionResult({
success: true,
output: data.output,
metadata: {
duration: data.duration,
startTime: data.startTime,
endTime: data.endTime,
},
logs: accumulatedBlockLogs,
})
},
onExecutionError: (data) => {
if (!isCurrentRunFromBlockExecution()) return
const executionId = executionIdRef.current
const isWorkflowModified =
data.error?.includes('Block not found in workflow') ||
data.error?.includes('Upstream dependency not executed')
@@ -1765,42 +1955,70 @@ export function useWorkflowExecution() {
handleExecutionErrorConsole({
workflowId,
executionId: executionIdRef.current,
executionId,
error: data.error,
durationMs: data.duration,
blockLogs: accumulatedBlockLogs,
finalBlockLogs: data.finalBlockLogs,
})
setCurrentExecutionId(workflowId, null)
setIsExecuting(workflowId, false)
setActiveBlocks(workflowId, new Set())
clearRunFromBlockExecutionState()
},
onExecutionCancelled: (data) => {
if (!isCurrentRunFromBlockExecution()) return
const executionId = executionIdRef.current
handleExecutionCancelledConsole({
workflowId,
executionId: executionIdRef.current,
executionId,
durationMs: data?.duration,
finalBlockLogs: data?.finalBlockLogs,
})
setCurrentExecutionId(workflowId, null)
setIsExecuting(workflowId, false)
setActiveBlocks(workflowId, new Set())
clearRunFromBlockExecutionState()
},
},
})
} catch (error) {
if ((error as Error).name !== 'AbortError') {
if (isRecoverableStreamRecoveryError(error)) {
preserveExecutionForRecovery = true
logger.warn('Run-from-block stream interrupted; preserving execution for replay', {
workflowId,
executionId: error.executionId ?? executionIdRef.current,
eventType: error instanceof SSEEventHandlerError ? error.eventType : undefined,
eventId: error instanceof SSEEventHandlerError ? error.eventId : undefined,
error: error.message,
})
setReconnectAttemptNonce((nonce) => nonce + 1)
} else if ((error as Error).name !== 'AbortError') {
logger.error('Run-from-block failed:', error)
}
} finally {
const currentId = getCurrentExecutionId(workflowId)
if (currentId === null || currentId === executionIdRef.current) {
setCurrentExecutionId(workflowId, null)
setIsExecuting(workflowId, false)
setActiveBlocks(workflowId, new Set())
if (preserveExecutionForRecovery) {
if (runFromBlockOwnerRef.current === runOwnerId) {
runFromBlockOwnerRef.current = null
}
} else {
const currentId = getCurrentExecutionId(workflowId)
if (executionIdRef.current && currentId === executionIdRef.current) {
setCurrentExecutionId(workflowId, null)
setIsExecuting(workflowId, false)
setActiveBlocks(workflowId, new Set())
if (runFromBlockOwnerRef.current === runOwnerId) {
runFromBlockOwnerRef.current = null
}
} else if (
!executionIdRef.current &&
currentId === null &&
runFromBlockOwnerRef.current === runOwnerId
) {
const workflowExecState = useExecutionStore.getState().getWorkflowExecution(workflowId)
if (workflowExecState.isExecuting) {
setIsExecuting(workflowId, false)
setActiveBlocks(workflowId, new Set())
}
runFromBlockOwnerRef.current = null
}
}
}
},
@@ -1814,6 +2032,9 @@ export function useWorkflowExecution() {
setActiveBlocks,
setBlockRunStatus,
setEdgeRunStatus,
updateConsole,
finishRunningEntries,
setExecutionResult,
addNotification,
buildBlockEventHandlers,
handleExecutionErrorConsole,
@@ -1843,21 +2064,9 @@ export function useWorkflowExecution() {
} catch (error) {
const errorResult = handleExecutionError(error, { executionId })
return errorResult
} finally {
setCurrentExecutionId(workflowId, null)
setIsExecuting(workflowId, false)
setIsDebugging(workflowId, false)
setActiveBlocks(workflowId, new Set())
}
},
[
activeWorkflowId,
setCurrentExecutionId,
setExecutionResult,
setIsExecuting,
setIsDebugging,
setActiveBlocks,
]
[activeWorkflowId, setExecutionResult, setIsExecuting]
)
useEffect(() => {
@@ -1866,8 +2075,16 @@ export function useWorkflowExecution() {
let cleanupRan = false
let reconnectionComplete = false
let ownsReconnect = false
let ownedReconnectExecutionId: string | null = null
const reconnectWorkflowId = activeWorkflowId
const releaseReconnectOwnership = () => {
activeReconnections.delete(reconnectWorkflowId)
ownsReconnect = false
ownedReconnectExecutionId = null
}
const runReconnect = async () => {
let executionId: string | undefined
let fromEventId = 0
@@ -1883,34 +2100,30 @@ export function useWorkflowExecution() {
// fall through to console entries
}
if (!executionId) {
const entries = useTerminalConsoleStore.getState().getWorkflowEntries(reconnectWorkflowId)
const runningEntries = entries.filter(
(e) => e.isRunning && e.workflowId === reconnectWorkflowId && e.executionId
)
if (runningEntries.length === 0) return
const sorted = [...runningEntries].sort((a, b) => {
const aTime = a.startedAt ? new Date(a.startedAt).getTime() : 0
const bTime = b.startedAt ? new Date(b.startedAt).getTime() : 0
return bTime - aTime
})
executionId = sorted[0].executionId!
const otherExecutionIds = new Set(
sorted.filter((e) => e.executionId !== executionId).map((e) => e.executionId!)
)
if (otherExecutionIds.size > 0) {
cancelRunningEntries(reconnectWorkflowId)
consolePersistence.persist()
if (!executionId || cleanupRan) return
const capturedExecutionId = executionId
const canReconnectClaimWorkflow = () => {
const executionState = useExecutionStore
.getState()
.getWorkflowExecution(reconnectWorkflowId)
const currentId = executionState?.currentExecutionId ?? null
if (currentId) return currentId === capturedExecutionId
return !executionState?.isExecuting
}
const clearCapturedExecutionPointer = async () => {
const pointer = await loadExecutionPointer(reconnectWorkflowId).catch(() => null)
if (pointer?.executionId === capturedExecutionId) {
await clearExecutionPointer(reconnectWorkflowId)
}
}
if (!executionId || cleanupRan) return
if (!canReconnectClaimWorkflow()) {
await clearCapturedExecutionPointer()
return
}
if (activeReconnections.has(reconnectWorkflowId)) return
activeReconnections.add(reconnectWorkflowId)
executionStream.cancel(reconnectWorkflowId)
ownsReconnect = true
executionStream.cancelExecute(reconnectWorkflowId)
const workflowEdges = useWorkflowStore.getState().edges
const activeBlocksSet = new Set<string>()
@@ -1932,47 +2145,80 @@ export function useWorkflowExecution() {
includeStartConsoleEntry: true,
})
const capturedExecutionId = executionId
ownedReconnectExecutionId = capturedExecutionId
const MAX_ATTEMPTS = 5
const BASE_DELAY_MS = 1000
const MAX_DELAY_MS = 15000
let activated = false
let activationStartedPersistence = false
const isReconnectStillCurrent = canReconnectClaimWorkflow
const stopStaleReconnect = () => {
reconnectionComplete = true
if (ownedReconnectExecutionId) {
executionStream.cancelReconnect(reconnectWorkflowId, ownedReconnectExecutionId)
}
releaseReconnectOwnership()
}
const releaseActivatedReconnectState = () => {
if (!activated) return
const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId)
if (currentId !== capturedExecutionId) return
setCurrentExecutionId(reconnectWorkflowId, null)
if (activationStartedPersistence) {
consolePersistence.executionEnded()
activationStartedPersistence = false
}
rawSetIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
}
const releaseReconnectStateWithoutTerminal = () => {
const executionState = useExecutionStore
.getState()
.getWorkflowExecution(reconnectWorkflowId)
const currentId = executionState?.currentExecutionId ?? null
if (currentId && currentId !== capturedExecutionId) return
finishRunningEntries(reconnectWorkflowId, capturedExecutionId)
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
activationStartedPersistence = false
}
const scheduleRetryableReconnect = () => {
releaseReconnectOwnership()
setTimeout(() => {
if (!cleanupRan && !reconnectionComplete) {
setReconnectAttemptNonce((nonce) => nonce + 1)
}
}, MAX_DELAY_MS)
}
const ensureActivated = () => {
if (activated || cleanupRan) return
activated = true
setCurrentExecutionId(reconnectWorkflowId, capturedExecutionId)
setIsExecuting(reconnectWorkflowId, true)
clearExecutionEntries(capturedExecutionId)
if (cleanupRan || reconnectionComplete) return false
if (!isReconnectStillCurrent()) {
stopStaleReconnect()
return false
}
if (!activated) {
activated = true
activationStartedPersistence = !useExecutionStore
.getState()
.getWorkflowExecution(reconnectWorkflowId).isExecuting
setCurrentExecutionId(reconnectWorkflowId, capturedExecutionId)
setIsExecuting(reconnectWorkflowId, true)
if (fromEventId === 0) {
clearExecutionEntries(capturedExecutionId)
}
}
return true
}
const wrapHandler =
<T>(handler: (data: T) => void) =>
(data: T) => {
ensureActivated()
if (!ensureActivated()) return
handler(data)
}
const cleanupFailedReconnect = () => {
const currentId = useExecutionStore.getState().getCurrentExecutionId(reconnectWorkflowId)
if (currentId && currentId !== capturedExecutionId) return
const hasRunningEntry = useTerminalConsoleStore
.getState()
.getWorkflowEntries(reconnectWorkflowId)
.some((entry) => entry.isRunning && entry.executionId === capturedExecutionId)
if (activated || hasRunningEntry) {
cancelRunningEntries(reconnectWorkflowId)
}
if (currentId === capturedExecutionId) {
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
}
}
const attemptReconnect = async (attempt: number): Promise<void> => {
if (cleanupRan || reconnectionComplete) return
@@ -1988,50 +2234,81 @@ export function useWorkflowExecution() {
executionId: capturedExecutionId,
fromEventId,
callbacks: {
onEventId: (eid) => {
ensureActivated()
onEventId: async (eid) => {
if (reconnectionComplete) return
if (!isReconnectStillCurrent()) {
stopStaleReconnect()
return
}
fromEventId = eid
if (eid % 5 === 0) {
await persistExecutionPointerProgress(
reconnectWorkflowId,
capturedExecutionId,
eid
)
}
},
onBlockStarted: wrapHandler(handlers.onBlockStarted),
onBlockCompleted: wrapHandler(handlers.onBlockCompleted),
onBlockError: wrapHandler(handlers.onBlockError),
onBlockChildWorkflowStarted: wrapHandler(handlers.onBlockChildWorkflowStarted),
onExecutionCompleted: (data) => {
if (!ensureActivated()) return
reconnectionComplete = true
activeReconnections.delete(reconnectWorkflowId)
if (!activated) {
clearExecutionPointer(reconnectWorkflowId)
return
}
releaseReconnectOwnership()
const currentId = useExecutionStore
.getState()
.getCurrentExecutionId(reconnectWorkflowId)
if (currentId !== capturedExecutionId) return
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
reconcileFinalBlockLogs(
updateConsole,
reconnectWorkflowId,
capturedExecutionId,
data?.finalBlockLogs
)
cancelRunningEntries(reconnectWorkflowId)
finishRunningEntries(reconnectWorkflowId, capturedExecutionId)
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
},
onExecutionError: (data) => {
onExecutionPaused: (data) => {
if (!ensureActivated()) return
reconnectionComplete = true
activeReconnections.delete(reconnectWorkflowId)
if (!activated) {
clearExecutionPointer(reconnectWorkflowId)
return
}
releaseReconnectOwnership()
const currentId = useExecutionStore
.getState()
.getCurrentExecutionId(reconnectWorkflowId)
if (currentId !== capturedExecutionId) return
reconcileFinalBlockLogs(
updateConsole,
reconnectWorkflowId,
capturedExecutionId,
data.finalBlockLogs
)
finishRunningEntries(reconnectWorkflowId, capturedExecutionId)
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
setExecutionResult({
success: true,
output: data.output,
metadata: {
duration: data.duration,
startTime: data.startTime,
endTime: data.endTime,
},
logs: accumulatedBlockLogs,
})
},
onExecutionError: (data) => {
if (!ensureActivated()) return
reconnectionComplete = true
releaseReconnectOwnership()
const currentId = useExecutionStore
.getState()
.getCurrentExecutionId(reconnectWorkflowId)
if (currentId !== capturedExecutionId) return
handleExecutionErrorConsole({
workflowId: reconnectWorkflowId,
executionId: capturedExecutionId,
@@ -2039,39 +2316,40 @@ export function useWorkflowExecution() {
blockLogs: accumulatedBlockLogs,
finalBlockLogs: data.finalBlockLogs,
})
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
},
onExecutionCancelled: (data) => {
if (!ensureActivated()) return
reconnectionComplete = true
activeReconnections.delete(reconnectWorkflowId)
if (!activated) {
clearExecutionPointer(reconnectWorkflowId)
return
}
releaseReconnectOwnership()
const currentId = useExecutionStore
.getState()
.getCurrentExecutionId(reconnectWorkflowId)
if (currentId !== capturedExecutionId) return
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
handleExecutionCancelledConsole({
workflowId: reconnectWorkflowId,
executionId: capturedExecutionId,
durationMs: data?.duration,
finalBlockLogs: data?.finalBlockLogs,
})
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
},
},
})
} catch (error) {
if (isReconnectTerminal(error)) {
if (isReconnectNonRetryable(error)) {
logger.info('Reconnection skipped; run buffer no longer exists', {
executionId: capturedExecutionId,
})
reconnectionComplete = true
activeReconnections.delete(reconnectWorkflowId)
clearExecutionPointer(reconnectWorkflowId)
cleanupFailedReconnect()
releaseReconnectStateWithoutTerminal()
await consolePersistence.persist()
releaseReconnectOwnership()
await clearCapturedExecutionPointer()
return
}
@@ -2084,27 +2362,17 @@ export function useWorkflowExecution() {
return attemptReconnect(attempt + 1)
}
if (!cleanupRan && !reconnectionComplete) {
reconnectionComplete = true
activeReconnections.delete(reconnectWorkflowId)
cleanupFailedReconnect()
scheduleRetryableReconnect()
await consolePersistence.persist()
return
}
}
if (!reconnectionComplete && !cleanupRan) {
reconnectionComplete = true
activeReconnections.delete(reconnectWorkflowId)
if (activated) {
const currentId = useExecutionStore
.getState()
.getCurrentExecutionId(reconnectWorkflowId)
if (currentId === capturedExecutionId) {
cancelRunningEntries(reconnectWorkflowId)
setCurrentExecutionId(reconnectWorkflowId, null)
setIsExecuting(reconnectWorkflowId, false)
setActiveBlocks(reconnectWorkflowId, new Set())
}
}
releaseActivatedReconnectState()
await consolePersistence.persist()
releaseReconnectOwnership()
}
}
@@ -2115,11 +2383,15 @@ export function useWorkflowExecution() {
return () => {
cleanupRan = true
executionStream.cancel(reconnectWorkflowId)
activeReconnections.delete(reconnectWorkflowId)
if (ownsReconnect) {
if (ownedReconnectExecutionId) {
executionStream.cancelReconnect(reconnectWorkflowId, ownedReconnectExecutionId)
}
releaseReconnectOwnership()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeWorkflowId, hasHydrated])
}, [activeWorkflowId, hasHydrated, reconnectAttemptNonce])
return {
isExecuting,
@@ -5,14 +5,196 @@ import { resetTerminalConsoleMock, terminalConsoleMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
addExecutionErrorConsoleEntry,
createBlockEventHandlers,
handleExecutionErrorConsole,
reconcileFinalBlockLogs,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
import type { BlockLog } from '@/executor/types'
import { useExecutionStore } from '@/stores/execution'
describe('workflow-execution-utils', () => {
beforeEach(() => {
resetTerminalConsoleMock()
vi.mocked(useExecutionStore.getState).mockReturnValue({
getCurrentExecutionId: vi.fn(() => 'exec-1'),
} as any)
})
describe('createBlockEventHandlers', () => {
it('skips duplicate block start rows during reconnect replay', () => {
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'fn-1',
blockName: 'Function 1',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 7,
isRunning: false,
success: true,
iterationCurrent: 0,
iterationTotal: 2,
iterationType: 'loop',
iterationContainerId: 'loop-1',
childWorkflowBlockId: 'child-inst-1',
childWorkflowName: 'Child Workflow',
parentIterations: [
{
iterationCurrent: 1,
iterationTotal: 3,
iterationType: 'parallel',
iterationContainerId: 'parallel-1',
},
],
})
const addConsole = vi.fn()
const handlers = createBlockEventHandlers(
{
workflowId: 'wf-1',
executionIdRef: { current: 'exec-1' },
workflowEdges: [],
activeBlocksSet: new Set<string>(),
activeBlockRefCounts: new Map<string, number>(),
accumulatedBlockLogs: [],
accumulatedBlockStates: new Map(),
executedBlockIds: new Set<string>(),
includeStartConsoleEntry: true,
},
{
addConsole,
updateConsole: vi.fn(),
setActiveBlocks: vi.fn(),
setBlockRunStatus: vi.fn(),
setEdgeRunStatus: vi.fn(),
}
)
handlers.onBlockStarted({
blockId: 'fn-1',
blockName: 'Function 1',
blockType: 'function',
executionOrder: 7,
iterationCurrent: 0,
iterationTotal: 2,
iterationType: 'loop',
iterationContainerId: 'loop-1',
childWorkflowBlockId: 'child-inst-1',
childWorkflowName: 'Child Workflow',
parentIterations: [
{
iterationCurrent: 1,
iterationTotal: 3,
iterationType: 'parallel',
iterationContainerId: 'parallel-1',
},
],
})
expect(addConsole).not.toHaveBeenCalled()
})
it('keeps distinct start rows when replay identity differs', () => {
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'fn-1',
blockName: 'Function 1',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 7,
isRunning: true,
iterationCurrent: 0,
iterationTotal: 2,
iterationType: 'loop',
iterationContainerId: 'loop-1',
})
const addConsole = vi.fn()
const handlers = createBlockEventHandlers(
{
workflowId: 'wf-1',
executionIdRef: { current: 'exec-1' },
workflowEdges: [],
activeBlocksSet: new Set<string>(),
activeBlockRefCounts: new Map<string, number>(),
accumulatedBlockLogs: [],
accumulatedBlockStates: new Map(),
executedBlockIds: new Set<string>(),
includeStartConsoleEntry: true,
},
{
addConsole,
updateConsole: vi.fn(),
setActiveBlocks: vi.fn(),
setBlockRunStatus: vi.fn(),
setEdgeRunStatus: vi.fn(),
}
)
handlers.onBlockStarted({
blockId: 'fn-1',
blockName: 'Function 1',
blockType: 'function',
executionOrder: 7,
iterationCurrent: 1,
iterationTotal: 2,
iterationType: 'loop',
iterationContainerId: 'loop-1',
})
expect(addConsole).toHaveBeenCalledTimes(1)
})
it('replays early child workflow instance updates after the start row is added', () => {
const updateConsole = vi.fn()
const handlers = createBlockEventHandlers(
{
workflowId: 'wf-1',
executionIdRef: { current: 'exec-1' },
workflowEdges: [],
activeBlocksSet: new Set<string>(),
activeBlockRefCounts: new Map<string, number>(),
accumulatedBlockLogs: [],
accumulatedBlockStates: new Map(),
executedBlockIds: new Set<string>(),
includeStartConsoleEntry: true,
},
{
addConsole: terminalConsoleMockFns.mockAddConsole as any,
updateConsole,
setActiveBlocks: vi.fn(),
setBlockRunStatus: vi.fn(),
setEdgeRunStatus: vi.fn(),
}
)
handlers.onBlockChildWorkflowStarted({
blockId: 'nested-workflow',
childWorkflowInstanceId: 'nested-inst-1',
executionOrder: 4,
childWorkflowBlockId: 'parent-inst-1',
childWorkflowName: 'Parent Workflow',
})
handlers.onBlockStarted({
blockId: 'nested-workflow',
blockName: 'Nested Workflow',
blockType: 'workflow',
executionOrder: 4,
childWorkflowBlockId: 'parent-inst-1',
childWorkflowName: 'Parent Workflow',
})
expect(updateConsole).toHaveBeenCalledTimes(2)
expect(updateConsole.mock.calls[1]).toEqual([
'nested-workflow',
expect.objectContaining({
childWorkflowInstanceId: 'nested-inst-1',
childWorkflowBlockId: 'parent-inst-1',
childWorkflowName: 'Parent Workflow',
executionOrder: 4,
}),
'exec-1',
])
})
})
describe('addExecutionErrorConsoleEntry', () => {
@@ -225,6 +407,296 @@ describe('workflow-execution-utils', () => {
expect(updateConsole).not.toHaveBeenCalled()
})
it('reconciles child workflow spans before running entries are swept to canceled', () => {
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'workflow-1',
blockName: 'Workflow 1',
blockType: 'workflow',
executionId: 'exec-1',
executionOrder: 2,
isRunning: false,
success: true,
childWorkflowInstanceId: 'child-inst-1',
})
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'starter',
blockName: 'Start',
blockType: 'starter',
executionId: 'exec-1',
executionOrder: 3,
isRunning: true,
childWorkflowBlockId: 'workflow-1',
childWorkflowName: 'Workflow 1',
})
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'api-1',
blockName: 'API 1',
blockType: 'api',
executionId: 'exec-1',
executionOrder: 4,
isRunning: true,
childWorkflowBlockId: 'child-inst-1',
childWorkflowName: 'Workflow 1',
})
const startedAt = new Date().toISOString()
const endedAt = new Date(Date.now() + 20).toISOString()
const updateConsole = vi.fn()
reconcileFinalBlockLogs(updateConsole, 'wf-1', 'exec-1', [
makeLog({
blockId: 'workflow-1',
blockName: 'Workflow 1',
blockType: 'workflow',
executionOrder: 2,
success: true,
childTraceSpans: [
{
id: 'starter-span',
name: 'Start',
type: 'starter',
blockId: 'starter',
executionOrder: 3,
status: 'success',
duration: 5,
startTime: startedAt,
endTime: endedAt,
output: {},
},
{
id: 'api-span',
name: 'API 1',
type: 'api',
blockId: 'api-1',
executionOrder: 4,
status: 'error',
errorHandled: true,
duration: 20,
startTime: startedAt,
endTime: endedAt,
output: { error: 'Request failed' },
},
],
}),
])
expect(updateConsole).toHaveBeenCalledTimes(2)
expect(updateConsole.mock.calls[0]).toEqual([
'starter',
expect.objectContaining({
success: true,
isRunning: false,
isCanceled: false,
childWorkflowBlockId: 'workflow-1',
}),
'exec-1',
])
expect(updateConsole.mock.calls[1]).toEqual([
'api-1',
expect.objectContaining({
executionOrder: 4,
success: false,
error: 'Request failed',
isRunning: false,
isCanceled: false,
childWorkflowBlockId: 'workflow-1',
}),
'exec-1',
])
})
it('uses span execution and iteration identity when reconciling repeated child blocks', () => {
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'workflow-1',
blockName: 'Workflow 1',
blockType: 'workflow',
executionId: 'exec-1',
executionOrder: 2,
success: true,
childWorkflowInstanceId: 'child-inst-1',
})
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'api-1',
blockName: 'API 1',
blockType: 'api',
executionId: 'exec-1',
executionOrder: 3,
isRunning: true,
iterationCurrent: 0,
iterationType: 'loop',
iterationContainerId: 'loop-1',
childWorkflowBlockId: 'workflow-1',
})
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'api-1',
blockName: 'API 1',
blockType: 'api',
executionId: 'exec-1',
executionOrder: 4,
isRunning: true,
iterationCurrent: 1,
iterationType: 'loop',
iterationContainerId: 'loop-1',
childWorkflowBlockId: 'workflow-1',
})
const startedAt = new Date().toISOString()
const endedAt = new Date(Date.now() + 20).toISOString()
const updateConsole = vi.fn()
reconcileFinalBlockLogs(updateConsole, 'wf-1', 'exec-1', [
makeLog({
blockId: 'workflow-1',
blockType: 'workflow',
executionOrder: 2,
childTraceSpans: [
{
id: 'api-iter-0',
name: 'API 1',
type: 'api',
blockId: 'api-1',
executionOrder: 3,
loopId: 'loop-1',
iterationIndex: 0,
status: 'success',
duration: 10,
startTime: startedAt,
endTime: endedAt,
output: { result: 'first' },
},
{
id: 'api-iter-1',
name: 'API 1',
type: 'api',
blockId: 'api-1',
executionOrder: 4,
loopId: 'loop-1',
iterationIndex: 1,
status: 'error',
duration: 20,
startTime: startedAt,
endTime: endedAt,
output: { error: new Error('second failed') },
},
],
}),
])
expect(updateConsole).toHaveBeenCalledTimes(2)
expect(updateConsole.mock.calls[0]).toEqual([
'api-1',
expect.objectContaining({
executionOrder: 3,
iterationCurrent: 0,
iterationType: 'loop',
iterationContainerId: 'loop-1',
replaceOutput: { result: 'first' },
success: true,
}),
'exec-1',
])
expect(updateConsole.mock.calls[1]).toEqual([
'api-1',
expect.objectContaining({
executionOrder: 4,
iterationCurrent: 1,
iterationType: 'loop',
iterationContainerId: 'loop-1',
error: 'second failed',
success: false,
}),
'exec-1',
])
})
it('recurses into nested workflow spans using the nested workflow instance id', () => {
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'workflow-1',
blockName: 'Workflow 1',
blockType: 'workflow',
executionId: 'exec-1',
executionOrder: 2,
success: true,
childWorkflowInstanceId: 'child-inst-1',
})
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'nested-workflow',
blockName: 'Nested Workflow',
blockType: 'workflow',
executionId: 'exec-1',
executionOrder: 3,
isRunning: false,
childWorkflowBlockId: 'workflow-1',
childWorkflowInstanceId: 'nested-inst-1',
})
terminalConsoleMockFns.mockAddConsole({
workflowId: 'wf-1',
blockId: 'nested-api',
blockName: 'Nested API',
blockType: 'api',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
childWorkflowBlockId: 'nested-workflow',
})
const startedAt = new Date().toISOString()
const endedAt = new Date(Date.now() + 20).toISOString()
const updateConsole = vi.fn()
reconcileFinalBlockLogs(updateConsole, 'wf-1', 'exec-1', [
makeLog({
blockId: 'workflow-1',
blockType: 'workflow',
executionOrder: 2,
childTraceSpans: [
{
id: 'nested-workflow-span',
name: 'Nested Workflow',
type: 'workflow',
blockId: 'nested-workflow',
executionOrder: 3,
status: 'success',
duration: 10,
startTime: startedAt,
endTime: endedAt,
output: {},
children: [
{
id: 'nested-api-span',
name: 'Nested API',
type: 'api',
blockId: 'nested-api',
executionOrder: 1,
status: 'success',
duration: 10,
startTime: startedAt,
endTime: endedAt,
output: { ok: true },
},
],
},
],
}),
])
expect(updateConsole.mock.calls[1]).toEqual([
'nested-api',
expect.objectContaining({
childWorkflowBlockId: 'nested-workflow',
success: true,
isRunning: false,
isCanceled: false,
}),
'exec-1',
])
})
it('is a no-op when finalBlockLogs is empty or executionId is missing', () => {
const updateConsole = vi.fn()
reconcileFinalBlockLogs(updateConsole, 'wf-1', 'exec-1', [])
@@ -256,6 +728,7 @@ describe('workflow-execution-utils', () => {
expect(calls[0]).toBe('cancel')
expect(calls).toContain('add')
expect(cancelRunningEntries).toHaveBeenCalledWith('wf-1', 'exec-1')
})
it('reconciles finalBlockLogs before sweeping running entries (Fix C)', () => {
@@ -1,19 +1,31 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import type { TraceSpan } from '@/lib/logs/types'
import type {
BlockChildWorkflowStartedData,
BlockCompletedData,
BlockErrorData,
BlockStartedData,
} from '@/lib/workflows/executor/execution-events'
import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from '@/executor/types'
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
import { processSSEStream } from '@/hooks/use-execution-stream'
import {
processSSEStream,
SSEEventHandlerError,
SSEStreamInterruptedError,
} from '@/hooks/use-execution-stream'
const logger = createLogger('workflow-execution-utils')
import { useExecutionStore } from '@/stores/execution'
import type { ConsoleEntry, ConsoleUpdate } from '@/stores/terminal'
import { saveExecutionPointer, useTerminalConsoleStore } from '@/stores/terminal'
import {
clearExecutionPointer,
consolePersistence,
saveExecutionPointer,
useTerminalConsoleStore,
} from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -118,6 +130,8 @@ export interface BlockEventHandlerDeps {
setEdgeRunStatus: (workflowId: string, edgeId: string, status: 'success' | 'error') => void
}
type BlockChildWorkflowStartedUpdate = BlockChildWorkflowStartedData
/**
* Creates block event handlers for SSE execution events.
* Shared by the workflow execution hook and standalone execution utilities.
@@ -140,6 +154,7 @@ export function createBlockEventHandlers(
} = config
const { addConsole, updateConsole, setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = deps
const pendingChildWorkflowStarts = new Map<string, BlockChildWorkflowStartedUpdate>()
const isStaleExecution = () =>
!!(
@@ -178,6 +193,94 @@ export function createBlockEventHandlers(
}),
})
const parentIterationsMatch = (
left: ConsoleEntry['parentIterations'],
right: BlockStartedData['parentIterations']
) => {
if (!left?.length && !right?.length) return true
if (!left || !right || left.length !== right.length) return false
return left.every((entry, index) => {
const other = right[index]
return (
entry.iterationCurrent === other.iterationCurrent &&
entry.iterationTotal === other.iterationTotal &&
entry.iterationType === other.iterationType &&
entry.iterationContainerId === other.iterationContainerId
)
})
}
type StartedIdentity = {
blockId: string
executionOrder?: number
iterationCurrent?: BlockStartedData['iterationCurrent']
iterationTotal?: BlockStartedData['iterationTotal']
iterationType?: BlockStartedData['iterationType']
iterationContainerId?: BlockStartedData['iterationContainerId']
childWorkflowBlockId?: BlockStartedData['childWorkflowBlockId']
childWorkflowName?: BlockStartedData['childWorkflowName']
parentIterations?: BlockStartedData['parentIterations']
}
const startedEntryKey = (data: StartedIdentity) =>
JSON.stringify({
blockId: data.blockId,
executionOrder: data.executionOrder,
iterationCurrent: data.iterationCurrent,
iterationTotal: data.iterationTotal,
iterationType: data.iterationType,
iterationContainerId: data.iterationContainerId,
childWorkflowBlockId: data.childWorkflowBlockId,
childWorkflowName: data.childWorkflowName,
parentIterations: data.parentIterations ?? [],
})
const matchesStartedIdentity = (entry: ConsoleEntry, data: StartedIdentity) =>
entry.executionId === executionIdRef.current &&
entry.blockId === data.blockId &&
(data.executionOrder === undefined || entry.executionOrder === data.executionOrder) &&
entry.iterationCurrent === data.iterationCurrent &&
entry.iterationTotal === data.iterationTotal &&
entry.iterationType === data.iterationType &&
entry.iterationContainerId === data.iterationContainerId &&
entry.childWorkflowBlockId === data.childWorkflowBlockId &&
entry.childWorkflowName === data.childWorkflowName &&
parentIterationsMatch(entry.parentIterations, data.parentIterations)
const hasExistingStartedEntry = (data: StartedIdentity) => {
if (!workflowId) return false
return useTerminalConsoleStore
.getState()
.getWorkflowEntries(workflowId)
.some((entry) => matchesStartedIdentity(entry, data))
}
const applyChildWorkflowStart = (data: BlockChildWorkflowStartedUpdate) => {
updateConsole(
data.blockId,
{
childWorkflowInstanceId: data.childWorkflowInstanceId,
...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }),
...(data.iterationTotal !== undefined && { iterationTotal: data.iterationTotal }),
...(data.iterationType !== undefined && { iterationType: data.iterationType }),
...(data.iterationContainerId !== undefined && {
iterationContainerId: data.iterationContainerId,
}),
...(data.parentIterations !== undefined && {
parentIterations: data.parentIterations,
}),
...(data.childWorkflowBlockId !== undefined && {
childWorkflowBlockId: data.childWorkflowBlockId,
}),
...(data.childWorkflowName !== undefined && {
childWorkflowName: data.childWorkflowName,
}),
...(data.executionOrder !== undefined && { executionOrder: data.executionOrder }),
},
executionIdRef.current
)
}
const createBlockLogEntry = (
data: BlockCompletedData | BlockErrorData,
options: { success: boolean; output?: unknown; error?: string }
@@ -237,6 +340,7 @@ export function createBlockEventHandlers(
updateActiveBlocks(data.blockId, true)
if (!includeStartConsoleEntry || !workflowId) return
if (hasExistingStartedEntry(data)) return
const startedAt = new Date().toISOString()
addConsole({
@@ -255,6 +359,13 @@ export function createBlockEventHandlers(
isRunning: true,
...extractIterationFields(data),
})
const pendingKey = startedEntryKey(data)
const pending = pendingChildWorkflowStarts.get(pendingKey)
if (pending) {
applyChildWorkflowStart(pending)
pendingChildWorkflowStarts.delete(pendingKey)
}
}
const onBlockCompleted = (data: BlockCompletedData) => {
@@ -327,33 +438,19 @@ export function createBlockEventHandlers(
updateConsoleErrorEntry(data)
}
const onBlockChildWorkflowStarted = (data: {
blockId: string
childWorkflowInstanceId: string
iterationCurrent?: number
iterationContainerId?: string
executionOrder?: number
}) => {
const onBlockChildWorkflowStarted = (data: BlockChildWorkflowStartedUpdate) => {
if (isStaleExecution()) return
updateConsole(
data.blockId,
{
childWorkflowInstanceId: data.childWorkflowInstanceId,
...(data.iterationCurrent !== undefined && { iterationCurrent: data.iterationCurrent }),
...(data.iterationContainerId !== undefined && {
iterationContainerId: data.iterationContainerId,
}),
...(data.executionOrder !== undefined && { executionOrder: data.executionOrder }),
},
executionIdRef.current
)
applyChildWorkflowStart(data)
if (!hasExistingStartedEntry(data)) {
pendingChildWorkflowStarts.set(startedEntryKey(data), data)
}
}
return { onBlockStarted, onBlockCompleted, onBlockError, onBlockChildWorkflowStarted }
}
type AddConsoleFn = (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => ConsoleEntry | undefined
type CancelRunningEntriesFn = (workflowId: string) => void
type CancelRunningEntriesFn = (workflowId: string, executionId?: string) => void
type UpdateConsoleFn = (
blockId: string,
update: string | ConsoleUpdate,
@@ -385,28 +482,157 @@ export function reconcileFinalBlockLogs(
if (!finalBlockLogs?.length || !executionId) return
for (const log of finalBlockLogs) {
const entries = useTerminalConsoleStore.getState().getWorkflowEntries(workflowId)
const running = entries.find(
(e) => e.blockId === log.blockId && e.executionId === executionId && e.isRunning
)
if (!running) continue
updateConsole(
log.blockId,
{
executionOrder: log.executionOrder,
replaceOutput: (log.output ?? {}) as Record<string, unknown>,
...(log.input ? { input: log.input } : {}),
success: log.success,
...(log.error ? { error: log.error } : {}),
durationMs: log.durationMs,
startedAt: log.startedAt,
endedAt: log.endedAt,
isRunning: false,
},
executionId
)
const matchesFinalLog = (entry: ConsoleEntry) =>
entry.blockId === log.blockId &&
entry.executionId === executionId &&
entry.executionOrder === log.executionOrder
const matchingEntry = entries.find(matchesFinalLog)
const runningEntry = entries.find((entry) => matchesFinalLog(entry) && entry.isRunning)
if (runningEntry) {
updateConsole(
log.blockId,
{
executionOrder: log.executionOrder,
replaceOutput: (log.output ?? {}) as Record<string, unknown>,
...(log.input ? { input: log.input } : {}),
success: log.success,
...(log.error ? { error: log.error } : {}),
durationMs: log.durationMs,
startedAt: log.startedAt,
endedAt: log.endedAt,
isRunning: false,
isCanceled: false,
},
executionId
)
}
const childWorkflowInstanceId = matchingEntry?.childWorkflowInstanceId
if (childWorkflowInstanceId && log.childTraceSpans?.length) {
reconcileChildTraceSpans(
updateConsole,
workflowId,
log.blockId,
childWorkflowInstanceId,
executionId,
log.childTraceSpans
)
}
}
}
function reconcileChildTraceSpans(
updateConsole: UpdateConsoleFn,
workflowId: string,
childWorkflowBlockId: string,
childWorkflowInstanceId: string,
executionId: string,
spans: TraceSpan[]
): void {
for (const span of spans) {
const matchingEntry = span.blockId
? findConsoleEntryForSpan(workflowId, executionId, childWorkflowBlockId, span)
: undefined
if (span.blockId) {
const errorMessage = normalizeSpanError(span.output?.error)
updateConsole(
span.blockId,
{
...spanConsoleIdentity(span, childWorkflowBlockId),
replaceOutput: (span.output ?? {}) as Record<string, unknown>,
success: span.status !== 'error',
...(errorMessage !== undefined ? { error: errorMessage } : {}),
durationMs: span.duration,
startedAt: span.startTime,
endedAt: span.endTime,
isRunning: false,
isCanceled: false,
},
executionId
)
}
if (span.children?.length) {
reconcileChildTraceSpans(
updateConsole,
workflowId,
matchingEntry?.blockId ?? childWorkflowBlockId,
matchingEntry?.childWorkflowInstanceId ?? childWorkflowInstanceId,
executionId,
span.children
)
}
}
}
function spanConsoleIdentity(span: TraceSpan, childWorkflowBlockId: string): ConsoleUpdate {
const iterationContainerId = span.loopId ?? span.parallelId
const iterationType = span.loopId ? 'loop' : span.parallelId ? 'parallel' : undefined
return {
...(span.executionOrder !== undefined && { executionOrder: span.executionOrder }),
...(span.iterationIndex !== undefined && { iterationCurrent: span.iterationIndex }),
...(iterationType !== undefined && { iterationType }),
...(iterationContainerId !== undefined && { iterationContainerId }),
...(span.parentIterations !== undefined && { parentIterations: span.parentIterations }),
childWorkflowBlockId,
}
}
function findConsoleEntryForSpan(
workflowId: string,
executionId: string,
childWorkflowBlockId: string,
span: TraceSpan
): ConsoleEntry | undefined {
if (!span.blockId) return undefined
const identity = spanConsoleIdentity(span, childWorkflowBlockId)
return useTerminalConsoleStore
.getState()
.getWorkflowEntries(workflowId)
.find(
(entry) =>
entry.blockId === span.blockId &&
entry.executionId === executionId &&
matchesConsoleIdentity(entry, identity)
)
}
function matchesConsoleIdentity(entry: ConsoleEntry, identity: ConsoleUpdate): boolean {
if (identity.executionOrder !== undefined && entry.executionOrder !== identity.executionOrder) {
return false
}
if (
identity.iterationCurrent !== undefined &&
entry.iterationCurrent !== identity.iterationCurrent
) {
return false
}
if (
identity.iterationContainerId !== undefined &&
entry.iterationContainerId !== identity.iterationContainerId
) {
return false
}
if (
identity.childWorkflowBlockId !== undefined &&
entry.childWorkflowBlockId !== identity.childWorkflowBlockId
) {
return false
}
if (
identity.childWorkflowInstanceId !== undefined &&
entry.childWorkflowInstanceId !== undefined &&
entry.childWorkflowInstanceId !== identity.childWorkflowInstanceId
) {
return false
}
return true
}
function normalizeSpanError(error: unknown): string | undefined {
if (error === undefined || error === null) return undefined
return typeof error === 'string' ? error : toError(error).message
}
export interface ExecutionTimingFields {
durationMs: number
startedAt: string
@@ -500,7 +726,7 @@ export function handleExecutionErrorConsole(
params.executionId,
params.finalBlockLogs
)
deps.cancelRunningEntries(params.workflowId)
deps.cancelRunningEntries(params.workflowId, params.executionId)
addExecutionErrorConsoleEntry(deps.addConsole, params)
}
@@ -585,7 +811,7 @@ export function handleExecutionCancelledConsole(
params.executionId,
params.finalBlockLogs
)
deps.cancelRunningEntries(params.workflowId)
deps.cancelRunningEntries(params.workflowId, params.executionId)
addCancelledConsoleEntry(deps.addConsole, params)
}
@@ -600,6 +826,7 @@ export interface WorkflowExecutionOptions {
useDraftState?: boolean
stopAfterBlockId?: string
abortSignal?: AbortSignal
preserveExecutionOnTerminal?: boolean
/** For run_from_block / run_block: start from a specific block using cached state */
runFromBlock?: {
startBlockId: string
@@ -622,7 +849,9 @@ export async function executeWorkflowWithFullLogging(
}
const executionId = options.executionId || generateId()
const { addConsole, updateConsole, cancelRunningEntries } = useTerminalConsoleStore.getState()
const { addConsole, updateConsole, cancelRunningEntries, finishRunningEntries } =
useTerminalConsoleStore.getState()
const clearOnTerminal = options.preserveExecutionOnTerminal !== true
const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus, setCurrentExecutionId } =
useExecutionStore.getState()
const wfId = targetWorkflowId
@@ -632,6 +861,17 @@ export async function executeWorkflowWithFullLogging(
const activeBlockRefCounts = new Map<string, number>()
const executionIdRef = { current: executionId }
const accumulatedBlockLogs: BlockLog[] = []
const isCurrentExecution = () => {
return useExecutionStore.getState().getCurrentExecutionId(wfId) === executionIdRef.current
}
const clearExecutionState = () => {
if (!isCurrentExecution()) return
setCurrentExecutionId(wfId, null)
clearExecutionPointer(wfId)
consolePersistence.executionEnded()
useExecutionStore.getState().setIsExecuting(wfId, false)
setActiveBlocks(wfId, new Set())
}
const blockHandlers = createBlockEventHandlers(
{
@@ -705,18 +945,24 @@ export async function executeWorkflowWithFullLogging(
output: {},
logs: [],
}
let executionFinished = false
let preserveExecutionForRecovery = false
try {
await processSSEStream(
response.body.getReader(),
{
onEventId: (eventId) => {
if (executionFinished) return
if (wfId && executionIdRef.current && eventId % 5 === 0) {
saveExecutionPointer({
workflowId: wfId,
executionId: executionIdRef.current,
lastEventId: eventId,
})
const executionId = executionIdRef.current
return consolePersistence.persist().then(() =>
saveExecutionPointer({
workflowId: wfId,
executionId,
lastEventId: eventId,
})
)
}
},
@@ -730,9 +976,10 @@ export async function executeWorkflowWithFullLogging(
onBlockChildWorkflowStarted: blockHandlers.onBlockChildWorkflowStarted,
onExecutionCompleted: (data) => {
setCurrentExecutionId(wfId, null)
if (!isCurrentExecution()) return
executionFinished = true
reconcileFinalBlockLogs(updateConsole, wfId, executionIdRef.current, data.finalBlockLogs)
cancelRunningEntries(wfId)
finishRunningEntries(wfId, executionIdRef.current)
executionResult = {
success: data.success,
output: data.output,
@@ -743,10 +990,34 @@ export async function executeWorkflowWithFullLogging(
endTime: data.endTime,
},
}
if (clearOnTerminal) {
clearExecutionState()
}
},
onExecutionPaused: (data) => {
if (!isCurrentExecution()) return
executionFinished = true
reconcileFinalBlockLogs(updateConsole, wfId, executionIdRef.current, data.finalBlockLogs)
finishRunningEntries(wfId, executionIdRef.current)
executionResult = {
success: true,
output: data.output,
logs: accumulatedBlockLogs,
metadata: {
duration: data.duration,
startTime: data.startTime,
endTime: data.endTime,
},
}
if (clearOnTerminal) {
clearExecutionState()
}
},
onExecutionCancelled: (data) => {
setCurrentExecutionId(wfId, null)
if (!isCurrentExecution()) return
executionFinished = true
executionResult = {
success: false,
output: {},
@@ -763,10 +1034,14 @@ export async function executeWorkflowWithFullLogging(
finalBlockLogs: data?.finalBlockLogs,
}
)
if (clearOnTerminal) {
clearExecutionState()
}
},
onExecutionError: (data) => {
setCurrentExecutionId(wfId, null)
if (!isCurrentExecution()) return
executionFinished = true
const errorMessage = data.error || 'Run failed'
executionResult = {
success: false,
@@ -788,13 +1063,22 @@ export async function executeWorkflowWithFullLogging(
finalBlockLogs: data.finalBlockLogs,
}
)
if (clearOnTerminal) {
clearExecutionState()
}
},
},
'CopilotExecution'
)
} catch (error) {
if (error instanceof SSEEventHandlerError || error instanceof SSEStreamInterruptedError) {
preserveExecutionForRecovery = true
}
throw error
} finally {
setCurrentExecutionId(wfId, null)
setActiveBlocks(wfId, new Set())
if (!preserveExecutionForRecovery && clearOnTerminal) {
clearExecutionState()
}
}
return executionResult
@@ -158,6 +158,27 @@ describe('ExecutionEngine', () => {
expect(result.status).toBeUndefined()
})
it('should not fall back to starter blocks for terminal resume snapshots', async () => {
const startNode = createMockNode('start', 'starter')
const dag = createMockDAG([startNode])
const context = createMockContext({
metadata: {
executionId: 'test-execution',
startTime: new Date().toISOString(),
pendingBlocks: [],
resumeFromSnapshot: true,
},
})
const edgeManager = createMockEdgeManager()
const nodeOrchestrator = createMockNodeOrchestrator()
const engine = new ExecutionEngine(context, dag, edgeManager, nodeOrchestrator)
const result = await engine.run()
expect(result.success).toBe(true)
expect(nodeOrchestrator.executionCount).toBe(0)
})
it('should execute all nodes in a multi-node workflow', async () => {
const nodes = [
createMockNode('start', 'starter'),
+5
View File
@@ -339,6 +339,11 @@ export class ExecutionEngine {
return
}
if (this.context.metadata.resumeFromSnapshot === true) {
this.execLogger.info('Resume snapshot has no downstream work to queue')
return
}
if (triggerBlockId) {
this.addToQueue(triggerBlockId)
return
+8 -4
View File
@@ -26,6 +26,7 @@ export interface ExecutionMetadata {
enforceCredentialAccess?: boolean
pendingBlocks?: string[]
resumeFromSnapshot?: boolean
resumeTerminalNoop?: boolean
credentialAccountUserId?: string
workflowStateOverride?: {
blocks: Record<string, any>
@@ -54,6 +55,7 @@ export interface SerializableExecutionState {
activeExecutionPath: string[]
pendingQueue?: string[]
remainingEdges?: Edge[]
resumeTerminalNoop?: boolean
dagIncomingEdges?: Record<string, string[]>
completedPauseContexts?: string[]
}
@@ -133,8 +135,9 @@ export interface ExecutionCallbacks {
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number
) => void
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
}
export interface ContextExtensions {
@@ -200,8 +203,9 @@ export interface ContextExtensions {
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number
) => void
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/**
* Run-from-block configuration. When provided, executor runs in partial
@@ -156,11 +156,12 @@ export class WorkflowBlockHandler implements BlockHandler {
? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId)
: block.id
const iterationContext = nodeMetadata ? getIterationContext(ctx, nodeMetadata) : undefined
ctx.onChildWorkflowInstanceReady?.(
await ctx.onChildWorkflowInstanceReady?.(
effectiveBlockId,
instanceId,
iterationContext,
nodeMetadata?.executionOrder
nodeMetadata?.executionOrder,
ctx.childWorkflowContext
)
}
+4 -2
View File
@@ -268,6 +268,7 @@ export interface ExecutionMetadata {
triggerBlockId?: string
useDraftState?: boolean
resumeFromSnapshot?: boolean
resumeTerminalNoop?: boolean
}
export interface BlockState {
@@ -385,8 +386,9 @@ export interface ExecutionContext {
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number
) => void
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => Promise<void>
/**
* AbortSignal for cancellation support.
@@ -0,0 +1,87 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
import { processSSEStream } from '@/hooks/use-execution-stream'
function streamEvents(events: ExecutionEvent[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
})
}
describe('processSSEStream', () => {
it('acknowledges event ids only after the matching handler completes', async () => {
const order: string[] = []
const event: ExecutionEvent = {
type: 'block:started',
eventId: 5,
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
blockId: 'block-1',
blockName: 'Block 1',
blockType: 'function',
executionOrder: 1,
},
}
await processSSEStream(
streamEvents([event]).getReader(),
{
onBlockStarted: async () => {
order.push('handler:start')
await Promise.resolve()
order.push('handler:end')
},
onEventId: vi.fn(async () => {
order.push('event-id')
}),
},
'test'
)
expect(order).toEqual(['handler:start', 'handler:end', 'event-id'])
})
it('propagates callback failures without acknowledging the event id', async () => {
const event: ExecutionEvent = {
type: 'block:started',
eventId: 6,
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
blockId: 'block-1',
blockName: 'Block 1',
blockType: 'function',
executionOrder: 1,
},
}
const onEventId = vi.fn()
await expect(
processSSEStream(
streamEvents([event]).getReader(),
{
onBlockStarted: async () => {
throw new Error('handler failed')
},
onEventId,
},
'test'
)
).rejects.toThrow('handler failed')
expect(onEventId).not.toHaveBeenCalled()
})
})
+163 -67
View File
@@ -32,12 +32,40 @@ export function isExecutionStreamHttpError(error: unknown): error is ExecutionSt
return error instanceof ExecutionStreamHttpError
}
export class SSEEventHandlerError extends Error {
constructor(
message: string,
public readonly eventType: string,
public readonly eventId: number | undefined,
public readonly executionId: string | undefined,
public readonly originalError: unknown
) {
super(message)
this.name = 'SSEEventHandlerError'
}
}
export class SSEStreamInterruptedError extends Error {
constructor(
message: string,
public readonly executionId: string | undefined,
public readonly originalError: unknown
) {
super(message)
this.name = 'SSEStreamInterruptedError'
}
}
/**
* Detects errors caused by the browser killing a fetch (page refresh, navigation, tab close).
* These should be treated as clean disconnects, not execution errors.
*/
function isClientDisconnectError(error: any): boolean {
if (error.name === 'AbortError') return true
return error.name === 'AbortError'
}
function isRecoverableStreamError(error: any): boolean {
if (isClientDisconnectError(error)) return false
const msg = (error.message ?? '').toLowerCase()
return (
msg.includes('network error') || msg.includes('failed to fetch') || msg.includes('load failed')
@@ -75,52 +103,69 @@ export async function processSSEStream(
continue
}
let event: ExecutionEvent
try {
const event = JSON.parse(data) as ExecutionEvent
if (event.eventId != null) {
callbacks.onEventId?.(event.eventId)
}
event = JSON.parse(data) as ExecutionEvent
} catch (error) {
logger.error('Failed to parse SSE event:', error, { data })
continue
}
try {
switch (event.type) {
case 'execution:started':
callbacks.onExecutionStarted?.(event.data)
await callbacks.onExecutionStarted?.(event.data)
break
case 'execution:completed':
callbacks.onExecutionCompleted?.(event.data)
await callbacks.onExecutionCompleted?.(event.data)
break
case 'execution:paused':
callbacks.onExecutionPaused?.(event.data)
await callbacks.onExecutionPaused?.(event.data)
break
case 'execution:error':
callbacks.onExecutionError?.(event.data)
await callbacks.onExecutionError?.(event.data)
break
case 'execution:cancelled':
callbacks.onExecutionCancelled?.(event.data)
await callbacks.onExecutionCancelled?.(event.data)
break
case 'block:started':
callbacks.onBlockStarted?.(event.data)
await callbacks.onBlockStarted?.(event.data)
break
case 'block:completed':
callbacks.onBlockCompleted?.(event.data)
await callbacks.onBlockCompleted?.(event.data)
break
case 'block:error':
callbacks.onBlockError?.(event.data)
await callbacks.onBlockError?.(event.data)
break
case 'block:childWorkflowStarted':
callbacks.onBlockChildWorkflowStarted?.(event.data)
await callbacks.onBlockChildWorkflowStarted?.(event.data)
break
case 'stream:chunk':
callbacks.onStreamChunk?.(event.data)
await callbacks.onStreamChunk?.(event.data)
break
case 'stream:done':
callbacks.onStreamDone?.(event.data)
await callbacks.onStreamDone?.(event.data)
break
default:
logger.warn('Unknown event type:', (event as any).type)
}
if (event.eventId != null) {
await callbacks.onEventId?.(event.eventId)
}
} catch (error) {
logger.error('Failed to parse SSE event:', error, { data })
logger.error('SSE event handler failed:', error, {
eventType: event.type,
eventId: event.eventId,
})
const message = error instanceof Error ? error.message : String(error)
throw new SSEEventHandlerError(
message,
event.type,
event.eventId,
event.executionId,
error
)
}
}
}
@@ -130,18 +175,18 @@ export async function processSSEStream(
}
export interface ExecutionStreamCallbacks {
onExecutionStarted?: (data: ExecutionStartedData) => void
onExecutionCompleted?: (data: ExecutionCompletedData) => void
onExecutionPaused?: (data: ExecutionPausedData) => void
onExecutionError?: (data: ExecutionErrorData) => void
onExecutionCancelled?: (data: ExecutionCancelledData) => void
onBlockStarted?: (data: BlockStartedData) => void
onBlockCompleted?: (data: BlockCompletedData) => void
onBlockError?: (data: BlockErrorData) => void
onBlockChildWorkflowStarted?: (data: BlockChildWorkflowStartedData) => void
onStreamChunk?: (data: StreamChunkData) => void
onStreamDone?: (data: StreamDoneData) => void
onEventId?: (eventId: number) => void
onExecutionStarted?: (data: ExecutionStartedData) => void | Promise<void>
onExecutionCompleted?: (data: ExecutionCompletedData) => void | Promise<void>
onExecutionPaused?: (data: ExecutionPausedData) => void | Promise<void>
onExecutionError?: (data: ExecutionErrorData) => void | Promise<void>
onExecutionCancelled?: (data: ExecutionCancelledData) => void | Promise<void>
onBlockStarted?: (data: BlockStartedData) => void | Promise<void>
onBlockCompleted?: (data: BlockCompletedData) => void | Promise<void>
onBlockError?: (data: BlockErrorData) => void | Promise<void>
onBlockChildWorkflowStarted?: (data: BlockChildWorkflowStartedData) => void | Promise<void>
onStreamChunk?: (data: StreamChunkData) => void | Promise<void>
onStreamDone?: (data: StreamDoneData) => void | Promise<void>
onEventId?: (eventId: number) => void | Promise<void>
}
export interface ExecuteStreamOptions {
@@ -191,6 +236,30 @@ export interface ReconnectStreamOptions {
*/
const sharedAbortControllers = new Map<string, AbortController>()
function executeStreamKey(workflowId: string): string {
return `${workflowId}:execute`
}
function reconnectStreamKey(workflowId: string, executionId: string): string {
return `${workflowId}:reconnect:${executionId}`
}
function abortStream(key: string): void {
const controller = sharedAbortControllers.get(key)
if (!controller) return
controller.abort()
sharedAbortControllers.delete(key)
}
function abortWorkflowStreams(workflowId: string): void {
const prefix = `${workflowId}:`
for (const [key, controller] of sharedAbortControllers) {
if (!key.startsWith(prefix)) continue
controller.abort()
sharedAbortControllers.delete(key)
}
}
/**
* Hook for executing workflows via server-side SSE streaming.
* Supports concurrent executions via per-workflow AbortController maps.
@@ -199,13 +268,12 @@ export function useExecutionStream() {
const execute = useCallback(async (options: ExecuteStreamOptions) => {
const { workflowId, callbacks = {}, onExecutionId, ...payload } = options
const existing = sharedAbortControllers.get(workflowId)
if (existing) {
existing.abort()
}
abortWorkflowStreams(workflowId)
const abortController = new AbortController()
sharedAbortControllers.set(workflowId, abortController)
const streamKey = executeStreamKey(workflowId)
sharedAbortControllers.set(streamKey, abortController)
let serverExecutionId: string | undefined
try {
// boundary-raw-fetch: workflow execute endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream; also reads the X-Execution-Id response header
@@ -242,7 +310,7 @@ export function useExecutionStream() {
throw new Error('No response body')
}
const serverExecutionId = response.headers.get('X-Execution-Id')
serverExecutionId = response.headers.get('X-Execution-Id') ?? undefined
if (serverExecutionId) {
onExecutionId?.(serverExecutionId)
}
@@ -254,15 +322,28 @@ export function useExecutionStream() {
logger.info('Execution stream disconnected (page unload or abort)')
return
}
if (isRecoverableStreamError(error)) {
logger.warn('Execution stream interrupted; preserving execution for reconnect', {
executionId: serverExecutionId,
error: error.message,
})
throw new SSEStreamInterruptedError(
'Execution stream interrupted before a terminal event was received',
serverExecutionId,
error
)
}
logger.error('Execution stream error:', error)
callbacks.onExecutionError?.({
error: error.message || 'Unknown error',
duration: 0,
})
if (!(error instanceof SSEEventHandlerError)) {
await callbacks.onExecutionError?.({
error: error.message || 'Unknown error',
duration: 0,
})
}
throw error
} finally {
if (sharedAbortControllers.get(workflowId) === abortController) {
sharedAbortControllers.delete(workflowId)
if (sharedAbortControllers.get(streamKey) === abortController) {
sharedAbortControllers.delete(streamKey)
}
}
}, [])
@@ -277,13 +358,12 @@ export function useExecutionStream() {
callbacks = {},
} = options
const existing = sharedAbortControllers.get(workflowId)
if (existing) {
existing.abort()
}
abortWorkflowStreams(workflowId)
const abortController = new AbortController()
sharedAbortControllers.set(workflowId, abortController)
const streamKey = executeStreamKey(workflowId)
sharedAbortControllers.set(streamKey, abortController)
let serverExecutionId: string | undefined
try {
// boundary-raw-fetch: run-from-block endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream; also reads the X-Execution-Id response header
@@ -324,7 +404,7 @@ export function useExecutionStream() {
throw new Error('No response body')
}
const serverExecutionId = response.headers.get('X-Execution-Id')
serverExecutionId = response.headers.get('X-Execution-Id') ?? undefined
if (serverExecutionId) {
onExecutionId?.(serverExecutionId)
}
@@ -336,15 +416,28 @@ export function useExecutionStream() {
logger.info('Run-from-block stream disconnected (page unload or abort)')
return
}
if (isRecoverableStreamError(error)) {
logger.warn('Run-from-block stream interrupted; preserving execution for reconnect', {
executionId: serverExecutionId,
error: error.message,
})
throw new SSEStreamInterruptedError(
'Run-from-block stream interrupted before a terminal event was received',
serverExecutionId,
error
)
}
logger.error('Run-from-block execution error:', error)
callbacks.onExecutionError?.({
error: error.message || 'Unknown error',
duration: 0,
})
if (!(error instanceof SSEEventHandlerError)) {
await callbacks.onExecutionError?.({
error: error.message || 'Unknown error',
duration: 0,
})
}
throw error
} finally {
if (sharedAbortControllers.get(workflowId) === abortController) {
sharedAbortControllers.delete(workflowId)
if (sharedAbortControllers.get(streamKey) === abortController) {
sharedAbortControllers.delete(streamKey)
}
}
}, [])
@@ -352,13 +445,10 @@ export function useExecutionStream() {
const reconnect = useCallback(async (options: ReconnectStreamOptions) => {
const { workflowId, executionId, fromEventId = 0, callbacks = {} } = options
const existing = sharedAbortControllers.get(workflowId)
if (existing) {
existing.abort()
}
const abortController = new AbortController()
sharedAbortControllers.set(workflowId, abortController)
const streamKey = reconnectStreamKey(workflowId, executionId)
abortStream(streamKey)
sharedAbortControllers.set(streamKey, abortController)
try {
// boundary-raw-fetch: execution reconnect endpoint returns an SSE stream consumed via response.body.getReader() and processSSEStream
const response = await fetch(
@@ -376,19 +466,15 @@ export function useExecutionStream() {
logger.error('Reconnection stream error:', error)
throw error
} finally {
if (sharedAbortControllers.get(workflowId) === abortController) {
sharedAbortControllers.delete(workflowId)
if (sharedAbortControllers.get(streamKey) === abortController) {
sharedAbortControllers.delete(streamKey)
}
}
}, [])
const cancel = useCallback((workflowId?: string) => {
if (workflowId) {
const controller = sharedAbortControllers.get(workflowId)
if (controller) {
controller.abort()
sharedAbortControllers.delete(workflowId)
}
abortWorkflowStreams(workflowId)
} else {
for (const [, controller] of sharedAbortControllers) {
controller.abort()
@@ -397,10 +483,20 @@ export function useExecutionStream() {
}
}, [])
const cancelReconnect = useCallback((workflowId: string, executionId: string) => {
abortStream(reconnectStreamKey(workflowId, executionId))
}, [])
const cancelExecute = useCallback((workflowId: string) => {
abortStream(executeStreamKey(workflowId))
}, [])
return {
execute,
executeFromBlock,
reconnect,
cancel,
cancelReconnect,
cancelExecute,
}
}
@@ -9,6 +9,8 @@ const {
executeWorkflowWithFullLogging,
getWorkflowEntries,
loadExecutionPointer,
MockSSEEventHandlerError,
MockSSEStreamInterruptedError,
saveExecutionPointer,
setActiveWorkflow,
} = vi.hoisted(() => ({
@@ -16,12 +18,32 @@ const {
executeWorkflowWithFullLogging: vi.fn(),
getWorkflowEntries: vi.fn(() => []),
loadExecutionPointer: vi.fn(),
MockSSEEventHandlerError: class SSEEventHandlerError extends Error {
executionId?: string
constructor(message: string, executionId?: string) {
super(message)
this.name = 'SSEEventHandlerError'
this.executionId = executionId
}
},
MockSSEStreamInterruptedError: class SSEStreamInterruptedError extends Error {
executionId?: string
constructor(message: string, executionId?: string) {
super(message)
this.name = 'SSEStreamInterruptedError'
this.executionId = executionId
}
},
saveExecutionPointer: vi.fn(),
setActiveWorkflow: vi.fn(),
}))
const setIsExecuting = vi.fn()
const setActiveBlocks = vi.fn()
const setCurrentExecutionId = vi.fn()
const getCurrentExecutionId = vi.fn()
const getWorkflowExecution = vi.fn(() => ({ isExecuting: false }))
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils', () => ({
@@ -31,13 +53,20 @@ vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-u
vi.mock('@/stores/execution/store', () => ({
useExecutionStore: {
getState: () => ({
getCurrentExecutionId,
getWorkflowExecution,
setActiveBlocks,
setIsExecuting,
setCurrentExecutionId,
}),
},
}))
vi.mock('@/hooks/use-execution-stream', () => ({
SSEEventHandlerError: MockSSEEventHandlerError,
SSEStreamInterruptedError: MockSSEStreamInterruptedError,
}))
vi.mock('@/stores/workflows/registry/store', () => ({
useWorkflowRegistry: {
getState: () => ({
@@ -73,6 +102,7 @@ import {
describe('run tool execution cancellation', () => {
beforeEach(() => {
vi.clearAllMocks()
getCurrentExecutionId.mockReturnValue(null)
getWorkflowEntries.mockReturnValue([])
loadExecutionPointer.mockResolvedValue(null)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
@@ -144,7 +174,9 @@ describe('run tool execution cancellation', () => {
)
})
it('binds a recovered execution without starting a new workflow run', async () => {
it('treats a tab-local execution pointer as handled in background', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
loadExecutionPointer.mockResolvedValueOnce({
workflowId: 'wf-1',
executionId: 'exec-existing',
@@ -153,14 +185,67 @@ describe('run tool execution cancellation', () => {
await expect(bindRunToolToExecution('tool-3', 'wf-1')).resolves.toBe(true)
expect(setActiveWorkflow).toHaveBeenCalledWith('wf-1')
expect(setIsExecuting).toHaveBeenCalledWith('wf-1', true)
expect(setCurrentExecutionId).toHaveBeenCalledWith('wf-1', 'exec-existing')
expect(saveExecutionPointer).toHaveBeenCalledWith({
workflowId: 'wf-1',
executionId: 'exec-existing',
lastEventId: 7,
})
expect(setActiveWorkflow).not.toHaveBeenCalled()
expect(setIsExecuting).not.toHaveBeenCalled()
expect(setCurrentExecutionId).not.toHaveBeenCalled()
expect(saveExecutionPointer).not.toHaveBeenCalled()
expect(executeWorkflowWithFullLogging).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledWith(
'/api/copilot/confirm',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"status":"background"'),
})
)
})
it('does not recover from shared console rows without a tab-local pointer', async () => {
loadExecutionPointer.mockResolvedValueOnce(null)
getWorkflowEntries.mockReturnValueOnce([
{
workflowId: 'wf-1',
executionId: 'exec-shared',
isRunning: true,
startedAt: new Date().toISOString(),
},
])
await expect(bindRunToolToExecution('tool-4', 'wf-1')).resolves.toBe(false)
expect(setActiveWorkflow).not.toHaveBeenCalled()
expect(setIsExecuting).not.toHaveBeenCalled()
expect(setCurrentExecutionId).not.toHaveBeenCalled()
expect(saveExecutionPointer).not.toHaveBeenCalled()
})
it('reports local stream handler failures as background instead of workflow errors', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
getCurrentExecutionId.mockImplementation(
() => saveExecutionPointer.mock.calls[0]?.[0]?.executionId ?? null
)
executeWorkflowWithFullLogging.mockRejectedValueOnce(
new MockSSEEventHandlerError('handler failed', 'exec-1')
)
executeRunToolOnClient('tool-5', 'run_workflow', { workflowId: 'wf-1' })
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/copilot/confirm',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"status":"background"'),
})
)
})
expect(clearExecutionPointer).not.toHaveBeenCalled()
expect(setIsExecuting).toHaveBeenCalledWith('wf-1', false)
expect(fetchMock).not.toHaveBeenCalledWith(
'/api/copilot/confirm',
expect.objectContaining({
body: expect.stringContaining('"status":"error"'),
})
)
})
})
@@ -1,7 +1,12 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import type { AsyncCompletionData } from '@/lib/copilot/async-runs/lifecycle'
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
type AsyncCompletionData,
type AsyncConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import {
@@ -11,14 +16,13 @@ import {
} from '@/lib/copilot/generated/tool-catalog-v1'
import { traceparentHeader } from '@/lib/copilot/tools/client/trace-context'
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
import { SSEEventHandlerError, SSEStreamInterruptedError } from '@/hooks/use-execution-stream'
import { useExecutionStore } from '@/stores/execution/store'
import {
clearExecutionPointer,
consolePersistence,
type ExecutionPointer,
loadExecutionPointer,
saveExecutionPointer,
useTerminalConsoleStore,
} from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -26,6 +30,20 @@ const logger = createLogger('CopilotRunToolExecution')
const activeRunToolByWorkflowId = new Map<string, string>()
const activeRunAbortByWorkflowId = new Map<string, AbortController>()
const manuallyStoppedToolCallIds = new Set<string>()
const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:'
interface PendingCompletionReport {
status: AsyncConfirmationStatus
message?: string
data?: AsyncCompletionData
}
class CompletionReportError extends Error {
constructor(message: string) {
super(message)
this.name = 'CompletionReportError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
@@ -47,55 +65,62 @@ function resolveTriggerBlockId(params: Record<string, unknown>): string | undefi
: undefined
}
function getRunningExecutionPointer(workflowId: string): ExecutionPointer | null {
const runningEntries = useTerminalConsoleStore
.getState()
.getWorkflowEntries(workflowId)
.filter((entry) => entry.isRunning && entry.executionId)
function pendingCompletionStorageKey(toolCallId: string): string {
return `${PENDING_COMPLETION_STORAGE_PREFIX}${toolCallId}`
}
if (runningEntries.length === 0) {
return null
}
const latestEntry = [...runningEntries].sort((a, b) => {
const aStartedAt = a.startedAt ? new Date(a.startedAt).getTime() : 0
const bStartedAt = b.startedAt ? new Date(b.startedAt).getTime() : 0
return bStartedAt - aStartedAt
})[0]
const executionId = latestEntry?.executionId
if (!executionId) {
return null
}
return {
workflowId,
executionId,
lastEventId: 0,
function savePendingCompletionReport(toolCallId: string, report: PendingCompletionReport): void {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(pendingCompletionStorageKey(toolCallId), JSON.stringify(report))
} catch (error) {
logger.warn('[RunTool] Failed to persist pending completion report', {
toolCallId,
error: toError(error).message,
})
}
}
async function findRecoverableExecutionPointer(
workflowId: string
): Promise<ExecutionPointer | null> {
const pointer = await loadExecutionPointer(workflowId)
if (pointer?.executionId) {
return pointer
function loadPendingCompletionReport(toolCallId: string): PendingCompletionReport | null {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(pendingCompletionStorageKey(toolCallId))
if (!raw) return null
const parsed = JSON.parse(raw) as PendingCompletionReport
return parsed?.status ? parsed : null
} catch (error) {
logger.warn('[RunTool] Failed to load pending completion report', {
toolCallId,
error: toError(error).message,
})
return null
}
}
return getRunningExecutionPointer(workflowId)
function clearPendingCompletionReport(toolCallId: string): void {
if (typeof window === 'undefined') return
try {
window.sessionStorage.removeItem(pendingCompletionStorageKey(toolCallId))
} catch (error) {
logger.warn('[RunTool] Failed to clear pending completion report', {
toolCallId,
error: toError(error).message,
})
}
}
export async function bindRunToolToExecution(
toolCallId: string,
workflowId: string
): Promise<boolean> {
const executionPointer = await findRecoverableExecutionPointer(workflowId)
if (!executionPointer) {
return false
}
const existingToolCallId = activeRunToolByWorkflowId.get(workflowId)
if (existingToolCallId === toolCallId) {
logger.info('[RunTool] Recovery skipped: run tool is already active in this tab', {
workflowId,
toolCallId,
})
return true
}
if (existingToolCallId && existingToolCallId !== toolCallId) {
logger.warn('[RunTool] Recovery skipped: another run tool is already active', {
workflowId,
@@ -105,20 +130,60 @@ export async function bindRunToolToExecution(
return false
}
useWorkflowRegistry.getState().setActiveWorkflow(workflowId)
activeRunToolByWorkflowId.set(workflowId, toolCallId)
const pointer = await loadExecutionPointer(workflowId).catch(() => null)
if (!pointer?.executionId) {
logger.info('[RunTool] Recovery skipped: no tab-local execution pointer', {
workflowId,
toolCallId,
})
return false
}
const { setCurrentExecutionId, setIsExecuting } = useExecutionStore.getState()
setIsExecuting(workflowId, true)
setCurrentExecutionId(workflowId, executionPointer.executionId)
saveExecutionPointer(executionPointer)
logger.info('[RunTool] Reattached tool call to existing workflow execution', {
logger.info('[RunTool] Recovery moved to background for existing execution pointer', {
workflowId,
toolCallId,
executionId: executionPointer.executionId,
lastEventId: executionPointer.lastEventId,
executionId: pointer.executionId,
})
const pendingCompletion = loadPendingCompletionReport(toolCallId)
if (pendingCompletion) {
try {
await reportCompletion(
toolCallId,
pendingCompletion.status,
pendingCompletion.message,
pendingCompletion.data
)
clearPendingCompletionReport(toolCallId)
} catch (error) {
logger.warn('[RunTool] Failed to report recovered terminal completion', {
workflowId,
toolCallId,
executionId: pointer.executionId,
error: toError(error).message,
})
}
return true
}
try {
await reportCompletion(
toolCallId,
ASYNC_TOOL_CONFIRMATION_STATUS.background,
'Client recovered an existing workflow execution; continuing in background.',
{
workflowId,
executionId: pointer.executionId,
lastEventId: pointer.lastEventId,
}
)
} catch (error) {
logger.warn('[RunTool] Failed to report recovered execution as background', {
workflowId,
toolCallId,
executionId: pointer.executionId,
error: toError(error).message,
})
}
return true
}
@@ -292,6 +357,15 @@ async function doExecuteRunTool(
setCurrentExecutionId(targetWorkflowId, executionId)
saveExecutionPointer({ workflowId: targetWorkflowId, executionId, lastEventId: 0 })
const executionStartTime = new Date().toISOString()
const releaseVisibleExecutionForBackground = () => {
const { setCurrentExecutionId: clearExecId, setActiveBlocks } = useExecutionStore.getState()
if (activeRunToolByWorkflowId.get(targetWorkflowId) === toolCallId) {
clearExecId(targetWorkflowId, null)
consolePersistence.executionEnded()
setIsExecuting(targetWorkflowId, false)
setActiveBlocks(targetWorkflowId, new Set())
}
}
const onPageHide = () => {
if (manuallyStoppedToolCallIds.has(toolCallId)) return
@@ -325,6 +399,8 @@ async function doExecuteRunTool(
runFromBlock: runFromBlock ? { startBlockId: runFromBlock.startBlockId } : undefined,
})
let leaveExecutionRecoverable = false
try {
const result = await executeWorkflowWithFullLogging({
workflowId: targetWorkflowId,
@@ -336,6 +412,7 @@ async function doExecuteRunTool(
stopAfterBlockId,
runFromBlock,
abortSignal: abortController.signal,
preserveExecutionOnTerminal: true,
})
// Determine success (same logic as staging's RunWorkflowClientTool)
@@ -368,21 +445,35 @@ async function doExecuteRunTool(
})
} else if (succeeded) {
logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName })
const pendingCompletion = {
status: MothershipStreamV1ToolOutcome.success,
message: `Workflow execution completed. Started at: ${executionStartTime}`,
data: buildResultData(result),
}
savePendingCompletionReport(toolCallId, pendingCompletion)
await reportCompletion(
toolCallId,
MothershipStreamV1ToolOutcome.success,
`Workflow execution completed. Started at: ${executionStartTime}`,
buildResultData(result)
pendingCompletion.status,
pendingCompletion.message,
pendingCompletion.data
)
clearPendingCompletionReport(toolCallId)
} else {
const msg = errorMessage || 'Workflow execution failed'
logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg })
const pendingCompletion = {
status: MothershipStreamV1ToolOutcome.error,
message: msg,
data: buildResultData(result),
}
savePendingCompletionReport(toolCallId, pendingCompletion)
await reportCompletion(
toolCallId,
MothershipStreamV1ToolOutcome.error,
msg,
buildResultData(result)
pendingCompletion.status,
pendingCompletion.message,
pendingCompletion.data
)
clearPendingCompletionReport(toolCallId)
}
} catch (err) {
if (manuallyStoppedToolCallIds.has(toolCallId)) {
@@ -392,6 +483,35 @@ async function doExecuteRunTool(
})
} else {
const msg = toError(err).message
if (err instanceof SSEEventHandlerError || err instanceof SSEStreamInterruptedError) {
leaveExecutionRecoverable = true
logger.warn(
'[RunTool] Execution stream interrupted; leaving workflow execution in background',
{
toolCallId,
toolName,
executionId: err.executionId,
error: msg,
}
)
releaseVisibleExecutionForBackground()
await reportCompletion(
toolCallId,
ASYNC_TOOL_CONFIRMATION_STATUS.background,
'Client lost local stream processing; workflow execution may still be continuing server-side.'
)
return
}
if (err instanceof CompletionReportError) {
leaveExecutionRecoverable = true
logger.warn('[RunTool] Completion report failed; leaving workflow execution recoverable', {
toolCallId,
toolName,
error: msg,
})
releaseVisibleExecutionForBackground()
return
}
logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg })
await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, msg)
}
@@ -408,11 +528,14 @@ async function doExecuteRunTool(
if (activeAbortController === abortController) {
activeRunAbortByWorkflowId.delete(targetWorkflowId)
}
const { setCurrentExecutionId: clearExecId } = useExecutionStore.getState()
clearExecId(targetWorkflowId, null)
clearExecutionPointer(targetWorkflowId)
consolePersistence.executionEnded()
setIsExecuting(targetWorkflowId, false)
const { setCurrentExecutionId: clearExecId, setActiveBlocks } = useExecutionStore.getState()
if (!leaveExecutionRecoverable && activeToolCallId === toolCallId) {
clearExecId(targetWorkflowId, null)
clearExecutionPointer(targetWorkflowId)
consolePersistence.executionEnded()
setIsExecuting(targetWorkflowId, false)
setActiveBlocks(targetWorkflowId, new Set())
}
}
}
@@ -454,54 +577,65 @@ function buildResultData(result: unknown): Record<string, unknown> | undefined {
*/
async function reportCompletion(
toolCallId: string,
status: MothershipStreamV1ToolOutcome,
status: AsyncConfirmationStatus,
message?: string,
data?: AsyncCompletionData
): Promise<void> {
try {
const body = JSON.stringify({
toolCallId,
status,
message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'),
...(data !== undefined ? { data } : {}),
})
const res = await fetch(COPILOT_CONFIRM_API_PATH, {
const basePayload = {
toolCallId,
status,
message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'),
...(data !== undefined ? { data } : {}),
}
const send = async (body: string) =>
fetch(COPILOT_CONFIRM_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...traceparentHeader() },
body,
})
const LARGE_PAYLOAD_THRESHOLD = 10 * 1024 * 1024
const bodySize = new Blob([body]).size
if (!res.ok && isRecord(data) && bodySize > LARGE_PAYLOAD_THRESHOLD) {
const { logs: _logs, ...dataWithoutLogs } = data
logger.warn('[RunTool] reportCompletion failed with large payload, retrying without logs', {
toolCallId,
status: res.status,
bodySize,
})
const retryRes = await fetch(COPILOT_CONFIRM_API_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...traceparentHeader() },
body: JSON.stringify({
const body = JSON.stringify(basePayload)
const LARGE_PAYLOAD_THRESHOLD = 10 * 1024 * 1024
const bodySize = new Blob([body]).size
let lastError: Error | null = null
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const res = await send(body)
if (res.ok) return
if (isRecord(data) && bodySize > LARGE_PAYLOAD_THRESHOLD) {
const { logs: _logs, ...dataWithoutLogs } = data
logger.warn('[RunTool] reportCompletion failed with large payload, retrying without logs', {
toolCallId,
status,
message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'),
data: dataWithoutLogs,
}),
})
if (!retryRes.ok) {
logger.warn('[RunTool] reportCompletion retry also failed', {
toolCallId,
status: retryRes.status,
status: res.status,
bodySize,
})
const retryRes = await send(
JSON.stringify({
toolCallId,
status,
message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'),
data: dataWithoutLogs,
})
)
if (retryRes.ok) return
lastError = new Error(`reportCompletion retry failed with status ${retryRes.status}`)
} else {
lastError = new Error(`reportCompletion failed with status ${res.status}`)
}
} else if (!res.ok) {
logger.warn('[RunTool] reportCompletion failed', { toolCallId, status: res.status })
} catch (err) {
lastError = toError(err)
}
if (attempt < 2) {
await sleep(250)
}
} catch (err) {
logger.error('[RunTool] reportCompletion error', {
toolCallId,
error: toError(err).message,
})
}
logger.error('[RunTool] reportCompletion failed after retries', {
toolCallId,
error: lastError?.message,
})
throw new CompletionReportError(lastError?.message ?? 'Failed to report tool completion')
}
+317
View File
@@ -0,0 +1,317 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionEventEntry } from '@/lib/execution/event-buffer'
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
const { mockGetRedisClient, mockRedis, persistedEntries } = vi.hoisted(() => {
const persistedEntries: ExecutionEventEntry[] = []
const mockRedis = {
incrby: vi.fn(),
hset: vi.fn(),
expire: vi.fn(),
hgetall: vi.fn(),
zrangebyscore: vi.fn(),
zremrangebyrank: vi.fn(),
pipeline: vi.fn(),
eval: vi.fn(),
}
const mockGetRedisClient = vi.fn(() => mockRedis)
return { mockGetRedisClient, mockRedis, persistedEntries }
})
vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: mockGetRedisClient,
}))
import {
createExecutionEventWriter,
flushExecutionStreamReplayBuffer,
initializeExecutionStreamMeta,
readExecutionEventsState,
} from '@/lib/execution/event-buffer'
function makeEvent(blockId: string): ExecutionEvent {
return {
type: 'block:started',
timestamp: new Date().toISOString(),
executionId: 'exec-1',
workflowId: 'wf-1',
data: {
blockId,
blockName: blockId,
blockType: 'function',
executionOrder: 1,
},
}
}
describe('execution event buffer', () => {
beforeEach(() => {
vi.clearAllMocks()
persistedEntries.length = 0
mockGetRedisClient.mockReturnValue(mockRedis)
mockRedis.hgetall.mockResolvedValue({})
mockRedis.zrangebyscore.mockResolvedValue([])
mockRedis.zremrangebyrank.mockResolvedValue(0)
mockRedis.eval.mockImplementation(
async (
_script: string,
_keyCount: number,
_eventsKey: string,
_seqKey: string,
_metaKey: string,
_ttl: number,
_eventLimit: number,
_updatedAt: string,
terminalStatus: string,
...args: (string | number)[]
) => {
for (let i = 0; i < args.length; i += 2) {
persistedEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
}
if (terminalStatus) {
await mockRedis.hset('meta', { status: terminalStatus })
}
return persistedEntries[0]?.eventId ?? false
}
)
mockRedis.pipeline.mockImplementation(() => ({
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
for (let i = 0; i < args.length; i += 2) {
persistedEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
}
}),
expire: vi.fn(),
zremrangebyrank: vi.fn(),
exec: vi.fn().mockResolvedValue(undefined),
}))
})
it('serializes event id reservation so reconnect replay preserves write order', async () => {
let releaseReservation: ((value: number) => void) | undefined
mockRedis.incrby.mockReturnValueOnce(
new Promise<number>((resolve) => {
releaseReservation = resolve
})
)
const writer = createExecutionEventWriter('exec-1')
const firstWrite = writer.write(makeEvent('first'))
const secondWrite = writer.write(makeEvent('second'))
await Promise.resolve()
expect(mockRedis.incrby).toHaveBeenCalledTimes(1)
releaseReservation?.(100)
await expect(Promise.all([firstWrite, secondWrite])).resolves.toMatchObject([
{ eventId: 1 },
{ eventId: 2 },
])
await writer.close()
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1, 2])
expect(
persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId)
).toEqual(['first', 'second'])
})
it('flush waits for queued writes before returning', async () => {
let releaseReservation: ((value: number) => void) | undefined
mockRedis.incrby.mockReturnValueOnce(
new Promise<number>((resolve) => {
releaseReservation = resolve
})
)
const writer = createExecutionEventWriter('exec-1')
const write = writer.write(makeEvent('terminal'))
const flush = writer.flush()
await Promise.resolve()
expect(persistedEntries).toEqual([])
releaseReservation?.(100)
await write
await flush
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
expect((persistedEntries[0].event.data as { blockId: string }).blockId).toBe('terminal')
})
it('flush drains events appended while another flush is in flight', async () => {
mockRedis.incrby.mockResolvedValue(100)
let releaseFirstFlush: (() => void) | undefined
const execCalls: Array<() => Promise<void>> = [
() =>
new Promise<void>((resolve) => {
releaseFirstFlush = resolve
}),
() => Promise.resolve(),
]
mockRedis.eval.mockImplementation(async (_script: string, ...args: unknown[]) => {
const batchEntries: ExecutionEventEntry[] = []
const zaddArgs = args.slice(8) as (string | number)[]
for (let i = 0; i < zaddArgs.length; i += 2) {
batchEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
}
await (execCalls.shift() ?? (() => Promise.resolve()))()
persistedEntries.push(...batchEntries)
return persistedEntries[0]?.eventId ?? false
})
mockRedis.pipeline.mockImplementation(() => {
const batchEntries: ExecutionEventEntry[] = []
return {
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
for (let i = 0; i < args.length; i += 2) {
batchEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
}
}),
expire: vi.fn(),
zremrangebyrank: vi.fn(),
exec: vi.fn(async () => {
await (execCalls.shift() ?? (() => Promise.resolve()))()
persistedEntries.push(...batchEntries)
}),
}
})
const writer = createExecutionEventWriter('exec-1')
await writer.write(makeEvent('first'))
const firstFlush = writer.flush()
await Promise.resolve()
expect(persistedEntries).toEqual([])
await writer.write(makeEvent('terminal'))
const terminalFlush = writer.flush()
releaseFirstFlush?.()
await firstFlush
await terminalFlush
expect(
persistedEntries.map((entry) => (entry.event.data as { blockId: string }).blockId)
).toEqual(['first', 'terminal'])
})
it('flush surfaces queued write failures', async () => {
mockRedis.incrby.mockRejectedValueOnce(new Error('redis reservation failed'))
const writer = createExecutionEventWriter('exec-1')
await expect(writer.write(makeEvent('lost'))).rejects.toThrow('redis reservation failed')
await expect(writer.flush()).rejects.toThrow('redis reservation failed')
})
it('allows terminal finalization after a recovered queued write failure', async () => {
mockRedis.incrby
.mockRejectedValueOnce(new Error('redis reservation failed'))
.mockResolvedValueOnce(200)
const writer = createExecutionEventWriter('exec-1')
await expect(writer.write(makeEvent('lost'))).rejects.toThrow('redis reservation failed')
await writer.write(makeEvent('terminal'))
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(true)
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([101])
expect(mockRedis.hset).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ status: 'complete' })
)
})
it('does not write terminal meta when the final replay flush fails', async () => {
mockRedis.incrby.mockResolvedValue(100)
mockRedis.eval.mockRejectedValue(new Error('redis flush failed'))
const writer = createExecutionEventWriter('exec-1')
await writer.write(makeEvent('terminal'))
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(false)
expect(mockRedis.hset).not.toHaveBeenCalled()
})
it('flushes replay events after a recovered final replay flush without terminal meta', async () => {
mockRedis.incrby.mockResolvedValue(100)
let flushAttempt = 0
mockRedis.eval.mockImplementation(async (_script: string, ...args: unknown[]) => {
const zaddArgs = args.slice(8) as (string | number)[]
if (flushAttempt > 0) {
for (let i = 0; i < zaddArgs.length; i += 2) {
persistedEntries.push(JSON.parse(zaddArgs[i + 1] as string) as ExecutionEventEntry)
}
}
if (flushAttempt++ === 0) {
throw new Error('first flush failed')
}
return persistedEntries[0]?.eventId ?? false
})
mockRedis.pipeline.mockImplementation(() => ({
zadd: vi.fn((_key: string, ...args: (string | number)[]) => {
if (flushAttempt > 0) {
for (let i = 0; i < args.length; i += 2) {
persistedEntries.push(JSON.parse(args[i + 1] as string) as ExecutionEventEntry)
}
}
}),
expire: vi.fn(),
zremrangebyrank: vi.fn(),
exec: vi.fn(async () => {
if (flushAttempt++ === 0) {
throw new Error('first flush failed')
}
}),
}))
const writer = createExecutionEventWriter('exec-1')
await writer.write(makeEvent('terminal'))
await expect(flushExecutionStreamReplayBuffer('exec-1', writer)).resolves.toBe(true)
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
expect(mockRedis.hset).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ status: 'complete' })
)
})
it('writes terminal event and terminal meta atomically through writeTerminal', async () => {
mockRedis.incrby.mockResolvedValue(100)
const writer = createExecutionEventWriter('exec-1')
await writer.writeTerminal(makeEvent('terminal'), 'complete')
expect(persistedEntries.map((entry) => entry.eventId)).toEqual([1])
expect(mockRedis.hset).toHaveBeenCalledWith('meta', { status: 'complete' })
})
it('retries active meta initialization before giving up', async () => {
mockRedis.hset.mockRejectedValueOnce(new Error('meta write failed')).mockResolvedValueOnce(1)
await expect(
initializeExecutionStreamMeta('exec-1', { userId: 'user-1', workflowId: 'wf-1' })
).resolves.toBe(true)
expect(mockRedis.hset).toHaveBeenCalledTimes(2)
expect(mockRedis.hset).toHaveBeenLastCalledWith(
'execution:stream:exec-1:meta',
expect.objectContaining({
status: 'active',
userId: 'user-1',
workflowId: 'wf-1',
})
)
})
it('reports pruned replay buffers before reading incomplete events', async () => {
mockRedis.hgetall.mockResolvedValue({ status: 'active', earliestEventId: '10' })
await expect(readExecutionEventsState('exec-1', 0)).resolves.toEqual({
status: 'pruned',
earliestEventId: 10,
})
expect(mockRedis.zrangebyscore).not.toHaveBeenCalled()
})
})
+459 -50
View File
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
import { getRedisClient } from '@/lib/core/config/redis'
import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events'
@@ -12,6 +13,27 @@ const RESERVE_BATCH = 100
const FLUSH_INTERVAL_MS = 15
const FLUSH_MAX_BATCH = 200
const MAX_PENDING_EVENTS = 1000
const ACTIVE_META_ATTEMPTS = 3
const FINALIZE_FLUSH_ATTEMPTS = 2
const FLUSH_EVENTS_SCRIPT = `
local terminal_status = ARGV[4]
for i = 5, #ARGV, 2 do
redis.call('ZADD', KEYS[1], ARGV[i], ARGV[i + 1])
end
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[1]))
redis.call('ZREMRANGEBYRANK', KEYS[1], 0, -tonumber(ARGV[2]) - 1)
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
if terminal_status ~= '' then
redis.call('HSET', KEYS[3], 'status', terminal_status, 'updatedAt', ARGV[3])
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[1]))
end
if oldest[2] then
redis.call('HSET', KEYS[3], 'earliestEventId', tostring(math.floor(tonumber(oldest[2]))), 'updatedAt', ARGV[3])
redis.call('EXPIRE', KEYS[3], tonumber(ARGV[1]))
end
return oldest[2] or false
`
function getEventsKey(executionId: string) {
return `${REDIS_PREFIX}${executionId}:events`
@@ -36,28 +58,242 @@ export interface ExecutionStreamMeta {
userId?: string
workflowId?: string
updatedAt?: string
earliestEventId?: number
replayStartEventId?: number
}
export type TerminalExecutionStreamStatus = Exclude<ExecutionStreamStatus, 'active'>
export type ExecutionMetaReadResult =
| { status: 'found'; meta: ExecutionStreamMeta }
| { status: 'missing' }
| { status: 'unavailable'; error: string }
export type ExecutionEventsReadResult =
| { status: 'ok'; events: ExecutionEventEntry[] }
| { status: 'pruned'; earliestEventId: number }
| { status: 'unavailable'; error: string }
export interface ExecutionEventEntry {
eventId: number
executionId: string
event: ExecutionEvent
}
interface MemoryExecutionStream {
events: ExecutionEventEntry[]
meta: ExecutionStreamMeta | null
nextEventId: number
expiresAt: number
}
export interface ExecutionEventWriter {
write: (event: ExecutionEvent) => Promise<ExecutionEventEntry>
writeTerminal: (
event: ExecutionEvent,
status: TerminalExecutionStreamStatus
) => Promise<ExecutionEventEntry>
flush: () => Promise<void>
close: () => Promise<void>
}
const memoryExecutionStreams = new Map<string, MemoryExecutionStream>()
function canUseMemoryEventBuffer(): boolean {
return typeof window === 'undefined' && !env.REDIS_URL
}
function pruneExpiredMemoryStreams(now = Date.now()): void {
for (const [executionId, stream] of memoryExecutionStreams) {
if (stream.expiresAt <= now) {
memoryExecutionStreams.delete(executionId)
}
}
}
function getMemoryStream(executionId: string): MemoryExecutionStream {
pruneExpiredMemoryStreams()
let stream = memoryExecutionStreams.get(executionId)
if (!stream) {
stream = {
events: [],
meta: null,
nextEventId: 1,
expiresAt: Date.now() + TTL_SECONDS * 1000,
}
memoryExecutionStreams.set(executionId, stream)
}
return stream
}
function touchMemoryStream(stream: MemoryExecutionStream): void {
stream.expiresAt = Date.now() + TTL_SECONDS * 1000
}
function isReplayBeforeAvailableEvents(
afterEventId: number,
earliestEventId?: number,
replayStartEventId?: number
): earliestEventId is number {
if (earliestEventId === undefined || !Number.isFinite(earliestEventId)) return false
if (
afterEventId === 0 &&
replayStartEventId !== undefined &&
Number.isFinite(replayStartEventId)
) {
return earliestEventId > replayStartEventId
}
return afterEventId + 1 < earliestEventId
}
function readMemoryMeta(executionId: string): ExecutionMetaReadResult {
pruneExpiredMemoryStreams()
const stream = memoryExecutionStreams.get(executionId)
if (!stream?.meta) return { status: 'missing' }
return { status: 'found', meta: stream.meta }
}
function readMemoryEvents(executionId: string, afterEventId: number): ExecutionEventsReadResult {
pruneExpiredMemoryStreams()
const stream = memoryExecutionStreams.get(executionId)
if (!stream) return { status: 'ok', events: [] }
const earliestEventId = stream.meta?.earliestEventId
if (
isReplayBeforeAvailableEvents(afterEventId, earliestEventId, stream.meta?.replayStartEventId)
) {
return { status: 'pruned', earliestEventId }
}
return {
status: 'ok',
events: stream.events.filter((entry) => entry.eventId > afterEventId),
}
}
function createMemoryExecutionEventWriter(executionId: string): ExecutionEventWriter {
const writeMemoryEvent = async (event: ExecutionEvent) => {
const stream = getMemoryStream(executionId)
const entry = {
eventId: stream.nextEventId++,
executionId,
event,
}
stream.events.push(entry)
if (stream.events.length > EVENT_LIMIT) {
stream.events = stream.events.slice(-EVENT_LIMIT)
const earliestEventId = stream.events[0]?.eventId
if (earliestEventId !== undefined && stream.meta) {
stream.meta = {
...stream.meta,
earliestEventId,
updatedAt: new Date().toISOString(),
}
}
}
touchMemoryStream(stream)
return entry
}
return {
write: writeMemoryEvent,
writeTerminal: async (event, status) => {
const entry = await writeMemoryEvent(event)
const stream = getMemoryStream(executionId)
stream.meta = {
...stream.meta,
status,
updatedAt: new Date().toISOString(),
}
touchMemoryStream(stream)
return entry
},
flush: async () => {},
close: async () => {},
}
}
export async function flushExecutionStreamReplayBuffer(
executionId: string,
writer: ExecutionEventWriter
): Promise<boolean> {
let writerClosed = false
for (let attempt = 1; attempt <= FINALIZE_FLUSH_ATTEMPTS; attempt++) {
try {
if (!writerClosed) {
await writer.close()
writerClosed = true
}
return true
} catch (error) {
logger.warn('Failed to flush execution stream replay buffer during finalization', {
executionId,
attempt,
error: toError(error).message,
})
}
}
return false
}
export async function resetExecutionStreamBuffer(executionId: string): Promise<boolean> {
if (canUseMemoryEventBuffer()) {
const stream = getMemoryStream(executionId)
stream.events = []
stream.meta = {
status: 'active',
replayStartEventId: stream.nextEventId,
updatedAt: new Date().toISOString(),
}
stream.expiresAt = Date.now() + TTL_SECONDS * 1000
return true
}
const redis = getRedisClient()
if (!redis) {
logger.warn('resetExecutionStreamBuffer: Redis client unavailable', { executionId })
return false
}
try {
const currentSequence = Number(await redis.get(getSeqKey(executionId)).catch(() => 0))
const replayStartEventId = Number.isFinite(currentSequence) ? currentSequence + 1 : 1
const metaKey = getMetaKey(executionId)
await redis.del(getEventsKey(executionId), metaKey)
await redis.hset(metaKey, {
replayStartEventId: String(replayStartEventId),
updatedAt: new Date().toISOString(),
})
await redis.expire(metaKey, TTL_SECONDS)
return true
} catch (error) {
logger.warn('Failed to reset execution stream buffer', {
executionId,
error: toError(error).message,
})
return false
}
}
export async function setExecutionMeta(
executionId: string,
meta: Partial<ExecutionStreamMeta>
): Promise<void> {
): Promise<boolean> {
const redis = getRedisClient()
if (!redis) {
if (canUseMemoryEventBuffer()) {
const stream = getMemoryStream(executionId)
const status = meta.status ?? stream.meta?.status
if (!status) return false
stream.meta = {
...stream.meta,
...meta,
status,
updatedAt: new Date().toISOString(),
}
touchMemoryStream(stream)
return true
}
logger.warn('setExecutionMeta: Redis client unavailable', { executionId })
return
return false
}
try {
const key = getMetaKey(executionId)
@@ -67,71 +303,160 @@ export async function setExecutionMeta(
if (meta.status) payload.status = meta.status
if (meta.userId) payload.userId = meta.userId
if (meta.workflowId) payload.workflowId = meta.workflowId
if (meta.earliestEventId !== undefined) payload.earliestEventId = String(meta.earliestEventId)
if (meta.replayStartEventId !== undefined) {
payload.replayStartEventId = String(meta.replayStartEventId)
}
await redis.hset(key, payload)
await redis.expire(key, TTL_SECONDS)
return true
} catch (error) {
logger.warn('Failed to update execution meta', {
executionId,
error: toError(error).message,
})
return false
}
}
export async function getExecutionMeta(executionId: string): Promise<ExecutionStreamMeta | null> {
export async function initializeExecutionStreamMeta(
executionId: string,
meta: Omit<ExecutionStreamMeta, 'status' | 'updatedAt'> & { status?: 'active' }
): Promise<boolean> {
for (let attempt = 1; attempt <= ACTIVE_META_ATTEMPTS; attempt++) {
const metaPersisted = await setExecutionMeta(executionId, {
...meta,
status: 'active',
})
if (metaPersisted) return true
logger.warn('Failed to persist active execution meta during initialization', {
executionId,
attempt,
})
}
return false
}
export async function readExecutionMetaState(
executionId: string
): Promise<ExecutionMetaReadResult> {
const redis = getRedisClient()
if (!redis) {
if (canUseMemoryEventBuffer()) {
return readMemoryMeta(executionId)
}
logger.warn('getExecutionMeta: Redis client unavailable', { executionId })
return null
return { status: 'unavailable', error: 'Redis client unavailable' }
}
try {
const key = getMetaKey(executionId)
const meta = await redis.hgetall(key)
if (!meta || Object.keys(meta).length === 0) return null
if (!isExecutionStreamStatus(meta.status)) return null
if (!meta || Object.keys(meta).length === 0) return { status: 'missing' }
if (!isExecutionStreamStatus(meta.status)) return { status: 'missing' }
return {
status: meta.status,
userId: meta.userId,
workflowId: meta.workflowId,
updatedAt: meta.updatedAt,
status: 'found',
meta: {
status: meta.status,
userId: meta.userId,
workflowId: meta.workflowId,
updatedAt: meta.updatedAt,
earliestEventId:
meta.earliestEventId !== undefined ? Number(meta.earliestEventId) : undefined,
replayStartEventId:
meta.replayStartEventId !== undefined ? Number(meta.replayStartEventId) : undefined,
},
}
} catch (error) {
const message = toError(error).message
logger.warn('Failed to read execution meta', {
executionId,
error: toError(error).message,
error: message,
})
return { status: 'unavailable', error: message }
}
}
export async function getExecutionMeta(executionId: string): Promise<ExecutionStreamMeta | null> {
const result = await readExecutionMetaState(executionId)
if (result.status === 'found') return result.meta
if (result.status === 'unavailable') {
return null
}
return null
}
export async function readExecutionEvents(
executionId: string,
afterEventId: number
): Promise<ExecutionEventEntry[]> {
const result = await readExecutionEventsState(executionId, afterEventId)
return result.status === 'ok' ? result.events : []
}
export async function readExecutionEventsState(
executionId: string,
afterEventId: number
): Promise<ExecutionEventsReadResult> {
const redis = getRedisClient()
if (!redis) return []
if (!redis) {
if (canUseMemoryEventBuffer()) {
return readMemoryEvents(executionId, afterEventId)
}
return { status: 'unavailable', error: 'Redis client unavailable' }
}
try {
const meta = await redis.hgetall(getMetaKey(executionId))
const earliestEventId =
meta?.earliestEventId !== undefined ? Number(meta.earliestEventId) : undefined
const replayStartEventId =
meta?.replayStartEventId !== undefined ? Number(meta.replayStartEventId) : undefined
if (isReplayBeforeAvailableEvents(afterEventId, earliestEventId, replayStartEventId)) {
return { status: 'pruned', earliestEventId }
}
const raw = await redis.zrangebyscore(getEventsKey(executionId), afterEventId + 1, '+inf')
return raw
.map((entry) => {
try {
return JSON.parse(entry) as ExecutionEventEntry
} catch {
return null
}
})
.filter((entry): entry is ExecutionEventEntry => Boolean(entry))
const latestMeta = await redis.hgetall(getMetaKey(executionId))
const latestEarliestEventId =
latestMeta?.earliestEventId !== undefined ? Number(latestMeta.earliestEventId) : undefined
const latestReplayStartEventId =
latestMeta?.replayStartEventId !== undefined
? Number(latestMeta.replayStartEventId)
: undefined
if (
isReplayBeforeAvailableEvents(afterEventId, latestEarliestEventId, latestReplayStartEventId)
) {
return { status: 'pruned', earliestEventId: latestEarliestEventId }
}
return {
status: 'ok',
events: raw
.map((entry) => {
try {
return JSON.parse(entry) as ExecutionEventEntry
} catch {
return null
}
})
.filter((entry): entry is ExecutionEventEntry => Boolean(entry)),
}
} catch (error) {
const message = toError(error).message
logger.warn('Failed to read execution events', {
executionId,
error: toError(error).message,
error: message,
})
return []
return { status: 'unavailable', error: message }
}
}
export function createExecutionEventWriter(executionId: string): ExecutionEventWriter {
const redis = getRedisClient()
if (!redis) {
if (canUseMemoryEventBuffer()) {
logger.info('createExecutionEventWriter: using in-memory event buffer', { executionId })
return createMemoryExecutionEventWriter(executionId)
}
logger.warn(
'createExecutionEventWriter: Redis client unavailable, events will not be buffered',
{
@@ -140,6 +465,9 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
)
return {
write: async (event) => ({ eventId: 0, executionId, event }),
writeTerminal: async () => {
throw new Error(`Execution event buffer unavailable for ${executionId}`)
},
flush: async () => {},
close: async () => {},
}
@@ -154,7 +482,7 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
if (flushTimer) return
flushTimer = setTimeout(() => {
flushTimer = null
void flush()
void flushPending()
}, FLUSH_INTERVAL_MS)
}
@@ -168,12 +496,14 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
}
}
let flushPromise: Promise<void> | null = null
let flushPromise: Promise<boolean> | null = null
let closed = false
let writeQueue: Promise<void> = Promise.resolve()
const inflightWrites = new Set<Promise<ExecutionEventEntry>>()
let writeFailure: Error | null = null
const doFlush = async () => {
if (pending.length === 0) return
const doFlush = async (terminalStatus?: TerminalExecutionStreamStatus): Promise<boolean> => {
if (pending.length === 0) return true
const batch = pending
pending = []
try {
@@ -182,12 +512,19 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
for (const entry of batch) {
zaddArgs.push(entry.eventId, JSON.stringify(entry))
}
const pipeline = redis.pipeline()
pipeline.zadd(key, ...zaddArgs)
pipeline.expire(key, TTL_SECONDS)
pipeline.expire(getSeqKey(executionId), TTL_SECONDS)
pipeline.zremrangebyrank(key, 0, -EVENT_LIMIT - 1)
await pipeline.exec()
await redis.eval(
FLUSH_EVENTS_SCRIPT,
3,
key,
getSeqKey(executionId),
getMetaKey(executionId),
TTL_SECONDS,
EVENT_LIMIT,
new Date().toISOString(),
terminalStatus ?? '',
...zaddArgs
)
return true
} catch (error) {
logger.warn('Failed to flush execution events', {
executionId,
@@ -205,25 +542,37 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
remaining: pending.length,
})
}
return false
}
}
const flush = async () => {
if (flushPromise) {
await flushPromise
return
}
flushPromise = doFlush()
try {
await flushPromise
} finally {
flushPromise = null
if (pending.length > 0) scheduleFlush()
const flushPending = async (
scheduleOnFailure = true,
terminalStatus?: TerminalExecutionStreamStatus
): Promise<boolean> => {
while (true) {
if (flushPromise) {
const ok = await flushPromise
if (!ok) return false
continue
}
if (pending.length === 0) return true
flushPromise = doFlush(terminalStatus)
let ok = false
try {
ok = await flushPromise
} finally {
flushPromise = null
}
if (!ok) {
if (scheduleOnFailure && pending.length > 0) scheduleFlush()
return false
}
}
}
const writeCore = async (event: ExecutionEvent): Promise<ExecutionEventEntry> => {
if (closed) return { eventId: 0, executionId, event }
if (nextEventId === 0 || nextEventId > maxReservedId) {
await reserveIds(1)
}
@@ -231,7 +580,7 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
const entry: ExecutionEventEntry = { eventId, executionId, event }
pending.push(entry)
if (pending.length >= FLUSH_MAX_BATCH) {
await flush()
await flushPending()
} else {
scheduleFlush()
}
@@ -239,7 +588,54 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
}
const write = (event: ExecutionEvent): Promise<ExecutionEventEntry> => {
const p = writeCore(event)
if (closed) return Promise.resolve({ eventId: 0, executionId, event })
const p = writeQueue.then(() => writeCore(event))
writeQueue = p.then(
() => {
writeFailure = null
},
(error) => {
writeFailure = toError(error)
}
)
inflightWrites.add(p)
const remove = () => inflightWrites.delete(p)
p.then(remove, remove)
return p
}
const writeTerminal = (
event: ExecutionEvent,
status: TerminalExecutionStreamStatus
): Promise<ExecutionEventEntry> => {
if (closed) return Promise.resolve({ eventId: 0, executionId, event })
const p = writeQueue.then(async () => {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
if (nextEventId === 0 || nextEventId > maxReservedId) {
await reserveIds(1)
}
const eventId = nextEventId++
const entry: ExecutionEventEntry = { eventId, executionId, event }
pending.push(entry)
const ok = await flushPending(false, status)
if (!ok) {
pending = pending.filter((pendingEntry) => pendingEntry !== entry)
throw new Error(`Failed to flush terminal execution event for ${executionId}`)
}
closed = true
return entry
})
writeQueue = p.then(
() => {
writeFailure = null
},
(error) => {
writeFailure = toError(error)
}
)
inflightWrites.add(p)
const remove = () => inflightWrites.delete(p)
p.then(remove, remove)
@@ -258,10 +654,23 @@ export function createExecutionEventWriter(executionId: string): ExecutionEventW
if (flushPromise) {
await flushPromise
}
if (pending.length > 0) {
await doFlush()
await flushCore(false)
}
const flushCore = async (scheduleOnFailure: boolean) => {
await writeQueue
const ok = await flushPending(scheduleOnFailure)
if (writeFailure) {
throw writeFailure
}
if (!ok) {
throw new Error(`Failed to flush execution events for ${executionId}`)
}
}
return { write, flush, close }
const flush = async () => {
await flushCore(true)
}
return { write, writeTerminal, flush, close }
}
@@ -652,6 +652,18 @@ export class LoggingSession {
const endTime = endedAt ? new Date(endedAt) : new Date()
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
const currentLog = await db
.select({ status: workflowExecutionLogs.status })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, this.executionId))
.limit(1)
.then((rows) => rows[0])
if (currentLog?.status === 'cancelled') {
this.completed = true
return
}
const costSummary = traceSpans?.length
? calculateCostSummary(traceSpans)
: {
@@ -739,6 +751,18 @@ export class LoggingSession {
const endTime = endedAt ? new Date(endedAt) : new Date()
const durationMs = typeof totalDurationMs === 'number' ? totalDurationMs : 0
const currentLog = await db
.select({ status: workflowExecutionLogs.status })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, this.executionId))
.limit(1)
.then((rows) => rows[0])
if (currentLog?.status === 'cancelled') {
this.completed = true
return
}
const costSummary = traceSpans?.length
? calculateCostSummary(traceSpans)
: {
@@ -65,6 +65,7 @@ function createBaseSpan(log: ValidBlockLog): TraceSpan {
status: log.error ? 'error' : 'success',
children: [],
blockId: log.blockId,
executionOrder: log.executionOrder,
input: log.input,
output,
...(childIds ?? {}),
+1
View File
@@ -214,6 +214,7 @@ export interface TraceSpan {
tokens?: TokenInfo
relativeStartMs?: number
blockId?: string
executionOrder?: number
input?: Record<string, unknown>
output?: Record<string, unknown>
childWorkflowSnapshotId?: string
@@ -477,9 +477,7 @@ export async function trackChatUpload(
size,
})
logger.info(
`Tracked chat upload: ${fileName} (display: ${candidate}) for chat ${chatId}`
)
logger.info(`Tracked chat upload: ${fileName} (display: ${candidate}) for chat ${chatId}`)
return { displayName: candidate }
} catch (error) {
// Other 23505s (e.g. active-key collision from a racing same-s3Key insert) signal
@@ -362,6 +362,68 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
])
})
it('awaits fire-and-forget block callbacks before returning terminal result', async () => {
let releaseBlockComplete: (() => void) | undefined
let markCallbackStarted: (() => void) | undefined
const blockCompletePromise = new Promise<void>((resolve) => {
releaseBlockComplete = resolve
})
const callbackStartedPromise = new Promise<void>((resolve) => {
markCallbackStarted = resolve
})
const callOrder: string[] = []
let hasReturned = false
executorExecuteMock.mockImplementation(async () => {
const contextExtensions = executorConstructorMock.mock.calls[0]?.[0]?.contextExtensions
void contextExtensions.onBlockComplete('block-1', 'Fetch', 'api', {
input: {},
output: { done: true },
executionTime: 10,
startedAt: new Date().toISOString(),
executionOrder: 1,
endedAt: new Date().toISOString(),
})
callOrder.push('executor:return')
return {
success: true,
status: 'completed',
output: { done: true },
logs: [],
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
}
})
const executionPromise = executeWorkflowCore({
snapshot: createSnapshot() as any,
callbacks: {
onBlockComplete: async () => {
callOrder.push('callback:start')
markCallbackStarted?.()
await blockCompletePromise
callOrder.push('callback:end')
},
},
loggingSession: loggingSession as any,
}).then((result) => {
hasReturned = true
callOrder.push('core:return')
return result
})
await callbackStartedPromise
expect(callOrder).toEqual(['executor:return', 'callback:start'])
expect(hasReturned).toBe(false)
releaseBlockComplete?.()
const result = await executionPromise
expect(result.status).toBe('completed')
expect(callOrder).toEqual(['executor:return', 'callback:start', 'callback:end', 'core:return'])
})
it('preserves successful execution when success finalization throws', async () => {
executorExecuteMock.mockResolvedValue({
success: true,
@@ -278,6 +278,22 @@ export async function executeWorkflowCore(
let processedInput = input || {}
let deploymentVersionId: string | undefined
let loggingStarted = false
const pendingLifecycleCallbacks = new Set<Promise<void>>()
const trackLifecycleCallback = (promise: Promise<void>) => {
pendingLifecycleCallbacks.add(promise)
void promise
.finally(() => {
pendingLifecycleCallbacks.delete(promise)
})
.catch(() => {})
}
const waitForLifecycleCallbacks = async () => {
while (pendingLifecycleCallbacks.size > 0) {
await Promise.allSettled([...pendingLifecycleCallbacks])
}
}
try {
let blocks
@@ -358,14 +374,22 @@ export async function executeWorkflowCore(
// Check if this is a resume execution before trigger resolution
const resumeFromSnapshot = metadata.resumeFromSnapshot === true
const resumePendingQueue = snapshot.state?.pendingQueue
const resumeRemainingEdges = snapshot.state?.remainingEdges
const resumeTerminalNoop = metadata.resumeTerminalNoop === true
let resolvedTriggerBlockId = triggerBlockId
// For resume executions, skip trigger resolution since we have a pending queue
if (resumeFromSnapshot && resumePendingQueue?.length) {
// Resume executions derive their queue from the snapshot. Even an empty
// queue is meaningful: a terminal pause block has no downstream work.
if (
resumeFromSnapshot &&
(resumePendingQueue !== undefined || resumeRemainingEdges !== undefined || resumeTerminalNoop)
) {
resolvedTriggerBlockId = undefined
logger.info(`[${requestId}] Skipping trigger resolution for resume execution`, {
pendingQueueLength: resumePendingQueue.length,
pendingQueueLength: resumePendingQueue?.length ?? 0,
remainingEdgeCount: resumeRemainingEdges?.length ?? 0,
resumeTerminalNoop,
})
} else if (!triggerBlockId) {
const executionKind =
@@ -425,7 +449,7 @@ export async function executeWorkflowCore(
})
}
const wrappedOnBlockComplete = async (
const wrappedOnBlockComplete = (
blockId: string,
blockName: string,
blockType: string,
@@ -439,36 +463,47 @@ export async function executeWorkflowCore(
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => {
try {
let persistenceSucceeded = false
const persistencePromise = (async () => {
await loggingSession.onBlockComplete(blockId, blockName, blockType, output)
if (onBlockComplete) {
void onBlockComplete(
blockId,
blockName,
blockType,
output,
iterationContext,
childWorkflowContext
).catch((error) => {
logger.warn(`[${requestId}] Block completion callback failed`, {
executionId,
blockId,
blockType,
error,
})
})
}
} catch (error) {
persistenceSucceeded = true
})().catch((error) => {
logger.warn(`[${requestId}] Block completion persistence failed`, {
executionId,
blockId,
blockType,
error,
})
}
})
const lifecyclePromise = (async () => {
await persistencePromise
if (!persistenceSucceeded || !onBlockComplete) return
try {
await onBlockComplete(
blockId,
blockName,
blockType,
output,
iterationContext,
childWorkflowContext
)
} catch (error) {
logger.warn(`[${requestId}] Block completion callback failed`, {
executionId,
blockId,
blockType,
error,
})
}
})()
trackLifecycleCallback(lifecyclePromise)
return persistencePromise
}
const wrappedOnBlockStart = async (
const wrappedOnBlockStart = (
blockId: string,
blockName: string,
blockType: string,
@@ -476,33 +511,44 @@ export async function executeWorkflowCore(
iterationContext?: IterationContext,
childWorkflowContext?: ChildWorkflowContext
) => {
try {
let persistenceSucceeded = false
const persistencePromise = (async () => {
await loggingSession.onBlockStart(blockId, blockName, blockType, new Date().toISOString())
if (onBlockStart) {
void onBlockStart(
blockId,
blockName,
blockType,
executionOrder,
iterationContext,
childWorkflowContext
).catch((error) => {
logger.warn(`[${requestId}] Block start callback failed`, {
executionId,
blockId,
blockType,
error,
})
})
}
} catch (error) {
persistenceSucceeded = true
})().catch((error) => {
logger.warn(`[${requestId}] Block start persistence failed`, {
executionId,
blockId,
blockType,
error,
})
}
})
const lifecyclePromise = (async () => {
await persistencePromise
if (!persistenceSucceeded || !onBlockStart) return
try {
await onBlockStart(
blockId,
blockName,
blockType,
executionOrder,
iterationContext,
childWorkflowContext
)
} catch (error) {
logger.warn(`[${requestId}] Block start callback failed`, {
executionId,
blockId,
blockType,
error,
})
}
})()
trackLifecycleCallback(lifecyclePromise)
return persistencePromise
}
const contextExtensions: ContextExtensions = {
@@ -561,6 +607,8 @@ export async function executeWorkflowCore(
)) as ExecutionResult)
: ((await executorInstance.execute(workflowId, resolvedTriggerBlockId)) as ExecutionResult)
await waitForLifecycleCallbacks()
loggingSession.setPostExecutionPromise(
(async () => {
try {
@@ -595,6 +643,8 @@ export async function executeWorkflowCore(
} catch (error: unknown) {
logger.error(`[${requestId}] Execution failed:`, error)
await waitForLifecycleCallbacks()
if (!loggingStarted) {
loggingStarted = await loggingSession.safeStart({
userId,
@@ -68,6 +68,8 @@ export interface ExecutionPausedEvent extends BaseExecutionEvent {
duration: number
startTime: string
endTime: string
/** Authoritative per-block terminal states from the server's blockLogs. */
finalBlockLogs?: BlockLog[]
}
}
@@ -184,7 +186,12 @@ export interface BlockChildWorkflowStartedEvent extends BaseExecutionEvent {
blockId: string
childWorkflowInstanceId: string
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
iterationContainerId?: string
parentIterations?: ParentIteration[]
childWorkflowBlockId?: string
childWorkflowName?: string
executionOrder?: number
}
}
@@ -431,13 +438,14 @@ export function createExecutionCallbacks(options: {
}
}
const onChildWorkflowInstanceReady = (
const onChildWorkflowInstanceReady = async (
blockId: string,
childWorkflowInstanceId: string,
iterationContext?: IterationContext,
executionOrder?: number
executionOrder?: number,
childWorkflowContext?: ChildWorkflowContext
) => {
void sendBufferedEvent({
await sendBufferedEvent({
type: 'block:childWorkflowStarted',
timestamp: new Date().toISOString(),
executionId,
@@ -447,7 +455,16 @@ export function createExecutionCallbacks(options: {
childWorkflowInstanceId,
...(iterationContext && {
iterationCurrent: iterationContext.iterationCurrent,
iterationTotal: iterationContext.iterationTotal,
iterationType: iterationContext.iterationType,
iterationContainerId: iterationContext.iterationContainerId,
...(iterationContext.parentIterations?.length && {
parentIterations: iterationContext.parentIterations,
}),
}),
...(childWorkflowContext && {
childWorkflowBlockId: childWorkflowContext.parentBlockId,
childWorkflowName: childWorkflowContext.workflowName,
}),
...(executionOrder !== undefined && { executionOrder }),
},
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,12 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer'
import {
createExecutionEventWriter,
type ExecutionEventWriter,
initializeExecutionStreamMeta,
type TerminalExecutionStreamStatus,
} from '@/lib/execution/event-buffer'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import {
@@ -23,6 +28,7 @@ import type { BlockLog, NormalizedBlockOutput } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
const logger = createLogger('QueuedWorkflowExecution')
const TERMINAL_PUBLISH_ERROR = 'Run buffer terminal event publish failed'
export const DIRECT_WORKFLOW_JOB_NAME = 'direct-workflow-execution'
@@ -86,23 +92,46 @@ function buildResult(
}
}
async function publishTerminalExecutionEvent(params: {
writer: ExecutionEventWriter
executionId: string
status: TerminalExecutionStreamStatus
event: ExecutionEvent
}): Promise<boolean> {
try {
await params.writer.writeTerminal(params.event, params.status)
return true
} catch (error) {
logger.warn('Failed to buffer terminal execution event', {
executionId: params.executionId,
status: params.status,
error: toError(error).message,
})
return false
}
}
export async function executeQueuedWorkflowJob(
payload: QueuedWorkflowExecutionPayload
): Promise<QueuedWorkflowExecutionResult> {
const { metadata } = payload
const { executionId, requestId, workflowId, triggerType } = metadata
const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId)
const timeoutController = createTimeoutAbortController(payload.timeoutMs)
const eventWriter = payload.streamEvents ? createExecutionEventWriter(executionId) : null
let eventWriterClosed = false
if (payload.streamEvents) {
await setExecutionMeta(executionId, {
status: 'active',
const metaInitialized = await initializeExecutionStreamMeta(executionId, {
userId: metadata.userId,
workflowId,
})
if (!metaInitialized) {
throw new Error('Run buffer temporarily unavailable')
}
}
const timeoutController = createTimeoutAbortController(payload.timeoutMs)
try {
const snapshot = new ExecutionSnapshot(
metadata,
@@ -161,19 +190,25 @@ export async function executeQueuedWorkflowJob(
await loggingSession.markAsFailed(timeoutErrorMessage)
if (eventWriter) {
await eventWriter.write({
type: 'execution:error',
timestamp: new Date().toISOString(),
eventWriterClosed = await publishTerminalExecutionEvent({
writer: eventWriter,
executionId,
workflowId,
data: {
error: timeoutErrorMessage,
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
status: 'error',
event: {
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: timeoutErrorMessage,
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
},
},
})
await setExecutionMeta(executionId, { status: 'error' })
}
if (eventWriter && !eventWriterClosed) {
throw new Error(TERMINAL_PUBLISH_ERROR)
}
return buildResult(
@@ -208,49 +243,65 @@ export async function executeQueuedWorkflowJob(
if (eventWriter) {
if (result.status === 'cancelled') {
await eventWriter.write({
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
eventWriterClosed = await publishTerminalExecutionEvent({
writer: eventWriter,
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
status: 'cancelled',
event: {
type: 'execution:cancelled',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
duration: result.metadata?.duration || 0,
finalBlockLogs: result.logs,
},
},
})
await setExecutionMeta(executionId, { status: 'cancelled' })
} else if (result.status === 'paused') {
await eventWriter.write({
type: 'execution:paused',
timestamp: new Date().toISOString(),
eventWriterClosed = await publishTerminalExecutionEvent({
writer: eventWriter,
executionId,
workflowId,
data: {
output: outputWithBase64,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || metadata.startTime,
endTime: result.metadata?.endTime || new Date().toISOString(),
status: 'complete',
event: {
type: 'execution:paused',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
output: outputWithBase64,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || metadata.startTime,
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: result.logs,
},
},
})
await setExecutionMeta(executionId, { status: 'complete' })
} else {
await eventWriter.write({
type: 'execution:completed',
timestamp: new Date().toISOString(),
eventWriterClosed = await publishTerminalExecutionEvent({
writer: eventWriter,
executionId,
workflowId,
data: {
success: result.success,
output: outputWithBase64,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || metadata.startTime,
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: result.logs,
status: 'complete',
event: {
type: 'execution:completed',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
success: result.success,
output: outputWithBase64,
duration: result.metadata?.duration || 0,
startTime: result.metadata?.startTime || metadata.startTime,
endTime: result.metadata?.endTime || new Date().toISOString(),
finalBlockLogs: result.logs,
},
},
})
await setExecutionMeta(executionId, { status: 'complete' })
}
}
if (eventWriter && !eventWriterClosed) {
throw new Error(TERMINAL_PUBLISH_ERROR)
}
return buildResult(
result.status === 'paused'
@@ -274,6 +325,10 @@ export async function executeQueuedWorkflowJob(
executionId
)
} catch (error) {
if (toError(error).message === TERMINAL_PUBLISH_ERROR) {
throw error
}
logger.error('Queued workflow execution failed', {
workflowId,
executionId,
@@ -295,18 +350,25 @@ export async function executeQueuedWorkflowJob(
const executionResult = hasExecutionResult(error) ? error.executionResult : undefined
if (eventWriter) {
await eventWriter.write({
type: 'execution:error',
timestamp: new Date().toISOString(),
eventWriterClosed = await publishTerminalExecutionEvent({
writer: eventWriter,
executionId,
workflowId,
data: {
error: toError(error).message,
duration: 0,
finalBlockLogs: executionResult?.logs,
status: 'error',
event: {
type: 'execution:error',
timestamp: new Date().toISOString(),
executionId,
workflowId,
data: {
error: toError(error).message,
duration: 0,
finalBlockLogs: executionResult?.logs,
},
},
})
await setExecutionMeta(executionId, { status: 'error' })
}
if (eventWriter && !eventWriterClosed) {
throw new Error(TERMINAL_PUBLISH_ERROR)
}
return buildResult(
@@ -330,8 +392,13 @@ export async function executeQueuedWorkflowJob(
} finally {
timeoutController.cleanup()
if (eventWriter) {
await eventWriter.close()
if (eventWriter && !eventWriterClosed) {
await eventWriter.close().catch((error) => {
logger.warn('Failed to close queued execution event writer', {
executionId,
error: toError(error).message,
})
})
}
await cleanupExecutionBase64Cache(executionId).catch((error) => {
+103 -21
View File
@@ -10,7 +10,7 @@ const MIGRATION_KEY = 'terminal-console-store-migrated'
/**
* Interval for persisting terminal state during active executions.
* Kept short enough that a hard refresh during execution still has
* recent running entries persisted for the reconnect flow to find.
* recent rows available once the tab-local reconnect pointer resumes.
*/
const EXECUTION_PERSIST_INTERVAL_MS = 5_000
@@ -94,16 +94,84 @@ export async function loadConsoleData(): Promise<PersistedConsoleData | null> {
}
}
let writeSequence = 0
let activeWrite: Promise<void> | null = null
function writeToIndexedDB(data: PersistedConsoleData): void {
const seq = ++writeSequence
interface PersistOptions {
merge?: boolean
}
function entryTimestamp(entry: ConsoleEntry): number {
return Date.parse(entry.endedAt ?? entry.startedAt ?? entry.timestamp)
}
function shouldReplaceEntry(existing: ConsoleEntry, incoming: ConsoleEntry): boolean {
if (existing.isRunning && !incoming.isRunning) return true
if (!existing.isRunning && incoming.isRunning) return false
return entryTimestamp(incoming) >= entryTimestamp(existing)
}
function mergeEntries(
existingEntries: ConsoleEntry[] = [],
incomingEntries: ConsoleEntry[] = []
): ConsoleEntry[] {
const entriesById = new Map<string, ConsoleEntry>()
const orderedIds: string[] = []
for (const entry of existingEntries) {
entriesById.set(entry.id, entry)
orderedIds.push(entry.id)
}
for (const entry of incomingEntries) {
const existing = entriesById.get(entry.id)
if (!existing) {
entriesById.set(entry.id, entry)
orderedIds.push(entry.id)
continue
}
if (shouldReplaceEntry(existing, entry)) {
entriesById.set(entry.id, entry)
}
}
return orderedIds
.map((id) => entriesById.get(id))
.filter((entry): entry is ConsoleEntry => !!entry)
}
function mergePersistedConsoleData(
existing: PersistedConsoleData | null,
incoming: PersistedConsoleData
): PersistedConsoleData {
if (!existing) return incoming
const workflowIds = new Set([
...Object.keys(existing.workflowEntries),
...Object.keys(incoming.workflowEntries),
])
const workflowEntries: Record<string, ConsoleEntry[]> = {}
for (const workflowId of workflowIds) {
const entries = mergeEntries(
existing.workflowEntries[workflowId],
incoming.workflowEntries[workflowId]
)
if (entries.length > 0) workflowEntries[workflowId] = entries
}
return {
workflowEntries,
isOpen: incoming.isOpen,
}
}
function writeToIndexedDB(
data: PersistedConsoleData,
{ merge = true }: PersistOptions = {}
): Promise<void> {
const doWrite = async () => {
try {
const serialized = JSON.stringify(data)
if (seq !== writeSequence) return
const nextData = merge ? mergePersistedConsoleData(await loadConsoleData(), data) : data
const serialized = JSON.stringify(nextData)
await set(STORE_KEY, serialized)
} catch (error) {
logger.warn('IndexedDB write failed', { error })
@@ -111,6 +179,7 @@ function writeToIndexedDB(data: PersistedConsoleData): void {
}
activeWrite = (activeWrite ?? Promise.resolve()).then(doWrite)
return activeWrite
}
/**
@@ -153,8 +222,8 @@ class ConsolePersistenceManager {
/**
* Called by the store when a running entry is added during an active execution.
* Triggers one immediate persist so the reconnect flow can find running entries
* after a page refresh, then disables until the next execution starts.
* Triggers one immediate persist so refreshes can hydrate visible terminal rows,
* then disables until the next execution starts.
*/
onRunningEntryAdded(): void {
if (!this.needsInitialPersist) return
@@ -178,9 +247,9 @@ class ConsolePersistenceManager {
* Triggers an immediate persist. Used for explicit user actions
* like clearing the console, and for page-hide durability.
*/
persist(): void {
if (!this.dataProvider) return
writeToIndexedDB(this.dataProvider())
persist(options?: PersistOptions): Promise<void> {
if (!this.dataProvider) return Promise.resolve()
return writeToIndexedDB(this.dataProvider(), options)
}
private startSafetyTimer(): void {
@@ -205,8 +274,8 @@ const EXEC_POINTER_PREFIX = 'terminal-active-execution:'
/**
* Lightweight pointer to an in-flight execution, persisted immediately on
* execution start so the reconnect flow can find it even if no console
* entries have been written yet. Keyed per-workflow so multiple tabs
* running different workflows don't overwrite each other.
* entries have been written yet. Stored in sessionStorage so ownership stays
* scoped to the browser tab that started the run.
*/
export interface ExecutionPointer {
workflowId: string
@@ -217,9 +286,9 @@ export interface ExecutionPointer {
export async function loadExecutionPointer(workflowId: string): Promise<ExecutionPointer | null> {
if (typeof window === 'undefined') return null
try {
const raw = await get<string>(`${EXEC_POINTER_PREFIX}${workflowId}`)
const raw = window.sessionStorage.getItem(`${EXEC_POINTER_PREFIX}${workflowId}`)
if (!raw) return null
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
const parsed = JSON.parse(raw)
if (!parsed?.executionId) return null
return parsed as ExecutionPointer
} catch {
@@ -227,12 +296,25 @@ export async function loadExecutionPointer(workflowId: string): Promise<Executio
}
}
export function saveExecutionPointer(pointer: ExecutionPointer): void {
if (typeof window === 'undefined') return
set(`${EXEC_POINTER_PREFIX}${pointer.workflowId}`, JSON.stringify(pointer)).catch(() => {})
export function saveExecutionPointer(pointer: ExecutionPointer): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
try {
window.sessionStorage.setItem(
`${EXEC_POINTER_PREFIX}${pointer.workflowId}`,
JSON.stringify(pointer)
)
} catch {
return Promise.resolve()
}
return Promise.resolve()
}
export function clearExecutionPointer(workflowId: string): void {
if (typeof window === 'undefined') return
set(`${EXEC_POINTER_PREFIX}${workflowId}`, '').catch(() => {})
export function clearExecutionPointer(workflowId: string): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
try {
window.sessionStorage.removeItem(`${EXEC_POINTER_PREFIX}${workflowId}`)
} catch {
return Promise.resolve()
}
return Promise.resolve()
}
@@ -136,5 +136,59 @@ describe('terminal console store', () => {
expect(entry.isCanceled).toBe(true)
expect(entry.isRunning).toBe(false)
})
it('only cancels running entries for the requested execution when provided', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function 1',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
})
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-2',
blockName: 'Function 2',
blockType: 'function',
executionId: 'exec-2',
executionOrder: 2,
isRunning: true,
})
useTerminalConsoleStore.getState().cancelRunningEntries('wf-1', 'exec-1')
const entries = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entries.find((entry) => entry.executionId === 'exec-1')).toMatchObject({
isCanceled: true,
isRunning: false,
})
expect(entries.find((entry) => entry.executionId === 'exec-2')).toMatchObject({
isRunning: true,
})
})
})
describe('finishRunningEntries', () => {
it('settles running entries without marking them canceled', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
startedAt: new Date(Date.now() - 1000).toISOString(),
})
useTerminalConsoleStore.getState().finishRunningEntries('wf-1', 'exec-1')
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.isCanceled).toBe(false)
expect(entry.isRunning).toBe(false)
expect(entry.endedAt).toBeDefined()
})
})
})
+47 -3
View File
@@ -110,6 +110,14 @@ const matchesEntryForUpdate = (
return false
}
if (
update.childWorkflowInstanceId !== undefined &&
entry.childWorkflowInstanceId !== undefined &&
entry.childWorkflowInstanceId !== update.childWorkflowInstanceId
) {
return false
}
return true
}
@@ -328,7 +336,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
clearWorkflowConsole: (workflowId: string) => {
set((state) => replaceWorkflowEntries(state, workflowId, EMPTY_CONSOLE_ENTRIES))
useExecutionStore.getState().clearRunPath(workflowId)
consolePersistence.persist()
consolePersistence.persist({ merge: false })
},
clearExecutionEntries: (executionId: string) =>
@@ -604,13 +612,17 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
}
},
cancelRunningEntries: (workflowId: string) => {
cancelRunningEntries: (workflowId: string, executionId?: string) => {
set((state) => {
const now = new Date()
const workflowEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
let didChange = false
const updatedEntries = workflowEntries.map((entry) => {
if (entry.workflowId === workflowId && entry.isRunning) {
if (
entry.workflowId === workflowId &&
entry.isRunning &&
(executionId === undefined || entry.executionId === executionId)
) {
didChange = true
const durationMs = entry.startedAt
? now.getTime() - new Date(entry.startedAt).getTime()
@@ -631,6 +643,38 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
return replaceWorkflowEntries(state, workflowId, updatedEntries)
})
},
finishRunningEntries: (workflowId: string, executionId?: string) => {
set((state) => {
const now = new Date()
const workflowEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES
let didChange = false
const updatedEntries = workflowEntries.map((entry) => {
if (
entry.workflowId === workflowId &&
entry.isRunning &&
(executionId === undefined || entry.executionId === executionId)
) {
didChange = true
const durationMs = entry.startedAt
? now.getTime() - new Date(entry.startedAt).getTime()
: entry.durationMs
return {
...entry,
isRunning: false,
isCanceled: false,
endedAt: now.toISOString(),
durationMs,
}
}
return entry
})
if (!didChange) {
return state
}
return replaceWorkflowEntries(state, workflowId, updatedEntries)
})
},
}))
)
+2 -1
View File
@@ -75,6 +75,7 @@ export interface ConsoleStore {
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
toggleConsole: () => void
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
cancelRunningEntries: (workflowId: string) => void
cancelRunningEntries: (workflowId: string, executionId?: string) => void
finishRunningEntries: (workflowId: string, executionId?: string) => void
_hasHydrated: boolean
}