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
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:
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user