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,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