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,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' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user