import { db, runOutsideTransactionContext, workflow, workflowDeploymentVersion } from '@sim/db' import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { loadWorkflowFromNormalizedTablesRaw, persistMigratedBlocks, } from '@sim/workflow-persistence/load' import { saveWorkflowToNormalizedTables as saveWorkflowToNormalizedTablesRaw } from '@sim/workflow-persistence/save' import type { DbOrTx, NormalizedWorkflowData } from '@sim/workflow-persistence/types' import type { BlockState, Loop, Parallel, WorkflowState } from '@sim/workflow-types/workflow' import type { InferSelectModel } from 'drizzle-orm' import { and, desc, eq, inArray, lt, sql } from 'drizzle-orm' import { LRUCache } from 'lru-cache' import type { Edge } from 'reactflow' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { backfillCanonicalModes, migrateSubblockIds, } from '@/lib/workflows/migrations/subblock-migrations' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation' const logger = createLogger('WorkflowDBHelpers') export type { DbOrTx, NormalizedWorkflowData } from '@sim/workflow-persistence/types' export type WorkflowDeploymentVersion = InferSelectModel function hasReturnedRows(result: unknown): boolean { if (Array.isArray(result)) return result.length > 0 if (result && typeof result === 'object') { const rows = 'rows' in result ? result.rows : undefined if (Array.isArray(rows)) return rows.length > 0 } return Boolean(result) } async function lockWorkflowForUpdate(tx: DbOrTx, workflowId: string): Promise { const query = tx.select({ id: workflow.id }).from(workflow).where(eq(workflow.id, workflowId)) if ('limit' in query && typeof query.limit === 'function') { const limited = query.limit(1) const rows = 'for' in limited && typeof limited.for === 'function' ? await limited.for('update') : await limited return hasReturnedRows(rows) } const rows = await query return hasReturnedRows(rows) } export interface WorkflowDeploymentVersionResponse { id: string version: number name?: string | null description?: string | null isActive: boolean createdAt: string createdBy?: string | null deployedBy?: string | null } export interface DeployedWorkflowData extends NormalizedWorkflowData { deploymentVersionId: string variables?: Record } export async function blockExistsInDeployment( workflowId: string, blockId: string ): Promise { try { const [result] = await db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.isActive, true) ) ) .limit(1) if (!result?.state) { return false } const state = result.state as WorkflowState return !!state.blocks?.[blockId] } catch (error) { logger.error(`Error checking block ${blockId} in deployment for workflow ${workflowId}:`, error) return false } } const DEPLOYED_STATE_CACHE_MAX_ENTRIES = 500 const DEPLOYED_STATE_CACHE_TTL_MS = 5 * 60 * 1000 /** * Caches post-migration deployed state by the immutable `deploymentVersionId`, so * a redeploy/rollback (which changes the active id) self-invalidates. The TTL is * absolute on purpose — it bounds the one non-immutable part, the live credential * remap in `applyBlockMigrations` — so credential changes still propagate. */ const deployedStateCache = new LRUCache({ max: DEPLOYED_STATE_CACHE_MAX_ENTRIES, ttl: DEPLOYED_STATE_CACHE_TTL_MS, }) /** Evicts one deployed-state entry, or clears the cache when no id is given. */ export function invalidateDeployedStateCache(deploymentVersionId?: string): void { if (deploymentVersionId) { deployedStateCache.delete(deploymentVersionId) return } deployedStateCache.clear() } export async function loadDeployedWorkflowState( workflowId: string, providedWorkspaceId?: string ): Promise { try { const [active] = await db .select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state, createdAt: workflowDeploymentVersion.createdAt, }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.isActive, true) ) ) .orderBy(desc(workflowDeploymentVersion.createdAt)) .limit(1) if (!active?.state) { throw new Error(`Workflow ${workflowId} has no active deployment`) } const cached = deployedStateCache.get(active.id) if (cached) { return structuredClone(cached) } const state = active.state as WorkflowState & { variables?: Record } let resolvedWorkspaceId = providedWorkspaceId if (!resolvedWorkspaceId) { const workflowContext = await getActiveWorkflowContext(workflowId) resolvedWorkspaceId = workflowContext?.workspaceId } if (!resolvedWorkspaceId) { throw new Error(`Workflow ${workflowId} has no workspace`) } const { blocks: migratedBlocks } = await applyBlockMigrations( state.blocks || {}, resolvedWorkspaceId ) const deployedState: DeployedWorkflowData = { blocks: migratedBlocks, edges: state.edges || [], loops: state.loops || {}, parallels: state.parallels || {}, variables: state.variables || {}, isFromNormalizedTables: false, deploymentVersionId: active.id, } deployedStateCache.set(active.id, deployedState) return structuredClone(deployedState) } catch (error) { logger.error(`Error loading deployed workflow state ${workflowId}:`, error) throw error } } interface MigrationContext { blocks: Record workspaceId: string executor: DbOrTx migrated: boolean } type BlockMigration = (ctx: MigrationContext) => MigrationContext | Promise function createMigrationPipeline(migrations: BlockMigration[]) { return async ( blocks: Record, workspaceId: string, executor: DbOrTx = db ): Promise<{ blocks: Record; migrated: boolean }> => { let ctx: MigrationContext = { blocks, workspaceId, executor, migrated: false } for (const migration of migrations) { ctx = await migration(ctx) } return { blocks: ctx.blocks, migrated: ctx.migrated } } } const applyBlockMigrations = createMigrationPipeline([ (ctx) => { const { blocks } = sanitizeAgentToolsInBlocks(ctx.blocks) return { ...ctx, blocks } }, (ctx) => ({ ...ctx, blocks: migrateAgentBlocksToMessagesFormat(ctx.blocks), }), (ctx) => { const { blocks, migrated } = migrateSubblockIds(ctx.blocks) return { ...ctx, blocks, migrated: ctx.migrated || migrated } }, async (ctx) => { const { blocks, migrated } = await migrateCredentialIds( ctx.blocks, ctx.workspaceId, ctx.executor ) return { ...ctx, blocks, migrated: ctx.migrated || migrated } }, (ctx) => { const { blocks, migrated } = backfillCanonicalModes(ctx.blocks) return { ...ctx, blocks, migrated: ctx.migrated || migrated } }, ]) /** * Migrates agent blocks from old format (systemPrompt/userPrompt) to new format (messages array) */ export function migrateAgentBlocksToMessagesFormat( blocks: Record ): Record { return Object.fromEntries( Object.entries(blocks).map(([id, block]) => { if (block.type === 'agent') { const systemPrompt = block.subBlocks.systemPrompt?.value const userPrompt = block.subBlocks.userPrompt?.value const messages = block.subBlocks.messages?.value if ((systemPrompt || userPrompt) && !messages) { const newMessages: Array<{ role: string; content: string }> = [] if (systemPrompt) { newMessages.push({ role: 'system', content: typeof systemPrompt === 'string' ? systemPrompt : String(systemPrompt), }) } if (userPrompt) { let userContent = userPrompt if (typeof userContent === 'object' && userContent !== null) { if ('input' in userContent) { userContent = (userContent as any).input } else { userContent = JSON.stringify(userContent) } } newMessages.push({ role: 'user', content: String(userContent), }) } return [ id, { ...block, subBlocks: { ...block.subBlocks, messages: { id: 'messages', type: 'messages-input', value: newMessages, }, }, }, ] } } return [id, block] }) ) } export const CREDENTIAL_SUBBLOCK_IDS = new Set([ 'credential', 'manualCredential', 'triggerCredentials', ]) async function migrateCredentialIds( blocks: Record, workspaceId: string, executor: DbOrTx ): Promise<{ blocks: Record; migrated: boolean }> { const potentialLegacyIds = new Set() for (const block of Object.values(blocks)) { for (const [subBlockId, subBlock] of Object.entries(block.subBlocks || {})) { if (!subBlock || typeof subBlock !== 'object') continue const value = (subBlock as { value?: unknown }).value if ( CREDENTIAL_SUBBLOCK_IDS.has(subBlockId) && typeof value === 'string' && value && !value.startsWith('cred_') ) { potentialLegacyIds.add(value) } if (subBlockId === 'tools' && Array.isArray(value)) { for (const tool of value) { const credParam = tool?.params?.credential if (typeof credParam === 'string' && credParam && !credParam.startsWith('cred_')) { potentialLegacyIds.add(credParam) } } } } } if (potentialLegacyIds.size === 0) { return { blocks, migrated: false } } const rows = await executor .select({ id: credential.id, accountId: credential.accountId }) .from(credential) .where( and( inArray(credential.accountId, [...potentialLegacyIds]), eq(credential.workspaceId, workspaceId) ) ) if (rows.length === 0) { return { blocks, migrated: false } } const accountToCredential = new Map(rows.map((r) => [r.accountId!, r.id])) const migratedBlocks = Object.fromEntries( Object.entries(blocks).map(([blockId, block]) => { let blockChanged = false const newSubBlocks = { ...block.subBlocks } for (const [subBlockId, subBlock] of Object.entries(newSubBlocks)) { if (CREDENTIAL_SUBBLOCK_IDS.has(subBlockId) && typeof subBlock.value === 'string') { const newId = accountToCredential.get(subBlock.value) if (newId) { newSubBlocks[subBlockId] = { ...subBlock, value: newId } blockChanged = true } } if (subBlockId === 'tools' && Array.isArray(subBlock.value)) { let toolsChanged = false const newTools = (subBlock.value as any[]).map((tool: any) => { const credParam = tool?.params?.credential if (typeof credParam === 'string') { const newId = accountToCredential.get(credParam) if (newId) { toolsChanged = true return { ...tool, params: { ...tool.params, credential: newId } } } } return tool }) if (toolsChanged) { newSubBlocks[subBlockId] = { ...subBlock, value: newTools as any } blockChanged = true } } } return [blockId, blockChanged ? { ...block, subBlocks: newSubBlocks } : block] }) ) const anyBlockChanged = Object.keys(migratedBlocks).some( (id) => migratedBlocks[id] !== blocks[id] ) return { blocks: migratedBlocks, migrated: anyBlockChanged } } /** * Load workflow from normalized tables and apply all block migrations * (credential ID rewrites, agent message migration, subblock ID migrations, * canonical-mode backfill, tool sanitization). Returns null if the workflow * has not been migrated to normalized tables yet. */ export async function loadWorkflowFromNormalizedTables( workflowId: string, externalTx?: DbOrTx ): Promise { const raw = await loadWorkflowFromNormalizedTablesRaw(workflowId, externalTx) if (!raw) return null const { blocks: finalBlocks, migrated } = await applyBlockMigrations( raw.blocks, raw.workspaceId, externalTx ?? db ) if (migrated) { // Deliberate fire-and-forget persistence on the global pool: it must not // join (or block) a read transaction this load may be running inside, so // it escapes the transaction context instead of tripping the wire. runOutsideTransactionContext(() => { Promise.resolve().then(() => persistMigratedBlocks(workflowId, raw.blocks, finalBlocks, raw.blockUpdatedAtById) ) }) } const patchedLoops: Record = { ...raw.loops } const patchedParallels: Record = { ...raw.parallels } for (const id of Object.keys(raw.loops)) { if (finalBlocks[id]) { patchedLoops[id] = { ...raw.loops[id], enabled: finalBlocks[id].enabled ?? true } } } for (const id of Object.keys(raw.parallels)) { if (finalBlocks[id]) { patchedParallels[id] = { ...raw.parallels[id], enabled: finalBlocks[id].enabled ?? true, } } } return { blocks: finalBlocks, edges: raw.edges, loops: patchedLoops, parallels: patchedParallels, isFromNormalizedTables: true, } } export async function loadWorkflowDeploymentSnapshot( workflowId: string, externalTx?: DbOrTx ): Promise { const loadSnapshot = async (tx: DbOrTx) => { const [normalizedData, [workflowRecord]] = await Promise.all([ loadWorkflowFromNormalizedTables(workflowId, tx), tx .select({ variables: workflow.variables }) .from(workflow) .where(eq(workflow.id, workflowId)) .limit(1), ]) if (!normalizedData) return null return buildWorkflowDeploymentSnapshot(normalizedData, workflowRecord?.variables) } if (externalTx) { return loadSnapshot(externalTx) } return db.transaction(async (tx) => { await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`) return loadSnapshot(tx) }) } export function buildWorkflowDeploymentSnapshot( normalizedData: NormalizedWorkflowData, variables: unknown ): WorkflowState { return { blocks: normalizedData.blocks, edges: normalizedData.edges, loops: normalizedData.loops, parallels: normalizedData.parallels, variables: (variables as WorkflowState['variables']) || {}, lastSaved: Date.now(), } } export async function saveWorkflowToNormalizedTables( workflowId: string, state: WorkflowState, externalTx?: DbOrTx ): Promise<{ success: boolean; error?: string }> { if (externalTx) { return saveWorkflowToNormalizedTablesRaw(workflowId, state, externalTx) } try { return await db.transaction(async (tx) => { await lockWorkflowForUpdate(tx, workflowId) return saveWorkflowToNormalizedTablesRaw(workflowId, state, tx) }) } catch (error) { const message = getErrorMessage(error, 'Failed to save workflow state') logger.error(`Error saving workflow ${workflowId} to normalized tables:`, error) return { success: false, error: message } } } export async function workflowExistsInNormalizedTables(workflowId: string): Promise { try { const { workflowBlocks } = await import('@sim/db') const blocks = await db .select({ id: workflowBlocks.id }) .from(workflowBlocks) .where(eq(workflowBlocks.workflowId, workflowId)) .limit(1) return blocks.length > 0 } catch (error) { logger.error(`Error checking if workflow ${workflowId} exists in normalized tables:`, error) return false } } type DeployWorkflowValidationResult = | { success: true } | { success: false; error: string; errorCode?: 'validation' } /** * Update the name and/or description metadata of an existing deployment version. * Shared by the workflow deployment-version PATCH route and the copilot * `update_deployment_version` tool so both behave identically. Returns the * resulting name/description, or null if the version does not exist. */ export async function updateDeploymentVersionMetadata(params: { workflowId: string version: number name?: string | null description?: string | null }): Promise<{ name: string | null; description: string | null } | null> { const updateData: { name?: string | null; description?: string | null } = {} if (params.name !== undefined) updateData.name = params.name if (params.description !== undefined) updateData.description = params.description if (Object.keys(updateData).length === 0) { const [row] = await db .select({ name: workflowDeploymentVersion.name, description: workflowDeploymentVersion.description, }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, params.workflowId), eq(workflowDeploymentVersion.version, params.version) ) ) .limit(1) return row ?? null } const [updated] = await db .update(workflowDeploymentVersion) .set(updateData) .where( and( eq(workflowDeploymentVersion.workflowId, params.workflowId), eq(workflowDeploymentVersion.version, params.version) ) ) .returning({ name: workflowDeploymentVersion.name, description: workflowDeploymentVersion.description, }) return updated ?? null } export async function deployWorkflow(params: { workflowId: string deployedBy: string workflowName?: string /** Optional human-readable summary of what changed, stored on the deployment version. */ description?: string | null /** Optional human-readable name/label for the deployment version. */ name?: string | null workflowState?: WorkflowState validateWorkflowState?: ( workflowState: WorkflowState, executor: DbOrTx ) => DeployWorkflowValidationResult | Promise onDeployTransaction?: ( tx: DbOrTx, result: { deploymentVersionId: string; version: number; previousVersionId?: string } ) => Promise }): Promise<{ success: boolean version?: number deploymentVersionId?: string deployedAt?: Date previousVersionId?: string currentState?: WorkflowState error?: string errorCode?: 'validation' | 'not_found' }> { const { workflowId, deployedBy, workflowName } = params try { const now = new Date() let currentState: WorkflowState | null = null const deployedVersion = await db.transaction(async (tx) => { if (!(await lockWorkflowForUpdate(tx, workflowId))) { return { success: false as const, error: 'Workflow not found', errorCode: 'not_found' as const, } } // Refuse to deploy an archived (soft-deleted) workflow. Checked under the row // lock so it's atomic with a concurrent fork rollback that archives a // promote-created workflow: a stale promote deploy can never resurrect it into // an archived-but-deployed (incoherent) state. const [archivedRow] = await tx .select({ archivedAt: workflow.archivedAt }) .from(workflow) .where(eq(workflow.id, workflowId)) .limit(1) if (archivedRow?.archivedAt != null) { return { success: false as const, error: 'Cannot deploy an archived workflow', errorCode: 'validation' as const, } } currentState = params.workflowState ?? (await loadWorkflowDeploymentSnapshot(workflowId, tx)) if (!currentState) { return { success: false as const, error: 'Failed to load workflow state', errorCode: 'validation' as const, } } const validationError = await params.validateWorkflowState?.(currentState, tx) if (validationError && !validationError.success) { return { success: false as const, error: validationError.error, errorCode: validationError.errorCode, } } const [currentActiveVersion] = await tx .select({ id: workflowDeploymentVersion.id }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.isActive, true) ) ) .limit(1) const previousVersionId = currentActiveVersion?.id const [{ maxVersion }] = await tx .select({ maxVersion: sql`COALESCE(MAX("version"), 0)` }) .from(workflowDeploymentVersion) .where(eq(workflowDeploymentVersion.workflowId, workflowId)) const nextVersion = Number(maxVersion) + 1 const deploymentVersionId = generateId() await tx .update(workflowDeploymentVersion) .set({ isActive: false }) .where(eq(workflowDeploymentVersion.workflowId, workflowId)) await tx.insert(workflowDeploymentVersion).values({ id: deploymentVersionId, workflowId, version: nextVersion, state: currentState, isActive: true, createdBy: deployedBy, createdAt: now, description: params.description?.trim() || null, name: params.name?.trim() || null, }) const updateData: Record = { isDeployed: true, deployedAt: now, } await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) await params.onDeployTransaction?.(tx, { deploymentVersionId, version: nextVersion, previousVersionId, }) return { success: true as const, version: nextVersion, deploymentVersionId, previousVersionId, currentState, } }) if (!deployedVersion.success) { return { success: false, error: deployedVersion.error, errorCode: deployedVersion.errorCode, } } const deployedState = deployedVersion.currentState if (!deployedState) { return { success: false, error: 'Failed to load workflow state' } } logger.info(`Deployed workflow ${workflowId} as v${deployedVersion.version}`) if (workflowName) { try { const { PlatformEvents } = await import('@/lib/core/telemetry') const blockTypeCounts: Record = {} for (const block of Object.values(deployedState.blocks)) { const blockType = block.type || 'unknown' blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 } PlatformEvents.workflowDeployed({ workflowId, workflowName, blocksCount: Object.keys(deployedState.blocks).length, edgesCount: deployedState.edges.length, version: deployedVersion.version, loopsCount: Object.keys(deployedState.loops).length, parallelsCount: Object.keys(deployedState.parallels).length, blockTypes: JSON.stringify(blockTypeCounts), }) } catch (telemetryError) { logger.warn(`Failed to track deployment telemetry for ${workflowId}`, telemetryError) } } return { success: true, version: deployedVersion.version, deploymentVersionId: deployedVersion.deploymentVersionId, previousVersionId: deployedVersion.previousVersionId, deployedAt: now, currentState: deployedState, } } catch (error) { logger.error(`Error deploying workflow ${workflowId}:`, error) return { success: false, error: getErrorMessage(error, 'Unknown error'), } } } export interface RegenerateStateInput { blocks?: Record edges?: Edge[] loops?: Record parallels?: Record lastSaved?: number variables?: Record metadata?: Record } interface RegenerateStateOutput { blocks: Record edges: Edge[] loops: Record parallels: Record lastSaved: number variables?: Record metadata?: Record } export function regenerateWorkflowStateIds(state: RegenerateStateInput): RegenerateStateOutput { const blockIdMapping = new Map() const edgeIdMapping = new Map() const loopIdMapping = new Map() const parallelIdMapping = new Map() Object.keys(state.blocks || {}).forEach((oldId) => { blockIdMapping.set(oldId, generateId()) }) ;(state.edges || []).forEach((edge: Edge) => { edgeIdMapping.set(edge.id, generateId()) }) Object.keys(state.loops || {}).forEach((oldId) => { loopIdMapping.set(oldId, generateId()) }) Object.keys(state.parallels || {}).forEach((oldId) => { parallelIdMapping.set(oldId, generateId()) }) const newBlocks: Record = {} const newEdges: Edge[] = [] const newLoops: Record = {} const newParallels: Record = {} Object.entries(state.blocks || {}).forEach(([oldId, block]) => { const newId = blockIdMapping.get(oldId)! const newBlock: BlockState = { ...block, id: newId, subBlocks: structuredClone(block.subBlocks), locked: false, } if (newBlock.data?.parentId) { const newParentId = blockIdMapping.get(newBlock.data.parentId) if (newParentId) { newBlock.data = { ...newBlock.data, parentId: newParentId } } } if (newBlock.subBlocks) { const updatedSubBlocks: Record = {} Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]) => { const updatedSubBlock = { ...subBlock } if ( typeof updatedSubBlock.value === 'string' && blockIdMapping.has(updatedSubBlock.value) ) { updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value } if ( (updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') && typeof updatedSubBlock.value === 'string' ) { try { const parsed = JSON.parse(updatedSubBlock.value) if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) { updatedSubBlock.value = JSON.stringify(parsed) } } catch {} } updatedSubBlocks[subId] = updatedSubBlock }) newBlock.subBlocks = updatedSubBlocks } newBlocks[newId] = newBlock }) ;(state.edges || []).forEach((edge: Edge) => { const newId = edgeIdMapping.get(edge.id)! const newSource = blockIdMapping.get(edge.source) || edge.source const newTarget = blockIdMapping.get(edge.target) || edge.target const newSourceHandle = edge.sourceHandle && blockIdMapping.has(edge.source) ? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource) : edge.sourceHandle newEdges.push({ ...edge, id: newId, source: newSource, target: newTarget, sourceHandle: newSourceHandle, }) }) Object.entries(state.loops || {}).forEach(([oldId, loop]) => { const newId = loopIdMapping.get(oldId)! const newLoop: Loop = { ...loop, id: newId } if (newLoop.nodes) { newLoop.nodes = newLoop.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId) } newLoops[newId] = newLoop }) Object.entries(state.parallels || {}).forEach(([oldId, parallel]) => { const newId = parallelIdMapping.get(oldId)! const newParallel: Parallel = { ...parallel, id: newId } if (newParallel.nodes) { newParallel.nodes = newParallel.nodes.map( (nodeId: string) => blockIdMapping.get(nodeId) || nodeId ) } newParallels[newId] = newParallel }) return { blocks: newBlocks, edges: newEdges, loops: newLoops, parallels: newParallels, lastSaved: state.lastSaved || Date.now(), ...(state.variables && { variables: state.variables }), ...(state.metadata && { metadata: state.metadata }), } } export async function undeployWorkflow(params: { workflowId: string tx?: DbOrTx onUndeployTransaction?: (tx: DbOrTx, result: { deploymentVersionIds: string[] }) => Promise }): Promise<{ success: boolean error?: string }> { const { workflowId, tx } = params const executeUndeploy = async (dbCtx: DbOrTx) => { if (!(await lockWorkflowForUpdate(dbCtx, workflowId))) { throw new Error('Workflow not found') } const deploymentVersions = await dbCtx .select({ id: workflowDeploymentVersion.id }) .from(workflowDeploymentVersion) .where(eq(workflowDeploymentVersion.workflowId, workflowId)) const deploymentVersionIds = deploymentVersions.map((version) => version.id) 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)) await params.onUndeployTransaction?.(dbCtx, { deploymentVersionIds }) } 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: getErrorMessage(error, 'Failed to undeploy workflow'), } } } export async function activateWorkflowVersion(params: { workflowId: string version: number onActivateTransaction?: ( tx: DbOrTx, result: { deploymentVersionId: string; previousVersionId?: string } ) => Promise }): Promise<{ success: boolean deployedAt?: Date state?: unknown previousVersionId?: string error?: string }> { const { workflowId, version } = params try { const now = new Date() let versionState: unknown const result = await db.transaction(async (tx) => { if (!(await lockWorkflowForUpdate(tx, workflowId))) { return { success: false as const, error: 'Workflow not found' } } const [currentActiveVersion] = await tx .select({ id: workflowDeploymentVersion.id }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.isActive, true) ) ) .limit(1) const [versionData] = await tx .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 as const, error: 'Deployment version not found' } } versionState = versionData.state 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)) await params.onActivateTransaction?.(tx, { deploymentVersionId: versionData.id, previousVersionId: currentActiveVersion?.id, }) return { success: true as const, previousVersionId: currentActiveVersion?.id } }) if (!result.success) { return { success: false, error: result.error } } logger.info(`Activated version ${version} for workflow ${workflowId}`) return { success: true, deployedAt: now, state: versionState, previousVersionId: result.previousVersionId, } } catch (error) { logger.error(`Error activating version ${version} for workflow ${workflowId}:`, error) return { success: false, error: getErrorMessage(error, 'Failed to activate version'), } } } async function activateWorkflowVersionById(params: { workflowId: string deploymentVersionId: string }): Promise<{ success: boolean deployedAt?: Date state?: unknown previousVersionId?: string error?: string }> { const { workflowId, deploymentVersionId } = params try { const now = new Date() let versionState: unknown const result = await db.transaction(async (tx) => { if (!(await lockWorkflowForUpdate(tx, workflowId))) { return { success: false as const, error: 'Workflow not found' } } const [currentActiveVersion] = await tx .select({ id: workflowDeploymentVersion.id }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.isActive, true) ) ) .limit(1) const [versionData] = await tx .select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.id, deploymentVersionId) ) ) .limit(1) if (!versionData) { return { success: false as const, error: 'Deployment version not found' } } versionState = versionData.state await tx .update(workflowDeploymentVersion) .set({ isActive: false }) .where(eq(workflowDeploymentVersion.workflowId, workflowId)) await tx .update(workflowDeploymentVersion) .set({ isActive: true }) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.id, deploymentVersionId) ) ) await tx .update(workflow) .set({ isDeployed: true, deployedAt: now }) .where(eq(workflow.id, workflowId)) return { success: true as const, previousVersionId: currentActiveVersion?.id } }) if (!result.success) { return { success: false, error: result.error } } logger.info(`Activated deployment version ${deploymentVersionId} for workflow ${workflowId}`) return { success: true, deployedAt: now, state: versionState, previousVersionId: result.previousVersionId, } } catch (error) { logger.error( `Error activating deployment version ${deploymentVersionId} for workflow ${workflowId}:`, error ) return { success: false, error: getErrorMessage(error, 'Failed to activate version'), } } } /** * Resolves the deployment version that precedes the currently active one — * the default rollback target when no explicit version is given. */ export async function findPreviousDeploymentVersion( workflowId: string ): Promise< { ok: true; version: number } | { ok: false; reason: 'no_active_version' | 'no_previous_version' } > { const [activeRow] = await db .select({ version: workflowDeploymentVersion.version }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), eq(workflowDeploymentVersion.isActive, true) ) ) .limit(1) if (!activeRow) { return { ok: false, reason: 'no_active_version' } } const [previousRow] = await db .select({ version: workflowDeploymentVersion.version }) .from(workflowDeploymentVersion) .where( and( eq(workflowDeploymentVersion.workflowId, workflowId), lt(workflowDeploymentVersion.version, activeRow.version) ) ) .orderBy(desc(workflowDeploymentVersion.version)) .limit(1) if (!previousRow) { return { ok: false, reason: 'no_previous_version' } } 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 version: number name: string | null description: string | null isActive: boolean createdAt: Date createdBy: string | null deployedByName: string | null }> }> { const { user } = await import('@sim/db') 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, deployedByName: user.name, }) .from(workflowDeploymentVersion) .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) .where(eq(workflowDeploymentVersion.workflowId, workflowId)) .orderBy(desc(workflowDeploymentVersion.version)) return { versions: rows.map((row) => ({ ...row, deployedByName: row.deployedByName ?? (row.createdBy === 'admin-api' ? 'Admin' : null), })), } }