import { execFile, spawn } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import { mkdir, open, readFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { env } from '../config/env.js'; import { resolveMainProjectRoot } from './main-project-root-service.js'; import { readTestServerDeploymentState, startTestServerDeployment, type TestServerDeploymentSnapshot, } from './test-server-deployment-service.js'; import { getRuntimeWorkServerBuildInfo, readLatestWorkServerBuildInfo, readLatestWorkServerSourceChange, } from './work-server-build-service.js'; const execFileAsync = promisify(execFile); export const serverCommandKeys = ['test', 'rel', 'prod', 'work-server', 'command-runner'] as const; export type ServerCommandKey = (typeof serverCommandKeys)[number]; type ServerDefinition = { key: ServerCommandKey; label: string; summary: string; environment: string; publicUrl: string | null; checkUrl: string; composeFile: string; serviceName: string; containerName: string; commandScript: string; commandWorkingDirectory: string; commandEnvironment: Record; restartStrategy: 'wait' | 'deferred'; deferredResponseMode?: 'wait-for-result' | 'accept-immediately'; }; export type ServerCommandSnapshot = { key: ServerCommandKey; label: string; summary: string; environment: string; publicUrl: string | null; checkUrl: string; composeFile: string; serviceName: string; availability: 'online' | 'degraded' | 'offline'; httpStatus: number | null; contentType: string | null; responsePreview: string | null; checkedAt: string; startedAt: string | null; runningVersion: string | null; runningBuiltAt: string | null; latestVersion: string | null; latestBuiltAt: string | null; latestSourceChangeAt: string | null; latestSourceChangePath: string | null; buildRequired: boolean; updateAvailable: boolean; updateSummary: string | null; responseTimeMs: number | null; composeStatus: string | null; composeDetails: string | null; lastCommand: string; commandScript: string; commandWorkingDirectory: string; errorMessage: string | null; deployment: WorkServerDeploymentSnapshot | null; }; export type ServerCommandRestartResult = { server: ServerCommandSnapshot; commandOutput: string | null; restartState: 'completed' | 'accepted'; deployment?: WorkServerDeploymentSnapshot | null; testDeployment?: TestServerDeploymentSnapshot | null; }; type ServerCommandScriptExecutionOptions = { commandScript?: string; environment?: Record; timeoutMs?: number; }; type ExecFileFailure = Error & { code?: number | string; signal?: NodeJS.Signals | string | null; stdout?: string; stderr?: string; }; type RemoteRestartPayload = { server?: ServerCommandSnapshot; commandOutput: string | null; restartState: 'completed' | 'accepted'; }; type HealthCheckAttempt = { url: string; httpStatus: number | null; contentType: string | null; responsePreview: string | null; availability: ServerCommandSnapshot['availability']; errorMessage: string | null; }; type RuntimeInspectionResult = { startedAt: string | null; composeStatus: string | null; composeDetails: string | null; availability?: ServerCommandSnapshot['availability']; responsePreview?: string | null; errorMessage?: string | null; }; type BuildInspectionResult = { runningVersion: string | null; runningBuiltAt: string | null; latestVersion: string | null; latestBuiltAt: string | null; latestSourceChangeAt: string | null; latestSourceChangePath: string | null; buildRequired: boolean; updateAvailable: boolean; updateSummary: string | null; }; type WorkServerSlot = 'blue' | 'green'; export type WorkServerDeploymentStepKey = | 'build-target-slot' | 'verify-target-health' | 'switch-proxy' | 'drain-previous-slot' | 'rebuild-previous-slot' | 'recover-interrupted-chat'; export type WorkServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed'; export type WorkServerDeploymentStepSnapshot = { key: WorkServerDeploymentStepKey; status: WorkServerDeploymentStepStatus; detail: string | null; updatedAt: string | null; }; export type WorkServerDeploymentPhase = | 'idle' | 'build-target-slot' | 'verify-target-health' | 'switch-proxy' | 'drain-previous-slot' | 'rebuild-previous-slot' | 'recover-interrupted-chat' | 'completed' | 'failed'; export type WorkServerDeploymentSnapshot = { status: 'idle' | 'running' | 'completed' | 'failed'; phase: WorkServerDeploymentPhase; summary: string | null; startedAt: string | null; updatedAt: string | null; completedAt: string | null; activeSlot: WorkServerSlot | null; targetSlot: WorkServerSlot | null; previousSlot: WorkServerSlot | null; targetContainer: string | null; previousContainer: string | null; previousSlotActiveChatRequestCount: number | null; previousSlotQueuedChatRequestCount: number | null; recoveredSessionCount: number | null; recoveredRestartedCount: number | null; recoveredRequeuedCount: number | null; lastError: string | null; logExcerpt: string | null; steps: WorkServerDeploymentStepSnapshot[]; }; const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000; const DEFERRED_RESTART_DELAY_MS = 2_000; const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500; const DEFERRED_RESTART_POLL_INTERVAL_MS = 150; const APP_SOURCE_TARGET_PATHS = [ 'src', 'public', 'index.html', 'package.json', 'tsconfig.json', 'tsconfig.app.json', 'vite.config.ts', 'scripts', ] as const; const APP_BUILD_INFO_FILE_CANDIDATES = [ '/tmp/ai-code-test-app-dist/index.html', '/tmp/ai-code-test-app-dist/manifest.webmanifest', '/tmp/ai-code-test-app-dist/assets', ] as const; const APP_BUILD_STAMP_RELATIVE_PATH = '.server-command-test-app-built-at'; const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const; const APP_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const; const WORK_SERVER_RESTART_LOCK_STALE_MS = 20 * 60 * 1000; type WorkServerRestartLockPayload = { startedAt: string; key: ServerCommandKey; pid: number; }; type WorkServerDeploymentStateFilePayload = { status?: unknown; phase?: unknown; summary?: unknown; startedAt?: unknown; updatedAt?: unknown; completedAt?: unknown; activeSlot?: unknown; targetSlot?: unknown; previousSlot?: unknown; targetContainer?: unknown; previousContainer?: unknown; previousSlotActiveChatRequestCount?: unknown; previousSlotQueuedChatRequestCount?: unknown; recoveredSessionCount?: unknown; recoveredRestartedCount?: unknown; recoveredRequeuedCount?: unknown; lastError?: unknown; logExcerpt?: unknown; steps?: unknown; }; export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) { const allowLocal = options?.allowLocal ?? false; let latestBuiltAt: string | null = null; const mainProjectRoot = normalizePath(resolveMainProjectRoot()); const buildStampCandidates = [ path.join(mainProjectRoot, APP_BUILD_STAMP_RELATIVE_PATH), path.join(normalizePath(env.SERVER_COMMAND_PROJECT_ROOT), APP_BUILD_STAMP_RELATIVE_PATH), ].filter((value, index, array) => array.indexOf(value) === index); for (const targetPath of buildStampCandidates) { const candidate = allowLocal ? await readLocalBuildTimestamp(targetPath) : null; if (candidate && (!latestBuiltAt || candidate > latestBuiltAt)) { latestBuiltAt = candidate; } } for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) { const candidates = [ allowLocal ? await readLocalBuildTimestamp(targetPath) : null, await readContainerBuildTimestamp(definition, targetPath), ].filter((value): value is string => Boolean(value)); for (const candidate of candidates) { if (!latestBuiltAt || candidate > latestBuiltAt) { latestBuiltAt = candidate; } } } return latestBuiltAt; } async function readLocalBuildTimestamp(targetPath: string) { try { const targetStat = await stat(targetPath); return normalizeDateTimeValue(targetStat.mtime.toISOString()); } catch { return null; } } async function readContainerBuildTimestamp(definition: ServerDefinition, targetPath: string) { try { const { stdout } = await execFileAsync( 'docker', ['exec', definition.containerName, 'sh', '-lc', `if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`], { cwd: definition.commandWorkingDirectory, timeout: 8000, maxBuffer: 1024 * 1024, }, ); return normalizeDateTimeValue(stdout.trim()); } catch (error) { if (!shouldRetryWithDockerSocket(error)) { return null; } return readContainerBuildTimestampViaSocket(definition, targetPath); } } type SourceChangeInfo = { changedAt: string; path: string; }; function isExcludedAppSourcePath(rootPath: string, targetPath: string) { const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/'); if (APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix))) { return true; } const baseName = path.basename(relativePath); return APP_SOURCE_EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(baseName)); } async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise { try { if (isExcludedAppSourcePath(rootPath, targetPath)) { return null; } const targetStat = await stat(targetPath); if (targetStat.isFile()) { return { changedAt: normalizeDateTimeValue(targetStat.mtime.toISOString()) ?? targetStat.mtime.toISOString(), path: path.relative(rootPath, targetPath) || path.basename(targetPath), }; } if (!targetStat.isDirectory()) { return null; } const entries = await fs.promises.readdir(targetPath, { withFileTypes: true }); let latest: SourceChangeInfo | null = null; for (const entry of entries) { if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git' || entry.name === '.docker') { continue; } const childPath = path.join(targetPath, entry.name); const candidate = await findLatestSourceChangeInPath(rootPath, childPath); if (!candidate) { continue; } if (!latest || candidate.changedAt > latest.changedAt) { latest = candidate; } } return latest; } catch { return null; } } async function readLatestAppSourceChange() { const projectRoot = normalizePath(resolveMainProjectRoot()); let latest: SourceChangeInfo | null = null; for (const relativePath of APP_SOURCE_TARGET_PATHS) { const candidate = await findLatestSourceChangeInPath(projectRoot, path.join(projectRoot, relativePath)); if (!candidate) { continue; } if (!latest || candidate.changedAt > latest.changedAt) { latest = candidate; } } return latest; } async function requestDockerEngine( method: string, requestPath: string, payload?: unknown, ): Promise { const socketPath = resolveDockerSocketPath(process.env); const body = payload == null ? null : JSON.stringify(payload); return await new Promise((resolve, reject) => { const request = http.request( { socketPath, path: requestPath, method, headers: body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), } : undefined, }, (response) => { const chunks: Buffer[] = []; response.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); response.on('end', () => { const responseText = Buffer.concat(chunks).toString('utf8'); if ((response.statusCode ?? 500) >= 400) { reject( new Error( trimPreview(`Docker API ${method} ${requestPath} failed: ${response.statusCode} ${responseText}`, 400) ?? `Docker API ${method} ${requestPath} failed: ${response.statusCode}`, ), ); return; } if (!responseText.trim()) { resolve(undefined as T); return; } try { resolve(JSON.parse(responseText) as T); } catch (error) { reject(error); } }); }, ); request.once('error', reject); request.setTimeout(8000, () => { request.destroy(new Error(`Docker API timeout: ${method} ${requestPath}`)); }); if (body) { request.write(body); } request.end(); }); } type DockerContainerInspect = { Id?: string; Name?: string; State?: { StartedAt?: string; Status?: string; }; }; async function inspectContainerViaSocket(containerName: string) { return requestDockerEngine('GET', `/containers/${encodeURIComponent(containerName)}/json`); } async function execContainerCommandViaSocket(containerName: string, command: string[]) { const execCreated = await requestDockerEngine<{ Id?: string }>('POST', `/containers/${encodeURIComponent(containerName)}/exec`, { AttachStdout: true, AttachStderr: true, Cmd: command, }); const execId = execCreated.Id?.trim(); if (!execId) { return null; } const output = await new Promise((resolve, reject) => { const request = http.request( { socketPath: resolveDockerSocketPath(process.env), path: `/exec/${encodeURIComponent(execId)}/start`, method: 'POST', headers: { 'Content-Type': 'application/json', }, }, (response) => { const chunks: Buffer[] = []; response.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); response.on('end', () => { resolve(decodeDockerExecStream(Buffer.concat(chunks))); }); }, ); request.once('error', reject); request.setTimeout(8000, () => { request.destroy(new Error(`Docker exec timeout: ${containerName}`)); }); request.write(JSON.stringify({ Detach: false, Tty: false })); request.end(); }); const execState = await requestDockerEngine<{ ExitCode?: number | null }>('GET', `/exec/${encodeURIComponent(execId)}/json`); if ((execState.ExitCode ?? 1) !== 0) { return null; } return output.trim(); } async function readContainerBuildTimestampViaSocket(definition: ServerDefinition, targetPath: string) { try { const output = await execContainerCommandViaSocket(definition.containerName, [ 'sh', '-lc', `if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`, ]); return normalizeDateTimeValue(output); } catch { return null; } } function normalizeUrl(value: string) { return value.trim().replace(/\/+$/, ''); } function normalizeOptionalUrl(value: string | null | undefined) { const normalized = value?.trim(); return normalized ? normalizeUrl(normalized) : null; } function normalizePath(value: string) { return path.resolve(value); } export function buildServerCommandApiRestartUrl(baseUrl: string, pathTemplate: string, key: ServerCommandKey) { const normalizedBaseUrl = normalizeUrl(baseUrl); const normalizedPath = pathTemplate.trim() || '/api/server-commands/{key}/actions/restart'; const pathName = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`; const resolvedPath = pathName.replaceAll('{key}', encodeURIComponent(key)); const baseUrlObject = new URL(`${normalizedBaseUrl}/`); const basePath = baseUrlObject.pathname.replace(/\/+$/, ''); const nextPath = basePath && resolvedPath === basePath ? resolvedPath : basePath && resolvedPath.startsWith(`${basePath}/`) ? resolvedPath : `${basePath}${resolvedPath}`.replace(/\/{2,}/g, '/'); baseUrlObject.pathname = nextPath.startsWith('/') ? nextPath : `/${nextPath}`; baseUrlObject.search = ''; baseUrlObject.hash = ''; return normalizeUrl(baseUrlObject.toString()); } function resolveCommandScriptPath(scriptName: string, preferredRoots: string[]) { const resolvedRoots = preferredRoots .map((root) => normalizePath(root)) .filter((root, index, array) => Boolean(root) && array.indexOf(root) === index); const candidates = resolvedRoots.map((root) => path.join(root, 'etc', 'commands', 'server-command', scriptName)); const existingPath = candidates.find((candidate) => fs.existsSync(candidate)); return existingPath ?? candidates[0]; } export function resolveDockerSocketPath(source: NodeJS.ProcessEnv | Record = process.env) { const explicitSocketPath = source.SERVER_COMMAND_DOCKER_SOCKET?.trim(); if (explicitSocketPath) { return explicitSocketPath; } const dockerHost = source.DOCKER_HOST?.trim(); if (dockerHost?.startsWith('unix://')) { return dockerHost.slice('unix://'.length); } return '/var/run/docker.sock'; } function getWorkServerActiveSlotFileCandidates() { const mainProjectRoot = normalizePath(resolveMainProjectRoot()); const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT); return [ env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim() || null, path.join(mainProjectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'), path.join(projectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'), path.join(projectRoot, '.docker', 'runtime', 'active-slot'), ].filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index); } async function readWorkServerActiveSlot(): Promise { for (const candidate of getWorkServerActiveSlotFileCandidates()) { try { const value = (await readFile(candidate, 'utf8')).trim(); if (value === 'blue' || value === 'green') { return value; } } catch { continue; } } return 'blue'; } function resolveWorkServerContainerName(slot: WorkServerSlot) { return slot === 'green' ? 'work-server-green' : 'work-server-blue'; } function appendComposeDetails(detailParts: Array) { return trimPreview(detailParts.filter(Boolean).join(' ')); } function shouldRetryWithDockerSocket(error: unknown) { const failure = error instanceof Error ? (error as ExecFileFailure) : null; const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n'); return failure?.code === 127 || failure?.code === 'ENOENT' || /docker CLI not found|spawn docker ENOENT/i.test(detail); } function decodeDockerExecStream(buffer: Buffer) { let offset = 0; const chunks: Buffer[] = []; while (offset + 8 <= buffer.length) { const frameLength = buffer.readUInt32BE(offset + 4); const frameStart = offset + 8; const frameEnd = frameStart + frameLength; if (frameEnd > buffer.length) { break; } chunks.push(buffer.subarray(frameStart, frameEnd)); offset = frameEnd; } return (chunks.length > 0 ? Buffer.concat(chunks) : buffer).toString('utf8'); } export function buildHealthCheckUrls(key: ServerCommandKey, checkUrl: string) { const normalized = normalizeUrl(checkUrl); if (key !== 'command-runner') { return [normalized]; } let parsedUrl: URL; try { parsedUrl = new URL(normalized); } catch { return [normalized]; } 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 dedupedUrls: string[] = []; for (const hostname of hostVariants) { const candidate = new URL(parsedUrl.toString()); candidate.hostname = hostname; const serialized = normalizeUrl(candidate.toString()); if (!dedupedUrls.includes(serialized)) { dedupedUrls.push(serialized); } } return dedupedUrls; } async function fetchHealthCheck(url: string): Promise { try { const response = await fetch(url, { method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(5000), }); const bodyText = await response.text(); return { url, httpStatus: response.status, contentType: response.headers.get('content-type'), responsePreview: trimPreview(bodyText), availability: response.ok ? 'online' : response.status < 500 ? 'degraded' : 'offline', errorMessage: null, }; } catch (error) { return { url, httpStatus: null, contentType: null, responsePreview: null, availability: 'offline', errorMessage: error instanceof Error ? error.message : '서버 상태를 확인하지 못했습니다.', }; } } async function restartViaDockerSocket(definition: ServerDefinition) { const mergedEnv = { ...process.env, ...definition.commandEnvironment, }; const socketPath = resolveDockerSocketPath(mergedEnv); const socketRestartScript = path.join(path.dirname(definition.commandScript), 'restart-via-docker-socket.mjs'); return execFileAsync('node', [socketRestartScript, definition.containerName], { cwd: definition.commandWorkingDirectory, timeout: 30000, maxBuffer: 1024 * 1024, env: { ...mergedEnv, SERVER_COMMAND_DOCKER_SOCKET: socketPath, }, }); } function getServerDefinitions(): ServerDefinition[] { const mainProjectRoot = normalizePath(resolveMainProjectRoot()); const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE); const projectRoot = normalizePath(useLocalMainMode ? mainProjectRoot : env.SERVER_COMMAND_PROJECT_ROOT); const scriptRootCandidates = [mainProjectRoot, projectRoot, '/workspace/main-project']; return [ { key: 'test', label: 'PREVIEW', summary: 'preview.sm-home.cloud 테스트 앱 컨테이너', environment: 'test', publicUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL), checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_CHECK_URL || env.SERVER_COMMAND_TEST_URL), composeFile: path.join(mainProjectRoot, 'docker-compose.yml'), serviceName: env.SERVER_COMMAND_TEST_SERVICE, containerName: 'ai-code-app-app-1', commandScript: resolveCommandScriptPath('restart-test.sh', scriptRootCandidates), commandWorkingDirectory: mainProjectRoot, commandEnvironment: { MAIN_PROJECT_ROOT: mainProjectRoot, SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'), SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_TEST_SERVICE, SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-app-1', SERVER_COMMAND_TEST_GIT_REMOTE: 'origin', SERVER_COMMAND_TEST_GIT_BRANCH: env.PLAN_MAIN_BRANCH, }, restartStrategy: 'wait', }, { key: 'rel', label: 'REL', summary: 'release 브랜치를 서비스하는 릴리즈 앱 컨테이너', environment: 'release', publicUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL), checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_CHECK_URL || env.SERVER_COMMAND_REL_URL), composeFile: path.join(mainProjectRoot, 'docker-compose.yml'), serviceName: env.SERVER_COMMAND_REL_SERVICE, containerName: 'ai-code-app-release', commandScript: resolveCommandScriptPath('restart-rel.sh', scriptRootCandidates), commandWorkingDirectory: mainProjectRoot, commandEnvironment: { MAIN_PROJECT_ROOT: mainProjectRoot, SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'), SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_REL_SERVICE, }, restartStrategy: 'wait', }, { key: 'prod', label: 'PROD', summary: '프로덕션 앱 컨테이너', environment: 'production', publicUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL), checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_CHECK_URL || env.SERVER_COMMAND_PROD_URL), composeFile: path.join(mainProjectRoot, 'docker-compose.yml'), serviceName: env.SERVER_COMMAND_PROD_SERVICE, containerName: 'ai-code-app-prod', commandScript: resolveCommandScriptPath('restart-prod.sh', scriptRootCandidates), commandWorkingDirectory: mainProjectRoot, commandEnvironment: { MAIN_PROJECT_ROOT: mainProjectRoot, SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'), SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_PROD_SERVICE, SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-prod', }, restartStrategy: 'deferred', deferredResponseMode: 'wait-for-result', }, { key: 'work-server', label: 'WORK-SERVER', summary: 'Plan, Board, History API를 제공하는 워크서버', environment: 'internal-api', publicUrl: null, checkUrl: normalizeUrl(env.SERVER_COMMAND_WORK_SERVER_URL), composeFile: path.join(projectRoot, 'etc', 'servers', 'work-server', 'docker-compose.yml'), serviceName: env.SERVER_COMMAND_WORK_SERVER_SERVICE, containerName: 'work-server', commandScript: resolveCommandScriptPath('restart-work-server.sh', scriptRootCandidates), commandWorkingDirectory: mainProjectRoot, commandEnvironment: { REPO_ROOT: mainProjectRoot, }, restartStrategy: 'deferred', deferredResponseMode: 'accept-immediately', }, { key: 'command-runner', label: 'COMMAND-RUNNER', summary: 'nohup으로 실행 중인 서버 명령 host runner', environment: 'host-runner', publicUrl: null, checkUrl: normalizeUrl(env.SERVER_COMMAND_RUNNER_URL), composeFile: path.join(projectRoot, 'scripts', 'run-server-command-runner.mjs'), serviceName: 'server-command-runner', containerName: 'server-command-runner', commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', scriptRootCandidates), commandWorkingDirectory: mainProjectRoot, commandEnvironment: { PROJECT_ROOT: mainProjectRoot, }, restartStrategy: 'deferred', deferredResponseMode: 'wait-for-result', }, ]; } function getServerDefinition(key: ServerCommandKey) { const definition = getServerDefinitions().find((item) => item.key === key); if (!definition) { throw new Error('지원하지 않는 서버입니다.'); } return definition; } async function executeServerCommandScript( definition: ServerDefinition, options: ServerCommandScriptExecutionOptions = {}, ) { const commandScript = options.commandScript ?? definition.commandScript; const timeoutMs = options.timeoutMs ?? 30000; return execFileAsync('sh', [commandScript], { cwd: definition.commandWorkingDirectory, timeout: timeoutMs, maxBuffer: 1024 * 1024, env: { ...process.env, ...definition.commandEnvironment, ...options.environment, }, }); } function trimPreview(value: string | null | undefined, maxLength = 220) { const normalized = value?.replace(/\s+/g, ' ').trim() ?? ''; if (!normalized) { return null; } return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; } function normalizeDateTimeValue(value: string | null | undefined) { const normalized = value?.trim(); if (!normalized) { return null; } const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } function getWorkServerRestartLockPath() { return path.join(resolveMainProjectRoot(), "etc", "servers", "work-server", ".docker", "runtime", "restart-in-progress.json"); } function getWorkServerDeploymentStatePath() { return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'deployment-state.json'); } const WORK_SERVER_DEPLOYMENT_STEP_KEYS: WorkServerDeploymentStepKey[] = [ 'build-target-slot', 'verify-target-health', 'switch-proxy', 'drain-previous-slot', 'rebuild-previous-slot', 'recover-interrupted-chat', ]; function normalizeWorkServerDeploymentStepKey(value: unknown): WorkServerDeploymentStepKey | null { return WORK_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as WorkServerDeploymentStepKey) ? (value as WorkServerDeploymentStepKey) : null; } function normalizeWorkServerSlotValue(value: unknown): WorkServerSlot | null { return value === 'blue' || value === 'green' ? value : null; } function normalizeWorkServerDeploymentPhase(value: unknown): WorkServerDeploymentPhase { return value === 'build-target-slot' || value === 'verify-target-health' || value === 'switch-proxy' || value === 'drain-previous-slot' || value === 'rebuild-previous-slot' || value === 'recover-interrupted-chat' || value === 'completed' || value === 'failed' ? value : 'idle'; } function normalizeWorkServerDeploymentStatus(value: unknown): WorkServerDeploymentSnapshot['status'] { return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle'; } function normalizeNumberOrNull(value: unknown) { return typeof value === 'number' && Number.isFinite(value) ? value : null; } function buildEmptyWorkServerDeploymentSnapshot(): WorkServerDeploymentSnapshot { return { status: 'idle', phase: 'idle', summary: null, startedAt: null, updatedAt: null, completedAt: null, activeSlot: null, targetSlot: null, previousSlot: null, targetContainer: null, previousContainer: null, previousSlotActiveChatRequestCount: null, previousSlotQueuedChatRequestCount: null, recoveredSessionCount: null, recoveredRestartedCount: null, recoveredRequeuedCount: null, lastError: null, logExcerpt: null, steps: WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({ key, status: 'pending', detail: null, updatedAt: null, })), }; } function normalizeWorkServerDeploymentSteps(value: unknown) { const fallback = buildEmptyWorkServerDeploymentSnapshot().steps; if (!Array.isArray(value)) { return fallback; } const normalizedByKey = new Map(); value.forEach((item) => { if (!item || typeof item !== 'object') { return; } const candidate = item as Record; const key = normalizeWorkServerDeploymentStepKey(candidate.key); if (!key) { return; } const status = candidate.status === 'running' || candidate.status === 'completed' || candidate.status === 'failed' || candidate.status === 'pending' ? candidate.status : 'pending'; normalizedByKey.set(key, { key, status, detail: typeof candidate.detail === 'string' ? candidate.detail : null, updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), }); }); return WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!); } function normalizeWorkServerDeploymentSnapshot(value: unknown): WorkServerDeploymentSnapshot { if (!value || typeof value !== 'object') { return buildEmptyWorkServerDeploymentSnapshot(); } const candidate = value as WorkServerDeploymentStateFilePayload; return { status: normalizeWorkServerDeploymentStatus(candidate.status), phase: normalizeWorkServerDeploymentPhase(candidate.phase), summary: typeof candidate.summary === 'string' ? candidate.summary : null, startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null), updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null), activeSlot: normalizeWorkServerSlotValue(candidate.activeSlot), targetSlot: normalizeWorkServerSlotValue(candidate.targetSlot), previousSlot: normalizeWorkServerSlotValue(candidate.previousSlot), targetContainer: typeof candidate.targetContainer === 'string' ? candidate.targetContainer : null, previousContainer: typeof candidate.previousContainer === 'string' ? candidate.previousContainer : null, previousSlotActiveChatRequestCount: normalizeNumberOrNull(candidate.previousSlotActiveChatRequestCount), previousSlotQueuedChatRequestCount: normalizeNumberOrNull(candidate.previousSlotQueuedChatRequestCount), recoveredSessionCount: normalizeNumberOrNull(candidate.recoveredSessionCount), recoveredRestartedCount: normalizeNumberOrNull(candidate.recoveredRestartedCount), recoveredRequeuedCount: normalizeNumberOrNull(candidate.recoveredRequeuedCount), lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null, logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null, steps: normalizeWorkServerDeploymentSteps(candidate.steps), }; } export async function readWorkServerDeploymentState(): Promise { try { const raw = await readFile(getWorkServerDeploymentStatePath(), 'utf8'); return normalizeWorkServerDeploymentSnapshot(JSON.parse(raw)); } catch { return null; } } async function acquireWorkServerRestartLock() { const lockPath = getWorkServerRestartLockPath(); await mkdir(path.dirname(lockPath), { recursive: true }); const startedAt = new Date().toISOString(); try { const handle = await open(lockPath, "wx"); try { await handle.writeFile(JSON.stringify({ startedAt, key: "work-server", pid: process.pid }) + "\n", "utf8"); } finally { await handle.close(); } return lockPath; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") { throw error; } let existingStartedAt: string | null = null; try { const raw = await readFile(lockPath, "utf8"); const parsed = JSON.parse(raw) as Partial; existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === "string" ? parsed.startedAt : null); const lockStat = await stat(lockPath).catch(() => null); const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null); if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) { await rm(lockPath, { force: true }).catch(() => undefined); return acquireWorkServerRestartLock(); } } catch { // ignore read failures and keep conflict response below } const conflictError = new Error( existingStartedAt ? "WORK-SERVER 무중단 재기동이 이미 진행 중입니다. 시작 시각 " + existingStartedAt : "WORK-SERVER 무중단 재기동이 이미 진행 중입니다.", ); (conflictError as Error & { statusCode?: number }).statusCode = 409; throw conflictError; } } function buildRestartCommandPreview(definition: ServerDefinition) { return `sh ${definition.commandScript}`; } function buildDeferredRestartProbePaths(definition: ServerDefinition) { const token = `${definition.key}-${Date.now()}-${process.pid}`; return { logPath: path.join('/tmp', `${token}.log`), statusPath: path.join('/tmp', `${token}.status`), }; } function buildAcceptedRestartSnapshot(definition: ServerDefinition): ServerCommandSnapshot { return { key: definition.key, label: definition.label, summary: definition.summary, environment: definition.environment, publicUrl: definition.publicUrl, checkUrl: definition.checkUrl, composeFile: definition.composeFile, serviceName: definition.serviceName, availability: 'degraded', httpStatus: null, contentType: null, responsePreview: null, checkedAt: new Date().toISOString(), startedAt: null, runningVersion: null, runningBuiltAt: null, latestVersion: null, latestBuiltAt: null, latestSourceChangeAt: null, latestSourceChangePath: null, buildRequired: false, updateAvailable: false, updateSummary: null, responseTimeMs: null, composeStatus: 'restarting', composeDetails: 'restart requested', lastCommand: buildRestartCommandPreview(definition), commandScript: definition.commandScript, commandWorkingDirectory: definition.commandWorkingDirectory, errorMessage: null, deployment: definition.key === 'work-server' ? buildEmptyWorkServerDeploymentSnapshot() : null, }; } function coerceServerSnapshot( definition: ServerDefinition, value: unknown, fallback: ServerCommandSnapshot, ): ServerCommandSnapshot { if (!value || typeof value !== 'object') { return fallback; } const item = value as Partial>; return { ...fallback, key: item.key === definition.key ? definition.key : fallback.key, label: typeof item.label === 'string' ? item.label : fallback.label, summary: typeof item.summary === 'string' ? item.summary : fallback.summary, environment: typeof item.environment === 'string' ? item.environment : fallback.environment, publicUrl: typeof item.publicUrl === 'string' ? item.publicUrl : item.publicUrl === null ? null : fallback.publicUrl, checkUrl: typeof item.checkUrl === 'string' ? item.checkUrl : fallback.checkUrl, composeFile: typeof item.composeFile === 'string' ? item.composeFile : fallback.composeFile, serviceName: typeof item.serviceName === 'string' ? item.serviceName : fallback.serviceName, availability: item.availability === 'online' || item.availability === 'degraded' || item.availability === 'offline' ? item.availability : fallback.availability, httpStatus: typeof item.httpStatus === 'number' ? item.httpStatus : item.httpStatus === null ? null : fallback.httpStatus, contentType: typeof item.contentType === 'string' ? item.contentType : item.contentType === null ? null : fallback.contentType, responsePreview: typeof item.responsePreview === 'string' ? item.responsePreview : item.responsePreview === null ? null : fallback.responsePreview, checkedAt: typeof item.checkedAt === 'string' ? item.checkedAt : fallback.checkedAt, startedAt: typeof item.startedAt === 'string' ? item.startedAt : item.startedAt === null ? null : fallback.startedAt, runningVersion: typeof item.runningVersion === 'string' ? item.runningVersion : item.runningVersion === null ? null : fallback.runningVersion, runningBuiltAt: typeof item.runningBuiltAt === 'string' ? item.runningBuiltAt : item.runningBuiltAt === null ? null : fallback.runningBuiltAt, latestVersion: typeof item.latestVersion === 'string' ? item.latestVersion : item.latestVersion === null ? null : fallback.latestVersion, latestBuiltAt: typeof item.latestBuiltAt === 'string' ? item.latestBuiltAt : item.latestBuiltAt === null ? null : fallback.latestBuiltAt, latestSourceChangeAt: typeof item.latestSourceChangeAt === 'string' ? item.latestSourceChangeAt : item.latestSourceChangeAt === null ? null : fallback.latestSourceChangeAt, latestSourceChangePath: typeof item.latestSourceChangePath === 'string' ? item.latestSourceChangePath : item.latestSourceChangePath === null ? null : fallback.latestSourceChangePath, buildRequired: typeof item.buildRequired === 'boolean' ? item.buildRequired : fallback.buildRequired, updateAvailable: typeof item.updateAvailable === 'boolean' ? item.updateAvailable : fallback.updateAvailable, updateSummary: typeof item.updateSummary === 'string' ? item.updateSummary : item.updateSummary === null ? null : fallback.updateSummary, responseTimeMs: typeof item.responseTimeMs === 'number' ? item.responseTimeMs : item.responseTimeMs === null ? null : fallback.responseTimeMs, composeStatus: typeof item.composeStatus === 'string' ? item.composeStatus : item.composeStatus === null ? null : fallback.composeStatus, composeDetails: typeof item.composeDetails === 'string' ? item.composeDetails : item.composeDetails === null ? null : fallback.composeDetails, lastCommand: typeof item.lastCommand === 'string' ? item.lastCommand : fallback.lastCommand, commandScript: typeof item.commandScript === 'string' ? item.commandScript : fallback.commandScript, commandWorkingDirectory: typeof item.commandWorkingDirectory === 'string' ? item.commandWorkingDirectory : fallback.commandWorkingDirectory, errorMessage: typeof item.errorMessage === 'string' ? item.errorMessage : item.errorMessage === null ? null : fallback.errorMessage, }; } export function buildRestartFailureMessage(label: string, error: unknown) { const failure = error instanceof Error ? (error as ExecFileFailure) : null; const output = trimPreview([failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n'), 400); const exitInfo = [ failure?.code != null ? `exit:${String(failure.code)}` : null, failure?.signal ? `signal:${String(failure.signal)}` : null, ] .filter(Boolean) .join(' '); return trimPreview( [`${label} 재기동에 실패했습니다.`, exitInfo || null, output || null].filter(Boolean).join(' '), 400, ) || `${label} 재기동에 실패했습니다.`; } async function waitForDeferredRestartResult( definition: ServerDefinition, statusPath: string, logPath: string, ): Promise { const deadline = Date.now() + DEFERRED_RESTART_CONFIRM_TIMEOUT_MS; while (Date.now() <= deadline) { try { const statusText = (await readFile(statusPath, 'utf8')).trim(); const output = trimPreview(await readFile(logPath, 'utf8'), 400); const exitCode = Number.parseInt(statusText, 10); await rm(statusPath, { force: true }); await rm(logPath, { force: true }); if (!Number.isNaN(exitCode) && exitCode !== 0) { const restartError = new Error( buildRestartFailureMessage( definition.label, Object.assign(new Error(`deferred restart exited with ${exitCode}`), { code: exitCode, stderr: output ?? '', stdout: '', }), ), ); (restartError as Error & { statusCode?: number }).statusCode = 500; throw restartError; } return output; } catch (error) { if ( error && typeof error === 'object' && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' ) { await new Promise((resolve) => { setTimeout(resolve, DEFERRED_RESTART_POLL_INTERVAL_MS); }); continue; } throw error; } } return null; } async function restartServerCommandDeferred(definition: ServerDefinition): Promise { const { logPath, statusPath } = buildDeferredRestartProbePaths(definition); const workServerLockPath = definition.key === "work-server" ? await acquireWorkServerRestartLock() : null; const shellCommand = [ `sleep ${Math.ceil(DEFERRED_RESTART_DELAY_MS / 1000)}`, `sh ${JSON.stringify(definition.commandScript)} >${JSON.stringify(logPath)} 2>&1`, 'status=$?', `printf '%s' \"$status\" >${JSON.stringify(statusPath)}`, ].join('; '); try { await new Promise((resolve, reject) => { const child = spawn('sh', ['-c', shellCommand], { cwd: definition.commandWorkingDirectory, detached: true, stdio: 'ignore', env: { ...process.env, ...definition.commandEnvironment, ...(workServerLockPath ? { WORK_SERVER_RESTART_LOCK_FILE: workServerLockPath } : {}), }, }); child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); } catch (error) { if (workServerLockPath) { await rm(workServerLockPath, { force: true }).catch(() => undefined); } throw error; } if (definition.deferredResponseMode === 'accept-immediately') { return { server: buildAcceptedRestartSnapshot(definition), commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`, restartState: 'accepted', deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null, }; } const commandOutput = await waitForDeferredRestartResult(definition, statusPath, logPath); return { server: buildAcceptedRestartSnapshot(definition), commandOutput: commandOutput ?? `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`, restartState: 'accepted', deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null, }; } async function parseRemoteRestartPayload(response: Response): Promise { const responseText = await response.text(); const parsed = responseText.trim().length > 0 ? (() => { try { return JSON.parse(responseText) as { item?: unknown; server?: unknown; commandOutput?: unknown; output?: unknown; restartState?: unknown; data?: { item?: unknown; server?: unknown; commandOutput?: unknown; output?: unknown; restartState?: unknown; }; }; } catch { return null; } })() : null; const nestedData = parsed?.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data) ? parsed.data : null; return { server: parsed?.item && typeof parsed.item === 'object' ? (parsed.item as ServerCommandSnapshot) : parsed?.server && typeof parsed.server === 'object' ? (parsed.server as ServerCommandSnapshot) : nestedData?.item && typeof nestedData.item === 'object' ? (nestedData.item as ServerCommandSnapshot) : nestedData?.server && typeof nestedData.server === 'object' ? (nestedData.server as ServerCommandSnapshot) : undefined, commandOutput: trimPreview( [ typeof parsed?.commandOutput === 'string' ? parsed.commandOutput : null, typeof parsed?.output === 'string' ? parsed.output : null, typeof nestedData?.commandOutput === 'string' ? nestedData.commandOutput : null, typeof nestedData?.output === 'string' ? nestedData.output : null, !parsed ? responseText : null, ] .filter(Boolean) .join('\n'), 400, ), restartState: parsed?.restartState === 'accepted' || nestedData?.restartState === 'accepted' ? 'accepted' : 'completed', }; } async function restartServerCommandViaApi(definition: ServerDefinition): Promise { const apiBaseUrl = normalizeOptionalUrl(env.SERVER_COMMAND_API_BASE_URL); if (!apiBaseUrl) { throw new Error('SERVER_COMMAND_API_BASE_URL이 비어 있습니다.'); } const requestUrl = buildServerCommandApiRestartUrl( apiBaseUrl, env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE, definition.key, ); const headers = new Headers(); if (env.SERVER_COMMAND_API_ACCESS_TOKEN?.trim()) { headers.set('X-Access-Token', env.SERVER_COMMAND_API_ACCESS_TOKEN.trim()); } let response: Response; try { response = await fetch(requestUrl, { method: 'POST', headers, signal: AbortSignal.timeout(15000), }); } catch (error) { const restartError = new Error(buildRestartFailureMessage(definition.label, error)); (restartError as Error & { statusCode?: number }).statusCode = 500; throw restartError; } if (!response.ok) { const detail = await response.text(); const failure = Object.assign(new Error(`HTTP ${response.status}`), { code: response.status, stderr: detail, stdout: '', }); const restartError = new Error(buildRestartFailureMessage(definition.label, failure)); (restartError as Error & { statusCode?: number }).statusCode = 500; throw restartError; } const remotePayload = await parseRemoteRestartPayload(response); const fallbackSnapshot = remotePayload.restartState === 'accepted' ? buildAcceptedRestartSnapshot(definition) : await checkServer(definition); return { server: coerceServerSnapshot(definition, remotePayload.server, fallbackSnapshot), commandOutput: remotePayload.commandOutput, restartState: remotePayload.restartState, }; } function shouldFallbackFromRemoteRestart(error: unknown) { const detail = error instanceof Error ? error.message : String(error); return /Failed to fetch|fetch failed|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|404|408/i.test(detail); } async function inspectComposeStatus(definition: ServerDefinition) { try { const { stdout } = await execFileAsync( 'docker', ['compose', '-f', definition.composeFile, 'ps', definition.serviceName, '--format', 'json'], { cwd: definition.commandWorkingDirectory, timeout: 8000, maxBuffer: 1024 * 1024, }, ); const normalized = stdout.trim(); if (!normalized) { return { startedAt: null, composeStatus: null, composeDetails: null, }; } const parsed = JSON.parse(normalized) as Record | Array>; const firstRow = Array.isArray(parsed) ? parsed[0] : parsed; const status = typeof firstRow?.State === 'string' ? firstRow.State : typeof firstRow?.Status === 'string' ? firstRow.Status : null; const details = trimPreview( [ typeof firstRow?.Name === 'string' ? `name:${firstRow.Name}` : null, typeof firstRow?.Publishers === 'string' ? `publishers:${firstRow.Publishers}` : null, typeof firstRow?.Health === 'string' ? `health:${firstRow.Health}` : null, ] .filter(Boolean) .join(' '), ); return { startedAt: null, composeStatus: status, composeDetails: details, }; } catch (error) { return { startedAt: null, composeStatus: null, composeDetails: trimPreview(error instanceof Error ? error.message : 'compose 상태 확인 실패'), }; } } async function inspectContainerRuntime( definition: ServerDefinition, containerNameOverride?: string, ): Promise { const containerName = containerNameOverride ?? definition.containerName; try { const { stdout } = await execFileAsync( 'docker', ['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', containerName], { cwd: definition.commandWorkingDirectory, timeout: 8000, maxBuffer: 1024 * 1024, }, ); const [startedAtRaw = '', statusRaw = '', nameRaw = ''] = stdout.trim().split('\t'); return { startedAt: normalizeDateTimeValue(startedAtRaw), composeStatus: statusRaw.trim() || null, composeDetails: trimPreview(nameRaw.trim() ? `name:${nameRaw.trim().replace(/^\//, '')}` : null), }; } catch (error) { if (shouldRetryWithDockerSocket(error)) { try { const inspected = await inspectContainerViaSocket(containerName); return { startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null), composeStatus: inspected.State?.Status?.trim() || null, composeDetails: trimPreview(inspected.Name?.trim() ? `name:${inspected.Name.trim().replace(/^\//, '')}` : null), }; } catch { // fall through to compose inspection } } return inspectComposeStatus(definition); } } async function inspectRunnerRuntime(definition: ServerDefinition): Promise { try { const runnerScriptName = path.basename(path.join(definition.commandWorkingDirectory, 'scripts', 'run-server-command-runner.mjs')); const { stdout } = await execFileAsync( 'sh', [ '-c', `ps -eo lstart=,args= | grep ${JSON.stringify(runnerScriptName)} | grep -v grep | head -n 1`, ], { cwd: definition.commandWorkingDirectory, timeout: 5000, maxBuffer: 1024 * 1024, }, ); const line = stdout.trim(); if (!line) { return { startedAt: null, composeStatus: null, composeDetails: null, }; } const match = line.match(/^([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/); const startedAt = normalizeDateTimeValue(match?.[1] ?? null); return { startedAt, composeStatus: 'running', composeDetails: trimPreview(match?.[2] ?? line), }; } catch { return { startedAt: null, composeStatus: null, composeDetails: null, }; } } async function inspectRunnerHeartbeat(): Promise { const heartbeatCandidates = [ env.SERVER_COMMAND_RUNNER_HEARTBEAT_FILE?.trim() || null, path.join(normalizePath(env.SERVER_COMMAND_PROJECT_ROOT), '.server-command-runner-heartbeat.json'), path.join(normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT), '.server-command-runner-heartbeat.json'), ].filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index); let latestHeartbeatPath: string | null = null; let latestHeartbeatStat: Awaited> | null = null; for (const candidate of heartbeatCandidates) { try { const candidateStat = await stat(candidate); if (!latestHeartbeatStat || candidateStat.mtimeMs > latestHeartbeatStat.mtimeMs) { latestHeartbeatPath = candidate; latestHeartbeatStat = candidateStat; } } catch { continue; } } const heartbeatPath = latestHeartbeatPath ?? heartbeatCandidates[0] ?? '.server-command-runner-heartbeat.json'; try { const heartbeatStat = latestHeartbeatStat ?? (await stat(heartbeatPath)); const ageMs = Date.now() - heartbeatStat.mtimeMs; const startedAt = normalizeDateTimeValue(heartbeatStat.birthtime.toISOString()); if (ageMs <= RUNNER_HEARTBEAT_FRESHNESS_MS) { return { startedAt, composeStatus: 'running', composeDetails: trimPreview(`heartbeat:${heartbeatPath}`), availability: 'online', responsePreview: trimPreview(`heartbeat ok · ${Math.max(0, Math.round(ageMs / 1000))}초 전 갱신`), errorMessage: '로컬 전용 runner를 heartbeat 파일 기준으로 확인했습니다.', }; } return { startedAt, composeStatus: 'stale', composeDetails: trimPreview(`heartbeat:${heartbeatPath}`), availability: 'offline', responsePreview: trimPreview(`heartbeat stale · ${Math.max(0, Math.round(ageMs / 1000))}초 경과`), errorMessage: 'runner heartbeat 갱신이 오래되었습니다.', }; } catch (error) { return { startedAt: null, composeStatus: null, composeDetails: trimPreview(`heartbeat:${heartbeatPath}`), availability: 'offline', responsePreview: null, errorMessage: error instanceof Error ? `runner heartbeat 확인 실패: ${error.message}` : 'runner heartbeat 확인 실패', }; } } function inspectCurrentProcessRuntime(): RuntimeInspectionResult { const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString(); return { startedAt, composeStatus: 'running', composeDetails: trimPreview(`pid:${process.pid}`), }; } async function inspectRuntime(definition: ServerDefinition): Promise { if (definition.key === 'command-runner') { const runtimeInfo = await inspectRunnerRuntime(definition); if (runtimeInfo.startedAt) { return runtimeInfo; } return inspectRunnerHeartbeat(); } if (definition.key === 'work-server') { const primarySlot = await readWorkServerActiveSlot(); const candidateSlots: WorkServerSlot[] = primarySlot === 'green' ? ['green', 'blue'] : ['blue', 'green']; for (const slot of candidateSlots) { const runtimeInfo = await inspectContainerRuntime(definition, resolveWorkServerContainerName(slot)); if (runtimeInfo.startedAt) { return { ...runtimeInfo, composeDetails: appendComposeDetails([`slot:${slot}`, runtimeInfo.composeDetails]), }; } } const runtimeInfo = await inspectContainerRuntime(definition); if (runtimeInfo.startedAt) { return { ...runtimeInfo, composeDetails: appendComposeDetails(['slot:proxy', runtimeInfo.composeDetails]), }; } return inspectCurrentProcessRuntime(); } return inspectContainerRuntime(definition); } async function inspectAppContainerBuild(definition: ServerDefinition): Promise { if (definition.key !== 'test' && definition.key !== 'prod') { return null; } if (definition.key === 'prod') { const testDefinition = getServerDefinition('test'); const testBuiltAt = await readAppBuildTimestamp(testDefinition, { allowLocal: true }); const prodBuiltAt = await readAppBuildTimestamp(definition); const updateAvailable = Boolean(testBuiltAt && (!prodBuiltAt || prodBuiltAt < testBuiltAt)); return { runningVersion: null, runningBuiltAt: prodBuiltAt, latestVersion: null, latestBuiltAt: testBuiltAt, latestSourceChangeAt: null, latestSourceChangePath: null, buildRequired: false, updateAvailable, updateSummary: updateAvailable && testBuiltAt ? `운영 반영 시각이 TEST보다 이전입니다. TEST ${testBuiltAt}, 운영 ${prodBuiltAt ?? '미확인'}` : prodBuiltAt ? `운영 반영 기준: ${prodBuiltAt}` : '운영 빌드 시각을 읽지 못했습니다.', }; } const latestSourceChange = await readLatestAppSourceChange(); const latestSourceChangedAt = latestSourceChange?.changedAt ?? null; const builtAt = await readAppBuildTimestamp(definition, { allowLocal: true }); if (builtAt) { return { runningVersion: null, runningBuiltAt: builtAt, latestVersion: null, latestBuiltAt: builtAt, latestSourceChangeAt: latestSourceChangedAt, latestSourceChangePath: latestSourceChange?.path ?? null, buildRequired: Boolean(latestSourceChangedAt && latestSourceChangedAt > builtAt), updateAvailable: false, updateSummary: latestSourceChangedAt && latestSourceChangedAt > builtAt ? `수정된 소스가 테스트 빌드보다 새롭습니다.${latestSourceChange?.path ? ` (${latestSourceChange.path})` : ''} 테스트 앱을 다시 빌드해야 합니다.` : `테스트 빌드 기준: ${builtAt}`, }; } return { runningVersion: null, runningBuiltAt: null, latestVersion: null, latestBuiltAt: null, latestSourceChangeAt: latestSourceChangedAt, latestSourceChangePath: latestSourceChange?.path ?? null, buildRequired: Boolean(latestSourceChangedAt), updateAvailable: false, updateSummary: latestSourceChangedAt ? `테스트 빌드 시각을 읽지 못했습니다.${latestSourceChange?.path ? ` 최근 소스 변경: ${latestSourceChange.path}` : ''}` : '테스트 빌드 시각을 읽지 못했습니다.', }; } async function inspectBuild(definition: ServerDefinition): Promise { if (definition.key !== 'work-server') { const appBuildInfo = await inspectAppContainerBuild(definition); if (appBuildInfo) { return appBuildInfo; } return { runningVersion: null, runningBuiltAt: null, latestVersion: null, latestBuiltAt: null, latestSourceChangeAt: null, latestSourceChangePath: null, buildRequired: false, updateAvailable: false, updateSummary: null, }; } const runningBuild = getRuntimeWorkServerBuildInfo(); const latestBuild = await readLatestWorkServerBuildInfo(); const latestSourceChange = await readLatestWorkServerSourceChange(); const latestSourceChangedAt = latestSourceChange?.changedAt ?? null; const buildRequired = latestSourceChangedAt ? !latestBuild?.builtAt || latestSourceChangedAt > latestBuild.builtAt : false; const updateAvailable = !buildRequired && Boolean(runningBuild?.builtAt) && Boolean(latestBuild?.builtAt) && Boolean(latestSourceChangedAt) && runningBuild!.builtAt < latestBuild!.builtAt && runningBuild!.builtAt < latestSourceChangedAt!; return { runningVersion: runningBuild?.buildId ?? null, runningBuiltAt: runningBuild?.builtAt ?? null, latestVersion: latestBuild?.buildId ?? null, latestBuiltAt: latestBuild?.builtAt ?? null, latestSourceChangeAt: latestSourceChangedAt, latestSourceChangePath: latestSourceChange?.path ?? null, buildRequired, updateAvailable, updateSummary: buildRequired ? `수정된 소스가 최신 빌드보다 새롭습니다.${latestSourceChange?.path ? ` (${latestSourceChange?.path})` : ''} 재시작 시 다시 빌드한 뒤 적용합니다.` : updateAvailable ? '새로운 work-server 빌드가 준비되어 있습니다. 재시작하면 최신 버전이 적용됩니다.' : runningBuild && latestBuild ? '실행 중인 work-server가 최신 빌드입니다.' : latestBuild ? latestSourceChangedAt ? '최신 빌드는 준비되어 있지만 실행 중 버전 정보를 읽지 못했습니다.' : '최신 빌드는 준비되어 있지만 워크서버 소스 수정일을 읽지 못했습니다. /app 또는 /workspace/main-project의 work-server 소스 경로를 확인해 주세요.' : latestSourceChangedAt ? '아직 확인된 work-server 빌드 정보가 없습니다.' : '워크서버 소스 수정일과 빌드 정보를 읽지 못했습니다. /app 또는 /workspace/main-project의 work-server 소스 경로를 확인해 주세요.', }; } async function checkServer(definition: ServerDefinition): Promise { const startedAt = Date.now(); const attemptUrls = buildHealthCheckUrls(definition.key, definition.checkUrl); const attempts: HealthCheckAttempt[] = []; let selectedAttempt: HealthCheckAttempt | null = null; for (const attemptUrl of attemptUrls) { const attempt = await fetchHealthCheck(attemptUrl); attempts.push(attempt); if (attempt.availability !== 'offline') { selectedAttempt = attempt; break; } } if (!selectedAttempt) { selectedAttempt = attempts[0] ?? { url: definition.checkUrl, httpStatus: null, contentType: null, responsePreview: null, availability: 'offline', errorMessage: '서버 상태를 확인하지 못했습니다.', }; } const runtimeInfo = await inspectRuntime(definition); const buildInfo = await inspectBuild(definition); const deployment = definition.key === 'work-server' ? await readWorkServerDeploymentState() : null; const fallbackAttempt = selectedAttempt.url !== definition.checkUrl ? `fallback health check succeeded via ${selectedAttempt.url}` : null; const collectedErrors = attempts .filter((attempt) => attempt.errorMessage) .map((attempt) => `${attempt.url} -> ${attempt.errorMessage}`); const errorMessage = runtimeInfo.errorMessage ?? selectedAttempt.errorMessage ?? (fallbackAttempt && collectedErrors.length > 0 ? trimPreview([fallbackAttempt, ...collectedErrors].join(' | '), 400) : collectedErrors.length > 0 ? trimPreview(collectedErrors.join(' | '), 400) : fallbackAttempt); return { key: definition.key, label: definition.label, summary: definition.summary, environment: definition.environment, publicUrl: definition.publicUrl, checkUrl: definition.checkUrl, composeFile: definition.composeFile, serviceName: definition.serviceName, availability: runtimeInfo.availability ?? selectedAttempt.availability, httpStatus: selectedAttempt.httpStatus, contentType: selectedAttempt.contentType, responsePreview: runtimeInfo.responsePreview ?? selectedAttempt.responsePreview, checkedAt: new Date().toISOString(), startedAt: runtimeInfo.startedAt, runningVersion: buildInfo.runningVersion, runningBuiltAt: buildInfo.runningBuiltAt, latestVersion: buildInfo.latestVersion, latestBuiltAt: buildInfo.latestBuiltAt, latestSourceChangeAt: buildInfo.latestSourceChangeAt, latestSourceChangePath: buildInfo.latestSourceChangePath, buildRequired: buildInfo.buildRequired, updateAvailable: buildInfo.updateAvailable, updateSummary: buildInfo.updateSummary, responseTimeMs: Date.now() - startedAt, composeStatus: definition.key === 'work-server' && deployment?.status === 'running' ? 'deploying' : runtimeInfo.composeStatus, composeDetails: definition.key === 'work-server' && deployment ? appendComposeDetails([ runtimeInfo.composeDetails, deployment.status !== 'idle' ? `deploy:${deployment.status}${deployment.targetSlot ? `:${deployment.targetSlot}` : ''}` : null, ]) : runtimeInfo.composeDetails, lastCommand: buildRestartCommandPreview(definition), commandScript: definition.commandScript, commandWorkingDirectory: definition.commandWorkingDirectory, errorMessage: deployment?.status === 'failed' && deployment.lastError ? trimPreview([deployment.lastError, errorMessage].filter(Boolean).join(' | '), 400) : errorMessage, deployment, }; } export async function listServerCommands() { const definitions = getServerDefinitions(); return Promise.all(definitions.map((definition) => checkServer(definition))); } export async function restartServerCommand(key: ServerCommandKey): Promise { const definition = getServerDefinition(key); if (normalizeOptionalUrl(env.SERVER_COMMAND_API_BASE_URL)) { try { return await restartServerCommandViaApi(definition); } catch (error) { if (!shouldFallbackFromRemoteRestart(error)) { throw error; } } } let stdout = ''; let stderr = ''; if (definition.restartStrategy === 'deferred') { return restartServerCommandDeferred(definition); } try { const commandResult = await executeServerCommandScript(definition); stdout = commandResult.stdout; stderr = commandResult.stderr; } catch (error) { if (shouldRetryWithDockerSocket(error)) { try { const commandResult = await restartViaDockerSocket(definition); stdout = commandResult.stdout; stderr = commandResult.stderr; } catch (socketError) { const restartError = new Error(buildRestartFailureMessage(definition.label, socketError)); (restartError as Error & { statusCode?: number }).statusCode = 500; throw restartError; } } else { const restartError = new Error(buildRestartFailureMessage(definition.label, error)); (restartError as Error & { statusCode?: number }).statusCode = 500; throw restartError; } } const server = await checkServer(definition); return { server, commandOutput: trimPreview([stdout, stderr].filter(Boolean).join('\n'), 400), restartState: 'completed', deployment: server.deployment, }; } export async function deployWorkServerCommand(): Promise { return restartServerCommand('work-server'); } export async function deployTestServerCommand(): Promise { const testDefinition = getServerDefinition('test'); const testDeployment = await startTestServerDeployment(); const server = await checkServer(testDefinition); return { server, commandOutput: 'TEST 배포를 시작했습니다. origin/main 푸시, 테스트 빌드, 테스트 배포 과정을 확인합니다.', restartState: 'accepted', testDeployment: testDeployment ?? (await readTestServerDeploymentState()), }; }