import { z } from 'zod'; import { db } from '../db/client.js'; import { chatRuntimeService } from './chat-runtime-service.js'; import { parseChatMessageParts, stringifyChatMessageParts, type ChatMessagePart } from './chat-message-parts.js'; import { NOTIFICATION_TOKEN_TABLE, WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js'; import { cleanupNotificationMessagesForStaleTargetClients } from './notification-message-service.js'; export const CHAT_CONVERSATION_TABLE = 'chat_conversations'; export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages'; export const CHAT_CONVERSATION_CLIENT_TABLE = 'chat_conversation_clients'; export const CHAT_CONVERSATION_REQUEST_TABLE = 'chat_conversation_requests'; export const CHAT_CONVERSATION_ACTIVITY_TABLE = 'chat_conversation_request_activities'; export const CHAT_CONVERSATION_SOURCE_CHANGE_TABLE = 'chat_conversation_source_changes'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000; const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000; const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const; const conversationPayloadSchema = z.object({ sessionId: z.string().trim().min(1).max(120), clientId: z.string().trim().max(120).nullable().optional(), title: z.string().trim().max(200).nullable().optional(), requestBadgeLabel: z.string().trim().max(120).nullable().optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), lastChatTypeId: z.string().trim().max(120).nullable().optional(), generalSectionName: z.string().trim().max(120).nullable().optional(), contextLabel: z.string().trim().max(200).nullable().optional(), contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).nullable().optional(), notifyOffline: z.boolean().optional(), }); const conversationMessagePayloadSchema = z.object({ sessionId: z.string().trim().min(1).max(120), messageId: z.number().int().positive(), author: z.enum(['codex', 'system', 'user']), text: z.string().max(200000), timestamp: z.string().trim().max(40), clientRequestId: z.string().trim().max(120).nullable().optional(), parts: z.array(z.custom()).optional(), }); export type ChatConversationItem = { sessionId: string; clientId: string | null; title: string; requestBadgeLabel: string | null; chatTypeId: string | null; lastChatTypeId: string | null; generalSectionName: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; hasUnreadResponse: boolean; currentRequestId: string | null; currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null; currentJobMessage: string | null; currentQueueSize: number; currentStatusUpdatedAt: string | null; isPendingWork: boolean; pendingWorkReason: 'prompt' | 'analysis' | 'design' | null; lastRequestPreview: string; lastMessagePreview: string; lastResponsePreview: string; createdAt: string; updatedAt: string; lastMessageAt: string | null; }; export type StoredChatMessage = { id: number; author: 'codex' | 'system' | 'user'; text: string; timestamp: string; clientRequestId?: string | null; parts?: ChatMessagePart[]; }; export type ChatConversationRequestStatus = | 'accepted' | 'queued' | 'started' | 'completed' | 'failed' | 'cancelled' | 'removed'; export type ChatConversationRequestItem = { sessionId: string; requestId: string; requesterClientId: string | null; status: ChatConversationRequestStatus; statusMessage: string | null; userMessageId: number | null; userText: string; responseMessageId: number | null; responseText: string; hasResponse: boolean; canDelete: boolean; createdAt: string; updatedAt: string; answeredAt: string | null; terminalAt: string | null; }; export type ChatConversationActivityLogItem = { sessionId: string; requestId: string; lines: string[]; updatedAt: string | null; }; export type ChatSourceChangeSnapshotItem = { id: string; sessionId: string; clientId: string | null; conversationTitle: string; chatTypeId: string | null; chatTypeLabel: string; requestId: string; requestTitle: string; questionText: string; answerText: string; status: ChatConversationRequestStatus; sourceChangedAt: string; updatedAt: string; featureTags: string[]; changedFiles: string[]; currentSourceFiles: string[]; diffBlocks: string[]; hasSourceChanges: boolean; reviewStatus: 'reviewed' | 'not-reviewed'; sourceChangeKind: 'request' | 'verification-group'; sourceEntryIds: string[]; conversationDeletedAt: string | null; }; export type RecoverableChatConversationRequestItem = { sessionId: string; clientId: string | null; chatTypeId: string | null; lastChatTypeId: string | null; generalSectionName: string | null; contextLabel: string | null; contextDescription: string | null; currentRequestId: string | null; currentJobStatus: ChatConversationItem['currentJobStatus']; requestId: string; status: ChatConversationRequestStatus; userText: string; createdAt: string; }; export type ChatConversationDetailPage = { messages: StoredChatMessage[]; requests: ChatConversationRequestItem[]; activityLogs: ChatConversationActivityLogItem[]; oldestLoadedMessageId: number | null; hasOlderMessages: boolean; }; type ChatConversationRequestStatusPatch = { requestId: string; status?: ChatConversationRequestStatus; userMessageId?: number | null; userText?: string | null; responseMessageId?: number | null; responseText?: string | null; }; type ChatConversationResponseCandidate = { id: number; messageId: number; author: StoredChatMessage['author']; text: string; clientRequestId: string | null; createdAt: string | null; }; type ChatConversationClientPreference = { sessionId: string; clientId: string; notifyOffline: boolean; lastReadResponseMessageId: number | null; }; type ChatConversationOfflineNotificationClientRow = { clientId: string; notifyOffline: boolean; hasActivePushRegistration: boolean; }; function normalizeDateTimeValue(value: unknown) { if (value == null) { return null; } if (value instanceof Date) { return value.toISOString(); } const normalized = String(value).trim(); if (!normalized) { return null; } const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString(); } function parseStringArray(value: unknown) { if (typeof value !== 'string') { return [] as string[]; } try { const parsed = JSON.parse(value) as unknown; if (!Array.isArray(parsed)) { return [] as string[]; } return parsed .map((item) => String(item ?? '').trim()) .filter(Boolean); } catch { return [] as string[]; } } function stringifyStringArray(value: string[]) { return JSON.stringify( Array.from( new Set( value.map((item) => String(item ?? '').trim()).filter(Boolean), ), ), ); } function createPreview(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } function createCompactText(value: string | null | undefined, limit = 88) { const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized; } function normalizeClientIdSet(clientIds: Iterable) { return new Set( Array.from(clientIds, (item) => String(item ?? '').trim()).filter(Boolean), ); } export function selectStaleOfflineNotificationClientIds( rows: ChatConversationOfflineNotificationClientRow[], options?: { keepClientIds?: Iterable; }, ) { const keepClientIds = normalizeClientIdSet(options?.keepClientIds ?? []); return rows .filter((row) => { if (!row.notifyOffline) { return false; } if (!row.clientId) { return false; } if (keepClientIds.has(row.clientId)) { return false; } return row.hasActivePushRegistration !== true; }) .map((row) => row.clientId); } const SOURCE_CHANGE_VERIFICATION_PATTERN = /^\s*\[\[source-change-verification:(.+?)\]\]\s*$/iu; const PROMPT_PART_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/iu; type SourceChangeVerificationFeature = { key: string; label: string; entryRefs: Array<{ sessionId: string; requestId: string; }>; }; type SourceChangeVerificationMetadata = { version: number; features: SourceChangeVerificationFeature[]; }; function parseJsonRecord(value: string) { try { const parsed = JSON.parse(value) as unknown; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; } catch { return null; } } function parseSourceChangeVerificationMetadata(text: string) { const lines = String(text ?? '').split('\n'); for (const line of lines) { const matched = line.match(SOURCE_CHANGE_VERIFICATION_PATTERN); if (!matched?.[1]) { continue; } const record = parseJsonRecord(matched[1]); if (!record) { continue; } const features = Array.isArray(record.features) ? record.features.flatMap((feature) => { if (!feature || typeof feature !== 'object' || Array.isArray(feature)) { return []; } const featureRecord = feature as Record; const key = String(featureRecord.key ?? '').trim(); const label = String(featureRecord.label ?? '').trim(); const entryRefs = Array.isArray(featureRecord.entryRefs) ? featureRecord.entryRefs.flatMap((entryRef) => { if (!entryRef || typeof entryRef !== 'object' || Array.isArray(entryRef)) { return []; } const entryRefRecord = entryRef as Record; const sessionId = String(entryRefRecord.sessionId ?? '').trim(); const requestId = String(entryRefRecord.requestId ?? '').trim(); return sessionId && requestId ? [{ sessionId, requestId }] : []; }) : []; return key && label && entryRefs.length > 0 ? [{ key, label, entryRefs }] : []; }) : []; if (features.length === 0) { continue; } return { version: Number(record.version ?? 1) || 1, features, } satisfies SourceChangeVerificationMetadata; } return null; } function parseSelectedPromptValues(text: string) { const selectedValues = new Set(); const lines = String(text ?? '').split('\n'); for (const line of lines) { const matched = line.match(PROMPT_PART_PATTERN); if (!matched?.[1]) { continue; } const record = parseJsonRecord(matched[1]); if (!record) { continue; } (Array.isArray(record.selectedValues) ? record.selectedValues : []).forEach((value) => { const normalized = String(value ?? '').trim(); if (normalized) { selectedValues.add(normalized); } }); } return selectedValues; } function sanitizeSourceChangeGroupKey(value: string, fallback: string) { const normalized = String(value ?? '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 24); return normalized || fallback; } function buildVerificationGroupRequestId(requestId: string, featureKey: string, index: number) { const normalizedRequestId = String(requestId ?? '').trim() || 'verification'; const safeKey = sanitizeSourceChangeGroupKey(featureKey, `group-${index + 1}`); const suffix = `::vf:${index + 1}:${safeKey}`; const baseLimit = Math.max(1, 120 - suffix.length); return `${normalizedRequestId.slice(0, baseLimit)}${suffix}`; } function createRequestTitle(userText: string, fallback: string) { const compact = createCompactText(userText, 72); return compact || fallback; } export function hasMeaningfulChatSourceArtifacts(snapshot: { changedFiles?: string[] | null; currentSourceFiles?: string[] | null; diffBlocks?: string[] | null; }) { return [snapshot.changedFiles, snapshot.currentSourceFiles, snapshot.diffBlocks].some((items) => Array.isArray(items) && items.some((item) => typeof item === 'string' && item.trim().length > 0), ); } function extractDiffBlocks(text: string) { return Array.from(text.matchAll(/```diff[^\n]*\n([\s\S]*?)```/g)) .map((match) => match[1]?.trim() ?? '') .filter(Boolean); } function normalizeWorkspaceFilePath(value: string) { const normalized = String(value ?? '') .trim() .replace(/\\/g, '/') .replace(/^file:\/\//, '') .replace(/[)>.,]+$/, '') .replace(/:\d+(?::\d+)?$/, ''); if (!normalized) { return ''; } const resourceMarker = '/resource/'; const resourceIndex = normalized.lastIndexOf(resourceMarker); if (resourceIndex >= 0) { const innerPath = normalized.slice(resourceIndex + resourceMarker.length).replace(/^\/+/, ''); return innerPath; } const apiResourceMarker = '/api/chat/resources/'; const apiResourceIndex = normalized.lastIndexOf(apiResourceMarker); if (apiResourceIndex >= 0) { return normalized.slice(apiResourceIndex + apiResourceMarker.length).replace(/^\/+/, ''); } const legacyWorkspaceMarker = '/workspace/main-project/'; const legacyWorkspaceIndex = normalized.lastIndexOf(legacyWorkspaceMarker); if (legacyWorkspaceIndex >= 0) { return normalized.slice(legacyWorkspaceIndex + legacyWorkspaceMarker.length); } for (const prefix of CURRENT_SOURCE_PREFIXES) { const marker = `/${prefix}`; const markerIndex = normalized.lastIndexOf(marker); if (markerIndex >= 0) { return normalized.slice(markerIndex + 1); } if (normalized.startsWith(prefix)) { return normalized; } } return normalized.replace(/^\/+/, '').replace(/^\.\//, ''); } function isCurrentSourcePath(path: string) { return CURRENT_SOURCE_PREFIXES.some((prefix) => path.startsWith(prefix)); } function extractChangedFiles(text: string) { const matches = Array.from( text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), ) .flatMap((match) => [match[1], match[2], match[3]]) .filter((value): value is string => Boolean(value)); return Array.from( new Set( matches .map((item) => normalizeWorkspaceFilePath(item)) .filter(Boolean), ), ).slice(0, 60); } function extractCurrentSourceFiles(text: string) { const textWithoutChatResourcePaths = text .replace(/\/api\/chat\/resources\/[^\s)`]+/g, ' ') .replace(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g, ' '); const diffPathMatches = Array.from( text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), ) .flatMap((match) => [match[1], match[2], match[3]]) .filter((value): value is string => Boolean(value)) .map((item) => normalizeWorkspaceFilePath(item)) .filter((path) => path && isCurrentSourcePath(path)); const workspacePathMatches = [ ...(text.match(/\[[^\]]*]\((\/[^)\s]+)\)/g) ?? []).map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), ...(text.match(/\/(?:[^/\s)]+\/)*(?:src|docs|etc|public|scripts)\/[^\s)`]+/g) ?? []), ] .map((item) => normalizeWorkspaceFilePath(item)) .filter((path) => path && isCurrentSourcePath(path)); const directRelativeMatches = (textWithoutChatResourcePaths.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []) .map((item) => normalizeWorkspaceFilePath(item)) .filter((path) => path && isCurrentSourcePath(path)); return Array.from(new Set([...diffPathMatches, ...workspacePathMatches, ...directRelativeMatches])).slice(0, 60); } function deriveFeatureTags(files: string[]) { const tags = new Set(); files.forEach((file) => { const segments = file.split('/').filter(Boolean); if (segments[0] === 'src' && segments[1] === 'features' && segments[2]) { tags.add(`feature:${segments[2]}`); return; } if (segments[0] === 'src' && segments[1] === 'components' && segments[2]) { tags.add(`component:${segments[2]}`); return; } if (segments[0] === 'src' && segments[1] === 'widgets' && segments[2]) { tags.add(`widget:${segments[2]}`); return; } if (segments[0] === 'docs' && segments[1]) { tags.add(`docs:${segments[1]}`); return; } if (segments[0]) { tags.add(segments[0]); } }); return Array.from(tags); } const PENDING_WORK_ANALYSIS_PATTERNS = [ /분석/u, /검토/u, /조사/u, /원인/u, /파악/u, /\banalysis\b/i, /\binvestigat(?:e|ion)\b/i, ] as const; const PENDING_WORK_DESIGN_PATTERNS = [ /설계/u, /프롬프트/u, /시안/u, /구조/u, /방향/u, /기획/u, /플로우/u, /아키텍처/u, /\bdesign\b/i, /\barchitecture\b/i, ] as const; const PENDING_WORK_IMPLEMENTATION_PATTERNS = [ /구현했/u, /수정했/u, /반영했/u, /적용했/u, /완료했/u, /마무리했/u, /배포했/u, /검증했/u, /빌드.*통과/u, /테스트.*통과/u, /캡처/u, /preview/iu, /변경 파일/u, /diff/u, /\bimplement(?:ed|ation)?\b/i, /\bfix(?:ed)?\b/i, /\bverified?\b/i, /\btested?\b/i, ] as const; const PENDING_WORK_RESPONSE_HOLD_PATTERNS = [ /원하시면/u, /진행해드릴/u, /이어(?:서|가)/u, /다음 단계/u, /선택/u, /옵션/u, /후속/u, /\bif you want\b/i, /\bnext step\b/i, ] as const; function normalizePendingWorkText(text: string | null | undefined) { return String(text ?? '').replace(/\s+/g, ' ').trim(); } function hasPendingWorkPattern(text: string, patterns: readonly RegExp[]) { return patterns.some((pattern) => pattern.test(text)); } function resolvePendingWorkReasonFromText(text: string) { if (!text) { return null; } if (hasPendingWorkPattern(text, PENDING_WORK_DESIGN_PATTERNS)) { return 'design' as const; } if (hasPendingWorkPattern(text, PENDING_WORK_ANALYSIS_PATTERNS)) { return 'analysis' as const; } return null; } function hasOpenPromptParts(parts: ChatMessagePart[] | undefined) { return (parts ?? []).some((part) => { if (part.type !== 'prompt' || part.readOnly === true) { return false; } if ((part.selectedValues?.length ?? 0) > 0) { return false; } if ((part.resultText?.trim() ?? '').length > 0) { return false; } if ((part.resolvedAt?.trim() ?? '').length > 0 || part.resolvedBy != null) { return false; } return true; }); } function resolvePendingWorkState(args: { requestText?: string | null; responseText?: string | null; latestCodexParts?: ChatMessagePart[] | undefined; }) { if (hasOpenPromptParts(args.latestCodexParts)) { return { isPendingWork: true, pendingWorkReason: 'prompt' as const, }; } const requestText = normalizePendingWorkText(args.requestText); const responseText = normalizePendingWorkText(args.responseText); const requestReason = resolvePendingWorkReasonFromText(requestText); if (!requestReason) { return { isPendingWork: false, pendingWorkReason: null, }; } if (hasPendingWorkPattern(responseText, PENDING_WORK_IMPLEMENTATION_PATTERNS)) { return { isPendingWork: false, pendingWorkReason: null, }; } if (!responseText) { return { isPendingWork: true, pendingWorkReason: requestReason, }; } const responseReason = resolvePendingWorkReasonFromText(responseText); if (responseReason || hasPendingWorkPattern(responseText, PENDING_WORK_RESPONSE_HOLD_PATTERNS)) { return { isPendingWork: true, pendingWorkReason: responseReason ?? requestReason, }; } return { isPendingWork: false, pendingWorkReason: null, }; } const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [ /이전\s*(채팅|대화|문맥)/u, /이전\s*요청/u, /마지막\s*요청/u, /요청내역/u, /두\s*단어/u, /최근\s*작업\s*(뱃지|badge|라벨)/iu, /(?:이어서|이어진|이어가|계속|추가로|연달아|후속|마저)/u, /^(?:그리고|그럼|그러면|또|또한|근데|그런데|여기도|여기서도|이것도|그것도|저것도|이거|그거|저거)/u, /\b(?:also|continue|continued|follow[\s-]?up|same|again)\b/i, ] as const; function normalizeRequestPreviewText(text: string) { return String(text ?? '').replace(/\s+/g, ' ').trim(); } function isContextDependentRequestPreview(text: string) { const normalized = normalizeRequestPreviewText(text); if (!normalized) { return false; } if (CONTEXT_DEPENDENT_REQUEST_PATTERNS.some((pattern) => pattern.test(normalized))) { return true; } if (normalized.length <= 16) { return true; } return false; } function buildLatestRequestPreview( requests: Array<{ text: string; createdAt: string | null }>, ): { text: string; createdAt: string | null } | null { const normalizedRequests = requests .map((request) => ({ text: normalizeRequestPreviewText(request.text), createdAt: request.createdAt, })) .filter((request) => Boolean(request.text)); const latestRequest = normalizedRequests[0]; if (!latestRequest) { return null; } if (!isContextDependentRequestPreview(latestRequest.text)) { return latestRequest; } const previousRequest = normalizedRequests.slice(1).find((request) => !isContextDependentRequestPreview(request.text)) ?? normalizedRequests[1] ?? null; if (!previousRequest) { return latestRequest; } return { text: `${previousRequest.text} ${latestRequest.text}`.trim(), createdAt: latestRequest.createdAt, }; } function isPreviewableConversationMessage(row: { author?: unknown; text?: unknown }) { const author = String(row.author ?? ''); const text = String(row.text ?? '').trim(); if (!text) { return false; } if (author === 'system' && text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`)) { return false; } return author === 'user' || author === 'codex'; } function buildConversationTitle(text: string) { const preview = createPreview(text); return preview || '새 대화'; } function mapConversationRow(row: Record): ChatConversationItem { return { sessionId: String(row.session_id ?? ''), clientId: row.client_id == null ? null : String(row.client_id), title: String(row.title ?? '새 대화'), requestBadgeLabel: row.request_badge_label == null ? null : String(row.request_badge_label), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), generalSectionName: row.general_section_name == null ? null : String(row.general_section_name), contextLabel: row.context_label == null ? null : String(row.context_label), contextDescription: row.context_description == null ? null : String(row.context_description), notifyOffline: Boolean(row.notify_offline), hasUnreadResponse: Boolean(row.has_unread_response), currentRequestId: row.current_request_id == null ? null : String(row.current_request_id), currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message), currentQueueSize: Number(row.current_queue_size ?? 0), currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), isPendingWork: false, pendingWorkReason: null, lastRequestPreview: '', lastMessagePreview: String(row.last_message_preview ?? ''), lastResponsePreview: '', createdAt: normalizeDateTimeValue(row.created_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', lastMessageAt: normalizeDateTimeValue(row.last_message_at), }; } function mapMessageRow(row: Record): StoredChatMessage { return { id: Number(row.message_id ?? row.id ?? 0), author: String(row.author ?? 'codex') as StoredChatMessage['author'], text: String(row.text ?? ''), timestamp: String(row.display_timestamp ?? ''), clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), parts: parseChatMessageParts(row.parts_json), }; } export function isVisibleConversationMessage(message: StoredChatMessage) { if (message.author !== 'system') { return true; } return message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); } function applyVisibleConversationMessageCondition(builder: any) { builder.whereNot('author', 'system').orWhere((nestedBuilder: any) => { nestedBuilder.where('author', '=', 'system').andWhere('text', 'like', `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n%`); }); } function mapClientPreferenceRow(row: Record): ChatConversationClientPreference { return { sessionId: String(row.session_id ?? ''), clientId: String(row.client_id ?? ''), notifyOffline: Boolean(row.notify_offline), lastReadResponseMessageId: row.last_read_response_message_id == null ? null : Number(row.last_read_response_message_id), }; } function mapRequestRow(row: Record): ChatConversationRequestItem { const status = String(row.status ?? 'accepted') as ChatConversationRequestStatus; const hasResponse = row.response_message_id != null || String(row.response_text ?? '').trim().length > 0; const canDelete = !hasResponse && !['queued', 'started', 'completed'].includes(status); return { sessionId: String(row.session_id ?? ''), requestId: String(row.request_id ?? ''), requesterClientId: row.requester_client_id == null ? null : String(row.requester_client_id), status, statusMessage: row.status_message == null ? null : String(row.status_message), userMessageId: row.user_message_id == null ? null : Number(row.user_message_id), userText: String(row.user_text ?? ''), responseMessageId: row.response_message_id == null ? null : Number(row.response_message_id), responseText: String(row.response_text ?? ''), hasResponse, canDelete, createdAt: normalizeDateTimeValue(row.created_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', answeredAt: normalizeDateTimeValue(row.answered_at), terminalAt: normalizeDateTimeValue(row.terminal_at), }; } function mapSourceChangeSnapshotRow(row: Record): ChatSourceChangeSnapshotItem { const sessionId = String(row.session_id ?? '').trim(); const requestId = String(row.request_id ?? '').trim(); return { id: `${sessionId}:${requestId}`, sessionId, clientId: row.client_id == null ? null : String(row.client_id), conversationTitle: String(row.conversation_title ?? '새 대화'), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), chatTypeLabel: row.chat_type_label == null ? '' : String(row.chat_type_label), requestId, requestTitle: String(row.request_title ?? requestId), questionText: String(row.question_text ?? ''), answerText: String(row.answer_text ?? ''), status: String(row.status ?? 'completed') as ChatConversationRequestStatus, sourceChangedAt: normalizeDateTimeValue(row.source_changed_at) ?? normalizeDateTimeValue(row.answered_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', featureTags: parseStringArray(row.feature_tags_json), changedFiles: parseStringArray(row.changed_files_json), currentSourceFiles: parseStringArray(row.current_source_files_json), diffBlocks: parseStringArray(row.diff_blocks_json), hasSourceChanges: Boolean(row.has_source_changes), reviewStatus: String(row.review_status ?? 'not-reviewed') === 'reviewed' ? 'reviewed' : 'not-reviewed', sourceChangeKind: String(row.source_change_kind ?? 'request') === 'verification-group' ? 'verification-group' : 'request', sourceEntryIds: parseStringArray(row.source_entry_ids_json), conversationDeletedAt: normalizeDateTimeValue(row.conversation_deleted_at), }; } function buildSourceChangeSnapshotPayload(args: { sessionId: string; clientId?: string | null; conversationTitle?: string | null; chatTypeId?: string | null; chatTypeLabel?: string | null; requestId: string; status: ChatConversationRequestStatus; questionText: string; answerText: string; answeredAt?: string | null; updatedAt?: string | null; reviewStatus?: 'reviewed' | 'not-reviewed'; sourceChangeKind?: 'request' | 'verification-group'; sourceEntryIds?: string[]; }) { const changedFiles = extractChangedFiles(args.answerText); const diffBlocks = extractDiffBlocks(args.answerText); const currentSourceFiles = extractCurrentSourceFiles(args.answerText); const featureTags = deriveFeatureTags(changedFiles); const hasSourceChanges = hasMeaningfulChatSourceArtifacts({ changedFiles, currentSourceFiles, diffBlocks, }); const sourceChangedAt = args.answeredAt?.trim() || args.updatedAt?.trim() || new Date().toISOString(); return { session_id: args.sessionId, client_id: normalizeClientId(args.clientId), request_id: args.requestId, conversation_title: args.conversationTitle?.trim() || '새 대화', chat_type_id: args.chatTypeId?.trim() || null, chat_type_label: args.chatTypeLabel?.trim() || null, request_title: createRequestTitle(args.questionText, args.requestId), question_text: args.questionText, answer_text: args.answerText, status: args.status, answered_at: args.answeredAt?.trim() || null, source_changed_at: sourceChangedAt, feature_tags_json: stringifyStringArray(featureTags), changed_files_json: stringifyStringArray(changedFiles), current_source_files_json: stringifyStringArray(currentSourceFiles), diff_blocks_json: stringifyStringArray(diffBlocks), has_source_changes: hasSourceChanges, review_status: args.reviewStatus === 'reviewed' ? 'reviewed' : 'not-reviewed', source_change_kind: args.sourceChangeKind === 'verification-group' ? 'verification-group' : 'request', source_entry_ids_json: stringifyStringArray(args.sourceEntryIds ?? []), conversation_deleted_at: null, updated_at: db.fn.now(), }; } function normalizeClientId(clientId?: string | null) { return clientId?.trim() || null; } function getTimeValue(value: string | null | undefined) { if (!value) { return 0; } const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : 0; } function isRuntimeRequestActive(requestId?: string | null) { const normalizedRequestId = requestId?.trim() || null; if (!normalizedRequestId) { return false; } const detail = chatRuntimeService.getJobDetail(normalizedRequestId); return detail.item != null && detail.terminalStatus == null; } function isTerminalRequestStatus(status: ChatConversationRequestStatus | null | undefined) { return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed'; } function isPreparingChatReplyText(text?: string | null) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.startsWith('응답을 준비하고 있습니다'); } function hasStoredRequestResponse(request: { responseMessageId?: number | null; responseText?: string | null; }) { const normalizedResponseText = String(request.responseText ?? '').trim(); if (normalizedResponseText.length > 0) { return !isPreparingChatReplyText(normalizedResponseText); } return request.responseMessageId != null; } function isConversationRequestActive( conversation: { current_request_id?: unknown; current_job_status?: unknown; } | null | undefined, requestId?: string | null, ) { const normalizedRequestId = requestId?.trim() || null; if (!normalizedRequestId) { return false; } const currentRequestId = String(conversation?.current_request_id ?? '').trim() || null; const currentJobStatus = String(conversation?.current_job_status ?? '').trim(); if (currentRequestId !== normalizedRequestId) { return false; } return currentJobStatus === 'queued' || currentJobStatus === 'started'; } function hasConversationMetadata( conversation: { title?: unknown; request_badge_label?: unknown; chat_type_id?: unknown; last_chat_type_id?: unknown; general_section_name?: unknown; context_label?: unknown; context_description?: unknown; current_request_id?: unknown; current_job_status?: unknown; } | null | undefined, ) { return [ conversation?.title, conversation?.request_badge_label, conversation?.chat_type_id, conversation?.last_chat_type_id, conversation?.general_section_name, conversation?.context_label, conversation?.context_description, conversation?.current_request_id, conversation?.current_job_status, ].some((value) => String(value ?? '').trim().length > 0); } export function normalizeStaleRequestItem( item: ChatConversationRequestItem, conversation: { current_request_id?: unknown; current_job_status?: unknown; current_status_updated_at?: unknown; } | null | undefined, ) { const runtimeActive = isRuntimeRequestActive(item.requestId); if ( shouldClearConversationJobState({ currentRequestId: String(conversation?.current_request_id ?? ''), currentJobStatus: conversation?.current_job_status == null ? null : String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'], currentStatusUpdatedAt: conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at), runtimeActive, request: item, }) ) { return { ...item, status: 'failed' as const, statusMessage: item.statusMessage ?? '중단된 오래된 요청', canDelete: true, terminalAt: item.terminalAt ?? item.updatedAt, }; } return item; } export function shouldClearConversationJobState(params: { currentRequestId?: string | null; currentJobStatus?: ChatConversationItem['currentJobStatus']; currentStatusUpdatedAt?: string | null; runtimeActive?: boolean; nowMs?: number; request: | { requestId?: string | null; status?: ChatConversationRequestStatus | null; responseMessageId?: number | null; responseText?: string | null; terminalAt?: string | null; updatedAt?: string | null; } | null | undefined; }) { const currentJobStatus = params.currentJobStatus ?? null; const requestStatus = params.request?.status ?? null; const hasStoredResponse = hasStoredRequestResponse(params.request ?? {}); if (!currentJobStatus) { return false; } const currentRequestId = params.currentRequestId?.trim() || null; if (!currentRequestId) { return true; } const requestId = params.request?.requestId?.trim() || null; if (!requestId || requestId !== currentRequestId) { return false; } const runtimeActive = params.runtimeActive === true; const lastUpdatedAt = Math.max( getTimeValue(params.currentStatusUpdatedAt), getTimeValue(params.request?.updatedAt), ); const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now(); const isStaleInProgressState = !runtimeActive && (currentJobStatus === 'queued' || currentJobStatus === 'started') && !hasStoredRequestResponse(params.request ?? {}) && !isTerminalRequestStatus(params.request?.status ?? null) && lastUpdatedAt > 0 && nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS; return ( (requestStatus != null && requestStatus !== 'completed' && isTerminalRequestStatus(requestStatus)) || hasStoredResponse || isStaleInProgressState ); } function getRequestStatusRank(status: ChatConversationRequestStatus | null | undefined) { switch (status) { case 'accepted': return 0; case 'queued': return 1; case 'started': return 2; case 'completed': case 'failed': case 'cancelled': case 'removed': return 3; default: return -1; } } function getDefaultChatConversationRequestStatusMessage(status: ChatConversationRequestStatus) { switch (status) { case 'accepted': return '요청을 접수했습니다.'; case 'queued': return '대기열 등록'; case 'started': return '요청 처리 중'; case 'completed': return '요청 처리 완료'; case 'failed': return '요청 처리 실패'; case 'cancelled': return '요청 실행 중단'; case 'removed': return '요청 기록이 제거되었습니다.'; default: return null; } } export function mergeChatConversationRequestStatus( currentStatus: ChatConversationRequestStatus | null | undefined, incomingStatus: ChatConversationRequestStatus | null | undefined, ): ChatConversationRequestStatus { const normalizedCurrent = currentStatus ?? null; const normalizedIncoming = incomingStatus ?? null; if (!normalizedCurrent && !normalizedIncoming) { return 'accepted'; } if (!normalizedCurrent) { return normalizedIncoming ?? 'accepted'; } if (!normalizedIncoming) { return normalizedCurrent; } if (isTerminalRequestStatus(normalizedCurrent) && !isTerminalRequestStatus(normalizedIncoming)) { return normalizedCurrent; } if (!isTerminalRequestStatus(normalizedCurrent) && isTerminalRequestStatus(normalizedIncoming)) { return normalizedIncoming; } return getRequestStatusRank(normalizedIncoming) >= getRequestStatusRank(normalizedCurrent) ? normalizedIncoming : normalizedCurrent; } export function buildChatConversationRequestPatchFromMessage(message: { id: number; author: StoredChatMessage['author']; text: string; clientRequestId?: string | null; }): ChatConversationRequestStatusPatch | null { const normalizedRequestId = message.clientRequestId?.trim() || null; if (!normalizedRequestId) { return null; } if (message.author === 'user') { return { requestId: normalizedRequestId, status: 'accepted', userMessageId: message.id, userText: message.text, }; } if (message.author === 'codex') { return { requestId: normalizedRequestId, status: 'started', responseMessageId: message.id, responseText: message.text, }; } return null; } export function selectChatConversationResponseCandidate( request: { requestId: string; createdAt: string; responseMessageId?: number | null; }, nextRequest: { createdAt: string; } | undefined, messages: ChatConversationResponseCandidate[], ) { const normalizedRequestId = request.requestId.trim(); if (!normalizedRequestId) { return null; } const directMatches = messages.filter( (message) => message.author === 'codex' && message.clientRequestId?.trim() === normalizedRequestId, ); if (directMatches.length > 0) { return directMatches.at(-1) ?? null; } if (request.responseMessageId != null) { const responseMessageMatch = messages.find( (message) => message.author === 'codex' && message.messageId === request.responseMessageId, ); if (responseMessageMatch) { return responseMessageMatch; } } const requestCreatedAt = getTimeValue(request.createdAt); const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt); const windowMatches = messages.filter((message) => { if (message.author !== 'codex') { return false; } const linkedRequestId = message.clientRequestId?.trim() || null; if (linkedRequestId && linkedRequestId !== normalizedRequestId) { return false; } const createdAt = getTimeValue(message.createdAt); if (requestCreatedAt > 0 && createdAt > 0 && createdAt < requestCreatedAt) { return false; } if (nextRequestCreatedAt > 0 && createdAt > 0 && createdAt >= nextRequestCreatedAt) { return false; } return Boolean(String(message.text ?? '').trim()); }); return windowMatches.at(-1) ?? null; } async function getLatestPreviewableMessageMap(sessionIds: string[]) { const normalizedSessionIds = sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('session_id', 'author', 'text', 'created_at') .whereIn('session_id', normalizedSessionIds) .whereIn('author', ['user', 'codex']) .orderBy('session_id', 'asc') .orderBy('created_at', 'desc') .orderBy('id', 'desc'); const messageMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); if (!sessionId || messageMap.has(sessionId) || !isPreviewableConversationMessage(row)) { continue; } messageMap.set(sessionId, { text: String(row.text ?? ''), createdAt: normalizeDateTimeValue(row.created_at), }); } return messageMap; } async function getLatestRequestPreviewMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'user_text', 'created_at', 'status', 'request_id') .whereIn('session_id', normalizedSessionIds) .whereNot('status', 'removed') .orderBy('session_id', 'asc') .orderBy('created_at', 'desc') .orderBy('request_id', 'desc'); const requestMap = new Map(); const requestRowsBySession = new Map>(); const completedSessionIds = new Set(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); const userText = String(row.user_text ?? '').trim(); if (!sessionId || completedSessionIds.has(sessionId) || !userText) { continue; } const requestRows = requestRowsBySession.get(sessionId) ?? []; requestRows.push({ text: userText, createdAt: normalizeDateTimeValue(row.created_at), }); if (requestRows.length >= 5) { completedSessionIds.add(sessionId); } requestRowsBySession.set(sessionId, requestRows); if (completedSessionIds.size >= normalizedSessionIds.length) { break; } } for (const sessionId of normalizedSessionIds) { const preview = buildLatestRequestPreview(requestRowsBySession.get(sessionId) ?? []); if (!preview) { continue; } requestMap.set(sessionId, preview); } return requestMap; } async function getLatestResponsePreviewMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'response_text', 'answered_at', 'updated_at', 'status', 'request_id') .whereIn('session_id', normalizedSessionIds) .whereNot('status', 'removed') .orderBy('session_id', 'asc') .orderByRaw('COALESCE(answered_at, updated_at, created_at) desc') .orderBy('request_id', 'desc'); const responseMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); const responseText = String(row.response_text ?? '').trim(); if (!sessionId || responseMap.has(sessionId) || !responseText) { continue; } responseMap.set(sessionId, { text: responseText, createdAt: normalizeDateTimeValue(row.answered_at ?? row.updated_at), }); } return responseMap; } function resolveConversationPreviewOverride( mapped: ChatConversationItem, latestMessage: { text: string; createdAt: string | null } | undefined, latestRequest: { text: string; createdAt: string | null } | undefined, ) { const latestMessageTime = getTimeValue(latestMessage?.createdAt); const latestRequestTime = getTimeValue(latestRequest?.createdAt); if (latestRequest && latestRequestTime > latestMessageTime) { return { ...mapped, lastMessagePreview: createPreview(latestRequest.text), lastMessageAt: latestRequest.createdAt, }; } if (latestMessage) { return { ...mapped, lastMessagePreview: createPreview(latestMessage.text), lastMessageAt: latestMessage.createdAt, }; } return mapped; } async function getLatestResponseMessageIdMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'response_message_id') .whereIn('session_id', normalizedSessionIds) .whereNotNull('response_message_id') .orderBy('session_id', 'asc') .orderBy('response_message_id', 'desc'); const responseMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); const responseMessageId = row.response_message_id == null ? null : Number(row.response_message_id); if (!sessionId || responseMessageId == null || responseMap.has(sessionId)) { continue; } responseMap.set(sessionId, responseMessageId); } return responseMap; } async function getLatestCodexPromptPartsMap(sessionIds: string[]) { const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); if (normalizedSessionIds.length === 0) { return new Map(); } const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('session_id', 'parts_json', 'created_at', 'message_id') .whereIn('session_id', normalizedSessionIds) .andWhere('author', 'codex') .orderBy('session_id', 'asc') .orderBy('created_at', 'desc') .orderBy('message_id', 'desc'); const promptPartMap = new Map(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); if (!sessionId || promptPartMap.has(sessionId)) { continue; } const parts = parseChatMessageParts(row.parts_json); if ((parts ?? []).some((part) => part.type === 'prompt')) { promptPartMap.set(sessionId, parts ?? []); } } return promptPartMap; } async function getLatestResponseMessageId(sessionId: string) { const responseMap = await getLatestResponseMessageIdMap([sessionId]); return responseMap.get(sessionId.trim()) ?? null; } export async function ensureChatConversationTables() { const hasConversationTable = await db.schema.hasTable(CHAT_CONVERSATION_TABLE); if (!hasConversationTable) { await db.schema.createTable(CHAT_CONVERSATION_TABLE, (table) => { table.string('session_id', 120).primary(); table.string('client_id', 120).nullable().index(); table.string('title', 200).notNullable().defaultTo('새 대화'); table.string('request_badge_label', 120).nullable(); table.string('chat_type_id', 120).nullable(); table.string('last_chat_type_id', 120).nullable(); table.string('general_section_name', 120).nullable(); table.string('context_label', 200).nullable(); table.text('context_description').nullable(); table.boolean('notify_offline').notNullable().defaultTo(false); table.string('current_request_id', 120).nullable(); table.string('current_job_status', 40).nullable(); table.text('current_job_message').nullable(); table.integer('current_queue_size').notNullable().defaultTo(0); table.timestamp('current_status_updated_at', { useTz: true }).nullable(); table.text('last_message_preview').notNullable().defaultTo(''); table.timestamp('last_message_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); } const requiredConversationColumns: Array<[string, (table: any) => void]> = [ ['client_id', (table) => table.string('client_id', 120).nullable().index()], ['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')], ['request_badge_label', (table) => table.string('request_badge_label', 120).nullable()], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], ['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()], ['general_section_name', (table) => table.string('general_section_name', 120).nullable()], ['context_label', (table) => table.string('context_label', 200).nullable()], ['context_description', (table) => table.text('context_description').nullable()], ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], ['current_request_id', (table) => table.string('current_request_id', 120).nullable()], ['current_job_status', (table) => table.string('current_job_status', 40).nullable()], ['current_job_message', (table) => table.text('current_job_message').nullable()], ['current_queue_size', (table) => table.integer('current_queue_size').notNullable().defaultTo(0)], ['current_status_updated_at', (table) => table.timestamp('current_status_updated_at', { useTz: true }).nullable()], ['last_message_preview', (table) => table.text('last_message_preview').notNullable().defaultTo('')], ['last_message_at', (table) => table.timestamp('last_message_at', { useTz: true }).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredConversationColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_TABLE, (table) => { createColumn(table); }); } } const hasClientTable = await db.schema.hasTable(CHAT_CONVERSATION_CLIENT_TABLE); if (!hasClientTable) { await db.schema.createTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('client_id', 120).notNullable().index(); table.boolean('notify_offline').notNullable().defaultTo(false); table.bigInteger('last_read_response_message_id').nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'client_id']); }); } const requiredClientColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['client_id', (table) => table.string('client_id', 120).notNullable().index()], ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], ['last_read_response_message_id', (table) => table.bigInteger('last_read_response_message_id').nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredClientColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_CLIENT_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => { createColumn(table); }); } } const hasMessageTable = await db.schema.hasTable(CHAT_CONVERSATION_MESSAGE_TABLE); if (!hasMessageTable) { await db.schema.createTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.bigInteger('message_id').notNullable(); table.string('author', 20).notNullable(); table.text('text').notNullable(); table.text('parts_json').notNullable().defaultTo('[]'); table.string('display_timestamp', 40).notNullable().defaultTo(''); table.string('client_request_id', 120).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'message_id']); }); } const requiredMessageColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['message_id', (table) => table.bigInteger('message_id').notNullable()], ['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')], ['text', (table) => table.text('text').notNullable().defaultTo('')], ['parts_json', (table) => table.text('parts_json').notNullable().defaultTo('[]')], ['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')], ['client_request_id', (table) => table.string('client_request_id', 120).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredMessageColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_MESSAGE_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => { createColumn(table); }); } } const hasRequestTable = await db.schema.hasTable(CHAT_CONVERSATION_REQUEST_TABLE); if (!hasRequestTable) { await db.schema.createTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('request_id', 120).notNullable(); table.string('requester_client_id', 200).nullable(); table.string('status', 40).notNullable().defaultTo('accepted'); table.text('status_message').nullable(); table.bigInteger('user_message_id').nullable(); table.text('user_text').notNullable().defaultTo(''); table.bigInteger('response_message_id').nullable(); table.text('response_text').notNullable().defaultTo(''); table.timestamp('answered_at', { useTz: true }).nullable(); table.timestamp('terminal_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'request_id']); }); } const requiredRequestColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['request_id', (table) => table.string('request_id', 120).notNullable()], ['requester_client_id', (table) => table.string('requester_client_id', 200).nullable()], ['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')], ['status_message', (table) => table.text('status_message').nullable()], ['user_message_id', (table) => table.bigInteger('user_message_id').nullable()], ['user_text', (table) => table.text('user_text').notNullable().defaultTo('')], ['response_message_id', (table) => table.bigInteger('response_message_id').nullable()], ['response_text', (table) => table.text('response_text').notNullable().defaultTo('')], ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()], ['terminal_at', (table) => table.timestamp('terminal_at', { useTz: true }).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredRequestColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_REQUEST_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => { createColumn(table); }); } } const hasActivityTable = await db.schema.hasTable(CHAT_CONVERSATION_ACTIVITY_TABLE); if (!hasActivityTable) { await db.schema.createTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('request_id', 120).notNullable().index(); table.integer('line_no').notNullable(); table.text('text').notNullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'request_id', 'line_no']); }); } const requiredActivityColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['request_id', (table) => table.string('request_id', 120).notNullable().index()], ['line_no', (table) => table.integer('line_no').notNullable()], ['text', (table) => table.text('text').notNullable().defaultTo('')], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredActivityColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_ACTIVITY_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => { createColumn(table); }); } } const hasSourceChangeTable = await db.schema.hasTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE); if (!hasSourceChangeTable) { await db.schema.createTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('client_id', 120).nullable().index(); table.string('request_id', 120).notNullable(); table.string('conversation_title', 200).notNullable().defaultTo('새 대화'); table.string('chat_type_id', 120).nullable(); table.string('chat_type_label', 200).nullable(); table.string('request_title', 200).notNullable().defaultTo(''); table.text('question_text').notNullable().defaultTo(''); table.text('answer_text').notNullable().defaultTo(''); table.string('status', 40).notNullable().defaultTo('completed'); table.timestamp('answered_at', { useTz: true }).nullable(); table.timestamp('source_changed_at', { useTz: true }).nullable(); table.text('feature_tags_json').notNullable().defaultTo('[]'); table.text('changed_files_json').notNullable().defaultTo('[]'); table.text('current_source_files_json').notNullable().defaultTo('[]'); table.text('diff_blocks_json').notNullable().defaultTo('[]'); table.boolean('has_source_changes').notNullable().defaultTo(false); table.string('review_status', 40).notNullable().defaultTo('not-reviewed'); table.string('source_change_kind', 40).notNullable().defaultTo('request'); table.text('source_entry_ids_json').notNullable().defaultTo('[]'); table.timestamp('conversation_deleted_at', { useTz: true }).nullable(); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.unique(['session_id', 'request_id']); }); } const requiredSourceChangeColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['client_id', (table) => table.string('client_id', 120).nullable().index()], ['request_id', (table) => table.string('request_id', 120).notNullable()], ['conversation_title', (table) => table.string('conversation_title', 200).notNullable().defaultTo('새 대화')], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], ['chat_type_label', (table) => table.string('chat_type_label', 200).nullable()], ['request_title', (table) => table.string('request_title', 200).notNullable().defaultTo('')], ['question_text', (table) => table.text('question_text').notNullable().defaultTo('')], ['answer_text', (table) => table.text('answer_text').notNullable().defaultTo('')], ['status', (table) => table.string('status', 40).notNullable().defaultTo('completed')], ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()], ['source_changed_at', (table) => table.timestamp('source_changed_at', { useTz: true }).nullable()], ['feature_tags_json', (table) => table.text('feature_tags_json').notNullable().defaultTo('[]')], ['changed_files_json', (table) => table.text('changed_files_json').notNullable().defaultTo('[]')], ['current_source_files_json', (table) => table.text('current_source_files_json').notNullable().defaultTo('[]')], ['diff_blocks_json', (table) => table.text('diff_blocks_json').notNullable().defaultTo('[]')], ['has_source_changes', (table) => table.boolean('has_source_changes').notNullable().defaultTo(false)], ['review_status', (table) => table.string('review_status', 40).notNullable().defaultTo('not-reviewed')], ['source_change_kind', (table) => table.string('source_change_kind', 40).notNullable().defaultTo('request')], ['source_entry_ids_json', (table) => table.text('source_entry_ids_json').notNullable().defaultTo('[]')], ['conversation_deleted_at', (table) => table.timestamp('conversation_deleted_at', { useTz: true }).nullable()], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; for (const [columnName, createColumn] of requiredSourceChangeColumns) { const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, columnName); if (!hasColumn) { await db.schema.alterTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => { createColumn(table); }); } } } export async function getChatConversation(sessionId: string, clientId?: string | null) { const normalizedSessionId = sessionId.trim(); let row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); if (!row) { return null; } const currentRequestId = String(row.current_request_id ?? '').trim() || null; if ( shouldClearConversationJobState({ currentRequestId, currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), runtimeActive: isRuntimeRequestActive(currentRequestId), request: currentRequestId ? await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: currentRequestId, }) .first() .then((requestRow) => requestRow ? { requestId: String(requestRow.request_id ?? ''), status: String(requestRow.status ?? '') as ChatConversationRequestStatus, responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseText: String(requestRow.response_text ?? ''), terminalAt: normalizeDateTimeValue(requestRow.terminal_at), updatedAt: normalizeDateTimeValue(requestRow.updated_at), } : null, ) : null, }) ) { const shouldMarkRequestFailed = currentRequestId && !isRuntimeRequestActive(currentRequestId) && ['queued', 'started'].includes(String(row.current_job_status ?? '').trim()); if (shouldMarkRequestFailed) { await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: currentRequestId, }) .whereIn('status', ['queued', 'started']) .update({ status: 'failed', status_message: '중단된 오래된 요청', terminal_at: db.fn.now(), updated_at: db.fn.now(), }); } await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); if (!row) { return null; } } const mapped = mapConversationRow(row); const latestPreviewMessageMap = await getLatestPreviewableMessageMap([normalizedSessionId]); const latestRequestPreviewMap = await getLatestRequestPreviewMap([normalizedSessionId]); const previewResolvedConversation = resolveConversationPreviewOverride( mapped, latestPreviewMessageMap.get(normalizedSessionId), latestRequestPreviewMap.get(normalizedSessionId), ); const normalizedClientId = normalizeClientId(clientId); if (!normalizedClientId) { return previewResolvedConversation; } const preference = await getChatConversationClientPreference(sessionId, normalizedClientId); const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId); return { ...previewResolvedConversation, clientId: normalizedClientId, notifyOffline: preference?.notifyOffline ?? previewResolvedConversation.notifyOffline, hasUnreadResponse: latestResponseMessageId != null && latestResponseMessageId > (preference?.lastReadResponseMessageId ?? 0), }; } export async function createChatConversation(payload: z.input) { const parsed = conversationPayloadSchema.parse(payload); const normalizedClientId = normalizeClientId(parsed.clientId); const notifyOffline = parsed.notifyOffline ?? true; await db(CHAT_CONVERSATION_TABLE) .insert({ session_id: parsed.sessionId, client_id: normalizedClientId, title: parsed.title?.trim() || '새 대화', request_badge_label: parsed.requestBadgeLabel?.trim() || null, chat_type_id: parsed.chatTypeId?.trim() || null, last_chat_type_id: parsed.lastChatTypeId?.trim() || parsed.chatTypeId?.trim() || null, general_section_name: parsed.generalSectionName?.trim() || null, context_label: parsed.contextLabel?.trim() || null, context_description: parsed.contextDescription?.trim() || null, notify_offline: notifyOffline, current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: null, last_message_preview: '', last_message_at: null, created_at: db.fn.now(), updated_at: db.fn.now(), }) .onConflict('session_id') .ignore(); if (normalizedClientId) { const existingPreference = await getChatConversationClientPreference(parsed.sessionId, normalizedClientId); if (!existingPreference) { await upsertChatConversationClientPreference(parsed.sessionId, normalizedClientId, notifyOffline); } } const conversation = await getChatConversation(parsed.sessionId, normalizedClientId); if (!conversation) { throw new Error('채팅방을 저장했지만 다시 불러오지 못했습니다.'); } return conversation; } export async function updateChatConversationContext( sessionId: string, payload: { title?: string | null; requestBadgeLabel?: string | null; clientId?: string | null; chatTypeId?: string | null; lastChatTypeId?: string | null; generalSectionName?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean | null; }, ) { const normalizedClientId = normalizeClientId(payload.clientId); const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); if (!current) { return null; } const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null; const requestedChatTypeId = payload.chatTypeId?.trim() || null; const nextChatTypeId = resolveNextConversationChatTypeId(currentChatTypeId, requestedChatTypeId); const requestedContextLabel = payload.contextLabel?.trim() || null; const requestedContextDescription = payload.contextDescription?.trim() || null; const hasRequestedRequestBadgeLabel = Object.prototype.hasOwnProperty.call(payload, 'requestBadgeLabel'); const hasRequestedGeneralSectionName = Object.prototype.hasOwnProperty.call(payload, 'generalSectionName'); const hasRequestedContextLabel = Object.prototype.hasOwnProperty.call(payload, 'contextLabel'); const hasRequestedContextDescription = Object.prototype.hasOwnProperty.call(payload, 'contextDescription'); await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId.trim() }) .update({ title: payload.title?.trim() || current.title || '새 대화', request_badge_label: resolveNextConversationContextValue( current.request_badge_label, payload.requestBadgeLabel, hasRequestedRequestBadgeLabel, ), client_id: normalizedClientId || current.client_id || null, chat_type_id: nextChatTypeId, last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null, general_section_name: resolveNextConversationContextValue( current.general_section_name, payload.generalSectionName, hasRequestedGeneralSectionName, ), context_label: resolveNextConversationContextValue(current.context_label, requestedContextLabel, hasRequestedContextLabel), context_description: resolveNextConversationContextValue( current.context_description, requestedContextDescription, hasRequestedContextDescription, ), notify_offline: normalizedClientId == null && payload.notifyOffline != null ? payload.notifyOffline : Boolean(current.notify_offline), updated_at: db.fn.now(), }); if (normalizedClientId && payload.notifyOffline != null) { await upsertChatConversationClientPreference(sessionId, normalizedClientId, payload.notifyOffline); } return getChatConversation(sessionId, normalizedClientId); } export function resolveNextConversationChatTypeId(currentChatTypeId?: string | null, requestedChatTypeId?: string | null) { const normalizedCurrentChatTypeId = String(currentChatTypeId ?? '').trim() || null; const normalizedRequestedChatTypeId = String(requestedChatTypeId ?? '').trim() || null; return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null; } export function resolveNextConversationContextValue( currentValue: string | null | undefined, requestedValue: string | null | undefined, hasRequestedValue: boolean, ) { if (!hasRequestedValue) { return String(currentValue ?? '').trim() || null; } return String(requestedValue ?? '').trim() || null; } export async function listChatConversations( clientId?: string | null, limit = 50, unreadStateClientId?: string | null, ) { const normalizedClientId = normalizeClientId(clientId); const normalizedUnreadStateClientId = normalizeClientId(unreadStateClientId ?? clientId); const normalizedLimit = Math.max(1, Math.min(200, Math.round(limit))); let conversationListScopeClientId = normalizedClientId; const buildConversationListQuery = (targetClientId?: string | null) => { const query = db(CHAT_CONVERSATION_TABLE) .select('*') .orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST') .orderByRaw('last_message_at DESC NULLS LAST') .orderByRaw('updated_at DESC NULLS LAST') .orderByRaw('created_at DESC NULLS LAST') .limit(normalizedLimit); if (targetClientId) { query.where((builder) => { builder .where({ client_id: targetClientId }) .orWhereExists( db(CHAT_CONVERSATION_CLIENT_TABLE) .select(db.raw('1')) .whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`) .andWhere({ client_id: targetClientId }), ); }); } return query; }; let rows = await buildConversationListQuery(normalizedClientId); // Browser storage reset can regenerate client_id and hide existing rooms. // When that happens, fall back to the recent global list so the user can recover. if (normalizedClientId && rows.length === 0) { conversationListScopeClientId = null; rows = await buildConversationListQuery(null); } const sessionIds = rows.map((row) => String(row.session_id ?? '')).filter(Boolean); const currentRequestIds = Array.from( new Set(rows.map((row) => String(row.current_request_id ?? '').trim()).filter(Boolean)), ); if (sessionIds.length > 0 && currentRequestIds.length > 0) { const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('session_id', 'request_id', 'status', 'response_message_id', 'response_text', 'terminal_at') .whereIn('session_id', sessionIds) .whereIn('request_id', currentRequestIds); const requestMap = new Map( requestRows.map((requestRow) => [ `${String(requestRow.session_id ?? '').trim()}:${String(requestRow.request_id ?? '').trim()}`, { requestId: String(requestRow.request_id ?? ''), status: String(requestRow.status ?? '') as ChatConversationRequestStatus, responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), responseText: String(requestRow.response_text ?? ''), terminalAt: normalizeDateTimeValue(requestRow.terminal_at), }, ]), ); const staleSessionIds = rows .filter((row) => shouldClearConversationJobState({ currentRequestId: String(row.current_request_id ?? ''), currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), runtimeActive: isRuntimeRequestActive(String(row.current_request_id ?? '')), request: requestMap.get(`${String(row.session_id ?? '').trim()}:${String(row.current_request_id ?? '').trim()}`) ?? null, }), ) .map((row) => String(row.session_id ?? '').trim()) .filter(Boolean); if (staleSessionIds.length > 0) { const staleRequestIds = rows .filter((row) => staleSessionIds.includes(String(row.session_id ?? '').trim())) .map((row) => ({ sessionId: String(row.session_id ?? '').trim(), requestId: String(row.current_request_id ?? '').trim(), status: String(row.current_job_status ?? '').trim(), })) .filter((item) => item.requestId && (item.status === 'queued' || item.status === 'started')); for (const staleItem of staleRequestIds) { await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: staleItem.sessionId, request_id: staleItem.requestId, }) .whereIn('status', ['queued', 'started']) .update({ status: 'failed', status_message: '중단된 오래된 요청', terminal_at: db.fn.now(), updated_at: db.fn.now(), }); } await db(CHAT_CONVERSATION_TABLE) .whereIn('session_id', staleSessionIds) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); rows = await buildConversationListQuery(conversationListScopeClientId); } } if (rows.length > 0) { const candidateSessionIds = rows.map((row) => String(row.session_id ?? '').trim()).filter(Boolean); const [messageSessionRows, requestSessionRows] = await Promise.all([ db(CHAT_CONVERSATION_MESSAGE_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds), db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds), ]); const visibleSessionIds = new Set( [...messageSessionRows, ...requestSessionRows] .map((row) => String(row.session_id ?? '').trim()) .filter(Boolean), ); rows = rows.filter((row) => { const sessionId = String(row.session_id ?? '').trim(); return visibleSessionIds.has(sessionId) || hasConversationMetadata(row); }); } const latestPreviewMessageMap = await getLatestPreviewableMessageMap( rows.map((row) => String(row.session_id ?? '')), ); const latestRequestPreviewMap = await getLatestRequestPreviewMap( rows.map((row) => String(row.session_id ?? '')), ); const latestResponsePreviewMap = await getLatestResponsePreviewMap( rows.map((row) => String(row.session_id ?? '')), ); const latestResponseMessageIdMap = await getLatestResponseMessageIdMap( rows.map((row) => String(row.session_id ?? '')), ); const latestCodexPromptPartsMap = await getLatestCodexPromptPartsMap( rows.map((row) => String(row.session_id ?? '')), ); if (!normalizedUnreadStateClientId) { return rows .map((row) => { const mapped = mapConversationRow(row); const pendingWorkState = resolvePendingWorkState({ requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '', responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '', latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId), }); return { ...resolveConversationPreviewOverride( mapped, latestPreviewMessageMap.get(mapped.sessionId), latestRequestPreviewMap.get(mapped.sessionId), ), ...pendingWorkState, lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''), lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''), hasUnreadResponse: false, }; }) .sort((left, right) => (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt), ); } if (rows.length === 0) { return []; } const preferences = await db(CHAT_CONVERSATION_CLIENT_TABLE) .select('*') .where({ client_id: normalizedUnreadStateClientId }) .whereIn( 'session_id', rows.map((row) => String(row.session_id ?? '')).filter(Boolean), ); const preferenceMap = new Map( preferences.map((row) => { const mapped = mapClientPreferenceRow(row); return [mapped.sessionId, mapped]; }), ); return rows .map((row) => { const mapped = mapConversationRow(row); const preference = preferenceMap.get(mapped.sessionId); const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId); const pendingWorkState = resolvePendingWorkState({ requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '', responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '', latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId), }); return { ...resolveConversationPreviewOverride( mapped, latestPreviewMessage, latestRequestPreviewMap.get(mapped.sessionId), ), ...pendingWorkState, lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''), lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''), clientId: normalizedUnreadStateClientId, notifyOffline: preference?.notifyOffline ?? mapped.notifyOffline, hasUnreadResponse: (latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) > (preference?.lastReadResponseMessageId ?? 0), }; }) .sort((left, right) => (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt), ); } export async function listChatConversationMessages( sessionId: string, options: { limit?: number; beforeMessageId?: number | null; } = {}, ) { const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200))); const normalizedBeforeMessageId = Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 ? Math.trunc(options.beforeMessageId as number) : null; const query = db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('*') .where({ session_id: sessionId.trim() }) .modify((builder) => { if (normalizedBeforeMessageId !== null) { builder.where('message_id', '<', normalizedBeforeMessageId); } builder.andWhere((visibilityBuilder) => { applyVisibleConversationMessageCondition(visibilityBuilder); }); }) .orderBy('message_id', 'desc') .orderBy('id', 'desc') .limit(normalizedLimit); const latestRows = await query; return latestRows.reverse().map((row: Parameters[0]) => mapMessageRow(row)); } async function listChatConversationActivityLogsByRequestIds( sessionId: string, requestIds: string[], ): Promise { const normalizedSessionId = sessionId.trim(); const normalizedRequestIds = Array.from(new Set(requestIds.map((item) => item.trim()).filter(Boolean))); if (!normalizedSessionId || normalizedRequestIds.length === 0) { return []; } const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .select('session_id', 'request_id', 'text', 'line_no', 'created_at') .where({ session_id: normalizedSessionId }) .whereIn('request_id', normalizedRequestIds) .orderBy('request_id', 'asc') .orderBy('line_no', 'asc') .orderBy('id', 'asc'); const activityMap = new Map(); for (const row of rows) { const requestId = String(row.request_id ?? '').trim(); if (!requestId) { continue; } const existing = activityMap.get(requestId); if (existing) { existing.lines.push(String(row.text ?? '')); existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt; continue; } activityMap.set(requestId, { sessionId: String(row.session_id ?? normalizedSessionId), requestId, lines: [String(row.text ?? '')], updatedAt: normalizeDateTimeValue(row.created_at), }); } return normalizedRequestIds .map((requestId) => activityMap.get(requestId)) .filter(Boolean) as ChatConversationActivityLogItem[]; } async function resolveConversationRequestCursor(sessionId: string, beforeMessageId: number) { const normalizedSessionId = sessionId.trim(); const normalizedBeforeMessageId = Math.trunc(beforeMessageId); if (!normalizedSessionId || normalizedBeforeMessageId <= 0) { return null; } const directRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('request_id', 'created_at') .where({ session_id: normalizedSessionId }) .andWhere((builder) => { builder.where('user_message_id', normalizedBeforeMessageId).orWhere('response_message_id', normalizedBeforeMessageId); }) .first(); if (directRequestRow) { return { requestId: String(directRequestRow.request_id ?? '').trim(), createdAt: normalizeDateTimeValue(directRequestRow.created_at) ?? '', }; } const messageRow = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('client_request_id') .where({ session_id: normalizedSessionId, message_id: normalizedBeforeMessageId, }) .first(); const linkedRequestId = String(messageRow?.client_request_id ?? '').trim(); if (!linkedRequestId) { return null; } const linkedRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('request_id', 'created_at') .where({ session_id: normalizedSessionId, request_id: linkedRequestId, }) .first(); if (!linkedRequestRow) { return null; } return { requestId: String(linkedRequestRow.request_id ?? '').trim(), createdAt: normalizeDateTimeValue(linkedRequestRow.created_at) ?? '', }; } export async function listChatConversationDetailPage( sessionId: string, options: { limit?: number; beforeMessageId?: number | null; } = {}, ): Promise { const normalizedSessionId = sessionId.trim(); const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 8))); const normalizedBeforeMessageId = Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 ? Math.trunc(options.beforeMessageId as number) : null; const requestCursor = normalizedBeforeMessageId == null ? null : await resolveConversationRequestCursor(normalizedSessionId, normalizedBeforeMessageId); const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId }) .whereNot('status', 'removed') .modify((builder) => { if (!requestCursor) { return; } builder.andWhere((cursorBuilder) => { cursorBuilder .where('created_at', '<', requestCursor.createdAt) .orWhere((sameTimeBuilder) => { sameTimeBuilder.where('created_at', '=', requestCursor.createdAt).andWhere('request_id', '<', requestCursor.requestId); }); }); }) .orderBy('created_at', 'desc') .orderBy('request_id', 'desc') .limit(normalizedLimit); const orderedRequestRows = [...requestRows].reverse(); const requests = orderedRequestRows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean); if (requestIds.length === 0) { return { messages: [], requests, activityLogs: [], oldestLoadedMessageId: null, hasOlderMessages: false, }; } const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .select('*') .where({ session_id: normalizedSessionId }) .whereIn('client_request_id', requestIds) .andWhere((builder) => { applyVisibleConversationMessageCondition(builder); }) .orderBy('message_id', 'asc') .orderBy('id', 'asc'); const messages = messageRows.map((row: Parameters[0]) => mapMessageRow(row)); const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, requestIds); const oldestLoadedMessageId = requests.reduce((oldestId, request) => { const candidateIds = [request.userMessageId, request.responseMessageId].filter( (value): value is number => typeof value === 'number' && Number.isInteger(value) && value > 0, ); if (candidateIds.length === 0) { return oldestId; } const nextCandidateId = Math.min(...candidateIds); return oldestId == null ? nextCandidateId : Math.min(oldestId, nextCandidateId); }, null) ?? messages[0]?.id ?? null; const oldestRequest = requests[0] ?? null; const hasOlderMessages = oldestRequest ? Boolean( await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId }) .whereNot('status', 'removed') .andWhere((builder) => { builder .where('created_at', '<', oldestRequest.createdAt) .orWhere((sameTimeBuilder) => { sameTimeBuilder.where('created_at', '=', oldestRequest.createdAt).andWhere('request_id', '<', oldestRequest.requestId); }); }) .first(), ) : false; return { messages, requests, activityLogs, oldestLoadedMessageId, hasOlderMessages, }; } export async function listChatConversationRequests(sessionId: string, limit = 200) { const normalizedSessionId = sessionId.trim(); const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId }) .orderBy('created_at', 'asc') .limit(Math.max(1, Math.min(1000, Math.round(limit)))); return rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); } export async function listChatSourceChangeSnapshots(clientId?: string | null, limit = 200) { await ensureChatConversationTables(); await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where({ has_source_changes: true }) .andWhere('changed_files_json', '[]') .andWhere('current_source_files_json', '[]') .andWhere('diff_blocks_json', '[]') .update({ has_source_changes: false, updated_at: db.fn.now(), }); const normalizedClientId = normalizeClientId(clientId); const normalizedLimit = Math.max(1, Math.min(500, Math.round(limit))); const buildQuery = (targetClientId?: string | null) => db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .select('*') .where({ has_source_changes: true }) .modify((builder) => { if (targetClientId) { builder.where({ client_id: targetClientId }); } }) .orderByRaw('COALESCE(source_changed_at, answered_at, updated_at) DESC NULLS LAST') .orderBy('updated_at', 'desc') .limit(normalizedLimit); let rows = await buildQuery(normalizedClientId); if (normalizedClientId && rows.length === 0) { rows = await buildQuery(null); } return rows.map((row: Parameters[0]) => mapSourceChangeSnapshotRow(row)); } async function applyVerifiedSourceChangeGrouping(args: { sessionId: string; clientId?: string | null; conversationTitle?: string | null; chatTypeId?: string | null; chatTypeLabel?: string | null; requestId: string; questionText: string; answerText: string; status: ChatConversationRequestStatus; answeredAt?: string | null; updatedAt?: string | null; }) { const metadata = parseSourceChangeVerificationMetadata(args.questionText); if (!metadata) { return; } const selectedFeatureKeys = parseSelectedPromptValues(args.answerText); const entryRefs = metadata.features.flatMap((feature) => feature.entryRefs); if (entryRefs.length === 0) { return; } const sourceRows = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .select('*') .where((builder) => { entryRefs.forEach((entryRef, index) => { const method = index === 0 ? 'where' : 'orWhere'; builder[method]((nestedBuilder: any) => { nestedBuilder.where({ session_id: entryRef.sessionId, request_id: entryRef.requestId, }); }); }); }); const sourceRowMap = new Map>(); sourceRows.forEach((row) => { const sessionId = String(row.session_id ?? '').trim(); const requestId = String(row.request_id ?? '').trim(); if (sessionId && requestId) { sourceRowMap.set(`${sessionId}:${requestId}`, row); } }); const activeSourceIds = Array.from(new Set(entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`))); await db.transaction(async (trx) => { if (activeSourceIds.length > 0) { await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where((builder) => { activeSourceIds.forEach((entryId, index) => { const dividerIndex = entryId.indexOf(':'); const sessionId = dividerIndex >= 0 ? entryId.slice(0, dividerIndex) : ''; const requestId = dividerIndex >= 0 ? entryId.slice(dividerIndex + 1) : ''; const method = index === 0 ? 'where' : 'orWhere'; builder[method]((nestedBuilder: any) => { nestedBuilder.where({ session_id: sessionId, request_id: requestId, }); }); }); }) .update({ has_source_changes: false, updated_at: db.fn.now(), }); } await Promise.all(metadata.features.map(async (feature, index) => { const featureRows = feature.entryRefs .map((entryRef) => sourceRowMap.get(`${entryRef.sessionId}:${entryRef.requestId}`) ?? null) .filter((row): row is Record => row != null); if (featureRows.length === 0) { return; } const changedFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.changed_files_json))); const currentSourceFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.current_source_files_json))); const diffBlocks = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.diff_blocks_json))); const payload = buildSourceChangeSnapshotPayload({ sessionId: args.sessionId, clientId: args.clientId ?? null, conversationTitle: args.conversationTitle ?? null, chatTypeId: args.chatTypeId ?? null, chatTypeLabel: args.chatTypeLabel ?? null, requestId: buildVerificationGroupRequestId(args.requestId, feature.key, index), status: args.status, questionText: args.questionText, answerText: args.answerText, answeredAt: args.answeredAt ?? null, updatedAt: args.updatedAt ?? null, reviewStatus: selectedFeatureKeys.has(feature.key) ? 'reviewed' : 'not-reviewed', sourceChangeKind: 'verification-group', sourceEntryIds: feature.entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`), }); await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .insert({ ...payload, request_title: `${feature.label} 검증`, feature_tags_json: stringifyStringArray([feature.label]), changed_files_json: changedFiles, current_source_files_json: currentSourceFiles, diff_blocks_json: diffBlocks, has_source_changes: hasMeaningfulChatSourceArtifacts({ changedFiles: parseStringArray(changedFiles), currentSourceFiles: parseStringArray(currentSourceFiles), diffBlocks: parseStringArray(diffBlocks), }), }) .onConflict(['session_id', 'request_id']) .merge(); })); }); } export async function syncChatSourceChangeSnapshot(sessionId: string, requestId: string) { await ensureChatConversationTables(); const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); if (!normalizedSessionId || !normalizedRequestId) { return null; } const [conversation, request] = await Promise.all([ db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(), db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(), ]); if (!request) { return null; } const status = String(request.status ?? 'accepted') as ChatConversationRequestStatus; const questionText = String(request.user_text ?? '').trim(); const answerText = String(request.response_text ?? '').trim(); if (status !== 'completed' || !answerText || isPreparingChatReplyText(answerText)) { return null; } const payload = buildSourceChangeSnapshotPayload({ sessionId: normalizedSessionId, clientId: conversation?.client_id == null ? null : String(conversation.client_id), conversationTitle: String(conversation?.title ?? '새 대화'), chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id), chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label), requestId: normalizedRequestId, status, questionText, answerText, answeredAt: normalizeDateTimeValue(request.answered_at), updatedAt: normalizeDateTimeValue(request.updated_at), }); await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .insert(payload) .onConflict(['session_id', 'request_id']) .merge(payload); await applyVerifiedSourceChangeGrouping({ sessionId: normalizedSessionId, clientId: conversation?.client_id == null ? null : String(conversation.client_id), conversationTitle: String(conversation?.title ?? '새 대화'), chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id), chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label), requestId: normalizedRequestId, questionText, answerText, status, answeredAt: normalizeDateTimeValue(request.answered_at), updatedAt: normalizeDateTimeValue(request.updated_at), }); const row = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); return row ? mapSourceChangeSnapshotRow(row) : null; } export async function getChatConversationRequest(sessionId: string, requestId: string) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); if (!normalizedSessionId || !normalizedRequestId) { return null; } const conversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null; } async function refreshConversationPreview(sessionId: string) { const latestMessage = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: sessionId.trim() }) .whereIn('author', ['user', 'codex']) .orderBy('created_at', 'desc') .orderBy('id', 'desc') .first(); await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId.trim() }) .update({ last_message_preview: latestMessage && isPreviewableConversationMessage(latestMessage) ? createPreview(String(latestMessage.text ?? '')) : '', last_message_at: latestMessage && isPreviewableConversationMessage(latestMessage) ? latestMessage.created_at : null, updated_at: db.fn.now(), }); } export async function appendChatConversationMessage( conversationPayload: z.input, messagePayload: z.input, ) { const conversation = conversationPayloadSchema.parse(conversationPayload); const message = conversationMessagePayloadSchema.parse(messagePayload); await createChatConversation(conversation); const currentConversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: conversation.sessionId }).first(); const resolvedClientRequestId = message.clientRequestId?.trim() || (message.author === 'codex' ? String(currentConversation?.current_request_id ?? '').trim() || null : null); await db(CHAT_CONVERSATION_MESSAGE_TABLE) .insert({ session_id: message.sessionId, message_id: message.messageId, author: message.author, text: message.text, parts_json: stringifyChatMessageParts(message.parts), display_timestamp: message.timestamp, client_request_id: resolvedClientRequestId, created_at: db.fn.now(), }) .onConflict(['session_id', 'message_id']) .merge({ author: message.author, text: message.text, parts_json: stringifyChatMessageParts(message.parts), display_timestamp: message.timestamp, client_request_id: resolvedClientRequestId, }); const currentTitle = String(currentConversation?.title ?? '새 대화').trim() || '새 대화'; const nextTitle = message.author === 'user' && (!currentTitle || currentTitle === '새 대화') ? buildConversationTitle(message.text) : currentTitle; await db(CHAT_CONVERSATION_TABLE) .where({ session_id: conversation.sessionId }) .update({ client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null, title: nextTitle, chat_type_id: currentConversation?.chat_type_id || conversation.chatTypeId?.trim() || null, last_chat_type_id: currentConversation?.chat_type_id || currentConversation?.last_chat_type_id || conversation.chatTypeId?.trim() || conversation.lastChatTypeId?.trim() || null, context_label: currentConversation?.chat_type_id || currentConversation?.context_label ? currentConversation?.context_label || null : conversation.contextLabel?.trim() || null, context_description: currentConversation?.chat_type_id || currentConversation?.context_description ? currentConversation?.context_description || null : conversation.contextDescription?.trim() || null, notify_offline: conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline, updated_at: db.fn.now(), }); await refreshConversationPreview(conversation.sessionId); if (normalizeClientId(conversation.clientId) && conversation.notifyOffline != null) { await upsertChatConversationClientPreference( conversation.sessionId, normalizeClientId(conversation.clientId)!, conversation.notifyOffline, ); } const requestPatch = buildChatConversationRequestPatchFromMessage({ id: message.messageId, author: message.author, text: message.text, clientRequestId: resolvedClientRequestId, }); if (requestPatch) { await upsertChatConversationRequest(conversation.sessionId, { requestId: requestPatch.requestId, requesterClientId: message.author === 'user' ? normalizeClientId(conversation.clientId) : undefined, status: requestPatch.status, userMessageId: requestPatch.userMessageId, userText: requestPatch.userText, responseMessageId: requestPatch.responseMessageId, responseText: requestPatch.responseText, }); } } export async function appendChatConversationActivityLine( sessionId: string, requestId: string, line: string, lineNo?: number, ) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const normalizedLine = line.trim(); if (!normalizedSessionId || !normalizedRequestId || !normalizedLine) { return null; } const normalizedLineNo = Number.isInteger(lineNo) && Number(lineNo) > 0 ? Number(lineNo) : undefined; let nextLineNo = normalizedLineNo ?? null; if (nextLineNo == null) { const existingLineCountRow = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .count<{ count?: string | number }>('id as count') .first(); nextLineNo = Number(existingLineCountRow?.count ?? 0) + 1; } await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .insert({ session_id: normalizedSessionId, request_id: normalizedRequestId, line_no: nextLineNo, text: normalizedLine, created_at: db.fn.now(), }) .onConflict(['session_id', 'request_id', 'line_no']) .merge({ text: normalizedLine, created_at: db.fn.now(), }); return nextLineNo; } export async function listChatConversationActivityLogs( sessionId: string, limitRequests = 500, ): Promise { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return []; } const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .select('request_id') .where({ session_id: normalizedSessionId }) .whereNot('status', 'removed') .orderBy('created_at', 'desc') .orderBy('request_id', 'desc') .limit(limitRequests); const requestIds = requestRows .map((row) => String(row.request_id ?? '').trim()) .filter(Boolean); if (requestIds.length === 0) { return []; } const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .select('session_id', 'request_id', 'text', 'line_no', 'created_at') .where({ session_id: normalizedSessionId }) .whereIn('request_id', requestIds) .orderBy('request_id', 'asc') .orderBy('line_no', 'asc') .orderBy('id', 'asc'); const activityMap = new Map(); for (const row of rows) { const requestId = String(row.request_id ?? '').trim(); if (!requestId) { continue; } const existing = activityMap.get(requestId); if (existing) { existing.lines.push(String(row.text ?? '')); existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt; continue; } activityMap.set(requestId, { sessionId: String(row.session_id ?? normalizedSessionId), requestId, lines: [String(row.text ?? '')], updatedAt: normalizeDateTimeValue(row.created_at), }); } return requestIds.map((requestId) => activityMap.get(requestId)).filter(Boolean) as ChatConversationActivityLogItem[]; } export async function listRecoverableChatConversationRequests(): Promise { await ensureChatConversationTables(); const rows = await db(`${CHAT_CONVERSATION_REQUEST_TABLE} as request`) .join(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'request.session_id') .select( 'request.session_id', 'request.request_id', 'request.status', 'request.user_text', 'request.created_at', 'conversation.client_id', 'conversation.chat_type_id', 'conversation.last_chat_type_id', 'conversation.general_section_name', 'conversation.context_label', 'conversation.context_description', 'conversation.current_request_id', 'conversation.current_job_status', ) .whereIn('request.status', ['accepted', 'queued', 'started']) .andWhere((builder) => { builder.whereNull('request.terminal_at'); }) .andWhere((builder) => { builder.whereNull('request.response_message_id').orWhere('request.response_message_id', 0); }) .orderByRaw( "case when request.request_id = conversation.current_request_id then 0 else 1 end asc", ) .orderBy('request.session_id', 'asc') .orderBy('request.created_at', 'asc') .orderBy('request.request_id', 'asc'); return rows .map((row) => { const sessionId = String(row.session_id ?? '').trim(); const requestId = String(row.request_id ?? '').trim(); const userText = String(row.user_text ?? '').trim(); const createdAt = normalizeDateTimeValue(row.created_at) ?? ''; if (!sessionId || !requestId || !userText || !createdAt) { return null; } return { sessionId, clientId: row.client_id == null ? null : String(row.client_id), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), generalSectionName: row.general_section_name == null ? null : String(row.general_section_name), contextLabel: row.context_label == null ? null : String(row.context_label), contextDescription: row.context_description == null ? null : String(row.context_description), currentRequestId: row.current_request_id == null ? null : String(row.current_request_id), currentJobStatus: row.current_job_status == null ? null : (String(row.current_job_status) as ChatConversationItem['currentJobStatus']), requestId, status: String(row.status ?? 'accepted') as ChatConversationRequestStatus, userText, createdAt, } satisfies RecoverableChatConversationRequestItem; }) .filter(Boolean) as RecoverableChatConversationRequestItem[]; } export async function updateChatConversationJobState( sessionId: string, payload: { requestId?: string | null; status?: 'queued' | 'started' | 'completed' | 'failed' | null; message?: string | null; queueSize?: number | null; clear?: boolean; }, ) { const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); if (!current) { return null; } const shouldClear = payload.clear === true; await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId.trim() }) .update({ current_request_id: shouldClear ? null : payload.requestId?.trim() || current.current_request_id || null, current_job_status: shouldClear ? null : payload.status ?? current.current_job_status ?? null, current_job_message: shouldClear ? null : payload.message?.trim() || null, current_queue_size: shouldClear ? 0 : Math.max(0, Math.round(payload.queueSize ?? current.current_queue_size ?? 0)), current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); const row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); return row ? mapConversationRow(row) : null; } async function clearConversationJobStateForRequest(sessionId: string, requestId: string) { await db(CHAT_CONVERSATION_TABLE) .where({ session_id: sessionId, current_request_id: requestId, }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); } export async function upsertChatConversationRequest( sessionId: string, payload: { requestId: string; requesterClientId?: string | null; status?: ChatConversationRequestStatus; statusMessage?: string | null; userMessageId?: number | null; userText?: string | null; responseMessageId?: number | null; responseText?: string | null; }, ) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = payload.requestId.trim(); const normalizedRequesterClientId = payload.requesterClientId?.trim() || null; if (!normalizedSessionId || !normalizedRequestId) { return null; } let nextRow: | { session_id: string; request_id: string; requester_client_id: string | null; status: ChatConversationRequestStatus; status_message: string | null; user_message_id: number | null; user_text: string; response_message_id: number | null; response_text: string; answered_at: unknown; terminal_at: unknown; created_at: unknown; updated_at: unknown; } | null = null; let nextStatus: ChatConversationRequestStatus = payload.status ?? 'accepted'; for (let attempt = 0; attempt < 3; attempt += 1) { const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); nextStatus = mergeChatConversationRequestStatus( (current?.status as ChatConversationRequestStatus | undefined) ?? null, payload.status ?? null, ); const currentStatus = (current?.status as ChatConversationRequestStatus | undefined) ?? null; const defaultStatusMessage = payload.status && payload.status !== currentStatus ? getDefaultChatConversationRequestStatusMessage(nextStatus) : null; const terminalStatus = ['completed', 'failed', 'cancelled', 'removed'].includes(nextStatus) ? db.fn.now() : current?.terminal_at ?? null; const answeredAt = payload.responseMessageId != null || (payload.responseText?.trim() ?? '').length > 0 ? current?.answered_at ?? db.fn.now() : current?.answered_at ?? null; nextRow = { session_id: normalizedSessionId, request_id: normalizedRequestId, requester_client_id: normalizedRequesterClientId ?? current?.requester_client_id ?? null, status: nextStatus, status_message: payload.statusMessage?.trim() || defaultStatusMessage || current?.status_message || null, user_message_id: payload.userMessageId ?? current?.user_message_id ?? null, user_text: payload.userText ?? current?.user_text ?? '', response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null, response_text: payload.responseText ?? current?.response_text ?? '', answered_at: answeredAt, terminal_at: terminalStatus, created_at: current?.created_at ?? db.fn.now(), updated_at: db.fn.now(), }; if (current) { await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .update(nextRow); break; } const insertedRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .insert(nextRow) .onConflict(['session_id', 'request_id']) .ignore() .returning(['session_id']); if (insertedRows.length > 0) { break; } } if (!nextRow) { return null; } const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); if ( shouldClearConversationJobState({ currentRequestId: normalizedRequestId, currentJobStatus: 'started', request: { requestId: normalizedRequestId, status: nextStatus, responseMessageId: nextRow.response_message_id, responseText: nextRow.response_text, terminalAt: nextRow.terminal_at == null ? null : String(nextRow.terminal_at), }, }) ) { await clearConversationJobStateForRequest(normalizedSessionId, normalizedRequestId); } if ( nextStatus === 'completed' && (nextRow.response_message_id != null || String(nextRow.response_text ?? '').trim().length > 0) ) { await syncChatSourceChangeSnapshot(normalizedSessionId, normalizedRequestId); } return row ? mapRequestRow(row) : null; } export async function repairChatConversationRequestLinks(sessionId?: string | null) { await ensureChatConversationTables(); const normalizedSessionId = sessionId?.trim() || null; const sessionRows = normalizedSessionId ? [{ session_id: normalizedSessionId }] : await db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').orderBy('session_id', 'asc'); let repairedRequestCount = 0; let linkedMessageCount = 0; let completedStatusCount = 0; const touchedSessions: string[] = []; for (const sessionRow of sessionRows) { const currentSessionId = String(sessionRow.session_id ?? '').trim(); if (!currentSessionId) { continue; } const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: currentSessionId }) .orderBy('created_at', 'asc') .orderBy('request_id', 'asc'); const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: currentSessionId }) .select('id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'created_at') .orderBy('created_at', 'asc') .orderBy('message_id', 'asc') .orderBy('id', 'asc'); let sessionTouched = false; const responseMessages: ChatConversationResponseCandidate[] = messageRows.map((row) => ({ id: Number(row.id ?? 0), messageId: Number(row.message_id ?? 0), author: String(row.author ?? 'codex') as StoredChatMessage['author'], text: String(row.text ?? ''), clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), createdAt: normalizeDateTimeValue(row.created_at), })); for (let index = 0; index < requestRows.length; index += 1) { const requestRow = requestRows[index]; const nextRequestRow = requestRows[index + 1]; const requestId = String(requestRow.request_id ?? '').trim(); if (!requestId) { continue; } const candidate = selectChatConversationResponseCandidate( { requestId, createdAt: normalizeDateTimeValue(requestRow.created_at) ?? '', responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), }, nextRequestRow ? { createdAt: normalizeDateTimeValue(nextRequestRow.created_at) ?? '', } : undefined, responseMessages, ); if (!candidate) { continue; } if ((candidate.clientRequestId?.trim() || null) !== requestId) { await db(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: currentSessionId, message_id: candidate.messageId, }) .update({ client_request_id: requestId, }); candidate.clientRequestId = requestId; linkedMessageCount += 1; sessionTouched = true; } const shouldPromoteToCompleted = !isTerminalRequestStatus(String(requestRow.status ?? '') as ChatConversationRequestStatus) && requestRow.terminal_at != null; const nextStatus = shouldPromoteToCompleted ? 'completed' : undefined; const previousStatus = String(requestRow.status ?? '').trim(); await upsertChatConversationRequest(currentSessionId, { requestId, status: nextStatus, responseMessageId: candidate.messageId, responseText: candidate.text, }); if ( requestRow.response_message_id == null || String(requestRow.response_text ?? '') !== candidate.text || requestRow.answered_at == null ) { repairedRequestCount += 1; sessionTouched = true; } if (nextStatus === 'completed' && previousStatus !== 'completed') { completedStatusCount += 1; sessionTouched = true; } } if (sessionTouched) { touchedSessions.push(currentSessionId); } } return { sessionCount: sessionRows.length, touchedSessions, repairedRequestCount, linkedMessageCount, completedStatusCount, }; } export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .first(); if (!current) { return { deleted: false, reason: 'not_found' as const }; } const conversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation); if (mapped.hasResponse) { return { deleted: false, reason: 'answered' as const }; } if (mapped.status === 'queued' || mapped.status === 'started') { return { deleted: false, reason: 'active' as const }; } await db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_MESSAGE_TABLE) .where({ session_id: normalizedSessionId, client_request_id: normalizedRequestId, }) .del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE) .where({ session_id: normalizedSessionId, request_id: normalizedRequestId, }) .del(); const conversation = await trx(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); if (conversation?.current_request_id === normalizedRequestId) { await trx(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); } }); await refreshConversationPreview(normalizedSessionId); return { deleted: true, reason: null as null }; } export async function clearAllChatConversationJobStates() { await ensureChatConversationTables(); await db(CHAT_CONVERSATION_TABLE) .whereNotNull('current_job_status') .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: db.fn.now(), updated_at: db.fn.now(), }); } export async function deleteChatConversation(sessionId: string) { const normalizedSessionId = sessionId.trim(); return db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) .where({ session_id: normalizedSessionId }) .update({ conversation_deleted_at: db.fn.now(), updated_at: db.fn.now(), }); await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del(); const deletedCount = await trx(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).del(); return deletedCount > 0; }); } export async function clearChatConversationData(sessionId: string, clientId?: string | null) { const normalizedSessionId = sessionId.trim(); await db.transaction(async (trx) => { await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del(); await trx(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: normalizedSessionId }) .update({ last_read_response_message_id: null, updated_at: db.fn.now(), }); await trx(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .update({ current_request_id: null, current_job_status: null, current_job_message: null, current_queue_size: 0, current_status_updated_at: null, last_message_preview: '', last_message_at: null, updated_at: db.fn.now(), }); }); return getChatConversation(normalizedSessionId, clientId); } export async function getChatConversationClientPreference(sessionId: string, clientId: string) { const row = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: sessionId.trim(), client_id: clientId.trim(), }) .first(); return row ? mapClientPreferenceRow(row) : null; } async function listRegisteredNotificationClientIds(clientIds: string[]) { const normalizedClientIds = [...new Set(clientIds.map((item) => String(item ?? '').trim()).filter(Boolean))]; if (!normalizedClientIds.length) { return new Set(); } const [webPushRows, tokenRows] = await Promise.all([ db(WEB_PUSH_SUBSCRIPTION_TABLE) .where({ is_enabled: true }) .whereIn('device_id', normalizedClientIds) .select('device_id'), db(NOTIFICATION_TOKEN_TABLE) .where({ is_enabled: true }) .whereIn('device_id', normalizedClientIds) .select('device_id'), ]); return new Set( [...webPushRows, ...tokenRows] .map((row) => String(row.device_id ?? '').trim()) .filter(Boolean), ); } export async function cleanupStaleChatConversationOfflineNotificationClients( sessionId: string, options?: { keepClientIds?: Iterable; }, ) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return [] as string[]; } const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: normalizedSessionId, notify_offline: true, }) .select('client_id'); const optedInClientIds = rows .map((row) => String(row.client_id ?? '').trim()) .filter(Boolean); const registeredClientIds = await listRegisteredNotificationClientIds(optedInClientIds); const staleClientIds = selectStaleOfflineNotificationClientIds( optedInClientIds.map((clientId) => ({ clientId, notifyOffline: true, hasActivePushRegistration: registeredClientIds.has(clientId), })), options, ); if (!staleClientIds.length) { return [] as string[]; } await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: normalizedSessionId }) .whereIn('client_id', staleClientIds) .update({ notify_offline: false, updated_at: db.fn.now(), }); await cleanupNotificationMessagesForStaleTargetClients({ sessionId: normalizedSessionId, staleClientIds, }); return staleClientIds; } export async function listChatConversationOfflineNotificationClientIds( sessionId: string, options?: { keepClientIds?: Iterable; }, ) { await cleanupStaleChatConversationOfflineNotificationClients(sessionId, options); const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: sessionId.trim(), notify_offline: true, }) .select('client_id'); return rows .map((row) => String(row.client_id ?? '').trim()) .filter(Boolean); } export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) { const normalizedSessionId = sessionId.trim(); const normalizedClientId = clientId.trim(); await db(CHAT_CONVERSATION_CLIENT_TABLE) .insert({ session_id: normalizedSessionId, client_id: normalizedClientId, notify_offline: notifyOffline, last_read_response_message_id: null, created_at: db.fn.now(), updated_at: db.fn.now(), }) .onConflict(['session_id', 'client_id']) .merge({ notify_offline: notifyOffline, updated_at: db.fn.now(), }); if (notifyOffline) { await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, { keepClientIds: [normalizedClientId], }); } return getChatConversationClientPreference(normalizedSessionId, normalizedClientId); } export async function markChatConversationResponsesRead(sessionId: string, clientId: string) { const normalizedSessionId = sessionId.trim(); const normalizedClientId = clientId.trim(); if (!normalizedSessionId || !normalizedClientId) { return null; } const currentConversation = await db(CHAT_CONVERSATION_TABLE) .where({ session_id: normalizedSessionId }) .first(); if (!currentConversation) { return null; } const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId); await db(CHAT_CONVERSATION_CLIENT_TABLE) .insert({ session_id: normalizedSessionId, client_id: normalizedClientId, notify_offline: Boolean(currentConversation.notify_offline), last_read_response_message_id: latestResponseMessageId, created_at: db.fn.now(), updated_at: db.fn.now(), }) .onConflict(['session_id', 'client_id']) .merge({ last_read_response_message_id: latestResponseMessageId, updated_at: db.fn.now(), }); await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, { keepClientIds: [normalizedClientId], }); return { sessionId: normalizedSessionId, lastReadResponseMessageId: latestResponseMessageId, }; }