diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts index bba1e428c..2d0afcce0 100644 --- a/apps/sim/app/api/v1/admin/index.ts +++ b/apps/sim/app/api/v1/admin/index.ts @@ -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, diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 06d9a491f..4c3916810 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -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 +} diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts new file mode 100644 index 000000000..a868313c0 --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -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(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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts new file mode 100644 index 000000000..9b5ac90f1 --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts new file mode 100644 index 000000000..004e4c15b --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts @@ -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(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') + } +}) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 2413eaa2d..09fc458f9 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -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}`) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts index 4ffc35f9e..76126ee86 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/activate/route.ts @@ -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 = { - 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) diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index 80ee376aa..87ec11e70 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -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) diff --git a/apps/sim/lib/db/types.ts b/apps/sim/lib/db/types.ts new file mode 100644 index 000000000..8039e2b78 --- /dev/null +++ b/apps/sim/lib/db/types.ts @@ -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 + > diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 2981e1d2a..96481c586 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -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 } +} diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts index 165db1a59..257d859b4 100644 --- a/apps/sim/lib/workflows/schedules/deploy.ts +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -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 - > - /** * Result of schedule creation during deploy */ diff --git a/bun.lock b/bun.lock index 5e24a3f2c..d7add8660 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio",