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,370 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
apiKey,
|
||||
chat,
|
||||
webhook,
|
||||
workflow,
|
||||
workflowDeploymentVersion,
|
||||
workflowFolder,
|
||||
workflowMcpTool,
|
||||
workflowSchedule,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { cleanupWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getSocketServerUrl } from '@/lib/core/utils/urls'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowLifecycle')
|
||||
|
||||
interface ArchiveWorkflowOptions {
|
||||
requestId: string
|
||||
notifySocket?: boolean
|
||||
archivedAt?: Date
|
||||
}
|
||||
|
||||
async function notifyWorkflowArchived(workflowId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
const socketResponse = await fetch(`${getSocketServerUrl()}/api/workflow-deleted`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': env.INTERNAL_API_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ workflowId }),
|
||||
})
|
||||
|
||||
if (!socketResponse.ok) {
|
||||
logger.warn(`[${requestId}] Failed to notify Socket.IO about archived workflow ${workflowId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Error notifying Socket.IO about archived workflow ${workflowId}`, {
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupExternalWebhooksForWorkflow(
|
||||
workflowId: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
|
||||
const webhooksToCleanup = await db
|
||||
.select({
|
||||
webhook: webhook,
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
userId: workflow.userId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
},
|
||||
})
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(eq(webhook.workflowId, workflowId))
|
||||
|
||||
for (const webhookData of webhooksToCleanup) {
|
||||
try {
|
||||
await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to cleanup external webhook ${webhookData.webhook.id} for workflow ${workflowId}`,
|
||||
{ error }
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Error during external webhook cleanup for workflow ${workflowId}`, {
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveWorkflow(
|
||||
workflowId: string,
|
||||
options: ArchiveWorkflowOptions
|
||||
): Promise<{ archived: boolean; workflow: Awaited<ReturnType<typeof getWorkflowById>> | null }> {
|
||||
const existingWorkflow = await getWorkflowById(workflowId, { includeArchived: true })
|
||||
|
||||
if (!existingWorkflow) {
|
||||
return { archived: false, workflow: null }
|
||||
}
|
||||
|
||||
if (existingWorkflow.archivedAt) {
|
||||
return { archived: false, workflow: existingWorkflow }
|
||||
}
|
||||
|
||||
const now = options.archivedAt ?? new Date()
|
||||
const affectedWorkflowMcpServers = await db
|
||||
.select({ serverId: workflowMcpTool.serverId })
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt)))
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(workflowSchedule)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
status: 'disabled',
|
||||
nextRunAt: null,
|
||||
lastQueuedAt: null,
|
||||
})
|
||||
.where(and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt)))
|
||||
|
||||
await tx
|
||||
.update(webhook)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
isActive: false,
|
||||
})
|
||||
.where(and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt)))
|
||||
|
||||
await tx
|
||||
.update(chat)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
isActive: false,
|
||||
})
|
||||
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
|
||||
|
||||
await tx
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt)))
|
||||
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
.set({
|
||||
isActive: false,
|
||||
})
|
||||
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
|
||||
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
archivedAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
isPublicApi: false,
|
||||
})
|
||||
.where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt)))
|
||||
})
|
||||
|
||||
try {
|
||||
PlatformEvents.workflowDeleted({
|
||||
workflowId,
|
||||
workspaceId: existingWorkflow.workspaceId || undefined,
|
||||
})
|
||||
} catch {}
|
||||
|
||||
if (options.notifySocket !== false) {
|
||||
await notifyWorkflowArchived(workflowId, options.requestId)
|
||||
}
|
||||
|
||||
if (existingWorkflow.workspaceId) {
|
||||
try {
|
||||
await cleanupWorkflowAliasBacking({
|
||||
workspaceId: existingWorkflow.workspaceId,
|
||||
workflowId,
|
||||
deletedAt: now,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn(`[${options.requestId}] Failed to clean up workflow alias backing`, {
|
||||
workflowId,
|
||||
workspaceId: existingWorkflow.workspaceId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await cleanupExternalWebhooksForWorkflow(workflowId, options.requestId)
|
||||
|
||||
if (existingWorkflow.workspaceId && mcpPubSub && affectedWorkflowMcpServers.length > 0) {
|
||||
const uniqueServerIds = [...new Set(affectedWorkflowMcpServers.map((row) => row.serverId))]
|
||||
for (const serverId of uniqueServerIds) {
|
||||
mcpPubSub.publishWorkflowToolsChanged({
|
||||
serverId,
|
||||
workspaceId: existingWorkflow.workspaceId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
workflow: await getWorkflowById(workflowId, { includeArchived: true }),
|
||||
}
|
||||
}
|
||||
|
||||
interface RestoreWorkflowOptions {
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export async function restoreWorkflow(
|
||||
workflowId: string,
|
||||
options: RestoreWorkflowOptions
|
||||
): Promise<{ restored: boolean; workflow: Awaited<ReturnType<typeof getWorkflowById>> | null }> {
|
||||
const existingWorkflow = await getWorkflowById(workflowId, { includeArchived: true })
|
||||
|
||||
if (!existingWorkflow) {
|
||||
return { restored: false, workflow: null }
|
||||
}
|
||||
|
||||
if (!existingWorkflow.archivedAt) {
|
||||
return { restored: false, workflow: existingWorkflow }
|
||||
}
|
||||
|
||||
if (existingWorkflow.workspaceId) {
|
||||
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
|
||||
const ws = await getWorkspaceWithOwner(existingWorkflow.workspaceId)
|
||||
if (!ws || ws.archivedAt) {
|
||||
throw new Error('Cannot restore workflow into an archived workspace')
|
||||
}
|
||||
}
|
||||
|
||||
let clearFolderId = false
|
||||
if (existingWorkflow.folderId) {
|
||||
const [folder] = await db
|
||||
.select({ archivedAt: workflowFolder.archivedAt })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, existingWorkflow.folderId))
|
||||
|
||||
if (!folder || folder.archivedAt) {
|
||||
clearFolderId = true
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
archivedAt: null,
|
||||
updatedAt: now,
|
||||
...(clearFolderId && { folderId: null }),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
|
||||
await tx
|
||||
.update(workflowSchedule)
|
||||
.set({ archivedAt: null, updatedAt: now })
|
||||
.where(eq(workflowSchedule.workflowId, workflowId))
|
||||
|
||||
await tx
|
||||
.update(webhook)
|
||||
.set({ archivedAt: null, updatedAt: now })
|
||||
.where(eq(webhook.workflowId, workflowId))
|
||||
|
||||
await tx
|
||||
.update(chat)
|
||||
.set({ archivedAt: null, updatedAt: now })
|
||||
.where(eq(chat.workflowId, workflowId))
|
||||
|
||||
await tx
|
||||
.update(workflowMcpTool)
|
||||
.set({ archivedAt: null, updatedAt: now })
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
})
|
||||
|
||||
logger.info(`[${options.requestId}] Restored workflow ${workflowId}`)
|
||||
|
||||
return {
|
||||
restored: true,
|
||||
workflow: await getWorkflowById(workflowId),
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveWorkflows(
|
||||
workflowIds: string[],
|
||||
options: ArchiveWorkflowOptions
|
||||
): Promise<number> {
|
||||
const uniqueWorkflowIds = Array.from(new Set(workflowIds))
|
||||
let archivedCount = 0
|
||||
|
||||
for (const workflowId of uniqueWorkflowIds) {
|
||||
const result = await archiveWorkflow(workflowId, options)
|
||||
if (result.archived) {
|
||||
archivedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return archivedCount
|
||||
}
|
||||
|
||||
export async function archiveWorkflowsForWorkspace(
|
||||
workspaceId: string,
|
||||
options: ArchiveWorkflowOptions
|
||||
): Promise<number> {
|
||||
const workflows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
|
||||
|
||||
return archiveWorkflows(
|
||||
workflows.map((entry) => entry.id),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
export async function archiveWorkflowsByIdsInWorkspace(
|
||||
workspaceId: string,
|
||||
workflowIds: string[],
|
||||
options: ArchiveWorkflowOptions
|
||||
): Promise<number> {
|
||||
if (workflowIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const workflows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.workspaceId, workspaceId),
|
||||
isNull(workflow.archivedAt),
|
||||
inArray(workflow.id, workflowIds)
|
||||
)
|
||||
)
|
||||
|
||||
return archiveWorkflows(
|
||||
workflows.map((entry) => entry.id),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables all resources owned by a banned user by archiving every workspace
|
||||
* they own (cascading to workflows, chats, KBs, tables, files, etc.)
|
||||
* and deleting their personal API keys.
|
||||
*/
|
||||
export async function disableUserResources(userId: string): Promise<void> {
|
||||
const requestId = generateRequestId()
|
||||
logger.info(`[${requestId}] Disabling resources for banned user ${userId}`)
|
||||
|
||||
const { archiveWorkspace } = await import('@/lib/workspaces/lifecycle')
|
||||
|
||||
const ownedWorkspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt)))
|
||||
|
||||
await Promise.all([
|
||||
...ownedWorkspaces.map((w) => archiveWorkspace(w.id, { requestId })),
|
||||
db.delete(apiKey).where(eq(apiKey.userId, userId)),
|
||||
])
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Disabled resources for user ${userId}: archived ${ownedWorkspaces.length} workspaces, deleted API keys`
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user