Skip to content

Commit 4df5d56

Browse files
feat(admin): routes to manage deployments (#2667)
* feat(admin): routes to manage deployments * fix naming fo deployed by
1 parent 7515809 commit 4df5d56

File tree

12 files changed

+447
-92
lines changed

12 files changed

+447
-92
lines changed

apps/sim/app/api/v1/admin/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
* DELETE /api/v1/admin/workflows/:id - Delete workflow
3030
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
3131
* POST /api/v1/admin/workflows/import - Import single workflow
32+
* POST /api/v1/admin/workflows/:id/deploy - Deploy workflow
33+
* DELETE /api/v1/admin/workflows/:id/deploy - Undeploy workflow
34+
* GET /api/v1/admin/workflows/:id/versions - List deployment versions
35+
* POST /api/v1/admin/workflows/:id/versions/:vid/activate - Activate specific version
3236
*
3337
* Organizations:
3438
* GET /api/v1/admin/organizations - List all organizations
@@ -65,6 +69,8 @@ export {
6569
unauthorizedResponse,
6670
} from '@/app/api/v1/admin/responses'
6771
export type {
72+
AdminDeploymentVersion,
73+
AdminDeployResult,
6874
AdminErrorResponse,
6975
AdminFolder,
7076
AdminListResponse,
@@ -76,6 +82,7 @@ export type {
7682
AdminSeatAnalytics,
7783
AdminSingleResponse,
7884
AdminSubscription,
85+
AdminUndeployResult,
7986
AdminUser,
8087
AdminUserBilling,
8188
AdminUserBillingWithSubscription,

apps/sim/app/api/v1/admin/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,3 +599,23 @@ export interface AdminSeatAnalytics {
599599
lastActive: string | null
600600
}>
601601
}
602+
603+
export interface AdminDeploymentVersion {
604+
id: string
605+
version: number
606+
name: string | null
607+
isActive: boolean
608+
createdAt: string
609+
createdBy: string | null
610+
deployedByName: string | null
611+
}
612+
613+
export interface AdminDeployResult {
614+
isDeployed: boolean
615+
version: number
616+
deployedAt: string
617+
}
618+
619+
export interface AdminUndeployResult {
620+
isDeployed: boolean
621+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { db, workflow } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { eq } from 'drizzle-orm'
4+
import {
5+
deployWorkflow,
6+
loadWorkflowFromNormalizedTables,
7+
undeployWorkflow,
8+
} from '@/lib/workflows/persistence/utils'
9+
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
10+
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
11+
import {
12+
badRequestResponse,
13+
internalErrorResponse,
14+
notFoundResponse,
15+
singleResponse,
16+
} from '@/app/api/v1/admin/responses'
17+
import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types'
18+
19+
const logger = createLogger('AdminWorkflowDeployAPI')
20+
21+
const ADMIN_ACTOR_ID = 'admin-api'
22+
23+
interface RouteParams {
24+
id: string
25+
}
26+
27+
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
28+
const { id: workflowId } = await context.params
29+
30+
try {
31+
const [workflowRecord] = await db
32+
.select({ id: workflow.id, name: workflow.name })
33+
.from(workflow)
34+
.where(eq(workflow.id, workflowId))
35+
.limit(1)
36+
37+
if (!workflowRecord) {
38+
return notFoundResponse('Workflow')
39+
}
40+
41+
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
42+
if (!normalizedData) {
43+
return badRequestResponse('Workflow has no saved state')
44+
}
45+
46+
const scheduleValidation = validateWorkflowSchedules(normalizedData.blocks)
47+
if (!scheduleValidation.isValid) {
48+
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
49+
}
50+
51+
const deployResult = await deployWorkflow({
52+
workflowId,
53+
deployedBy: ADMIN_ACTOR_ID,
54+
workflowName: workflowRecord.name,
55+
})
56+
57+
if (!deployResult.success) {
58+
return internalErrorResponse(deployResult.error || 'Failed to deploy workflow')
59+
}
60+
61+
const scheduleResult = await createSchedulesForDeploy(workflowId, normalizedData.blocks, db)
62+
if (!scheduleResult.success) {
63+
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
64+
}
65+
66+
logger.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
67+
68+
const response: AdminDeployResult = {
69+
isDeployed: true,
70+
version: deployResult.version!,
71+
deployedAt: deployResult.deployedAt!.toISOString(),
72+
}
73+
74+
return singleResponse(response)
75+
} catch (error) {
76+
logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error })
77+
return internalErrorResponse('Failed to deploy workflow')
78+
}
79+
})
80+
81+
export const DELETE = withAdminAuthParams<RouteParams>(async (request, context) => {
82+
const { id: workflowId } = await context.params
83+
84+
try {
85+
const [workflowRecord] = await db
86+
.select({ id: workflow.id })
87+
.from(workflow)
88+
.where(eq(workflow.id, workflowId))
89+
.limit(1)
90+
91+
if (!workflowRecord) {
92+
return notFoundResponse('Workflow')
93+
}
94+
95+
const result = await undeployWorkflow({ workflowId })
96+
if (!result.success) {
97+
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
98+
}
99+
100+
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
101+
102+
const response: AdminUndeployResult = {
103+
isDeployed: false,
104+
}
105+
106+
return singleResponse(response)
107+
} catch (error) {
108+
logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error })
109+
return internalErrorResponse('Failed to undeploy workflow')
110+
}
111+
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { db, workflow } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { eq } from 'drizzle-orm'
4+
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
5+
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
6+
import {
7+
badRequestResponse,
8+
internalErrorResponse,
9+
notFoundResponse,
10+
singleResponse,
11+
} from '@/app/api/v1/admin/responses'
12+
13+
const logger = createLogger('AdminWorkflowActivateVersionAPI')
14+
15+
interface RouteParams {
16+
id: string
17+
versionId: string
18+
}
19+
20+
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
21+
const { id: workflowId, versionId } = await context.params
22+
23+
try {
24+
const [workflowRecord] = await db
25+
.select({ id: workflow.id })
26+
.from(workflow)
27+
.where(eq(workflow.id, workflowId))
28+
.limit(1)
29+
30+
if (!workflowRecord) {
31+
return notFoundResponse('Workflow')
32+
}
33+
34+
const versionNum = Number(versionId)
35+
if (!Number.isFinite(versionNum) || versionNum < 1) {
36+
return badRequestResponse('Invalid version number')
37+
}
38+
39+
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
40+
if (!result.success) {
41+
if (result.error === 'Deployment version not found') {
42+
return notFoundResponse('Deployment version')
43+
}
44+
return internalErrorResponse(result.error || 'Failed to activate version')
45+
}
46+
47+
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
48+
49+
return singleResponse({
50+
success: true,
51+
version: versionNum,
52+
deployedAt: result.deployedAt!.toISOString(),
53+
})
54+
} catch (error) {
55+
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
56+
return internalErrorResponse('Failed to activate deployment version')
57+
}
58+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { db, workflow } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { eq } from 'drizzle-orm'
4+
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
5+
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
6+
import {
7+
internalErrorResponse,
8+
notFoundResponse,
9+
singleResponse,
10+
} from '@/app/api/v1/admin/responses'
11+
import type { AdminDeploymentVersion } from '@/app/api/v1/admin/types'
12+
13+
const logger = createLogger('AdminWorkflowVersionsAPI')
14+
15+
interface RouteParams {
16+
id: string
17+
}
18+
19+
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
20+
const { id: workflowId } = await context.params
21+
22+
try {
23+
const [workflowRecord] = await db
24+
.select({ id: workflow.id })
25+
.from(workflow)
26+
.where(eq(workflow.id, workflowId))
27+
.limit(1)
28+
29+
if (!workflowRecord) {
30+
return notFoundResponse('Workflow')
31+
}
32+
33+
const { versions } = await listWorkflowVersions(workflowId)
34+
35+
const response: AdminDeploymentVersion[] = versions.map((v) => ({
36+
id: v.id,
37+
version: v.version,
38+
name: v.name,
39+
isActive: v.isActive,
40+
createdAt: v.createdAt.toISOString(),
41+
createdBy: v.createdBy,
42+
deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null),
43+
}))
44+
45+
logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`)
46+
47+
return singleResponse({ versions: response })
48+
} catch (error) {
49+
logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error })
50+
return internalErrorResponse('Failed to list deployment versions')
51+
}
52+
})

apps/sim/app/api/workflows/[id]/deploy/route.ts

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { and, desc, eq } from 'drizzle-orm'
44
import type { NextRequest } from 'next/server'
55
import { generateRequestId } from '@/lib/core/utils/request'
66
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
7-
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
87
import {
9-
createSchedulesForDeploy,
10-
deleteSchedulesForWorkflow,
11-
validateWorkflowSchedules,
12-
} from '@/lib/workflows/schedules'
8+
deployWorkflow,
9+
loadWorkflowFromNormalizedTables,
10+
undeployWorkflow,
11+
} from '@/lib/workflows/persistence/utils'
12+
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
1313
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
1414
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1515

@@ -207,21 +207,11 @@ export async function DELETE(
207207
return createErrorResponse(error.message, error.status)
208208
}
209209

210-
await db.transaction(async (tx) => {
211-
await deleteSchedulesForWorkflow(id, tx)
212-
213-
await tx
214-
.update(workflowDeploymentVersion)
215-
.set({ isActive: false })
216-
.where(eq(workflowDeploymentVersion.workflowId, id))
217-
218-
await tx
219-
.update(workflow)
220-
.set({ isDeployed: false, deployedAt: null })
221-
.where(eq(workflow.id, id))
222-
})
210+
const result = await undeployWorkflow({ workflowId: id })
211+
if (!result.success) {
212+
return createErrorResponse(result.error || 'Failed to undeploy workflow', 500)
213+
}
223214

224-
// Remove all MCP tools that reference this workflow
225215
await removeMcpToolsForWorkflow(id, requestId)
226216

227217
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)

0 commit comments

Comments
 (0)