chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,355 @@
/**
* @vitest-environment node
*/
import {
databaseMock,
hybridAuthMockFns,
posthogServerMock,
workflowAuthzMockFns,
workflowsUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockMarkExecutionCancelled,
mockAbortManualExecution,
mockBeginPausedCancellation,
mockBlockQueuedResumesForCancellation,
mockClearPausedCancellationIntent,
mockCompletePausedCancellation,
mockGetPausedCancellationStatus,
mockFinalizeExecutionStream,
mockReadExecutionMetaState,
mockWriteEvent,
mockWriteTerminalEvent,
} = vi.hoisted(() => ({
mockMarkExecutionCancelled: vi.fn(),
mockAbortManualExecution: 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(),
mockWriteTerminalEvent: vi.fn(),
}))
vi.mock('@/lib/execution/cancellation', () => ({
markExecutionCancelled: (...args: unknown[]) => mockMarkExecutionCancelled(...args),
}))
vi.mock('@/lib/execution/manual-cancellation', () => ({
abortManualExecution: (...args: unknown[]) => mockAbortManualExecution(...args),
}))
vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({
PauseResumeManager: {
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),
},
}))
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
vi.mock('@/lib/posthog/server', () => posthogServerMock)
vi.mock('@/lib/execution/event-buffer', () => ({
finalizeExecutionStream: (...args: unknown[]) => mockFinalizeExecutionStream(...args),
readExecutionMetaState: (...args: unknown[]) => mockReadExecutionMetaState(...args),
createExecutionEventWriter: () => ({
write: (...args: unknown[]) => mockWriteEvent(...args),
writeTerminal: (...args: unknown[]) => mockWriteTerminalEvent(...args),
close: vi.fn().mockResolvedValue(undefined),
}),
}))
import { POST } from './route'
const makeRequest = () =>
new NextRequest('http://localhost/api/workflows/wf-1/executions/ex-1/cancel', {
method: 'POST',
})
const makeParams = () => ({ params: Promise.resolve({ id: 'wf-1', executionId: 'ex-1' }) })
describe('POST /api/workflows/[id]/executions/[executionId]/cancel', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1' })
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
})
mockAbortManualExecution.mockReturnValue(false)
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 })
mockWriteTerminalEvent.mockResolvedValue({ eventId: 1 })
})
it('returns success when cancellation was durably recorded', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: true,
locallyAborted: false,
pausedCancelled: false,
reason: 'recorded',
})
})
it('returns unsuccessful response when Redis is unavailable', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: '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: false,
reason: 'redis_unavailable',
})
})
it('returns unsuccessful response when Redis persistence fails', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_write_failed',
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: false,
executionId: 'ex-1',
redisAvailable: true,
durablyRecorded: false,
locallyAborted: false,
pausedCancelled: false,
reason: 'redis_write_failed',
})
})
it('returns success when local fallback aborts execution without Redis durability', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockAbortManualExecution.mockReturnValue(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: false,
durablyRecorded: false,
locallyAborted: true,
pausedCancelled: false,
reason: 'redis_unavailable',
})
})
it('returns success when a paused HITL execution is cancelled directly in the database', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
mockCompletePausedCancellation.mockResolvedValue(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
executionId: 'ex-1',
redisAvailable: 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: 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 () => {
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
success: false,
error: 'Unauthorized',
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(401)
})
it('returns 403 when workflow access is denied', async () => {
mockMarkExecutionCancelled.mockResolvedValue({ durablyRecorded: true, reason: 'recorded' })
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
message: 'Access denied',
status: 403,
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(403)
})
it('updates execution log status in DB when durably recorded', async () => {
const mockWhere = vi.fn().mockResolvedValue(undefined)
const mockSet = vi.fn(() => ({ where: mockWhere }))
databaseMock.db.update.mockReturnValueOnce({ set: mockSet })
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
await POST(makeRequest(), makeParams())
expect(databaseMock.db.update).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith({
status: 'cancelled',
endedAt: expect.any(Date),
})
})
it('updates execution log status in DB when locally aborted', async () => {
const mockWhere = vi.fn().mockResolvedValue(undefined)
const mockSet = vi.fn(() => ({ where: mockWhere }))
databaseMock.db.update.mockReturnValueOnce({ set: mockSet })
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: false,
reason: 'redis_unavailable',
})
mockAbortManualExecution.mockReturnValue(true)
await POST(makeRequest(), makeParams())
expect(databaseMock.db.update).toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith({
status: 'cancelled',
endedAt: expect.any(Date),
})
})
it('does not update execution log status in DB when only paused execution was cancelled', async () => {
mockBeginPausedCancellation.mockResolvedValue(true)
await POST(makeRequest(), makeParams())
expect(databaseMock.db.update).not.toHaveBeenCalled()
})
it('returns success even if direct DB update fails', async () => {
mockMarkExecutionCancelled.mockResolvedValue({
durablyRecorded: true,
reason: 'recorded',
})
databaseMock.db.update.mockReturnValueOnce({
set: vi.fn(() => ({
where: vi.fn(() => {
throw new Error('DB connection failed')
}),
})),
})
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
const data = await response.json()
expect(data.success).toBe(true)
})
})
@@ -0,0 +1,326 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
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 {
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,
workflowId: string
): Promise<boolean> {
for (let attempt = 1; attempt <= PAUSED_CANCELLATION_DB_ATTEMPTS; attempt++) {
try {
const cancelled = await PauseResumeManager.completePausedCancellation(executionId, workflowId)
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,
context: { workspaceId?: string; userId?: string } = {}
): Promise<boolean> {
const metaState = await readExecutionMetaState(executionId)
if (metaState.status === 'found' && metaState.meta.status === 'cancelled') {
return true
}
const writer = createExecutionEventWriter(executionId, {
workspaceId: context.workspaceId,
workflowId,
userId: context.userId,
})
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'
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => {
const parsed = await parseRequest(cancelWorkflowExecutionContract, req, context)
if (!parsed.success) return parsed.response
const { id: workflowId, executionId } = parsed.data.params
try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'write',
})
if (!workflowAuthorization.allowed) {
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status }
)
}
if (
auth.apiKeyType === 'workspace' &&
workflowAuthorization.workflow?.workspaceId !== auth.workspaceId
) {
return NextResponse.json(
{ error: 'API key is not authorized for this workspace' },
{ status: 403 }
)
}
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
let pausedCancellationStarted = false
let pausedCancelled = false
try {
pausedCancellationStarted = await PauseResumeManager.beginPausedCancellation(
executionId,
workflowId
)
} catch (error) {
logger.warn('Failed to begin paused execution cancellation in database', {
executionId,
error,
})
}
const pendingPausedCancellation = pausedCancellationStarted
? null
: await PauseResumeManager.getPausedCancellationStatus(executionId, workflowId)
const isPausedCancellationPath =
pausedCancellationStarted || pendingPausedCancellation !== null
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 (!pausedCancellationStarted) {
logger.warn('Execution cancellation was not durably recorded', {
executionId,
reason: cancellation.reason,
})
}
if (!isPausedCancellationPath && (cancellation.durablyRecorded || locallyAborted)) {
await PauseResumeManager.blockQueuedResumesForCancellation(executionId, workflowId).catch(
(error) => {
logger.warn('Failed to block queued paused resumes after cancellation', {
executionId,
error,
})
}
)
} else if (!isPausedCancellationPath) {
await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).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,
{
workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
userId: auth.userId,
}
)
pausedCancellationPublishFailed = !pausedCancellationPublished
if (pausedCancellationPublished) {
pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId)
}
} else {
if (pendingPausedCancellation === 'cancelled') {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId,
{
workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
userId: auth.userId,
}
)
pausedCancellationPublishFailed = !pausedCancellationPublished
pausedCancelled = pausedCancellationPublished
} else if (pendingPausedCancellation === 'cancelling') {
pausedCancellationPublished = await ensurePausedCancellationEventPublished(
executionId,
workflowId,
{
workspaceId: workflowAuthorization.workflow?.workspaceId ?? undefined,
userId: auth.userId,
}
)
pausedCancellationPublishFailed = !pausedCancellationPublished
if (pausedCancellationPublished) {
pausedCancelled = await completePausedCancellationWithRetry(executionId, workflowId)
}
}
}
if (
pausedCancellationPublishFailed &&
(pausedCancellationStarted || pendingPausedCancellation === 'cancelling')
) {
await PauseResumeManager.clearPausedCancellationIntent(executionId, workflowId).catch(
(error) => {
logger.warn('Failed to clear paused cancellation intent after publish failure', {
executionId,
error,
})
}
)
}
if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) {
try {
await db
.update(workflowExecutionLogs)
.set({ status: 'cancelled', endedAt: new Date() })
.where(
and(
eq(workflowExecutionLogs.executionId, executionId),
eq(workflowExecutionLogs.status, 'running')
)
)
} catch (dbError) {
logger.warn('Failed to update execution log status directly', {
executionId,
error: dbError,
})
}
}
const success =
(isPausedCancellationPath
? pausedCancelled && pausedCancellationPublished
: cancellation.durablyRecorded) || locallyAborted
if (success) {
const workspaceId = workflowAuthorization.workflow?.workspaceId
captureServerEvent(
auth.userId,
'workflow_execution_cancelled',
{ workflow_id: workflowId, workspace_id: workspaceId ?? '' },
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}
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:
isPausedCancellationPath || pausedCancelled
? pausedCancellationPublished
: cancellation.reason !== 'redis_unavailable',
durablyRecorded,
locallyAborted,
pausedCancelled,
reason,
})
} catch (error) {
logger.error('Failed to cancel execution', {
workflowId,
executionId,
error: toError(error).message,
})
return NextResponse.json(
{ error: toError(error).message || 'Failed to cancel execution' },
{ status: 500 }
)
}
}
)
@@ -0,0 +1,232 @@
import { db } from '@sim/db'
import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
getWorkflowExecutionContract,
type WorkflowExecutionStatusResponse,
} from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import type { PausePoint } from '@/executor/types'
const logger = createLogger('WorkflowExecutionStatusAPI')
type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
interface TraceSpanShape {
blockId?: string
output?: Record<string, unknown>
children?: TraceSpanShape[]
}
interface ExecutionDataShape {
finalOutput?: { error?: string } & Record<string, unknown>
error?: { message?: string } | string
completionFailure?: string
traceSpans?: TraceSpanShape[]
}
function collectBlockOutputs(spans: TraceSpanShape[] | undefined): Map<string, unknown> {
const map = new Map<string, unknown>()
const visit = (list?: TraceSpanShape[]): void => {
if (!list) return
for (const span of list) {
if (span.blockId && span.output !== undefined && !map.has(span.blockId)) {
map.set(span.blockId, span.output)
}
if (span.children) visit(span.children)
}
}
visit(spans)
return map
}
function resolvePath(value: unknown, path: string[]): unknown {
let current: unknown = value
for (const segment of path) {
if (current == null || typeof current !== 'object') return undefined
current = (current as Record<string, unknown>)[segment]
}
return current
}
function pickSelectedOutputs(
selectedOutputs: string[],
blockOutputs: Map<string, unknown>
): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const selector of selectedOutputs) {
const [head, ...rest] = selector.split('.')
if (!head) continue
if (!blockOutputs.has(head)) continue
const blockValue = blockOutputs.get(head)
out[selector] = rest.length === 0 ? blockValue : resolvePath(blockValue, rest)
}
return out
}
function pickEarliestPausePoint(points: PausePoint[]): PausePoint | null {
const active = points.filter((p) => p.resumeStatus === 'paused')
if (active.length === 0) return null
return active.reduce<PausePoint | null>((best, current) => {
if (!best) return current
if (!current.resumeAt) return best
if (!best.resumeAt) return current
return current.resumeAt < best.resumeAt ? current : best
}, null)
}
function normalizePausePoints(raw: unknown): PausePoint[] {
if (!raw) return []
if (Array.isArray(raw)) return raw as PausePoint[]
if (typeof raw === 'object') return Object.values(raw as Record<string, PausePoint>)
return []
}
function extractError(executionData: unknown): string | null {
if (!executionData || typeof executionData !== 'object') return null
const data = executionData as ExecutionDataShape
if (typeof data.error === 'string') return data.error
if (data.error && typeof data.error === 'object' && typeof data.error.message === 'string') {
return data.error.message
}
if (typeof data.finalOutput?.error === 'string') return data.finalOutput.error
if (typeof data.completionFailure === 'string') return data.completionFailure
return null
}
export const GET = withRouteHandler(
async (
request: NextRequest,
context: { params: Promise<{ id: string; executionId: string }> }
) => {
const parsed = await parseRequest(getWorkflowExecutionContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId, executionId } = parsed.data.params
const { includeOutput, selectedOutputs } = parsed.data.query
const access = await validateWorkflowAccess(request, workflowId, false)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
const [logRow] = await db
.select({
executionId: workflowExecutionLogs.executionId,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
status: workflowExecutionLogs.status,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
costTotal: workflowExecutionLogs.costTotal,
})
.from(workflowExecutionLogs)
.where(
and(
eq(workflowExecutionLogs.executionId, executionId),
eq(workflowExecutionLogs.workflowId, workflowId)
)
)
.limit(1)
if (!logRow) {
return NextResponse.json({ error: 'Execution not found' }, { status: 404 })
}
const [pausedRow] = await db
.select({
id: pausedExecutions.id,
status: pausedExecutions.status,
pausePoints: pausedExecutions.pausePoints,
resumedCount: pausedExecutions.resumedCount,
pausedAt: pausedExecutions.pausedAt,
nextResumeAt: pausedExecutions.nextResumeAt,
})
.from(pausedExecutions)
.where(eq(pausedExecutions.executionId, executionId))
.limit(1)
const isCurrentlyPaused =
!!pausedRow && (pausedRow.status === 'paused' || pausedRow.status === 'partially_resumed')
let status: WorkflowExecutionStatusResponse['status']
if (isCurrentlyPaused) {
status = 'paused'
} else {
status = logRow.status as LogStatus
}
let paused: WorkflowExecutionStatusResponse['paused'] = null
if (isCurrentlyPaused && pausedRow) {
const points = normalizePausePoints(pausedRow.pausePoints)
const earliest = pickEarliestPausePoint(points)
paused = {
pausedAt: pausedRow.pausedAt.toISOString(),
resumeAt: pausedRow.nextResumeAt?.toISOString() ?? earliest?.resumeAt ?? null,
pauseKind: earliest?.pauseKind ?? null,
blockedOnBlockId: earliest?.blockId ?? null,
pausedExecutionId: pausedRow.id,
pausePointCount: points.length,
resumedCount: pausedRow.resumedCount,
}
}
const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null
// Heavy execution data may live in object storage; resolve the pointer
// before reading error / finalOutput / traceSpans (no-op for inline rows).
const executionData = (await materializeExecutionData(
logRow.executionData as Record<string, unknown> | null,
{
workspaceId: logRow.workspaceId,
workflowId: logRow.workflowId,
executionId: logRow.executionId,
}
)) as ExecutionDataShape | undefined
const error = status === 'failed' ? extractError(executionData) : null
const finalOutput =
includeOutput && status === 'completed' && executionData
? (executionData.finalOutput ?? null)
: null
const blockOutputs =
selectedOutputs.length > 0
? pickSelectedOutputs(selectedOutputs, collectBlockOutputs(executionData?.traceSpans))
: null
const response: WorkflowExecutionStatusResponse = {
executionId: logRow.executionId,
workflowId: logRow.workflowId ?? workflowId,
status,
trigger: logRow.trigger,
level: logRow.level,
startedAt: logRow.startedAt.toISOString(),
endedAt: logRow.endedAt?.toISOString() ?? null,
totalDurationMs: logRow.totalDurationMs ?? null,
paused,
cost,
error,
finalOutput,
blockOutputs,
}
logger.debug('Fetched execution status', {
workflowId,
executionId,
status,
paused: !!paused,
})
return NextResponse.json(response)
}
)
@@ -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/platform-authz/workflow', () => ({
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'
)
})
})
@@ -0,0 +1,230 @@
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { type NextRequest, NextResponse } from 'next/server'
import { streamWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
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,
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')
const POLL_INTERVAL_MS = 500
const MAX_POLL_DURATION_MS = 55 * 60 * 1000 // 55 minutes (just under Redis 1hr TTL)
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'
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; executionId: string }> }) => {
const parsed = await parseRequest(streamWorkflowExecutionContract, req, context)
if (!parsed.success) return parsed.response
const { id: workflowId, executionId } = parsed.data.params
const { from: fromEventId } = parsed.data.query
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: session.user.id,
action: 'read',
})
if (!workflowAuthorization.allowed) {
return NextResponse.json(
{ error: workflowAuthorization.message || 'Access denied' },
{ status: workflowAuthorization.status }
)
}
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 })
}
logger.info('Reconnection stream requested', {
workflowId,
executionId,
fromEventId,
metaStatus: meta.status,
})
const encoder = new TextEncoder()
let closed = false
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
let lastEventId = fromEventId
const pollDeadline = Date.now() + MAX_POLL_DURATION_MS
const enqueue = (text: string) => {
if (closed) return
try {
controller.enqueue(encoder.encode(text))
} catch {
closed = true
}
}
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) 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 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
}
while (!closed && Date.now() < pollDeadline) {
await sleep(POLL_INTERVAL_MS)
if (closed) return
const newEvents = await readEventsOrThrow(lastEventId)
if (enqueueEvents(newEvents)) {
closeWithDone()
return
}
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 })
throw new Error('Execution stream ended before a terminal event was available')
}
} catch (error) {
logger.error('Error in reconnection stream', {
executionId,
error: toError(error).message,
})
if (!closed) {
try {
controller.error(error)
} catch {}
}
}
},
cancel() {
closed = true
logger.info('Client disconnected from reconnection stream', { executionId })
},
})
return new NextResponse(stream, {
headers: {
...SSE_HEADERS,
'X-Execution-Id': executionId,
},
})
} catch (error: any) {
logger.error('Failed to start reconnection stream', {
workflowId,
executionId,
error: error.message,
})
return NextResponse.json(
{ error: error.message || 'Failed to start reconnection stream' },
{ status: 500 }
)
}
}
)