refactor(deployments): consolidate version reads, status mapping, and v1 auth prologue (#5013)

* refactor(deployments): consolidate version-list reads onto listWorkflowVersions

The UI deployments list route and the mothership get_deployment_log handler
each had their own inline version query; both now consume the shared
persistence helper, so every deployment surface (UI, v1, admin, tools,
mothership) reads versions through one code path.

* refactor(deployments): shared status mapper, single-version fetch, and v1 workflow resolver

- statusForOrchestrationError maps orchestration error codes to HTTP statuses
  in one place (was an identical ternary in six routes: UI deploy/activate,
  v1 deploy/rollback, tool deploy/promote)
- getWorkflowDeploymentVersion consolidates the single-version fetch used by
  the UI version GET and the deployments tool version route
- resolveV1DeploymentWorkflow extracts the v1 mutation prologue (active-record
  load, admin permission check, 404 masking) shared by deploy, undeploy, and
  rollback
This commit is contained in:
Waleed
2026-06-12 17:12:06 -07:00
committed by GitHub
parent 58cff68b5e
commit cd324d5657
14 changed files with 199 additions and 200 deletions
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performFullDeploy } from '@/lib/workflows/orchestration'
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import {
authenticateDeploymentToolRequest,
authorizeDeploymentWorkflow,
@@ -58,9 +59,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (!result.success) {
const status =
result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500
return deploymentToolError(result.error || 'Failed to deploy workflow', status)
return deploymentToolError(
result.error || 'Failed to deploy workflow',
statusForOrchestrationError(result.errorCode)
)
}
return NextResponse.json({
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import {
authenticateDeploymentToolRequest,
authorizeDeploymentWorkflow,
@@ -58,9 +59,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return deploymentToolError(result.error || 'Failed to promote deployment version', status)
return deploymentToolError(
result.error || 'Failed to promote deployment version',
statusForOrchestrationError(result.errorCode)
)
}
return NextResponse.json({
@@ -5,7 +5,6 @@
* session/internal auth, workspace permission enforcement, and the mapping of
* orchestration results to tool responses.
*/
import { db } from '@sim/db'
import { createMockRequest, hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing'
import { WorkflowLockedError } from '@sim/workflow-authz'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -16,12 +15,14 @@ const {
mockPerformFullUndeploy,
mockPerformActivateVersion,
mockListWorkflowVersions,
mockGetWorkflowDeploymentVersion,
} = vi.hoisted(() => ({
mockEnforceUserRateLimit: vi.fn(),
mockPerformFullDeploy: vi.fn(),
mockPerformFullUndeploy: vi.fn(),
mockPerformActivateVersion: vi.fn(),
mockListWorkflowVersions: vi.fn(),
mockGetWorkflowDeploymentVersion: vi.fn(),
}))
vi.mock('@/lib/core/rate-limiter', () => ({
@@ -36,6 +37,7 @@ vi.mock('@/lib/workflows/orchestration', () => ({
vi.mock('@/lib/workflows/persistence/utils', () => ({
listWorkflowVersions: mockListWorkflowVersions,
getWorkflowDeploymentVersion: mockGetWorkflowDeploymentVersion,
}))
import { POST as deployPost } from '@/app/api/tools/deployments/deploy/route'
@@ -313,26 +315,16 @@ describe('GET /api/tools/deployments/versions', () => {
})
describe('GET /api/tools/deployments/version', () => {
function mockVersionRow(rows: unknown[]) {
vi.mocked(db.select).mockReturnValueOnce({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: vi.fn(() => Promise.resolve(rows)) })),
})),
} as never)
}
it('returns version metadata and the deployed state', async () => {
mockVersionRow([
{
id: 'v-3',
version: 3,
name: 'Release 3',
description: null,
isActive: false,
createdAt: '2026-06-12T00:00:00.000Z',
state: { blocks: {}, edges: [] },
},
])
mockGetWorkflowDeploymentVersion.mockResolvedValue({
id: 'v-3',
version: 3,
name: 'Release 3',
description: null,
isActive: false,
createdAt: '2026-06-12T00:00:00.000Z',
state: { blocks: {}, edges: [] },
})
const response = await getVersionGet(
makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=3`)
@@ -352,7 +344,7 @@ describe('GET /api/tools/deployments/version', () => {
})
it('returns 404 when the version does not exist', async () => {
mockVersionRow([])
mockGetWorkflowDeploymentVersion.mockResolvedValue(null)
const response = await getVersionGet(
makeGet('version', `workflowId=${WORKFLOW_ID}&workspaceId=ws-1&version=9`)
@@ -1,12 +1,10 @@
import { db } from '@sim/db'
import { workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deploymentsGetVersionContract } from '@/lib/api/contracts/tools/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 { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils'
import {
authenticateDeploymentToolRequest,
authorizeDeploymentWorkflow,
@@ -41,25 +39,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const access = await authorizeDeploymentWorkflow(auth.userId, workflowId, workspaceId, 'read')
if (!access.ok) return access.response
const [row] = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
const row = await getWorkflowDeploymentVersion(workflowId, version)
if (!row) {
return deploymentToolError('Deployment version not found', 404)
}
@@ -1,10 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
assertWorkflowMutable,
getActiveWorkflowRecord,
WorkflowLockedError,
} from '@sim/workflow-authz'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1DeployWorkflowBodySchema,
@@ -16,12 +12,10 @@ 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 { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
const logger = createLogger('V1WorkflowDeployAPI')
@@ -55,16 +49,9 @@ export const POST = withRouteHandler(
return validationErrorResponse(body.error)
}
const workflowData = await getActiveWorkflowRecord(id)
if (!workflowData?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const workspaceId = workflowData.workspaceId
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin')
if (accessError) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
if (!target.ok) return target.response
const { workflow, workspaceId } = target
await assertWorkflowMutable(id)
@@ -73,7 +60,7 @@ export const POST = withRouteHandler(
const result = await performFullDeploy({
workflowId: id,
userId,
workflowName: workflowData.name || undefined,
workflowName: workflow.name || undefined,
versionName: body.data.name,
versionDescription: body.data.description ?? undefined,
requestId,
@@ -81,9 +68,10 @@ export const POST = withRouteHandler(
})
if (!result.success) {
const status =
result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500
return NextResponse.json({ error: result.error || 'Failed to deploy workflow' }, { status })
return NextResponse.json(
{ error: result.error || 'Failed to deploy workflow' },
{ status: statusForOrchestrationError(result.errorCode) }
)
}
captureServerEvent(
@@ -142,18 +130,11 @@ export const DELETE = withRouteHandler(
const { id } = parsed.data.params
const workflowData = await getActiveWorkflowRecord(id)
if (!workflowData?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const workspaceId = workflowData.workspaceId
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
if (!target.ok) return target.response
const { workflow, workspaceId } = target
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin')
if (accessError) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (!workflowData.isDeployed) {
if (!workflow.isDeployed) {
return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 })
}
@@ -1,10 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
assertWorkflowMutable,
getActiveWorkflowRecord,
WorkflowLockedError,
} from '@sim/workflow-authz'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1RollbackWorkflowBodySchema,
@@ -15,13 +11,11 @@ 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 { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
const logger = createLogger('V1WorkflowRollbackAPI')
@@ -55,18 +49,11 @@ export const POST = withRouteHandler(
return validationErrorResponse(body.error)
}
const workflowData = await getActiveWorkflowRecord(id)
if (!workflowData?.workspaceId) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const workspaceId = workflowData.workspaceId
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
if (!target.ok) return target.response
const { workflow, workspaceId } = target
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'admin')
if (accessError) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
if (!workflowData.isDeployed) {
if (!workflow.isDeployed) {
return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 })
}
@@ -94,17 +81,15 @@ export const POST = withRouteHandler(
workflowId: id,
version: targetVersion,
userId,
workflow: workflowData as Record<string, unknown>,
workflow: workflow as Record<string, unknown>,
requestId,
request,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
return NextResponse.json(
{ error: result.error || 'Failed to roll back workflow' },
{ status }
{ status: statusForOrchestrationError(result.errorCode) }
)
}
+39
View File
@@ -0,0 +1,39 @@
import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/workflow-authz'
import { NextResponse } from 'next/server'
import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware'
function workflowNotFoundResponse(): NextResponse {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
/**
* Resolves the target workflow for a v1 deployment mutation: loads the active
* record and verifies the caller's admin permission on its workspace. Access
* failures are masked as 404, matching the v1 workflow read surface so
* unauthorized callers cannot probe workflow existence.
*/
export async function resolveV1DeploymentWorkflow(
rateLimit: RateLimitResult,
userId: string,
workflowId: string
): Promise<
| { ok: true; workflow: ActiveWorkflowRecord; workspaceId: string }
| { ok: false; response: NextResponse }
> {
const workflow = await getActiveWorkflowRecord(workflowId)
if (!workflow?.workspaceId) {
return { ok: false, response: workflowNotFoundResponse() }
}
const accessError = await validateWorkspaceAccess(
rateLimit,
userId,
workflow.workspaceId,
'admin'
)
if (accessError) {
return { ok: false, response: workflowNotFoundResponse() }
}
return { ok: true, workflow, workspaceId: workflow.workspaceId }
}
@@ -10,6 +10,7 @@ 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,
@@ -106,9 +107,10 @@ export const POST = withRouteHandler(
})
if (!result.success) {
const status =
result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500
return createErrorResponse(result.error || 'Failed to deploy workflow', status)
return createErrorResponse(
result.error || 'Failed to deploy workflow',
statusForOrchestrationError(result.errorCode)
)
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
@@ -8,7 +8,11 @@ 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 { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils'
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'
@@ -37,17 +41,7 @@ export const GET = withRouteHandler(
return createErrorResponse('Invalid version', 400)
}
const [row] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
const row = await getWorkflowDeploymentVersion(id, versionNum)
if (!row?.state) {
return createErrorResponse('Deployment version not found', 404)
}
@@ -110,15 +104,9 @@ export const PATCH = withRouteHandler(
})
if (!activateResult.success) {
const status =
activateResult.errorCode === 'not_found'
? 404
: activateResult.errorCode === 'validation'
? 400
: 500
return createErrorResponse(
activateResult.error || 'Failed to activate deployment',
status
statusForOrchestrationError(activateResult.errorCode)
)
}
@@ -1,11 +1,10 @@
import { db, user, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { desc, eq } from 'drizzle-orm'
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'
@@ -27,25 +26,10 @@ export const GET = withRouteHandler(
return createErrorResponse(error.message, error.status)
}
const rawVersions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
createdBy: workflowDeploymentVersion.createdBy,
deployedBy: user.name,
})
.from(workflowDeploymentVersion)
.leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id))
.where(eq(workflowDeploymentVersion.workflowId, id))
.orderBy(desc(workflowDeploymentVersion.version))
const versions = rawVersions.map((v) => ({
...v,
deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
const { versions: rows } = await listWorkflowVersions(id)
const versions = rows.map(({ deployedByName, ...version }) => ({
...version,
deployedBy: deployedByName,
}))
return createSuccessResponse({ versions })
@@ -14,10 +14,12 @@ const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() =
const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion
const performActivateVersionMock = workflowsOrchestrationMockFns.mockPerformActivateVersion
const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock } = vi.hoisted(() => ({
resolveWorkflowStateRefMock: vi.fn(),
generateWorkflowDiffSummaryMock: vi.fn(),
}))
const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock, listWorkflowVersionsMock } =
vi.hoisted(() => ({
resolveWorkflowStateRefMock: vi.fn(),
generateWorkflowDiffSummaryMock: vi.fn(),
listWorkflowVersionsMock: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
@@ -72,6 +74,11 @@ vi.mock('@/app/api/workflows/utils', () => ({
checkNeedsRedeployment: checkNeedsRedeploymentMock,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
listWorkflowVersions: listWorkflowVersionsMock,
updateDeploymentVersionMetadata: vi.fn(),
}))
import { db } from '@sim/db'
import {
executeCheckDeploymentStatus,
@@ -216,34 +223,38 @@ describe('executeGetDeploymentLog', () => {
})
})
it('returns versions from the deployment-version table', async () => {
const rows = [
{
id: 'v2',
version: 2,
name: null,
description: null,
isActive: true,
createdAt: new Date('2026-05-30T00:00:00.000Z'),
createdBy: 'user-1',
},
{
id: 'v1',
version: 1,
name: 'first',
description: 'initial',
isActive: false,
createdAt: new Date('2026-05-29T00:00:00.000Z'),
createdBy: null,
},
]
vi.mocked(db.select).mockReturnValueOnce(selectChain(rows) as never)
it('returns versions from the shared listWorkflowVersions helper', async () => {
listWorkflowVersionsMock.mockResolvedValue({
versions: [
{
id: 'v2',
version: 2,
name: null,
description: null,
isActive: true,
createdAt: new Date('2026-05-30T00:00:00.000Z'),
createdBy: 'user-1',
deployedByName: 'Waleed',
},
{
id: 'v1',
version: 1,
name: 'first',
description: 'initial',
isActive: false,
createdAt: new Date('2026-05-29T00:00:00.000Z'),
createdBy: null,
deployedByName: null,
},
],
})
const result = await executeGetDeploymentLog({ workflowId: 'wf-1' }, {
userId: 'user-1',
workflowId: 'wf-1',
} as ExecutionContext)
expect(listWorkflowVersionsMock).toHaveBeenCalledWith('wf-1')
expect(result.success).toBe(true)
expect(result.output).toMatchObject({
workflowId: 'wf-1',
@@ -1,13 +1,7 @@
import { db } from '@sim/db'
import {
chat,
workflow,
workflowDeploymentVersion,
workflowMcpServer,
workflowMcpTool,
} from '@sim/db/schema'
import { chat, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { toError } from '@sim/utils/errors'
import { and, desc, eq, inArray, isNull } from 'drizzle-orm'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import {
performCreateWorkflowMcpServer,
@@ -16,7 +10,10 @@ import {
} from '@/lib/mcp/orchestration'
import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison'
import { performActivateVersion, performRevertToVersion } from '@/lib/workflows/orchestration'
import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils'
import {
listWorkflowVersions,
updateDeploymentVersionMetadata,
} from '@/lib/workflows/persistence/utils'
import { checkNeedsRedeployment } from '@/app/api/workflows/utils'
import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access'
import type {
@@ -357,19 +354,7 @@ export async function executeGetDeploymentLog(
}
await ensureWorkflowAccess(workflowId, context.userId)
const rows = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
createdBy: workflowDeploymentVersion.createdBy,
})
.from(workflowDeploymentVersion)
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
.orderBy(desc(workflowDeploymentVersion.version))
const { versions: rows } = await listWorkflowVersions(workflowId)
const versions = rows.map((r) => ({
id: r.id,
@@ -1 +1,11 @@
export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | 'internal'
/**
* Maps an orchestration error code to its HTTP status. Shared by every route
* surface (UI, v1, tool routes) so deployment errors map identically.
*/
export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number {
if (code === 'validation') return 400
if (code === 'not_found') return 404
return 500
}
@@ -1186,6 +1186,44 @@ export async function findPreviousDeploymentVersion(
return { ok: true, version: previousRow.version }
}
/**
* Fetches a single deployment version of a workflow, including its state
* snapshot. Returns null when the version does not exist.
*/
export async function getWorkflowDeploymentVersion(
workflowId: string,
version: number
): Promise<{
id: string
version: number
name: string | null
description: string | null
isActive: boolean
createdAt: Date
state: unknown
} | null> {
const [row] = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
return row ?? null
}
export async function listWorkflowVersions(workflowId: string): Promise<{
versions: Array<{
id: string