6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
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 { chatDeploymentPasswordSchema } from '@/lib/api/contracts/chats'
|
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
|
import {
|
|
getWorkflowDeploymentSummary,
|
|
performFullDeploy,
|
|
} from '@/lib/workflows/orchestration/deploy'
|
|
import { checkNeedsRedeployment } from '@/app/api/workflows/utils'
|
|
|
|
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 }>
|
|
/** When true, public SSE may expose thinking if the client also opts into agent-events-v1. */
|
|
includeThinking?: boolean
|
|
/** When true, public SSE may expose tool lifecycle if the client opts into agent-events-v1. */
|
|
includeToolCalls?: boolean
|
|
workspaceId?: string | null
|
|
/** Stable identity for the underlying workflow deployment operation. */
|
|
idempotencyKey?: string
|
|
}
|
|
|
|
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 = [],
|
|
includeThinking = false,
|
|
includeToolCalls = false,
|
|
} = params
|
|
|
|
/**
|
|
* Validate the password here rather than only at the HTTP boundary. The
|
|
* copilot `deploy_chat` tool reaches this function without going through a
|
|
* route contract, so a whitespace-only or over-long password would otherwise
|
|
* be encrypted and stored — and neither can ever be submitted through the
|
|
* chat login form, permanently locking visitors out of the deployment.
|
|
*/
|
|
if (password !== undefined) {
|
|
const validatedPassword = chatDeploymentPasswordSchema.safeParse(password)
|
|
if (!validatedPassword.success) {
|
|
return { success: false, error: validatedPassword.error.issues[0].message }
|
|
}
|
|
}
|
|
|
|
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 } : {}),
|
|
}
|
|
|
|
/**
|
|
* Only deploy when the draft drifted from the active version, and never
|
|
* while another attempt is in flight — a blocked retry must not admit a
|
|
* fresh deployment version on top of the pending one.
|
|
*/
|
|
const deploymentSummary = await getWorkflowDeploymentSummary(workflowId)
|
|
const attemptStatus = deploymentSummary.latestDeploymentAttempt?.status
|
|
if (attemptStatus === 'preparing' || attemptStatus === 'activating') {
|
|
return {
|
|
success: false,
|
|
error:
|
|
'A workflow deployment is still preparing. Retry chat deployment after it becomes active.',
|
|
}
|
|
}
|
|
|
|
const needsRedeploy =
|
|
!deploymentSummary.activeDeployment || (await checkNeedsRedeployment(workflowId))
|
|
|
|
let deployResult: Awaited<ReturnType<typeof performFullDeploy>> | null = null
|
|
if (needsRedeploy) {
|
|
deployResult = await performFullDeploy({
|
|
workflowId,
|
|
userId,
|
|
versionDescription: params.versionDescription,
|
|
versionName: params.versionName,
|
|
idempotencyKey: params.idempotencyKey,
|
|
})
|
|
if (!deployResult.success) {
|
|
return { success: false, error: deployResult.error || 'Failed to deploy workflow' }
|
|
}
|
|
if (deployResult.latestDeploymentAttempt?.isCurrent === false) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
'The workflow deployment attempt is historical and no longer describes production. Retry chat deployment as a new tool call.',
|
|
}
|
|
}
|
|
if (deployResult.latestDeploymentAttempt?.status !== 'active') {
|
|
return {
|
|
success: false,
|
|
error:
|
|
deployResult.warnings?.[0] ??
|
|
'Workflow deployment is still preparing. Retry chat deployment after it becomes active.',
|
|
}
|
|
}
|
|
if (!deployResult.activeDeployment) {
|
|
return {
|
|
success: false,
|
|
error: 'Workflow deployment reported active without a live deployment version.',
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
/**
|
|
* A password-protected chat must end up with a stored password. Both HTTP
|
|
* routes already reject this; without the same guard here a copilot
|
|
* `deploy_chat` call could create one with no password, which fails closed at
|
|
* login with an opaque "Authentication configuration error".
|
|
*/
|
|
if (authType === 'password' && !encryptedPassword && !existingDeployment?.password) {
|
|
return { success: false, error: 'Password is required when using password protection' }
|
|
}
|
|
|
|
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,
|
|
includeThinking,
|
|
includeToolCalls,
|
|
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,
|
|
includeThinking,
|
|
includeToolCalls,
|
|
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 ?? toDeployedAtDate(deploymentSummary),
|
|
version: deployResult?.version ?? deploymentSummary.activeDeployment?.version,
|
|
}
|
|
}
|
|
|
|
function toDeployedAtDate(summary: {
|
|
activeDeployment: { deployedAt: string } | null
|
|
}): Date | null {
|
|
return summary.activeDeployment ? new Date(summary.activeDeployment.deployedAt) : null
|
|
}
|
|
|
|
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 }
|
|
}
|