Files
ai-code-app/scripts/run-server-command-runner.mjs

954 lines
27 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 STREAM_CAPTURE_LIMIT = 256 * 1024;
const CODEX_LIVE_IDLE_TIMEOUT_MS = Math.max(
30_000,
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '90000'),
);
const CODEX_LIVE_MAX_EXECUTION_MS = Math.max(
CODEX_LIVE_IDLE_TIMEOUT_MS,
Number(process.env.SERVER_COMMAND_RUNNER_CODEX_MAX_EXECUTION_MS?.trim() || `${10 * 60 * 1000}`),
);
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',
},
prod: {
label: 'PROD',
scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-prod.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_PROD_SERVICE?.trim() || 'prod-app',
SERVER_COMMAND_CONTAINER_NAME: process.env.SERVER_COMMAND_PROD_CONTAINER_NAME?.trim() || 'ai-code-app-prod',
},
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;
}
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(),
},
null,
2,
),
'utf8',
);
}
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 extractCompletedAgentMessageText(item) {
if (!item || typeof item !== 'object') {
return '';
}
if (item.type !== 'agent_message') {
return '';
}
return collectCodexTextFragments(item.text ?? item.content ?? item.message).join('');
}
function extractCodexStreamText(parsed) {
const type = typeof parsed.type === 'string' ? parsed.type : '';
if (type === 'item.completed') {
return {
completedText: extractCompletedAgentMessageText(parsed.item),
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 = projectRoot;
const prompt = String(payload?.prompt ?? '');
const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource');
const uploadDir = path.join(resourceDir, 'uploads');
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex';
const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds);
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;
let idleTimer = null;
let executionTimer = null;
let terminationRequested = 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 clearExecutionTimers = () => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
if (executionTimer) {
clearTimeout(executionTimer);
executionTimer = null;
}
};
const requestTermination = (message) => {
if (terminationRequested) {
return;
}
terminationRequested = true;
clearExecutionTimers();
if (!responseClosed) {
sendJsonLine(response, {
type: 'error',
message,
});
response.end();
responseClosed = true;
}
child.kill('SIGTERM');
setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, 3000).unref?.();
};
const refreshIdleTimer = () => {
if (terminationRequested) {
return;
}
if (idleTimer) {
clearTimeout(idleTimer);
}
idleTimer = setTimeout(() => {
requestTermination(
`Codex Live 실행이 ${Math.round(CODEX_LIVE_IDLE_TIMEOUT_MS / 1000)}초 동안 출력이 없어 중단되었습니다.`,
);
}, CODEX_LIVE_IDLE_TIMEOUT_MS);
idleTimer.unref?.();
};
executionTimer = setTimeout(() => {
requestTermination(
`Codex Live 실행이 ${Math.round(configuredMaxExecutionMs / 1000)}초를 넘어 중단되었습니다.`,
);
}, configuredMaxExecutionMs);
executionTimer.unref?.();
refreshIdleTimer();
const handleCodexJsonLine = (line) => {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
return false;
}
const activityLog = extractCodexActivityLog(parsed);
if (activityLog) {
refreshIdleTimer();
sendJsonLine(response, {
type: 'activity',
line: activityLog,
});
}
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
if (nextCompletedText) {
refreshIdleTimer();
completedText = nextCompletedText;
sendJsonLine(response, {
type: 'completed',
text: nextCompletedText,
});
return true;
}
if (deltaText) {
refreshIdleTimer();
sendJsonLine(response, {
type: 'delta',
text: deltaText,
});
return true;
}
return false;
};
response.on('close', () => {
responseClosed = true;
clearExecutionTimers();
});
child.stdout?.on('data', (chunk) => {
refreshIdleTimer();
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) => {
refreshIdleTimer();
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) => {
clearExecutionTimers();
if (!responseClosed) {
sendJsonLine(response, {
type: 'error',
message: error instanceof Error ? error.message : String(error),
});
response.end();
}
await cleanup();
});
child.on('close', async (code) => {
clearExecutionTimers();
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 resolveCodexLiveMaxExecutionMs(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return CODEX_LIVE_MAX_EXECUTION_MS;
}
return Math.max(CODEX_LIVE_IDLE_TIMEOUT_MS, Math.min(7_200_000, Math.max(60_000, Math.round(value * 1000))));
}
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();
process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`);
});