d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
479 lines
14 KiB
TypeScript
479 lines
14 KiB
TypeScript
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' }
|
|
}
|
|
}
|