Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

980 lines
32 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db'
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { captureServerEvent } from '@/lib/posthog/server'
import { validateTriggerWebhookConfigForDeploy } from '@/lib/webhooks/deploy'
import { normalizedStringify } from '@/lib/workflows/comparison/normalize'
import {
DEPLOYMENT_ERROR_CODES,
type DeploymentComponentStatus,
isDeploymentOperationAction,
isDeploymentOperationStatus,
isNonRetryableDeploymentErrorCode,
parseDeploymentReadiness,
} from '@/lib/workflows/deployment-lifecycle'
import {
DEPLOYMENT_READINESS_COMPONENTS,
enqueueWorkflowDeploymentPreparation,
enqueueWorkflowUndeploySideEffects,
notifySocketDeploymentChanged,
processWorkflowDeploymentOutboxEvent,
} from '@/lib/workflows/deployment-outbox'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
import {
getWorkflowDeploymentStatus,
prepareWorkflowDeployment,
prepareWorkflowVersionActivation,
type WorkflowDeploymentOperation,
type WorkflowDeploymentStatus,
} from '@/lib/workflows/persistence/deployment-operations'
import {
loadWorkflowDeploymentSnapshot,
saveWorkflowToNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import { validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { emitWorkflowUndeployedEvent } from '@/lib/workspace-events/emitter'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployOrchestration')
type DeploymentReadinessSummaryStatus = DeploymentComponentStatus | 'not_applicable'
export interface ActiveDeploymentResult {
deploymentVersionId: string
version: number
deployedAt: string
}
export interface DeploymentAttemptResult {
id: string
deploymentVersionId: string
version: number
action: 'deploy' | 'activate'
status: 'preparing' | 'activating' | 'active' | 'failed' | 'superseded'
/** Whether this attempt still describes the workflow's current deployment lifecycle. */
isCurrent: boolean
readiness: {
webhooks: DeploymentReadinessSummaryStatus
schedules: DeploymentReadinessSummaryStatus
mcp: DeploymentReadinessSummaryStatus
}
requestedAt: string
activatedAt?: string | null
error?: {
code: string
message: string
retryable: boolean
} | null
}
export interface PerformFullDeployParams {
workflowId: string
userId: string
/**
* Optional summary of what changed, stored on the created deployment version.
* The copilot deploy tools require this; the UI deploy route sets it
* separately via the version metadata endpoint, so it stays optional here.
*/
versionDescription?: string
/**
* Optional name/label for the created deployment version. The copilot deploy
* tools require this; the UI deploy route sets it via the version metadata
* endpoint, so it stays optional here.
*/
versionName?: string
/** Stable identity for one logical deployment operation. */
idempotencyKey?: string
/** Correlation ID for logging and outbox tracing. */
requestId?: string
/**
* Override the actor ID used in audit logs and the `deployedBy` field.
* Defaults to `userId`. Use `'admin-api'` for admin-initiated actions.
*/
actorId?: string
}
/**
* Resolves a mutation-lock denial to a message instead of throwing, so the entry
* points below return their `{ success: false }` result shape rather than
* surfacing a 500 to callers that expect one — matching `performRevertToVersion`.
*/
async function workflowLockDenial(workflowId: string): Promise<string | null> {
try {
await assertWorkflowMutable(workflowId)
return null
} catch (error) {
if (error instanceof WorkflowLockedError) return error.message
throw error
}
}
export interface PerformFullDeployResult {
success: boolean
deployedAt?: Date
version?: number
deploymentVersionId?: string
activeDeployment?: ActiveDeploymentResult | null
latestDeploymentAttempt?: DeploymentAttemptResult | null
error?: string
errorCode?: OrchestrationErrorCode
warnings?: string[]
}
/**
* Admits a deployment through the v2 prepare/activate protocol. The candidate
* version remains inactive until every required side effect is ready. Callers
* that can replay a logical operation must provide a stable `idempotencyKey`;
* `requestId` is correlation metadata only.
*/
export async function performFullDeploy(
params: PerformFullDeployParams
): Promise<PerformFullDeployResult> {
const { workflowId, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const idempotencyKey = params.idempotencyKey ?? generateId()
// Backstop for every caller — routes may assert first to render their own 423,
// but the copilot deploy tools call this directly.
const lockDenial = await workflowLockDenial(workflowId)
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' }
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
try {
return await performStableFullDeploy({
params,
actorId,
requestId,
idempotencyKey,
})
} catch (error) {
logger.error(`[${requestId}] Deployment preparation failed`, { workflowId, error })
return {
success: false,
error: getErrorMessage(error, 'Failed to prepare workflow deployment'),
errorCode: 'internal',
}
}
}
async function performStableFullDeploy(params: {
params: PerformFullDeployParams
actorId: string
requestId: string
idempotencyKey: string
}): Promise<PerformFullDeployResult> {
const workflowState = await loadWorkflowDeploymentSnapshot(params.params.workflowId)
if (!workflowState) {
return {
success: false,
error: 'Failed to load workflow state',
errorCode: 'validation',
}
}
const validation = await validateDeploymentState(workflowState.blocks)
if (!validation.success) return validation
const requestHash = createDeploymentRequestHash({
action: 'deploy',
workflowId: params.params.workflowId,
userId: params.params.userId,
workflowState: canonicalizeDeploymentWorkflowState(workflowState),
})
let outboxEventId: string | undefined
const prepared = await prepareWorkflowDeployment({
workflowId: params.params.workflowId,
actorId: params.actorId,
requestHash,
idempotencyKey: bindIdempotencyKeyToRequest(params.idempotencyKey, requestHash),
workflowState,
name: params.params.versionName,
description: params.params.versionDescription,
readinessComponents: DEPLOYMENT_READINESS_COMPONENTS,
onPrepareTransaction: async (tx, operation) => {
if (!operation.deploymentVersionId || operation.version === null) {
throw new Error('Prepared deployment operation is missing its target version')
}
outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, {
protocolVersion: operation.protocolVersion,
operationId: operation.id,
generation: operation.generation,
workflowId: operation.workflowId,
deploymentVersionId: operation.deploymentVersionId,
version: operation.version,
userId: params.params.userId,
requestId: params.requestId,
checkpoints: {},
})
},
})
if (!prepared.success) {
return {
success: false,
error: prepared.error,
errorCode: mapPrepareFailureCode(prepared.reason),
}
}
const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId)
const deploymentStatus = await getWorkflowDeploymentStatus(params.params.workflowId)
const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, deploymentStatus)
if (inlineFailure) return inlineFailure
const result = buildStableDeploymentResult(deploymentStatus, processResult)
/**
* The top-level version identifies the snapshot THIS call admitted, even
* while cutover is still pending — otherwise callers would attribute the
* deploy to the previous live version. `activeDeployment` keeps reporting
* what is actually live.
*/
return {
...result,
version: prepared.operation.version,
deploymentVersionId: prepared.operation.deploymentVersionId,
}
}
/**
* Surfaces a synchronous failure when the attempt created by this request
* already failed terminally, so callers get an error response instead of a
* success payload with a buried failed status.
*/
function buildInlinePreparationFailure(
operationId: string,
status: WorkflowDeploymentStatus
): { success: false; error: string; errorCode: OrchestrationErrorCode } | null {
const latest = status.latestOperation
if (!latest || latest.id !== operationId || latest.status !== 'failed') return null
return {
success: false,
error: latest.errorMessage || 'Deployment preparation failed',
errorCode:
latest.errorCode === DEPLOYMENT_ERROR_CODES.webhookPathConflict
? 'conflict'
: latest.errorCode === DEPLOYMENT_ERROR_CODES.invalidTriggerConfiguration
? 'validation'
: 'internal',
}
}
async function validateDeploymentState(
blocks: Record<string, BlockState>
): Promise<
| { success: true }
| { success: false; error: string; errorCode: Extract<OrchestrationErrorCode, 'validation'> }
> {
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return {
success: false,
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
errorCode: 'validation',
}
}
const triggerValidation = await validateTriggerWebhookConfigForDeploy(blocks)
if (!triggerValidation.success) {
return {
success: false,
error: triggerValidation.error?.message || 'Invalid trigger configuration',
errorCode: 'validation',
}
}
return { success: true }
}
function canonicalizeDeploymentWorkflowState(
workflowState: WorkflowState
): Record<string, unknown> {
const { lastSaved: _lastSaved, edges, ...stableState } = workflowState
const sortedEdges = [...edges].sort((left, right) => {
if (left.id !== right.id) return left.id < right.id ? -1 : 1
const normalizedLeft = normalizedStringify(left)
const normalizedRight = normalizedStringify(right)
if (normalizedLeft === normalizedRight) return 0
return normalizedLeft < normalizedRight ? -1 : 1
})
return { ...stableState, edges: sortedEdges }
}
function createDeploymentRequestHash(value: Record<string, unknown>): string {
return sha256Hex(normalizedStringify(value))
}
function bindIdempotencyKeyToRequest(idempotencyKey: string, requestHash: string): string {
return `${idempotencyKey}:request:${requestHash}`
}
function mapPrepareFailureCode(
reason:
| 'workflow_not_found'
| 'workflow_archived'
| 'deployment_version_not_found'
| 'idempotency_conflict'
| 'invalid_request'
): OrchestrationErrorCode {
if (reason === 'workflow_not_found' || reason === 'deployment_version_not_found') {
return 'not_found'
}
if (reason === 'idempotency_conflict') return 'conflict'
return 'validation'
}
async function processStableDeploymentPreparationNow(
outboxEventId: string | undefined,
requestId: string
): Promise<string | undefined> {
if (!outboxEventId) return undefined
try {
return await processWorkflowDeploymentOutboxEvent(outboxEventId)
} catch (error) {
logger.warn(`[${requestId}] Inline deployment preparation errored; outbox will retry`, {
outboxEventId,
error,
})
return 'processing_error'
}
}
function buildStableDeploymentResult(
status: WorkflowDeploymentStatus,
processResult: string | undefined
): PerformFullDeployResult {
const activeDeployment = status.activeDeployment
? {
deploymentVersionId: status.activeDeployment.deploymentVersionId,
version: status.activeDeployment.version,
deployedAt: status.activeDeployment.deployedAt.toISOString(),
}
: null
const latestDeploymentAttempt = summarizeDeploymentOperation(
status.latestOperation,
status.activeDeployment?.deploymentVersionId ?? null
)
const warning = getStableDeploymentWarning(
latestDeploymentAttempt,
processResult,
activeDeployment !== null
)
return {
success: true,
deployedAt: status.activeDeployment?.deployedAt,
version: status.activeDeployment?.version,
deploymentVersionId: status.activeDeployment?.deploymentVersionId,
activeDeployment,
latestDeploymentAttempt,
warnings: warning ? [warning] : undefined,
}
}
/**
* Returns the active deployment and latest attempt without mutating deployment state.
*/
export async function getWorkflowDeploymentSummary(workflowId: string): Promise<{
activeDeployment: ActiveDeploymentResult | null
latestDeploymentAttempt: DeploymentAttemptResult | null
warnings?: string[]
}> {
const result = buildStableDeploymentResult(
await getWorkflowDeploymentStatus(workflowId),
undefined
)
return {
activeDeployment: result.activeDeployment ?? null,
latestDeploymentAttempt: result.latestDeploymentAttempt ?? null,
warnings: result.warnings,
}
}
function summarizeDeploymentOperation(
operation: WorkflowDeploymentOperation | null,
activeDeploymentVersionId: string | null
): DeploymentAttemptResult | null {
if (!operation) return null
if (
!isDeploymentOperationAction(operation.action) ||
!isDeploymentOperationStatus(operation.status)
) {
return null
}
const readiness = parseDeploymentReadiness(operation.componentReadiness)
const componentStatus = (
component: (typeof DEPLOYMENT_READINESS_COMPONENTS)[number]
): DeploymentReadinessSummaryStatus => readiness?.[component]?.status ?? 'not_applicable'
return {
id: operation.id,
deploymentVersionId: operation.deploymentVersionId,
version: operation.version,
action: operation.action,
status: operation.status,
isCurrent:
operation.status === 'active'
? operation.deploymentVersionId === activeDeploymentVersionId
: operation.status !== 'superseded',
readiness: {
webhooks: componentStatus('webhooks'),
schedules: componentStatus('schedules'),
mcp: componentStatus('mcp'),
},
requestedAt: operation.createdAt.toISOString(),
activatedAt:
operation.status === 'active' ? (operation.completedAt?.toISOString() ?? null) : null,
error:
operation.errorCode && operation.errorMessage
? {
code: operation.errorCode,
message: operation.errorMessage,
retryable: !isNonRetryableDeploymentErrorCode(operation.errorCode),
}
: null,
}
}
function getStableDeploymentWarning(
attempt: DeploymentAttemptResult | null,
processResult: string | undefined,
hasActiveDeployment: boolean
): string | undefined {
if (!attempt) return undefined
if (attempt.status === 'active' && !attempt.isCurrent) {
return 'The latest successful deployment attempt is historical; no matching deployment version is currently active.'
}
if (attempt.status === 'preparing' || attempt.status === 'activating') {
if (processResult === 'processing_error') {
return hasActiveDeployment
? 'Deployment preparation hit an error and will retry automatically. The prior workflow version remains active until cutover.'
: 'Deployment preparation hit an error and will retry automatically. The workflow remains undeployed until activation.'
}
return hasActiveDeployment
? 'Deployment preparation is queued and may finish shortly. The prior workflow version remains active until cutover.'
: 'Deployment preparation is queued and may finish shortly. The workflow remains undeployed until activation.'
}
if (attempt.status === 'failed') {
return hasActiveDeployment
? 'Deployment preparation failed. The prior workflow version remains active.'
: 'Deployment preparation failed. The workflow remains undeployed.'
}
if (attempt.status === 'superseded') {
return 'This deployment attempt was superseded by a newer request.'
}
if (processResult === 'dead_letter' || processResult === 'not_found') {
return 'Deployment activation completed, but its post-activation event could not be retried automatically.'
}
if (
processResult === 'pending' ||
processResult === 'processing' ||
processResult === 'lease_lost'
) {
return 'Deployment activation completed, and post-activation notifications are queued.'
}
return undefined
}
export interface PerformFullUndeployParams {
workflowId: string
userId: string
requestId?: string
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
}
export interface PerformFullUndeployResult {
success: boolean
error?: string
warnings?: string[]
}
/**
* Performs a full workflow undeploy: marks the workflow as undeployed, queues
* external cleanup transactionally, emits a telemetry event, and records an
* audit log entry. Both the deploy API DELETE handler and the copilot undeploy
* tools must use this single function.
*/
export async function performFullUndeploy(
params: PerformFullUndeployParams
): Promise<PerformFullUndeployResult> {
const { workflowId, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const lockDenial = await workflowLockDenial(workflowId)
if (lockDenial) return { success: false, error: lockDenial }
const [workflowRecord] = await db
.select()
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) {
return { success: false, error: 'Workflow not found' }
}
const workflowData = workflowRecord as Record<string, unknown>
let outboxEventId: string | undefined
const result = await undeployWorkflow({
workflowId,
onUndeployTransaction: async (tx, undeploy) => {
outboxEventId = await enqueueWorkflowUndeploySideEffects(tx, {
workflowId,
deploymentVersionIds: undeploy.deploymentVersionIds,
userId,
requestId,
})
},
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to undeploy workflow' }
}
logger.info(`[${requestId}] Workflow undeployed successfully: ${workflowId}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.workflowUndeployed({ workflowId })
} catch (_e) {
// Telemetry is best-effort
}
recordAudit({
workspaceId: (workflowData.workspaceId as string) || null,
actorId: actorId,
action: AuditAction.WORKFLOW_UNDEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: (workflowData.name as string) || undefined,
description: `Undeployed workflow "${(workflowData.name as string) || workflowId}"`,
})
await notifySocketDeploymentChanged(workflowId)
const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId)
const undeployWorkspaceId = workflowData.workspaceId as string | null
if (undeployWorkspaceId) {
void emitWorkflowUndeployedEvent({
workflowId,
workflowName: (workflowData.name as string) || workflowId,
workspaceId: undeployWorkspaceId,
})
}
return { success: true, warnings: sideEffectWarning ? [sideEffectWarning] : undefined }
}
export interface PerformActivateVersionParams {
workflowId: string
version: number
userId: string
/** Stable identity for one logical activation operation. */
idempotencyKey?: string
/** Correlation ID for logging and outbox tracing. */
requestId?: string
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
}
export interface PerformActivateVersionResult {
success: boolean
deployedAt?: Date
activeDeployment?: ActiveDeploymentResult | null
latestDeploymentAttempt?: DeploymentAttemptResult | null
error?: string
errorCode?: OrchestrationErrorCode
warnings?: string[]
}
export interface PerformRevertToVersionParams {
workflowId: string
version: number | 'active'
userId: string
workflow: Record<string, unknown>
request?: NextRequest
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
actorName?: string
actorEmail?: string
}
export interface PerformRevertToVersionResult {
success: boolean
lastSaved?: number
error?: string
errorCode?: OrchestrationErrorCode
}
/**
* Admits an existing version through the v2 prepare/activate protocol. Callers
* that can replay a logical operation must provide a stable `idempotencyKey`.
*/
export async function performActivateVersion(
params: PerformActivateVersionParams
): Promise<PerformActivateVersionResult> {
const { workflowId, version, userId } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const idempotencyKey = params.idempotencyKey ?? generateId()
const lockDenial = await workflowLockDenial(workflowId)
if (lockDenial) return { success: false, error: lockDenial, errorCode: 'validation' }
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
isActive: workflowDeploymentVersion.isActive,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
if (!versionRow?.state) {
return { success: false, error: 'Deployment version not found', errorCode: 'not_found' }
}
if (versionRow.isActive) {
const [workflowDeployment] = await db
.select({ deployedAt: workflowTable.deployedAt })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
const status = await getWorkflowDeploymentStatus(workflowId)
const stableResult = buildStableDeploymentResult(status, 'completed')
return {
success: true,
deployedAt: stableResult.deployedAt ?? workflowDeployment?.deployedAt ?? new Date(),
activeDeployment: stableResult.activeDeployment,
latestDeploymentAttempt: stableResult.latestDeploymentAttempt,
warnings: stableResult.warnings,
}
}
const deployedState = versionRow.state as { blocks?: Record<string, unknown> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return { success: false, error: 'Invalid deployed state structure', errorCode: 'validation' }
}
const scheduleValidation = validateWorkflowSchedules(blocks as Record<string, BlockState>)
if (!scheduleValidation.isValid) {
return {
success: false,
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
errorCode: 'validation',
}
}
const triggerValidation = await validateTriggerWebhookConfigForDeploy(
blocks as Record<string, BlockState>
)
if (!triggerValidation.success) {
return {
success: false,
error: triggerValidation.error?.message || 'Invalid trigger configuration',
errorCode: 'validation',
}
}
try {
return await performStableVersionActivation({
workflowId,
deploymentVersionId: versionRow.id,
version,
userId,
actorId,
requestId,
idempotencyKey,
})
} catch (error) {
logger.error(`[${requestId}] Version activation preparation failed`, {
workflowId,
version,
error,
})
return {
success: false,
error: getErrorMessage(error, 'Failed to prepare version activation'),
errorCode: 'internal',
}
}
}
async function performStableVersionActivation(params: {
workflowId: string
deploymentVersionId: string
version: number
userId: string
actorId: string
requestId: string
idempotencyKey: string
}): Promise<PerformActivateVersionResult> {
const requestHash = createDeploymentRequestHash({
action: 'activate',
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
version: params.version,
userId: params.userId,
})
let outboxEventId: string | undefined
const prepared = await prepareWorkflowVersionActivation({
workflowId: params.workflowId,
deploymentVersionId: params.deploymentVersionId,
actorId: params.actorId,
requestHash,
idempotencyKey: bindIdempotencyKeyToRequest(params.idempotencyKey, requestHash),
readinessComponents: DEPLOYMENT_READINESS_COMPONENTS,
onPrepareTransaction: async (tx, operation) => {
if (!operation.deploymentVersionId || operation.version === null) {
throw new Error('Prepared activation operation is missing its target version')
}
outboxEventId = await enqueueWorkflowDeploymentPreparation(tx, {
protocolVersion: operation.protocolVersion,
operationId: operation.id,
generation: operation.generation,
workflowId: operation.workflowId,
deploymentVersionId: operation.deploymentVersionId,
version: operation.version,
userId: params.userId,
requestId: params.requestId,
checkpoints: {},
})
},
})
if (!prepared.success) {
return {
success: false,
error: prepared.error,
errorCode: mapPrepareFailureCode(prepared.reason),
}
}
const processResult = await processStableDeploymentPreparationNow(outboxEventId, params.requestId)
const status = await getWorkflowDeploymentStatus(params.workflowId)
const inlineFailure = buildInlinePreparationFailure(prepared.operation.id, status)
if (inlineFailure) return inlineFailure
const result = buildStableDeploymentResult(status, processResult)
return {
success: result.success,
deployedAt: result.deployedAt,
activeDeployment: result.activeDeployment,
latestDeploymentAttempt: result.latestDeploymentAttempt,
warnings: result.warnings,
}
}
async function processDeploymentSideEffectsNow(
outboxEventId: string | undefined,
requestId: string
): Promise<string | undefined> {
if (!outboxEventId) {
return 'Deployment state changed, but side-effect sync was not queued. Redeploy if triggers or schedules look stale.'
}
try {
const result = await processWorkflowDeploymentOutboxEvent(outboxEventId)
if (result === 'completed') return undefined
if (result === 'dead_letter' || result === 'not_found') {
logger.error(`[${requestId}] Deployment side-effect sync cannot be retried automatically`, {
outboxEventId,
result,
})
return 'Deployment saved, but trigger, schedule, and MCP sync could not be queued. Redeploy if triggers or schedules look stale.'
}
logger.warn(`[${requestId}] Deployment side-effect sync queued for retry`, {
outboxEventId,
result,
})
return 'Deployment saved. Trigger, schedule, and MCP sync is queued and may finish shortly.'
} catch (error) {
logger.warn(`[${requestId}] Deployment side-effect sync queued for retry`, {
outboxEventId,
error,
})
return 'Deployment saved. Trigger, schedule, and MCP sync is queued and may finish shortly.'
}
}
/**
* Reverts the current workflow draft to match a saved deployment version.
* This matches the deployment modal's "load deployment" behavior and is used
* by both the HTTP route and the mothership tool handler.
*/
export async function performRevertToVersion(
params: PerformRevertToVersionParams
): Promise<PerformRevertToVersionResult> {
const { workflowId, version, userId, workflow } = params
const actorId = params.actorId ?? userId
const versionLabel = String(version)
const lastSaved = Date.now()
let saveResult: { success: boolean; error?: string; errorCode?: OrchestrationErrorCode }
try {
await assertWorkflowMutable(workflowId)
saveResult = await db.transaction(async (tx) => {
await tx
.select({ id: workflowTable.id })
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
.for('update')
const [stateRow] =
version === 'active'
? await tx
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
: await tx
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, version)
)
)
.limit(1)
if (!stateRow?.state) {
return { success: false, error: 'Deployment version not found' }
}
const deployedState = stateRow.state as {
blocks?: Record<string, unknown>
edges?: unknown[]
loops?: Record<string, unknown>
parallels?: Record<string, unknown>
variables?: WorkflowState['variables']
}
if (!deployedState.blocks || !deployedState.edges) {
return { success: false, error: 'Invalid deployed state structure' }
}
const hasDeploymentVariables = Object.hasOwn(deployedState, 'variables')
const restoredState: WorkflowState = {
blocks: deployedState.blocks,
edges: deployedState.edges,
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
lastSaved,
} as WorkflowState
if (hasDeploymentVariables) {
restoredState.variables = deployedState.variables || {}
}
const result = await saveWorkflowToNormalizedTables(workflowId, restoredState, tx)
if (!result.success) return result
await tx
.update(workflowTable)
.set({
...(hasDeploymentVariables ? { variables: deployedState.variables || {} } : {}),
lastSynced: new Date(),
updatedAt: new Date(),
})
.where(eq(workflowTable.id, workflowId))
return result
})
} catch (error) {
if (error instanceof WorkflowLockedError) {
return { success: false, error: error.message, errorCode: 'validation' }
}
throw error
}
if (!saveResult.success) {
return {
success: false,
error: saveResult.error || 'Failed to save deployed state',
errorCode:
saveResult.error === 'Deployment version not found'
? 'not_found'
: saveResult.error === 'Invalid deployed state structure'
? 'internal'
: 'internal',
}
}
try {
await fetch(`${getSocketServerUrl()}/api/workflow-reverted`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.INTERNAL_API_SECRET,
},
body: JSON.stringify({ workflowId, timestamp: lastSaved }),
})
} catch (error) {
logger.error('Error sending workflow reverted event to socket server', error)
}
const workspaceId = (workflow.workspaceId as string) || ''
captureServerEvent(
userId,
'workflow_deployment_reverted',
{
workflow_id: workflowId,
workspace_id: workspaceId,
version: versionLabel,
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
recordAudit({
workspaceId: workspaceId || null,
actorId,
actorName: params.actorName,
actorEmail: params.actorEmail,
action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: (workflow.name as string) || undefined,
description: `Reverted workflow to deployment version ${versionLabel}`,
metadata: {
targetVersion: versionLabel,
},
request: params.request,
})
return {
success: true,
lastSaved,
}
}