chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,648 @@
|
||||
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 { 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 {
|
||||
enqueueWorkflowDeploymentSideEffects,
|
||||
enqueueWorkflowUndeploySideEffects,
|
||||
processWorkflowDeploymentOutboxEvent,
|
||||
} from '@/lib/workflows/deployment-outbox'
|
||||
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
|
||||
import {
|
||||
activateWorkflowVersion,
|
||||
deployWorkflow,
|
||||
saveWorkflowToNormalizedTables,
|
||||
undeployWorkflow,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { validateWorkflowSchedules } from '@/lib/workflows/schedules'
|
||||
import {
|
||||
emitWorkflowDeployedEvent,
|
||||
emitWorkflowUndeployedEvent,
|
||||
} from '@/lib/workspace-events/emitter'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('DeployOrchestration')
|
||||
|
||||
/**
|
||||
* Notifies the socket server that a workflow's deployment state has changed,
|
||||
* so all connected clients can refresh their deployment queries.
|
||||
*/
|
||||
async function notifySocketDeploymentChanged(workflowId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${getSocketServerUrl()}/api/workflow-deployed`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': env.INTERNAL_API_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ workflowId }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
`Socket deployment notification failed (${response.status}) for workflow ${workflowId}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending workflow deployed event to socket server', error)
|
||||
}
|
||||
}
|
||||
|
||||
export interface PerformFullDeployParams {
|
||||
workflowId: string
|
||||
userId: string
|
||||
workflowName?: 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
|
||||
requestId?: string
|
||||
/**
|
||||
* Optional NextRequest for external webhook subscriptions.
|
||||
* If not provided, a synthetic request is constructed from the base URL.
|
||||
*/
|
||||
request?: NextRequest
|
||||
/**
|
||||
* Override the actor ID used in audit logs and the `deployedBy` field.
|
||||
* Defaults to `userId`. Use `'admin-api'` for admin-initiated actions.
|
||||
*/
|
||||
actorId?: string
|
||||
}
|
||||
|
||||
export interface PerformFullDeployResult {
|
||||
success: boolean
|
||||
deployedAt?: Date
|
||||
version?: number
|
||||
deploymentVersionId?: string
|
||||
error?: string
|
||||
errorCode?: OrchestrationErrorCode
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a full workflow deployment: creates a deployment version, queues
|
||||
* external side effects transactionally, processes that outbox event after
|
||||
* commit, and notifies clients. Both the deploy API route and the copilot
|
||||
* deploy tools must use this single function so behaviour stays consistent.
|
||||
*/
|
||||
export async function performFullDeploy(
|
||||
params: PerformFullDeployParams
|
||||
): Promise<PerformFullDeployResult> {
|
||||
const { workflowId, userId, workflowName } = params
|
||||
const actorId = params.actorId ?? userId
|
||||
const requestId = params.requestId ?? generateRequestId()
|
||||
|
||||
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' }
|
||||
}
|
||||
|
||||
const workflowData = workflowRecord as Record<string, unknown>
|
||||
let outboxEventId: string | undefined
|
||||
|
||||
const deployResult = await deployWorkflow({
|
||||
workflowId,
|
||||
deployedBy: actorId,
|
||||
workflowName: workflowName || workflowRecord.name || undefined,
|
||||
description: params.versionDescription,
|
||||
name: params.versionName,
|
||||
validateWorkflowState: async (workflowState) => {
|
||||
const scheduleValidation = validateWorkflowSchedules(workflowState.blocks)
|
||||
if (!scheduleValidation.isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid schedule configuration: ${scheduleValidation.error}`,
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
const triggerValidation = await validateTriggerWebhookConfigForDeploy(workflowState.blocks)
|
||||
if (!triggerValidation.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: triggerValidation.error?.message || 'Invalid trigger configuration',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
return { success: true }
|
||||
},
|
||||
onDeployTransaction: async (tx, result) => {
|
||||
outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
|
||||
workflowId,
|
||||
deploymentVersionId: result.deploymentVersionId,
|
||||
userId,
|
||||
requestId,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (!deployResult.success) {
|
||||
const error = deployResult.error || 'Failed to deploy workflow'
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
errorCode: deployResult.errorCode,
|
||||
}
|
||||
}
|
||||
|
||||
const deployedAt = deployResult.deployedAt!
|
||||
const deploymentVersionId = deployResult.deploymentVersionId
|
||||
const previousVersionId = deployResult.previousVersionId
|
||||
const deploymentSnapshot = deployResult.currentState
|
||||
|
||||
if (!deploymentVersionId || !deploymentSnapshot) {
|
||||
await undeployWorkflow({ workflowId })
|
||||
return { success: false, error: 'Failed to resolve deployment version' }
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: (workflowData.workspaceId as string) || null,
|
||||
actorId: actorId,
|
||||
action: AuditAction.WORKFLOW_DEPLOYED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: workflowId,
|
||||
resourceName: (workflowData.name as string) || undefined,
|
||||
description: `Deployed workflow "${(workflowData.name as string) || workflowId}"`,
|
||||
metadata: {
|
||||
deploymentVersionId,
|
||||
version: deployResult.version,
|
||||
previousVersionId: previousVersionId || undefined,
|
||||
},
|
||||
request: params.request,
|
||||
})
|
||||
|
||||
const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId)
|
||||
await notifySocketDeploymentChanged(workflowId)
|
||||
|
||||
const workspaceId = workflowData.workspaceId as string | null
|
||||
if (workspaceId) {
|
||||
void emitWorkflowDeployedEvent({
|
||||
workflowId,
|
||||
workflowName: (workflowData.name as string) || workflowId,
|
||||
workspaceId,
|
||||
version: deployResult.version ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deployedAt,
|
||||
version: deployResult.version,
|
||||
deploymentVersionId,
|
||||
warnings: sideEffectWarning ? [sideEffectWarning] : 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 [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
|
||||
workflow: Record<string, unknown>
|
||||
requestId?: string
|
||||
request?: NextRequest
|
||||
/** Override the actor ID used in audit logs. Defaults to `userId`. */
|
||||
actorId?: string
|
||||
}
|
||||
|
||||
export interface PerformActivateVersionResult {
|
||||
success: boolean
|
||||
deployedAt?: Date
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates an existing deployment version: validates schedules, activates the
|
||||
* version, queues external side effects transactionally, processes that outbox
|
||||
* event after commit, and records an audit entry. Both the deployment version
|
||||
* PATCH handler and the admin activate route must use this function.
|
||||
*/
|
||||
export async function performActivateVersion(
|
||||
params: PerformActivateVersionParams
|
||||
): Promise<PerformActivateVersionResult> {
|
||||
const { workflowId, version, userId, workflow } = params
|
||||
const actorId = params.actorId ?? userId
|
||||
const requestId = params.requestId ?? generateRequestId()
|
||||
|
||||
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)
|
||||
|
||||
return { success: true, deployedAt: workflowDeployment?.deployedAt ?? new Date(), 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',
|
||||
}
|
||||
}
|
||||
|
||||
let outboxEventId: string | undefined
|
||||
const result = await activateWorkflowVersion({
|
||||
workflowId,
|
||||
version,
|
||||
onActivateTransaction: async (tx, activation) => {
|
||||
outboxEventId = await enqueueWorkflowDeploymentSideEffects(tx, {
|
||||
workflowId,
|
||||
deploymentVersionId: activation.deploymentVersionId,
|
||||
userId,
|
||||
requestId,
|
||||
forceRecreateSubscriptions: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to activate version' }
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: (workflow.workspaceId as string) || null,
|
||||
actorId: actorId,
|
||||
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
|
||||
resourceType: AuditResourceType.WORKFLOW,
|
||||
resourceId: workflowId,
|
||||
description: `Activated deployment version ${version}`,
|
||||
resourceName: (workflow.name as string) || undefined,
|
||||
metadata: {
|
||||
version,
|
||||
deploymentVersionId: versionRow.id,
|
||||
previousVersionId: result.previousVersionId || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const sideEffectWarning = await processDeploymentSideEffectsNow(outboxEventId, requestId)
|
||||
await notifySocketDeploymentChanged(workflowId)
|
||||
|
||||
const activationWorkspaceId = (workflow.workspaceId as string) || null
|
||||
if (activationWorkspaceId) {
|
||||
void emitWorkflowDeployedEvent({
|
||||
workflowId,
|
||||
workflowName: (workflow.name as string) || workflowId,
|
||||
workspaceId: activationWorkspaceId,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deployedAt: result.deployedAt,
|
||||
warnings: sideEffectWarning ? [sideEffectWarning] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user