feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by
This commit is contained in:
Vikhyath Mondreti
2026-01-02 17:58:19 -08:00
committed by GitHub
parent 7515809df0
commit 4df5d56ac5
12 changed files with 447 additions and 92 deletions

View File

@@ -29,6 +29,10 @@
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
*
* Organizations:
* GET /api/v1/admin/organizations - List all organizations
@@ -65,6 +69,8 @@ export {
unauthorizedResponse,
} from '@/app/api/v1/admin/responses'
export type {
AdminDeploymentVersion,
AdminDeployResult,
AdminErrorResponse,
AdminFolder,
AdminListResponse,
@@ -76,6 +82,7 @@ export type {
AdminSeatAnalytics,
AdminSingleResponse,
AdminSubscription,
AdminUndeployResult,
AdminUser,
AdminUserBilling,
AdminUserBillingWithSubscription,

View File

@@ -599,3 +599,23 @@ export interface AdminSeatAnalytics {
lastActive: string | null
}>
}
export interface AdminDeploymentVersion {
id: string
version: number
name: string | null
isActive: boolean
createdAt: string
createdBy: string | null
deployedByName: string | null
}
export interface AdminDeployResult {
isDeployed: boolean
version: number
deployedAt: string
}
export interface AdminUndeployResult {
isDeployed: boolean
}

View File

@@ -0,0 +1,111 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowDeployAPI')
const ADMIN_ACTOR_ID = 'admin-api'
interface RouteParams {
id: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return badRequestResponse('Workflow has no saved state')
}
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const deployResult = await deployWorkflow({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
workflowName: workflowRecord.name,
})
if (!deployResult.success) {
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
}
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
if (!scheduleResult.success) {
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
}
logger.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to deploy workflow')
}
})
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const result = await undeployWorkflow({ workflowId })
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {
isDeployed: false,
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to undeploy workflow')
}
})

View File

@@ -0,0 +1,58 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
interface RouteParams {
id: string
versionId: string
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId, versionId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const versionNum = Number(versionId)
if (!Number.isFinite(versionNum) || versionNum < 1) {
return badRequestResponse('Invalid version number')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
return internalErrorResponse(result.error || 'Failed to activate version')
}
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
return singleResponse({
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
})
} catch (error) {
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to activate deployment version')
}
})

View File

