type ChatRuntimeJobMode = 'queue' | 'direct'; type ChatRuntimeLifecycleStatus = 'queued' | 'running'; type ChatRuntimeTerminalStatus = 'completed' | 'failed' | 'cancelled' | 'removed'; type RuntimeJobControl = { cancel?: () => Promise | boolean; remove?: () => Promise | boolean; }; type RuntimeJobRecord = ChatRuntimeJobItem & { logs: string[]; lastUpdatedAt: string; terminalStatus: ChatRuntimeTerminalStatus | null; }; export type ChatRuntimeJobItem = { requestId: string; sessionId: string; mode: ChatRuntimeJobMode; status: ChatRuntimeLifecycleStatus; summary: string; enqueuedAt: string; startedAt: string | null; pid: number | null; }; export type ChatRuntimeSessionSummary = { sessionId: string; runningCount: number; queuedCount: number; latestRequestId: string | null; latestStatus: ChatRuntimeLifecycleStatus | null; }; export type ChatRuntimeSnapshot = { generatedAt: string; runningCount: number; queuedCount: number; sessionCount: number; running: ChatRuntimeJobItem[]; queued: ChatRuntimeJobItem[]; sessions: ChatRuntimeSessionSummary[]; recent: Array; }; export type ChatRuntimeJobDetail = { item: ChatRuntimeJobItem | null; logs: string[]; lastUpdatedAt: string | null; terminalStatus: ChatRuntimeTerminalStatus | null; availableActions: { cancel: boolean; remove: boolean; }; }; type RuntimeSubscriber = (snapshot: ChatRuntimeSnapshot) => void; const MAX_LOG_LINES = 80; const MAX_ARCHIVED_JOBS = 40; function nowIso() { return new Date().toISOString(); } function summarizeText(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 120 ? `${normalized.slice(0, 117).trimEnd()}...` : normalized; } function normalizeLogLine(line: string) { return String(line ?? '').replace(/\r/g, '').trimEnd(); } class ChatRuntimeService { private readonly queuedJobs = new Map(); private readonly runningJobs = new Map(); private readonly archivedJobs = new Map(); private readonly controls = new Map(); private readonly subscribers = new Set(); subscribe(listener: RuntimeSubscriber) { this.subscribers.add(listener); return () => { this.subscribers.delete(listener); }; } getSnapshot(): ChatRuntimeSnapshot { const running = [...this.runningJobs.values()].sort((left, right) => (left.startedAt ?? left.enqueuedAt).localeCompare(right.startedAt ?? right.enqueuedAt), ); const queued = [...this.queuedJobs.values()].sort((left, right) => left.enqueuedAt.localeCompare(right.enqueuedAt)); const sessionMap = new Map(); for (const item of [...running, ...queued]) { const current = sessionMap.get(item.sessionId) ?? { sessionId: item.sessionId, runningCount: 0, queuedCount: 0, latestRequestId: null, latestStatus: null, }; if (item.status === 'running') { current.runningCount += 1; } else { current.queuedCount += 1; } current.latestRequestId = item.requestId; current.latestStatus = item.status; sessionMap.set(item.sessionId, current); } const sessions = [...sessionMap.values()].sort((left, right) => { const loadDiff = right.runningCount + right.queuedCount - (left.runningCount + left.queuedCount); return loadDiff !== 0 ? loadDiff : left.sessionId.localeCompare(right.sessionId); }); return { generatedAt: nowIso(), runningCount: running.length, queuedCount: queued.length, sessionCount: sessions.length, running: running.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item), queued: queued.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item), sessions, recent: [...this.archivedJobs.values()] .sort((left, right) => right.lastUpdatedAt.localeCompare(left.lastUpdatedAt)) .slice(0, 12) .map(({ logs: _logs, ...item }) => ({ ...item, terminalStatus: item.terminalStatus ?? 'completed', })), }; } getJobDetail(requestId: string): ChatRuntimeJobDetail { const current = this.runningJobs.get(requestId) ?? this.queuedJobs.get(requestId) ?? this.archivedJobs.get(requestId) ?? null; return { item: current ? { requestId: current.requestId, sessionId: current.sessionId, mode: current.mode, status: current.status, summary: current.summary, enqueuedAt: current.enqueuedAt, startedAt: current.startedAt, pid: current.pid, } : null, logs: current?.logs ?? [], lastUpdatedAt: current?.lastUpdatedAt ?? null, terminalStatus: current?.terminalStatus ?? null, availableActions: { cancel: this.runningJobs.has(requestId) && this.controls.has(requestId), remove: this.queuedJobs.has(requestId) && this.controls.has(requestId), }, }; } registerQueuedControl(requestId: string, control: RuntimeJobControl) { this.controls.set(requestId, control); } registerRunningControl(requestId: string, control: RuntimeJobControl) { this.controls.set(requestId, control); } enqueueJob(args: { sessionId: string; requestId: string; mode: ChatRuntimeJobMode; text: string; }) { const existingRunning = this.runningJobs.get(args.requestId); if (existingRunning) { return existingRunning; } const item: RuntimeJobRecord = { requestId: args.requestId, sessionId: args.sessionId, mode: args.mode, status: 'queued', summary: summarizeText(args.text), enqueuedAt: nowIso(), startedAt: null, pid: null, logs: ['큐에 등록되었습니다.'], lastUpdatedAt: nowIso(), terminalStatus: null, }; this.archivedJobs.delete(args.requestId); this.queuedJobs.set(args.requestId, item); this.emit(); return item; } startJob(args: { sessionId: string; requestId: string; mode: ChatRuntimeJobMode; text: string; pid?: number | null; }) { const queuedItem = this.queuedJobs.get(args.requestId); const runningItem: RuntimeJobRecord = { requestId: args.requestId, sessionId: args.sessionId, mode: args.mode, status: 'running', summary: summarizeText(args.text), enqueuedAt: queuedItem?.enqueuedAt ?? nowIso(), startedAt: nowIso(), pid: args.pid == null ? null : Math.round(args.pid), logs: queuedItem?.logs ?? [], lastUpdatedAt: nowIso(), terminalStatus: null, }; runningItem.logs = [...runningItem.logs, '실행이 시작되었습니다.'].slice(-MAX_LOG_LINES); this.queuedJobs.delete(args.requestId); this.archivedJobs.delete(args.requestId); this.runningJobs.set(args.requestId, runningItem); this.emit(); return runningItem; } attachProcess(requestId: string, pid?: number | null) { const current = this.runningJobs.get(requestId); if (!current || pid == null) { return current ?? null; } const next: RuntimeJobRecord = { ...current, pid: Math.round(pid), lastUpdatedAt: nowIso(), logs: [...current.logs, `프로세스가 연결되었습니다. pid=${Math.round(pid)}`].slice(-MAX_LOG_LINES), }; this.runningJobs.set(requestId, next); this.emit(); return next; } appendLog(requestId: string, line: string) { const normalizedLine = normalizeLogLine(line); if (!normalizedLine) { return; } const current = this.runningJobs.get(requestId) ?? this.queuedJobs.get(requestId) ?? this.archivedJobs.get(requestId); if (!current) { return; } const next: RuntimeJobRecord = { ...current, logs: [...current.logs, normalizedLine].slice(-MAX_LOG_LINES), lastUpdatedAt: nowIso(), }; if (this.runningJobs.has(requestId)) { this.runningJobs.set(requestId, next); } else if (this.queuedJobs.has(requestId)) { this.queuedJobs.set(requestId, next); } else { this.archivedJobs.set(requestId, next); } this.emit(); } async cancelJob(requestId: string) { const control = this.controls.get(requestId); if (!this.runningJobs.has(requestId) || !control?.cancel) { return false; } const result = await control.cancel(); return result === true; } async removeQueuedJob(requestId: string) { const control = this.controls.get(requestId); if (!this.queuedJobs.has(requestId) || !control?.remove) { return false; } const result = await control.remove(); return result === true; } finishJob(requestId: string, terminalStatus: ChatRuntimeTerminalStatus = 'completed') { const removedRunning = this.runningJobs.get(requestId); const removedQueued = this.queuedJobs.get(requestId); const removed = removedRunning ?? removedQueued ?? null; this.runningJobs.delete(requestId); this.queuedJobs.delete(requestId); this.controls.delete(requestId); if (!removed) { return; } const archived: RuntimeJobRecord = { ...removed, lastUpdatedAt: nowIso(), terminalStatus, logs: [...removed.logs, this.buildTerminalLog(terminalStatus)].slice(-MAX_LOG_LINES), }; this.archivedJobs.delete(requestId); this.archivedJobs.set(requestId, archived); this.trimArchivedJobs(); this.emit(); } clearAll() { if ( this.runningJobs.size === 0 && this.queuedJobs.size === 0 && this.archivedJobs.size === 0 && this.controls.size === 0 ) { return; } this.runningJobs.clear(); this.queuedJobs.clear(); this.archivedJobs.clear(); this.controls.clear(); this.emit(); } private buildTerminalLog(status: ChatRuntimeTerminalStatus) { if (status === 'completed') { return '실행이 완료되었습니다.'; } if (status === 'failed') { return '실행이 실패로 종료되었습니다.'; } if (status === 'cancelled') { return '실행이 강제 취소되었습니다.'; } return '대기열에서 제거되었습니다.'; } private trimArchivedJobs() { while (this.archivedJobs.size > MAX_ARCHIVED_JOBS) { const firstKey = this.archivedJobs.keys().next().value; if (!firstKey) { return; } this.archivedJobs.delete(firstKey); } } private emit() { const snapshot = this.getSnapshot(); this.subscribers.forEach((listener) => { listener(snapshot); }); } } export const chatRuntimeService = new ChatRuntimeService();