d25d482dc2
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
544 lines
16 KiB
TypeScript
544 lines
16 KiB
TypeScript
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 }
|
|
}
|