@@ -0,0 +1,52 @@
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeploymentVersion } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowVersionsAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowRecord] = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const { versions } = await listWorkflowVersions(workflowId)
const response: AdminDeploymentVersion[] = versions.map((v) => ({
id: v.id,
version: v.version,
name: v.name,
isActive: v.isActive,
createdAt: v.createdAt.toISOString(),
createdBy: v.createdBy,
deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
}))
logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`)
return singleResponse({ versions: response })
} catch (error) {
logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to list deployment versions')
}
})

View File

@@ -4,12 +4,12 @@ import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import {
createSchedulesForDeploy,
deleteSchedulesForWorkflow,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -207,21 +207,11 @@ export async function DELETE(
return createErrorResponse(error.message, error.status)
}
await db.transaction(async (tx) => {
await deleteSchedulesForWorkflow(id, tx)
const result = await undeployWorkflow({ workflowId: id })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
}
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, id))
await tx
.update(workflow)
.set({ isDeployed: false, deployedAt: null })
.where(eq(workflow.id, id))
})
// Remove all MCP tools that reference this workflow
await removeMcpToolsForWorkflow(id, requestId)
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)

View File

@@ -1,9 +1,8 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
@@ -27,68 +26,24 @@ export async function POST(
const versionNum = Number(version)
if (!Number.isFinite(versionNum)) {
return createErrorResponse('Invalid version', 400)
return createErrorResponse('Invalid version number', 400)
}
const now = new Date()
const result = await activateWorkflowVersion({ workflowId: id, version: versionNum })
if (!result.success) {
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
// Get the state of the version being activated for MCP tool sync
const [versionData] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
await db.transaction(async (tx) => {
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
const updated = await tx
.update(workflowDeploymentVersion)
.set({ isActive: true })
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.returning({ id: workflowDeploymentVersion.id })
if (updated.length === 0) {
throw new Error('Deployment version not found')
}
const updateData: Record<string, unknown> = {
isDeployed: true,
deployedAt: now,
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})
// Sync MCP tools with the activated version's parameter schema
if (versionData?.state) {
if (result.state) {
await syncMcpToolsForWorkflow({
workflowId: id,
requestId,
state: versionData.state,
state: result.state,
context: 'activate',
})
}
return createSuccessResponse({ success: true, deployedAt: now })
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to activate deployment', 500)

View File

@@ -21,7 +21,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(error.message, error.status)
}
const versions = await db
const rawVersions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
@@ -36,6 +36,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.where(eq(workflowDeploymentVersion.workflowId, id))
.orderBy(desc(workflowDeploymentVersion.version))
const versions = rawVersions.map((v) => ({
...v,
deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
}))
return createSuccessResponse({ versions })
} catch (error: any) {
logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error)

17
apps/sim/lib/db/types.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { db } from '@sim/db'
import type * as schema from '@sim/db/schema'
import type { ExtractTablesWithRelations } from 'drizzle-orm'
import type { PgTransaction } from 'drizzle-orm/pg-core'
import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js'
/**
* Type for database or transaction context.
* Allows functions to work with either the db instance or a transaction.
*/
export type DbOrTx =
| typeof db
| PgTransaction<
PostgresJsQueryResultHKT,
typeof schema,
ExtractTablesWithRelations<typeof schema>
>

View File

@@ -13,6 +13,7 @@ import type { InferSelectModel } from 'drizzle-orm'
import { and, desc, eq, sql } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import type { DbOrTx } from '@/lib/db/types'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
@@ -731,3 +732,158 @@ export function regenerateWorkflowStateIds(state: any): any {
...(state.metadata && { metadata: state.metadata }),
}
}
/**
* Undeploy a workflow by deactivating all versions and clearing deployment state.
* Handles schedule deletion and returns the result.
*/
export async function undeployWorkflow(params: { workflowId: string; tx?: DbOrTx }): Promise<{
success: boolean
error?: string
}> {
const { workflowId, tx } = params
const executeUndeploy = async (dbCtx: DbOrTx) => {
const { deleteSchedulesForWorkflow } = await import('@/lib/workflows/schedules/deploy')
await deleteSchedulesForWorkflow(workflowId, dbCtx)
await dbCtx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
await dbCtx
.update(workflow)
.set({ isDeployed: false, deployedAt: null })
.where(eq(workflow.id, workflowId))
}
try {
if (tx) {
await executeUndeploy(tx)
} else {
await db.transaction(async (txn) => {
await executeUndeploy(txn)
})
}
logger.info(`Undeployed workflow ${workflowId}`)
return { success: true }
} catch (error) {
logger.error(`Error undeploying workflow ${workflowId}:`, error)
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to undeploy workflow',
}
}
}
/**
* Activate a specific deployment version for a workflow.
* Deactivates the current active version and activates the specified one.
*/
export async function activateWorkflowVersion(params: {
workflowId: string
version: number
}): Promise<{
success: boolean
deployedAt?: Date
state?: unknown
error?: string
}> {
const { workflowId, version } = params
try {
const [versionData] = await db
.select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
if (!versionData) {
return { success: false, error: 'Deployment version not found' }
}
const now = new Date()
await db.transaction(async (tx) => {
await tx
.update(workflowDeploymentVersion)
.set({ isActive: false })
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
await tx
.update(workflowDeploymentVersion)
.set({ isActive: true })
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
await tx
.update(workflow)
.set({ isDeployed: true, deployedAt: now })
.where(eq(workflow.id, workflowId))
})
logger.info(`Activated version ${version} for workflow ${workflowId}`)
return {
success: true,
deployedAt: now,
state: versionData.state,
}
} catch (error) {
logger.error(`Error activating version ${version} for workflow ${workflowId}:`, error)
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to activate version',
}
}
}
/**
* List all deployment versions for a workflow.
*/
export async function listWorkflowVersions(workflowId: string): Promise<{
versions: Array<{
id: string
version: number
name: string | null
isActive: boolean
createdAt: Date
createdBy: string | null
deployedByName: string | null
}>
}> {
const { user } = await import('@sim/db')
const versions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
createdBy: workflowDeploymentVersion.createdBy,
deployedByName: user.name,
})
.from(workflowDeploymentVersion)
.leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id))
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
.orderBy(desc(workflowDeploymentVersion.version))
return { versions }
}

View File

@@ -1,27 +1,12 @@
import { type db, workflowSchedule } from '@sim/db'
import type * as schema from '@sim/db/schema'
import { workflowSchedule } from '@sim/db'
import { createLogger } from '@sim/logger'
import type { ExtractTablesWithRelations } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import type { PgTransaction } from 'drizzle-orm/pg-core'
import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js'
import type { DbOrTx } from '@/lib/db/types'
import type { BlockState } from '@/lib/workflows/schedules/utils'
import { findScheduleBlocks, validateScheduleBlock } from '@/lib/workflows/schedules/validation'
const logger = createLogger('ScheduleDeployUtils')
/**
* Type for database or transaction context
* This allows the functions to work with either the db instance or a transaction
*/
type DbOrTx =
| typeof db
| PgTransaction<
PostgresJsQueryResultHKT,
typeof schema,
ExtractTablesWithRelations<typeof schema>
>
/**
* Result of schedule creation during deploy
*/

View File

@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",