Files
ai-code-app/scripts/run-server-command-runner.mjs
2026-04-21 03:33:23 +09:00

1046 lines
30 KiB
JavaScript

import { createServer } from 'node:http';
import { execFile, spawn } from 'node:child_process';
import { access, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const host = process.env.SERVER_COMMAND_RUNNER_HOST?.trim() || '0.0.0.0';
const port = Number(process.env.SERVER_COMMAND_RUNNER_PORT?.trim() || '3211');
const accessToken = process.env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() || 'local-server-command-runner';
const runnerLogFile = process.env.SERVER_COMMAND_RUNNER_LOG_FILE?.trim() || '/tmp/server-command-runner.log';
const heartbeatFile =
process.env.SERVER_COMMAND_RUNNER_HEARTBEAT_FILE?.trim() || path.join(projectRoot, '.server-command-runner-heartbeat.json');
const startedAt = new Date().toISOString();
const runnerLogMaxBytes = Math.max(256 * 1024, Number(process.env.SERVER_COMMAND_RUNNER_LOG_MAX_BYTES?.trim() || `${5 * 1024 * 1024}`));
const runnerLogTrimToBytes = Math.max(
128 * 1024,
Math.min(
runnerLogMaxBytes,
Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_TO_BYTES?.trim() || `${2 * 1024 * 1024}`),
),
);
const runnerLogTrimIntervalMs = Math.max(
15_000,
Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'),
);
const cpuWatchdogEnabled = process.env.SERVER_COMMAND_CPU_WATCHDOG_ENABLED?.trim() !== 'false';
const cpuWatchdogIntervalMs = Math.max(15_000, Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS?.trim() || '60000'));
const cpuWatchdogThresholdPercent = Math.max(
10,
Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT?.trim() || '120'),
);
const cpuWatchdogConsecutiveLimit = Math.max(
2,
Math.round(Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT?.trim() || '8')),
);
const cpuWatchdogCooldownMs = Math.max(
60_000,
Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS?.trim() || '1200000'),
);
const STREAM_CAPTURE_LIMIT = 256 * 1024;
const CODEX_HOME_RUNTIME_PATHS = [
'auth.json',
'config.toml',
'rules',
'skills',
'vendor_imports',
'models_cache.json',
'version.json',
];
const activeCodexExecutions = new Map();
const commandDefinitions = {
test: {
label: 'TEST',
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-test.sh'),
workingDirectory: projectRoot,
env: {
MAIN_PROJECT_ROOT: projectRoot,
SERVER_COMMAND_COMPOSE_FILE: path.join(projectRoot, 'docker-compose.yml'),
SERVER_COMMAND_SERVICE: process.env.SERVER_COMMAND_TEST_SERVICE?.trim() || 'app',
SERVER_COMMAND_CONTAINER_NAME: process.env.SERVER_COMMAND_TEST_CONTAINER_NAME?.trim() || 'ai-code-app-app-1',
},
restartStrategy: 'deferred',
},
rel: {
label: 'REL',
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-rel.sh'),
workingDirectory: projectRoot,
env: {
MAIN_PROJECT_ROOT: projectRoot,
SERVER_COMMAND_COMPOSE_FILE: path.join(projectRoot, 'docker-compose.yml'),
SERVER_COMMAND_SERVICE: process.env.SERVER_COMMAND_REL_SERVICE?.trim() || 'release-app',
},
restartStrategy: 'deferred',
},
'work-server': {
label: 'WORK-SERVER',
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-work-server.sh'),
workingDirectory: projectRoot,
env: {
REPO_ROOT: projectRoot,
},
restartStrategy: 'deferred',
},
'command-runner': {
label: 'COMMAND-RUNNER',
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-server-command-runner.sh'),
workingDirectory: projectRoot,
env: {
PROJECT_ROOT: projectRoot,
SERVER_COMMAND_RUNNER_NODE_BIN:
process.env.SERVER_COMMAND_RUNNER_NODE_BIN?.trim() || 'node',
SERVER_COMMAND_RUNNER_HOST: process.env.SERVER_COMMAND_RUNNER_HOST?.trim() || '127.0.0.1',
SERVER_COMMAND_RUNNER_PORT: process.env.SERVER_COMMAND_RUNNER_PORT?.trim() || '3211',
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: process.env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() || accessToken,
},
restartStrategy: 'deferred',
},
};
function normalizeRepoPathCandidate(value) {
const normalized = String(value ?? '').trim();
return normalized ? path.resolve(normalized) : '';
}
function translateWorkspacePathToHost(inputPath) {
const normalizedInput = normalizeRepoPathCandidate(inputPath);
if (!normalizedInput) {
return normalizedInput;
}
const replacements = [
['/workspace/main-project', projectRoot],
['/workspace/auto_codex/repo', projectRoot],
];
for (const [workspacePrefix, hostPrefix] of replacements) {
if (normalizedInput === workspacePrefix) {
return hostPrefix;
}
if (normalizedInput.startsWith(`${workspacePrefix}${path.sep}`)) {
return path.join(hostPrefix, normalizedInput.slice(workspacePrefix.length + 1));
}
}
return normalizedInput;
}
const cpuWatchdogTargets = [
{
name: 'test-app',
containerName: 'ai-code-app-app-1',
restartMode: 'command',
restartKey: 'test',
},
{
name: 'release-app',
containerName: 'ai-code-app-release',
restartMode: 'command',
restartKey: 'rel',
},
{
name: 'prod-app',
containerName: 'ai-code-app-prod',
restartMode: 'docker',
},
{
name: 'work-server',
containerName: 'work-server',
restartMode: 'command',
restartKey: 'work-server',
},
];
const cpuWatchdogState = new Map(
cpuWatchdogTargets.map((target) => [
target.containerName,
{
lastCpuPercent: null,
breachCount: 0,
lastSampleAt: null,
lastRestartAt: null,
lastRestartReason: null,
},
]),
);
let cpuWatchdogBusy = false;
function trimOutput(value, maxLength = 400) {
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
}
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
});
response.end(JSON.stringify(payload));
}
async function trimRunnerLogIfNeeded() {
try {
const logStat = await stat(runnerLogFile);
if (!logStat.isFile() || logStat.size <= runnerLogMaxBytes) {
return;
}
const content = await readFile(runnerLogFile, 'utf8');
const trimmedContent = content.slice(-runnerLogTrimToBytes);
const boundaryIndex = trimmedContent.indexOf('\n');
const nextContent = boundaryIndex >= 0 ? trimmedContent.slice(boundaryIndex + 1) : trimmedContent;
await writeFile(runnerLogFile, `[log-trimmed ${new Date().toISOString()}]\n${nextContent}`, 'utf8');
} catch {
// ignore log trim failures so the runner itself keeps serving requests
}
}
async function writeHeartbeat() {
await mkdir(path.dirname(heartbeatFile), { recursive: true });
await writeFile(
heartbeatFile,
JSON.stringify(
{
ok: true,
service: 'server-command-runner',
pid: process.pid,
host,
port,
cwd: projectRoot,
startedAt,
updatedAt: new Date().toISOString(),
cpuWatchdog: {
enabled: cpuWatchdogEnabled,
intervalMs: cpuWatchdogIntervalMs,
thresholdPercent: cpuWatchdogThresholdPercent,
consecutiveLimit: cpuWatchdogConsecutiveLimit,
cooldownMs: cpuWatchdogCooldownMs,
targets: cpuWatchdogTargets.map((target) => {
const state = cpuWatchdogState.get(target.containerName);
return {
name: target.name,
containerName: target.containerName,
restartMode: target.restartMode,
lastCpuPercent: state?.lastCpuPercent ?? null,
breachCount: state?.breachCount ?? 0,
lastSampleAt: state?.lastSampleAt ?? null,
lastRestartAt: state?.lastRestartAt ?? null,
lastRestartReason: state?.lastRestartReason ?? null,
};
}),
},
},
null,
2,
),
'utf8',
);
}
function parseCpuPercentage(value) {
const numeric = Number(String(value ?? '').replace('%', '').trim());
return Number.isFinite(numeric) ? numeric : null;
}
async function restartContainerByDocker(containerName) {
await execFileAsync('docker', ['restart', containerName], {
cwd: projectRoot,
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
}
async function sampleCpuWatchdog() {
if (!cpuWatchdogEnabled || cpuWatchdogBusy || cpuWatchdogTargets.length === 0) {
return;
}
cpuWatchdogBusy = true;
try {
const { stdout } = await execFileAsync(
'docker',
[
'stats',
'--no-stream',
'--format',
'{{json .}}',
...cpuWatchdogTargets.map((target) => target.containerName),
],
{
cwd: projectRoot,
timeout: 15_000,
maxBuffer: 1024 * 1024,
},
);
const now = new Date().toISOString();
const sampledContainers = new Set();
for (const line of stdout.split('\n')) {
const trimmedLine = line.trim();
if (!trimmedLine) {
continue;
}
let parsed;
try {
parsed = JSON.parse(trimmedLine);
} catch {
continue;
}
const containerName = String(parsed.Name ?? '').trim();
const cpuPercent = parseCpuPercentage(parsed.CPUPerc);
const target = cpuWatchdogTargets.find((entry) => entry.containerName === containerName);
const state = cpuWatchdogState.get(containerName);
if (!target || !state) {
continue;
}
sampledContainers.add(containerName);
state.lastCpuPercent = cpuPercent;
state.lastSampleAt = now;
state.breachCount = cpuPercent != null && cpuPercent >= cpuWatchdogThresholdPercent ? state.breachCount + 1 : 0;
const cooldownPassed =
!state.lastRestartAt || Date.now() - new Date(state.lastRestartAt).getTime() >= cpuWatchdogCooldownMs;
if (state.breachCount < cpuWatchdogConsecutiveLimit || !cooldownPassed) {
continue;
}
const restartReason = `cpu ${cpuPercent?.toFixed(1) ?? '?'}% sustained for ${
state.breachCount
} samples`;
process.stdout.write(
`[cpu-watchdog] restarting ${target.containerName} because ${restartReason} (threshold ${cpuWatchdogThresholdPercent}%)\n`,
);
if (target.restartMode === 'command' && target.restartKey) {
await runRestartCommand(target.restartKey);
} else {
await restartContainerByDocker(target.containerName);
}
state.breachCount = 0;
state.lastRestartAt = new Date().toISOString();
state.lastRestartReason = restartReason;
await writeHeartbeat().catch(() => {
// noop
});
}
for (const target of cpuWatchdogTargets) {
if (sampledContainers.has(target.containerName)) {
continue;
}
const state = cpuWatchdogState.get(target.containerName);
if (!state) {
continue;
}
state.lastCpuPercent = null;
state.lastSampleAt = now;
state.breachCount = 0;
}
} catch (error) {
process.stdout.write(
`[cpu-watchdog] sample failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
} finally {
cpuWatchdogBusy = false;
}
}
void trimRunnerLogIfNeeded();
setInterval(() => {
void trimRunnerLogIfNeeded();
}, runnerLogTrimIntervalMs).unref();
function sendJsonLine(response, payload) {
response.write(`${JSON.stringify(payload)}\n`);
}
function readRequestBody(request, maxBytes = 4 * 1024 * 1024) {
return new Promise((resolve, reject) => {
let total = 0;
const chunks = [];
request.on('data', (chunk) => {
total += chunk.length;
if (total > maxBytes) {
reject(new Error('요청 본문이 너무 큽니다.'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
request.on('error', reject);
});
}
function summarizeCodexOutput(output) {
const normalized = String(output ?? '').trim();
if (!normalized) {
return 'Codex 실행 결과가 비어 있습니다.';
}
return normalized
.split('\n')
.map((line) => line.trimEnd())
.filter(Boolean)
.slice(-12)
.join('\n');
}
function summarizeCommand(command, limit = 180) {
const normalized = String(command ?? '').replace(/\s+/g, ' ').trim();
return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized;
}
function summarizeCommandOutput(output, 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) {
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) {
const normalized = String(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 collectCodexTextFragments(value) {
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;
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 extractCodexStreamText(parsed) {
const type = typeof parsed.type === 'string' ? parsed.type : '';
if (type === 'item.completed') {
return {
completedText: collectCodexTextFragments(parsed.item).join(''),
deltaText: '',
};
}
if (type === 'item.delta' || type === 'response.output_text.delta') {
return {
completedText: '',
deltaText:
type === 'response.output_text.delta'
? collectCodexTextFragments(parsed.delta ?? parsed.text).join('')
: collectCodexTextFragments(parsed.delta).join(''),
};
}
if (type === 'response.completed') {
return {
completedText: collectCodexTextFragments(parsed.response).join(''),
deltaText: '',
};
}
return {
completedText: '',
deltaText: '',
};
}
function extractCodexActivityLog(parsed) {
const type = typeof parsed.type === 'string' ? parsed.type : '';
const item = parsed.item && typeof parsed.item === 'object' ? parsed.item : 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 '';
}
async function prepareWritableCodexHome(tempDir) {
const writableCodexHome = path.join(tempDir, '.codex');
const sourceCodexHome =
process.env.CODEX_HOME_TEMPLATE?.trim() ||
process.env.CODEX_HOME?.trim() ||
path.join(process.env.HOME ?? '/root', '.codex');
await mkdir(writableCodexHome, { recursive: true });
for (const relativePath of CODEX_HOME_RUNTIME_PATHS) {
const sourcePath = path.join(sourceCodexHome, relativePath);
const targetPath = path.join(writableCodexHome, relativePath);
try {
await access(sourcePath);
} catch {
continue;
}
await mkdir(path.dirname(targetPath), { recursive: true });
await cp(sourcePath, targetPath, { recursive: true, force: true });
}
return writableCodexHome;
}
async function validateCodexExecutionRuntime(repoPath, codexBin) {
const issues = [];
try {
const repoStat = await stat(repoPath);
if (!repoStat.isDirectory()) {
issues.push(`repoPath 경로가 디렉터리가 아닙니다: ${repoPath}`);
}
} catch {
issues.push(`repoPath 경로를 찾지 못했습니다: ${repoPath}`);
}
const trimmedCodexBin = String(codexBin ?? '').trim();
const pathCandidates =
trimmedCodexBin && !trimmedCodexBin.includes(path.sep)
? (process.env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.join(entry, trimmedCodexBin))
: [trimmedCodexBin];
let codexResolved = false;
for (const candidate of pathCandidates) {
try {
await access(candidate);
codexResolved = true;
break;
} catch {
// try next candidate
}
}
if (!codexResolved) {
issues.push(`Codex 실행 파일을 찾지 못했습니다: ${codexBin}`);
}
if (issues.length > 0) {
throw new Error(issues.join(' / '));
}
}
async function runCodexLiveExecution(payload, response) {
const requestId = String(payload?.requestId ?? '').trim();
const sessionId = String(payload?.sessionId ?? '').trim();
const repoPath = translateWorkspacePathToHost(String(payload?.repoPath ?? '').trim() || projectRoot);
const prompt = String(payload?.prompt ?? '');
const resourceDir = translateWorkspacePathToHost(
String(payload?.resourceDir ?? path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource')),
);
const uploadDir = translateWorkspacePathToHost(
String(payload?.uploadDir ?? path.join(resourceDir, 'uploads')),
);
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex';
if (!requestId || !sessionId || !prompt.trim()) {
sendJson(response, 400, {
message: 'requestId, sessionId, prompt는 필수입니다.',
});
return;
}
await validateCodexExecutionRuntime(repoPath, codexBin);
await mkdir(resourceDir, { recursive: true });
await mkdir(uploadDir, { recursive: true });
const tempDir = await mkdtemp(path.join(tmpdir(), 'command-runner-codex-'));
const writableCodexHome = await prepareWritableCodexHome(tempDir);
let stdoutTail = '';
let stderrTail = '';
let jsonLineBuffer = '';
let completedText = '';
let responseClosed = false;
response.writeHead(200, {
'content-type': 'application/x-ndjson; charset=utf-8',
'cache-control': 'no-store',
});
const child = spawn(
codexBin,
['exec', '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'],
{
cwd: repoPath,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
CODEX_HOME: writableCodexHome,
CODEX_LIVE_CHAT_SESSION_ID: sessionId,
CODEX_LIVE_CHAT_RESOURCE_DIR: resourceDir,
CODEX_LIVE_CHAT_UPLOAD_DIR: uploadDir,
},
},
);
activeCodexExecutions.set(requestId, {
child,
tempDir,
});
sendJsonLine(response, {
type: 'started',
pid: child.pid ?? null,
});
const cleanup = async () => {
activeCodexExecutions.delete(requestId);
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
};
const handleCodexJsonLine = (line) => {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
return false;
}
const activityLog = extractCodexActivityLog(parsed);
if (activityLog) {
sendJsonLine(response, {
type: 'activity',
line: activityLog,
});
}
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
if (nextCompletedText) {
completedText = nextCompletedText;
sendJsonLine(response, {
type: 'completed',
text: nextCompletedText,
});
return true;
}
if (deltaText) {
sendJsonLine(response, {
type: 'delta',
text: deltaText,
});
return true;
}
return false;
};
response.on('close', () => {
responseClosed = true;
});
child.stdout?.on('data', (chunk) => {
const text = String(chunk);
stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT);
jsonLineBuffer += text;
const lines = jsonLineBuffer.split('\n');
jsonLineBuffer = lines.pop() ?? '';
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
if (handleCodexJsonLine(line)) {
continue;
}
if (!line.startsWith('{') && !isIgnorableCodexDiagnosticLine(line)) {
sendJsonLine(response, {
type: 'stdout',
line,
});
}
}
});
child.stderr?.on('data', (chunk) => {
const text = String(chunk);
stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT);
text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
if (isIgnorableCodexDiagnosticLine(line)) {
return;
}
sendJsonLine(response, {
type: 'stderr',
line,
});
});
});
child.on('error', async (error) => {
if (!responseClosed) {
sendJsonLine(response, {
type: 'error',
message: error instanceof Error ? error.message : String(error),
});
response.end();
}
await cleanup();
});
child.on('close', async (code) => {
const trailingLine = jsonLineBuffer.trim();
if (trailingLine) {
handleCodexJsonLine(trailingLine);
}
if (!responseClosed) {
if (code !== 0) {
sendJsonLine(response, {
type: 'error',
message: summarizeCodexOutput(`${stderrTail}\n${completedText}\n${stdoutTail}`),
});
}
response.end();
}
await cleanup();
});
child.stdin?.end(prompt);
}
function isAuthorized(request) {
const token = String(request.headers['x-access-token'] ?? '').trim();
return token.length > 0 && token === accessToken;
}
async function runRestartCommand(key) {
const definition = commandDefinitions[key];
if (!definition) {
return {
statusCode: 404,
payload: {
message: '지원하지 않는 서버입니다.',
},
};
}
if (definition.restartStrategy === 'deferred') {
await new Promise((resolve, reject) => {
const child = spawn(
'sh',
['-c', `sleep 1 && exec sh "${definition.scriptPath}" >/tmp/${key}-restart.log 2>&1`],
{
cwd: definition.workingDirectory,
detached: true,
stdio: 'ignore',
env: {
...process.env,
...definition.env,
},
},
);
child.once('error', reject);
child.once('spawn', () => {
child.unref();
resolve();
});
});
return {
statusCode: 202,
payload: {
ok: true,
restartState: 'accepted',
commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
},
};
}
try {
const commandResult = await execFileAsync('sh', [definition.scriptPath], {
cwd: definition.workingDirectory,
timeout: 30000,
maxBuffer: 1024 * 1024,
env: {
...process.env,
...definition.env,
},
});
return {
statusCode: 200,
payload: {
ok: true,
restartState: 'completed',
commandOutput: trimOutput([commandResult.stdout, commandResult.stderr].filter(Boolean).join('\n')),
},
};
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
const detail = trimOutput(
[
failure instanceof Error && 'stderr' in failure ? String(failure.stderr ?? '') : '',
failure instanceof Error && 'stdout' in failure ? String(failure.stdout ?? '') : '',
failure.message,
]
.filter(Boolean)
.join('\n'),
);
const code =
failure instanceof Error && 'code' in failure && (typeof failure.code === 'number' || typeof failure.code === 'string')
? String(failure.code)
: null;
return {
statusCode: 500,
payload: {
message: `${definition.label} 재기동에 실패했습니다.${code ? ` exit:${code}` : ''}${detail ? ` ${detail}` : ''}`,
},
};
}
}
const server = createServer(async (request, response) => {
const requestUrl = new URL(request.url || '/', `http://${request.headers.host || `${host}:${port}`}`);
if (request.method === 'GET' && requestUrl.pathname === '/health') {
sendJson(response, 200, {
ok: true,
service: 'server-command-runner',
cwd: projectRoot,
});
return;
}
if (!isAuthorized(request)) {
sendJson(response, 403, {
message: '권한 토큰이 필요합니다.',
});
return;
}
const matchedPath = requestUrl.pathname.match(/^\/api\/server-commands\/([^/]+)\/actions\/restart$/);
if (request.method === 'POST' && matchedPath) {
const key = decodeURIComponent(matchedPath[1]);
const result = await runRestartCommand(key);
sendJson(response, result.statusCode, result.payload);
return;
}
if (request.method === 'POST' && requestUrl.pathname === '/api/codex-live/execute') {
try {
const rawBody = await readRequestBody(request);
const payload = rawBody ? JSON.parse(rawBody) : {};
await runCodexLiveExecution(payload, response);
} catch (error) {
sendJson(response, 500, {
message: error instanceof Error ? error.message : 'Codex 실행 요청을 처리하지 못했습니다.',
});
}
return;
}
const cancelMatch = requestUrl.pathname.match(/^\/api\/codex-live\/jobs\/([^/]+)\/cancel$/);
if (request.method === 'POST' && cancelMatch) {
const requestId = decodeURIComponent(cancelMatch[1]);
const activeExecution = activeCodexExecutions.get(requestId);
if (!activeExecution) {
sendJson(response, 404, {
cancelled: false,
message: '실행 중인 Codex 작업을 찾지 못했습니다.',
});
return;
}
try {
activeExecution.child.kill('SIGTERM');
setTimeout(() => {
const current = activeCodexExecutions.get(requestId);
if (current?.child && !current.child.killed) {
current.child.kill('SIGKILL');
}
}, 3000).unref?.();
sendJson(response, 200, {
cancelled: true,
});
} catch (error) {
sendJson(response, 500, {
cancelled: false,
message: error instanceof Error ? error.message : 'Codex 작업 취소에 실패했습니다.',
});
}
return;
}
sendJson(response, 404, {
message: '지원하지 않는 경로입니다.',
});
});
server.listen(port, host, () => {
void writeHeartbeat().catch(() => {
// noop
});
const heartbeatTimer = setInterval(() => {
void writeHeartbeat().catch(() => {
// noop
});
}, 10_000);
heartbeatTimer.unref();
if (cpuWatchdogEnabled) {
const cpuWatchdogTimer = setInterval(() => {
void sampleCpuWatchdog();
}, cpuWatchdogIntervalMs);
cpuWatchdogTimer.unref();
void sampleCpuWatchdog();
}
process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`);
});