mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user