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,167 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workflowAutoLayoutContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { applyAutoLayout } from '@/lib/workflows/autolayout'
|
||||
import {
|
||||
DEFAULT_HORIZONTAL_SPACING,
|
||||
DEFAULT_LAYOUT_PADDING,
|
||||
DEFAULT_VERTICAL_SPACING,
|
||||
} from '@/lib/workflows/autolayout/constants'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
type NormalizedWorkflowData,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AutoLayoutAPI')
|
||||
|
||||
/**
|
||||
* POST /api/workflows/[id]/autolayout
|
||||
* Apply autolayout to an existing workflow
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
const { id: workflowId } = await context.params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
const parsed = await parseRequest(workflowAutoLayoutContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const layoutOptions = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, {
|
||||
userId,
|
||||
})
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const canUpdate = authorization.allowed
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
let currentWorkflowData: NormalizedWorkflowData | null
|
||||
|
||||
if (layoutOptions.blocks && layoutOptions.edges) {
|
||||
logger.info(`[${requestId}] Using provided blocks with live measurements`)
|
||||
currentWorkflowData = {
|
||||
blocks: layoutOptions.blocks,
|
||||
edges: layoutOptions.edges,
|
||||
loops: layoutOptions.loops || {},
|
||||
parallels: layoutOptions.parallels || {},
|
||||
isFromNormalizedTables: false,
|
||||
}
|
||||
} else {
|
||||
logger.info(`[${requestId}] Loading blocks from database`)
|
||||
currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
}
|
||||
|
||||
if (!currentWorkflowData) {
|
||||
logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`)
|
||||
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
|
||||
}
|
||||
|
||||
const autoLayoutOptions = {
|
||||
horizontalSpacing: layoutOptions.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING,
|
||||
verticalSpacing: layoutOptions.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING,
|
||||
padding: {
|
||||
x: layoutOptions.padding?.x ?? DEFAULT_LAYOUT_PADDING.x,
|
||||
y: layoutOptions.padding?.y ?? DEFAULT_LAYOUT_PADDING.y,
|
||||
},
|
||||
alignment: layoutOptions.alignment,
|
||||
gridSize: layoutOptions.gridSize,
|
||||
}
|
||||
|
||||
const layoutResult = applyAutoLayout(
|
||||
currentWorkflowData.blocks,
|
||||
currentWorkflowData.edges,
|
||||
autoLayoutOptions
|
||||
)
|
||||
|
||||
if (!layoutResult.success || !layoutResult.blocks) {
|
||||
logger.error(`[${requestId}] Auto layout failed:`, {
|
||||
error: layoutResult.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Auto layout failed',
|
||||
details: layoutResult.error || 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
const blockCount = Object.keys(layoutResult.blocks).length
|
||||
|
||||
logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, {
|
||||
blockCount,
|
||||
workflowId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Autolayout applied successfully to ${blockCount} blocks`,
|
||||
data: {
|
||||
blockCount,
|
||||
elapsed: `${elapsed}ms`,
|
||||
layoutedBlocks: layoutResult.blocks,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Autolayout failed',
|
||||
details: getErrorMessage(error, 'Unknown error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for workflow chat status route auth and access.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
dbChainMock,
|
||||
dbChainMockFns,
|
||||
hybridAuthMockFns,
|
||||
resetDbChainMock,
|
||||
workflowAuthzMockFns,
|
||||
workflowsUtilsMock,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...args: unknown[]) => ({ type: 'and', args })),
|
||||
eq: vi.fn(),
|
||||
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
import { GET } from '@/app/api/workflows/[id]/chat/status/route'
|
||||
|
||||
describe('Workflow Chat Status Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when user lacks workspace access', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Access denied',
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns deployment details when authorized', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'chat-1',
|
||||
identifier: 'assistant',
|
||||
title: 'Support Bot',
|
||||
description: 'desc',
|
||||
customizations: { theme: 'dark' },
|
||||
authType: 'public',
|
||||
allowedEmails: [],
|
||||
outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
|
||||
password: 'secret',
|
||||
isActive: true,
|
||||
},
|
||||
])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.isDeployed).toBe(true)
|
||||
expect(data.deployment.id).toBe('chat-1')
|
||||
expect(data.deployment.hasPassword).toBe(true)
|
||||
expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getChatDeploymentStatusContract } from '@/lib/api/contracts/deployments'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatStatusAPI')
|
||||
|
||||
/**
|
||||
* GET endpoint to check if a workflow has an active chat deployment
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const parsed = await parseRequest(getChatDeploymentStatusContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: id,
|
||||
userId: auth.userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return createErrorResponse(
|
||||
authorization.message || 'Access denied',
|
||||
authorization.status || 403
|
||||
)
|
||||
}
|
||||
|
||||
// Find any active chat deployments for this workflow
|
||||
const deploymentResults = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
identifier: chat.identifier,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
customizations: chat.customizations,
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
outputConfigs: chat.outputConfigs,
|
||||
password: chat.password,
|
||||
isActive: chat.isActive,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.workflowId, id), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
const isDeployed = deploymentResults.length > 0 && deploymentResults[0].isActive
|
||||
const deploymentInfo =
|
||||
deploymentResults.length > 0
|
||||
? {
|
||||
id: deploymentResults[0].id,
|
||||
identifier: deploymentResults[0].identifier,
|
||||
title: deploymentResults[0].title,
|
||||
description: deploymentResults[0].description,
|
||||
customizations: deploymentResults[0].customizations,
|
||||
authType: deploymentResults[0].authType,
|
||||
allowedEmails: deploymentResults[0].allowedEmails,
|
||||
outputConfigs: deploymentResults[0].outputConfigs,
|
||||
hasPassword: Boolean(deploymentResults[0].password),
|
||||
}
|
||||
: null
|
||||
|
||||
return createSuccessResponse({
|
||||
isDeployed,
|
||||
deployment: deploymentInfo,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error checking chat deployment status:`, error)
|
||||
return createErrorResponse(error.message || 'Failed to check chat deployment status', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db, workflow } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { updatePublicApiContract } from '@/lib/api/contracts/deployments'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
|
||||
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import {
|
||||
checkNeedsRedeployment,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
} from '@/app/api/workflows/utils'
|
||||
import {
|
||||
PublicApiNotAllowedError,
|
||||
validatePublicApiAllowed,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
const logger = createLogger('WorkflowDeployAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 120
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const { error, workflow: workflowData } = await validateWorkflowPermissions(
|
||||
id,
|
||||
requestId,
|
||||
'read'
|
||||
)
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
if (!workflowData.isDeployed) {
|
||||
logger.info(`[${requestId}] Workflow is not deployed: ${id}`)
|
||||
return createSuccessResponse({
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
apiKey: null,
|
||||
needsRedeployment: false,
|
||||
isPublicApi: workflowData.isPublicApi ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
const needsRedeployment = await checkNeedsRedeployment(id)
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
|
||||
|
||||
const responseApiKeyInfo = workflowData.workspaceId
|
||||
? 'Workspace API keys'
|
||||
: 'Personal API keys'
|
||||
|
||||
return createSuccessResponse({
|
||||
apiKey: responseApiKeyInfo,
|
||||
isDeployed: workflowData.isDeployed,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
needsRedeployment,
|
||||
isPublicApi: workflowData.isPublicApi ?? false,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)
|
||||
return createErrorResponse(error.message || 'Failed to fetch deployment information', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
const actorUserId: string | null = session?.user?.id ?? null
|
||||
if (!actorUserId) {
|
||||
logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`)
|
||||
return createErrorResponse('Unable to determine deploying user', 400)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
const result = await performFullDeploy({
|
||||
workflowId: id,
|
||||
userId: actorUserId,
|
||||
workflowName: workflowData!.name || undefined,
|
||||
requestId,
|
||||
request,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(
|
||||
result.error || 'Failed to deploy workflow',
|
||||
statusForOrchestrationError(result.errorCode)
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
captureServerEvent(
|
||||
actorUserId,
|
||||
'workflow_deployed',
|
||||
{ workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' },
|
||||
{
|
||||
groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined,
|
||||
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
const responseApiKeyInfo = workflowData!.workspaceId
|
||||
? 'Workspace API keys'
|
||||
: 'Personal API keys'
|
||||
|
||||
return createSuccessResponse({
|
||||
apiKey: responseApiKeyInfo,
|
||||
isDeployed: true,
|
||||
deployedAt: result.deployedAt,
|
||||
warnings: result.warnings,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
const message = getErrorMessage(error, 'Failed to deploy workflow')
|
||||
logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error })
|
||||
return createErrorResponse(message, 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(updatePublicApiContract, request, context, {
|
||||
validationErrorResponse: () =>
|
||||
createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const { isPublicApi } = parsed.data.body
|
||||
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
if (isPublicApi) {
|
||||
try {
|
||||
await validatePublicApiAllowed(session?.user?.id, workflowData?.workspaceId ?? undefined)
|
||||
} catch (err) {
|
||||
if (err instanceof PublicApiNotAllowedError) {
|
||||
return createErrorResponse('Public API access is disabled', 403)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
|
||||
|
||||
const wsId = workflowData?.workspaceId
|
||||
|
||||
recordAudit({
|
||||
workspaceId: wsId ?? null,
|
||||
actorId: session!.user.id,
|
||||
action: AuditAction.WORKFLOW_PUBLIC_API_TOGGLED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: id,
|
||||
resourceName: workflowData?.name ?? undefined,
|
||||
description: `${isPublicApi ? 'Enabled' : 'Disabled'} public API for workflow "${workflowData?.name ?? id}"`,
|
||||
metadata: { isPublicApi },
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session!.user.id,
|
||||
'workflow_public_api_toggled',
|
||||
{ workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi },
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
|
||||
return createSuccessResponse({ isPublicApi })
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
const message = getErrorMessage(error, 'Failed to update deployment settings')
|
||||
logger.error(`[${requestId}] Error updating deployment settings`, { error })
|
||||
return createErrorResponse(message, 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
const result = await performFullUndeploy({
|
||||
workflowId: id,
|
||||
userId: session!.user.id,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
|
||||
}
|
||||
|
||||
const wsId = workflowData?.workspaceId
|
||||
captureServerEvent(
|
||||
session!.user.id,
|
||||
'workflow_undeployed',
|
||||
{ workflow_id: id, workspace_id: wsId ?? '' },
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
|
||||
return createSuccessResponse({
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
apiKey: null,
|
||||
warnings: result.warnings,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
const message = getErrorMessage(error, 'Failed to undeploy workflow')
|
||||
logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error })
|
||||
return createErrorResponse(message, 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Tests for the workflow deployed-state API route.
|
||||
* Covers internal-JWT authorization (acting user required + workspace read
|
||||
* permission) and the unchanged session path.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import {
|
||||
workflowAuthzMockFns,
|
||||
workflowsPersistenceUtilsMock,
|
||||
workflowsPersistenceUtilsMockFns,
|
||||
workflowsUtilsMock,
|
||||
workflowsUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockVerifyInternalToken } = vi.hoisted(() => ({
|
||||
mockVerifyInternalToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/internal', () => ({
|
||||
verifyInternalToken: mockVerifyInternalToken,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
import { GET } from './route'
|
||||
|
||||
const mockAuthorizeWorkflowByWorkspacePermission =
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
|
||||
const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState
|
||||
const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions
|
||||
|
||||
const DEPLOYED_STATE = {
|
||||
blocks: { 'block-1': { id: 'block-1', type: 'starter' } },
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
variables: {},
|
||||
}
|
||||
|
||||
function createRequest(options?: { bearerToken?: string }) {
|
||||
const headers: Record<string, string> = {}
|
||||
if (options?.bearerToken) {
|
||||
headers.Authorization = `Bearer ${options.bearerToken}`
|
||||
}
|
||||
return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers })
|
||||
}
|
||||
|
||||
const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) })
|
||||
|
||||
describe('GET /api/workflows/[id]/deployed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockVerifyInternalToken.mockResolvedValue({ valid: false })
|
||||
mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE)
|
||||
})
|
||||
|
||||
describe('internal JWT path', () => {
|
||||
it('returns 200 when the token carries a user with read permission', async () => {
|
||||
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
|
||||
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.deployedState).toEqual(DEPLOYED_STATE)
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
action: 'read',
|
||||
})
|
||||
expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when the acting user lacks read permission', async () => {
|
||||
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to read this workflow',
|
||||
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
|
||||
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when the token carries no acting user (fail closed)', async () => {
|
||||
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined })
|
||||
|
||||
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Forbidden')
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
|
||||
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 404 when the workflow does not exist', async () => {
|
||||
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
message: 'Workflow not found',
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Workflow not found')
|
||||
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session path', () => {
|
||||
it('returns 200 when session permissions validate', async () => {
|
||||
mockValidateWorkflowPermissions.mockResolvedValue({
|
||||
error: null,
|
||||
session: { user: { id: 'user-123' } },
|
||||
workflow: { id: 'workflow-123' },
|
||||
})
|
||||
|
||||
const response = await GET(createRequest(), routeParams())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.deployedState).toEqual(DEPLOYED_STATE)
|
||||
expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith(
|
||||
'workflow-123',
|
||||
expect.any(String),
|
||||
'read'
|
||||
)
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates validateWorkflowPermissions errors unchanged', async () => {
|
||||
mockValidateWorkflowPermissions.mockResolvedValue({
|
||||
error: { message: 'Unauthorized', status: 401 },
|
||||
session: null,
|
||||
workflow: null,
|
||||
})
|
||||
|
||||
const response = await GET(createRequest(), routeParams())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('falls back to session validation when the bearer token is not a valid internal token', async () => {
|
||||
mockVerifyInternalToken.mockResolvedValue({ valid: false })
|
||||
mockValidateWorkflowPermissions.mockResolvedValue({
|
||||
error: { message: 'Unauthorized', status: 401 },
|
||||
session: null,
|
||||
workflow: null,
|
||||
})
|
||||
|
||||
const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockValidateWorkflowPermissions).toHaveBeenCalled()
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null deployedState when loading the snapshot fails', async () => {
|
||||
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment'))
|
||||
|
||||
const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.deployedState).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
|
||||
import type { NextRequest, NextResponse } from 'next/server'
|
||||
import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { verifyInternalToken } from '@/lib/auth/internal'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowDeployedStateAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function addNoCacheHeaders(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/workflows/[id]/deployed
|
||||
* Returns the active deployed state snapshot for a workflow.
|
||||
*
|
||||
* Internal (server-to-server) calls must carry the acting user in the internal
|
||||
* JWT payload (`generateInternalToken(userId)` — the executor's
|
||||
* `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that
|
||||
* user with the same workspace-read semantics as the sibling
|
||||
* `/api/workflows/[id]` route. Internal calls without a user id are rejected
|
||||
* (fail closed). Session calls are authorized via
|
||||
* `validateWorkflowPermissions` as before.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const parsed = await parseRequest(getDeployedWorkflowStateContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
let isInternalCall = false
|
||||
let internalCallUserId: string | undefined
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.split(' ')[1]
|
||||
const verification = await verifyInternalToken(token)
|
||||
isInternalCall = verification.valid
|
||||
internalCallUserId = verification.userId
|
||||
}
|
||||
|
||||
if (isInternalCall) {
|
||||
if (!internalCallUserId) {
|
||||
logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`)
|
||||
return addNoCacheHeaders(createErrorResponse('Forbidden', 403))
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: id,
|
||||
userId: internalCallUserId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow ${id} not found for internal call`)
|
||||
return addNoCacheHeaders(createErrorResponse('Workflow not found', 404))
|
||||
}
|
||||
if (!authorization.allowed) {
|
||||
logger.warn(
|
||||
`[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}`
|
||||
)
|
||||
return addNoCacheHeaders(
|
||||
createErrorResponse(authorization.message || 'Access denied', authorization.status)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
|
||||
if (error) {
|
||||
const response = createErrorResponse(error.message, error.status)
|
||||
return addNoCacheHeaders(response)
|
||||
}
|
||||
}
|
||||
|
||||
let deployedState = null
|
||||
try {
|
||||
const data = await loadDeployedWorkflowState(id)
|
||||
deployedState = {
|
||||
blocks: data.blocks,
|
||||
edges: data.edges,
|
||||
loops: data.loops,
|
||||
parallels: data.parallels,
|
||||
variables: data.variables,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error })
|
||||
deployedState = null
|
||||
}
|
||||
|
||||
const response = createSuccessResponse({ deployedState })
|
||||
return addNoCacheHeaders(response)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error)
|
||||
const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500)
|
||||
return addNoCacheHeaders(response)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { workflowDeploymentVersionParamSchema } from '@/lib/api/contracts/workflows'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { performRevertToVersion } from '@/lib/workflows/orchestration'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('RevertToDeploymentVersionAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id, version } = await params
|
||||
|
||||
try {
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowRecord,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
await assertWorkflowMutable(id)
|
||||
|
||||
const versionValidation = workflowDeploymentVersionParamSchema.safeParse(version)
|
||||
if (!versionValidation.success) {
|
||||
return createErrorResponse('Invalid version', 400)
|
||||
}
|
||||
|
||||
const result = await performRevertToVersion({
|
||||
workflowId: id,
|
||||
version: versionValidation.data,
|
||||
userId: session!.user.id,
|
||||
workflow: (workflowRecord ?? {}) as Record<string, unknown>,
|
||||
request,
|
||||
actorName: session!.user.name ?? undefined,
|
||||
actorEmail: session!.user.email ?? undefined,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return createErrorResponse(
|
||||
result.error || 'Failed to revert',
|
||||
result.errorCode === 'not_found' ? 404 : 500
|
||||
)
|
||||
}
|
||||
|
||||
return createSuccessResponse({
|
||||
message: 'Reverted to deployment version',
|
||||
lastSaved: result.lastSaved,
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
logger.error('Error reverting to deployment version', error)
|
||||
return createErrorResponse(error.message || 'Failed to revert', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
import { db, workflowDeploymentVersion } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { updateDeploymentVersionMetadataContract } from '@/lib/api/contracts/deployments'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performActivateVersion } from '@/lib/workflows/orchestration'
|
||||
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
|
||||
import {
|
||||
getWorkflowDeploymentVersion,
|
||||
updateDeploymentVersionMetadata,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowDeploymentVersionAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 120
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id, version } = await params
|
||||
|
||||
try {
|
||||
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
const versionNum = Number(version)
|
||||
if (!Number.isFinite(versionNum)) {
|
||||
return createErrorResponse('Invalid version', 400)
|
||||
}
|
||||
|
||||
const row = await getWorkflowDeploymentVersion(id, versionNum)
|
||||
if (!row?.state) {
|
||||
return createErrorResponse('Deployment version not found', 404)
|
||||
}
|
||||
|
||||
return createSuccessResponse({ deployedState: row.state })
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`[${requestId}] Error fetching deployment version ${version} for workflow ${id}`,
|
||||
error
|
||||
)
|
||||
return createErrorResponse(error.message || 'Failed to fetch deployment version', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(updateDeploymentVersionMetadataContract, request, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
createErrorResponse(getValidationErrorMessage(error, 'Invalid request body'), 400),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id, version } = parsed.data.params
|
||||
const { name, description, isActive } = parsed.data.body
|
||||
|
||||
// Activation requires admin permission, other updates require write
|
||||
const requiredPermission = isActive ? 'admin' : 'write'
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, requiredPermission)
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
const versionNum = version
|
||||
|
||||
// Handle activation
|
||||
if (isActive) {
|
||||
const actorUserId = session?.user?.id
|
||||
if (!actorUserId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`
|
||||
)
|
||||
return createErrorResponse('Unable to determine activating user', 400)
|
||||
}
|
||||
|
||||
const activateResult = await performActivateVersion({
|
||||
workflowId: id,
|
||||
version: versionNum,
|
||||
userId: actorUserId,
|
||||
workflow: workflowData as Record<string, unknown>,
|
||||
requestId,
|
||||
request,
|
||||
})
|
||||
|
||||
if (!activateResult.success) {
|
||||
return createErrorResponse(
|
||||
activateResult.error || 'Failed to activate deployment',
|
||||
statusForOrchestrationError(activateResult.errorCode)
|
||||
)
|
||||
}
|
||||
|
||||
let updatedName: string | null | undefined
|
||||
let updatedDescription: string | null | undefined
|
||||
if (name !== undefined || description !== undefined) {
|
||||
const activationUpdateData: { name?: string; description?: string | null } = {}
|
||||
if (name !== undefined) {
|
||||
activationUpdateData.name = name
|
||||
}
|
||||
if (description !== undefined) {
|
||||
activationUpdateData.description = description
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(workflowDeploymentVersion)
|
||||
.set(activationUpdateData)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
name: workflowDeploymentVersion.name,
|
||||
description: workflowDeploymentVersion.description,
|
||||
})
|
||||
|
||||
if (updated) {
|
||||
updatedName = updated.name
|
||||
updatedDescription = updated.description
|
||||
logger.info(
|
||||
`[${requestId}] Updated deployment version ${version} metadata during activation`,
|
||||
{ name: activationUpdateData.name, description: activationUpdateData.description }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId
|
||||
captureServerEvent(
|
||||
actorUserId,
|
||||
'deployment_version_activated',
|
||||
{ workflow_id: id, workspace_id: wsId ?? '', version: versionNum },
|
||||
wsId ? { groups: { workspace: wsId } } : undefined
|
||||
)
|
||||
|
||||
return createSuccessResponse({
|
||||
success: true,
|
||||
deployedAt: activateResult.deployedAt,
|
||||
warnings: activateResult.warnings,
|
||||
...(updatedName !== undefined && { name: updatedName }),
|
||||
...(updatedDescription !== undefined && { description: updatedDescription }),
|
||||
})
|
||||
}
|
||||
|
||||
// Handle name/description updates (shared with the update_deployment_version copilot tool)
|
||||
const updated = await updateDeploymentVersionMetadata({
|
||||
workflowId: id,
|
||||
version: versionNum,
|
||||
name,
|
||||
description,
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return createErrorResponse('Deployment version not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, {
|
||||
name,
|
||||
description,
|
||||
})
|
||||
|
||||
return createSuccessResponse({ name: updated.name, description: updated.description })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error updating deployment version`, error)
|
||||
return createErrorResponse(error.message || 'Failed to update deployment version', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { listDeploymentVersionsContract } from '@/lib/api/contracts/deployments'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowDeploymentsListAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const parsed = await parseRequest(listDeploymentVersionsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
try {
|
||||
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
const { versions: rows } = await listWorkflowVersions(id)
|
||||
const versions = rows.map(({ deployedByName, ...version }) => ({
|
||||
...version,
|
||||
deployedBy: deployedByName,
|
||||
}))
|
||||
|
||||
return createSuccessResponse({ versions })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error)
|
||||
return createErrorResponse(error.message || 'Failed to list deployments', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { duplicateWorkflowContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateAPI')
|
||||
|
||||
// POST /api/workflows/[id]/duplicate - Duplicate a workflow with all its blocks, edges, and subflows
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: sourceWorkflowId } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(duplicateWorkflowContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, description, workspaceId, folderId, newId } = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)
|
||||
|
||||
const result = await duplicateWorkflow({
|
||||
sourceWorkflowId,
|
||||
userId,
|
||||
name,
|
||||
description,
|
||||
workspaceId,
|
||||
folderId,
|
||||
requestId,
|
||||
newWorkflowId: newId,
|
||||
})
|
||||
|
||||
try {
|
||||
PlatformEvents.workflowDuplicated({
|
||||
sourceWorkflowId,
|
||||
newWorkflowId: result.id,
|
||||
workspaceId,
|
||||
})
|
||||
} catch {
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'workflow_duplicated',
|
||||
{
|
||||
source_workflow_id: sourceWorkflowId,
|
||||
new_workflow_id: result.id,
|
||||
workspace_id: workspaceId ?? '',
|
||||
},
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(
|
||||
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: workspaceId || null,
|
||||
actorId: userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.WORKFLOW_DUPLICATED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: result.id,
|
||||
resourceName: result.name,
|
||||
description: `Duplicated workflow from ${sourceWorkflowId}`,
|
||||
metadata: {
|
||||
sourceWorkflowId,
|
||||
newWorkflowId: result.id,
|
||||
folderId: folderId || undefined,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
if (error.message === 'Source workflow not found') {
|
||||
logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`)
|
||||
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (error.message === 'Source workflow not found or access denied') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied access to source workflow ${sourceWorkflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (error.message === 'Cross-workspace workflow duplication is not supported') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted cross-workspace workflow duplication for ${sourceWorkflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
if (error.message === 'Folder is locked') {
|
||||
return NextResponse.json({ error: error.message }, { status: 423 })
|
||||
}
|
||||
|
||||
if (error.message === 'Target folder not found') {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(
|
||||
`[${requestId}] Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`,
|
||||
error
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to duplicate workflow' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Tests that internal JWT callers receive the standard response format
|
||||
* even when the child workflow has a Response block.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AuthType } from '@/lib/auth/hybrid'
|
||||
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
|
||||
import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
|
||||
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
|
||||
import { storeLargeValue } from '@/lib/execution/payloads/store'
|
||||
import { EXECUTION_RESOURCE_LIMIT_CODE } from '@/lib/execution/resource-errors'
|
||||
import type { ExecutionResult } from '@/lib/workflows/types'
|
||||
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
|
||||
|
||||
const {
|
||||
mockAddLargeValueReference,
|
||||
mockDownloadFile,
|
||||
mockRegisterLargeValueOwner,
|
||||
mockUploadFile,
|
||||
uploadedFiles,
|
||||
mockIsWorkspaceApiExecutionEntitled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAddLargeValueReference: vi.fn(),
|
||||
mockDownloadFile: vi.fn(),
|
||||
mockRegisterLargeValueOwner: vi.fn(),
|
||||
mockUploadFile: vi.fn(),
|
||||
uploadedFiles: new Map<string, Buffer>(),
|
||||
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/api-access', () => ({
|
||||
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
|
||||
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
|
||||
}))
|
||||
|
||||
const MATERIALIZATION_CONTEXT = {
|
||||
workspaceId: 'workspace-1',
|
||||
workflowId: 'workflow-1',
|
||||
executionId: 'execution-1',
|
||||
userId: 'user-1',
|
||||
}
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
StorageService: {
|
||||
downloadFile: mockDownloadFile,
|
||||
uploadFile: mockUploadFile,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
|
||||
addLargeValueReference: mockAddLargeValueReference,
|
||||
registerLargeValueOwner: mockRegisterLargeValueOwner,
|
||||
}))
|
||||
|
||||
function buildExecutionResult(overrides: Partial<ExecutionResult> = {}): ExecutionResult {
|
||||
return {
|
||||
success: true,
|
||||
output: { data: { issues: [] }, status: 200, headers: {} },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'response-1',
|
||||
blockType: 'response',
|
||||
blockName: 'Response',
|
||||
success: true,
|
||||
output: { data: { issues: [] }, status: 200, headers: {} },
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
duration: 500,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Response block gating by auth type', () => {
|
||||
let resultWithResponseBlock: ExecutionResult
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearLargeValueCacheForTests()
|
||||
uploadedFiles.clear()
|
||||
mockAddLargeValueReference.mockResolvedValue(undefined)
|
||||
mockRegisterLargeValueOwner.mockResolvedValue(true)
|
||||
mockUploadFile.mockImplementation(async ({ customKey, file }) => {
|
||||
uploadedFiles.set(customKey, file)
|
||||
return { key: customKey }
|
||||
})
|
||||
mockDownloadFile.mockImplementation(
|
||||
async ({ key }) => uploadedFiles.get(key) ?? Buffer.from('{}')
|
||||
)
|
||||
resultWithResponseBlock = buildExecutionResult()
|
||||
})
|
||||
|
||||
it('should detect a Response block in execution result', () => {
|
||||
expect(workflowHasResponseBlock(resultWithResponseBlock)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect a Response block when none exists', () => {
|
||||
const resultWithoutResponseBlock = buildExecutionResult({
|
||||
output: { result: 'hello' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'agent-1',
|
||||
blockType: 'agent',
|
||||
blockName: 'Agent',
|
||||
success: true,
|
||||
output: { result: 'hello' },
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(workflowHasResponseBlock(resultWithoutResponseBlock)).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip Response block formatting for internal JWT callers', () => {
|
||||
const authType = AuthType.INTERNAL_JWT
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
|
||||
|
||||
expect(hasResponseBlock).toBe(true)
|
||||
|
||||
// This mirrors the route.ts condition:
|
||||
// if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(...))
|
||||
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
|
||||
expect(shouldFormatAsResponseBlock).toBe(false)
|
||||
})
|
||||
|
||||
it('should apply Response block formatting for API key callers', async () => {
|
||||
const authType = AuthType.API_KEY
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
|
||||
|
||||
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
|
||||
expect(shouldFormatAsResponseBlock).toBe(true)
|
||||
|
||||
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should apply Response block formatting for session callers', () => {
|
||||
const authType = AuthType.SESSION
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
|
||||
|
||||
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
|
||||
expect(shouldFormatAsResponseBlock).toBe(true)
|
||||
})
|
||||
|
||||
it('should return raw user data via createHttpResponseFromBlock', async () => {
|
||||
const response = await createHttpResponseFromBlock(resultWithResponseBlock)
|
||||
const body = await response.json()
|
||||
|
||||
// Response block returns the user-defined data directly (no success/executionId wrapper)
|
||||
expect(body).toEqual({ issues: [] })
|
||||
expect(body.success).toBeUndefined()
|
||||
expect(body.executionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should respect custom status codes from Response block', async () => {
|
||||
const result = buildExecutionResult({
|
||||
output: { data: { error: 'Not found' }, status: 404, headers: {} },
|
||||
})
|
||||
|
||||
const response = await createHttpResponseFromBlock(result)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should materialize manifest data for Response block HTTP output', async () => {
|
||||
const rows = Array.from({ length: 100 }, (_, index) => ({
|
||||
key: `SIM-${index}`,
|
||||
payload: 'x'.repeat(100),
|
||||
}))
|
||||
const output = await compactExecutionPayload(
|
||||
{
|
||||
data: { rows },
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
requireDurable: true,
|
||||
preserveRoot: true,
|
||||
thresholdBytes: 1024,
|
||||
}
|
||||
)
|
||||
const response = await createHttpResponseFromBlock(
|
||||
buildExecutionResult({ output }),
|
||||
MATERIALIZATION_CONTEXT
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.rows).toEqual(rows)
|
||||
expect(body.success).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should materialize Response block manifests from an allowed source execution', async () => {
|
||||
const rows = [{ key: 'SIM-1' }, { key: 'SIM-2' }]
|
||||
const manifest = await createLargeArrayManifest(rows, {
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'source-execution-1',
|
||||
})
|
||||
|
||||
const response = await createHttpResponseFromBlock(
|
||||
buildExecutionResult({
|
||||
output: {
|
||||
data: { rows: manifest },
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
largeValueExecutionIds: ['source-execution-1'],
|
||||
}
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.rows).toEqual(rows)
|
||||
})
|
||||
|
||||
it('should reject Response block manifests from non-source same-workflow executions', async () => {
|
||||
const manifest = await createLargeArrayManifest([{ key: 'SIM-stale' }], {
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'stale-execution-1',
|
||||
})
|
||||
|
||||
await expect(
|
||||
createHttpResponseFromBlock(
|
||||
buildExecutionResult({
|
||||
output: {
|
||||
data: { rows: manifest },
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
largeValueExecutionIds: ['source-execution-1'],
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Large execution value is not available in this execution')
|
||||
})
|
||||
|
||||
it('should materialize Response block manifests inherited by the source snapshot', async () => {
|
||||
const rows = [{ key: 'SIM-inherited' }]
|
||||
const manifest = await createLargeArrayManifest(rows, {
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'original-execution-1',
|
||||
})
|
||||
|
||||
const response = await createHttpResponseFromBlock(
|
||||
buildExecutionResult({
|
||||
output: {
|
||||
data: { rows: manifest },
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
largeValueExecutionIds: ['source-execution-1', 'original-execution-1'],
|
||||
}
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.rows).toEqual(rows)
|
||||
})
|
||||
|
||||
it('should recursively materialize refs inside Response block manifest rows', async () => {
|
||||
const text = 'nested'.repeat(2 * 1024 * 1024)
|
||||
const nestedOutput = await compactExecutionPayload(
|
||||
{ text },
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'original-execution-1',
|
||||
requireDurable: true,
|
||||
preserveRoot: true,
|
||||
}
|
||||
)
|
||||
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
|
||||
const manifest = await createLargeArrayManifest([{ nested: nestedRef }], {
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'source-execution-1',
|
||||
})
|
||||
const response = await createHttpResponseFromBlock(
|
||||
buildExecutionResult({
|
||||
output: {
|
||||
data: { rows: manifest },
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
largeValueExecutionIds: ['source-execution-1'],
|
||||
}
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.rows).toEqual([{ nested: text }])
|
||||
})
|
||||
|
||||
it('should recursively materialize refs inside stored Response block objects', async () => {
|
||||
const text = 'nested'.repeat(2 * 1024 * 1024)
|
||||
const nestedOutput = await compactExecutionPayload(
|
||||
{ text },
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'original-execution-1',
|
||||
requireDurable: true,
|
||||
preserveRoot: true,
|
||||
}
|
||||
)
|
||||
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
|
||||
const storedValue = {
|
||||
wrapper: {
|
||||
nested: nestedRef,
|
||||
padding: 'x'.repeat(2048),
|
||||
},
|
||||
}
|
||||
const storedJson = JSON.stringify(storedValue)
|
||||
const storedOutput = await storeLargeValue(
|
||||
storedValue,
|
||||
storedJson,
|
||||
Buffer.byteLength(storedJson),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'source-execution-1',
|
||||
requireDurable: true,
|
||||
}
|
||||
)
|
||||
|
||||
const response = await createHttpResponseFromBlock(
|
||||
buildExecutionResult({
|
||||
output: {
|
||||
data: storedOutput,
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
largeValueExecutionIds: ['source-execution-1'],
|
||||
}
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.wrapper.nested).toEqual(text)
|
||||
})
|
||||
|
||||
it('should memoize repeated materialized objects while resolving nested refs', async () => {
|
||||
const text = 'nested'.repeat(2 * 1024 * 1024)
|
||||
const nestedOutput = await compactExecutionPayload(
|
||||
{ text },
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'original-execution-1',
|
||||
requireDurable: true,
|
||||
preserveRoot: true,
|
||||
}
|
||||
)
|
||||
const nestedRef = (nestedOutput as unknown as { text: unknown }).text
|
||||
const sourceValue = { nested: nestedRef }
|
||||
const sourceJson = JSON.stringify(sourceValue)
|
||||
const sourceRef = await storeLargeValue(
|
||||
sourceValue,
|
||||
sourceJson,
|
||||
Buffer.byteLength(sourceJson),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
executionId: 'source-execution-1',
|
||||
requireDurable: true,
|
||||
}
|
||||
)
|
||||
|
||||
const response = await createHttpResponseFromBlock(
|
||||
buildExecutionResult({
|
||||
output: {
|
||||
data: { first: sourceRef, second: sourceRef },
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
}),
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
largeValueKeys: sourceRef.key ? [sourceRef.key] : [],
|
||||
}
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
expect(body).toEqual({
|
||||
first: { nested: text },
|
||||
second: { nested: text },
|
||||
})
|
||||
})
|
||||
|
||||
it('should materialize large string refs for Response block HTTP output', async () => {
|
||||
const text = 'x'.repeat(9 * 1024 * 1024)
|
||||
const output = await compactExecutionPayload(
|
||||
{
|
||||
data: { text },
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
requireDurable: true,
|
||||
preserveRoot: true,
|
||||
}
|
||||
)
|
||||
const response = await createHttpResponseFromBlock(
|
||||
buildExecutionResult({ output }),
|
||||
MATERIALIZATION_CONTEXT
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.text).toBe(text)
|
||||
})
|
||||
|
||||
it('should reject Response block HTTP output that is too large to inline', async () => {
|
||||
const output = await compactExecutionPayload(
|
||||
{
|
||||
data: {
|
||||
text: 'x'.repeat(17 * 1024 * 1024),
|
||||
},
|
||||
status: 200,
|
||||
headers: {},
|
||||
},
|
||||
{
|
||||
...MATERIALIZATION_CONTEXT,
|
||||
requireDurable: true,
|
||||
preserveRoot: true,
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
createHttpResponseFromBlock(buildExecutionResult({ output }), MATERIALIZATION_CONTEXT)
|
||||
).rejects.toMatchObject({
|
||||
code: EXECUTION_RESOURCE_LIMIT_CODE,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import {
|
||||
createMockRequest,
|
||||
executionPreprocessingMock,
|
||||
executionPreprocessingMockFns,
|
||||
hybridAuthMockFns,
|
||||
loggingSessionMock,
|
||||
requestUtilsMockFns,
|
||||
workflowAuthzMockFns,
|
||||
workflowsPersistenceUtilsMock,
|
||||
workflowsPersistenceUtilsMockFns,
|
||||
workflowsUtilsMock,
|
||||
workflowsUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockEnqueue,
|
||||
mockExecuteWorkflowCore,
|
||||
mockHandlePostExecutionPauseState,
|
||||
mockIsWorkspaceApiExecutionEntitled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
|
||||
mockExecuteWorkflowCore: vi.fn(),
|
||||
mockHandlePostExecutionPauseState: vi.fn(),
|
||||
mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/api-access', () => ({
|
||||
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required',
|
||||
isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled,
|
||||
}))
|
||||
|
||||
const mockCheckHybridAuth = hybridAuthMockFns.mockCheckHybridAuth
|
||||
const mockPreprocessExecution = executionPreprocessingMockFns.mockPreprocessExecution
|
||||
|
||||
const mockAuthorizeWorkflowByWorkspacePermission =
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock)
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
|
||||
|
||||
vi.mock('@/lib/workflows/executor/execution-core', () => ({
|
||||
executeWorkflowCore: mockExecuteWorkflowCore,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/executor/pause-persistence', () => ({
|
||||
handlePostExecutionPauseState: mockHandlePostExecutionPauseState,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/payloads/store', () => ({
|
||||
storeLargeValue: vi.fn(async (_value, _json, size: number) => ({
|
||||
__simLargeValueRef: true,
|
||||
version: 1,
|
||||
id: 'lv_abcdefghijkl',
|
||||
kind: 'string',
|
||||
size,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/async-jobs', () => ({
|
||||
getJobQueue: vi.fn().mockResolvedValue({
|
||||
enqueue: mockEnqueue,
|
||||
startJob: vi.fn(),
|
||||
completeJob: vi.fn(),
|
||||
markJobFailed: vi.fn(),
|
||||
}),
|
||||
shouldExecuteInline: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: vi.fn().mockReturnValue('http://localhost:3000'),
|
||||
getOllamaUrl: vi.fn().mockReturnValue('http://localhost:11434'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/call-chain', () => ({
|
||||
SIM_VIA_HEADER: 'x-sim-via',
|
||||
parseCallChain: vi.fn().mockReturnValue([]),
|
||||
validateCallChain: vi.fn().mockReturnValue(null),
|
||||
buildNextCallChain: vi.fn().mockReturnValue(['workflow-1']),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock)
|
||||
|
||||
vi.mock('@/background/workflow-execution', () => ({
|
||||
executeWorkflowJob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/utils/id', () => ({
|
||||
generateId: vi.fn(() => 'execution-123'),
|
||||
generateShortId: vi.fn(() => 'mock-short-id'),
|
||||
isValidUuid: vi.fn((v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)
|
||||
),
|
||||
}))
|
||||
|
||||
import { storeLargeValue } from '@/lib/execution/payloads/store'
|
||||
import { POST } from './route'
|
||||
|
||||
describe('workflow execute async route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-12345678')
|
||||
workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValue(false)
|
||||
hybridAuthMockFns.mockHasExternalApiCredentials.mockReturnValue(true)
|
||||
|
||||
mockCheckHybridAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'session-user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
workflow: {
|
||||
id: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
workspaceId: 'workspace-1',
|
||||
},
|
||||
})
|
||||
|
||||
mockPreprocessExecution.mockResolvedValue({
|
||||
success: true,
|
||||
actorUserId: 'actor-1',
|
||||
workflowRecord: {
|
||||
id: 'workflow-1',
|
||||
userId: 'owner-1',
|
||||
workspaceId: 'workspace-1',
|
||||
},
|
||||
})
|
||||
workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState.mockResolvedValue(null)
|
||||
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null)
|
||||
mockExecuteWorkflowCore.mockResolvedValue({
|
||||
success: true,
|
||||
status: 'completed',
|
||||
output: { ok: true },
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
})
|
||||
mockHandlePostExecutionPauseState.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('queues async execution with matching correlation metadata', async () => {
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Execution-Mode': 'async',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(202)
|
||||
expect(body.executionId).toBe('execution-123')
|
||||
expect(body.jobId).toBe('job-123')
|
||||
expect(mockEnqueue).toHaveBeenCalledWith(
|
||||
'workflow-execution',
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'actor-1',
|
||||
workspaceId: 'workspace-1',
|
||||
executionId: 'execution-123',
|
||||
executionMode: 'async',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'actor-1',
|
||||
workspaceId: 'workspace-1',
|
||||
correlation: expect.objectContaining({
|
||||
executionId: 'execution-123',
|
||||
requestId: 'req-12345678',
|
||||
source: 'workflow',
|
||||
workflowId: 'workflow-1',
|
||||
triggerType: 'manual',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects cross-site session requests before authorization work', async () => {
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(body.error).toBe('Access denied')
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
|
||||
expect(mockEnqueue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows same-site session requests (multi-subdomain Run, e.g. www.<domain>)', async () => {
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Execution-Mode': 'async',
|
||||
'Sec-Fetch-Site': 'same-site',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(202)
|
||||
expect(mockEnqueue).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects oversized request bodies before authorization work', async () => {
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': String(10 * 1024 * 1024 + 1),
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(body.error).toContain('Workflow execution request body')
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('authenticates before rejecting oversized request bodies', async () => {
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
authType: 'api_key',
|
||||
})
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': String(10 * 1024 * 1024 + 1),
|
||||
'X-API-Key': 'invalid',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
expect(mockCheckHybridAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 499 when a non-SSE execution is cancelled by client disconnect', async () => {
|
||||
const abortController = new AbortController()
|
||||
mockExecuteWorkflowCore.mockImplementationOnce(
|
||||
async ({ abortSignal }: { abortSignal: AbortSignal }) => {
|
||||
abortController.abort()
|
||||
expect(abortSignal.aborted).toBe(true)
|
||||
return {
|
||||
success: false,
|
||||
status: 'cancelled',
|
||||
output: { partial: true },
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-1/execute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ input: { hello: 'world' } }),
|
||||
signal: abortController.signal,
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(499)
|
||||
expect(body.error).toBe('Client cancelled request')
|
||||
})
|
||||
|
||||
it('rejects large MCP bridge outputs instead of returning large-value refs', async () => {
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'internal-user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
mockExecuteWorkflowCore.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 'completed',
|
||||
output: 'x'.repeat(10 * 1024 * 1024 + 1),
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
})
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Sim-MCP-Tool-Call': 'true',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
expect(body.error).toContain('Workflow execution response')
|
||||
expect(storeLargeValue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not trust client-spoofed MCP bridge headers on API key executions', async () => {
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'api-user-1',
|
||||
authType: 'api_key',
|
||||
apiKeyType: 'personal',
|
||||
})
|
||||
workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValueOnce(true)
|
||||
workflowsUtilsMockFns.mockCreateHttpResponseFromBlock.mockResolvedValueOnce(
|
||||
Response.json({ response: 'plain text body' })
|
||||
)
|
||||
mockExecuteWorkflowCore.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 'completed',
|
||||
output: { response: 'plain text body' },
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
})
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': 'valid',
|
||||
'X-Sim-MCP-Tool-Call': 'true',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toEqual({ response: 'plain text body' })
|
||||
expect(workflowsUtilsMockFns.mockCreateHttpResponseFromBlock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps trusted internal MCP bridge executions on the JSON envelope path', async () => {
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'internal-user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
workflowsUtilsMockFns.mockWorkflowHasResponseBlock.mockReturnValueOnce(true)
|
||||
mockExecuteWorkflowCore.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 'completed',
|
||||
output: { response: 'plain text body' },
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
})
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Sim-MCP-Tool-Call': 'true',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toMatchObject({
|
||||
success: true,
|
||||
output: { response: 'plain text body' },
|
||||
})
|
||||
expect(workflowsUtilsMockFns.mockCreateHttpResponseFromBlock).not.toHaveBeenCalled()
|
||||
expect(mockExecuteWorkflowCore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
snapshot: expect.objectContaining({
|
||||
input: { hello: 'world' },
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves authenticated-user actor semantics for trusted MCP bridge calls', async () => {
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'api-user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
mockExecuteWorkflowCore.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 'completed',
|
||||
output: { ok: true },
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
})
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
{ input: { hello: 'world' } },
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Sim-MCP-Tool-Call': 'true',
|
||||
'X-Sim-MCP-Tool-Actor': 'authenticated-user',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ id: 'workflow-1' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockPreprocessExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'api-user-1',
|
||||
useAuthenticatedUserAsActor: true,
|
||||
})
|
||||
)
|
||||
const executionCall = mockExecuteWorkflowCore.mock.calls[0][0]
|
||||
const snapshot =
|
||||
typeof executionCall.snapshot === 'string'
|
||||
? JSON.parse(executionCall.snapshot)
|
||||
: executionCall.snapshot
|
||||
expect(snapshot.metadata.enforceCredentialAccess).toBe(true)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Override global db mock with the configurable chain mock
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
const { mockValidateWorkflowAccess, mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({
|
||||
mockValidateWorkflowAccess: vi.fn(),
|
||||
mockGetWorkspaceBilledAccountUserId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/workflows/middleware', () => ({
|
||||
validateWorkflowAccess: mockValidateWorkflowAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/utils', () => ({
|
||||
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/execution/logging-session', () => ({
|
||||
LoggingSession: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
markAsFailed: vi.fn().mockResolvedValue(undefined),
|
||||
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
safeComplete: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
|
||||
buildTraceSpans: vi.fn().mockReturnValue([]),
|
||||
}))
|
||||
|
||||
import { POST } from './route'
|
||||
|
||||
const makeRequest = (workflowId: string, body: unknown) =>
|
||||
new NextRequest(`http://localhost/api/workflows/${workflowId}/log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const validResult = { success: true, output: { value: 42 } }
|
||||
|
||||
describe('POST /api/workflows/[id]/log cross-tenant guard', () => {
|
||||
const OWNER_WORKFLOW_ID = 'wf-owner'
|
||||
const ATTACKER_WORKFLOW_ID = 'wf-attacker'
|
||||
const VICTIM_EXECUTION_ID = 'exec-victim-uuid'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
mockValidateWorkflowAccess.mockResolvedValue({ error: null })
|
||||
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
|
||||
// Default: no existing log (fresh execution)
|
||||
dbChainMockFns.limit.mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('returns 404 when executionId belongs to a different workflow', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(ATTACKER_WORKFLOW_ID, {
|
||||
executionId: VICTIM_EXECUTION_ID,
|
||||
result: validResult,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: ATTACKER_WORKFLOW_ID }) }
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe('Execution not found')
|
||||
})
|
||||
|
||||
it('proceeds when executionId belongs to the same workflow', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(OWNER_WORKFLOW_ID, {
|
||||
executionId: VICTIM_EXECUTION_ID,
|
||||
result: validResult,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
|
||||
)
|
||||
|
||||
expect(res.status).not.toBe(404)
|
||||
expect(res.status).not.toBe(403)
|
||||
})
|
||||
|
||||
it('proceeds when executionId has no existing log row (fresh execution)', async () => {
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([])
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(OWNER_WORKFLOW_ID, {
|
||||
executionId: 'brand-new-execution-id',
|
||||
result: validResult,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
|
||||
)
|
||||
|
||||
expect(res.status).not.toBe(404)
|
||||
expect(res.status).not.toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { workflowLogContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
const logger = createLogger('WorkflowLogAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await context.params
|
||||
|
||||
try {
|
||||
const accessValidation = await validateWorkflowAccess(request, id, false)
|
||||
if (accessValidation.error) {
|
||||
logger.warn(
|
||||
`[${requestId}] Workflow access validation failed: ${accessValidation.error.message}`
|
||||
)
|
||||
return createErrorResponse(accessValidation.error.message, accessValidation.error.status)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(workflowLogContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { logs, executionId, result } = parsed.data.body
|
||||
|
||||
if (result) {
|
||||
if (!executionId) {
|
||||
logger.warn(`[${requestId}] Missing executionId for result logging`)
|
||||
return createErrorResponse('executionId is required when logging results', 400)
|
||||
}
|
||||
|
||||
const [existingLog] = await db
|
||||
.select({ workflowId: workflowExecutionLogs.workflowId })
|
||||
.from(workflowExecutionLogs)
|
||||
.where(eq(workflowExecutionLogs.executionId, executionId))
|
||||
.limit(1)
|
||||
|
||||
if (existingLog && existingLog.workflowId !== id) {
|
||||
logger.warn(
|
||||
`[${requestId}] executionId ${executionId} belongs to workflow ${existingLog.workflowId}, not ${id}`
|
||||
)
|
||||
return createErrorResponse('Execution not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, {
|
||||
executionId,
|
||||
success: result.success,
|
||||
})
|
||||
|
||||
const isChatExecution = result.metadata?.source === 'chat'
|
||||
|
||||
const triggerType = isChatExecution ? 'chat' : 'manual'
|
||||
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
|
||||
|
||||
const workspaceId = accessValidation.workflow.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
|
||||
if (!billedAccountUserId) {
|
||||
logger.error(
|
||||
`[${requestId}] Unable to resolve billed account for workspace ${workspaceId}`
|
||||
)
|
||||
return createErrorResponse('Unable to resolve billing account for this workspace', 500)
|
||||
}
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: billedAccountUserId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
const resultWithOutput = {
|
||||
...result,
|
||||
output: result.output ?? {},
|
||||
}
|
||||
|
||||
const { traceSpans, totalDuration } = buildTraceSpans(resultWithOutput as ExecutionResult)
|
||||
|
||||
if (result.success === false) {
|
||||
const message = result.error || 'Workflow run failed'
|
||||
await loggingSession.safeCompleteWithError({
|
||||
endedAt: new Date().toISOString(),
|
||||
totalDurationMs: totalDuration || result.metadata?.duration || 0,
|
||||
error: { message },
|
||||
traceSpans,
|
||||
})
|
||||
} else {
|
||||
await loggingSession.safeComplete({
|
||||
endedAt: new Date().toISOString(),
|
||||
totalDurationMs: totalDuration || result.metadata?.duration || 0,
|
||||
finalOutput: result.output || {},
|
||||
traceSpans,
|
||||
})
|
||||
}
|
||||
|
||||
return createSuccessResponse({
|
||||
message: 'Run logs persisted successfully',
|
||||
})
|
||||
}
|
||||
|
||||
if (!logs || !Array.isArray(logs) || logs.length === 0) {
|
||||
logger.warn(`[${requestId}] No logs provided for workflow: ${id}`)
|
||||
return createErrorResponse('No logs provided', 400)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Persisting ${logs.length} logs for workflow: ${id}`, {
|
||||
executionId,
|
||||
})
|
||||
|
||||
return createSuccessResponse({ message: 'Logs persisted successfully' })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error persisting logs for workflow: ${id}`, error)
|
||||
return createErrorResponse(error.message || 'Failed to persist logs', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { pausedWorkflowExecutionByIdContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string; executionId: string }> }
|
||||
) => {
|
||||
const parsed = await parseRequest(pausedWorkflowExecutionByIdContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workflowId, executionId } = parsed.data.params
|
||||
|
||||
const access = await validateWorkflowAccess(request, workflowId, false)
|
||||
if (access.error) {
|
||||
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
|
||||
}
|
||||
|
||||
const detail = await PauseResumeManager.getPausedExecutionDetail({
|
||||
workflowId,
|
||||
executionId,
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(detail)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { pausedWorkflowExecutionsContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const parsed = await parseRequest(pausedWorkflowExecutionsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workflowId } = parsed.data.params
|
||||
|
||||
const access = await validateWorkflowAccess(request, workflowId, false)
|
||||
if (access.error) {
|
||||
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
|
||||
}
|
||||
|
||||
const { status: statusFilter } = parsed.data.query
|
||||
|
||||
const pausedExecutions = await PauseResumeManager.listPausedExecutions({
|
||||
workflowId,
|
||||
status: statusFilter,
|
||||
})
|
||||
|
||||
return NextResponse.json({ pausedExecutions })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertFolderMutable,
|
||||
FolderLockedError,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { restoreWorkflowContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performRestoreWorkflow } from '@/lib/workflows/orchestration'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('RestoreWorkflowAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const parsed = await parseRequest(restoreWorkflowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: workflowId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workflowData = await getWorkflowById(workflowId, { includeArchived: true })
|
||||
if (!workflowData) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workflowData.workspaceId) {
|
||||
const permission = await getUserEntityPermissions(
|
||||
auth.userId,
|
||||
'workspace',
|
||||
workflowData.workspaceId
|
||||
)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
} else if (workflowData.userId !== auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (workflowData.locked) {
|
||||
throw new WorkflowLockedError('Workflow is locked')
|
||||
}
|
||||
await assertFolderMutable(workflowData.folderId)
|
||||
|
||||
const result = await performRestoreWorkflow({
|
||||
workflowId,
|
||||
userId: auth.userId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const status =
|
||||
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Restored workflow ${workflowId}`)
|
||||
|
||||
captureServerEvent(
|
||||
auth.userId,
|
||||
'workflow_restored',
|
||||
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
|
||||
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Internal server error') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,938 @@
|
||||
/**
|
||||
* Integration tests for workflow by ID API route
|
||||
* Tests the new centralized permissions system
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import {
|
||||
auditMock,
|
||||
envMock,
|
||||
hybridAuthMockFns,
|
||||
telemetryMock,
|
||||
workflowAuthzMockFns,
|
||||
workflowsOrchestrationMock,
|
||||
workflowsOrchestrationMockFns,
|
||||
workflowsPersistenceUtilsMock,
|
||||
workflowsPersistenceUtilsMockFns,
|
||||
workflowsUtilsMock,
|
||||
workflowsUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows'
|
||||
|
||||
const mockLoadWorkflowFromNormalizedTables =
|
||||
workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables
|
||||
const mockGetWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById
|
||||
const mockAuthorizeWorkflowByWorkspacePermission =
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
|
||||
const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow
|
||||
const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow
|
||||
|
||||
const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({
|
||||
mockDbUpdate: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
mockDbTransaction: vi.fn(),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Helper to set mock auth state consistently across getSession and hybrid auth.
|
||||
*/
|
||||
function mockGetSession(session: { user: { id: string } } | null) {
|
||||
if (session) {
|
||||
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: session.user.id,
|
||||
})
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: session.user.id,
|
||||
})
|
||||
} else {
|
||||
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValue({ success: false })
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => envMock)
|
||||
|
||||
vi.mock('@/lib/core/telemetry', () => telemetryMock)
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
update: () => mockDbUpdate(),
|
||||
select: () => mockDbSelect(),
|
||||
transaction: mockDbTransaction,
|
||||
},
|
||||
workflow: {},
|
||||
}))
|
||||
|
||||
import { DELETE, GET, PUT } from './route'
|
||||
|
||||
describe('Workflow By ID API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('mock-request-id-12345678'),
|
||||
})
|
||||
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(null)
|
||||
mockPerformUpdateWorkflow.mockImplementation(async (params) => ({
|
||||
success: true,
|
||||
workflow: {
|
||||
id: params.workflowId,
|
||||
name: params.name ?? params.currentName,
|
||||
description: params.description ?? null,
|
||||
workspaceId: params.workspaceId,
|
||||
folderId: params.folderId ?? params.currentFolderId ?? null,
|
||||
sortOrder: params.sortOrder ?? null,
|
||||
locked: params.locked ?? null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
archivedAt: null,
|
||||
},
|
||||
}))
|
||||
mockDbTransaction.mockImplementation(async (callback) =>
|
||||
callback({
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('GET /api/workflows/[id]', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
mockGetSession(null)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 404 when workflow does not exist', async () => {
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(null)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent')
|
||||
const params = Promise.resolve({ id: 'nonexistent' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Workflow not found')
|
||||
})
|
||||
|
||||
it.concurrent('should allow access when user has admin workspace permission', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.data.id).toBe('workflow-123')
|
||||
})
|
||||
|
||||
it('omits null workflow description from state metadata so response validates', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-null-description',
|
||||
userId: 'user-123',
|
||||
name: 'No Description Workflow',
|
||||
description: null,
|
||||
workspaceId: 'workspace-456',
|
||||
folderId: null,
|
||||
sortOrder: 0,
|
||||
color: '#3972F6',
|
||||
lastSynced: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
isPublicApi: false,
|
||||
locked: false,
|
||||
runCount: 0,
|
||||
lastRunAt: null,
|
||||
archivedAt: null,
|
||||
variables: {},
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue({
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-null-description')
|
||||
const params = Promise.resolve({ id: 'workflow-null-description' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.data.state.metadata).toEqual({ name: 'No Description Workflow' })
|
||||
expect(getWorkflowResponseDataSchema.safeParse(data.data).success).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should allow access when user has workspace permissions', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.data.id).toBe('workflow-123')
|
||||
})
|
||||
|
||||
it('should deny access when user has no workspace permissions', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to read this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
|
||||
})
|
||||
|
||||
it.concurrent('should use normalized tables when available', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
blocks: { 'block-1': { id: 'block-1', type: 'starter' } },
|
||||
edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.data.state.blocks).toEqual(mockNormalizedData.blocks)
|
||||
expect(data.data.state.edges).toEqual(mockNormalizedData.edges)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/workflows/[id]', () => {
|
||||
it('should allow admin to delete workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(true)
|
||||
expect(mockPerformDeleteWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow admin to delete workspace workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should prevent deletion of the last workflow in workspace', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockPerformDeleteWorkflow.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Cannot delete the only workflow in the workspace',
|
||||
errorCode: 'validation',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
|
||||
})
|
||||
|
||||
it('should allow user with write permission to delete workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
mockPerformDeleteWorkflow.mockResolvedValue({ success: true })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(true)
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowId: 'workflow-123', action: 'write' })
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should deny deletion for read-only users', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to write this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/workflows/[id]', () => {
|
||||
function mockDuplicateCheck(results: Array<{ id: string }> = []) {
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(results),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
it('should allow user with write permission to update workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const updateData = { name: 'Updated Workflow' }
|
||||
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
mockDuplicateCheck([])
|
||||
|
||||
mockDbUpdate.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.workflow.name).toBe('Updated Workflow')
|
||||
})
|
||||
|
||||
it('should allow users with write permission to update workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const updateData = { name: 'Updated Workflow' }
|
||||
const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() }
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
mockDuplicateCheck([])
|
||||
|
||||
mockDbUpdate.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.workflow.name).toBe('Updated Workflow')
|
||||
})
|
||||
|
||||
it('should deny update for users with only read permission', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const updateData = { name: 'Updated Workflow' }
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to write this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
|
||||
})
|
||||
|
||||
it.concurrent('should validate request data', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
const invalidData = { name: '' }
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(invalidData),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Validation error')
|
||||
})
|
||||
|
||||
it('should reject rename when duplicate name exists in same folder', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Original Name',
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
mockPerformUpdateWorkflow.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'A workflow named "Duplicate Name" already exists in this folder',
|
||||
errorCode: 'conflict',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: 'Duplicate Name' }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder')
|
||||
})
|
||||
|
||||
it('should reject rename when duplicate name exists at root level', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Original Name',
|
||||
folderId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
mockPerformUpdateWorkflow.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'A workflow named "Duplicate Name" already exists in this folder',
|
||||
errorCode: 'conflict',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: 'Duplicate Name' }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('A workflow named "Duplicate Name" already exists in this folder')
|
||||
})
|
||||
|
||||
it('should allow rename when no duplicate exists in same folder', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Original Name',
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const updatedWorkflow = { ...mockWorkflow, name: 'Unique Name', updatedAt: new Date() }
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
mockDuplicateCheck([])
|
||||
|
||||
mockDbUpdate.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: 'Unique Name' }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.workflow.name).toBe('Unique Name')
|
||||
})
|
||||
|
||||
it('should allow same name in different folders', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'My Workflow',
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() }
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
// No duplicate in target folder
|
||||
mockDuplicateCheck([])
|
||||
|
||||
mockDbUpdate.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ folderId: 'folder-2' }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.workflow.folderId).toBe('folder-2')
|
||||
})
|
||||
|
||||
it('should reject moving to a folder where same name already exists', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'My Workflow',
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
mockPerformUpdateWorkflow.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'A workflow named "My Workflow" already exists in this folder',
|
||||
errorCode: 'conflict',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ folderId: 'folder-2' }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('A workflow named "My Workflow" already exists in this folder')
|
||||
})
|
||||
|
||||
it('should skip duplicate check when only updating non-name/non-folder fields', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const updatedWorkflow = {
|
||||
...mockWorkflow,
|
||||
description: 'Updated description',
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
mockDbUpdate.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([updatedWorkflow]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ description: 'Updated description' }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// db.select should NOT have been called since no name/folder change
|
||||
expect(mockDbSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling', () => {
|
||||
it.concurrent('should handle database errors gracefully', async () => {
|
||||
mockGetSession({ user: { id: 'user-123' } })
|
||||
|
||||
mockGetWorkflowById.mockRejectedValue(new Error('Database connection timeout'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Internal server error')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertFolderMutable,
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
FolderLockedError,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkflowContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowByIdAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workflows/[id]
|
||||
* Fetch a single workflow by ID
|
||||
* Uses hybrid approach: try normalized tables first, fallback to JSON blob
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const isInternalCall = auth.authType === AuthType.INTERNAL_JWT
|
||||
const userId = auth.userId || null
|
||||
|
||||
let workflowData = await getWorkflowById(workflowId)
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is not authorized for this workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (isInternalCall && !userId) {
|
||||
// Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId.
|
||||
// These are already authenticated via internal JWT; allow read access.
|
||||
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
|
||||
} else if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
} else {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
workflowData = authorization.workflow
|
||||
if (!authorization.allowed) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`)
|
||||
const [normalizedData, [workflowRecord]] = await Promise.all([
|
||||
loadWorkflowFromNormalizedTables(workflowId, tx),
|
||||
tx.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1),
|
||||
])
|
||||
return { normalizedData, workflowRecord }
|
||||
})
|
||||
const responseWorkflowData = snapshot.workflowRecord ?? workflowData
|
||||
|
||||
// Stamp `workflowId` from the path param on each variable so the
|
||||
// global client-side variables store can filter by workflow without
|
||||
// requiring persisted variables to carry a redundant `workflowId`.
|
||||
// The persisted blob may or may not include `workflowId` depending on
|
||||
// when the variable was last written; the path param is authoritative.
|
||||
const persistedVariables =
|
||||
(responseWorkflowData.variables as Record<string, Record<string, unknown>>) || {}
|
||||
const stampedVariables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [variableId, variable] of Object.entries(persistedVariables)) {
|
||||
if (variable && typeof variable === 'object') {
|
||||
stampedVariables[variableId] = { ...variable, workflowId }
|
||||
}
|
||||
}
|
||||
const workflowStateMetadata = {
|
||||
name: responseWorkflowData.name,
|
||||
...(typeof responseWorkflowData.description === 'string'
|
||||
? { description: responseWorkflowData.description }
|
||||
: {}),
|
||||
}
|
||||
|
||||
if (snapshot.normalizedData) {
|
||||
const finalWorkflowData = {
|
||||
...responseWorkflowData,
|
||||
state: {
|
||||
blocks: snapshot.normalizedData.blocks,
|
||||
edges: snapshot.normalizedData.edges,
|
||||
loops: snapshot.normalizedData.loops,
|
||||
parallels: snapshot.normalizedData.parallels,
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: responseWorkflowData.isDeployed || false,
|
||||
deployedAt: responseWorkflowData.deployedAt,
|
||||
metadata: workflowStateMetadata,
|
||||
},
|
||||
variables: stampedVariables,
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
|
||||
|
||||
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
|
||||
}
|
||||
|
||||
const emptyWorkflowData = {
|
||||
...responseWorkflowData,
|
||||
state: {
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
lastSaved: Date.now(),
|
||||
isDeployed: responseWorkflowData.isDeployed || false,
|
||||
deployedAt: responseWorkflowData.deployedAt,
|
||||
metadata: workflowStateMetadata,
|
||||
},
|
||||
variables: stampedVariables,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: emptyWorkflowData }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/workflows/[id]
|
||||
* Delete a workflow by ID
|
||||
*/
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow || (await getWorkflowById(workflowId))
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const canDelete = authorization.allowed
|
||||
|
||||
if (!canDelete) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
const result = await performDeleteWorkflow({
|
||||
workflowId,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const status =
|
||||
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'workflow_deleted',
|
||||
{ workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' },
|
||||
workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined
|
||||
)
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/workflows/[id]
|
||||
* Update workflow metadata (name, description, folderId)
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
const { id: workflowId } = await context.params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
const parsed = await parseRequest(updateWorkflowContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const updates = parsed.data.body
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow || (await getWorkflowById(workflowId))
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const canUpdate = authorization.allowed
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to update workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (updates.locked !== undefined && authorization.workspacePermission !== 'admin') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to lock workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required to lock workflows' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked')
|
||||
if (hasNonLockUpdate) {
|
||||
await assertWorkflowMutable(workflowId)
|
||||
}
|
||||
if (updates.folderId !== undefined) {
|
||||
await assertFolderMutable(updates.folderId)
|
||||
}
|
||||
|
||||
if (!workflowData.workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
const result = await performUpdateWorkflow({
|
||||
workflowId,
|
||||
userId,
|
||||
workspaceId: workflowData.workspaceId,
|
||||
currentName: workflowData.name,
|
||||
currentFolderId: workflowData.folderId,
|
||||
currentLocked: workflowData.locked,
|
||||
...updates,
|
||||
requestId,
|
||||
})
|
||||
|
||||
if (!result.success || !result.workflow) {
|
||||
const status =
|
||||
result.errorCode === 'not_found'
|
||||
? 404
|
||||
: result.errorCode === 'conflict'
|
||||
? 409
|
||||
: result.errorCode === 'validation'
|
||||
? 400
|
||||
: 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, {
|
||||
updates,
|
||||
})
|
||||
|
||||
return NextResponse.json({ workflow: result.workflow }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,324 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { putWorkflowNormalizedStateContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getSocketServerUrl } from '@/lib/core/utils/urls'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
|
||||
import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
saveWorkflowToNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
|
||||
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
const logger = createLogger('WorkflowStateAPI')
|
||||
|
||||
/**
|
||||
* GET /api/workflows/[id]/state
|
||||
* Fetch the current workflow state from normalized tables.
|
||||
* Used by the client after server-side edits (edit_workflow) to stay in sync.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: auth.userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const snapshot = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`)
|
||||
const [normalized, [workflowRecord]] = await Promise.all([
|
||||
loadWorkflowFromNormalizedTables(workflowId, tx),
|
||||
tx
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1),
|
||||
])
|
||||
return { normalized, variables: workflowRecord?.variables }
|
||||
})
|
||||
|
||||
if (!snapshot.normalized) {
|
||||
return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Stamp `workflowId` from the path param on each variable so the
|
||||
// global client-side variables store can filter by workflow without
|
||||
// requiring clients to thread the path param through. The read
|
||||
// contract requires this server-stamped field.
|
||||
const persistedVariables =
|
||||
(snapshot.variables as Record<string, Record<string, unknown>>) || {}
|
||||
const variables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [variableId, variable] of Object.entries(persistedVariables)) {
|
||||
if (variable && typeof variable === 'object') {
|
||||
variables[variableId] = { ...variable, workflowId }
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
blocks: snapshot.normalized.blocks,
|
||||
edges: snapshot.normalized.edges,
|
||||
loops: snapshot.normalized.loops || {},
|
||||
parallels: snapshot.normalized.parallels || {},
|
||||
variables,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflow state', {
|
||||
workflowId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/workflows/[id]/state
|
||||
* Save complete workflow state to normalized database tables
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
const { id: workflowId } = await context.params
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const parsed = await parseRequest(putWorkflowNormalizedStateContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const state = parsed.data.body
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const canUpdate = authorization.allowed
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to update workflow state ${workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
// Note: prior versions cross-checked that each variable's `workflowId`
|
||||
// equalled the path param. The write contract does not carry `workflowId`
|
||||
// per variable (the path param is the source of truth), so the check
|
||||
// is unreachable and was removed.
|
||||
|
||||
// Sanitize custom tools in agent blocks before saving
|
||||
const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(
|
||||
state.blocks as Record<string, BlockState>
|
||||
)
|
||||
|
||||
// Save to normalized tables
|
||||
// Ensure all required fields are present for WorkflowState type
|
||||
// Filter out blocks without type or name before saving
|
||||
const filteredBlocks = Object.entries(sanitizedBlocks).reduce(
|
||||
(acc, [blockId, block]: [string, BlockState]) => {
|
||||
if (block.type && block.name) {
|
||||
// Ensure all required fields are present
|
||||
acc[blockId] = {
|
||||
...block,
|
||||
enabled: block.enabled !== undefined ? block.enabled : true,
|
||||
horizontalHandles:
|
||||
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
|
||||
height: block.height !== undefined ? block.height : 0,
|
||||
subBlocks: block.subBlocks || {},
|
||||
outputs: block.outputs || {},
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as typeof state.blocks
|
||||
)
|
||||
|
||||
const typedBlocks = filteredBlocks as Record<string, BlockState>
|
||||
const validatedEdges = validateEdges(state.edges as WorkflowState['edges'], typedBlocks)
|
||||
const validationWarnings = validatedEdges.dropped.map(
|
||||
({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`
|
||||
)
|
||||
const canonicalLoops = generateLoopBlocks(typedBlocks)
|
||||
const canonicalParallels = generateParallelBlocks(typedBlocks)
|
||||
|
||||
const workflowState = {
|
||||
blocks: filteredBlocks,
|
||||
edges: validatedEdges.valid,
|
||||
loops: canonicalLoops,
|
||||
parallels: canonicalParallels,
|
||||
lastSaved: state.lastSaved || Date.now(),
|
||||
isDeployed: state.isDeployed || false,
|
||||
deployedAt: state.deployedAt,
|
||||
}
|
||||
|
||||
const saveResult = await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
.for('update')
|
||||
|
||||
const result = await saveWorkflowToNormalizedTables(
|
||||
workflowId,
|
||||
workflowState as WorkflowState,
|
||||
tx
|
||||
)
|
||||
|
||||
if (!result.success) return result
|
||||
|
||||
// Update workflow's lastSynced timestamp and variables if provided
|
||||
const updateData: {
|
||||
lastSynced: Date
|
||||
updatedAt: Date
|
||||
variables?: typeof state.variables
|
||||
} = {
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// If variables are provided in the state, update them in the workflow record
|
||||
if (state.variables !== undefined) {
|
||||
updateData.variables = state.variables
|
||||
}
|
||||
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
if (!saveResult.success) {
|
||||
logger.error(
|
||||
`[${requestId}] Failed to save workflow ${workflowId} state:`,
|
||||
saveResult.error
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save workflow state', details: saveResult.error },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Extract and persist custom tools to database
|
||||
try {
|
||||
const workspaceId = workflowData.workspaceId
|
||||
if (workspaceId) {
|
||||
const { saved, errors } = await extractAndPersistCustomTools(
|
||||
workflowState,
|
||||
workspaceId,
|
||||
userId
|
||||
)
|
||||
|
||||
if (saved > 0) {
|
||||
logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, {
|
||||
workflowId,
|
||||
})
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn(`[${requestId}] Some custom tools failed to persist`, {
|
||||
errors,
|
||||
workflowId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${requestId}] Workflow has no workspaceId, skipping custom tools persistence`,
|
||||
{
|
||||
workflowId,
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`)
|
||||
|
||||
try {
|
||||
const notifyResponse = await fetch(`${getSocketServerUrl()}/api/workflow-updated`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': env.INTERNAL_API_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ workflowId }),
|
||||
})
|
||||
|
||||
if (!notifyResponse.ok) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to notify Socket.IO server about workflow ${workflowId} update`
|
||||
)
|
||||
}
|
||||
} catch (notificationError) {
|
||||
logger.warn(
|
||||
`[${requestId}] Error notifying Socket.IO server about workflow ${workflowId} update`,
|
||||
notificationError
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, warnings: [...warnings, ...validationWarnings] },
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(
|
||||
`[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`,
|
||||
error
|
||||
)
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getWorkflowStatusContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import {
|
||||
checkNeedsRedeployment,
|
||||
createErrorResponse,
|
||||
createSuccessResponse,
|
||||
} from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowStatusAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const parsed = await parseRequest(getWorkflowStatusContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id } = parsed.data.params
|
||||
|
||||
try {
|
||||
const validation = await validateWorkflowAccess(request, id, false)
|
||||
if (validation.error) {
|
||||
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
|
||||
return createErrorResponse(validation.error.message, validation.error.status)
|
||||
}
|
||||
|
||||
const needsRedeployment = validation.workflow.isDeployed
|
||||
? await checkNeedsRedeployment(id)
|
||||
: false
|
||||
|
||||
return createSuccessResponse({
|
||||
isDeployed: validation.workflow.isDeployed,
|
||||
deployedAt: validation.workflow.deployedAt,
|
||||
isPublished: validation.workflow.isPublished,
|
||||
needsRedeployment,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting status for workflow: ${id}`, error)
|
||||
return createErrorResponse('Failed to get status', 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Tests for workflow variables API route
|
||||
* Tests the optimized permissions and caching system
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
hybridAuthMockFns,
|
||||
workflowAuthzMockFns,
|
||||
workflowsUtilsMock,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getWorkflowVariablesContract } from '@/lib/api/contracts/workflows'
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
import { GET, POST } from '@/app/api/workflows/[id]/variables/route'
|
||||
|
||||
describe('Workflow Variables API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/workflows/[id]/variables', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Authentication required',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 404 when workflow does not exist', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
message: 'Workflow not found',
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent/variables')
|
||||
const params = Promise.resolve({ id: 'nonexistent' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Workflow not found')
|
||||
})
|
||||
|
||||
it('should allow access when user has workspace permission', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {
|
||||
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
|
||||
},
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.data).toEqual({
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
workflowId: 'workflow-123',
|
||||
},
|
||||
})
|
||||
const parsed = getWorkflowVariablesContract.response.schema.parse(data)
|
||||
expect(parsed.data['var-1'].workflowId).toBe('workflow-123')
|
||||
})
|
||||
|
||||
it('should allow access when user has workspace permissions', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {
|
||||
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
|
||||
},
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
// GET stamps `workflowId` from the path param on each variable.
|
||||
expect(data.data).toEqual({
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
workflowId: 'workflow-123',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should deny access when user has no workspace permissions', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {},
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to read this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
|
||||
})
|
||||
|
||||
it('should include proper cache headers', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {
|
||||
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
|
||||
},
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Cache-Control')).toBe('max-age=30, stale-while-revalidate=300')
|
||||
expect(response.headers.get('ETag')).toMatch(/^"variables-workflow-123-\d+"$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/workflows/[id]/variables', () => {
|
||||
it('should allow user with write permission to update variables', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {},
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ variables }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny access for users without permissions', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'other-user',
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {},
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to write this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'test',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
},
|
||||
}
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ variables }),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
|
||||
})
|
||||
|
||||
it('should validate request data schema', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {},
|
||||
}
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
const invalidData = { variables: [{ name: 'test' }] }
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(invalidData),
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Validation error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle database errors gracefully', async () => {
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce(
|
||||
new Error('Database connection failed')
|
||||
)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Database connection failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
assertWorkflowMutable,
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
WorkflowLockedError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workflowVariablesContract } from '@/lib/api/contracts/workflows'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
|
||||
const logger = createLogger('WorkflowVariablesAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workflowId = (await context.params).id
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
const isAuthorized = authorization.allowed
|
||||
|
||||
if (!isAuthorized) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to update variables for workflow ${workflowId} without permission`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await assertWorkflowMutable(workflowId)
|
||||
|
||||
const parsed = await parseRequest(workflowVariablesContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { variables } = parsed.data.body
|
||||
// Note: prior versions cross-checked that each variable's `workflowId`
|
||||
// equalled the path param. The write contract does not carry `workflowId`
|
||||
// per variable (the path param is the source of truth), so the check
|
||||
// is unreachable and was removed.
|
||||
|
||||
// Variables are already in Record format - use directly
|
||||
// The frontend is the source of truth for what variables should exist
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
variables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
recordAudit({
|
||||
workspaceId: workflowData.workspaceId ?? null,
|
||||
actorId: userId,
|
||||
actorName: auth.userName,
|
||||
actorEmail: auth.userEmail,
|
||||
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: workflowId,
|
||||
resourceName: workflowData.name ?? undefined,
|
||||
description: `Updated workflow variables`,
|
||||
metadata: {
|
||||
variableCount: Object.keys(variables).length,
|
||||
variableNames: Object.values(variables).map((v) => v.name),
|
||||
workflowName: workflowData.name ?? undefined,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating workflow variables`, error)
|
||||
return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workflowId = (await params).id
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
const isAuthorized = authorization.allowed
|
||||
|
||||
if (!isAuthorized) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to access variables for workflow ${workflowId} without permission`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Return variables if they exist. Stamp `workflowId` from the path
|
||||
// param on each entry so the global client-side variables store can
|
||||
// filter by workflow; the read contract requires this stamped field.
|
||||
const persistedVariables =
|
||||
(workflowData.variables as Record<string, Record<string, unknown>>) || {}
|
||||
const variables: Record<string, Variable> = {}
|
||||
for (const [variableId, variable] of Object.entries(persistedVariables)) {
|
||||
if (variable && typeof variable === 'object') {
|
||||
variables[variableId] = { ...variable, workflowId } as Variable
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache headers to prevent frequent reloading
|
||||
const variableHash = JSON.stringify(variables).length
|
||||
const headers = new Headers({
|
||||
'Cache-Control': 'max-age=30, stale-while-revalidate=300', // Cache for 30 seconds, stale for 5 min
|
||||
ETag: `"variables-${workflowId}-${variableHash}"`,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ data: variables },
|
||||
{
|
||||
status: 200,
|
||||
headers,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Workflow variables fetch error`, error)
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error')
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user