import { db } from '@sim/db' import { copilotChats, copilotMessages } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, asc, eq, isNull, sql } from 'drizzle-orm' import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import { assertActiveWorkspaceAccess, checkWorkspaceAccess, } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatLifecycle') export interface ChatLoadResult { chatId: string chat: CopilotChatDetailRow | null conversationHistory: unknown[] isNew: boolean } /** * Minimal column set needed to perform workflow/workspace authorization for a * copilot chat. Heavy TOAST-able columns (messages, planArtifact, previewYaml, * config, resources) are intentionally excluded — callers that only need to * verify ownership should not pay the detoast cost for those fields. */ const copilotChatAuthColumns = { id: copilotChats.id, userId: copilotChats.userId, workflowId: copilotChats.workflowId, workspaceId: copilotChats.workspaceId, type: copilotChats.type, } as const /** * Column set for chat-detail callers that need chat metadata. The conversation * transcript is no longer selected from `copilot_chats.messages` (JSONB) — * reads now source it from the normalized `copilot_messages` table via * `loadCopilotChatMessages`, which avoids detoasting the large messages blob on * every load. The copilot-only TOAST-able fields (`previewYaml`, * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, * `lastSeenAt`) remain excluded. */ const copilotChatDetailColumns = { ...copilotChatAuthColumns, title: copilotChats.title, conversationId: copilotChats.conversationId, resources: copilotChats.resources, createdAt: copilotChats.createdAt, updatedAt: copilotChats.updatedAt, } as const /** * Column set for the legacy copilot chat detail endpoint. Extends * `copilotChatDetailColumns` with `model`, `planArtifact`, and `config` — the * fields the legacy `transformChat` response shape includes. Still drops * `previewYaml` (JSONB), `pinned`, and `lastSeenAt`. */ const copilotChatLegacyDetailColumns = { ...copilotChatDetailColumns, model: copilotChats.model, planArtifact: copilotChats.planArtifact, config: copilotChats.config, } as const /** * Load a chat's transcript from the normalized `copilot_messages` table in * canonical order (`seq` first, then `created_at`/`id` as a deterministic * tiebreak; `NULLS LAST` so any not-yet-sequenced row sorts after sequenced * ones). Each row's `content` is the full message object — identical in shape * to a legacy JSONB array element — so the downstream normalize/transcript * pipeline is unchanged. */ export async function loadCopilotChatMessages(chatId: string): Promise { const rows = await db .select({ content: copilotMessages.content }) .from(copilotMessages) .where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt))) .orderBy( sql`${copilotMessages.seq} asc nulls last`, asc(copilotMessages.createdAt), asc(copilotMessages.id) ) // Also strip on read: rows written before the backfill still carry outputs. return rows.map((row) => stripToolResultOutput(row.content as PersistedMessage)) } type CopilotChatAuthRow = Pick< typeof copilotChats.$inferSelect, 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' > export type CopilotChatDetailRow = Pick< typeof copilotChats.$inferSelect, | 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'type' | 'title' | 'conversationId' | 'resources' | 'createdAt' | 'updatedAt' > & { /** Transcript assembled from `copilot_messages` (no longer a chat-row column). */ messages: unknown[] } export type CopilotChatLegacyDetailRow = CopilotChatDetailRow & Pick async function authorizeCopilotChatRow( chat: T | undefined, chatId: string, userId: string ): Promise { if (!chat) { logger.warn('Copilot chat not found or not owned by user', { chatId, userId }) return null } if (chat.workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId: chat.workflowId, userId, action: 'read', }) if (!authorization.allowed || !authorization.workflow) { logger.warn('Copilot chat workflow not authorized for user', { chatId, userId, workflowId: chat.workflowId, }) return null } } else if (chat.workspaceId) { const access = await checkWorkspaceAccess(chat.workspaceId, userId) if (!access.exists || !access.hasAccess) { logger.warn('Copilot chat workspace not accessible to user', { chatId, userId, workspaceId: chat.workspaceId, }) return null } } return chat } /** * Verify a copilot chat exists, is owned by the user, and the user has access * to its workflow/workspace. Selects only the columns required for the * authorization check — use this for routes that only need ownership * verification before a mutation (rename, delete, update-messages). */ export async function getAccessibleCopilotChatAuth( chatId: string, userId: string ): Promise { const [chat] = await db .select(copilotChatAuthColumns) .from(copilotChats) .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) .limit(1) return authorizeCopilotChatRow(chat, chatId, userId) } /** * Load a copilot chat row for the legacy chat detail endpoint, including the * transcript plus `model`, `planArtifact`, and `config`. Drops `previewYaml` * (JSONB), `pinned`, and `lastSeenAt` — none of which the endpoint returns. */ export async function getAccessibleCopilotChat( chatId: string, userId: string ): Promise { const [chat] = await db .select(copilotChatLegacyDetailColumns) .from(copilotChats) .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) .limit(1) const authorized = await authorizeCopilotChatRow(chat, chatId, userId) if (!authorized) return null const messages = await loadCopilotChatMessages(chatId) return { ...authorized, messages } } /** * Load a copilot chat with the conversation transcript and resources after * authorization, omitting copilot-only TOAST-able fields (`previewYaml`, * `planArtifact`, `config`) and unused metadata (`model`, `pinned`, * `lastSeenAt`). Use this for the mothership chat detail endpoint and the * shared `resolveOrCreateChat` path — every column read here is consumed * downstream, and dropping the others avoids per-request detoast overhead. */ export async function getAccessibleCopilotChatWithMessages( chatId: string, userId: string ): Promise { const [chat] = await db .select(copilotChatDetailColumns) .from(copilotChats) .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) .limit(1) const authorized = await authorizeCopilotChatRow(chat, chatId, userId) if (!authorized) return null const messages = await loadCopilotChatMessages(chatId) return { ...authorized, messages } } /** * Resolve or create a copilot chat session. * If chatId is provided, loads the existing chat. Otherwise creates a new one. * Supports both workflow-scoped and workspace-scoped chats. */ export async function resolveOrCreateChat(params: { chatId?: string userId: string workflowId?: string workspaceId?: string model: string type?: 'mothership' | 'copilot' }): Promise { const { chatId, userId, workflowId, workspaceId, model, type } = params if (workspaceId) { await assertActiveWorkspaceAccess(workspaceId, userId) } if (chatId) { const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) if (chat) { if (workflowId && chat.workflowId !== workflowId) { logger.warn('Copilot chat workflow mismatch', { chatId, userId, requestWorkflowId: workflowId, chatWorkflowId: chat.workflowId, }) return { chatId, chat: null, conversationHistory: [], isNew: false } } if (workspaceId && chat.workspaceId !== workspaceId) { logger.warn('Copilot chat workspace mismatch', { chatId, userId, requestWorkspaceId: workspaceId, chatWorkspaceId: chat.workspaceId, }) return { chatId, chat: null, conversationHistory: [], isNew: false } } if (chat.workflowId) { const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId) if (!activeWorkflow) { logger.warn('Copilot chat workflow no longer active', { chatId, userId, workflowId: chat.workflowId, }) return { chatId, chat: null, conversationHistory: [], isNew: false } } } } return { chatId, chat: chat ?? null, conversationHistory: chat && Array.isArray(chat.messages) ? chat.messages : [], isNew: false, } } const now = new Date() const [newChat] = await db .insert(copilotChats) .values({ userId, ...(workflowId ? { workflowId } : {}), ...(workspaceId ? { workspaceId } : {}), type: type ?? 'copilot', title: null, model, lastSeenAt: now, }) .returning(copilotChatDetailColumns) if (!newChat) { logger.warn('Failed to create new copilot chat row', { userId, workflowId, workspaceId }) return { chatId: '', chat: null, conversationHistory: [], isNew: true, } } return { chatId: newChat.id, chat: { ...newChat, messages: [] }, conversationHistory: [], isNew: true, } }