d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
746 lines
22 KiB
TypeScript
746 lines
22 KiB
TypeScript
import { db, workflowDeploymentVersion, workflow as workflowTable } from '@sim/db'
|
|
import { createLogger } from '@sim/logger'
|
|
import { and, eq, ne } from 'drizzle-orm'
|
|
import { NextRequest } from 'next/server'
|
|
import {
|
|
enqueueOutboxEvent,
|
|
type OutboxHandlerRegistry,
|
|
type ProcessSingleOutboxResult,
|
|
processOutboxEventById,
|
|
} from '@/lib/core/outbox/service'
|
|
import { generateRequestId } from '@/lib/core/utils/request'
|
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
|
import { setWorkflowMcpTransactionLockTimeout } from '@/lib/mcp/server-locks'
|
|
import {
|
|
notifyMcpToolServers,
|
|
removeMcpToolsForWorkflow,
|
|
syncMcpToolsForWorkflow,
|
|
} from '@/lib/mcp/workflow-mcp-sync'
|
|
import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
|
|
import { createSchedulesForDeploy, deleteSchedulesForWorkflow } from '@/lib/workflows/schedules'
|
|
import type { BlockState } from '@/stores/workflows/workflow/types'
|
|
|
|
const logger = createLogger('WorkflowDeploymentOutbox')
|
|
|
|
export const WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS = {
|
|
SYNC_ACTIVE_SIDE_EFFECTS: 'workflow.deployment.sync-active-side-effects',
|
|
CLEANUP_INACTIVE_SIDE_EFFECTS: 'workflow.deployment.cleanup-inactive-side-effects',
|
|
CLEANUP_UNDEPLOYED_SIDE_EFFECTS: 'workflow.deployment.cleanup-undeployed-side-effects',
|
|
} as const
|
|
|
|
interface SyncActiveSideEffectsPayload {
|
|
workflowId: string
|
|
deploymentVersionId: string
|
|
userId: string
|
|
requestId?: string
|
|
forceRecreateSubscriptions?: boolean
|
|
}
|
|
|
|
interface CleanupUndeployedSideEffectsPayload {
|
|
workflowId: string
|
|
deploymentVersionIds: string[]
|
|
userId: string
|
|
requestId?: string
|
|
}
|
|
|
|
interface CleanupInactiveSideEffectsPayload {
|
|
workflowId: string
|
|
activeDeploymentVersionId: string
|
|
userId: string
|
|
requestId?: string
|
|
}
|
|
|
|
export async function enqueueWorkflowDeploymentSideEffects(
|
|
executor: Pick<typeof db, 'insert'>,
|
|
payload: SyncActiveSideEffectsPayload
|
|
): Promise<string> {
|
|
return enqueueOutboxEvent(
|
|
executor,
|
|
WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS,
|
|
payload,
|
|
{ maxAttempts: 10 }
|
|
)
|
|
}
|
|
|
|
export async function enqueueWorkflowUndeploySideEffects(
|
|
executor: Pick<typeof db, 'insert'>,
|
|
payload: CleanupUndeployedSideEffectsPayload
|
|
): Promise<string> {
|
|
return enqueueOutboxEvent(
|
|
executor,
|
|
WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS,
|
|
payload,
|
|
{ maxAttempts: 10 }
|
|
)
|
|
}
|
|
|
|
async function enqueueWorkflowInactiveDeploymentCleanup(
|
|
executor: Pick<typeof db, 'insert'>,
|
|
payload: CleanupInactiveSideEffectsPayload
|
|
): Promise<string> {
|
|
return enqueueOutboxEvent(
|
|
executor,
|
|
WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_INACTIVE_SIDE_EFFECTS,
|
|
payload,
|
|
{ maxAttempts: 10 }
|
|
)
|
|
}
|
|
|
|
export async function processWorkflowDeploymentOutboxEvent(
|
|
eventId: string
|
|
): Promise<ProcessSingleOutboxResult> {
|
|
return processOutboxEventById(eventId, workflowDeploymentOutboxHandlers)
|
|
}
|
|
|
|
const syncActiveSideEffects = async (rawPayload: unknown): Promise<void> => {
|
|
const payload = parseSyncActiveSideEffectsPayload(rawPayload)
|
|
const requestId = payload.requestId ?? generateRequestId()
|
|
const [workflowRecord] = await db
|
|
.select()
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, payload.workflowId))
|
|
.limit(1)
|
|
|
|
if (!workflowRecord) {
|
|
logger.warn(`[${requestId}] Workflow missing during deployment side-effect sync`, {
|
|
workflowId: payload.workflowId,
|
|
})
|
|
return
|
|
}
|
|
|
|
const [versionRow] = await db
|
|
.select({
|
|
id: workflowDeploymentVersion.id,
|
|
state: workflowDeploymentVersion.state,
|
|
isActive: workflowDeploymentVersion.isActive,
|
|
})
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, payload.workflowId),
|
|
eq(workflowDeploymentVersion.id, payload.deploymentVersionId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!versionRow?.isActive) {
|
|
logger.info(`[${requestId}] Skipping stale deployment side-effect sync`, {
|
|
workflowId: payload.workflowId,
|
|
deploymentVersionId: payload.deploymentVersionId,
|
|
})
|
|
if (versionRow) {
|
|
await cleanupDeploymentVersionIfInactive({
|
|
workflowId: payload.workflowId,
|
|
deploymentVersionId: payload.deploymentVersionId,
|
|
workflow: workflowRecord as Record<string, unknown>,
|
|
userId: payload.userId,
|
|
requestId,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
const state = versionRow.state as { blocks?: Record<string, BlockState> }
|
|
const blocks = state.blocks ?? {}
|
|
const workflowData = workflowRecord as Record<string, unknown>
|
|
|
|
if (!(await cleanupStaleDeploymentIfNeeded({ payload, workflow: workflowData, requestId }))) {
|
|
return
|
|
}
|
|
|
|
const request = new NextRequest(new URL('/api/webhooks', getBaseUrl()))
|
|
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
|
|
request,
|
|
workflowId: payload.workflowId,
|
|
workflow: workflowData,
|
|
userId: payload.userId,
|
|
blocks,
|
|
requestId,
|
|
deploymentVersionId: payload.deploymentVersionId,
|
|
forceRecreateSubscriptions: payload.forceRecreateSubscriptions ?? false,
|
|
strictExternalCleanup: true,
|
|
})
|
|
|
|
if (!triggerSaveResult.success) {
|
|
throw new Error(triggerSaveResult.error?.message || 'Failed to sync trigger configuration')
|
|
}
|
|
|
|
if (!(await cleanupStaleDeploymentIfNeeded({ payload, workflow: workflowData, requestId }))) {
|
|
return
|
|
}
|
|
|
|
const scheduleResult = await createSchedulesIfStillActive({
|
|
workflowId: payload.workflowId,
|
|
deploymentVersionId: payload.deploymentVersionId,
|
|
blocks,
|
|
})
|
|
if (!scheduleResult.success) {
|
|
throw new Error(scheduleResult.error || 'Failed to sync schedules')
|
|
}
|
|
|
|
if (!(await cleanupStaleDeploymentIfNeeded({ payload, workflow: workflowData, requestId }))) {
|
|
return
|
|
}
|
|
|
|
await syncMcpToolsIfStillActive({
|
|
workflowId: payload.workflowId,
|
|
deploymentVersionId: payload.deploymentVersionId,
|
|
requestId,
|
|
state,
|
|
})
|
|
|
|
if (!(await cleanupStaleDeploymentIfNeeded({ payload, workflow: workflowData, requestId }))) {
|
|
return
|
|
}
|
|
|
|
if (workflowRecord.workspaceId) {
|
|
await pruneWorkflowGroupOutputsIfStillActive({
|
|
workflowId: payload.workflowId,
|
|
deploymentVersionId: payload.deploymentVersionId,
|
|
workspaceId: workflowRecord.workspaceId,
|
|
validBlockIds: new Set(Object.keys(blocks)),
|
|
requestId,
|
|
})
|
|
}
|
|
|
|
if (!(await cleanupStaleDeploymentIfNeeded({ payload, workflow: workflowData, requestId }))) {
|
|
return
|
|
}
|
|
|
|
await syncInactiveDeploymentCleanup({
|
|
workflowId: payload.workflowId,
|
|
activeDeploymentVersionId: payload.deploymentVersionId,
|
|
workflow: workflowData,
|
|
userId: payload.userId,
|
|
requestId,
|
|
})
|
|
}
|
|
|
|
const cleanupInactiveSideEffects = async (rawPayload: unknown): Promise<void> => {
|
|
const payload = parseCleanupInactiveSideEffectsPayload(rawPayload)
|
|
const requestId = payload.requestId ?? generateRequestId()
|
|
const [workflowRecord] = await db
|
|
.select()
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, payload.workflowId))
|
|
.limit(1)
|
|
|
|
if (!workflowRecord) return
|
|
|
|
await cleanupInactiveDeploymentVersions({
|
|
workflowId: payload.workflowId,
|
|
activeDeploymentVersionId: payload.activeDeploymentVersionId,
|
|
workflow: workflowRecord as Record<string, unknown>,
|
|
userId: payload.userId,
|
|
requestId,
|
|
})
|
|
}
|
|
|
|
const cleanupUndeployedSideEffects = async (rawPayload: unknown): Promise<void> => {
|
|
const payload = parseCleanupUndeployedSideEffectsPayload(rawPayload)
|
|
const requestId = payload.requestId ?? generateRequestId()
|
|
const [workflowRecord] = await db
|
|
.select()
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, payload.workflowId))
|
|
.limit(1)
|
|
|
|
if (!workflowRecord) return
|
|
const workflowData = workflowRecord as Record<string, unknown>
|
|
|
|
for (const deploymentVersionId of payload.deploymentVersionIds) {
|
|
const [versionRow] = await db
|
|
.select({ isActive: workflowDeploymentVersion.isActive })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, payload.workflowId),
|
|
eq(workflowDeploymentVersion.id, deploymentVersionId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!versionRow || versionRow.isActive) continue
|
|
await cleanupDeploymentVersionIfInactive({
|
|
workflowId: payload.workflowId,
|
|
workflow: workflowData,
|
|
userId: payload.userId,
|
|
requestId,
|
|
deploymentVersionId,
|
|
})
|
|
}
|
|
|
|
await cleanupNullVersionWebhooksIfStillUndeployed({
|
|
workflowId: payload.workflowId,
|
|
workflow: workflowData,
|
|
requestId,
|
|
})
|
|
|
|
await removeMcpToolsIfStillUndeployed(payload.workflowId, requestId)
|
|
}
|
|
|
|
/**
|
|
* Run inactive-version cleanup synchronously as part of the active-version sync, right
|
|
* after the active version's webhooks/schedules are registered.
|
|
*
|
|
* {@link cleanupInactiveDeploymentVersions} re-checks that each version is still inactive
|
|
* before tearing anything down, so it can never touch the now-active version. Running it
|
|
* inline — rather than only enqueueing it — closes the window where a lost
|
|
* `CLEANUP_INACTIVE` outbox event leaves superseded webhooks behind as live-but-never-polled
|
|
* `is_active` orphans. The deferred event is kept as a fallback so cleanup still retries if
|
|
* the inline pass throws, without failing the already-succeeded registration.
|
|
*/
|
|
async function syncInactiveDeploymentCleanup(params: {
|
|
workflowId: string
|
|
activeDeploymentVersionId: string
|
|
workflow: Record<string, unknown>
|
|
userId: string
|
|
requestId: string
|
|
}): Promise<void> {
|
|
try {
|
|
await cleanupInactiveDeploymentVersions(params)
|
|
} catch (cleanupError) {
|
|
logger.warn(
|
|
`[${params.requestId}] Inline inactive-deployment cleanup failed; deferring to outbox retry`,
|
|
cleanupError
|
|
)
|
|
await enqueueWorkflowInactiveDeploymentCleanup(db, {
|
|
workflowId: params.workflowId,
|
|
activeDeploymentVersionId: params.activeDeploymentVersionId,
|
|
userId: params.userId,
|
|
requestId: params.requestId,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function cleanupInactiveDeploymentVersions(params: {
|
|
workflowId: string
|
|
activeDeploymentVersionId: string
|
|
workflow: Record<string, unknown>
|
|
userId: string
|
|
requestId: string
|
|
}): Promise<void> {
|
|
const inactiveVersions = await db
|
|
.select({ id: workflowDeploymentVersion.id })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, params.workflowId),
|
|
ne(workflowDeploymentVersion.id, params.activeDeploymentVersionId),
|
|
eq(workflowDeploymentVersion.isActive, false)
|
|
)
|
|
)
|
|
|
|
for (const version of inactiveVersions) {
|
|
await cleanupDeploymentVersionIfInactive({
|
|
workflowId: params.workflowId,
|
|
workflow: params.workflow,
|
|
userId: params.userId,
|
|
requestId: params.requestId,
|
|
deploymentVersionId: version.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function cleanupDeploymentVersionIfInactive(params: {
|
|
workflowId: string
|
|
deploymentVersionId: string
|
|
workflow: Record<string, unknown>
|
|
userId: string
|
|
requestId: string
|
|
}): Promise<void> {
|
|
if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) {
|
|
await enqueueWorkflowDeploymentSideEffects(db, {
|
|
workflowId: params.workflowId,
|
|
deploymentVersionId: params.deploymentVersionId,
|
|
userId: params.userId,
|
|
requestId: params.requestId,
|
|
forceRecreateSubscriptions: true,
|
|
})
|
|
return
|
|
}
|
|
|
|
const isStillInactive = async () =>
|
|
!(await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId))
|
|
|
|
await cleanupWebhooksForWorkflow(
|
|
params.workflowId,
|
|
params.workflow,
|
|
params.requestId,
|
|
params.deploymentVersionId,
|
|
false,
|
|
true,
|
|
isStillInactive
|
|
)
|
|
|
|
if (!(await isStillInactive())) {
|
|
await enqueueWorkflowDeploymentSideEffects(db, {
|
|
workflowId: params.workflowId,
|
|
deploymentVersionId: params.deploymentVersionId,
|
|
userId: params.userId,
|
|
requestId: params.requestId,
|
|
forceRecreateSubscriptions: true,
|
|
})
|
|
return
|
|
}
|
|
|
|
const deletedSchedules = await deleteSchedulesForDeploymentIfInactive({
|
|
workflowId: params.workflowId,
|
|
deploymentVersionId: params.deploymentVersionId,
|
|
})
|
|
if (!deletedSchedules) {
|
|
if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) {
|
|
await enqueueWorkflowDeploymentSideEffects(db, {
|
|
workflowId: params.workflowId,
|
|
deploymentVersionId: params.deploymentVersionId,
|
|
userId: params.userId,
|
|
requestId: params.requestId,
|
|
forceRecreateSubscriptions: true,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
if (await isDeploymentVersionActive(params.workflowId, params.deploymentVersionId)) {
|
|
await enqueueWorkflowDeploymentSideEffects(db, {
|
|
workflowId: params.workflowId,
|
|
deploymentVersionId: params.deploymentVersionId,
|
|
userId: params.userId,
|
|
requestId: params.requestId,
|
|
forceRecreateSubscriptions: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function deleteSchedulesForDeploymentIfInactive(params: {
|
|
workflowId: string
|
|
deploymentVersionId: string
|
|
}): Promise<boolean> {
|
|
return db.transaction(async (tx) => {
|
|
const [versionRow] = await tx
|
|
.select({ id: workflowDeploymentVersion.id })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, params.workflowId),
|
|
eq(workflowDeploymentVersion.id, params.deploymentVersionId),
|
|
eq(workflowDeploymentVersion.isActive, false)
|
|
)
|
|
)
|
|
.limit(1)
|
|
.for('update')
|
|
|
|
if (!versionRow) return false
|
|
|
|
await deleteSchedulesForWorkflow(params.workflowId, tx, params.deploymentVersionId)
|
|
return true
|
|
})
|
|
}
|
|
|
|
async function cleanupStaleDeploymentIfNeeded(params: {
|
|
payload: SyncActiveSideEffectsPayload
|
|
workflow: Record<string, unknown>
|
|
requestId: string
|
|
}): Promise<boolean> {
|
|
if (
|
|
await isDeploymentVersionActive(params.payload.workflowId, params.payload.deploymentVersionId)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
logger.info(`[${params.requestId}] Cleaning up stale deployment side effects`, {
|
|
workflowId: params.payload.workflowId,
|
|
deploymentVersionId: params.payload.deploymentVersionId,
|
|
})
|
|
await cleanupDeploymentVersionIfInactive({
|
|
workflowId: params.payload.workflowId,
|
|
workflow: params.workflow,
|
|
userId: params.payload.userId,
|
|
requestId: params.requestId,
|
|
deploymentVersionId: params.payload.deploymentVersionId,
|
|
})
|
|
return false
|
|
}
|
|
|
|
async function isDeploymentVersionActive(
|
|
workflowId: string,
|
|
deploymentVersionId: string
|
|
): Promise<boolean> {
|
|
const [versionRow] = await db
|
|
.select({ id: workflowDeploymentVersion.id })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, workflowId),
|
|
eq(workflowDeploymentVersion.id, deploymentVersionId),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
return Boolean(versionRow)
|
|
}
|
|
|
|
async function removeMcpToolsIfStillUndeployed(
|
|
workflowId: string,
|
|
requestId: string
|
|
): Promise<void> {
|
|
const tools = await db.transaction(async (tx) => {
|
|
await setWorkflowMcpTransactionLockTimeout(tx)
|
|
|
|
const [workflowRecord] = await tx
|
|
.select({ id: workflowTable.id, isDeployed: workflowTable.isDeployed })
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, workflowId))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
if (!workflowRecord || workflowRecord.isDeployed) return []
|
|
return removeMcpToolsForWorkflow(workflowId, requestId, tx, true)
|
|
})
|
|
notifyMcpToolServers(tools)
|
|
}
|
|
|
|
async function cleanupNullVersionWebhooksIfStillUndeployed(params: {
|
|
workflowId: string
|
|
workflow: Record<string, unknown>
|
|
requestId: string
|
|
}): Promise<void> {
|
|
const isStillUndeployed = async () => {
|
|
const [workflowRecord] = await db
|
|
.select({ isDeployed: workflowTable.isDeployed })
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, params.workflowId))
|
|
.limit(1)
|
|
|
|
return Boolean(workflowRecord && !workflowRecord.isDeployed)
|
|
}
|
|
|
|
if (!(await isStillUndeployed())) return
|
|
await cleanupWebhooksForWorkflow(
|
|
params.workflowId,
|
|
params.workflow,
|
|
params.requestId,
|
|
null,
|
|
false,
|
|
true,
|
|
isStillUndeployed
|
|
)
|
|
}
|
|
|
|
async function syncMcpToolsIfStillActive(params: {
|
|
workflowId: string
|
|
deploymentVersionId: string
|
|
requestId: string
|
|
state: { blocks?: Record<string, unknown> }
|
|
}): Promise<void> {
|
|
const tools = await db.transaction(async (tx) => {
|
|
await setWorkflowMcpTransactionLockTimeout(tx)
|
|
|
|
const [workflowRecord] = await tx
|
|
.select({ id: workflowTable.id })
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, params.workflowId))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
if (!workflowRecord) return []
|
|
|
|
const [versionRow] = await tx
|
|
.select({ id: workflowDeploymentVersion.id })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, params.workflowId),
|
|
eq(workflowDeploymentVersion.id, params.deploymentVersionId),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!versionRow) return []
|
|
|
|
return syncMcpToolsForWorkflow({
|
|
workflowId: params.workflowId,
|
|
requestId: params.requestId,
|
|
state: params.state,
|
|
context: 'deployment-outbox',
|
|
tx,
|
|
notify: false,
|
|
throwOnError: true,
|
|
})
|
|
})
|
|
notifyMcpToolServers(tools)
|
|
}
|
|
|
|
async function createSchedulesIfStillActive(params: {
|
|
workflowId: string
|
|
deploymentVersionId: string
|
|
blocks: Record<string, BlockState>
|
|
}) {
|
|
return db.transaction(async (tx) => {
|
|
const [workflowRecord] = await tx
|
|
.select({ id: workflowTable.id })
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, params.workflowId))
|
|
.limit(1)
|
|
.for('update')
|
|
|
|
if (!workflowRecord) {
|
|
return { success: true as const }
|
|
}
|
|
|
|
const [versionRow] = await tx
|
|
.select({ id: workflowDeploymentVersion.id })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, params.workflowId),
|
|
eq(workflowDeploymentVersion.id, params.deploymentVersionId),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!versionRow) {
|
|
return { success: true as const }
|
|
}
|
|
|
|
const result = await createSchedulesForDeploy(
|
|
params.workflowId,
|
|
params.blocks,
|
|
tx,
|
|
params.deploymentVersionId
|
|
)
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Failed to sync schedules')
|
|
}
|
|
return result
|
|
})
|
|
}
|
|
|
|
async function pruneWorkflowGroupOutputsIfStillActive(params: {
|
|
workflowId: string
|
|
deploymentVersionId: string
|
|
workspaceId: string
|
|
validBlockIds: Set<string>
|
|
requestId: string
|
|
}): Promise<void> {
|
|
await db.transaction(async (tx) => {
|
|
const [workflowRecord] = await tx
|
|
.select({ id: workflowTable.id })
|
|
.from(workflowTable)
|
|
.where(eq(workflowTable.id, params.workflowId))
|
|
.limit(1)
|
|
.for('update')
|
|
|
|
if (!workflowRecord) return
|
|
|
|
const [versionRow] = await tx
|
|
.select({ id: workflowDeploymentVersion.id })
|
|
.from(workflowDeploymentVersion)
|
|
.where(
|
|
and(
|
|
eq(workflowDeploymentVersion.workflowId, params.workflowId),
|
|
eq(workflowDeploymentVersion.id, params.deploymentVersionId),
|
|
eq(workflowDeploymentVersion.isActive, true)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!versionRow) return
|
|
|
|
const { pruneStaleWorkflowGroupOutputs } = await import('@/lib/table/workflow-groups/service')
|
|
await pruneStaleWorkflowGroupOutputs({
|
|
workflowId: params.workflowId,
|
|
workspaceId: params.workspaceId,
|
|
validBlockIds: params.validBlockIds,
|
|
requestId: params.requestId,
|
|
tx,
|
|
})
|
|
})
|
|
}
|
|
|
|
function parseSyncActiveSideEffectsPayload(payload: unknown): SyncActiveSideEffectsPayload {
|
|
const record = parsePayloadRecord(payload)
|
|
const workflowId = parseRequiredString(record.workflowId, 'workflowId')
|
|
const deploymentVersionId = parseRequiredString(record.deploymentVersionId, 'deploymentVersionId')
|
|
const userId = parseRequiredString(record.userId, 'userId')
|
|
const requestId =
|
|
typeof record.requestId === 'string' && record.requestId.length > 0
|
|
? record.requestId
|
|
: undefined
|
|
const forceRecreateSubscriptions =
|
|
typeof record.forceRecreateSubscriptions === 'boolean'
|
|
? record.forceRecreateSubscriptions
|
|
: undefined
|
|
|
|
return { workflowId, deploymentVersionId, userId, requestId, forceRecreateSubscriptions }
|
|
}
|
|
|
|
function parseCleanupUndeployedSideEffectsPayload(
|
|
payload: unknown
|
|
): CleanupUndeployedSideEffectsPayload {
|
|
const record = parsePayloadRecord(payload)
|
|
const workflowId = parseRequiredString(record.workflowId, 'workflowId')
|
|
const userId = parseRequiredString(record.userId, 'userId')
|
|
const deploymentVersionIds = parseRequiredStringArray(
|
|
record.deploymentVersionIds,
|
|
'deploymentVersionIds'
|
|
)
|
|
const requestId =
|
|
typeof record.requestId === 'string' && record.requestId.length > 0
|
|
? record.requestId
|
|
: undefined
|
|
|
|
return { workflowId, deploymentVersionIds, userId, requestId }
|
|
}
|
|
|
|
function parseCleanupInactiveSideEffectsPayload(
|
|
payload: unknown
|
|
): CleanupInactiveSideEffectsPayload {
|
|
const record = parsePayloadRecord(payload)
|
|
const workflowId = parseRequiredString(record.workflowId, 'workflowId')
|
|
const activeDeploymentVersionId = parseRequiredString(
|
|
record.activeDeploymentVersionId,
|
|
'activeDeploymentVersionId'
|
|
)
|
|
const userId = parseRequiredString(record.userId, 'userId')
|
|
const requestId =
|
|
typeof record.requestId === 'string' && record.requestId.length > 0
|
|
? record.requestId
|
|
: undefined
|
|
|
|
return { workflowId, activeDeploymentVersionId, userId, requestId }
|
|
}
|
|
|
|
function parsePayloadRecord(payload: unknown): Record<string, unknown> {
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
throw new Error('Deployment outbox payload must be an object')
|
|
}
|
|
return payload as Record<string, unknown>
|
|
}
|
|
|
|
function parseRequiredString(value: unknown, fieldName: string): string {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
throw new Error(`Deployment outbox payload is missing ${fieldName}`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function parseRequiredStringArray(value: unknown, fieldName: string): string[] {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.some((item) => typeof item !== 'string' || item.length === 0)
|
|
) {
|
|
throw new Error(`Deployment outbox payload is missing ${fieldName}`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
export const workflowDeploymentOutboxHandlers: OutboxHandlerRegistry = {
|
|
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.SYNC_ACTIVE_SIDE_EFFECTS]: syncActiveSideEffects,
|
|
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_INACTIVE_SIDE_EFFECTS]: cleanupInactiveSideEffects,
|
|
[WORKFLOW_DEPLOYMENT_OUTBOX_EVENTS.CLEANUP_UNDEPLOYED_SIDE_EFFECTS]: cleanupUndeployedSideEffects,
|
|
}
|