import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { DATA_DIR, SERVICE_ID, TIMEZONE } from './config.js'; import { logger } from './logger.js'; import type { RegisteredGroup } from './types.js'; import { readJsonFile, writeJsonFile } from './utils.js'; export interface RestartInterruptedGroup { chatJid: string; groupName: string; status: 'processing' | 'idle' | 'waiting'; elapsedMs: number | null; pendingMessages: boolean; pendingTasks: number; } export interface RestartContext { chatJid: string; summary: string; verify: string[]; writtenAt: string; source?: 'explicit' | 'shutdown-snapshot'; signal?: string; interruptedGroups?: RestartInterruptedGroup[]; } export interface InferredRestartContext { chatJid: string; lines: string[]; } export interface RestartRecoveryCandidate { chatJid: string; groupFolder: string; status: RestartInterruptedGroup['status']; pendingMessages: boolean; pendingTasks: number; } const INFER_WINDOW_MS = 3 * 60 * 1000; function getRestartContextPath(serviceId: string = SERVICE_ID): string { return path.join(DATA_DIR, `restart-context.${serviceId}.json`); } function formatKoreanTimestamp(timestamp: string | number | Date): string { return new Date(timestamp).toLocaleString('ko-KR', { timeZone: TIMEZONE, hour12: false, }); } function getMainGroupJid( roomBindings: Record, ): string | null { const mainEntry = Object.entries(roomBindings).find( ([, group]) => group.isMain === true, ); return mainEntry?.[0] ?? null; } function readRestartContextFile(filePath: string): RestartContext | null { const data = readJsonFile(filePath); if (data === null && fs.existsSync(filePath)) { logger.warn( { filePath }, 'Failed to parse restart context file; ignoring invalid content', ); } return data; } function isRecoverableInterruptedGroup( group: RestartInterruptedGroup, ): boolean { return group.status === 'processing'; } export function getRecoverableInterruptedGroups( context: Pick | null, ): RestartInterruptedGroup[] { return (context?.interruptedGroups ?? []).filter( isRecoverableInterruptedGroup, ); } function getLatestDistBuildTime(): number | null { const distDir = path.join(process.cwd(), 'dist'); if (!fs.existsSync(distDir)) return null; let latestMtime = 0; for (const entry of fs.readdirSync(distDir)) { if (!entry.endsWith('.js')) continue; const stat = fs.statSync(path.join(distDir, entry)); latestMtime = Math.max(latestMtime, stat.mtimeMs); } return latestMtime > 0 ? latestMtime : null; } function getRecentCommit(processStartedAtMs: number): string | null { try { const sinceSeconds = Math.max( 60, Math.ceil(INFER_WINDOW_MS / 1000) + 30, ).toString(); const output = execSync( `git log --since='${sinceSeconds} seconds ago' -1 --format=%h%x20%s`, { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', }, ).trim(); if (!output) return null; const commitTime = execSync('git log -1 --format=%ct', { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', }).trim(); const commitTimeMs = Number(commitTime) * 1000; if ( Number.isFinite(commitTimeMs) && commitTimeMs >= processStartedAtMs - INFER_WINDOW_MS ) { return output; } } catch { // Ignore git inference failures on deployed environments without .git. } return null; } export function writeRestartContext( context: Omit, serviceIds: string[] = [SERVICE_ID], ): string[] { fs.mkdirSync(DATA_DIR, { recursive: true }); const payload: RestartContext = { ...context, verify: context.verify, writtenAt: new Date().toISOString(), }; const writtenPaths: string[] = []; for (const serviceId of serviceIds) { const filePath = getRestartContextPath(serviceId); writeJsonFile(filePath, payload, true); writtenPaths.push(filePath); } return writtenPaths; } export function writeShutdownRestartContext( roomBindings: Record, interruptedGroups: RestartInterruptedGroup[], signal: string, serviceIds: string[] = [SERVICE_ID], ): string[] { const recoverableInterruptedGroups = interruptedGroups.filter( isRecoverableInterruptedGroup, ); if (recoverableInterruptedGroups.length === 0) return []; fs.mkdirSync(DATA_DIR, { recursive: true }); const mainChatJid = getMainGroupJid(roomBindings) ?? interruptedGroups[0].chatJid; const writtenPaths: string[] = []; for (const serviceId of serviceIds) { const filePath = getRestartContextPath(serviceId); const existing = readRestartContextFile(filePath); const mergedInterrupted = [ ...getRecoverableInterruptedGroups(existing), ...recoverableInterruptedGroups, ].filter( (group, index, all) => all.findIndex((candidate) => candidate.chatJid === group.chatJid) === index, ); const payload: RestartContext = { chatJid: existing?.chatJid || mainChatJid, summary: existing?.summary || '서비스 재시작으로 진행 중인 작업이 중단됨', verify: existing?.verify ?? [], writtenAt: new Date().toISOString(), source: existing?.source || 'shutdown-snapshot', signal, interruptedGroups: mergedInterrupted, }; writeJsonFile(filePath, payload, true); writtenPaths.push(filePath); } return writtenPaths; } export function buildInterruptedRestartAnnouncement( interrupted: RestartInterruptedGroup, ): string { const lines = ['서비스 재시작으로 이전 작업이 중단됐습니다.']; lines.push(`- 직전 상태: ${interrupted.status}`); if (interrupted.elapsedMs !== null) { lines.push( `- 직전 실행 시간: ${Math.round(interrupted.elapsedMs / 1000)}초`, ); } if (interrupted.pendingMessages) { lines.push('- 미처리 메시지가 남아 있었음'); } if (interrupted.pendingTasks > 0) { lines.push(`- 대기 태스크: ${interrupted.pendingTasks}개`); } lines.push('- 필요하면 이어서 요청해 주세요.'); return lines.join('\n'); } export function consumeRestartContext(): RestartContext | null { const filePath = getRestartContextPath(); if (!fs.existsSync(filePath)) return null; const parsed = readJsonFile(filePath); if (!parsed) { logger.warn( { filePath }, 'Failed to read restart context; removing invalid file', ); } try { fs.unlinkSync(filePath); } catch { // Ignore cleanup failure. } return parsed; } export function buildRestartAnnouncement(context: RestartContext): string { const lines = ['재시작 완료.', `- 변경: ${context.summary}`]; const recoverableInterruptedGroups = getRecoverableInterruptedGroups(context); if (recoverableInterruptedGroups.length > 0) { lines.push(`- 중단 작업 감지: ${recoverableInterruptedGroups.length}개`); } if (context.verify.length > 0) { lines.push(`- 검증: ${context.verify.join(', ')}`); } lines.push(`- 기록 시각: ${formatKoreanTimestamp(context.writtenAt)}`); return lines.join('\n'); } export function getInterruptedRecoveryCandidates( context: RestartContext | null, roomBindings: Record, ): RestartRecoveryCandidate[] { if (!context?.interruptedGroups?.length) return []; const seen = new Set(); const candidates: RestartRecoveryCandidate[] = []; for (const interrupted of getRecoverableInterruptedGroups(context)) { if (seen.has(interrupted.chatJid)) continue; const group = roomBindings[interrupted.chatJid]; if (!group) continue; seen.add(interrupted.chatJid); candidates.push({ chatJid: interrupted.chatJid, groupFolder: group.folder, status: interrupted.status, pendingMessages: interrupted.pendingMessages, pendingTasks: interrupted.pendingTasks, }); } return candidates; } export function inferRecentRestartContext( roomBindings: Record, processStartedAtMs: number, ): InferredRestartContext | null { const chatJid = getMainGroupJid(roomBindings); if (!chatJid) return null; const latestBuildTime = getLatestDistBuildTime(); const recentCommit = getRecentCommit(processStartedAtMs); const buildLooksRecent = latestBuildTime !== null && latestBuildTime >= processStartedAtMs - INFER_WINDOW_MS; if (!buildLooksRecent && !recentCommit) return null; const lines = ['재시작 감지.', '- 명시적 재시작 힌트는 없어 추론 기반임.']; if (recentCommit) { lines.push(`- 최근 커밋: ${recentCommit}`); } if (latestBuildTime !== null) { lines.push(`- 최근 빌드: ${formatKoreanTimestamp(latestBuildTime)}`); } lines.push(`- 시작 시각: ${formatKoreanTimestamp(processStartedAtMs)}`); return { chatJid, lines }; }