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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,249 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { performFullDeploy } from '@/lib/workflows/orchestration/deploy'
const logger = createLogger('ChatDeployOrchestration')
export interface ChatDeployPayload {
workflowId: string
userId: string
identifier: string
title: string
description?: string
/** Summary of what changed in this deployment version (distinct from the chat-facing `description`). */
versionDescription?: string
/** Short name/label for this deployment version. */
versionName?: string
customizations?: { primaryColor?: string; welcomeMessage?: string; imageUrl?: string }
authType?: 'public' | 'password' | 'email' | 'sso'
password?: string | null
allowedEmails?: string[]
outputConfigs?: Array<{ blockId: string; path: string }>
workspaceId?: string | null
}
export interface PerformChatDeployResult {
success: boolean
chatId?: string
chatUrl?: string
deployedAt?: Date | null
version?: number
error?: string
}
/**
* Deploys a chat: deploys the underlying workflow via `performFullDeploy`,
* encrypts passwords, creates or updates the chat record, fires telemetry,
* and records an audit entry. Both the chat API route and the copilot
* `deploy_chat` tool must use this function.
*/
export async function performChatDeploy(
params: ChatDeployPayload
): Promise<PerformChatDeployResult> {
const {
workflowId,
userId,
identifier,
title,
description = '',
authType = 'public',
password,
allowedEmails = [],
outputConfigs = [],
} = params
const customizations = {
primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)',
welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}),
}
const deployResult = await performFullDeploy({
workflowId,
userId,
versionDescription: params.versionDescription,
versionName: params.versionName,
})
if (!deployResult.success) {
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
}
let encryptedPassword: string | null = null
if (authType === 'password' && password) {
const { encrypted } = await encryptSecret(password)
encryptedPassword = encrypted
}
const [existingDeployment] = await db
.select()
.from(chat)
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
.limit(1)
let chatId: string
if (existingDeployment) {
chatId = existingDeployment.id
let passwordToStore: string | null
if (authType === 'password') {
passwordToStore = encryptedPassword || existingDeployment.password
} else {
passwordToStore = null
}
await db
.update(chat)
.set({
identifier,
title,
description: description || null,
customizations,
authType,
password: passwordToStore,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
updatedAt: new Date(),
})
.where(eq(chat.id, chatId))
} else {
chatId = generateId()
await db.insert(chat).values({
id: chatId,
workflowId,
userId,
identifier,
title,
description: description || null,
customizations,
isActive: true,
authType,
password: encryptedPassword,
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
createdAt: new Date(),
updatedAt: new Date(),
})
}
const baseUrl = getBaseUrl()
let chatUrl: string
try {
const url = new URL(baseUrl)
let host = url.host
if (host.startsWith('www.')) {
host = host.substring(4)
}
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
} catch {
chatUrl = `${baseUrl}/chat/${identifier}`
}
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.chatDeployed({
chatId,
workflowId,
authType,
hasOutputConfigs: outputConfigs.length > 0,
})
} catch (_e) {
// Telemetry is best-effort
}
recordAudit({
workspaceId: params.workspaceId || null,
actorId: userId,
action: AuditAction.CHAT_DEPLOYED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: title,
description: `Deployed chat "${title}"`,
metadata: {
workflowId,
identifier,
authType,
chatUrl,
isUpdate: !!existingDeployment,
hasOutputConfigs: outputConfigs.length > 0,
hasCustomizations: !!(
params.customizations?.primaryColor ||
params.customizations?.welcomeMessage ||
params.customizations?.imageUrl
),
},
})
return {
success: true,
chatId,
chatUrl,
deployedAt: deployResult.deployedAt,
version: deployResult.version,
}
}
export interface PerformChatUndeployParams {
chatId: string
userId: string
workspaceId?: string | null
}
export interface PerformChatUndeployResult {
success: boolean
error?: string
}
/**
* Undeploys a chat: deletes the chat record and records an audit entry.
* Both the chat manage DELETE route and the copilot `deploy_chat` undeploy
* action must use this function.
*/
export async function performChatUndeploy(
params: PerformChatUndeployParams
): Promise<PerformChatUndeployResult> {
const { chatId, userId, workspaceId } = params
const [chatRecord] = await db
.select({
title: chat.title,
workflowId: chat.workflowId,
identifier: chat.identifier,
authType: chat.authType,
})
.from(chat)
.where(eq(chat.id, chatId))
.limit(1)
if (!chatRecord) {
return { success: false, error: 'Chat not found' }
}
await db.delete(chat).where(eq(chat.id, chatId))
logger.info(`Chat "${chatId}" deleted successfully`)
recordAudit({
workspaceId: workspaceId || null,
actorId: userId,
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord.title || chatId,
description: `Deleted chat deployment "${chatRecord.title || chatId}"`,
metadata: {
workflowId: chatRecord.workflowId || undefined,
identifier: chatRecord.identifier || undefined,
authType: chatRecord.authType || undefined,
},
})
return { success: true }
}
@@ -0,0 +1,360 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockLimit,
mockUpdateSet,
mockSaveWorkflowToNormalizedTables,
mockRecordAudit,
mockCaptureServerEvent,
mockTransaction,
mockDeployWorkflow,
mockActivateWorkflowVersion,
mockValidateWorkflowSchedules,
mockValidateTriggerWebhookConfigForDeploy,
mockEmitWorkflowDeployedEvent,
mockTx,
} = vi.hoisted(() => ({
mockLimit: vi.fn(),
mockUpdateSet: vi.fn(),
mockSaveWorkflowToNormalizedTables: vi.fn(),
mockRecordAudit: vi.fn(),
mockCaptureServerEvent: vi.fn(),
mockTransaction: vi.fn(),
mockDeployWorkflow: vi.fn(),
mockActivateWorkflowVersion: vi.fn(),
mockValidateWorkflowSchedules: vi.fn(),
mockValidateTriggerWebhookConfigForDeploy: vi.fn(),
mockEmitWorkflowDeployedEvent: vi.fn(),
mockTx: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(() => ({
for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]),
})),
})),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
execute: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: mockLimit,
})),
})),
})),
update: vi.fn(() => ({
set: mockUpdateSet,
})),
transaction: mockTransaction,
},
workflow: { id: 'workflow.id' },
workflowDeploymentVersion: {
workflowId: 'workflowDeploymentVersion.workflowId',
version: 'workflowDeploymentVersion.version',
isActive: 'workflowDeploymentVersion.isActive',
state: 'workflowDeploymentVersion.state',
},
}))
vi.mock('@sim/audit', () => ({
AuditAction: {
WORKFLOW_DEPLOYMENT_REVERTED: 'WORKFLOW_DEPLOYMENT_REVERTED',
WORKFLOW_DEPLOYED: 'WORKFLOW_DEPLOYED',
WORKFLOW_UNDEPLOYED: 'WORKFLOW_UNDEPLOYED',
WORKFLOW_DEPLOYMENT_ACTIVATED: 'WORKFLOW_DEPLOYMENT_ACTIVATED',
},
AuditResourceType: { WORKFLOW: 'WORKFLOW' },
recordAudit: mockRecordAudit,
}))
vi.mock('@/lib/workflows/deployment-outbox', () => ({
enqueueWorkflowDeploymentSideEffects: vi.fn().mockResolvedValue('outbox-1'),
enqueueWorkflowUndeploySideEffects: vi.fn().mockResolvedValue('outbox-2'),
processWorkflowDeploymentOutboxEvent: vi.fn().mockResolvedValue('completed'),
}))
vi.mock('@/lib/workspace-events/emitter', () => ({
emitWorkflowDeployedEvent: mockEmitWorkflowDeployedEvent,
}))
vi.mock('@/lib/core/config/env', () => ({
env: { INTERNAL_API_SECRET: 'secret' },
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'http://localhost:3000',
getSocketServerUrl: () => 'http://localhost:3002',
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
activateWorkflowVersion: mockActivateWorkflowVersion,
activateWorkflowVersionById: vi.fn(),
deployWorkflow: mockDeployWorkflow,
loadWorkflowDeploymentSnapshot: vi.fn(),
saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables,
undeployWorkflow: vi.fn(),
}))
vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
removeMcpToolsForWorkflow: vi.fn(),
syncMcpToolsForWorkflow: vi.fn(),
}))
vi.mock('@/lib/webhooks/deploy', () => ({
cleanupWebhooksForWorkflow: vi.fn(),
restorePreviousVersionWebhooks: vi.fn(),
saveTriggerWebhooksForDeploy: vi.fn(),
validateTriggerWebhookConfigForDeploy: mockValidateTriggerWebhookConfigForDeploy,
}))
vi.mock('@/lib/workflows/schedules', () => ({
cleanupDeploymentVersion: vi.fn(),
createSchedulesForDeploy: vi.fn(),
validateWorkflowSchedules: mockValidateWorkflowSchedules,
}))
import {
performActivateVersion,
performFullDeploy,
performRevertToVersion,
} from '@/lib/workflows/orchestration/deploy'
describe('performRevertToVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockTransaction.mockImplementation(async (callback) => callback(mockTx))
mockTx.select.mockImplementation((selection?: Record<string, unknown>) => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit:
selection && Object.hasOwn(selection, 'state')
? mockLimit
: vi.fn(() => ({
for: vi.fn().mockResolvedValue([{ id: 'workflow-1' }]),
})),
})),
})),
}))
mockTx.update.mockReturnValue({ set: mockUpdateSet })
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true })
})
it('restores variables when the deployment snapshot includes them', async () => {
mockLimit.mockResolvedValue([
{
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
},
},
])
const result = await performRevertToVersion({
workflowId: 'workflow-1',
version: 3,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledWith(
'workflow-1',
expect.objectContaining({
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
}),
mockTx
)
expect(mockUpdateSet).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
variableA: {
id: 'variableA',
name: 'API_KEY',
type: 'plain',
value: 'deployed-value',
},
},
})
)
})
it('preserves existing variables when reverting a legacy snapshot without variables', async () => {
mockLimit.mockResolvedValue([
{
state: {
blocks: {},
edges: [],
loops: {},
parallels: {},
},
},
])
const result = await performRevertToVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
const savedState = mockSaveWorkflowToNormalizedTables.mock.calls[0][1]
expect(Object.hasOwn(savedState, 'variables')).toBe(false)
const workflowUpdate = mockUpdateSet.mock.calls[0][0]
expect(Object.hasOwn(workflowUpdate, 'variables')).toBe(false)
})
})
describe('performFullDeploy workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockLimit.mockResolvedValue([
{ id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
])
mockDeployWorkflow.mockResolvedValue({
success: true,
deployedAt: new Date(),
version: 4,
deploymentVersionId: 'dv-1',
previousVersionId: null,
currentState: { blocks: {} },
})
})
it('emits workflow_deployed after a successful deploy', async () => {
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledTimes(1)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({
workflowId: 'workflow-1',
workflowName: 'My Workflow',
workspaceId: 'workspace-1',
version: 4,
})
})
it('does not emit when the deploy fails', async () => {
mockDeployWorkflow.mockResolvedValueOnce({ success: false, error: 'nope' })
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(false)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('emission rejection does not fail the deploy', async () => {
mockEmitWorkflowDeployedEvent.mockRejectedValueOnce(new Error('emit failed'))
const result = await performFullDeploy({
workflowId: 'workflow-1',
userId: 'user-1',
})
expect(result.success).toBe(true)
})
})
describe('performActivateVersion workspace event emission', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })))
mockValidateWorkflowSchedules.mockReturnValue({ isValid: true })
mockValidateTriggerWebhookConfigForDeploy.mockResolvedValue({ success: true })
mockLimit.mockResolvedValue([{ id: 'dv-2', state: { blocks: {} }, isActive: false }])
mockActivateWorkflowVersion.mockResolvedValue({
success: true,
deployedAt: new Date(),
previousVersionId: 'dv-1',
})
})
it('emits workflow_deployed when activating a version (rollback/activation)', async () => {
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).toHaveBeenCalledWith({
workflowId: 'workflow-1',
workflowName: 'My Workflow',
workspaceId: 'workspace-1',
version: 2,
})
})
it('does not emit when the version is already active (no-op activation)', async () => {
mockLimit
.mockResolvedValueOnce([{ id: 'dv-2', state: { blocks: {} }, isActive: true }])
.mockResolvedValueOnce([{ deployedAt: new Date() }])
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(true)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
it('does not emit when activation fails', async () => {
mockActivateWorkflowVersion.mockResolvedValueOnce({ success: false, error: 'nope' })
const result = await performActivateVersion({
workflowId: 'workflow-1',
version: 2,
userId: 'user-1',
workflow: { id: 'workflow-1', name: 'My Workflow', workspaceId: 'workspace-1' },
})
expect(result.success).toBe(false)
expect(mockEmitWorkflowDeployedEvent).not.toHaveBeenCalled()
})
})
@@ -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,
}
}
@@ -0,0 +1,543 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import {
chat,
webhook,
workflow,
workflowFolder,
workflowMcpTool,
workflowSchedule,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray, isNull, min } from 'drizzle-orm'
import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
import { checkForCircularReference } from '@/lib/workflows/utils'
const logger = createLogger('FolderLifecycle')
export interface PerformCreateFolderParams {
userId: string
workspaceId: string
name: string
id?: string
parentId?: string | null
color?: string
sortOrder?: number
}
export interface PerformCreateFolderResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
folder?: typeof workflowFolder.$inferSelect
}
export interface PerformUpdateFolderParams {
folderId: string
workspaceId: string
userId: string
name?: string
color?: string
isExpanded?: boolean
locked?: boolean
parentId?: string | null
sortOrder?: number
}
export interface PerformUpdateFolderResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
folder?: typeof workflowFolder.$inferSelect
}
/**
* Verifies that a prospective parent folder exists, belongs to the target
* workspace, and is not archived. Mirrors the validation in the duplicate
* route's `assertTargetParentFolderMutable` so a caller cannot reparent a
* folder to a non-existent id or to a folder in another workspace. Returns
* an error result when invalid, or `null` when the parent is acceptable.
*/
async function assertParentFolderInWorkspace(
parentId: string,
workspaceId: string
): Promise<{ error: string; errorCode: OrchestrationErrorCode } | null> {
const [parent] = await db
.select({
workspaceId: workflowFolder.workspaceId,
archivedAt: workflowFolder.archivedAt,
})
.from(workflowFolder)
.where(eq(workflowFolder.id, parentId))
.limit(1)
if (!parent || parent.workspaceId !== workspaceId || parent.archivedAt) {
return { error: 'Parent folder not found', errorCode: 'validation' }
}
return null
}
async function nextFolderSortOrder(
workspaceId: string,
parentId: string | null | undefined
): Promise<number> {
const folderParentCondition = parentId
? eq(workflowFolder.parentId, parentId)
: isNull(workflowFolder.parentId)
const workflowParentCondition = parentId
? eq(workflow.folderId, parentId)
: isNull(workflow.folderId)
const [[folderResult], [workflowResult]] = await Promise.all([
db
.select({ minSortOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
db
.select({ minSortOrder: min(workflow.sortOrder) })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)),
])
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
return minSortOrder != null ? minSortOrder - 1 : 0
}
export async function performCreateFolder(
params: PerformCreateFolderParams
): Promise<PerformCreateFolderResult> {
try {
const folderId = params.id || generateId()
const parentId = params.parentId || null
if (parentId) {
if (parentId === folderId) {
return {
success: false,
error: 'Folder cannot be its own parent',
errorCode: 'validation',
}
}
const parentError = await assertParentFolderInWorkspace(parentId, params.workspaceId)
if (parentError) return { success: false, ...parentError }
}
const sortOrder =
params.sortOrder !== undefined
? params.sortOrder
: await nextFolderSortOrder(params.workspaceId, parentId)
const [folder] = await db
.insert(workflowFolder)
.values({
id: folderId,
name: params.name.trim(),
userId: params.userId,
workspaceId: params.workspaceId,
parentId,
color: params.color || '#6B7280',
sortOrder,
})
.returning()
logger.info('Created workflow folder', { folderId, workspaceId: params.workspaceId, parentId })
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
action: AuditAction.FOLDER_CREATED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folder.name,
description: `Created folder "${folder.name}"`,
metadata: {
name: folder.name,
workspaceId: params.workspaceId,
parentId: parentId || undefined,
color: folder.color,
sortOrder: folder.sortOrder,
},
})
return { success: true, folder }
} catch (error) {
logger.error('Failed to create workflow folder', { error })
return { success: false, error: 'Internal server error', errorCode: 'internal' }
}
}
export async function performUpdateFolder(
params: PerformUpdateFolderParams
): Promise<PerformUpdateFolderResult> {
try {
if (params.parentId && params.parentId === params.folderId) {
return { success: false, error: 'Folder cannot be its own parent', errorCode: 'validation' }
}
if (params.parentId) {
const parentError = await assertParentFolderInWorkspace(params.parentId, params.workspaceId)
if (parentError) return { success: false, ...parentError }
const wouldCreateCycle = await checkForCircularReference(params.folderId, params.parentId)
if (wouldCreateCycle) {
return {
success: false,
error: 'Cannot create circular folder reference',
errorCode: 'validation',
}
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (params.name !== undefined) updates.name = params.name.trim()
if (params.color !== undefined) updates.color = params.color
if (params.isExpanded !== undefined) updates.isExpanded = params.isExpanded
if (params.locked !== undefined) updates.locked = params.locked
if (params.parentId !== undefined) updates.parentId = params.parentId || null
if (params.sortOrder !== undefined) updates.sortOrder = params.sortOrder
const [folder] = await db
.update(workflowFolder)
.set(updates)
.where(
and(
eq(workflowFolder.id, params.folderId),
eq(workflowFolder.workspaceId, params.workspaceId)
)
)
.returning()
if (!folder) {
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
}
logger.info('Updated workflow folder', { folderId: params.folderId, updates })
return { success: true, folder }
} catch (error) {
logger.error('Failed to update workflow folder', { error })
return { success: false, error: 'Internal server error', errorCode: 'internal' }
}
}
/**
* Recursively deletes a folder: removes child folders first, archives non-archived
* workflows in each folder via {@link archiveWorkflowsByIdsInWorkspace}, then deletes
* the folder row.
*/
async function deleteFolderRecursively(
folderId: string,
workspaceId: string,
archivedAt?: Date
): Promise<{ folders: number; workflows: number }> {
const timestamp = archivedAt ?? new Date()
const stats = { folders: 0, workflows: 0 }
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.parentId, folderId),
eq(workflowFolder.workspaceId, workspaceId),
isNull(workflowFolder.archivedAt)
)
)
for (const childFolder of childFolders) {
const childStats = await deleteFolderRecursively(childFolder.id, workspaceId, timestamp)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
if (workflowsInFolder.length > 0) {
await archiveWorkflowsByIdsInWorkspace(
workspaceId,
workflowsInFolder.map((entry) => entry.id),
{ requestId: `folder-${folderId}`, archivedAt: timestamp }
)
stats.workflows += workflowsInFolder.length
}
await db
.update(workflowFolder)
.set({ archivedAt: timestamp })
.where(eq(workflowFolder.id, folderId))
stats.folders += 1
return stats
}
/**
* Counts non-archived workflows in the folder and all descendant folders.
*/
async function countWorkflowsInFolderRecursively(
folderId: string,
workspaceId: string
): Promise<number> {
let count = 0
const workflowsInFolder = await db
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
isNull(workflow.archivedAt)
)
)
count += workflowsInFolder.length
const childFolders = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.parentId, folderId),
eq(workflowFolder.workspaceId, workspaceId),
isNull(workflowFolder.archivedAt)
)
)
for (const childFolder of childFolders) {
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
}
return count
}
/** Parameters for {@link performDeleteFolder}. */
export interface PerformDeleteFolderParams {
folderId: string
workspaceId: string
userId: string
folderName?: string
}
/** Outcome of {@link performDeleteFolder}. */
export interface PerformDeleteFolderResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
deletedItems?: { folders: number; workflows: number }
}
/**
* Performs a full folder deletion: enforces the last-workflow guard,
* recursively archives child workflows and sub-folders, and records
* an audit entry. Both the folders API DELETE handler and the copilot
* delete_folder tool must use this function.
*/
export async function performDeleteFolder(
params: PerformDeleteFolderParams
): Promise<PerformDeleteFolderResult> {
const { folderId, workspaceId, userId, folderName } = params
const workflowsInFolder = await countWorkflowsInFolderRecursively(folderId, workspaceId)
const totalWorkflowsInWorkspace = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
return {
success: false,
error: 'Cannot delete folder containing the only workflow(s) in the workspace',
errorCode: 'validation',
}
}
const deletionStats = await deleteFolderRecursively(folderId, workspaceId)
logger.info('Deleted folder and all contents:', { folderId, deletionStats })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName,
description: `Deleted folder "${folderName || folderId}"`,
metadata: {
affected: {
workflows: deletionStats.workflows,
subfolders: deletionStats.folders - 1,
},
},
})
return { success: true, deletedItems: deletionStats }
}
/**
* Recursively restores a folder and its children/workflows within a transaction.
* Only restores workflows whose `archivedAt` matches the folder's — workflows
* individually deleted before the folder are left archived.
*/
async function restoreFolderRecursively(
folderId: string,
workspaceId: string,
folderArchivedAt: Date,
tx: Parameters<Parameters<typeof db.transaction>[0]>[0]
): Promise<{ folders: number; workflows: number }> {
const stats = { folders: 0, workflows: 0 }
await tx.update(workflowFolder).set({ archivedAt: null }).where(eq(workflowFolder.id, folderId))
stats.folders += 1
const archivedWorkflows = await tx
.select({ id: workflow.id })
.from(workflow)
.where(
and(
eq(workflow.folderId, folderId),
eq(workflow.workspaceId, workspaceId),
eq(workflow.archivedAt, folderArchivedAt)
)
)
if (archivedWorkflows.length > 0) {
const workflowIds = archivedWorkflows.map((wf) => wf.id)
const now = new Date()
const restoreSet = { archivedAt: null, updatedAt: now }
await tx.update(workflow).set(restoreSet).where(inArray(workflow.id, workflowIds))
await tx
.update(workflowSchedule)
.set(restoreSet)
.where(inArray(workflowSchedule.workflowId, workflowIds))
await tx.update(webhook).set(restoreSet).where(inArray(webhook.workflowId, workflowIds))
await tx.update(chat).set(restoreSet).where(inArray(chat.workflowId, workflowIds))
await tx
.update(workflowMcpTool)
.set(restoreSet)
.where(inArray(workflowMcpTool.workflowId, workflowIds))
stats.workflows += archivedWorkflows.length
}
const archivedChildren = await tx
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(
and(
eq(workflowFolder.parentId, folderId),
eq(workflowFolder.workspaceId, workspaceId),
eq(workflowFolder.archivedAt, folderArchivedAt)
)
)
for (const child of archivedChildren) {
const childStats = await restoreFolderRecursively(child.id, workspaceId, folderArchivedAt, tx)
stats.folders += childStats.folders
stats.workflows += childStats.workflows
}
return stats
}
/** Parameters for {@link performRestoreFolder}. */
export interface PerformRestoreFolderParams {
folderId: string
workspaceId: string
userId: string
folderName?: string
}
/** Outcome of {@link performRestoreFolder}. */
export interface PerformRestoreFolderResult {
success: boolean
error?: string
restoredItems?: { folders: number; workflows: number }
}
/**
* Restores an archived folder and all its archived children and workflows.
* If the folder's parent is still archived, moves it to the root level.
*/
export async function performRestoreFolder(
params: PerformRestoreFolderParams
): Promise<PerformRestoreFolderResult> {
const { folderId, workspaceId, userId, folderName } = params
const [folder] = await db
.select()
.from(workflowFolder)
.where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId)))
if (!folder) {
return { success: false, error: 'Folder not found' }
}
if (!folder.archivedAt) {
return { success: true, restoredItems: { folders: 0, workflows: 0 } }
}
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws || ws.archivedAt) {
return { success: false, error: 'Cannot restore folder into an archived workspace' }
}
const restoredStats = await db.transaction(async (tx) => {
if (folder.parentId) {
const [parentFolder] = await tx
.select({ archivedAt: workflowFolder.archivedAt })
.from(workflowFolder)
.where(eq(workflowFolder.id, folder.parentId))
if (!parentFolder || parentFolder.archivedAt) {
await tx
.update(workflowFolder)
.set({ parentId: null })
.where(eq(workflowFolder.id, folderId))
}
}
return restoreFolderRecursively(folderId, workspaceId, folder.archivedAt!, tx)
})
logger.info('Restored folder and all contents:', { folderId, restoredStats })
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FOLDER_RESTORED,
resourceType: AuditResourceType.FOLDER,
resourceId: folderId,
resourceName: folderName ?? folder.name,
description: `Restored folder "${folderName ?? folder.name}"`,
metadata: {
affected: {
workflows: restoredStats.workflows,
subfolders: restoredStats.folders - 1,
},
},
})
return { success: true, restoredItems: restoredStats }
}
@@ -0,0 +1,22 @@
export {
performChatDeploy,
performChatUndeploy,
} from './chat-deploy'
export {
performActivateVersion,
performFullDeploy,
performFullUndeploy,
performRevertToVersion,
} from './deploy'
export {
performCreateFolder,
performDeleteFolder,
performRestoreFolder,
performUpdateFolder,
} from './folder-lifecycle'
export {
performCreateWorkflow,
performDeleteWorkflow,
performRestoreWorkflow,
performUpdateWorkflow,
} from './workflow-lifecycle'
@@ -0,0 +1,11 @@
export type OrchestrationErrorCode = 'validation' | 'not_found' | 'conflict' | 'internal'
/**
* Maps an orchestration error code to its HTTP status. Shared by every route
* surface (UI, v1, tool routes) so deployment errors map identically.
*/
export function statusForOrchestrationError(code: OrchestrationErrorCode | undefined): number {
if (code === 'validation') return 400
if (code === 'not_found') return 404
return 500
}
@@ -0,0 +1,478 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isFolderInWorkspace } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, min, ne } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { archiveWorkflow, restoreWorkflow } from '@/lib/workflows/lifecycle'
import type { OrchestrationErrorCode } from '@/lib/workflows/orchestration/types'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
const logger = createLogger('WorkflowLifecycle')
export interface PerformCreateWorkflowParams {
userId: string
workspaceId: string
name: string
id?: string
description?: string | null
folderId?: string | null
sortOrder?: number
deduplicate?: boolean
requestId?: string
}
export interface PerformCreateWorkflowResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
workflow?: {
id: string
name: string
description?: string | null
workspaceId: string
folderId?: string | null
sortOrder: number
createdAt: Date
updatedAt: Date
startBlockId?: string
subBlockValues: Record<string, unknown>
}
}
export interface PerformUpdateWorkflowParams {
workflowId: string
userId: string
workspaceId: string
currentName: string
currentFolderId?: string | null
/** Prior `locked` value, used to detect lock-state transitions for instrumentation. */
currentLocked?: boolean | null
name?: string
description?: string | null
folderId?: string | null
sortOrder?: number
locked?: boolean
requestId?: string
}
export interface PerformUpdateWorkflowResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
workflow?: {
id: string
name: string
description: string | null
workspaceId: string | null
folderId: string | null
sortOrder: number | null
locked: boolean | null
createdAt: Date
updatedAt: Date
archivedAt: Date | null
}
}
export interface PerformDeleteWorkflowParams {
workflowId: string
userId: string
requestId?: string
/** When true, allows deleting the last workflow in a workspace (used by admin API). */
skipLastWorkflowGuard?: boolean
/** Override the actor ID used in audit logs. Defaults to `userId`. */
actorId?: string
}
export interface PerformDeleteWorkflowResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
}
export interface PerformRestoreWorkflowParams {
workflowId: string
userId: string
requestId?: string
}
export interface PerformRestoreWorkflowResult {
success: boolean
error?: string
errorCode?: OrchestrationErrorCode
workflow?: Awaited<ReturnType<typeof restoreWorkflow>>['workflow']
}
async function nextWorkflowSortOrder(
workspaceId: string,
folderId: string | null | undefined
): Promise<number> {
const workflowParentCondition = folderId
? eq(workflow.folderId, folderId)
: isNull(workflow.folderId)
const folderParentCondition = folderId
? eq(workflowFolder.parentId, folderId)
: isNull(workflowFolder.parentId)
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
db
.select({ minOrder: min(workflow.sortOrder) })
.from(workflow)
.where(
and(
eq(workflow.workspaceId, workspaceId),
workflowParentCondition,
isNull(workflow.archivedAt)
)
),
db
.select({ minOrder: min(workflowFolder.sortOrder) })
.from(workflowFolder)
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
])
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
number | null
>((currentMin, candidate) => {
if (candidate == null) return currentMin
if (currentMin == null) return candidate
return Math.min(currentMin, candidate)
}, null)
return minSortOrder != null ? minSortOrder - 1 : 0
}
async function workflowNameExistsInFolder(params: {
workspaceId: string
name: string
folderId?: string | null
excludeWorkflowId?: string
}): Promise<boolean> {
const conditions = [
eq(workflow.workspaceId, params.workspaceId),
isNull(workflow.archivedAt),
eq(workflow.name, params.name),
]
if (params.excludeWorkflowId) {
conditions.push(ne(workflow.id, params.excludeWorkflowId))
}
if (params.folderId) {
conditions.push(eq(workflow.folderId, params.folderId))
} else {
conditions.push(isNull(workflow.folderId))
}
const [duplicateWorkflow] = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(...conditions))
.limit(1)
return Boolean(duplicateWorkflow)
}
export async function performCreateWorkflow(
params: PerformCreateWorkflowParams
): Promise<PerformCreateWorkflowResult> {
const requestId = params.requestId ?? generateRequestId()
const workflowId = params.id || generateId()
const folderId = params.folderId || null
try {
if (!(await isFolderInWorkspace(folderId, params.workspaceId))) {
return { success: false, error: 'Target folder not found', errorCode: 'validation' }
}
const name = params.deduplicate
? await deduplicateWorkflowName(params.name, params.workspaceId, folderId)
: params.name
if (!params.deduplicate) {
const duplicate = await workflowNameExistsInFolder({
workspaceId: params.workspaceId,
name,
folderId,
})
if (duplicate) {
return {
success: false,
error: `A workflow named "${name}" already exists in this folder`,
errorCode: 'conflict',
}
}
}
const sortOrder =
params.sortOrder !== undefined
? params.sortOrder
: await nextWorkflowSortOrder(params.workspaceId, folderId)
const now = new Date()
const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts()
await db.transaction(async (tx) => {
await tx.insert(workflow).values({
id: workflowId,
userId: params.userId,
workspaceId: params.workspaceId,
folderId,
sortOrder,
name,
description: params.description,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
})
logger.info(`[${requestId}] Successfully created workflow ${workflowId}`)
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
action: AuditAction.WORKFLOW_CREATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: name,
description: `Created workflow "${name}"`,
metadata: {
name,
description: params.description || undefined,
workspaceId: params.workspaceId,
folderId: folderId || undefined,
sortOrder,
},
})
return {
success: true,
workflow: {
id: workflowId,
name,
description: params.description,
workspaceId: params.workspaceId,
folderId,
sortOrder,
createdAt: now,
updatedAt: now,
startBlockId,
subBlockValues,
},
}
} catch (error) {
logger.error(`[${requestId}] Failed to create workflow`, { error })
return { success: false, error: toError(error).message, errorCode: 'internal' }
}
}
export async function performUpdateWorkflow(
params: PerformUpdateWorkflowParams
): Promise<PerformUpdateWorkflowResult> {
const requestId = params.requestId ?? generateRequestId()
try {
const targetName = params.name ?? params.currentName
const targetFolderId =
params.folderId !== undefined ? params.folderId || null : params.currentFolderId || null
if (
params.folderId !== undefined &&
!(await isFolderInWorkspace(targetFolderId, params.workspaceId))
) {
return { success: false, error: 'Target folder not found', errorCode: 'validation' }
}
if (params.name !== undefined || params.folderId !== undefined) {
const duplicate = await workflowNameExistsInFolder({
workspaceId: params.workspaceId,
name: targetName,
folderId: targetFolderId,
excludeWorkflowId: params.workflowId,
})
if (duplicate) {
return {
success: false,
error: `A workflow named "${targetName}" already exists in this folder`,
errorCode: 'conflict',
}
}
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (params.name !== undefined) updateData.name = params.name
if (params.description !== undefined) updateData.description = params.description
if (params.folderId !== undefined) updateData.folderId = params.folderId
if (params.sortOrder !== undefined) updateData.sortOrder = params.sortOrder
if (params.locked !== undefined) updateData.locked = params.locked
const [updatedWorkflow] = await db
.update(workflow)
.set(updateData)
.where(eq(workflow.id, params.workflowId))
.returning({
id: workflow.id,
name: workflow.name,
description: workflow.description,
workspaceId: workflow.workspaceId,
folderId: workflow.folderId,
sortOrder: workflow.sortOrder,
locked: workflow.locked,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
archivedAt: workflow.archivedAt,
})
if (!updatedWorkflow) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
logger.info(`[${requestId}] Successfully updated workflow ${params.workflowId}`, {
updates: updateData,
})
if (params.locked !== undefined && params.locked !== (params.currentLocked ?? false)) {
const workspaceId = updatedWorkflow.workspaceId
recordAudit({
workspaceId: workspaceId ?? null,
actorId: params.userId,
action: params.locked ? AuditAction.WORKFLOW_LOCKED : AuditAction.WORKFLOW_UNLOCKED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: params.workflowId,
resourceName: updatedWorkflow.name,
description: `${params.locked ? 'Locked' : 'Unlocked'} workflow "${updatedWorkflow.name}"`,
metadata: { locked: params.locked },
})
captureServerEvent(
params.userId,
'workflow_lock_toggled',
{
workflow_id: params.workflowId,
...(workspaceId ? { workspace_id: workspaceId } : {}),
locked: params.locked,
},
workspaceId ? { groups: { workspace: workspaceId } } : undefined
)
}
return { success: true, workflow: updatedWorkflow }
} catch (error) {
logger.error(`[${requestId}] Failed to update workflow ${params.workflowId}`, { error })
return { success: false, error: toError(error).message, errorCode: 'internal' }
}
}
/**
* Performs a full workflow deletion: enforces the last-workflow guard,
* archives the workflow via `archiveWorkflow`, and records an audit entry.
* Both the workflow API DELETE handler and the copilot delete_workflow tool
* must use this function.
*/
export async function performDeleteWorkflow(
params: PerformDeleteWorkflowParams
): Promise<PerformDeleteWorkflowResult> {
const { workflowId, userId, skipLastWorkflowGuard = false } = params
const actorId = params.actorId ?? userId
const requestId = params.requestId ?? generateRequestId()
const [workflowRecord] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowRecord) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
if (!skipLastWorkflowGuard && workflowRecord.workspaceId) {
const totalWorkflows = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workflowRecord.workspaceId), isNull(workflow.archivedAt)))
if (totalWorkflows.length <= 1) {
return {
success: false,
error: 'Cannot delete the only workflow in the workspace',
errorCode: 'validation',
}
}
}
const archiveResult = await archiveWorkflow(workflowId, { requestId })
if (!archiveResult.workflow) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
logger.info(`[${requestId}] Successfully archived workflow ${workflowId}`)
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: actorId,
action: AuditAction.WORKFLOW_DELETED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowRecord.name,
description: `Archived workflow "${workflowRecord.name}"`,
metadata: {
archived: archiveResult.archived,
},
})
return { success: true }
}
export async function performRestoreWorkflow(
params: PerformRestoreWorkflowParams
): Promise<PerformRestoreWorkflowResult> {
const { workflowId, userId } = params
const requestId = params.requestId ?? generateRequestId()
try {
const restoreResult = await restoreWorkflow(workflowId, { requestId })
if (!restoreResult.workflow) {
return { success: false, error: 'Workflow not found', errorCode: 'not_found' }
}
if (!restoreResult.restored) {
return {
success: false,
error: 'Workflow is not archived',
errorCode: 'validation',
workflow: restoreResult.workflow,
}
}
logger.info(`[${requestId}] Successfully restored workflow ${workflowId}`)
recordAudit({
workspaceId: restoreResult.workflow.workspaceId || null,
actorId: userId,
action: AuditAction.WORKFLOW_RESTORED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: restoreResult.workflow.name,
description: `Restored workflow "${restoreResult.workflow.name}"`,
metadata: {
workflowName: restoreResult.workflow.name,
workspaceId: restoreResult.workflow.workspaceId || undefined,
},
})
return { success: true, workflow: restoreResult.workflow }
} catch (error) {
logger.error(`[${requestId}] Failed to restore workflow ${workflowId}`, { error })
return { success: false, error: toError(error).message, errorCode: 'internal' }
}
}