Files
WeHub Mirror 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
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

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