import { chmod, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { FastifyBaseLogger } from 'fastify'; import type { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; import { env } from '../config/env.js'; import { db } from '../db/client.js'; import { createDefaultChatTypeExecutionPolicy, getAppConfigSnapshot, getChatContextSettingsConfig, getChatTypesConfig, type ChatTypeExecutionPolicy, } from './app-config-service.js'; import { BOARD_POSTS_TABLE } from './board-service.js'; import { appendChatConversationMessage, appendChatConversationActivityLine, getChatConversationRequest, type ChatConversationRequestItem, type ChatConversationRequestUsageSnapshot, getChatConversation, listChatConversationOfflineNotificationClientIds, listRecoverableChatConversationRequests, listChatConversationMessages, listChatConversationRequests, shouldClearConversationJobState, upsertChatConversationRequest, updateChatConversationJobState, updateChatConversationContext, } from './chat-room-service.js'; import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js'; import { hasErrorLogViewAccessToken } from './error-log-service.js'; import { getSharedResourceTokenDetailBySharePath, validateSharedResourceAccessPinBySharePath, } from './shared-resource-token-service.js'; import { sendNotifications } from './notification-service.js'; import { createNotificationMessage } from './notification-message-service.js'; import { subscribeNotificationMessageChanges, type NotificationMessageChangeEvent, } from './notification-message-service.js'; import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js'; import { resolveMainProjectRoot } from './main-project-root-service.js'; import { isRuntimeDraining, trackWebSocketConnectionClosed, trackWebSocketConnectionOpened } from './runtime-drain-service.js'; import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js'; import { findLatestPlanItem, findPlanItemByPreviewUrl, findPlanItemByWorkId, getPlanItemById, listPlanActionHistories, listPlanIssueHistories, listPlanSourceWorkHistories, mapPlanActionRow, mapPlanIssueRow, mapPlanSourceWorkRow, PLAN_TABLE, } from './plan-service.js'; type ChatAuthor = 'codex' | 'system' | 'user'; type CodexParticipantRole = 'default' | 'moderator' | 'conversation' | 'reviewer'; type CodexParticipantTurn = 'standard' | 'opening' | 'discussion' | 'review' | 'closing'; type ChatRequestMode = 'queue' | 'direct'; type ChatMessage = { id: number; author: ChatAuthor; text: string; timestamp: string; clientRequestId?: string | null; parts?: ChatMessagePart[]; }; type ChatContext = { pageId: string | null; pageTitle: string; topMenu: string; focusedComponentId: string | null; pageUrl: string; appOrigin?: string; appDomain?: string; isStandaloneMode?: boolean; pageVisibilityState?: 'visible' | 'hidden'; pageFocusState?: 'focused' | 'blurred'; codexModel?: string | null; codexParticipants?: Array<{ id?: string; name?: string; model?: string; prompt?: string; chatTypeId?: string | null; defaultContextIds?: string[]; role?: CodexParticipantRole | null; }>; chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; chatTypeBaseDescription?: string; chatTypeExecutionPolicy?: ChatTypeExecutionPolicy; defaultContextIds?: string[]; defaultContexts?: Array<{ id?: string; title?: string; content?: string; }>; customContextTitle?: string | null; customContextContent?: string | null; }; type ChatPromptContextRef = { key: 'prompt_parent_question'; promptTitle: string; promptDescription?: string | null; parentQuestionText?: string | null; }; type ChatInboundMessage = | { type: 'context:update'; payload: ChatContext; } | { type: 'presence:ping'; payload?: { at?: number; }; } | { type: 'event:received'; payload: { eventId: number; }; } | { type: 'message:send'; payload: { sessionId?: string; text: string; requestId?: string; mode?: 'queue' | 'direct'; requestOrigin?: 'composer' | 'prompt'; sharedResourceTokenId?: string | null; parentRequestId?: string | null; promptContextRef?: ChatPromptContextRef | null; omitPromptHistory?: boolean; codexModel?: string | null; codexParticipants?: Array<{ id?: string; name?: string; model?: string; prompt?: string; chatTypeId?: string | null; defaultContextIds?: string[]; role?: CodexParticipantRole | null; }>; chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; chatTypeBaseDescription?: string; chatTypeExecutionPolicy?: ChatTypeExecutionPolicy; defaultContextIds?: string[]; defaultContexts?: Array<{ id?: string; title?: string; content?: string; }>; customContextTitle?: string | null; customContextContent?: string | null; }; } | { type: 'runtime:watch'; payload: { requestId?: string | null; }; }; type ChatOutboundPayload = | { type: 'chat:init'; payload: { messages: ChatMessage[]; }; } | { type: 'chat:message'; payload: ChatMessage; } | { type: 'chat:message:update'; payload: ChatMessage; } | { type: 'chat:status'; payload: { connectedAt: string; }; } | { type: 'chat:job'; payload: { requestId: string; status: 'queued' | 'started' | 'completed' | 'failed'; mode: 'queue' | 'direct'; queueSize: number; message: string; }; } | { type: 'chat:error'; payload: { message: string; }; } | { type: 'chat:runtime'; payload: ChatRuntimeSnapshot; } | { type: 'chat:runtime:detail'; payload: ChatRuntimeJobDetail; } | { type: 'chat:activity'; payload: { requestId: string; line: string; lineCount: number; lineNo?: number; }; } | { type: 'chat:request:update'; payload: ChatConversationRequestItem; } | { type: 'notification:messages-updated'; payload: | { action: 'created' | 'updated'; itemId: number; category: string; read: boolean; } | { action: 'deleted'; itemId: number; }; }; type ChatOutboundMessage = ChatOutboundPayload & { eventId: number; sessionId: string; }; type ChatSessionState = { sessionId: string; clientId: string | null; sockets: Set; lastSeenAt: number; isDeleted: boolean; context: ChatContext | null; queue: Array<{ requestId: string; text: string; mode: 'queue' | 'direct'; requestedAtMs: number; requestOrigin?: 'composer' | 'prompt'; sharedResourceTokenId?: string | null; parentRequestId?: string | null; omitPromptHistory?: boolean; context: ChatContext | null; }>; activeRequestCount: number; pendingQueueReleaseEventId: number | null; pendingQueueReleaseTimer: ReturnType | null; nextEventId: number; eventHistory: ChatOutboundMessage[]; messagePersistenceTail: Promise; watchedRuntimeRequestId: string | null; }; type ChatRuntimeController = { getJobDetail: (requestId: string) => ReturnType; cancelJob: (requestId: string) => Promise; removeQueuedJob: (requestId: string) => Promise; }; type ActiveChatExecution = { cancel: () => Promise | boolean; }; let activeRuntimeController: ChatRuntimeController | null = null; let activeChatService: ChatService | null = null; const activeChatProcessRegistry = new Map(); const QUEUE_RELEASE_FALLBACK_DELAY_MS = 1500; export function getChatRuntimeController() { return activeRuntimeController; } export function getActiveChatService() { return activeChatService; } type ChatServiceRuntimeSnapshot = { activeRequestCount: number; queuedRequestCount: number; connectedSessionCount: number; activeSocketCount: number; canAcceptNewRequests: boolean; }; function getSessionSocketReadyState(session: ChatSessionState) { for (const socket of session.sockets) { if (socket.readyState === SOCKET_READY_STATE_OPEN) { return socket.readyState; } } for (const socket of session.sockets) { return socket.readyState; } return null; } function hasOpenSessionSocket(session: ChatSessionState) { for (const socket of session.sockets) { if (socket.readyState === SOCKET_READY_STATE_OPEN) { return true; } } return false; } const SOCKET_PATH = '/ws/chat'; const KST_TIME_ZONE = 'Asia/Seoul'; const STREAM_CAPTURE_LIMIT = 256 * 1024; const CHAT_PUBLIC_RESOURCE_DIR = '.codex_chat'; const CHAT_PUBLIC_RESOURCE_SUBDIR = 'resource'; const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; const CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH = 'source/chat-room-reference.md'; const CHAT_SESSION_REFERENCE_AUTO_START = ''; const CHAT_SESSION_REFERENCE_AUTO_END = ''; const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777; const SOCKET_READY_STATE_OPEN = 1; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const MAX_CHAT_ACTIVITY_MESSAGE_LINES = 240; const MAX_CHAT_ACTIVITY_MESSAGE_CHARS = 80_000; const CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS = 15_000; const CHAT_PROMPT_HISTORY_MAX_MESSAGES = 12; const CHAT_PROMPT_HISTORY_MAX_CHARS = 3200; const CHAT_SESSION_EVENT_HISTORY_LIMIT = 400; let chatMessageSequence = 0; export function isChatClientActivelyViewing(clientId: string | null | undefined, sessions: Iterable) { return evaluateChatClientActiveViewing(clientId, sessions).isActive; } function evaluateChatClientActiveViewing(clientId: string | null | undefined, sessions: Iterable, nowMs = Date.now()) { const normalizedClientId = clientId?.trim(); if (!normalizedClientId) { return { isActive: false, matchedSessions: [], }; } const matchedSessions: Array<{ sessionId: string; lastSeenAt: number; presenceAgeMs: number; socketReadyState: number | null; pageVisibilityState: 'visible' | 'hidden'; pageFocusState: 'focused' | 'blurred'; pageOrigin: string | null; reason: | 'socket-not-open' | 'stale-presence' | 'backgrounded' | 'active-no-page-url' | 'active-supported-origin' | 'unsupported-origin' | 'invalid-page-url'; }> = []; for (const session of sessions) { if (session.clientId?.trim() !== normalizedClientId) { continue; } const pageVisibilityState = session.context?.pageVisibilityState ?? 'visible'; const pageFocusState = session.context?.pageFocusState ?? 'focused'; const pageUrl = session.context?.pageUrl?.trim(); const presenceAgeMs = Math.max(0, nowMs - session.lastSeenAt); const socketReadyState = getSessionSocketReadyState(session); if (!hasOpenSessionSocket(session)) { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'socket-not-open', }); continue; } const hasFreshPresence = presenceAgeMs < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS; if (!hasFreshPresence) { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'stale-presence', }); continue; } if (pageVisibilityState === 'hidden' || pageFocusState === 'blurred') { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'backgrounded', }); continue; } if (!pageUrl) { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'active-no-page-url', }); return { isActive: true, matchedSessions, }; } try { const resolvedUrl = new URL(pageUrl); if (resolvedUrl.origin === 'https://preview.sm-home.cloud' || resolvedUrl.origin === 'https://test.sm-home.cloud') { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: resolvedUrl.origin, reason: 'active-supported-origin', }); return { isActive: true, matchedSessions, }; } matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: resolvedUrl.origin, reason: 'unsupported-origin', }); } catch { matchedSessions.push({ sessionId: session.sessionId, lastSeenAt: session.lastSeenAt, presenceAgeMs, socketReadyState, pageVisibilityState, pageFocusState, pageOrigin: null, reason: 'invalid-page-url', }); continue; } } return { isActive: false, matchedSessions, }; } function logChatClientActiveViewingEvaluation( logger: FastifyBaseLogger, clientId: string, evaluation: ReturnType, ) { if (evaluation.matchedSessions.length === 0) { logger.info( { clientId, active: false, reason: 'no-matching-session', }, 'chat offline notification presence evaluation', ); return; } for (const sessionState of evaluation.matchedSessions) { logger.info( { clientId, active: evaluation.isActive, sessionId: sessionState.sessionId, lastSeenAt: sessionState.lastSeenAt, presenceAgeMs: sessionState.presenceAgeMs, socketReadyState: sessionState.socketReadyState, pageVisibilityState: sessionState.pageVisibilityState, pageFocusState: sessionState.pageFocusState, pageOrigin: sessionState.pageOrigin, reason: sessionState.reason, }, 'chat offline notification presence evaluation', ); } } export function resolveChatContextAppOrigin(context: ChatContext | null | undefined) { const directOrigin = context?.appOrigin?.trim(); if (directOrigin) { try { return new URL(directOrigin).origin; } catch { return null; } } const pageUrl = context?.pageUrl?.trim(); if (!pageUrl) { return null; } try { return new URL(pageUrl).origin; } catch { return null; } } export function resolveChatContextAppDomain(context: ChatContext | null | undefined) { const directDomain = context?.appDomain?.trim().toLowerCase(); if (directDomain) { return directDomain; } const appOrigin = resolveChatContextAppOrigin(context); if (!appOrigin) { return null; } try { return new URL(appOrigin).hostname.trim().toLowerCase() || null; } catch { return null; } } function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: string) { const fallbackUrl = new URL('/chat/live', resolveChatContextAppOrigin(context) ?? 'https://preview.sm-home.cloud'); fallbackUrl.searchParams.set('topMenu', 'chat'); fallbackUrl.searchParams.set('sessionId', sessionId); const pageUrl = context?.pageUrl?.trim(); if (!pageUrl) { return fallbackUrl.toString(); } try { const targetUrl = new URL(pageUrl); targetUrl.pathname = '/chat/live'; targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat'); targetUrl.searchParams.set('sessionId', sessionId); targetUrl.searchParams.delete('chatView'); targetUrl.searchParams.delete('runtimeRequestId'); return targetUrl.toString(); } catch { return fallbackUrl.toString(); } } function createChatNotificationPreview(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } function createChatQuestionAnswerNotificationBody(args: { questionText?: string | null; answerText?: string | null; fallback: string; }) { const questionPreview = createChatNotificationPreview(args.questionText ?? ''); const answerPreview = createChatNotificationPreview(args.answerText ?? ''); if (questionPreview && answerPreview) { return `질문: ${questionPreview}\n답변: ${answerPreview}`; } if (answerPreview) { return `답변: ${answerPreview}`; } if (questionPreview) { return `질문: ${questionPreview}`; } return args.fallback; } function normalizeStructuredChatMessage(message: ChatMessage): ChatMessage { if (message.author === 'user') { return message; } const existingParts = Array.isArray(message.parts) ? message.parts.filter(Boolean) : []; const extracted = extractChatMessageParts(message.text); const nextParts = existingParts.length > 0 ? existingParts : extracted.parts; if (nextParts.length === 0) { return existingParts.length === 0 ? message : { ...message, parts: existingParts }; } return { ...message, text: extracted.strippedText, parts: nextParts, }; } function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) { const questionPreview = createChatNotificationPreview(questionText ?? ''); return questionPreview ? `질문: ${questionPreview}` : fallback ?? ''; } function normalizeNotificationDetailText(text?: string | null) { const normalized = String(text ?? '').trim(); return normalized || undefined; } export function collectOfflineNotificationClientIds(preferredClientIds?: string[]) { const nextClientIds = new Set(); for (const candidate of preferredClientIds ?? []) { const normalized = String(candidate ?? '').trim(); if (normalized) { nextClientIds.add(normalized); } } return [...nextClientIds]; } export function filterInactiveOfflineNotificationClientIds( clientIds: string[], sessions: Iterable, isActive: (clientId: string, currentSessions: Iterable) => boolean = isChatClientActivelyViewing, ) { return clientIds.filter((clientId) => !isActive(clientId, sessions)); } export function shouldSendOfflineChatNotification(options: { receiveRoomNotifications?: boolean | null; conversationNotifyOffline?: boolean | null; }) { if (options.receiveRoomNotifications === false) { return false; } return options.conversationNotifyOffline === true; } function isPreparingChatReply(text?: string | null) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.startsWith('응답을 준비하고 있습니다'); } function formatTime(date: Date) { return new Intl.DateTimeFormat('sv-SE', { timeZone: KST_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) .format(date) .replace(',', ''); } export function resolveResponseTimestamp(requestedAtMs?: number | null, nowMs = Date.now()) { if (!Number.isFinite(requestedAtMs)) { return formatTime(new Date(nowMs)); } return formatTime(new Date(Math.max(nowMs, Number(requestedAtMs) + 1_000))); } function parseRequestedAtMs(value: string) { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : Date.now(); } function formatKstDate(date = new Date()) { return new Intl.DateTimeFormat('en-CA', { timeZone: KST_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', }).format(date); } function createChatMessageId() { chatMessageSequence = (chatMessageSequence + 1) % 1_000; return Date.now() * 1_000 + chatMessageSequence; } function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null, parts?: ChatMessagePart[]): ChatMessage { return { id: createChatMessageId(), author, text, timestamp: formatTime(new Date()), clientRequestId: clientRequestId?.trim() || null, parts: Array.isArray(parts) ? parts : [], }; } function createRequestId() { return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } async function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) { const queryToken = url.searchParams.get('accessToken')?.trim(); const headerToken = Array.isArray(request.headers['x-access-token']) ? String(request.headers['x-access-token'][0] ?? '').trim() : String(request.headers['x-access-token'] ?? '').trim(); if (hasErrorLogViewAccessToken(queryToken || headerToken)) { return true; } const shareToken = url.searchParams.get('shareToken')?.trim() || ''; const sharePin = url.searchParams.get('sharePin')?.trim() || ''; const clientId = url.searchParams.get('clientId')?.trim() || ''; const requestedSessionId = url.searchParams.get('sessionId')?.trim() || ''; if (!shareToken) { return false; } const sharePath = '/chat/share/' + encodeURIComponent(shareToken); const sharedTokenDetail = await getSharedResourceTokenDetailBySharePath(sharePath); const sharedToken = sharedTokenDetail?.token ?? null; const resourceContext = sharedToken?.resourceContext ?? null; if (!resourceContext || (requestedSessionId && resourceContext.sessionId !== requestedSessionId)) { return false; } if (!sharedToken || !sharedToken.enabled || sharedToken.sharePath !== sharePath) { return false; } if (sharedToken.effectiveExpiresAt) { const expiresAtMs = Date.parse(sharedToken.effectiveExpiresAt); if (Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()) { return false; } } const pinStatus = await validateSharedResourceAccessPinBySharePath(sharePath, sharePin, { clientId, }); if (pinStatus.status !== 'ok' && pinStatus.status !== 'not-configured') { return false; } return true; } function hashRequestId(value: string) { let hash = 0; for (const character of value) { hash = (hash * 31 + character.charCodeAt(0)) | 0; } return Math.abs(hash) + 1_000_000; } export function fitActivityLogLines(lines: string[]) { const normalizedLines = lines.map((line) => line.trim()).filter(Boolean); if (normalizedLines.length <= 1) { return normalizedLines; } let startIndex = Math.max(0, normalizedLines.length - MAX_CHAT_ACTIVITY_MESSAGE_LINES); let fittedLines = normalizedLines.slice(startIndex); while (fittedLines.length > 1 && fittedLines.join('\n\n').length > MAX_CHAT_ACTIVITY_MESSAGE_CHARS) { startIndex += 1; fittedLines = normalizedLines.slice(startIndex); } return fittedLines; } export function createActivityLogMessage(requestId: string, lines: string[]) { const normalizedRequestId = requestId.trim(); const fittedLines = fitActivityLogLines(lines); const messageBody = fittedLines.length > 0 ? fittedLines.join('\n\n') : '활동 로그를 준비하고 있습니다.'; const messageText = `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${messageBody}`; return createMessage('system', messageText, normalizedRequestId) ? { id: hashRequestId(normalizedRequestId), author: 'system' as const, text: messageText, timestamp: formatTime(new Date()), clientRequestId: normalizedRequestId, } : null; } function shouldPersistMessageUpdate(message: ChatMessage) { if (message.author === 'codex') { return false; } return !String(message.text ?? '').startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); } function isSocketOpen(socket: WebSocket | null | undefined) { return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN); } function closeSocketSafely( logger: FastifyBaseLogger, socket: WebSocket | null | undefined, message: string, code = 1000, reason = 'replaced', ) { if (!socket) { return; } try { socket.close(code, reason); } catch (error) { logger.warn(error, message); } } function sendSocketEnvelope( logger: FastifyBaseLogger, socket: WebSocket | null | undefined, envelope: ChatOutboundMessage, message: string, ) { const targetSocket = socket; if (!targetSocket || targetSocket.readyState !== SOCKET_READY_STATE_OPEN) { return false; } try { targetSocket.send(JSON.stringify(envelope)); return true; } catch (error) { logger.warn(error, message); return false; } } type ParsedPlanContext = { planId: number | null; workId: string | null; previewUrl: string | null; }; type PlanSnapshot = { planId: number; workId: string; status: string; note: string; workerStatus: string | null; issueTags: string[]; hasOpenIssues: boolean; assignedBranch: string | null; lastError: string | null; latestActionNote: string | null; latestIssue: { tag: string; message: string; resolved: boolean; } | null; latestSourceWork: { summary: string; previewUrl: string | null; changedFiles: string[]; } | null; recentActionNotes: string[]; recentWorkSummaries: string[]; }; function truncateText(value: string | null | undefined, limit = 120) { const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized; } function extractReferencedPlanId(input: string) { const planIdMatch = input.match(/(?:planid=|#)(\d{1,9})/i) ?? input.match(/\bplan\s*(\d{1,9})\b/i); const parsedPlanId = planIdMatch?.[1] ? Number(planIdMatch[1]) : NaN; return Number.isInteger(parsedPlanId) && parsedPlanId > 0 ? parsedPlanId : null; } function extractReferencedWorkId(input: string) { const workIdMatch = input.match(/\bworkid\s*[:=]?\s*([^\s,]+)/i); return workIdMatch?.[1]?.trim() || null; } function extractReferencedPreviewUrl(input: string) { const previewUrlMatch = input.match(/https?:\/\/[^\s)]+/i); return previewUrlMatch?.[0]?.trim() || null; } function buildChangedFilesSummary(changedFiles: string[], limit = 5) { if (changedFiles.length === 0) { return '변경/신규 파일: 기록 없음'; } const visibleFiles = changedFiles.slice(0, limit); const suffix = changedFiles.length > limit ? ` 외 ${changedFiles.length - limit}개` : ''; return `변경/신규 파일: ${visibleFiles.join(', ')}${suffix}`; } function parsePlanContext(context: ChatContext | null, input: string): ParsedPlanContext { const pageUrl = context?.pageUrl ?? ''; const referencedPlanId = extractReferencedPlanId(input); const referencedWorkId = extractReferencedWorkId(input); const referencedPreviewUrl = extractReferencedPreviewUrl(input); if (!pageUrl) { return { planId: referencedPlanId, workId: referencedWorkId, previewUrl: referencedPreviewUrl, }; } try { const url = new URL(pageUrl); const planIdText = url.searchParams.get('planId'); const workIdText = url.searchParams.get('workId'); const planIdNumber = planIdText ? Number(planIdText) : NaN; return { planId: Number.isInteger(planIdNumber) && planIdNumber > 0 ? planIdNumber : referencedPlanId, workId: workIdText?.trim() || referencedWorkId, previewUrl: referencedPreviewUrl, }; } catch { return { planId: referencedPlanId, workId: referencedWorkId, previewUrl: referencedPreviewUrl, }; } } async function loadPlanSnapshot(planId: number): Promise { const item = await getPlanItemById(planId); if (!item) { return null; } const [actionRows, issueRows, sourceWorkRows] = await Promise.all([ listPlanActionHistories(planId), listPlanIssueHistories(planId), listPlanSourceWorkHistories(planId), ]); const latestAction = actionRows[0] ? mapPlanActionRow(actionRows[0]) : null; const latestIssue = issueRows[0] ? mapPlanIssueRow(issueRows[0]) : null; const latestSourceWork = sourceWorkRows[0] ? mapPlanSourceWorkRow(sourceWorkRows[0]) : null; return { planId, workId: String(item.workId ?? '작업ID'), status: String(item.status ?? '-'), note: String(item.note ?? ''), workerStatus: item.workerStatus ? String(item.workerStatus) : null, issueTags: Array.isArray(item.issueTags) ? item.issueTags.map((value) => String(value)) : [], hasOpenIssues: Boolean(item.hasOpenIssues), assignedBranch: item.assignedBranch ? String(item.assignedBranch) : null, lastError: item.lastError ? String(item.lastError) : null, latestActionNote: latestAction ? String(latestAction.note) : null, latestIssue: latestIssue ? { tag: String(latestIssue.issueTag), message: String(latestIssue.message), resolved: Boolean(latestIssue.resolved), } : null, latestSourceWork: latestSourceWork ? { summary: String(latestSourceWork.summary), previewUrl: latestSourceWork.previewUrl ? String(latestSourceWork.previewUrl) : null, changedFiles: latestSourceWork.changedFiles, } : null, recentActionNotes: actionRows .slice(0, 5) .map((row) => truncateText(String(mapPlanActionRow(row).note ?? ''), 140)) .filter(Boolean), recentWorkSummaries: sourceWorkRows .slice(0, 5) .map((row) => truncateText(String(mapPlanSourceWorkRow(row).summary ?? ''), 140)) .filter(Boolean), }; } function isWorklogRequest(input: string) { const normalized = input.toLowerCase(); const mentionsWorklog = input.includes('워크일지') || input.includes('작업로그') || input.includes('작업 일지') || input.includes('작업일지') || normalized.includes('worklog'); const asksToWrite = input.includes('작성') || input.includes('정리') || input.includes('기록') || input.includes('밀린') || input.includes('써'); return mentionsWorklog || (normalized.includes('log') && asksToWrite); } function isPlanDetailRequest(input: string) { const normalized = input.toLowerCase(); return ( input.includes('작업') || input.includes('계획') || input.includes('이력') || input.includes('이슈') || input.includes('상태') || input.includes('브랜치') || normalized.includes('plan') || normalized.includes('issue') || normalized.includes('status') || normalized.includes('history') || normalized.includes('branch') ); } function buildWorklogReply(context: ChatContext | null, snapshot: PlanSnapshot | null) { const pageTitle = context?.pageTitle ?? '현재 화면'; if (!snapshot) { return `현재 화면: ${pageTitle}\n작업로그 초안을 만들 Plan을 찾지 못했습니다. Plan 상세를 연 뒤 다시 요청해 주세요.`; } const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); const todayWork = [ truncateText(snapshot.note, 180), ...snapshot.recentWorkSummaries, snapshot.latestActionNote ? truncateText(snapshot.latestActionNote, 140) : '', ].filter(Boolean); const issues = snapshot.latestIssue ? [ `${snapshot.latestIssue.tag} ${snapshot.latestIssue.resolved ? '해결' : '미해결'}: ${truncateText( snapshot.latestIssue.message, 140, )}`, ] : []; const decisions = [ snapshot.assignedBranch ? `작업 브랜치: ${snapshot.assignedBranch}` : '', snapshot.latestSourceWork?.previewUrl ? `preview 링크: ${snapshot.latestSourceWork.previewUrl}` : '', snapshot.status ? `현재 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}` : '', ].filter(Boolean); const details = [...snapshot.recentActionNotes, ...snapshot.recentWorkSummaries].filter(Boolean); return [ `현재 화면: ${pageTitle}`, `기준 Plan: #${snapshot.planId} ${snapshot.workId}`, '아래 초안을 작업일지에 바로 붙여 넣으면 됩니다.', '', `# ${today} 작업일지`, '', '## 오늘 작업', ...(todayWork.length > 0 ? todayWork.slice(0, 6).map((item) => `- ${item}`) : ['- Plan 메모와 이력 확인 후 작업 내용을 보강해 주세요.']), '', '## 이슈 및 해결', ...(issues.length > 0 ? issues.map((item) => `- ${item}`) : ['- 현재 기록된 이슈 없음']), '', '## 결정 사항', ...(decisions.length > 0 ? decisions.map((item) => `- ${item}`) : ['- 별도 결정 사항 없음']), '', '## 상세 작업 내역', ...(details.length > 0 ? details.slice(0, 8).map((item) => `- ${item}`) : ['- 최근 조치 이력이 없어 상세 내역 보강이 필요합니다.']), ].join('\n'); } function buildRecentPlanHistoryLines(snapshot: PlanSnapshot | null, limit = 2) { if (!snapshot) { return []; } const historyItems = [ ...snapshot.recentActionNotes.map((note) => `[조치] ${note}`), ...snapshot.recentWorkSummaries.map((summary) => `[소스] ${summary}`), ] .filter(Boolean) .slice(0, limit); if (historyItems.length === 0) { return []; } return ['최근 조치 이력:', ...historyItems.map((item, index) => `${index + 1}. ${item}`)]; } function isDetailRequest(input: string) { const normalized = input.toLowerCase(); return ( input.includes('상세') || input.includes('자세히') || input.includes('전체') || normalized.includes('detail') || normalized.includes('details') || normalized.includes('more') ); } export function isAutomationRegistrationCountRequest(input: string) { const normalized = input.toLowerCase(); const mentionsAutomation = input.includes('자동화') || normalized.includes('automation') || normalized.includes('plan'); const mentionsRegistration = input.includes('등록') || input.includes('접수') || normalized.includes('register') || normalized.includes('count'); const mentionsToday = input.includes('오늘') || normalized.includes('today'); const asksCount = input.includes('총') || input.includes('건수') || input.includes('몇 건') || input.includes('몇건') || normalized.includes('count') || normalized.includes('total'); return mentionsAutomation && mentionsRegistration && (mentionsToday || asksCount); } function isAutomationRegistrationDefinitionRequest(input: string) { const normalized = input.toLowerCase(); const mentionsAutomation = input.includes('자동화') || normalized.includes('automation'); const mentionsRegistration = input.includes('등록') || input.includes('접수') || normalized.includes('register'); const asksMeaning = input.includes('무슨 뜻') || input.includes('뭐야') || input.includes('뜻') || input.includes('기준') || input.includes('뭘 의미') || normalized.includes('meaning') || normalized.includes('define'); return mentionsAutomation && mentionsRegistration && asksMeaning; } export function shouldUseAgenticCodexReply(input: string) { const normalized = input.toLowerCase(); const trimmed = input.trim(); if (isAutomationRegistrationCountRequest(input) || isAutomationRegistrationDefinitionRequest(input)) { return false; } if (!trimmed) { return false; } if (trimmed.length <= 3 && !/(db|api|fix|bug|log|sql)/i.test(trimmed)) { return false; } if (isDetailRequest(input) || isPlanDetailRequest(input) || isWorklogRequest(input)) { return false; } return ( input.includes('수정') || input.includes('변경') || input.includes('구현') || input.includes('추가') || input.includes('삭제') || input.includes('고쳐') || input.includes('만들어') || input.includes('등록해줘') || input.includes('조회') || input.includes('확인') || input.includes('읽어') || input.includes('분석') || input.includes('DB') || input.includes('API') || input.includes('소스') || input.includes('파일') || input.includes('로그') || /(?:\/|\\).+\.[a-z0-9]+/i.test(input) || normalized.includes('fix') || normalized.includes('implement') || normalized.includes('change') || normalized.includes('edit') || normalized.includes('read') || normalized.includes('source') || normalized.includes('file') || normalized.includes('db') || normalized.includes('api') || /\s/.test(trimmed) ); } export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) { void context; void input; return false; } function summarizeCodexOutput(output: string) { const normalized = String(output ?? '').trim(); if (!normalized) { return 'Codex 실행 결과가 비어 있습니다.'; } const lines = normalized .split('\n') .map((line) => line.trimEnd()) .filter(Boolean); return lines.slice(-12).join('\n'); } function parseChatTokenMetricValue(valueText: string, suffixText?: string) { const normalizedValue = Number(String(valueText ?? '').replace(/,/g, '').trim()); if (!Number.isFinite(normalizedValue)) { return null; } const suffix = String(suffixText ?? '').trim().toLowerCase(); const multiplier = suffix === 'k' ? 1_000 : suffix === 'm' ? 1_000_000 : 1; return Math.max(0, Math.round(normalizedValue * multiplier)); } function parseChatRequestTokenUsageMetrics(tokenUsageText: string) { const normalizedText = tokenUsageText .replace(/^tokens?\s+used\s*:?\s*/iu, '') .replace(/\(([^)]+)\)/g, ', $1') .trim(); const metrics = new Map(); for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) { const label = match[3]?.toLowerCase() ?? ''; const value = parseChatTokenMetricValue(match[1] ?? '', match[2]); if (!label || value === null) { continue; } metrics.set(label, value); } for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) { const label = match[1]?.toLowerCase() ?? ''; const value = parseChatTokenMetricValue(match[2] ?? '', match[3]); if (!label || value === null) { continue; } metrics.set(label, value); } if (metrics.size > 0) { return metrics; } const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i); if (!fallbackMatch) { return null; } const fallbackValue = parseChatTokenMetricValue(fallbackMatch[1], fallbackMatch[2]); if (fallbackValue === null) { return null; } return new Map([['total', fallbackValue]]); } function getChatRequestTotalTokenCount(metrics: Map) { const total = metrics.get('total'); if (typeof total === 'number' && Number.isFinite(total)) { return total; } return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0); } function normalizeChatUsageMetricValue(value: unknown) { const normalizedValue = typeof value === 'string' ? Number(value.replace(/,/g, '').trim()) : Number(value); if (!Number.isFinite(normalizedValue)) { return null; } return Math.max(0, Math.round(normalizedValue)); } function createChatUsageSnapshotFromMetrics(metrics: Map) { const totalTokens = getChatRequestTotalTokenCount(metrics); return { tokenTotals: { total: metrics.get('total') ?? totalTokens, input: metrics.get('input') ?? 0, output: metrics.get('output') ?? 0, cached: metrics.get('cached') ?? 0, reasoning: metrics.get('reasoning') ?? 0, }, totalTokens, } satisfies ChatConversationRequestUsageSnapshot; } function extractChatRequestUsageSnapshotFromStructuredJson(output: string): ChatConversationRequestUsageSnapshot | null { const lines = String(output ?? '') .split('\n') .map((line) => line.trim()) .filter(Boolean); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index] ?? ''; if (!line.startsWith('{')) { continue; } let parsed: Record; try { parsed = JSON.parse(line) as Record; } catch { continue; } const usage = parsed.usage && typeof parsed.usage === 'object' ? (parsed.usage as Record) : parsed.response && typeof parsed.response === 'object' && parsed.response !== null && 'usage' in parsed.response && parsed.response.usage && typeof parsed.response.usage === 'object' ? (parsed.response.usage as Record) : null; if (!usage) { continue; } const metrics = new Map(); const input = normalizeChatUsageMetricValue(usage.input_tokens); const output = normalizeChatUsageMetricValue(usage.output_tokens); const cached = normalizeChatUsageMetricValue(usage.cached_input_tokens); const reasoning = normalizeChatUsageMetricValue(usage.reasoning_output_tokens ?? usage.reasoning_tokens); const total = normalizeChatUsageMetricValue(usage.total_tokens) ?? normalizeChatUsageMetricValue(usage.totalTokens); if (input !== null) { metrics.set('input', input); } if (output !== null) { metrics.set('output', output); } if (cached !== null) { metrics.set('cached', cached); } if (reasoning !== null) { metrics.set('reasoning', reasoning); } if (total !== null) { metrics.set('total', total); } if (metrics.size > 0) { return createChatUsageSnapshotFromMetrics(metrics); } } return null; } function extractChatRequestTokenUsageText(output: string) { const text = String(output ?? ''); if (!text) { return ''; } const lines = text.split('\n').map((line) => line.trim()); const lastMatchedIndex = lines.reduce((foundIndex, line, index) => { return /tokens?\s+used/i.test(line) ? index : foundIndex; }, -1); if (lastMatchedIndex < 0) { return ''; } const matchedLine = lines[lastMatchedIndex] ?? ''; const usageLines = [matchedLine]; if (!/\d/.test(matchedLine)) { for (let index = lastMatchedIndex + 1; index < lines.length && usageLines.length < 5; index += 1) { const nextLine = lines[index]?.trim() ?? ''; if (!nextLine) { if (usageLines.length > 1) { break; } continue; } if (/^(?:\[plan-progress\]|done:|noop:|board_post:|recovered_commit:|git\s|diff --git|commit\s[0-9a-f]{7,})/i.test(nextLine)) { break; } if (!/\d/.test(nextLine) && !/(input|output|total|cached|reasoning)/i.test(nextLine)) { if (usageLines.length > 1) { break; } continue; } usageLines.push(nextLine); } } return usageLines.join(' ').replace(/\s+/g, ' ').trim(); } function extractChatRequestUsageSnapshot(output: string): ChatConversationRequestUsageSnapshot | null { const structuredSnapshot = extractChatRequestUsageSnapshotFromStructuredJson(output); if (structuredSnapshot) { return structuredSnapshot; } const tokenUsageText = extractChatRequestTokenUsageText(output); if (!tokenUsageText) { return null; } const metrics = parseChatRequestTokenUsageMetrics(tokenUsageText); if (!metrics) { return null; } return createChatUsageSnapshotFromMetrics(metrics); } type ChatCodexReplyResult = { text: string; usageSnapshot: ChatConversationRequestUsageSnapshot | null; totalTokens: number | null; }; class ChatRuntimeExecutionError extends Error { responseText: string; usageSnapshot: ChatConversationRequestUsageSnapshot | null; totalTokens: number | null; constructor( message: string, responseText = '', usageSnapshot: ChatConversationRequestUsageSnapshot | null = null, totalTokens: number | null = null, ) { super(message); this.name = 'ChatRuntimeExecutionError'; this.responseText = responseText.trim(); this.usageSnapshot = usageSnapshot; this.totalTokens = totalTokens; } } function summarizeCommand(command: string, limit = 180) { const normalized = String(command ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized; } function summarizeCommandOutput(output: string, maxLines = 3, maxLength = 220) { const lines = String(output ?? '') .replace(/\r/g, '') .split('\n') .map((line) => line.trim()) .filter(Boolean); if (lines.length === 0) { return ''; } const joined = lines.slice(0, maxLines).join(' / '); return joined.length > maxLength ? `${joined.slice(0, maxLength - 1).trimEnd()}...` : joined; } function inferCommandReason(command: string) { const normalized = command.toLowerCase(); if (normalized.includes('rg ') || normalized.includes('ripgrep')) { return '관련 파일이나 텍스트를 빠르게 찾기 위해 검색했습니다.'; } if (normalized.includes('sed -n') || normalized.includes('cat ') || normalized.includes('less ')) { return '해당 파일 내용을 직접 읽어 문맥을 확인했습니다.'; } if (normalized.includes('ls ') || normalized === 'ls' || normalized.includes('rg --files')) { return '디렉터리 구조와 대상 파일 위치를 확인했습니다.'; } if (normalized.includes('git status')) { return '작업 트리 상태와 변경 범위를 점검했습니다.'; } if (normalized.includes('tsc ') || normalized.includes('npm test') || normalized.includes('node --test')) { return '수정 후 타입이나 동작 검증을 진행했습니다.'; } if (normalized.includes('curl ') || normalized.includes('fetch')) { return '실제 응답이나 연결 상태를 확인했습니다.'; } return '현재 요청을 해결하기 위한 다음 단계를 확인했습니다.'; } function isIgnorableCodexDiagnosticLine(line: string) { const normalized = line.trim(); if (!normalized) { return true; } return ( /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\b/.test(normalized) || normalized.includes('ignoring interface.defaultPrompt') || normalized.includes('failed to open state db') || normalized.includes('state db discrepancy') || normalized.includes('Failed to kill MCP process group') || normalized.includes('Failed to delete shell snapshot') || normalized.includes('failed to unwatch ') ); } function extractCodexActivityLog(parsed: Record) { const type = typeof parsed.type === 'string' ? parsed.type : ''; const item = parsed.item && typeof parsed.item === 'object' ? (parsed.item as Record) : null; const itemType = typeof item?.type === 'string' ? item.type : ''; if (!item || itemType !== 'command_execution') { return ''; } const command = summarizeCommand(typeof item.command === 'string' ? item.command : ''); if (!command) { return ''; } const reason = inferCommandReason(command); if (type === 'item.started') { return `# 이유: ${reason}\n$ ${command}`; } if (type === 'item.completed') { const exitCode = typeof item.exit_code === 'number' && Number.isFinite(item.exit_code) ? Math.round(item.exit_code) : null; const outputSummary = summarizeCommandOutput(typeof item.aggregated_output === 'string' ? item.aggregated_output : ''); const statusLabel = exitCode === null ? '# 결과: 완료' : exitCode === 0 ? '# 결과: 완료(0)' : `# 결과: 종료(${exitCode})`; return outputSummary ? `${statusLabel}\n# 출력: ${outputSummary}` : statusLabel; } return ''; } function normalizeCodexReplyOutput(output: string) { const normalized = String(output ?? '').trim(); return normalized || 'Codex 실행 결과가 비어 있습니다.'; } function collectCodexTextFragments(value: unknown): string[] { if (typeof value === 'string') { const normalized = value.trim(); return normalized ? [normalized] : []; } if (Array.isArray(value)) { return value.flatMap((item) => collectCodexTextFragments(item)); } if (!value || typeof value !== 'object') { return []; } const record = value as Record; const directTextKeys = ['text', 'delta', 'output_text', 'content', 'message']; for (const key of directTextKeys) { const fragments = collectCodexTextFragments(record[key]); if (fragments.length > 0) { return fragments; } } if (typeof record.type === 'string' && record.type.includes('output_text')) { const fragments = collectCodexTextFragments(record.text ?? record.delta); if (fragments.length > 0) { return fragments; } } return Object.values(record).flatMap((item) => collectCodexTextFragments(item)); } function extractCompletedAgentMessageText(item: unknown) { if (!item || typeof item !== 'object') { return ''; } const record = item as Record; if (record.type !== 'agent_message') { return ''; } return collectCodexTextFragments(record.text ?? record.content ?? record.message).join(''); } export function extractCodexStreamText(parsed: Record) { const type = typeof parsed.type === 'string' ? parsed.type : ''; if (type === 'item.completed') { const completedText = extractCompletedAgentMessageText(parsed.item); return { type, completedText, deltaText: '', }; } if (type === 'item.delta' || type === 'response.output_text.delta') { const deltaText = type === 'response.output_text.delta' ? collectCodexTextFragments(parsed.delta ?? parsed.text).join('') : collectCodexTextFragments(parsed.delta).join(''); return { type, completedText: '', deltaText, }; } if (type === 'response.completed') { const completedText = collectCodexTextFragments(parsed.response).join(''); return { type, completedText, deltaText: '', }; } return { type, completedText: '', deltaText: '', }; } export function parseStructuredCodexStdoutLine(line: string) { const normalizedLine = String(line ?? '').trim(); if (!normalizedLine) { return { activityLog: '', completedText: '', deltaText: '', usageSnapshot: null, shouldKeepRaw: false, }; } let parsed: Record; try { parsed = JSON.parse(normalizedLine) as Record; } catch { return { activityLog: '', completedText: '', deltaText: '', usageSnapshot: null, shouldKeepRaw: true, }; } const activityLog = extractCodexActivityLog(parsed); const streamText = extractCodexStreamText(parsed); const usageSnapshot = extractChatRequestUsageSnapshotFromStructuredJson(normalizedLine); const shouldKeepRaw = !activityLog && !streamText.completedText && !streamText.deltaText; return { activityLog, completedText: streamText.completedText, deltaText: streamText.deltaText, usageSnapshot, shouldKeepRaw, }; } async function streamReplyChunks(text: string, onProgress?: (text: string) => void, chunkSize = 28, delayMs = 24) { const normalized = normalizeCodexReplyOutput(text); if (!onProgress) { return normalized; } for (let index = chunkSize; index < normalized.length; index += chunkSize) { onProgress(normalized.slice(0, index)); await new Promise((resolve) => { setTimeout(resolve, delayMs); }); } onProgress(normalized); return normalized; } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function stripTrailingLineInfo(value: string) { return value.replace(/:\d+(?::\d+)?$/, ''); } function trimPathCandidate(value: string) { return value.replace(/^[("'`\[]+/, '').replace(/[)\]"'`,.;:]+$/, ''); } function encodeUrlPathSegments(value: string) { return value .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(segment)) .join('/'); } const CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN = /^(?:src|public|docs|etc|scripts|\.github)\/[^\n\s)\]"'`,]+$/; const CHAT_RESOURCE_ROOT_FILE_PATTERN = /^(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)$/; function isLikelyRepoRelativeChatResourcePath(candidate: string) { const normalized = candidate.replace(/^\/+/, ''); return ( CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN.test(normalized) || CHAT_RESOURCE_ROOT_FILE_PATTERN.test(normalized) ); } function buildChatResourcePublicUrl(relativePath: string) { const cleaned = relativePath.replace(/^public\//, '').replace(/^\/+/, ''); return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${encodeUrlPathSegments(cleaned)}`; } function normalizeEmbeddedChatResourceUrls(text: string) { return String(text ?? '') .replace(/https?:\/(api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/gi, '/$1') .replace( /(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g, (_match, resourcePath) => resourcePath, ); } function toPosixPath(value: string) { return value.split(path.sep).join('/'); } function normalizeExistingChatPublicUrl(candidate: string) { const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())) .replace(/^\/+/, '') .replace(/^public\//, ''); if (!cleaned.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) { return null; } return buildChatResourcePublicUrl(cleaned); } export function extractDiffCodeBlocks(output: string) { const matches = Array.from(String(output ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g)); return matches .map((match) => (typeof match[1] === 'string' ? match[1].trim() : '')) .filter(Boolean); } function protectDiffCodeBlocks(output: string) { const blocks: string[] = []; const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => { const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`; blocks.push(match); return token; }); return { text, blocks }; } function restoreDiffCodeBlocks(output: string, blocks: string[]) { return blocks.reduce( (current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block), String(output ?? ''), ); } async function resolveChatResourceSourcePath(repoPath: string, candidate: string) { const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())); if (!cleaned) { return null; } let sourcePath: string | null = null; if (cleaned.startsWith(repoPath)) { sourcePath = cleaned; } else if (cleaned.startsWith('/')) { if (isLikelyRepoRelativeChatResourcePath(cleaned)) { sourcePath = path.resolve(repoPath, cleaned.replace(/^\/+/, '')); } else { sourcePath = cleaned; } } else { sourcePath = path.resolve(repoPath, cleaned); } const normalizedRepoPath = path.resolve(repoPath); const normalizedSourcePath = path.resolve(sourcePath); const relativePath = path.relative(normalizedRepoPath, normalizedSourcePath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return null; } try { const sourceStat = await stat(normalizedSourcePath); if (!sourceStat.isFile()) { return null; } } catch { return null; } return { absolutePath: normalizedSourcePath, relativePath: toPosixPath(relativePath), }; } export function isChatResourcePathCandidate(candidate: string) { const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())); if (!cleaned) { return false; } if (cleaned.startsWith(`/${CHAT_PUBLIC_RESOURCE_DIR}/`)) { return true; } return isLikelyRepoRelativeChatResourcePath(cleaned) || /^\/(?:workspace|root|home|Users|tmp)\//.test(cleaned); } export async function stageChatResourceFile(repoPath: string, sessionId: string, candidate: string) { const existingPublicUrl = normalizeExistingChatPublicUrl(candidate); if (existingPublicUrl) { return existingPublicUrl; } const resolvedSource = await resolveChatResourceSourcePath(repoPath, candidate); if (!resolvedSource) { return null; } await ensureChatSessionResourceDirectories(repoPath, sessionId); const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/${resolvedSource.relativePath}`; const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath); await mkdir(path.dirname(targetAbsolutePath), { recursive: true }); await cp(resolvedSource.absolutePath, targetAbsolutePath, { force: true }); return buildChatResourcePublicUrl(targetRelativePath); } async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, output: string) { const diffBlocks = extractDiffCodeBlocks(output); if (diffBlocks.length === 0) { return []; } const urls: string[] = []; await ensureChatSessionResourceDirectories(repoPath, sessionId); for (const [index, diffText] of diffBlocks.entries()) { const fileName = diffBlocks.length === 1 ? 'response.diff' : `response-${index + 1}.diff`; const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/_generated/${fileName}`; const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath); await mkdir(path.dirname(targetAbsolutePath), { recursive: true }); await writeFile(targetAbsolutePath, `${diffText}\n`, 'utf8'); urls.push(buildChatResourcePublicUrl(targetRelativePath)); } return urls; } async function ensureWorldWritableDirectory(absolutePath: string) { await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE }); await chmod(absolutePath, CHAT_SESSION_RESOURCE_DIR_MODE).catch(() => {}); } export async function ensureChatSessionResourceDirectories(repoPath: string, sessionId: string) { const sessionRoot = path.join(repoPath, 'public', CHAT_PUBLIC_RESOURCE_DIR, sessionId); const resourceRoot = path.join(sessionRoot, CHAT_PUBLIC_RESOURCE_SUBDIR); const sourceRoot = path.join(resourceRoot, 'source'); const uploadRoot = path.join(resourceRoot, 'uploads'); await ensureWorldWritableDirectory(sessionRoot); await ensureWorldWritableDirectory(resourceRoot); await ensureWorldWritableDirectory(sourceRoot); await ensureWorldWritableDirectory(uploadRoot); return { sessionRoot, resourceRoot, sourceRoot, uploadRoot, }; } function appendDiffResourceLinks(output: string, diffUrls: string[]) { if (diffUrls.length === 0) { return output; } const uniqueUrls = diffUrls.filter((url, index) => diffUrls.indexOf(url) === index && !output.includes(url)); if (uniqueUrls.length === 0) { return output; } const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n'); return `${output}\n\n${hiddenPreviewTags}`; } function isPreviewEligibleChatResourceUrl(url: string) { const pathname = url.split('?')[0]?.toLowerCase() ?? ''; return /\.(?:png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|pdf|html?|diff)$/i.test(pathname); } function resolveChatResourcePublicUrlToAbsolutePath(repoPath: string, url: string) { const normalizedUrl = normalizeEmbeddedChatResourceUrls(url).trim(); const relativeUrl = normalizedUrl.replace(/^\/+/, ''); const routePrefix = `${CHAT_API_RESOURCE_ROUTE_PREFIX.replace(/^\/+/, '')}/`; if (!relativeUrl.startsWith(routePrefix)) { return null; } const encodedResourcePath = relativeUrl.slice(routePrefix.length); try { const decodedPath = encodedResourcePath .split('/') .filter(Boolean) .map((segment) => decodeURIComponent(segment)) .join('/'); if (!decodedPath.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) { return null; } return path.resolve(repoPath, 'public', decodedPath); } catch { return null; } } async function doesChatResourceUrlExist(repoPath: string, url: string) { const absolutePath = resolveChatResourcePublicUrlToAbsolutePath(repoPath, url); if (!absolutePath) { return false; } try { const fileStat = await stat(absolutePath); return fileStat.isFile(); } catch { return false; } } async function sanitizeChatResourcePresentation(output: string, repoPath: string) { let sanitized = normalizeEmbeddedChatResourceUrls(output); const previewMatches = Array.from(sanitized.matchAll(/\[\[preview:([^\]\n]+)\]\]/gi)); const previewReplacementMap = new Map(); for (const match of previewMatches) { const marker = match[0] ?? ''; const rawUrl = match[1]?.trim() ?? ''; if (!marker || previewReplacementMap.has(marker)) { continue; } const normalizedUrl = normalizeEmbeddedChatResourceUrls(rawUrl); const exists = await doesChatResourceUrlExist(repoPath, normalizedUrl); const keepMarker = exists && isPreviewEligibleChatResourceUrl(normalizedUrl); previewReplacementMap.set(marker, keepMarker ? `[[preview:${normalizedUrl}]]` : ''); } for (const [source, target] of previewReplacementMap.entries()) { sanitized = sanitized.replaceAll(source, target); } return sanitized.replace(/\n{3,}/g, '\n\n').trim(); } export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) { const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output); const escapedRepoPath = escapeRegExp(path.resolve(repoPath)); const filePathPattern = "[^\\n\\s)\\]\"'`,]+"; const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`; const candidatePattern = new RegExp( `${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`, 'g', ); const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)]; let rewrittenOutput = outputWithoutDiffBlocks; if (matches.length > 0) { const replacementMap = new Map(); for (const match of matches) { const rawCandidate = match[0]?.trim(); if (!rawCandidate || replacementMap.has(rawCandidate)) { continue; } const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate); if (stagedUrl) { replacementMap.set(rawCandidate, stagedUrl); } } const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length); for (const [sourcePath, publicUrl] of replacements) { rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl); } } rewrittenOutput = await sanitizeChatResourcePresentation(rewrittenOutput, repoPath); rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks); const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output); return sanitizeChatResourcePresentation(appendDiffResourceLinks(rewrittenOutput, diffUrls), repoPath); } function normalizeChatPromptHistoryText(text: string) { return String(text ?? '').replace(/\s+/g, ' ').trim(); } function normalizePromptHistoryMessageLimit(value: number | undefined) { if (value === undefined || !Number.isFinite(value)) { return CHAT_PROMPT_HISTORY_MAX_MESSAGES; } return Math.min(50, Math.max(1, Math.round(value))); } function normalizePromptHistoryCharLimit(value: number | undefined) { if (value === undefined || !Number.isFinite(value)) { return CHAT_PROMPT_HISTORY_MAX_CHARS; } return Math.min(20_000, Math.max(500, Math.round(value))); } async function buildRecentChatPromptHistory( sessionId: string, requestId: string, limits?: { maxMessages?: number; maxChars?: number; disabled?: boolean; }, ) { if (limits?.disabled) { return { items: [], omittedCount: 0, }; } const maxMessages = normalizePromptHistoryMessageLimit(limits?.maxMessages); const maxChars = normalizePromptHistoryCharLimit(limits?.maxChars); const messages = await listChatConversationMessages(sessionId, { limit: 80 }); const relevantMessages = messages.filter((message: (typeof messages)[number]) => { if (message.clientRequestId?.trim() === requestId.trim()) { return false; } if (message.author !== 'user' && message.author !== 'codex') { return false; } return Boolean(normalizeChatPromptHistoryText(message.text)); }); const selectedMessages: typeof relevantMessages = []; let totalChars = 0; for (let index = relevantMessages.length - 1; index >= 0; index -= 1) { const message = relevantMessages[index]; const text = normalizeChatPromptHistoryText(message.text); const nextChars = text.length; if ( selectedMessages.length >= maxMessages || (selectedMessages.length > 0 && totalChars + nextChars > maxChars) ) { break; } selectedMessages.unshift(message); totalChars += nextChars; } return { items: selectedMessages.map( (message: (typeof selectedMessages)[number]) => `[${message.author}] ${normalizeChatPromptHistoryText(message.text)}`, ), omittedCount: Math.max(0, relevantMessages.length - selectedMessages.length), }; } function cloneChatContext(context: ChatContext | null): ChatContext | null { return context ? { ...context } : null; } function resolvePromptChatTypeLabel(context: ChatContext | null) { return context?.chatTypeLabel?.trim() || ''; } function normalizeChatContextIdList(ids?: string[] | null) { if (!Array.isArray(ids)) { return []; } return Array.from( new Set( ids .map((id) => id?.trim() || '') .filter(Boolean), ), ); } function normalizeChatContextEntries( entries: ChatContext['defaultContexts'], ): Array<{ id: string; title: string; content: string }> { if (!Array.isArray(entries)) { return []; } return entries .map((entry) => ({ id: entry?.id?.trim() || '', title: entry?.title?.trim() || '', content: entry?.content?.trim() || '', })) .filter((entry) => entry.content); } function normalizePromptContextText(value: string | null | undefined, maxLength = 400) { const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); if (!normalized) { return ''; } if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, maxLength).trimEnd()}...`; } function normalizeChatPromptContextRef( value: ChatPromptContextRef | null | undefined, ): ChatPromptContextRef | null { if (!value || value.key !== 'prompt_parent_question') { return null; } const promptTitle = normalizePromptContextText(value.promptTitle, 500); if (!promptTitle) { return null; } return { key: 'prompt_parent_question', promptTitle, promptDescription: normalizePromptContextText(value.promptDescription, 1000) || null, parentQuestionText: normalizePromptContextText(value.parentQuestionText, 1000) || null, }; } function buildPromptContextInstructionLines(promptContextRef?: ChatPromptContextRef | null) { if (!promptContextRef || promptContextRef.key !== 'prompt_parent_question') { return []; } const lines = ['prompt 문맥 참조:']; if (promptContextRef.parentQuestionText) { lines.push(`- 상위 사용자 질의: ${promptContextRef.parentQuestionText}`); } lines.push(`- 대상 질의: ${promptContextRef.promptTitle}`); if (promptContextRef.promptDescription) { lines.push(`- 질의 설명: ${promptContextRef.promptDescription}`); } lines.push('- 위 항목은 말풍선 원문이 아니라 서버가 전달한 참조 문맥입니다. 현재 요청을 해석할 때 함께 참고하세요.'); return lines; } function normalizeCodexParticipants( participants: ChatContext['codexParticipants'], ): Array<{ id: string; name: string; model: string; prompt: string; chatTypeId: string | null; defaultContextIds: string[]; role: CodexParticipantRole; }> { if (!Array.isArray(participants)) { return []; } return Array.from( new Map( participants .map((participant, index) => { const name = participant?.name?.trim() || ''; const model = participant?.model?.trim() || ''; const prompt = participant?.prompt?.trim() || ''; const chatTypeId = participant?.chatTypeId?.trim() || null; const defaultContextIds = normalizeChatContextIdList(participant?.defaultContextIds); const role = participant?.role === 'moderator' ? 'moderator' : participant?.role === 'conversation' ? 'conversation' : participant?.role === 'reviewer' ? 'reviewer' : 'default'; const id = participant?.id?.trim() || `codex-participant-${index + 1}`; if (!name || !model) { return null; } return [ id, { id, name, model, prompt, chatTypeId, defaultContextIds, role, }, ] as const; }) .filter( ( item, ): item is readonly [ string, { id: string; name: string; model: string; prompt: string; chatTypeId: string | null; defaultContextIds: string[]; role: CodexParticipantRole; }, ] => Boolean(item), ), ).values(), ); } function buildStructuredChatContextSections(context: ChatContext | null) { const sections: string[] = []; const baseDescription = context?.chatTypeBaseDescription?.trim() || ''; const defaultContexts = normalizeChatContextEntries(context?.defaultContexts); const customContextTitle = context?.customContextTitle?.trim() || ''; const customContextContent = context?.customContextContent?.trim() || ''; if (baseDescription) { sections.push(['## 채팅 유형 context 원문', baseDescription].join('\n')); } if (defaultContexts.length > 0) { sections.push( [ '## 채팅방에서 선택한 공통 문맥', '- 아래 공통 문맥은 채팅 유형 context와 충돌하지 않는 범위에서만 보조로 적용하세요.', ...defaultContexts.map((entry) => [`### ${entry.title || '공통 문맥'}`, entry.content].filter(Boolean).join('\n'), ), ].join('\n\n'), ); } if (customContextTitle || customContextContent) { sections.push( [ `## 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`, '- 아래 채팅방 전용 메모는 채팅 유형 context를 바꾸지 않으며, 충돌하지 않는 범위에서만 보조로 해석하세요.', customContextContent, ] .filter(Boolean) .join('\n'), ); } return sections; } function resolveChatTypeExecutionPolicy(context: ChatContext | null) { return context?.chatTypeExecutionPolicy ?? createDefaultChatTypeExecutionPolicy(); } function buildChatSessionReferenceContextSummary(context: ChatContext | null) { const lines: string[] = []; const defaultContexts = normalizeChatContextEntries(context?.defaultContexts); const customContextTitle = context?.customContextTitle?.trim() || ''; const customContextContent = context?.customContextContent?.trim() || ''; lines.push('## 현재 채팅 유형 context 요약'); lines.push('- 이 문서에는 채팅방 전용 메모와 현재 적용 중인 context 식별 정보만 짧게 유지합니다.'); lines.push('- 채팅 유형 context가 최상위이며, 공통 문맥과 채팅방 전용 메모는 그 아래 보조 문맥으로만 사용합니다.'); lines.push('- 공통 문맥 상세 원문은 공통 문맥 관리 데이터와 실행 prompt 본문을 기준으로 확인합니다.'); if (defaultContexts.length > 0) { lines.push(''); lines.push('### 적용 중인 공통 문맥'); lines.push(...defaultContexts.map((entry) => `- ${entry.title || entry.id || '공통 문맥'}`)); } if (customContextTitle || customContextContent) { lines.push(''); lines.push(`### 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`); lines.push(customContextContent || '- 전용 메모 본문 없음'); } return lines.join('\n'); } function composeResolvedChatTypeDescription( baseDescription: string, defaultContexts: Array<{ id: string; title: string; content: string }>, customContextTitle?: string | null, customContextContent?: string | null, ) { const sections = [baseDescription.trim()].filter(Boolean); defaultContexts.forEach((context) => { const normalizedContent = context.content.trim(); if (!normalizedContent) { return; } sections.push(`## 기본 유형 · ${context.title || '공통 문맥'}\n${normalizedContent}`); }); const normalizedCustomContextTitle = customContextTitle?.trim() || ''; const normalizedCustomContextContent = customContextContent?.trim() || ''; if (normalizedCustomContextTitle || normalizedCustomContextContent) { sections.push( [`## 채팅방 전용 Context${normalizedCustomContextTitle ? ` · ${normalizedCustomContextTitle}` : ''}`, normalizedCustomContextContent] .filter(Boolean) .join('\n'), ); } return sections.join('\n\n').trim(); } export async function resolveCodexLiveChatContext(context: ChatContext | null, sessionId?: string | null) { if (!context) { return null; } const normalizedChatTypeId = context.chatTypeId?.trim() || null; if (!normalizedChatTypeId) { return cloneChatContext(context); } const [chatTypesConfig, chatContextSettings] = await Promise.all([ getChatTypesConfig(), getChatContextSettingsConfig(), ]); const resolvedChatType = chatTypesConfig.chatTypes.find((item) => item.id === normalizedChatTypeId && item.enabled) ?? null; const roomContext = chatContextSettings.roomContexts.find((item) => item.sessionId === (sessionId?.trim() || '')) ?? null; const explicitDefaultContextIds = normalizeChatContextIdList(context.defaultContextIds); const chatTypeDefaultContextIds = chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds ?? []; const resolvedDefaultContextIds = explicitDefaultContextIds.length > 0 ? explicitDefaultContextIds : roomContext?.defaultContextIds.length ? roomContext.defaultContextIds : chatTypeDefaultContextIds; const resolvedDefaultContexts = resolvedDefaultContextIds .map((contextId) => chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled), ) .filter((item): item is NonNullable => Boolean(item)) .map((item) => ({ id: item.id, title: item.title, content: item.content, })); const resolvedCustomContextTitle = roomContext?.customContextTitle?.trim() || ''; const resolvedCustomContextContent = roomContext?.customContextContent?.trim() || ''; const resolvedCodexParticipants = normalizeCodexParticipants(roomContext?.codexParticipants ?? context.codexParticipants); const resolvedBaseDescription = resolvedChatType?.description?.trim() || context.chatTypeBaseDescription?.trim() || ''; const resolvedDescription = composeResolvedChatTypeDescription( resolvedBaseDescription, resolvedDefaultContexts, resolvedCustomContextTitle, resolvedCustomContextContent, ) || context.chatTypeDescription?.trim() || resolvedBaseDescription; return { ...context, chatTypeId: normalizedChatTypeId, chatTypeLabel: resolvedChatType?.name ?? context.chatTypeLabel ?? '', chatTypeDescription: resolvedDescription, chatTypeBaseDescription: resolvedBaseDescription, chatTypeExecutionPolicy: resolvedChatType?.executionPolicy ?? context.chatTypeExecutionPolicy ?? createDefaultChatTypeExecutionPolicy(), codexModel: (resolvedCodexParticipants[0]?.model ?? context.codexModel?.trim()) || null, codexParticipants: resolvedCodexParticipants, defaultContextIds: resolvedDefaultContextIds, defaultContexts: resolvedDefaultContexts, customContextTitle: resolvedCustomContextTitle || null, customContextContent: resolvedCustomContextContent || null, } satisfies ChatContext; } function buildChatSessionReferenceAutoSection(args: { context: ChatContext | null; sessionId: string; requestId: string; requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled'; requestedAt?: Date | null; completedAt?: Date | null; input?: string; }) { const chatTypeLabel = resolvePromptChatTypeLabel(args.context) || '없음'; const pageTitle = args.context?.pageTitle?.trim() || '없음'; const topMenu = args.context?.topMenu?.trim() || '없음'; const pageUrl = args.context?.pageUrl?.trim() || '없음'; const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음'; const codexModel = args.context?.codexModel?.trim() || '기본값'; const codexParticipants = normalizeCodexParticipants(args.context?.codexParticipants); const requestStatusLabel = args.requestStatus === 'completed' ? '완료' : args.requestStatus === 'failed' ? '실패' : args.requestStatus === 'cancelled' ? '취소' : '실행 중'; return [ CHAT_SESSION_REFERENCE_AUTO_START, '## 자동 갱신 문맥', `- 마지막 갱신 시각: ${formatTime(new Date())}`, `- sessionId: ${args.sessionId}`, `- requestId: ${args.requestId}`, `- 요청 상태: ${requestStatusLabel}`, `- 요청 시작 시각: ${formatTime(args.requestedAt ?? new Date())}`, ...(args.completedAt ? [`- 요청 종료 시각: ${formatTime(args.completedAt)}`] : []), `- 채팅 유형: ${chatTypeLabel}`, `- Codex 모델: ${codexModel}`, ...(codexParticipants.length > 0 ? [ `- Codex 참가자: ${codexParticipants .map((participant) => `${participant.name}(${participant.model}${participant.role !== 'default' ? `, 역할:${participant.role}` : ''}${participant.chatTypeId ? `, 유형:${participant.chatTypeId}` : ''})`, ) .join(', ')}`, ] : []), `- 화면 제목: ${pageTitle}`, `- topMenu: ${topMenu}`, `- focusedComponentId: ${focusedComponentId}`, `- pageUrl: ${pageUrl}`, '', buildChatSessionReferenceContextSummary(args.context), CHAT_SESSION_REFERENCE_AUTO_END, ].join('\n'); } function mergeChatSessionReferenceContent(existingContent: string, autoSection: string) { const trimmedExisting = existingContent.trim(); const defaultHeader = [ '# 채팅방 참고 리소스', '', '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.', '사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.', ].join('\n'); if (!trimmedExisting) { return `${defaultHeader}\n\n${autoSection}\n`; } const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START); if (firstAutoStartIndex >= 0) { const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader; return `${preservedHeader}\n\n${autoSection}\n`; } return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`; } async function writeChatSessionReferenceContentSafely(absolutePath: string, content: string) { try { await writeFile(absolutePath, content, 'utf8'); return; } catch (error) { if ( !error || typeof error !== 'object' || !('code' in error) || (error.code !== 'EACCES' && error.code !== 'EPERM') ) { throw error; } } await rm(absolutePath, { force: true }); await writeFile(absolutePath, content, 'utf8'); } export async function ensureChatSessionReferenceResource(args: { repoPath: string; sessionId: string; requestId: string; context: ChatContext | null; requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled'; requestedAt?: Date | null; completedAt?: Date | null; input: string; recentHistoryLines: string[]; omittedHistoryCount: number; }) { const ensuredDirectories = await ensureChatSessionResourceDirectories(args.repoPath, args.sessionId); const resourceRelativePath = `public/.codex_chat/${args.sessionId}/resource/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`; const absolutePath = path.join(ensuredDirectories.sourceRoot, 'chat-room-reference.md'); const autoSection = buildChatSessionReferenceAutoSection({ context: args.context, sessionId: args.sessionId, requestId: args.requestId, requestStatus: args.requestStatus, requestedAt: args.requestedAt, completedAt: args.completedAt, input: args.input, }); let existingContent = ''; try { existingContent = await readFile(absolutePath, 'utf8'); } catch { existingContent = ''; } const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection); if (nextContent !== existingContent) { await writeChatSessionReferenceContentSafely(absolutePath, nextContent); } return resourceRelativePath; } async function refreshChatSessionReferenceForRequest(args: { sessionId: string; requestId: string; context: ChatContext | null; input: string; requestStatus: 'started' | 'completed' | 'failed' | 'cancelled'; requestedAt: Date; completedAt?: Date | null; }) { const repoPath = resolveMainProjectRoot(); await ensureChatSessionReferenceResource({ repoPath, sessionId: args.sessionId, requestId: args.requestId, context: args.context, requestStatus: args.requestStatus, requestedAt: args.requestedAt, completedAt: args.completedAt ?? null, input: args.input, recentHistoryLines: [], omittedHistoryCount: 0, }); } function buildChatTypeInstructionBlock(context: ChatContext | null) { const chatTypeLabel = resolvePromptChatTypeLabel(context); const chatTypeDescription = context?.chatTypeDescription?.trim() || ''; const structuredSections = buildStructuredChatContextSections(context); const hasSpecificChatType = Boolean(chatTypeLabel); const hasContextDescription = Boolean( structuredSections.length > 0 || (chatTypeDescription && chatTypeDescription !== '없음'), ); if (!hasSpecificChatType && !hasContextDescription) { return [ '## 채팅 유형 context 필수 규칙', '- 선택된 채팅 유형 context가 없습니다.', '- 그래도 AGENTS.md와 현재 사용자 요청을 기준으로 처리하되, Plan 자동화용 자동화 유형 context는 섞지 마세요.', ]; } return [ '## 채팅 유형 context 필수 규칙', '- 아래 채팅 유형 context는 선택 사항이나 참고 메모가 아니라 이 Codex Live 실행의 상위 필수 지시입니다.', '- 우선순위는 1. 채팅 유형 context 2. 현재 턴의 직접 사용자 지시 3. 채팅방에서 선택한 공통 문맥과 전용 메모 4. 최근 대화 문맥과 화면 문맥 순서로 해석하세요.', '- 사용자 요청, 공통 문맥, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선하세요.', '- context 안의 작업 범위, 금지사항, 검증 방식, 답변 스타일, 산출물 규칙을 반드시 지키세요.', '- 공통 문맥과 채팅방 전용 메모는 채팅 유형 context를 덮어쓰지 못하며, 충돌하지 않는 범위에서만 보조로 적용하세요.', '- context가 모호하면 무시하지 말고, 가장 보수적으로 해석해 지킬 수 있는 범위에서 처리하세요.', '- 실행 전 내부적으로 context 준수 여부를 점검하고, 최종 답변도 context 기준에 맞추세요.', '', '### 선택된 채팅 유형', `- label: ${chatTypeLabel || '없음'}`, '', '### 반드시 지킬 context 원문', structuredSections.length > 0 ? structuredSections.join('\n\n') : (chatTypeDescription || '선택된 채팅 유형 context 원문 없음'), ]; } function buildChatSessionReferenceInstructionBlock(referenceContent?: string | null) { const normalizedContent = referenceContent?.trim() || ''; if (!normalizedContent) { return [ '## 채팅방 참고 문서', '- 현재 채팅방 참고 문서 본문을 불러오지 못했습니다.', '- 그래도 위에 제공된 참고 문서 경로를 우선 확인하고, 채팅 유형 context와 AGENTS.md 규칙을 먼저 따르세요.', ]; } return [ '## 채팅방 참고 문서', '- 아래는 이 채팅방에서 시작 전에 먼저 읽어야 하는 참고 문서 원문입니다.', '- 이 문서의 지시와 메모를 현재 요청 해석에 즉시 반영하세요.', '', normalizedContent, ]; } export function buildAgenticCodexPrompt( context: ChatContext | null, input: string, sessionId: string, promptContext?: { repoPath?: string; recentHistoryLines?: string[]; omittedHistoryCount?: number; sessionReferenceResourcePath?: string; sessionReferenceContent?: string; }, ) { const repoPath = promptContext?.repoPath?.trim() || resolveMainProjectRoot(); const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`; const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`; const sessionReferenceResourcePath = promptContext?.sessionReferenceResourcePath || `${chatSessionResourceDir}/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`; const recentHistoryLines = promptContext?.recentHistoryLines ?? []; const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0); return [ '당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.', 'Codex Live는 Plan 자동화와 별개입니다. 자동화 유형 context를 Codex Live 기본 문맥으로 섞지 마세요.', `저장소 루트(main_project): ${repoPath}`, '반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.', '가능한 작업 범위:', '- 로컬 소스 파일 읽기', '- 필요 시 DB 직접 조회', '- 필요 시 로컬 API 응답 확인', '- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정', '- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.', `- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`, `- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`, `- 이 채팅방의 지속 참고 문서: ${sessionReferenceResourcePath}`, '- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.', '- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.', '- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.', '- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.', '- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.', '- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.', ...buildChatSessionReferenceInstructionBlock(promptContext?.sessionReferenceContent), '', ...buildChatTypeInstructionBlock(context), '', '응답 규칙:', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.', '- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.', '- 참고 화면 정보의 `pageTitle`과 `pageUrl`은 현재 열려 있는 Codex Live 채팅 컨테이너 정보일 수 있습니다. 리소스 관리 등록 경로의 `수정한 화면명`, 작업 뱃지, 결과 문구에 이 값을 그대로 복사하지 말고 실제로 수정하거나 확인한 화면/메뉴 기준으로 판단하세요.', '- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.', '- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.', '- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.', '- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 실제 `.html` 리소스를 준비한 뒤 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 또는 `preview":{"type":"resource","url":"resource/<수정한 화면명>/<기능>//sample.html"}` 형태를 사용하세요. 리소스 관리에 등록된 `resource/...` 경로는 자동으로 `/api/resource-manager/preview/...`로 해석됩니다. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.', '- 변경된 소스 파일이 있는 경우에만 최종 답변에 변경 이력을 ```diff 코드블록으로 포함하세요.', '- 변경된 소스 파일이 있으면 마지막에 해당 파일 경로를 짧게 적으세요.', '- 한국어로 간결하게 답하세요.', '', '현재 채팅 컨테이너 참고 정보:', `- pageTitle: ${context?.pageTitle ?? '없음'}`, `- topMenu: ${context?.topMenu ?? '없음'}`, `- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`, `- pageUrl: ${context?.pageUrl ?? '없음'}`, '', '최근 대화 문맥(보조 참조):', ...(recentHistoryLines.length > 0 ? [ ...recentHistoryLines.map((line) => `- ${line}`), ...(omittedHistoryCount > 0 ? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`] : []), ] : ['- 참조할 최근 대화가 없습니다.']), '', '사용자 요청:', input, '', '최종 답변만 출력하세요.', ].join('\n'); } export async function validateAgenticCodexRuntime(repoPath: string, codexBin: string) { const issues: string[] = []; try { const repoStat = await stat(repoPath); if (!repoStat.isDirectory()) { issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로가 디렉터리가 아닙니다: ${repoPath}`); } } catch { issues.push(`PLAN_MAIN_PROJECT_REPO_PATH 경로를 찾지 못했습니다: ${repoPath}`); } const runnerCandidates = buildCommandRunnerApiCandidates('/health'); const headers = new Headers(); if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim()) { headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim()); } let runnerReachable = false; for (const candidate of runnerCandidates) { try { const response = await fetch(candidate, { method: 'GET', headers, signal: AbortSignal.timeout(5000), }); if (response.ok) { runnerReachable = true; break; } } catch { // try next candidate } } if (!runnerReachable) { issues.push(`SERVER_COMMAND_RUNNER_URL 경로로 Codex runner에 연결하지 못했습니다: ${env.SERVER_COMMAND_RUNNER_URL}`); } if (issues.length > 0) { throw new Error(`채팅 실행 환경이 준비되지 않았습니다. ${issues.join(' / ')}`); } } function normalizeRunnerUrl(value: string) { return value.trim().replace(/\/+$/, ''); } function buildCommandRunnerApiCandidates(requestPath: string) { const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health'; let parsedUrl: URL; try { parsedUrl = new URL(configuredHealthUrl); } catch { return []; } const hostVariants = parsedUrl.hostname === 'host.docker.internal' ? ['host.docker.internal', '127.0.0.1', 'localhost'] : parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost' ? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal'] : [parsedUrl.hostname]; const deduped: string[] = []; for (const hostname of hostVariants) { const candidate = new URL(parsedUrl.toString()); candidate.hostname = hostname; candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; candidate.search = ''; candidate.hash = ''; const serialized = normalizeRunnerUrl(candidate.toString()); if (!deduped.includes(serialized)) { deduped.push(serialized); } } return deduped; } async function requestCommandRunner(requestPath: string, init?: RequestInit) { const headers = new Headers(init?.headers); if (init?.body != null && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) { headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim()); } let lastError: Error | null = null; for (const url of buildCommandRunnerApiCandidates(requestPath)) { try { return await fetch(url, { ...init, headers, }); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } } throw lastError ?? new Error('command-runner에 연결하지 못했습니다.'); } async function cancelRunnerCodexExecution(requestId: string) { try { const response = await requestCommandRunner(`/api/codex-live/jobs/${encodeURIComponent(requestId)}/cancel`, { method: 'POST', }); if (!response.ok) { return false; } const payload = (await response.json().catch(() => null)) as { cancelled?: boolean } | null; return payload?.cancelled === true; } catch { return false; } } async function runAgenticCodexReply( context: ChatContext | null, input: string, sessionId: string, requestId: string, options?: { omitPromptHistory?: boolean; requestedAt?: Date | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, isCancellationRequested?: () => boolean, ): Promise { const repoPath = resolveMainProjectRoot(); await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN); const resolvedContext = await resolveCodexLiveChatContext(context, sessionId); const appConfig = await getAppConfigSnapshot(); const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, { maxMessages: appConfig.chat?.maxContextMessages, maxChars: appConfig.chat?.maxContextChars, disabled: options?.omitPromptHistory === true, }); const codexLiveMaxExecutionSeconds = typeof appConfig.chat?.codexLiveMaxExecutionSeconds === 'number' && Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds) ? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds))) : null; const codexLiveIdleTimeoutSeconds = typeof appConfig.chat?.codexLiveIdleTimeoutSeconds === 'number' && Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds) ? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds))) : null; const requestedAt = options?.requestedAt ?? new Date(); const syncSessionReference = async ( requestStatus: 'started' | 'completed' | 'failed' | 'cancelled', completedAt?: Date | null, ) => ensureChatSessionReferenceResource({ repoPath, sessionId, requestId, context: resolvedContext, requestStatus, requestedAt, completedAt: completedAt ?? null, input, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, }); const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({ repoPath, sessionId, requestId, context: resolvedContext, requestStatus: 'started', requestedAt, input, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, }); const sessionReferenceAbsolutePath = path.join(repoPath, sessionReferenceResourcePath); let sessionReferenceContent = ''; try { sessionReferenceContent = await readFile(sessionReferenceAbsolutePath, 'utf8'); } catch { sessionReferenceContent = ''; } const prompt = buildAgenticCodexPrompt(resolvedContext, input, sessionId, { repoPath, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, sessionReferenceResourcePath, sessionReferenceContent, }); let streamedOutput = ''; let stdoutTail = ''; let stderr = ''; let jsonLineBuffer = ''; let lastProgressText = ''; let completedAgentMessage = ''; let streamedUsageSnapshot: ChatConversationRequestUsageSnapshot | null = null; let hasIncrementalDelta = false; const finalizeReplyOutput = async () => { const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail); const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId); const usageSnapshot = streamedUsageSnapshot ?? extractChatRequestUsageSnapshot([completedAgentMessage, stdoutTail, stderr].filter(Boolean).join('\n')); if (!rewrittenOutput) { return { text: '', usageSnapshot, totalTokens: usageSnapshot?.totalTokens ?? null, }; } // If the CLI only produced a final completed event, avoid sending it as one big batch. if (!hasIncrementalDelta && rewrittenOutput) { await streamReplyChunks(rewrittenOutput, onProgress); } else if (rewrittenOutput !== lastProgressText) { onProgress?.(rewrittenOutput); } return { text: rewrittenOutput, usageSnapshot, totalTokens: usageSnapshot?.totalTokens ?? null, }; }; const throwIfCancelled = async () => { if (!isCancellationRequested?.()) { return; } await cancelRunnerCodexExecution(requestId).catch(() => false); throw new Error('CHAT_RUNTIME_CANCELLED'); }; await throwIfCancelled(); activeChatProcessRegistry.set(requestId, { cancel: async () => { const cancelled = await cancelRunnerCodexExecution(requestId); return cancelled || isCancellationRequested?.() === true; }, }); chatRuntimeService.appendLog( requestId, `실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`, ); onActivity?.( `# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`, ); try { await new Promise(async (resolve, reject) => { const emitProgress = (nextText: string) => { const normalizedProgress = nextText.trim(); if (!normalizedProgress || normalizedProgress === lastProgressText) { return; } lastProgressText = normalizedProgress; streamedOutput = normalizedProgress; onProgress?.(normalizedProgress); }; try { await throwIfCancelled(); const response = await requestCommandRunner('/api/codex-live/execute', { method: 'POST', body: JSON.stringify({ requestId, sessionId, repoPath, prompt, model: context?.codexModel?.trim() || null, resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'), uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'), maxExecutionSeconds: codexLiveMaxExecutionSeconds, idleTimeoutSeconds: codexLiveIdleTimeoutSeconds, }), }); if (!response.ok) { reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.')); return; } await throwIfCancelled(); if (!response.body) { reject(new Error('command-runner Codex 스트림이 비어 있습니다.')); return; } chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let remoteErrorMessage = ''; const handleRunnerLine = (line: string) => { let parsed: Record; try { parsed = JSON.parse(line) as Record; } catch { return; } const eventType = typeof parsed.type === 'string' ? parsed.type : ''; if (eventType === 'started') { const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null; const model = typeof parsed.model === 'string' && parsed.model.trim() ? parsed.model.trim() : null; const appliedIdleTimeoutSeconds = typeof parsed.configuredIdleTimeoutSeconds === 'number' && Number.isFinite(parsed.configuredIdleTimeoutSeconds) ? Math.round(parsed.configuredIdleTimeoutSeconds) : null; const appliedMaxExecutionSeconds = typeof parsed.configuredMaxExecutionSeconds === 'number' && Number.isFinite(parsed.configuredMaxExecutionSeconds) ? Math.round(parsed.configuredMaxExecutionSeconds) : null; chatRuntimeService.attachProcess(requestId, pid); chatRuntimeService.appendLog( requestId, pid ? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}` : '호스트 command-runner에서 Codex 프로세스를 시작했습니다.', ); if (model) { chatRuntimeService.appendLog(requestId, `선택 모델: ${model}`); } if (appliedMaxExecutionSeconds != null || appliedIdleTimeoutSeconds != null) { const appliedSummary = `command-runner 적용값: 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` + `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`; chatRuntimeService.appendLog(requestId, appliedSummary); onActivity?.(`# ${appliedSummary}`); if ( (appliedMaxExecutionSeconds != null && codexLiveMaxExecutionSeconds != null && appliedMaxExecutionSeconds !== codexLiveMaxExecutionSeconds) || (appliedIdleTimeoutSeconds != null && codexLiveIdleTimeoutSeconds != null && appliedIdleTimeoutSeconds !== codexLiveIdleTimeoutSeconds) ) { const mismatchSummary = `설정 불일치 감지: 요청값 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / ` + `무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초, ` + `실제 적용값 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` + `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`; chatRuntimeService.appendLog(requestId, mismatchSummary); onActivity?.(`# 경고: ${mismatchSummary}`); } } return; } if (eventType === 'attached') { const attachedRequestId = String(parsed.requestId ?? '').trim(); const completed = parsed.completed === true; const attachSummary = attachedRequestId && attachedRequestId === requestId ? completed ? '기존 command-runner 실행 이력을 다시 연결했습니다.' : '기존 command-runner 실행에 재부착했습니다.' : completed ? '기존 command-runner 응답 이력을 다시 연결했습니다.' : '기존 command-runner 실행 스트림에 다시 연결했습니다.'; chatRuntimeService.appendLog(requestId, attachSummary); onActivity?.(`# ${attachSummary}`); return; } if (eventType === 'activity') { const activityLog = String(parsed.line ?? '').trim(); if (activityLog) { chatRuntimeService.appendLog(requestId, activityLog); onActivity?.(activityLog); } return; } if (eventType === 'delta') { const deltaText = String(parsed.text ?? ''); if (deltaText) { hasIncrementalDelta = true; emitProgress(`${streamedOutput}${deltaText}`); } return; } if (eventType === 'completed') { completedAgentMessage = String(parsed.text ?? '').trim(); const usageSnapshot = parsed.usageSnapshot && typeof parsed.usageSnapshot === 'object' ? (parsed.usageSnapshot as ChatConversationRequestUsageSnapshot) : null; if (usageSnapshot) { streamedUsageSnapshot = usageSnapshot; } if (completedAgentMessage && hasIncrementalDelta) { emitProgress(completedAgentMessage); } return; } if (eventType === 'usage') { const usageSnapshot = parsed.usageSnapshot && typeof parsed.usageSnapshot === 'object' ? (parsed.usageSnapshot as ChatConversationRequestUsageSnapshot) : null; if (usageSnapshot) { streamedUsageSnapshot = usageSnapshot; } return; } if (eventType === 'stdout') { const stdoutLine = String(parsed.line ?? '').trim(); if (stdoutLine) { const structuredStdout = parseStructuredCodexStdoutLine(stdoutLine); if (!structuredStdout.shouldKeepRaw) { if (structuredStdout.activityLog) { chatRuntimeService.appendLog(requestId, structuredStdout.activityLog); onActivity?.(structuredStdout.activityLog); } if (structuredStdout.deltaText) { hasIncrementalDelta = true; emitProgress(`${streamedOutput}${structuredStdout.deltaText}`); } if (structuredStdout.completedText) { completedAgentMessage = structuredStdout.completedText.trim(); if (completedAgentMessage && hasIncrementalDelta) { emitProgress(completedAgentMessage); } } if (structuredStdout.usageSnapshot) { streamedUsageSnapshot = structuredStdout.usageSnapshot; } return; } stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT); chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`); onActivity?.(`[stdout] ${stdoutLine}`); } return; } if (eventType === 'stderr') { const stderrLine = String(parsed.line ?? '').trim(); if (stderrLine) { stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT); chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`); onActivity?.(`[stderr] ${stderrLine}`); } return; } if (eventType === 'error') { remoteErrorMessage = String(parsed.message ?? '').trim(); } }; while (true) { await throwIfCancelled(); const { value, done } = await reader.read(); if (done) { break; } jsonLineBuffer += decoder.decode(value, { stream: true }); const lines = jsonLineBuffer.split('\n'); jsonLineBuffer = lines.pop() ?? ''; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { continue; } handleRunnerLine(line); } } const trailingLine = jsonLineBuffer.trim(); if (trailingLine) { handleRunnerLine(trailingLine); } if (remoteErrorMessage) { reject(new Error(remoteErrorMessage)); return; } resolve(); } catch (error) { reject(error); } }); } catch (error) { const completedAt = new Date(); await syncSessionReference(error instanceof Error && error.message === 'CHAT_RUNTIME_CANCELLED' ? 'cancelled' : 'failed', completedAt); const failureResponseText = await finalizeReplyOutput(); if (failureResponseText.text) { throw new ChatRuntimeExecutionError( error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.', failureResponseText.text, failureResponseText.usageSnapshot, failureResponseText.totalTokens, ); } throw error; } await syncSessionReference('completed', new Date()); return await finalizeReplyOutput(); } async function getTodayAutomationRegistrationCounts() { const [planCountResult, boardReceivedCountResult] = await Promise.all([ db(PLAN_TABLE) .whereNot('automation_type', 'none') .whereRaw("(created_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE]) .count<{ count: string }>('* as count') .first(), db(BOARD_POSTS_TABLE) .whereNotNull('automation_received_at') .whereRaw( "(automation_received_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE], ) .count<{ count: string }>('* as count') .first(), ]); return { today: formatKstDate(), planCount: Number(planCountResult?.count ?? 0), boardReceivedCount: Number(boardReceivedCountResult?.count ?? 0), }; } async function buildAutomationRegistrationCountReply() { const counts = await getTodayAutomationRegistrationCounts(); const lines = [ '결과', `- ${counts.today} KST 기준 오늘 자동화 등록 총 건수는 ${counts.planCount}건입니다.`, '- 기본 기준: `plan_items`에서 `automation_type <> \'none\'` 이고 오늘 생성된 건수', ]; if (counts.planCount === counts.boardReceivedCount) { lines.push(`- 교차 확인: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수도 ${counts.boardReceivedCount}건으로 같습니다.`); } else { lines.push(`- 참고: 오늘 ` + '`board_posts.automation_received_at`' + ` 기준 접수 건수는 ${counts.boardReceivedCount}건입니다.`); lines.push('- 이 프로젝트는 게시판 등록, 자동화 접수, Plan 생성이 분리될 수 있어 기준에 따라 숫자가 달라질 수 있습니다.'); } return lines.join('\n'); } function buildAutomationRegistrationDefinitionReply() { return [ '결과', '- 이 프로젝트에서 `자동화 등록`은 하나로 고정된 용어가 아닙니다.', '- `board_posts.created_at`: 게시판 등록', '- `board_posts.automation_received_at`: 자동화 접수', '- `plan_items.created_at` + `automation_type <> \'none\'`: 실제 자동화 대상 Plan 생성', '- 건수 질문이면 어떤 기준인지 먼저 구분하고, 모호하면 기본적으로 Plan 생성 기준을 우선 안내합니다.', ].join('\n'); } function buildProgressMessages(input: string) { const messages = ['요청을 분석하고 있습니다.']; if (isAutomationRegistrationCountRequest(input)) { messages.push('오늘 기준 집계라 DB 기준과 시간대 기준을 확인하고 있습니다.'); return messages; } if (/db|데이터베이스|sql|쿼리|집계|건수/i.test(input)) { messages.push('DB와 집계 기준을 확인하고 있습니다.'); } if (/api|응답|endpoint|엔드포인트|fetch|호출/i.test(input)) { messages.push('API 경로와 실제 응답을 확인하고 있습니다.'); } if (/파일|소스|코드|tsx|ts|js|css|수정|변경|구현|fix|edit|implement/i.test(input)) { messages.push('관련 소스와 연결 흐름을 확인하고 있습니다.'); } return [...new Set(messages)]; } function normalizeProgressSummary(text: string) { return text.replace(/\s+/g, ' ').trim(); } function shouldSkipActivityProgressSummary(text: string) { return ( !text || /^요청을 처리합니다\./.test(text) || /^응답 생성이 완료되었습니다\.$/.test(text) || /^완료(?:\(\d+\))?$/i.test(text) || /^종료\(\d+\)$/i.test(text) ); } export function summarizeActivityProgressLine(activityLine: string) { const lines = String(activityLine ?? '') .split('\n') .map((line) => normalizeProgressSummary(line)) .filter(Boolean); for (const line of lines) { if (line.startsWith('# 이유:')) { const summary = normalizeProgressSummary(line.slice('# 이유:'.length)); if (!shouldSkipActivityProgressSummary(summary)) { return summary; } } } for (const line of lines) { for (const prefix of ['# 진행:', '# 상태:', '# 경고:']) { if (!line.startsWith(prefix)) { continue; } const summary = normalizeProgressSummary(line.slice(prefix.length)); if (!shouldSkipActivityProgressSummary(summary)) { return summary; } } } return ''; } function stripActivityLinePrefix(line: string) { return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim(); } function resolveCompactActivityStageLineNo(summary: string, normalizedLine: string) { if (!summary) { return null; } if (/^#\s*오류:/u.test(normalizedLine) || /오류|실패|중단/u.test(summary)) { return 5; } if (/요청을 처리합니다|대기열|즉시 요청 실행/u.test(summary)) { return 1; } if (/분석|의도|문맥|생각 중/u.test(summary)) { return 2; } if (/\bdb\b|데이터베이스|\bapi\b|엔드포인트|응답|소스|코드|파일|흐름|쿼리|집계|resource|리소스|화면/u.test(summary)) { return 3; } if (/구현|수정|변경|작성|빌드|patch|diff|전송 중/u.test(summary)) { return 4; } if (/검증|테스트|캡처|preview|완료|결과|정리/u.test(summary)) { return 5; } return null; } function createCompactActivityLogEntry(activityLine: string) { const normalizedLine = normalizeProgressSummary(activityLine); if (!normalizedLine) { return null; } const strippedSummary = stripActivityLinePrefix(normalizedLine); const summarizedProgress = summarizeActivityProgressLine(normalizedLine); const summary = summarizedProgress || strippedSummary; const lineNo = resolveCompactActivityStageLineNo(summary, normalizedLine); if (!lineNo || !summary) { return null; } if (/^#\s*오류:/u.test(normalizedLine)) { return { line: `# 오류: ${summary}`, lineNo, }; } const prefix = lineNo === 1 || lineNo === 5 ? '# 상태:' : '# 진행:'; return { line: `${prefix} ${summary}`, lineNo, }; } function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { const normalized = input.toLowerCase(); const pageTitle = context?.pageTitle ?? '현재 화면'; const previewUrl = context?.pageUrl || '없음'; const detailRequested = isDetailRequest(input); if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) { const lines = [ '결과', `- preview: ${snapshot?.latestSourceWork?.previewUrl ?? previewUrl}`, ]; if (snapshot?.latestSourceWork?.summary) { lines.push(`- 요약: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); } if (detailRequested && snapshot?.latestSourceWork) { lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`); } if (!detailRequested) { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if (input.includes('계획') || normalized.includes('plan')) { if (!snapshot) { return ['결과', '- 연결된 Plan을 찾지 못했습니다.', '- planId 또는 작업 ID를 함께 보내 주세요.'].join('\n'); } const lines = [ '작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`, `- 메모: ${truncateText(snapshot.note, 110) || '기록 없음'}`, ]; if (detailRequested) { if (snapshot.assignedBranch) { lines.push(`- 브랜치: ${snapshot.assignedBranch}`); } lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if ((input.includes('이슈') || normalized.includes('issue')) && snapshot) { const lines = ['이슈 요약']; if (snapshot.latestIssue) { lines.push( `- ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`, `- 내용: ${truncateText(snapshot.latestIssue.message, 110)}`, ); } else if (snapshot.issueTags.length > 0) { lines.push(`- 태그: ${snapshot.issueTags.join(', ')}`); } else { lines.push('- 최근 이슈 이력이 없습니다.'); } if (detailRequested) { lines.push(...buildRecentPlanHistoryLines(snapshot)); } return lines.join('\n'); } if ((input.includes('이력') || input.includes('최근') || normalized.includes('history')) && snapshot) { const lines = [ '최근 이력', `- ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 120) || '최근 작업 이력이 없습니다.'}`, ]; if (detailRequested) { lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if (input.includes('문서') || normalized.includes('docs')) { return ['결과', '- 문서 요청으로 인식했습니다.', '- 대상 문서명이나 링크를 함께 보내 주세요.'].join('\n'); } if (isWorklogRequest(input)) { if (snapshot) { return buildWorklogReply(context, snapshot); } return ['결과', '- 작업로그 초안을 만들 Plan이 없습니다.', '- planId 또는 작업 화면에서 다시 요청해 주세요.'].join('\n'); } if ( input.includes('컴포넌트') || normalized.includes('widget') || normalized.includes('api') ) { return ['결과', '- 대상 리소스를 특정해 주세요.', '- 예: 버튼 컴포넌트, previewer, history api'].join('\n'); } if (snapshot && isPlanDetailRequest(input)) { const lines = [ '작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`, `- 최근 작업: ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 110) || '기록 없음'}`, ]; if (detailRequested) { lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } return [ '결과', `- 현재 화면: ${pageTitle}`, '- 요청을 더 구체적으로 적어 주시면 필요한 정보만 짧게 정리합니다.', '- 예: preview 링크, 최근 이슈, 작업 요약, 상세', ].join('\n'); } function buildPlanReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { const normalized = input.toLowerCase(); const detailRequested = isDetailRequest(input); const pageTitle = context?.pageTitle ?? '현재 화면'; if (!snapshot) { return `현재 화면: ${pageTitle}\n선택된 Plan을 찾지 못했습니다. Plan 목록에서 항목을 선택한 뒤 다시 요청해 주세요.`; } const lines = ['작업 요약', `- #${snapshot.planId} ${snapshot.workId}`, `- 상태: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`]; if (isWorklogRequest(input)) { return buildWorklogReply(context, snapshot); } if (snapshot.note) { lines.push(`- 메모: ${truncateText(snapshot.note, 110)}`); } if (normalized.includes('preview') || normalized.includes('링크') || normalized.includes('url')) { lines.push(`- preview: ${snapshot.latestSourceWork?.previewUrl ?? context?.pageUrl ?? '없음'}`); if (snapshot.latestSourceWork) { lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); if (detailRequested) { lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`); } } if (!detailRequested) { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } if (snapshot.latestIssue) { lines.push(`- 최근 이슈: ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? '해결' : '미해결'}`); if (detailRequested) { lines.push(`- 이슈 내용: ${truncateText(snapshot.latestIssue.message, 100)}`); } } else if (snapshot.issueTags.length > 0) { lines.push(`- 이슈 태그: ${snapshot.issueTags.join(', ')}`); } if (snapshot.latestActionNote) { lines.push(`- 최근 조치: ${truncateText(snapshot.latestActionNote, 100)}`); } if (snapshot.latestSourceWork) { lines.push(`- 최근 작업: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); if (detailRequested && snapshot.latestSourceWork.changedFiles.length > 0) { lines.push(`- 파일 ${snapshot.latestSourceWork.changedFiles.length}개 변경`); } } if (snapshot.lastError && snapshot.hasOpenIssues) { lines.push(`- 주의: ${truncateText(snapshot.lastError, 100)}`); } if (detailRequested) { if (snapshot.assignedBranch) { lines.push(`- 브랜치: ${snapshot.assignedBranch}`); } lines.push(...buildRecentPlanHistoryLines(snapshot)); } else { lines.push('- 상세가 필요하면 "상세"라고 입력해 주세요.'); } return lines.join('\n'); } async function buildCodexReply( context: ChatContext | null, input: string, sessionId: string, requestId: string, options?: { omitPromptHistory?: boolean; requestedAt?: Date | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, isCancellationRequested?: () => boolean, ): Promise { return runAgenticCodexReply( context, input, sessionId, requestId, options, onProgress, onActivity, isCancellationRequested, ); } function formatParticipantTurnLabel(participant: { name: string; model: string; turn: CodexParticipantTurn; }) { const suffix = participant.turn === 'opening' ? '중재 시작' : participant.turn === 'discussion' ? '프리토킹' : participant.turn === 'review' ? '최종 검토' : participant.turn === 'closing' ? '최종 정리' : null; return suffix ? `${participant.name}(${participant.model}, ${suffix})` : `${participant.name}(${participant.model})`; } type ResolvedCodexParticipant = ReturnType[number]; type CodexExecutionStage = { parallel: boolean; participants: ResolvedCodexParticipant[]; }; type CodexExecutionPlanStep = { key: string; label: string; lineNo: number; kind: 'analysis' | 'stage' | 'finalize'; participantKeys?: string[]; }; type CodexExecutorActivitySlot = { participantId: string; label: string; lineNo: number; }; function buildCodexPlanParticipantKey(participant: ResolvedCodexParticipant) { return `${participant.id}:${participant.turn}`; } export function resolveCodexParticipantsForExecution(context: ChatContext | null) { const configuredParticipants = normalizeCodexParticipants(context?.codexParticipants); const normalizedParticipants = configuredParticipants.length > 0 ? configuredParticipants : [ { id: 'codex-default', name: 'Codex', model: context?.codexModel?.trim() || 'gpt-5.4', prompt: '', chatTypeId: null, defaultContextIds: [], role: 'default' as const, }, ]; const executionPolicy = resolveChatTypeExecutionPolicy(context); const shouldAutoBind = executionPolicy.participantBinding === 'first-moderator-rest-conversation' || executionPolicy.participantBinding === 'first-moderator-rest-conversation-last-reviewer'; const policyBoundParticipants = shouldAutoBind ? normalizedParticipants.map((participant, index, source) => ({ ...participant, role: index === 0 ? ('moderator' as const) : executionPolicy.participantBinding === 'first-moderator-rest-conversation-last-reviewer' && executionPolicy.reviewPolicy === 'reviewer' && index === source.length - 1 ? ('reviewer' as const) : ('conversation' as const), })) : normalizedParticipants; const moderator = policyBoundParticipants.find((participant) => participant.role === 'moderator') ?? null; if (!moderator) { return policyBoundParticipants.map((participant) => ({ ...participant, turn: participant.role === 'conversation' ? ('discussion' as const) : participant.role === 'reviewer' ? ('review' as const) : ('standard' as const), })); } const reviewer = executionPolicy.reviewPolicy === 'reviewer' ? policyBoundParticipants.find((participant) => participant.role === 'reviewer') ?? null : null; const discussionParticipants = policyBoundParticipants.filter( (participant) => participant.id !== moderator.id && participant.id !== reviewer?.id, ); if (discussionParticipants.length === 0 && !reviewer) { return [ { ...moderator, turn: 'standard' as const, }, ]; } return [ { ...moderator, turn: 'opening' as const, }, ...discussionParticipants.map((participant) => ({ ...participant, turn: participant.role === 'conversation' ? ('discussion' as const) : ('standard' as const), })), ...(reviewer ? [ { ...reviewer, turn: 'review' as const, }, ] : []), { ...moderator, turn: 'closing' as const, }, ]; } export function resolveCodexExecutionStages( context: ChatContext | null, requestMode: ChatRequestMode = 'queue', ): CodexExecutionStage[] { const participants = resolveCodexParticipantsForExecution(context); if (participants.length <= 1 || requestMode !== 'direct') { return participants.map((participant) => ({ parallel: false, participants: [participant], })); } const executionPolicy = resolveChatTypeExecutionPolicy(context); if (!executionPolicy.finalSummaryRequired) { const parallelParticipants = participants .filter((participant) => participant.turn !== 'closing') .map((participant) => participant.turn === 'opening' ? { ...participant, turn: 'standard' as const, } : participant, ); return [ { parallel: parallelParticipants.length > 1, participants: parallelParticipants, }, ]; } const stages: CodexExecutionStage[] = []; const openingParticipants = participants.filter((participant) => participant.turn === 'opening'); const discussionParticipants = participants.filter( (participant) => participant.turn === 'discussion' || participant.turn === 'standard', ); const reviewParticipants = participants.filter((participant) => participant.turn === 'review'); const closingParticipants = participants.filter((participant) => participant.turn === 'closing'); openingParticipants.forEach((participant) => { stages.push({ parallel: false, participants: [participant], }); }); if (discussionParticipants.length > 0) { stages.push({ parallel: discussionParticipants.length > 1, participants: discussionParticipants, }); } reviewParticipants.forEach((participant) => { stages.push({ parallel: false, participants: [participant], }); }); closingParticipants.forEach((participant) => { stages.push({ parallel: false, participants: [participant], }); }); return stages; } function buildCodexExecutionPlanSteps(stages: CodexExecutionStage[]) { const steps: CodexExecutionPlanStep[] = [ { key: 'analysis', label: '요청 분석', lineNo: 11, kind: 'analysis', }, ]; stages.forEach((stage, index) => { const turnSet = new Set(stage.participants.map((participant) => participant.turn)); let label = stage.parallel ? `병렬 실행 (${stage.participants.length}명)` : '실행'; if (turnSet.size === 1) { const [turn] = [...turnSet]; if (turn === 'opening') { label = '중재 시작'; } else if (turn === 'discussion') { label = stage.parallel ? `병렬 토론 (${stage.participants.length}명)` : '토론 실행'; } else if (turn === 'review') { label = '최종 검토'; } else if (turn === 'closing') { label = '최종 정리'; } else if (turn === 'standard') { label = stage.parallel ? `병렬 실행 (${stage.participants.length}명)` : '단일 실행'; } } steps.push({ key: `stage-${index + 1}`, label, lineNo: 11 + steps.length, kind: 'stage', participantKeys: stage.participants.map((participant) => buildCodexPlanParticipantKey(participant)), }); }); steps.push({ key: 'finalize', label: '응답 정리', lineNo: 11 + steps.length, kind: 'finalize', }); return steps; } function buildCodexExecutorActivitySlots(participants: ResolvedCodexParticipant[], startLineNo: number) { const slots: CodexExecutorActivitySlot[] = []; const seenParticipantIds = new Set(); let lineOffset = startLineNo; participants.forEach((participant) => { if (seenParticipantIds.has(participant.id)) { return; } seenParticipantIds.add(participant.id); slots.push({ participantId: participant.id, label: `${participant.name}(${participant.model})`, lineNo: lineOffset, }); lineOffset += 1; }); return slots; } async function resolveCodexParticipantTypeOverrides( participants: Array<{ id: string; name: string; model: string; prompt: string; chatTypeId: string | null; role: CodexParticipantRole; turn: CodexParticipantTurn; }>, roomContext: ChatContext | null, ) { const participantTypeIds = Array.from( new Set( participants .map((participant) => participant.chatTypeId?.trim() || '') .filter((chatTypeId) => chatTypeId && chatTypeId !== (roomContext?.chatTypeId?.trim() || '')), ), ); if (participantTypeIds.length === 0) { return new Map< string, { label: string; description: string; } >(); } const [chatTypesConfig, chatContextSettings] = await Promise.all([ getChatTypesConfig(), getChatContextSettingsConfig(), ]); return new Map( participantTypeIds.map((chatTypeId) => { const resolvedChatType = chatTypesConfig.chatTypes.find((item) => item.id === chatTypeId && item.enabled) ?? null; const defaultContextIds = chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === chatTypeId)?.defaultContextIds ?? []; const defaultContexts = defaultContextIds .map((contextId) => chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled), ) .filter((item): item is NonNullable => Boolean(item)) .map((item) => ({ id: item.id, title: item.title, content: item.content, })); const baseDescription = resolvedChatType?.description?.trim() || ''; const description = composeResolvedChatTypeDescription(baseDescription, defaultContexts); return [ chatTypeId, { label: resolvedChatType?.name ?? '개별 채팅유형', description: description || baseDescription, }, ] as const; }), ); } export function buildParticipantRequestInput( baseInput: string, participant: { name: string; model: string; prompt: string; chatTypeId: string | null; role: CodexParticipantRole; turn: CodexParticipantTurn; }, participants: Array<{ name: string; model: string; prompt: string; chatTypeId: string | null; role: CodexParticipantRole; turn: CodexParticipantTurn; }>, previousReplies: Array<{ name: string; model: string; text: string }>, participantTypeOverride?: { label: string; description: string } | null, executionPolicy?: ChatTypeExecutionPolicy | null, promptContextRef?: ChatPromptContextRef | null, ) { const resolvedPolicy = executionPolicy ?? createDefaultChatTypeExecutionPolicy(); const lines = [ baseInput.trim(), '', `추가 실행 역할: 이번 응답은 ${participant.name} (${participant.model}) 차례입니다.`, ]; if (participant.prompt) { lines.push(`역할 메모: ${participant.prompt}`); } if (participant.turn === 'opening') { lines.push( resolvedPolicy.mode === 'dispatcher-workers' ? '역할 지시: 당신은 이번 대화의 중계 지시자입니다. 지금 턴에서는 작업을 역할·기준·검증 축으로 분해하고, 어떤 Codex가 어떤 관점으로 이어서 처리해야 하는지 분명하게 배분하세요.' : '역할 지시: 당신은 이번 대화의 회의 기록자 겸 중재자입니다. 지금 턴에서는 결론을 먼저 확정하지 말고 요청을 재정리한 뒤, 다른 Codex가 이어서 토론할 핵심 논점·검증 기준·확인 포인트를 짧고 분명하게 남기세요.', ); } else if (participant.turn === 'closing') { lines.push( resolvedPolicy.mode === 'dispatcher-workers' ? '역할 지시: 당신은 이번 대화의 중계 지시자입니다. 앞선 Codex 발언을 종합해 최종 결과물, 검토 결과, 남은 리스크와 후속 액션을 보고 형식으로 정리하세요.' : '역할 지시: 당신은 이번 대화의 회의 기록자 겸 중재자입니다. 앞선 Codex 발언을 바탕으로 최종 결론과 남은 쟁점을 정리하고, 필요하면 resource·검증 결과·적용 결론 중심으로 보고를 마무리하세요.', ); } else if (participant.turn === 'review' || participant.role === 'reviewer') { lines.push( '역할 지시: 당신은 최종 검토자입니다. 앞선 논의의 근거, 누락, 충돌, 검증 부족 지점을 점검하고 최종 종합 전에 보완 또는 승인 의견을 분명하게 남기세요.', ); } else if (participant.turn === 'discussion' || participant.role === 'conversation') { lines.push( resolvedPolicy.mode === 'dispatcher-workers' ? '역할 지시: 당신은 실작업자입니다. 배정된 관점에서 구현, 설계, 검증, 반례를 구체적으로 제시하고 필요하면 앞선 지시를 수정 제안하세요.' : '역할 지시: 당신은 프리토킹 참가자입니다. 앞선 Codex 의견에 자유롭게 동의·반박·보완하면서 구현, 설계, 검증 관점의 구체 논점을 추가하세요.', ); } lines.push( `실행 정책: ${resolvedPolicy.mode}`, `결과물 보고 정책: ${resolvedPolicy.resourceReportPolicy}`, `최종 종합 강제: ${resolvedPolicy.finalSummaryRequired ? '예' : '아니오'}`, ); if (participantTypeOverride?.description) { lines.push( '', `참가자 전용 채팅유형: ${participantTypeOverride.label}`, participantTypeOverride.description, ); } const promptContextLines = buildPromptContextInstructionLines(promptContextRef); if (promptContextLines.length > 0) { lines.push('', ...promptContextLines); } if (participants.length > 1) { lines.push( `참가자 순서: ${participants.map((entry) => formatParticipantTurnLabel(entry)).join(' -> ')}`, ); } if (previousReplies.length > 0) { lines.push('', '이전 Codex 발언:'); previousReplies.forEach((reply, index) => { lines.push(`${index + 1}. ${reply.name} (${reply.model})`); lines.push(reply.text.trim()); }); lines.push('', '위 Codex 발언을 읽고 이어서 답하세요. 필요하면 동의, 반박, 보완, 정리를 명확히 적으세요.'); } else if (participants.length > 1) { lines.push( '', participant.turn === 'opening' ? '중재 시작 턴입니다. 다음 Codex가 바로 이어서 토론할 수 있게 논점을 분명히 남기세요.' : '첫 번째 Codex로서 먼저 답한 뒤, 다음 Codex가 이어서 토론할 수 있게 논점을 분명히 남기세요.', ); } return lines.filter(Boolean).join('\n'); } export class ChatService { private readonly wss = new WebSocketServer({ noServer: true }); private readonly clientStates = new Map(); private readonly sessions = new Map(); private readonly cancelledRequestIds = new Set(); private readonly unsubscribeRuntimeBroadcast: () => void; private readonly unsubscribeNotificationBroadcast: () => void; constructor(private readonly logger: FastifyBaseLogger) { activeChatService = this; activeRuntimeController = { getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId), cancelJob: (requestId) => this.cancelRuntimeJob(requestId), removeQueuedJob: (requestId) => this.removeQueuedRuntimeJob(requestId), }; this.unsubscribeRuntimeBroadcast = chatRuntimeService.subscribe((snapshot) => { this.broadcastRuntimeSnapshot(snapshot); }); this.unsubscribeNotificationBroadcast = subscribeNotificationMessageChanges((event) => { this.broadcastNotificationMessageChange(event); }); this.wss.on('connection', (socket: WebSocket, request: IncomingMessage) => { void this.handleConnection(socket, request).catch((error: unknown) => { const session = this.clientStates.get(socket); this.logger.error(error, 'chat websocket connection initialization failed'); this.clientStates.delete(socket); if (session) { session.sockets.delete(socket); } closeSocketSafely(this.logger, socket, 'failed to close websocket after initialization error'); }); }); } attachUpgradeHandler() { return (request: IncomingMessage, socket: Socket, head: Buffer) => { const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; const url = new URL(request.url ?? '/', origin); if (url.pathname !== SOCKET_PATH) { socket.destroy(); return; } this.wss.handleUpgrade(request, socket, head, (websocket: WebSocket) => { this.wss.emit('connection', websocket, request); }); }; } getRuntimeSnapshot(): ChatServiceRuntimeSnapshot { let activeRequestCount = 0; let queuedRequestCount = 0; let activeSocketCount = 0; let connectedSessionCount = 0; for (const session of this.sessions.values()) { activeRequestCount += session.activeRequestCount; queuedRequestCount += session.queue.length; let sessionHasOpenSocket = false; for (const socket of session.sockets) { if (socket.readyState === SOCKET_READY_STATE_OPEN) { activeSocketCount += 1; sessionHasOpenSocket = true; } } if (sessionHasOpenSocket) { connectedSessionCount += 1; } } return { activeRequestCount, queuedRequestCount, connectedSessionCount, activeSocketCount, canAcceptNewRequests: !isRuntimeDraining(), }; } close() { activeRuntimeController = null; if (activeChatService === this) { activeChatService = null; } this.unsubscribeRuntimeBroadcast(); this.unsubscribeNotificationBroadcast(); for (const execution of activeChatProcessRegistry.values()) { void Promise.resolve(execution.cancel()).catch(() => { // noop }); } activeChatProcessRegistry.clear(); chatRuntimeService.clearAll(); for (const client of this.clientStates.keys()) { client.close(); } this.wss.close(); } async recoverInterruptedSessions() { if (!(await isCurrentWorkServerSlotActive())) { this.logger.info( { slot: process.env.WORK_SERVER_SLOT?.trim() || null, }, 'skip interrupted chat recovery on inactive work-server slot', ); return { sessionCount: 0, restartedCount: 0, requeuedCount: 0, }; } const recoverableRequests = await listRecoverableChatConversationRequests(); if (recoverableRequests.length === 0) { return { sessionCount: 0, restartedCount: 0, requeuedCount: 0, }; } const requestsBySession = new Map(); for (const item of recoverableRequests) { const existing = requestsBySession.get(item.sessionId); if (existing) { existing.push(item); } else { requestsBySession.set(item.sessionId, [item]); } } let restartedCount = 0; let requeuedCount = 0; for (const [sessionId, items] of requestsBySession.entries()) { const recoverableItems = items.filter((item) => !shouldClearConversationJobState({ currentRequestId: item.currentRequestId, currentJobStatus: item.currentJobStatus, currentStatusUpdatedAt: item.currentStatusUpdatedAt, runtimeActive: false, request: { requestId: item.requestId, status: item.status, responseMessageId: null, responseText: '', terminalAt: null, updatedAt: item.updatedAt, }, })); for (const staleItem of items.filter((item) => !recoverableItems.includes(item))) { await upsertChatConversationRequest(staleItem.sessionId, { requestId: staleItem.requestId, requestOrigin: staleItem.requestOrigin, sharedResourceTokenId: staleItem.sharedResourceTokenId, parentRequestId: staleItem.parentRequestId, status: 'failed', statusMessage: '중단된 오래된 요청', userText: staleItem.userText, }); } if (recoverableItems.length === 0) { continue; } const session = this.getOrCreateSession(sessionId, recoverableItems[0]?.clientId ?? null); const primaryItem = recoverableItems.find((item) => item.requestId === item.currentRequestId && item.currentJobStatus === 'started') ?? recoverableItems.find((item) => item.status === 'started') ?? recoverableItems[0]; session.clientId = primaryItem?.clientId?.trim() || session.clientId; session.context = { pageId: null, pageTitle: '', topMenu: '', focusedComponentId: null, pageUrl: '', chatTypeId: primaryItem?.chatTypeId ?? primaryItem?.lastChatTypeId ?? null, chatTypeLabel: primaryItem?.contextLabel ?? '', chatTypeDescription: primaryItem?.contextDescription ?? '', }; for (const item of recoverableItems) { if (item.status === 'started' && item.requestId !== primaryItem?.requestId) { await upsertChatConversationRequest(item.sessionId, { requestId: item.requestId, requestOrigin: item.requestOrigin, sharedResourceTokenId: item.sharedResourceTokenId, parentRequestId: item.parentRequestId, status: 'failed', statusMessage: '중단된 오래된 요청', userText: item.userText, }); continue; } if (item.requestId === primaryItem?.requestId) { continue; } const requestedAtMs = parseRequestedAtMs(item.createdAt); session.queue.push({ requestId: item.requestId, text: item.userText, mode: 'queue', requestedAtMs, requestOrigin: item.requestOrigin ?? 'composer', sharedResourceTokenId: item.sharedResourceTokenId ?? null, parentRequestId: item.parentRequestId ?? null, context: session.context ? cloneChatContext(session.context) : null, }); chatRuntimeService.enqueueJob({ sessionId, requestId: item.requestId, mode: 'queue', text: item.userText, }); chatRuntimeService.appendLog(item.requestId, '워크서버 재기동 후 대기열 복구를 준비합니다.'); requeuedCount += 1; } if (!primaryItem) { continue; } restartedCount += 1; void this.executeRequest(session, { requestId: primaryItem.requestId, text: primaryItem.userText, mode: 'queue', requestedAtMs: parseRequestedAtMs(primaryItem.createdAt), requestOrigin: primaryItem.requestOrigin ?? 'composer', sharedResourceTokenId: primaryItem.sharedResourceTokenId ?? null, parentRequestId: primaryItem.parentRequestId ?? null, context: session.context ? cloneChatContext(session.context) : null, }).catch((error: unknown) => { this.logger.error( { error, sessionId: session.sessionId, requestId: primaryItem.requestId }, 'failed to recover interrupted chat request', ); }); } this.logger.info( { sessionCount: requestsBySession.size, restartedCount, requeuedCount, }, 'recovered interrupted chat sessions after work-server restart', ); return { sessionCount: requestsBySession.size, restartedCount, requeuedCount, }; } private getOrCreateSession(sessionId: string, clientId?: string | null) { const existing = this.sessions.get(sessionId); if (existing) { if (clientId?.trim()) { existing.clientId = clientId.trim(); } return existing; } const nextSession: ChatSessionState = { sessionId, clientId: clientId?.trim() || null, sockets: new Set(), lastSeenAt: Date.now(), isDeleted: false, context: null, queue: [], activeRequestCount: 0, pendingQueueReleaseEventId: null, pendingQueueReleaseTimer: null, nextEventId: 1, eventHistory: [], messagePersistenceTail: Promise.resolve(), watchedRuntimeRequestId: null, }; this.sessions.set(sessionId, nextSession); return nextSession; } private createSessionEnvelope(session: ChatSessionState, message: ChatOutboundPayload): ChatOutboundMessage { return { ...message, eventId: session.nextEventId++, sessionId: session.sessionId, }; } private sendEnvelopeToSessionSockets(session: ChatSessionState, envelope: ChatOutboundMessage, errorMessage: string) { for (const socket of session.sockets) { sendSocketEnvelope(this.logger, socket, envelope, errorMessage); } } private retainEnvelopeForReplay(session: ChatSessionState, envelope: ChatOutboundMessage) { if (envelope.type === 'chat:init' || envelope.type === 'chat:status') { return; } session.eventHistory.push(envelope); if (session.eventHistory.length > CHAT_SESSION_EVENT_HISTORY_LIMIT) { session.eventHistory.splice(0, session.eventHistory.length - CHAT_SESSION_EVENT_HISTORY_LIMIT); } } private persistConversationMessage(session: ChatSessionState, message: ChatMessage) { if (session.isDeleted) { return Promise.resolve(); } const nextPersistence = session.messagePersistenceTail .catch(() => undefined) .then(() => appendChatConversationMessage( { sessionId: session.sessionId, clientId: session.clientId, title: null, contextLabel: session.context?.chatTypeLabel ?? null, contextDescription: session.context?.chatTypeDescription ?? null, notifyOffline: undefined, }, { sessionId: session.sessionId, messageId: message.id, author: message.author, text: message.text, timestamp: message.timestamp, clientRequestId: message.clientRequestId ?? null, parts: message.parts ?? [], }, ), ); session.messagePersistenceTail = nextPersistence.catch((error: unknown) => { this.logger.error(error, 'failed to persist chat message'); }); return session.messagePersistenceTail; } private sendToSession( session: ChatSessionState, message: ChatOutboundPayload, options?: { skipOfflineNotification?: boolean; }, ) { if (session.isDeleted) { const normalizedDeletedMessage = message.type === 'chat:message' ? { ...message, payload: normalizeStructuredChatMessage(message.payload), } : message; return this.createSessionEnvelope(session, normalizedDeletedMessage); } const normalizedMessage = message.type === 'chat:message' ? { ...message, payload: normalizeStructuredChatMessage(message.payload), } : message; const envelope = this.createSessionEnvelope(session, normalizedMessage); this.retainEnvelopeForReplay(session, envelope); this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send websocket session envelope'); if (normalizedMessage.type === 'chat:message') { this.persistConversationMessage(session, normalizedMessage.payload); if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) { void this.sendOfflineNotificationBestEffort(session, normalizedMessage.payload); } } return envelope; } private async sendOfflineNotificationBestEffort(session: ChatSessionState, message: ChatMessage) { try { await this.sendOfflineNotificationIfNeeded(session, message); } catch (error: unknown) { this.logger.error(error, 'failed to send offline chat notification; request processing will continue'); } } private updateMessageInSession(session: ChatSessionState, message: ChatMessage) { const normalizedMessage = normalizeStructuredChatMessage(message); const envelope = this.createSessionEnvelope(session, { type: 'chat:message:update', payload: normalizedMessage, }); this.retainEnvelopeForReplay(session, envelope); this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send websocket message update envelope'); // Streaming codex deltas and synthesized activity summaries are transient UI state. // Persist only the final chat message / activity rows to avoid long DB tails that // can keep a finished request looking "running" until every intermediate update flushes. if (shouldPersistMessageUpdate(normalizedMessage)) { this.persistConversationMessage(session, normalizedMessage); } return envelope; } broadcastRequestUpdate(sessionId: string, request: ChatConversationRequestItem) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } for (const session of this.sessions.values()) { if (session.sessionId !== normalizedSessionId) { continue; } this.sendToSession(session, { type: 'chat:request:update', payload: request, }); } } broadcastMessageUpdate(sessionId: string, message: ChatMessage) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } for (const session of this.sessions.values()) { if (session.sessionId !== normalizedSessionId) { continue; } this.updateMessageInSession(session, message); } } private broadcastRuntimeSnapshot(snapshot = chatRuntimeService.getSnapshot()) { for (const session of this.sessions.values()) { this.sendToSession(session, { type: 'chat:runtime', payload: snapshot, }); this.pushRuntimeDetail(session); } } private broadcastNotificationMessageChange(event: NotificationMessageChangeEvent) { for (const session of this.sessions.values()) { const payload = event.action === 'deleted' ? { action: 'deleted' as const, itemId: event.id, } : { action: event.action, itemId: event.item.id, category: event.item.category, read: event.item.read, }; const envelope = this.createSessionEnvelope(session, { type: 'notification:messages-updated', payload, }); this.sendEnvelopeToSessionSockets(session, envelope, 'failed to send notification message update envelope'); } } getSessionClientIdMap() { return new Map( [...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]), ); } private pushRuntimeDetail(session: ChatSessionState) { const requestId = session.watchedRuntimeRequestId?.trim(); if (!requestId) { return; } this.sendToSession(session, { type: 'chat:runtime:detail', payload: chatRuntimeService.getJobDetail(requestId), }); } private async cancelRuntimeJob(requestId: string) { const execution = activeChatProcessRegistry.get(requestId); const detail = chatRuntimeService.getJobDetail(requestId); if (!execution && detail.item && detail.terminalStatus == null) { chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.'); this.cancelledRequestIds.add(requestId); const session = this.findSessionByRequestId(requestId); if (session) { void upsertChatConversationRequest(session.sessionId, { requestId, status: 'cancelled', statusMessage: '사용자 요청으로 실행 취소를 대기합니다.', }).catch((error: unknown) => { this.logger.warn(error, 'failed to persist pending chat request cancellation state'); }); } return true; } if (!execution) { return false; } chatRuntimeService.appendLog(requestId, '사용자 요청으로 실행 취소를 시도합니다.'); this.cancelledRequestIds.add(requestId); const session = this.findSessionByRequestId(requestId); if (session) { void upsertChatConversationRequest(session.sessionId, { requestId, status: 'cancelled', statusMessage: '사용자 요청으로 실행 취소를 시도합니다.', }).catch((error: unknown) => { this.logger.warn(error, 'failed to persist chat request cancellation state'); }); } try { const cancelled = await execution.cancel(); if (!cancelled && this.cancelledRequestIds.has(requestId)) { chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.'); return true; } return cancelled; } catch (error) { this.logger.warn(error, 'failed to cancel chat runtime job'); chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.'); this.cancelledRequestIds.delete(requestId); return false; } } private async removeQueuedRuntimeJob(requestId: string) { let removed = false; let removedSessionId: string | null = null; for (const session of this.sessions.values()) { const nextQueue = session.queue.filter((item) => item.requestId !== requestId); if (nextQueue.length === session.queue.length) { continue; } session.queue = nextQueue; removed = true; removedSessionId = session.sessionId; this.emitJobState(session, { requestId, status: 'completed', mode: 'queue', queueSize: session.queue.length, message: '대기열에서 제거됨', }); this.sendToSession(session, { type: 'chat:message', payload: createMessage('system', '대기열 요청이 관리 화면에서 제거되었습니다.'), }); break; } if (!removed) { return false; } chatRuntimeService.finishJob(requestId, 'removed'); if (removedSessionId) { await upsertChatConversationRequest(removedSessionId, { requestId, status: 'removed', statusMessage: '대기열에서 제거되었습니다.', }); } return true; } private tryStartNextQueuedRequest(session: ChatSessionState) { this.normalizeSessionExecutionState(session); if (session.activeRequestCount > 0 || session.queue.length === 0 || session.pendingQueueReleaseEventId !== null) { return; } const nextQueuedRequest = session.queue.shift(); if (!nextQueuedRequest) { return; } void this.executeRequest(session, nextQueuedRequest).catch((error: unknown) => { this.logger.error(error, 'queued chat reply build failed'); this.sendToSession(session, { type: 'chat:error', payload: { message: '대기 중이던 채팅 요청 처리 중 오류가 발생했습니다.', }, }); }); } private clearPendingQueueReleaseTimer(session: ChatSessionState) { if (session.pendingQueueReleaseTimer !== null) { clearTimeout(session.pendingQueueReleaseTimer); session.pendingQueueReleaseTimer = null; } } private releaseQueuedRequests(session: ChatSessionState) { session.pendingQueueReleaseEventId = null; this.clearPendingQueueReleaseTimer(session); this.tryStartNextQueuedRequest(session); } private schedulePendingQueueRelease(session: ChatSessionState) { this.clearPendingQueueReleaseTimer(session); session.pendingQueueReleaseTimer = setTimeout(() => { if (session.pendingQueueReleaseEventId === null) { session.pendingQueueReleaseTimer = null; return; } this.releaseQueuedRequests(session); }, QUEUE_RELEASE_FALLBACK_DELAY_MS); } private normalizeSessionExecutionState(session: ChatSessionState) { const snapshot = chatRuntimeService.getSnapshot(); const runtimeSession = snapshot.sessions.find((item) => item.sessionId === session.sessionId) ?? null; const runtimeRunningCount = runtimeSession?.runningCount ?? 0; const runtimeQueuedIds = new Set( snapshot.queued.filter((item) => item.sessionId === session.sessionId).map((item) => item.requestId), ); session.activeRequestCount = runtimeRunningCount; session.queue = session.queue.filter((item) => runtimeQueuedIds.has(item.requestId)); } private emitJobState( session: ChatSessionState, payload: { requestId: string; status: 'queued' | 'started' | 'completed' | 'failed'; mode: 'queue' | 'direct'; queueSize: number; message: string; }, ) { this.sendToSession(session, { type: 'chat:job', payload, }); void updateChatConversationJobState(session.sessionId, { requestId: payload.requestId, status: payload.status, message: payload.message, queueSize: payload.queueSize, clear: false, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat job state'); }); const requestStatus = payload.status === 'queued' ? 'queued' : payload.status === 'started' ? 'started' : payload.status === 'completed' ? 'completed' : this.cancelledRequestIds.has(payload.requestId) ? 'cancelled' : 'failed'; void upsertChatConversationRequest(session.sessionId, { requestId: payload.requestId, status: requestStatus, statusMessage: payload.message, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat request state'); }); } private findSessionByRequestId(requestId: string) { for (const session of this.sessions.values()) { if (session.queue.some((item) => item.requestId === requestId)) { return session; } if ( session.eventHistory.some((event) => { if (event.type === 'chat:job') { return event.payload.requestId === requestId; } if (event.type === 'chat:message' || event.type === 'chat:message:update') { return event.payload.clientRequestId === requestId; } return false; }) ) { return session; } } return null; } private async sendOfflineNotificationIfNeeded(session: ChatSessionState, message: ChatMessage) { if (!session.clientId?.trim()) { return; } if (message.author !== 'codex' || isPreparingChatReply(message.text)) { return; } const appOrigin = resolveChatContextAppOrigin(session.context); const appDomain = resolveChatContextAppDomain(session.context); const [conversation, appConfig] = await Promise.all([ getChatConversation(session.sessionId, session.clientId), getAppConfigSnapshot(appOrigin), ]); if ( !shouldSendOfflineChatNotification({ receiveRoomNotifications: appConfig.chat?.receiveRoomNotifications, conversationNotifyOffline: conversation?.notifyOffline, }) ) { return; } const requestOwnerClientId = message.clientRequestId?.trim() ? (await getChatConversationRequest(session.sessionId, message.clientRequestId).catch(() => null)) ?.requesterClientId?.trim() || null : null; if (!requestOwnerClientId) { return; } const preferredNotificationClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId, { keepClientIds: [session.clientId, requestOwnerClientId], }).catch(() => []); const notificationCandidateClientIds = collectOfflineNotificationClientIds([ requestOwnerClientId, ...preferredNotificationClientIds, ]); const notificationTargetClientIds = notificationCandidateClientIds.filter((clientId) => { const evaluation = evaluateChatClientActiveViewing(clientId, this.sessions.values()); logChatClientActiveViewingEvaluation(this.logger, clientId, evaluation); return !evaluation.isActive; }); if (!notificationTargetClientIds.length) { this.logger.info( { sessionId: session.sessionId, messageId: message.id, requestOwnerClientId, notificationCandidateClientIds, }, 'chat offline notification skipped because every target client is actively viewing', ); return; } const notificationPayload = await this.buildOfflineChatNotificationPayload( session, message, conversation?.title || '현재 채팅방', ); if (!notificationPayload) { return; } await this.createOfflineAppNotificationIfNeeded(notificationPayload, notificationTargetClientIds); const result = await sendNotifications( { title: notificationPayload.title, body: notificationPayload.body, data: notificationPayload.data, threadId: notificationPayload.threadId, targetClientIds: notificationTargetClientIds, targetAppOrigins: appOrigin ? [appOrigin] : undefined, targetAppDomains: appDomain ? [appDomain] : undefined, }, { disableIos: true, }, ); if (!result.web.ok && !result.web.skipped) { this.logger.warn( { sessionId: session.sessionId, messageId: message.id, targetClientIds: notificationTargetClientIds, webPush: result.web, }, 'chat webpush delivery reported failures', ); } } private async createOfflineAppNotificationIfNeeded( payload: { title: string; body: string; previewText: string; metadata: Record; linkUrl: string; }, targetClientIds: string[], ) { await createNotificationMessage({ title: payload.title, body: payload.body, category: 'chat', source: 'codex-live', priority: 'normal', metadata: { ...payload.metadata, targetClientIds, linkUrl: payload.linkUrl, linkLabel: '채팅 바로 열기', previewText: payload.previewText, }, }); } private async buildOfflineChatNotificationPayload( session: ChatSessionState, message: ChatMessage, conversationTitle: string, ) { const answerText = String(message.text ?? '').trim(); if (!answerText || isPreparingChatReply(answerText)) { return null; } const linkUrl = buildChatNotificationTargetUrl(session.context, session.sessionId); const requests = message.clientRequestId?.trim() ? await listChatConversationRequests(session.sessionId, 200).catch(() => []) : []; const matchingRequest = requests.find((request) => request.requestId === message.clientRequestId) ?? requests.find((request) => request.responseMessageId === message.id) ?? null; const questionText = matchingRequest?.userText?.trim() || ''; const fallback = createChatNotificationPreview(answerText) || `${conversationTitle} 채팅방에 새 응답이 도착했습니다.`; const body = createChatQuestionAnswerNotificationBody({ questionText, answerText, fallback, }); const previewText = createChatQuestionOnlyNotificationPreview(questionText, fallback); if (!body.trim()) { return null; } const appOrigin = resolveChatContextAppOrigin(session.context); const appDomain = resolveChatContextAppDomain(session.context); const metadata = { category: 'chat', sessionId: session.sessionId, conversationTitle, messageId: String(message.id), messageTimestamp: message.timestamp, questionText: normalizeNotificationDetailText(questionText) ?? '', answerText: normalizeNotificationDetailText(answerText) ?? '', targetUrl: linkUrl, notificationKey: `chat:${session.sessionId}:${message.id}`, type: 'chat-reply', suppressIfVisible: 'true', appOrigin: normalizeNotificationDetailText(appOrigin) ?? '', appDomain: normalizeNotificationDetailText(appDomain) ?? '', }; return { title: 'Codex Live 새 메시지', body, previewText, linkUrl, threadId: `chat:${session.sessionId}`, data: metadata, metadata, }; } private replaySessionHistory(session: ChatSessionState, lastEventId: number) { if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) { return; } const pendingEnvelopes = session.eventHistory.filter((envelope) => envelope.eventId > lastEventId); for (const envelope of pendingEnvelopes) { this.sendEnvelopeToSessionSockets(session, envelope, 'failed to replay websocket session history envelope'); } } private async initializeSession(session: ChatSessionState) { if (session.isDeleted) { return; } await session.messagePersistenceTail.catch(() => undefined); const messages = await listChatConversationMessages(session.sessionId, { limit: 500 }); const initEnvelope = this.createSessionEnvelope(session, { type: 'chat:init', payload: { messages, }, }); this.sendEnvelopeToSessionSockets(session, initEnvelope, 'failed to send websocket init envelope'); const statusEnvelope = this.createSessionEnvelope(session, { type: 'chat:status', payload: { connectedAt: new Date().toISOString(), }, }); this.sendEnvelopeToSessionSockets(session, statusEnvelope, 'failed to send websocket status envelope'); this.sendToSession(session, { type: 'chat:runtime', payload: chatRuntimeService.getSnapshot(), }); } private async handleConnection(socket: WebSocket, request: IncomingMessage) { const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; const url = new URL(request.url ?? '/', origin); if (!(await hasAuthorizedChatSocketAccess(request, url))) { closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized'); return; } const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId(); const clientId = url.searchParams.get('clientId')?.trim() || null; const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0); const lastEventId = Number.isFinite(lastEventIdRaw) && lastEventIdRaw > 0 ? lastEventIdRaw : 0; const session = this.getOrCreateSession(requestedSessionId, clientId); session.sockets.add(socket); session.lastSeenAt = Date.now(); this.clientStates.set(socket, session); trackWebSocketConnectionOpened(); socket.on('message', (raw: RawData) => { this.handleMessage(socket, raw); }); socket.on('close', () => { this.clientStates.delete(socket); session.sockets.delete(socket); trackWebSocketConnectionClosed(); }); socket.on('error', (error: Error) => { this.logger.error(error, 'chat websocket error'); this.clientStates.delete(socket); session.sockets.delete(socket); trackWebSocketConnectionClosed(); }); await this.initializeSession(session); this.replaySessionHistory(session, lastEventId); } async forgetSession(sessionId: string) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } const session = this.sessions.get(normalizedSessionId); const runtimeSnapshot = chatRuntimeService.getSnapshot(); const runtimeRequestIds = new Set( [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent] .filter((item) => item.sessionId === normalizedSessionId) .map((item) => item.requestId), ); if (session) { session.isDeleted = true; session.queue = []; session.eventHistory = []; session.pendingQueueReleaseEventId = null; this.clearPendingQueueReleaseTimer(session); session.watchedRuntimeRequestId = null; session.activeRequestCount = 0; for (const socket of session.sockets) { this.clientStates.delete(socket); closeSocketSafely(this.logger, socket, 'failed to close deleted chat websocket session'); } session.sockets.clear(); this.sessions.delete(normalizedSessionId); } for (const requestId of runtimeRequestIds) { const detail = chatRuntimeService.getJobDetail(requestId); if (detail.availableActions.cancel) { try { await this.cancelRuntimeJob(requestId); } catch { // ignore and hard-clear runtime state below } } else if (detail.availableActions.remove) { try { await this.removeQueuedRuntimeJob(requestId); } catch { // ignore and hard-clear runtime state below } } activeChatProcessRegistry.delete(requestId); this.cancelledRequestIds.delete(requestId); } chatRuntimeService.clearSession(normalizedSessionId); } resetSessionData(sessionId: string) { const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { return; } const session = this.sessions.get(normalizedSessionId); if (!session) { return; } session.queue = []; session.eventHistory = []; session.pendingQueueReleaseEventId = null; this.clearPendingQueueReleaseTimer(session); session.watchedRuntimeRequestId = null; session.activeRequestCount = 0; } private handleMessage(socket: WebSocket, raw: RawData) { try { const message = JSON.parse(raw.toString()) as ChatInboundMessage; const session = this.clientStates.get(socket); if (session) { session.lastSeenAt = Date.now(); } if (message.type === 'context:update') { this.handleContextUpdate(socket, message.payload); return; } if (message.type === 'presence:ping') { return; } if (message.type === 'event:received') { const receivedEventId = Math.round(Number(message.payload?.eventId ?? 0)); if ( session && Number.isFinite(receivedEventId) && receivedEventId > 0 && session.pendingQueueReleaseEventId !== null && receivedEventId >= session.pendingQueueReleaseEventId ) { this.releaseQueuedRequests(session); } return; } if (message.type === 'message:send') { this.rebindSocketToSession(socket, message.payload.sessionId); void this.handleUserMessage( socket, message.payload.text, message.payload.requestId, message.payload.mode === 'direct' ? 'direct' : 'queue', { codexModel: message.payload.codexModel ?? null, codexParticipants: message.payload.codexParticipants, chatTypeId: message.payload.chatTypeId ?? null, chatTypeLabel: message.payload.chatTypeLabel, chatTypeDescription: message.payload.chatTypeDescription, chatTypeBaseDescription: message.payload.chatTypeBaseDescription, defaultContextIds: message.payload.defaultContextIds, defaultContexts: message.payload.defaultContexts, customContextTitle: message.payload.customContextTitle, customContextContent: message.payload.customContextContent, }, { omitPromptHistory: message.payload.omitPromptHistory === true, requestOrigin: message.payload.requestOrigin === 'prompt' || message.payload.requestOrigin === 'composer' ? message.payload.requestOrigin : undefined, parentRequestId: message.payload.parentRequestId, promptContextRef: normalizeChatPromptContextRef(message.payload.promptContextRef), }, ).catch((error: unknown) => { this.logger.error(error, 'chat reply build failed'); const session = this.clientStates.get(socket); if (!session) { return; } this.sendToSession(session, { type: 'chat:error', payload: { message: error instanceof Error && error.message.trim() ? error.message.trim() : '채팅 응답을 만드는 중 오류가 발생했습니다.', }, }); }); return; } if (message.type === 'runtime:watch') { if (!session) { return; } session.watchedRuntimeRequestId = message.payload?.requestId?.trim() || null; this.pushRuntimeDetail(session); } } catch (error) { this.logger.warn(error, 'invalid chat websocket payload'); const session = this.clientStates.get(socket); if (!session) { return; } this.sendToSession(session, { type: 'chat:error', payload: { message: '채팅 메시지를 처리하지 못했습니다.', }, }); } } private handleContextUpdate(socket: WebSocket, context: ChatContext) { const state = this.clientStates.get(socket); if (!state) { return; } const previousFocus = state.context?.focusedComponentId; const previousContextLabel = state.context?.chatTypeLabel ?? null; const previousContextDescription = state.context?.chatTypeDescription ?? null; const previousCodexModel = state.context?.codexModel ?? null; state.context = context; const nextContextLabel = context.chatTypeLabel ?? null; const nextContextDescription = context.chatTypeDescription ?? null; const nextCodexModel = context.codexModel ?? null; if ( previousContextLabel !== nextContextLabel || previousContextDescription !== nextContextDescription || previousCodexModel !== nextCodexModel ) { void updateChatConversationContext(state.sessionId, { clientId: state.clientId, codexModel: nextCodexModel, contextLabel: nextContextLabel, contextDescription: nextContextDescription, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat context'); }); } if (context.focusedComponentId && context.focusedComponentId !== previousFocus) { this.sendToSession(state, { type: 'chat:message', payload: createMessage('codex', `선택 포커스가 ${context.focusedComponentId}로 이동했습니다. 관련 문맥을 반영합니다.`), }); } } private rebindSocketToSession(socket: WebSocket, targetSessionId?: string | null) { const normalizedTargetSessionId = targetSessionId?.trim() || ''; const currentSession = this.clientStates.get(socket); if (!currentSession || !normalizedTargetSessionId || currentSession.sessionId === normalizedTargetSessionId) { return currentSession ?? null; } const targetSession = this.getOrCreateSession(normalizedTargetSessionId, currentSession.clientId); currentSession.sockets.delete(socket); targetSession.sockets.add(socket); targetSession.lastSeenAt = Date.now(); this.clientStates.set(socket, targetSession); return targetSession; } private async handleUserMessage( socket: WebSocket, text: string, requestId?: string, mode: 'queue' | 'direct' = 'queue', contextOverride?: Partial | null, requestOptions?: { omitPromptHistory?: boolean; requestOrigin?: 'composer' | 'prompt'; sharedResourceTokenId?: string | null; parentRequestId?: string | null; promptContextRef?: ChatPromptContextRef | null; }, ) { const state = this.clientStates.get(socket); if (!state) { return; } await this.submitUserMessageToSession(state, text, requestId, mode, contextOverride, requestOptions); } async submitExternalMessage( sessionId: string, text: string, options?: { requestId?: string; mode?: 'queue' | 'direct'; requestOrigin?: 'composer' | 'prompt'; sharedResourceTokenId?: string | null; parentRequestId?: string | null; promptContextRef?: ChatPromptContextRef | null; omitPromptHistory?: boolean; contextOverride?: Partial | null; clientId?: string | null; }, ) { const normalizedSessionId = sessionId.trim(); const trimmedText = text.trim(); if (!normalizedSessionId || !trimmedText) { return null; } const conversation = await getChatConversation(normalizedSessionId, options?.clientId ?? null); const parentRequest = options?.parentRequestId?.trim() ? await getChatConversationRequest(normalizedSessionId, options.parentRequestId.trim()) : null; const session = this.getOrCreateSession( normalizedSessionId, conversation?.clientId ?? parentRequest?.requesterClientId ?? options?.clientId ?? null, ); session.clientId = conversation?.clientId?.trim() || parentRequest?.requesterClientId?.trim() || options?.clientId?.trim() || session.clientId; const baseContext: Partial = { pageId: null, pageTitle: '', topMenu: '', focusedComponentId: null, pageUrl: '', codexModel: conversation?.codexModel ?? null, chatTypeId: parentRequest?.chatTypeId ?? conversation?.chatTypeId ?? conversation?.lastChatTypeId ?? null, chatTypeLabel: parentRequest?.chatTypeLabel ?? conversation?.contextLabel ?? '', chatTypeDescription: conversation?.contextDescription ?? '', }; return this.submitUserMessageToSession( session, trimmedText, options?.requestId, options?.mode === 'direct' ? 'direct' : 'queue', { ...baseContext, ...(options?.contextOverride ?? {}), }, { omitPromptHistory: options?.omitPromptHistory, requestOrigin: options?.requestOrigin, sharedResourceTokenId: options?.sharedResourceTokenId, parentRequestId: options?.parentRequestId, promptContextRef: options?.promptContextRef, }, ); } private async submitUserMessageToSession( state: ChatSessionState, text: string, requestId?: string, mode: 'queue' | 'direct' = 'queue', contextOverride?: Partial | null, requestOptions?: { omitPromptHistory?: boolean; requestOrigin?: 'composer' | 'prompt'; sharedResourceTokenId?: string | null; parentRequestId?: string | null; promptContextRef?: ChatPromptContextRef | null; }, ) { const trimmed = text.trim(); if (!trimmed) { return null; } if (isRuntimeDraining()) { this.sendToSession(state, { type: 'chat:error', payload: { message: '현재 서버가 배포 전환 중이라 새 AI 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.', }, }); return null; } if (contextOverride) { const mergedContext = { ...(state.context ?? { pageId: null, pageTitle: '', topMenu: '', focusedComponentId: null, pageUrl: '', }), ...contextOverride, }; state.context = (await resolveCodexLiveChatContext(mergedContext, state.sessionId)) ?? mergedContext; void updateChatConversationContext(state.sessionId, { clientId: state.clientId, codexModel: state.context.codexModel ?? null, chatTypeId: state.context.chatTypeId ?? null, lastChatTypeId: state.context.chatTypeId ?? null, contextLabel: state.context.chatTypeLabel ?? null, contextDescription: state.context.chatTypeDescription ?? null, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist chat context from message send'); }); } this.normalizeSessionExecutionState(state); const nextRequestId = requestId?.trim() || createRequestId(); const requestedAtMs = Date.now(); const requestOrigin: 'composer' | 'prompt' = requestOptions?.requestOrigin === 'prompt' ? 'prompt' : 'composer'; const request: { requestId: string; text: string; mode: 'queue' | 'direct'; requestedAtMs: number; requestOrigin: 'composer' | 'prompt'; sharedResourceTokenId: string | null; parentRequestId: string | null; promptContextRef: ChatPromptContextRef | null; omitPromptHistory?: boolean; context: ChatContext | null; } = { requestId: nextRequestId, text: trimmed, mode, requestedAtMs, requestOrigin, sharedResourceTokenId: requestOptions?.sharedResourceTokenId?.trim() || null, parentRequestId: requestOptions?.parentRequestId?.trim() || null, promptContextRef: normalizeChatPromptContextRef(requestOptions?.promptContextRef), omitPromptHistory: requestOptions?.omitPromptHistory === true, context: cloneChatContext(state.context), }; if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) { const queuedUserMessage = { ...createMessage('user', trimmed, nextRequestId), timestamp: formatTime(new Date(requestedAtMs)), }; this.sendToSession(state, { type: 'chat:message', payload: queuedUserMessage, }); state.queue.push(request); chatRuntimeService.enqueueJob({ sessionId: state.sessionId, requestId: nextRequestId, mode, text: trimmed, }); chatRuntimeService.registerQueuedControl(nextRequestId, { remove: () => this.removeQueuedRuntimeJob(nextRequestId), }); this.emitJobState(state, { requestId: nextRequestId, status: 'queued', mode, queueSize: state.queue.length, message: `대기열 ${state.queue.length}건`, }); void upsertChatConversationRequest(state.sessionId, { requestId: nextRequestId, chatTypeId: request.context?.chatTypeId ?? state.context?.chatTypeId ?? null, chatTypeLabel: request.context?.chatTypeLabel ?? state.context?.chatTypeLabel ?? null, requestOrigin: request.requestOrigin, sharedResourceTokenId: request.sharedResourceTokenId, parentRequestId: request.parentRequestId, promptContextRef: request.promptContextRef, status: 'queued', statusMessage: `대기열 ${state.queue.length}건`, userMessageId: queuedUserMessage.id, userText: trimmed, }).catch((error: unknown) => { this.logger.error(error, 'failed to persist queued chat request'); }); return nextRequestId; } void this.executeRequest(state, request).catch((error: unknown) => { this.logger.error(error, 'direct chat reply build failed'); this.sendToSession(state, { type: 'chat:error', payload: { message: '즉시 채팅 요청 처리 중 오류가 발생했습니다.', }, }); }); return nextRequestId; } private async executeRequest( session: ChatSessionState, request: { requestId: string; text: string; mode: 'queue' | 'direct'; requestedAtMs: number; requestOrigin?: 'composer' | 'prompt'; sharedResourceTokenId?: string | null; parentRequestId?: string | null; promptContextRef?: ChatPromptContextRef | null; omitPromptHistory?: boolean; context: ChatContext | null; }, ) { let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; let hasAnnouncedStreaming = false; const compactActivityLineMap = new Map(); session.activeRequestCount += 1; const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId); const isRetryAttempt = existingRequest != null && !existingRequest.hasResponse && (existingRequest.status === 'failed' || existingRequest.status === 'cancelled'); const hasStoredUserMessage = existingRequest?.userMessageId != null; let userMessageId = existingRequest?.userMessageId ?? null; if (!hasStoredUserMessage) { const userMessage = { ...createMessage('user', request.text, request.requestId), timestamp: formatTime(new Date(request.requestedAtMs)), }; userMessageId = userMessage.id; this.sendToSession(session, { type: 'chat:message', payload: userMessage, }); } await upsertChatConversationRequest(session.sessionId, { requestId: request.requestId, chatTypeId: request.context?.chatTypeId ?? session.context?.chatTypeId ?? null, chatTypeLabel: request.context?.chatTypeLabel ?? session.context?.chatTypeLabel ?? null, requestOrigin: request.requestOrigin, sharedResourceTokenId: request.sharedResourceTokenId, parentRequestId: request.parentRequestId, promptContextRef: request.promptContextRef, status: request.mode === 'direct' ? 'accepted' : existingRequest?.status ?? 'queued', statusMessage: isRetryAttempt ? '재처리 요청 접수' : undefined, incrementRetryCount: isRetryAttempt, allowTerminalStatusReset: isRetryAttempt, userMessageId, userText: request.text, }); const appendActivityLineAt = (lineNo: number, line: string) => { const normalizedLine = normalizeProgressSummary(line); if (!normalizedLine || lineNo <= 0) { return; } const previousLine = compactActivityLineMap.get(lineNo); if (previousLine === normalizedLine) { return; } compactActivityLineMap.set(lineNo, normalizedLine); const activityLines = Array.from(compactActivityLineMap.entries()) .sort((left, right) => left[0] - right[0]) .map(([, activityLine]) => activityLine); void appendChatConversationActivityLine( session.sessionId, request.requestId, normalizedLine, lineNo, ).catch((error: unknown) => { this.logger.warn(error, 'failed to persist compact chat activity line'); }); this.sendToSession(session, { type: 'chat:activity', payload: { requestId: request.requestId, line: normalizedLine, lineCount: activityLines.length, lineNo, }, }); const activityMessage = createActivityLogMessage(request.requestId, activityLines); if (activityMessage) { this.updateMessageInSession(session, activityMessage); } }; const appendActivityLine = (line: string) => { const compactEntry = createCompactActivityLogEntry(line); if (!compactEntry) { return; } appendActivityLineAt(compactEntry.lineNo, compactEntry.line); }; chatRuntimeService.startJob({ sessionId: session.sessionId, requestId: request.requestId, mode: request.mode, text: request.text, }); chatRuntimeService.registerRunningControl(request.requestId, { cancel: () => this.cancelRuntimeJob(request.requestId), }); chatRuntimeService.appendLog(request.requestId, `요청을 처리합니다. mode=${request.mode}`); appendActivityLine(`# 상태: 요청을 처리합니다. mode=${request.mode}`); this.emitJobState(session, { requestId: request.requestId, status: 'started', mode: request.mode, queueSize: session.queue.length, message: request.mode === 'direct' ? '즉시 요청 실행 중' : `요청 처리 중${session.queue.length > 0 ? ` · 대기열 ${session.queue.length}건` : ''}`, }); this.sendToSession(session, { type: 'chat:message', payload: createMessage( 'system', request.mode === 'direct' ? '즉시 요청 실행 중입니다.' : `요청 실행 중입니다.${session.queue.length > 0 ? ` 남은 대기열 ${session.queue.length}건` : ''}`, request.requestId, ), }); const stopProgressTimer = () => { if (progressTimer !== null) { clearTimeout(progressTimer); progressTimer = null; } }; const progressMessages = buildProgressMessages(request.text); let progressIndex = progressMessages.length > 1 ? 1 : 0; let lastProgressMessage = progressMessages[0] ?? ''; let lastMeaningfulProgressSummary = ''; let progressTimer: ReturnType | null = null; const emitSystemProgressMessage = (nextMessage: string, options?: { appendActivity?: boolean }) => { const normalizedMessage = normalizeProgressSummary(nextMessage); if (!normalizedMessage || normalizedMessage === lastProgressMessage) { return false; } lastProgressMessage = normalizedMessage; chatRuntimeService.appendLog(request.requestId, normalizedMessage); if (options?.appendActivity !== false) { appendActivityLine(`# 진행: ${normalizedMessage}`); } this.sendToSession(session, { type: 'chat:message', payload: createMessage('system', normalizedMessage, request.requestId), }); return true; }; const scheduleFallbackProgressMessage = () => { if (progressIndex >= progressMessages.length) { return; } stopProgressTimer(); progressTimer = setTimeout(() => { const nextMessage = progressMessages[progressIndex] ?? ''; if (!nextMessage || lastMeaningfulProgressSummary) { stopProgressTimer(); return; } if (emitSystemProgressMessage(nextMessage)) { progressIndex += 1; } stopProgressTimer(); }, 2200); }; const executionContext = request.context ?? session.context ?? null; const codexParticipants = resolveCodexParticipantsForExecution(executionContext); const codexExecutionStages = resolveCodexExecutionStages(executionContext, request.mode); const shouldShowCodexExecutionPlan = request.mode === 'direct' && codexParticipants.length > 1; const planSteps = shouldShowCodexExecutionPlan ? buildCodexExecutionPlanSteps(codexExecutionStages) : []; const executorSlots = shouldShowCodexExecutionPlan ? buildCodexExecutorActivitySlots(codexParticipants, 12 + planSteps.length + 2) : []; const planStepsByParticipantKey = new Map(); const completedPlanParticipantKeys = new Set(); const executorSlotByParticipantId = new Map(); planSteps.forEach((step) => { step.participantKeys?.forEach((participantKey) => { const currentSteps = planStepsByParticipantKey.get(participantKey) ?? []; currentSteps.push(step); planStepsByParticipantKey.set(participantKey, currentSteps); }); }); executorSlots.forEach((slot) => { executorSlotByParticipantId.set(slot.participantId, slot); }); const syncPlanStepCompletion = (step: CodexExecutionPlanStep) => { if (!step.participantKeys || step.participantKeys.length === 0) { return; } writePlanStep( step, step.participantKeys.every((participantKey) => completedPlanParticipantKeys.has(participantKey)), ); }; const writePlanStep = (step: CodexExecutionPlanStep, completed: boolean) => { appendActivityLineAt(step.lineNo, `${completed ? '☑' : '☐'} ${step.label}`); }; const writeExecutorActivity = (participantId: string, status: string) => { const slot = executorSlotByParticipantId.get(participantId); if (!slot) { return; } appendActivityLineAt(slot.lineNo, `실행기: ${slot.label} · ${normalizeProgressSummary(status) || '대기 중'}`); }; if (shouldShowCodexExecutionPlan) { appendActivityLineAt(10, '# 계획: 관리자 계획'); planSteps.forEach((step) => { writePlanStep(step, false); }); appendActivityLineAt(12 + planSteps.length, '# 실행기: 진행 현황'); executorSlots.forEach((slot) => { appendActivityLineAt(slot.lineNo, `실행기: ${slot.label} · 대기 중`); }); } const participantTypeOverrides = await resolveCodexParticipantTypeOverrides( codexParticipants, executionContext, ); const completedParticipantReplies: Array<{ name: string; model: string; text: string }> = []; let finalCodexReplyMessage: ChatMessage | null = null; let finalUsageSnapshot: ChatConversationRequestUsageSnapshot | null = null; let finalTotalTokens: number | null = null; try { chatRuntimeService.appendLog(request.requestId, '요청 분석을 시작합니다.'); appendActivityLine('# 진행: 요청 분석을 시작합니다.'); this.sendToSession(session, { type: 'chat:message', payload: createMessage('system', progressMessages[0] ?? '요청을 분석하고 있습니다.', request.requestId), }); scheduleFallbackProgressMessage(); const runParticipant = async ( participant: (typeof codexParticipants)[number], previousReplies: Array<{ name: string; model: string; text: string }>, ) => { const participantPlanKey = buildCodexPlanParticipantKey(participant); const participantPlanSteps = planStepsByParticipantKey.get(participantPlanKey) ?? []; if (participantPlanSteps.length > 0) { const analysisStep = planSteps.find((step) => step.kind === 'analysis'); if (analysisStep) { writePlanStep(analysisStep, true); } } writeExecutorActivity(participant.id, '응답 준비 중'); const codexReplyMessage = createMessage('codex', '', request.requestId); const participantPrefix = `[${participant.name} · ${participant.model}]`; this.updateMessageInSession(session, { ...codexReplyMessage, text: `${participantPrefix}\n응답을 준비하고 있습니다...`, timestamp: resolveResponseTimestamp(request.requestedAtMs), }); const baseExecutionContext = request.context ?? session.context ?? null; const participantContextSeed: ChatContext | null = baseExecutionContext ? { ...baseExecutionContext, pageId: baseExecutionContext.pageId ?? null, pageTitle: baseExecutionContext.pageTitle ?? '', topMenu: baseExecutionContext.topMenu ?? '', focusedComponentId: baseExecutionContext.focusedComponentId ?? null, pageUrl: baseExecutionContext.pageUrl ?? '', codexModel: participant.model, ...(participant.chatTypeId ? { chatTypeId: participant.chatTypeId, } : null), ...(participant.defaultContextIds.length > 0 ? { defaultContextIds: participant.defaultContextIds, } : null), } : { pageId: null, pageTitle: '', topMenu: '', focusedComponentId: null, pageUrl: '', codexModel: participant.model, chatTypeId: participant.chatTypeId, defaultContextIds: participant.defaultContextIds, }; const participantExecutionContext = (await resolveCodexLiveChatContext(participantContextSeed, session.sessionId)) ?? participantContextSeed; if (participantExecutionContext) { participantExecutionContext.codexModel = participant.model; } const reply = await buildCodexReply( participantExecutionContext, buildParticipantRequestInput( request.text, participant, codexParticipants, previousReplies, participant.chatTypeId ? participantTypeOverrides.get(participant.chatTypeId) : null, resolveChatTypeExecutionPolicy(executionContext), request.promptContextRef, ), session.sessionId, request.requestId, { omitPromptHistory: request.omitPromptHistory === true, requestedAt: new Date(request.requestedAtMs), }, (partialReply) => { stopProgressTimer(); if (!hasAnnouncedStreaming) { hasAnnouncedStreaming = true; chatRuntimeService.appendLog(request.requestId, '응답을 실시간으로 전송 중입니다.'); appendActivityLine('# 진행: 응답을 실시간으로 전송 중입니다.'); } this.updateMessageInSession(session, { ...codexReplyMessage, text: `${participantPrefix}\n${partialReply}`.trim(), timestamp: resolveResponseTimestamp(request.requestedAtMs), }); }, (activityLine) => { appendActivityLine(activityLine); const activitySummary = summarizeActivityProgressLine(activityLine); if (activitySummary) { writeExecutorActivity(participant.id, activitySummary); } if (activitySummary && activitySummary !== lastMeaningfulProgressSummary) { lastMeaningfulProgressSummary = activitySummary; stopProgressTimer(); emitSystemProgressMessage(activitySummary, { appendActivity: false }); } }, () => this.cancelledRequestIds.has(request.requestId), ); const extracted = extractChatMessageParts(reply.text); finalCodexReplyMessage = { ...codexReplyMessage, text: `${participantPrefix}\n${extracted.strippedText}`.trim(), parts: extracted.parts, timestamp: resolveResponseTimestamp(request.requestedAtMs), }; this.sendToSession( session, { type: 'chat:message', payload: finalCodexReplyMessage, }, { skipOfflineNotification: true, }, ); await this.persistConversationMessage(session, finalCodexReplyMessage); finalUsageSnapshot = reply.usageSnapshot; finalTotalTokens = reply.totalTokens; completedPlanParticipantKeys.add(participantPlanKey); participantPlanSteps.forEach((step) => { syncPlanStepCompletion(step); }); writeExecutorActivity(participant.id, '처리 완료'); return { name: participant.name, model: participant.model, text: extracted.strippedText, }; }; for (const stage of codexExecutionStages) { const stagePreviousReplies = [...completedParticipantReplies]; if (stage.parallel) { const results = await Promise.allSettled( stage.participants.map((participant) => runParticipant(participant, stagePreviousReplies)), ); const rejected = results.find( (result): result is PromiseRejectedResult => result.status === 'rejected', ); if (rejected) { throw rejected.reason; } results.forEach((result) => { if (result.status === 'fulfilled') { completedParticipantReplies.push(result.value); } }); continue; } for (const participant of stage.participants) { const result = await runParticipant(participant, [...completedParticipantReplies]); completedParticipantReplies.push(result); } } const finalizeStep = planSteps.find((step) => step.kind === 'finalize'); if (finalizeStep) { writePlanStep(finalizeStep, true); } chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.'); appendActivityLine('# 상태: 응답 생성이 완료되었습니다.'); // Final replies must be durably stored before the request is marked complete, // otherwise replay/reload can be stuck on the placeholder text. if (!finalCodexReplyMessage) { throw new Error('Codex 참가자 응답을 만들지 못했습니다.'); } const completedReplyMessage = finalCodexReplyMessage as ChatMessage; await upsertChatConversationRequest(session.sessionId, { requestId: request.requestId, chatTypeId: request.context?.chatTypeId ?? session.context?.chatTypeId ?? null, chatTypeLabel: request.context?.chatTypeLabel ?? session.context?.chatTypeLabel ?? null, requestOrigin: request.requestOrigin, sharedResourceTokenId: request.sharedResourceTokenId, parentRequestId: request.parentRequestId, status: 'completed', statusMessage: '요청 처리 완료', responseMessageId: completedReplyMessage.id, responseText: completedReplyMessage.text, usageSnapshot: finalUsageSnapshot, totalTokens: finalTotalTokens, }); await refreshChatSessionReferenceForRequest({ sessionId: session.sessionId, requestId: request.requestId, context: request.context ?? session.context ?? null, input: request.text, requestStatus: 'completed', requestedAt: new Date(request.requestedAtMs), completedAt: new Date(), }); const terminalMessageEnvelope = this.sendToSession(session, { type: 'chat:message', payload: createMessage( 'system', request.mode === 'direct' ? '즉시 요청 처리가 끝났습니다.' : `요청 처리가 끝났습니다.${session.queue.length > 0 ? ` 대기열 ${session.queue.length}건이 이어집니다.` : ''}`, request.requestId, ), }); session.pendingQueueReleaseEventId = terminalMessageEnvelope.eventId; this.schedulePendingQueueRelease(session); this.emitJobState(session, { requestId: request.requestId, status: 'completed', mode: request.mode, queueSize: session.queue.length, message: '요청 처리 완료', }); chatRuntimeService.finishJob(request.requestId, 'completed'); await this.sendOfflineNotificationBestEffort(session, completedReplyMessage); } catch (error) { const finalizeStep = planSteps.find((step) => step.kind === 'finalize'); if (finalizeStep) { writePlanStep(finalizeStep, false); } const wasCancelled = this.cancelledRequestIds.has(request.requestId); terminalStatus = wasCancelled ? 'cancelled' : 'failed'; const failureResponseText = error instanceof ChatRuntimeExecutionError ? error.responseText : ''; const failureUsageSnapshot = error instanceof ChatRuntimeExecutionError ? error.usageSnapshot : null; const failureTotalTokens = error instanceof ChatRuntimeExecutionError ? error.totalTokens : null; if (failureResponseText) { const extractedFailureReply = extractChatMessageParts(failureResponseText); const failedCodexReplyBase = createMessage('codex', '', request.requestId); const failedCodexReplyMessage = { ...failedCodexReplyBase, text: extractedFailureReply.strippedText, parts: extractedFailureReply.parts, timestamp: resolveResponseTimestamp(request.requestedAtMs), }; this.sendToSession( session, { type: 'chat:message', payload: failedCodexReplyMessage, }, { skipOfflineNotification: true, }, ); await this.persistConversationMessage(session, failedCodexReplyMessage); await upsertChatConversationRequest(session.sessionId, { requestId: request.requestId, requestOrigin: request.requestOrigin, sharedResourceTokenId: request.sharedResourceTokenId, parentRequestId: request.parentRequestId, status: wasCancelled ? 'cancelled' : 'failed', statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패', responseMessageId: failedCodexReplyMessage.id, responseText: failedCodexReplyMessage.text, usageSnapshot: failureUsageSnapshot, totalTokens: failureTotalTokens, }); } else if (failureUsageSnapshot || failureTotalTokens != null) { await upsertChatConversationRequest(session.sessionId, { requestId: request.requestId, requestOrigin: request.requestOrigin, sharedResourceTokenId: request.sharedResourceTokenId, parentRequestId: request.parentRequestId, status: wasCancelled ? 'cancelled' : 'failed', statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패', usageSnapshot: failureUsageSnapshot, totalTokens: failureTotalTokens, }); } chatRuntimeService.appendLog( request.requestId, wasCancelled ? '사용자 요청으로 실행이 중단되었습니다.' : error instanceof Error ? `실행 오류: ${error.message}` : '실행 오류가 발생했습니다.', ); appendActivityLine( wasCancelled ? '# 상태: 사용자 요청으로 실행이 중단되었습니다.' : error instanceof Error ? `# 오류: ${error.message}` : '# 오류: 실행 오류가 발생했습니다.', ); const terminalErrorEnvelope = this.sendToSession(session, { type: 'chat:message', payload: createMessage( 'system', wasCancelled ? '요청 실행이 중단되었습니다.' : '요청 처리 중 오류가 발생했습니다.', request.requestId, ), }); session.pendingQueueReleaseEventId = terminalErrorEnvelope.eventId; this.schedulePendingQueueRelease(session); this.emitJobState(session, { requestId: request.requestId, status: 'failed', mode: request.mode, queueSize: session.queue.length, message: wasCancelled ? '요청 실행 중단' : '요청 처리 실패', }); await refreshChatSessionReferenceForRequest({ sessionId: session.sessionId, requestId: request.requestId, context: request.context ?? session.context ?? null, input: request.text, requestStatus: wasCancelled ? 'cancelled' : 'failed', requestedAt: new Date(request.requestedAtMs), completedAt: new Date(), }); throw error; } finally { stopProgressTimer(); activeChatProcessRegistry.delete(request.requestId); if (chatRuntimeService.getJobDetail(request.requestId).terminalStatus == null) { chatRuntimeService.finishJob(request.requestId, terminalStatus); } this.cancelledRequestIds.delete(request.requestId); session.activeRequestCount = Math.max(0, session.activeRequestCount - 1); this.tryStartNextQueuedRequest(session); } } }