feat: update codex live runtime and restart flow

This commit is contained in:
2026-04-23 18:10:43 +09:00
parent b0b9980a6c
commit 6e863feafd
36 changed files with 1636 additions and 358 deletions

View File

@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}"
SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-prod-app}"
SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod}"
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}"
SERVER_COMMAND_PROD_GIT_REMOTE="${SERVER_COMMAND_PROD_GIT_REMOTE:-origin}"
SERVER_COMMAND_PROD_GIT_BRANCH="${SERVER_COMMAND_PROD_GIT_BRANCH:-main}"
SERVER_COMMAND_PROD_GIT_USERNAME="${SERVER_COMMAND_PROD_GIT_USERNAME:-}"
SERVER_COMMAND_PROD_GIT_PASSWORD="${SERVER_COMMAND_PROD_GIT_PASSWORD:-}"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$MAIN_PROJECT_ROOT"
if ! command -v git >/dev/null 2>&1; then
echo "git CLI not found: cannot pull ${SERVER_COMMAND_PROD_GIT_REMOTE}/${SERVER_COMMAND_PROD_GIT_BRANCH}" >&2
exit 127
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
if [ "$CURRENT_BRANCH" != "$SERVER_COMMAND_PROD_GIT_BRANCH" ]; then
echo "expected branch ${SERVER_COMMAND_PROD_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2
exit 1
fi
if [ -n "$SERVER_COMMAND_PROD_GIT_USERNAME" ] && [ -n "$SERVER_COMMAND_PROD_GIT_PASSWORD" ]; then
REMOTE_URL=$(git remote get-url "$SERVER_COMMAND_PROD_GIT_REMOTE")
CREDENTIAL_STORE=$(mktemp)
trap 'rm -f "$CREDENTIAL_STORE"' EXIT INT TERM
printf '%s\n' "$REMOTE_URL" \
| sed "s#^https://#https://${SERVER_COMMAND_PROD_GIT_USERNAME}:${SERVER_COMMAND_PROD_GIT_PASSWORD}@#" \
>"$CREDENTIAL_STORE"
git -c "credential.helper=store --file=$CREDENTIAL_STORE" pull --ff-only "$SERVER_COMMAND_PROD_GIT_REMOTE" "$SERVER_COMMAND_PROD_GIT_BRANCH"
else
git pull --ff-only "$SERVER_COMMAND_PROD_GIT_REMOTE" "$SERVER_COMMAND_PROD_GIT_BRANCH"
fi
if command -v docker >/dev/null 2>&1; then
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "$SERVER_COMMAND_SERVICE"
fi
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME"
fi
echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2
exit 127

View File

@@ -12,11 +12,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$MAIN_PROJECT_ROOT"
if command -v docker >/dev/null 2>&1; then
if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then
exit 0
fi
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "$SERVER_COMMAND_SERVICE"
fi
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then

View File

@@ -12,11 +12,6 @@ RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
RUNNER_CPU_WATCHDOG_ENABLED="${SERVER_COMMAND_CPU_WATCHDOG_ENABLED:-true}"
RUNNER_CPU_WATCHDOG_INTERVAL_MS="${SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS:-60000}"
RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT="${SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT:-120}"
RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT="${SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT:-8}"
RUNNER_CPU_WATCHDOG_COOLDOWN_MS="${SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS:-1200000}"
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
@@ -43,11 +38,6 @@ setsid env \
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
SERVER_COMMAND_CPU_WATCHDOG_ENABLED="$RUNNER_CPU_WATCHDOG_ENABLED" \
SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS="$RUNNER_CPU_WATCHDOG_INTERVAL_MS" \
SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT="$RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT" \
SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT="$RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT" \
SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS="$RUNNER_CPU_WATCHDOG_COOLDOWN_MS" \
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
echo "server-command-runner restart requested"

View File

@@ -42,6 +42,11 @@ SERVER_COMMAND_MAIN_PROJECT_ROOT=/workspace/main-project
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/
SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/
SERVER_COMMAND_PROD_URL=https://sm-home.cloud/
SERVER_COMMAND_PROD_GIT_REMOTE=origin
SERVER_COMMAND_PROD_GIT_BRANCH=main
SERVER_COMMAND_PROD_GIT_USERNAME=
SERVER_COMMAND_PROD_GIT_PASSWORD=
SERVER_COMMAND_WORK_SERVER_URL=http://127.0.0.1:3100/health
SERVER_COMMAND_RUNNER_URL=http://host.docker.internal:3211/health
SERVER_COMMAND_RUNNER_ACCESS_TOKEN=local-server-command-runner
@@ -49,4 +54,5 @@ SERVER_COMMAND_RUNNER_HOST=0.0.0.0
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE=/workspace/main-project/.server-command-runner-heartbeat.json
SERVER_COMMAND_TEST_SERVICE=app
SERVER_COMMAND_REL_SERVICE=release-app
SERVER_COMMAND_PROD_SERVICE=prod-app
SERVER_COMMAND_WORK_SERVER_SERVICE=work-server

View File

@@ -19,7 +19,7 @@ docker compose logs -f work-server
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner도 함께 켭니다.
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.
```bash
cd /home/how2ice/project/ai-code-app
@@ -53,7 +53,9 @@ npm run server-command:runner
## Codex Live
`Codex Live`는 현재 프로젝트 환경 `main_project` 경로를 기준으로 실행됩니다. 기본값은 `PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project`며, 소스 수정이 필요하면 이 경로의 실제 프로젝트를 바로 수정합니다.
`Codex Live`는 현재 프로젝트 환경에서 `PLAN_MAIN_PROJECT_REPO_PATH`로 지정된 `main_project` 저장소 루트를 기준으로 실행됩니다. 문서 예시와 기본 컨테이너 값은 `PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project`지만, 이것은 환경 변수 기본 예시일 뿐이며 실제 실행 루트는 현재 마운트된 프로젝트 경로를 따릅니다.
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다.
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 작업메모 반영 요청 모두 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. 별도 브랜치 생성이나 `release -> main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.

View File

@@ -71,12 +71,14 @@ const envSchema = z.object({
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'),
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
});

View File

@@ -7,6 +7,7 @@ import { z } from 'zod';
import { env } from '../config/env.js';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
createChatConversation,
deleteUnansweredChatConversationRequest,
@@ -16,6 +17,7 @@ import {
listChatConversationDetailPage,
listChatConversations,
markChatConversationResponsesRead,
upsertChatConversationRequest,
updateChatConversationContext,
} from '../services/chat-room-service.js';
import { chatRuntimeService } from '../services/chat-runtime-service.js';
@@ -308,11 +310,56 @@ export async function registerChatRoutes(app: FastifyInstance) {
};
});
app.post('/api/chat/runtime/jobs/:requestId/rollback', async (request, reply) => {
const params = z.object({
requestId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const payload = z
.object({
sessionId: z.string().trim().min(1).max(120).optional(),
})
.parse(request.body ?? {});
chatRuntimeService.appendLog(params.requestId, '사용자 요청으로 최근 실행 롤백을 시작합니다.');
try {
const result = await rollbackChatRuntimeRequest({
requestId: params.requestId,
sessionId: payload.sessionId,
});
await upsertChatConversationRequest(result.sessionId, {
requestId: result.requestId,
status: 'cancelled',
statusMessage: '사용자 요청으로 최근 실행 변경을 롤백했습니다.',
});
chatRuntimeService.setArchivedJobTerminalStatus(
params.requestId,
'cancelled',
'최근 실행이 롤백되어 상태를 취소로 변경했습니다.',
);
chatRuntimeService.appendLog(params.requestId, '최근 실행 롤백이 완료되었습니다.');
return {
ok: true,
...result,
};
} catch (error) {
const message = error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.';
chatRuntimeService.appendLog(params.requestId, `최근 실행 롤백 실패: ${message}`);
return reply.code(409).send({
message,
});
}
});
app.post('/api/chat/conversations', async (request) => {
const payload = z.object({
sessionId: z.string().trim().min(1).max(120),
title: z.string().trim().max(200).optional(),
chatTypeId: z.string().trim().max(120).nullable().optional(),
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
contextLabel: z.string().trim().max(200).optional(),
contextDescription: z.string().trim().max(2000).optional(),
notifyOffline: z.boolean().optional(),
@@ -324,6 +371,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
clientId: clientId || null,
title: payload.title ?? '새 대화',
chatTypeId: payload.chatTypeId ?? null,
lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null,
contextLabel: payload.contextLabel ?? null,
contextDescription: payload.contextDescription ?? null,
notifyOffline: payload.notifyOffline ?? true,
@@ -438,6 +486,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
const payload = z.object({
title: z.string().trim().min(1).max(200).optional(),
chatTypeId: z.string().trim().max(120).optional().nullable(),
lastChatTypeId: z.string().trim().max(120).optional().nullable(),
contextLabel: z.string().trim().max(200).optional().nullable(),
contextDescription: z.string().trim().max(2000).optional().nullable(),
notifyOffline: z.boolean().optional(),
@@ -456,6 +505,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
title: payload.title ?? current.title,
clientId: current.clientId,
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId,
contextLabel: payload.contextLabel ?? current.contextLabel,
contextDescription: payload.contextDescription ?? current.contextDescription,
notifyOffline: payload.notifyOffline ?? current.notifyOffline,

View File

@@ -15,6 +15,7 @@ const conversationPayloadSchema = z.object({
clientId: z.string().trim().max(120).nullable().optional(),
title: z.string().trim().max(200).nullable().optional(),
chatTypeId: z.string().trim().max(120).nullable().optional(),
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
contextLabel: z.string().trim().max(200).nullable().optional(),
contextDescription: z.string().trim().max(2000).nullable().optional(),
notifyOffline: z.boolean().optional(),
@@ -34,6 +35,7 @@ export type ChatConversationItem = {
clientId: string | null;
title: string;
chatTypeId: string | null;
lastChatTypeId: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
@@ -173,6 +175,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
clientId: row.client_id == null ? null : String(row.client_id),
title: String(row.title ?? '새 대화'),
chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id),
contextLabel: row.context_label == null ? null : String(row.context_label),
contextDescription: row.context_description == null ? null : String(row.context_description),
notifyOffline: Boolean(row.notify_offline),
@@ -709,6 +712,7 @@ export async function ensureChatConversationTables() {
table.string('client_id', 120).nullable().index();
table.string('title', 200).notNullable().defaultTo('새 대화');
table.string('chat_type_id', 120).nullable();
table.string('last_chat_type_id', 120).nullable();
table.string('context_label', 200).nullable();
table.text('context_description').nullable();
table.boolean('notify_offline').notNullable().defaultTo(false);
@@ -728,6 +732,7 @@ export async function ensureChatConversationTables() {
['client_id', (table) => table.string('client_id', 120).nullable().index()],
['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')],
['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()],
['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()],
['context_label', (table) => table.string('context_label', 200).nullable()],
['context_description', (table) => table.text('context_description').nullable()],
['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)],
@@ -1012,6 +1017,7 @@ export async function createChatConversation(payload: z.input<typeof conversatio
client_id: normalizedClientId,
title: parsed.title?.trim() || '새 대화',
chat_type_id: parsed.chatTypeId?.trim() || null,
last_chat_type_id: parsed.lastChatTypeId?.trim() || parsed.chatTypeId?.trim() || null,
context_label: parsed.contextLabel?.trim() || null,
context_description: parsed.contextDescription?.trim() || null,
notify_offline: notifyOffline,
@@ -1051,6 +1057,7 @@ export async function updateChatConversationContext(
title?: string | null;
clientId?: string | null;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean | null;
@@ -1069,6 +1076,7 @@ export async function updateChatConversationContext(
title: payload.title?.trim() || current.title || '새 대화',
client_id: normalizedClientId || current.client_id || null,
chat_type_id: payload.chatTypeId?.trim() || null,
last_chat_type_id: payload.lastChatTypeId?.trim() || null,
context_label: payload.contextLabel?.trim() || null,
context_description: payload.contextDescription?.trim() || null,
notify_offline:
@@ -1606,6 +1614,11 @@ export async function appendChatConversationMessage(
client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
title: nextTitle,
chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null,
last_chat_type_id:
conversation.chatTypeId?.trim() ||
currentConversation?.last_chat_type_id ||
currentConversation?.chat_type_id ||
null,
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
notify_offline:

View File

@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRollbackDiffText } from './chat-runtime-rollback-service.js';
test('normalizeRollbackDiffText strips chat resource prefixes from stored diff paths', () => {
const normalized = normalizeRollbackDiffText([
'diff --git a/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx b/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx',
'--- a/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx',
'+++ b/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx',
'@@',
'+const value = 1;',
'',
].join('\n'));
assert.match(normalized, /^diff --git a\/src\/app\/main\/MainHeader\.tsx b\/src\/app\/main\/MainHeader\.tsx/m);
assert.match(normalized, /^--- a\/src\/app\/main\/MainHeader\.tsx$/m);
assert.match(normalized, /^\+\+\+ b\/src\/app\/main\/MainHeader\.tsx$/m);
});
test('normalizeRollbackDiffText keeps the final repo path when resource prefixes were duplicated', () => {
const normalized = normalizeRollbackDiffText(
'diff --git a/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/api/chat/resources/.codex_chat/chat-room/resource/README.md b/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/api/chat/resources/.codex_chat/chat-room/resource/README.md\n',
);
assert.equal(normalized.trim(), 'diff --git a/README.md b/README.md');
});

View File

@@ -0,0 +1,174 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import { env } from '../config/env.js';
import { db } from '../db/client.js';
import { CHAT_CONVERSATION_REQUEST_TABLE } from './chat-room-service.js';
function extractDiffBlocks(text: string) {
return Array.from(String(text ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g))
.map((match) => (typeof match[1] === 'string' ? match[1].trim() : ''))
.filter(Boolean);
}
function resolveChatRepoPath() {
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
if (!repoPath?.trim()) {
throw new Error('롤백 대상 저장소 경로가 설정되지 않았습니다.');
}
return path.resolve(repoPath);
}
function summarizeGitApplyError(stderr: string) {
const normalized = String(stderr ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (normalized.length === 0) {
return '현재 작업트리가 이 요청 시점과 달라 자동 롤백을 적용할 수 없습니다.';
}
return normalized.slice(0, 3).join('\n');
}
function stripChatResourcePrefix(candidate: string) {
let normalized = String(candidate ?? '').replace(/\\/g, '/');
const marker = '/resource/';
while (normalized.includes(marker)) {
normalized = normalized.slice(normalized.indexOf(marker) + marker.length);
}
return normalized.replace(/^\/+/, '');
}
function normalizeRollbackDiffLine(line: string) {
const normalizedLine = String(line ?? '');
if (normalizedLine.startsWith('diff --git ')) {
return normalizedLine.replace(/^diff --git a\/(.+?) b\/(.+)$/, (_match, left, right) => {
return `diff --git a/${stripChatResourcePrefix(left)} b/${stripChatResourcePrefix(right)}`;
});
}
if (normalizedLine.startsWith('--- a/')) {
return `--- a/${stripChatResourcePrefix(normalizedLine.slice('--- a/'.length))}`;
}
if (normalizedLine.startsWith('+++ b/')) {
return `+++ b/${stripChatResourcePrefix(normalizedLine.slice('+++ b/'.length))}`;
}
if (normalizedLine.startsWith('rename from ')) {
return `rename from ${stripChatResourcePrefix(normalizedLine.slice('rename from '.length))}`;
}
if (normalizedLine.startsWith('rename to ')) {
return `rename to ${stripChatResourcePrefix(normalizedLine.slice('rename to '.length))}`;
}
return normalizedLine;
}
export function normalizeRollbackDiffText(diffText: string) {
return String(diffText ?? '')
.split('\n')
.map((line) => normalizeRollbackDiffLine(line))
.join('\n');
}
async function runGitApplyReverse(repoPath: string, diffText: string, checkOnly: boolean) {
return await new Promise<{ ok: boolean; stderr: string }>((resolve, reject) => {
const args = ['apply', '--reverse', '--whitespace=nowarn'];
if (checkOnly) {
args.push('--check');
}
args.push('-');
const child = spawn('git', args, {
cwd: repoPath,
stdio: ['pipe', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += String(chunk ?? '');
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
resolve({
ok: code === 0,
stderr,
});
});
child.stdin.write(diffText);
child.stdin.end();
});
}
export async function rollbackChatRuntimeRequest(args: { requestId: string; sessionId?: string | null }) {
const normalizedRequestId = args.requestId.trim();
const normalizedSessionId = args.sessionId?.trim() || null;
if (!normalizedRequestId) {
throw new Error('롤백할 요청 ID가 비어 있습니다.');
}
const repoPath = resolveChatRepoPath();
const row = await db(CHAT_CONVERSATION_REQUEST_TABLE)
.select('session_id', 'request_id', 'status', 'response_text')
.where('request_id', normalizedRequestId)
.modify((query) => {
if (normalizedSessionId) {
query.andWhere('session_id', normalizedSessionId);
}
})
.orderBy('updated_at', 'desc')
.first();
if (!row) {
throw new Error('롤백할 Codex Live 실행 기록을 찾지 못했습니다.');
}
const status = String(row.status ?? '').trim();
if (status === 'cancelled' || status === 'removed') {
throw new Error('취소되었거나 제거된 요청은 롤백할 수 없습니다.');
}
const diffBlocks = extractDiffBlocks(String(row.response_text ?? ''));
if (diffBlocks.length === 0) {
throw new Error('이 요청에는 롤백 가능한 diff 기록이 없습니다.');
}
const diffText = normalizeRollbackDiffText(`${diffBlocks.join('\n\n')}\n`);
const checkResult = await runGitApplyReverse(repoPath, diffText, true);
if (!checkResult.ok) {
throw new Error(summarizeGitApplyError(checkResult.stderr));
}
const applyResult = await runGitApplyReverse(repoPath, diffText, false);
if (!applyResult.ok) {
throw new Error(summarizeGitApplyError(applyResult.stderr));
}
return {
rolledBack: true,
requestId: normalizedRequestId,
sessionId: String(row.session_id ?? normalizedSessionId ?? ''),
diffBlockCount: diffBlocks.length,
};
}

View File

@@ -335,6 +335,32 @@ class ChatRuntimeService {
this.emit();
}
setArchivedJobTerminalStatus(requestId: string, terminalStatus: ChatRuntimeTerminalStatus, logLine?: string | null) {
const current = this.archivedJobs.get(requestId);
if (!current) {
return false;
}
const nextLogs = [...current.logs];
const normalizedLogLine = normalizeLogLine(String(logLine ?? ''));
if (normalizedLogLine) {
nextLogs.push(normalizedLogLine);
}
const next: RuntimeJobRecord = {
...current,
terminalStatus,
lastUpdatedAt: nowIso(),
logs: nextLogs.slice(-MAX_LOG_LINES),
};
this.archivedJobs.set(requestId, next);
this.emit();
return true;
}
clearAll() {
if (
this.runningJobs.size === 0 &&

View File

@@ -155,6 +155,53 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources',
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
});
test('rewriteCodexOutputWithChatResources keeps diff paths intact while rewriting prose file paths', async () => {
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
const sourcePath = path.join(repoPath, 'src', 'a.ts');
await mkdir(path.dirname(sourcePath), { recursive: true });
await writeFile(sourcePath, 'export const value = 1;\n', 'utf8');
const output = [
'변경 파일: src/a.ts',
'',
'```diff',
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@',
'-export const value = 1;',
'+export const value = 2;',
'```',
].join('\n');
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
const savedDiffPath = path.join(
repoPath,
'public',
'.codex_chat',
'chat-room',
'resource',
'_generated',
'response.diff',
);
assert.match(rewritten, /변경 파일: \/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/a\.ts/);
assert.match(rewritten, /diff --git a\/src\/a\.ts b\/src\/a\.ts/);
assert.doesNotMatch(rewritten, /diff --git a\/api\/chat\/resources\//);
assert.equal(
await readFile(savedDiffPath, 'utf8'),
[
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@',
'-export const value = 1;',
'+export const value = 2;',
'',
].join('\n'),
);
});
test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => {
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx');

View File

@@ -1205,6 +1205,24 @@ export function extractDiffCodeBlocks(output: string) {
.filter(Boolean);
}
function protectDiffCodeBlocks(output: string) {
const blocks: string[] = [];
const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => {
const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`;
blocks.push(match);
return token;
});
return { text, blocks };
}
function restoreDiffCodeBlocks(output: string, blocks: string[]) {
return blocks.reduce(
(current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block),
String(output ?? ''),
);
}
async function resolveChatResourceSourcePath(repoPath: string, candidate: string) {
const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim()));
@@ -1323,6 +1341,7 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) {
}
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output);
const escapedRepoPath = escapeRegExp(path.resolve(repoPath));
const filePathPattern = "[^\\n\\s)\\]\"'`,]+";
const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`;
@@ -1330,39 +1349,36 @@ export async function rewriteCodexOutputWithChatResources(output: string, repoPa
`${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`,
'g',
);
const matches = [...output.matchAll(candidatePattern)];
const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)];
let rewrittenOutput = outputWithoutDiffBlocks;
if (matches.length > 0) {
const replacementMap = new Map<string, string>();
if (matches.length === 0) {
return output;
}
for (const match of matches) {
const rawCandidate = match[0]?.trim();
const replacementMap = new Map<string, string>();
if (!rawCandidate || replacementMap.has(rawCandidate)) {
continue;
}
for (const match of matches) {
const rawCandidate = match[0]?.trim();
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
if (!rawCandidate || replacementMap.has(rawCandidate)) {
continue;
if (stagedUrl) {
replacementMap.set(rawCandidate, stagedUrl);
}
}
const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate);
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
if (stagedUrl) {
replacementMap.set(rawCandidate, stagedUrl);
for (const [sourcePath, publicUrl] of replacements) {
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
}
}
let rewrittenOutput = output;
const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length);
for (const [sourcePath, publicUrl] of replacements) {
rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl);
}
rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput);
rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks);
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, rewrittenOutput);
const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output);
return appendDiffResourceLinks(rewrittenOutput, diffUrls);
}
@@ -1648,6 +1664,7 @@ async function runAgenticCodexReply(
requestId: string,
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
@@ -1670,8 +1687,21 @@ async function runAgenticCodexReply(
let lastProgressText = '';
let completedAgentMessage = '';
let hasIncrementalDelta = false;
const throwIfCancelled = async () => {
if (!isCancellationRequested?.()) {
return;
}
await cancelRunnerCodexExecution(requestId).catch(() => false);
throw new Error('CHAT_RUNTIME_CANCELLED');
};
await throwIfCancelled();
activeChatProcessRegistry.set(requestId, {
cancel: () => cancelRunnerCodexExecution(requestId),
cancel: async () => {
const cancelled = await cancelRunnerCodexExecution(requestId);
return cancelled || isCancellationRequested?.() === true;
},
});
await new Promise<void>(async (resolve, reject) => {
@@ -1688,6 +1718,7 @@ async function runAgenticCodexReply(
};
try {
await throwIfCancelled();
const response = await requestCommandRunner('/api/codex-live/execute', {
method: 'POST',
body: JSON.stringify({
@@ -1705,6 +1736,8 @@ async function runAgenticCodexReply(
return;
}
await throwIfCancelled();
if (!response.body) {
reject(new Error('command-runner Codex 스트림이 비어 있습니다.'));
return;
@@ -1792,6 +1825,7 @@ async function runAgenticCodexReply(
};
while (true) {
await throwIfCancelled();
const { value, done } = await reader.read();
if (done) {
@@ -2130,8 +2164,17 @@ async function buildCodexReply(
requestId: string,
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
return runAgenticCodexReply(
context,
input,
sessionId,
requestId,
onProgress,
onActivity,
isCancellationRequested,
);
}
export class ChatService {
@@ -2364,6 +2407,25 @@ export class ChatService {
private async cancelRuntimeJob(requestId: string) {
const execution = activeChatProcessRegistry.get(requestId);
const detail = chatRuntimeService.getJobDetail(requestId);
if (!execution && detail.item && detail.terminalStatus == null) {
chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.');
this.cancelledRequestIds.add(requestId);
const session = this.findSessionByRequestId(requestId);
if (session) {
void upsertChatConversationRequest(session.sessionId, {
requestId,
status: 'cancelled',
statusMessage: '사용자 요청으로 실행 취소를 대기합니다.',
}).catch((error: unknown) => {
this.logger.warn(error, 'failed to persist pending chat request cancellation state');
});
}
return true;
}
if (!execution) {
return false;
@@ -2384,7 +2446,14 @@ export class ChatService {
}
try {
return await execution.cancel();
const cancelled = await execution.cancel();
if (!cancelled && this.cancelledRequestIds.has(requestId)) {
chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.');
return true;
}
return cancelled;
} catch (error) {
this.logger.warn(error, 'failed to cancel chat runtime job');
chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.');
@@ -3221,6 +3290,7 @@ export class ChatService {
(activityLine) => {
appendActivityLine(activityLine);
},
() => this.cancelledRequestIds.has(request.requestId),
);
chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.');
appendActivityLine('# 상태: 응답 생성이 완료되었습니다.');

View File

@@ -36,6 +36,15 @@ test('listServerCommands uses app as the default test restart service', async ()
assert.equal(testCommand.serviceName, 'app');
});
test('listServerCommands exposes prod restart command', async () => {
const commands = await listServerCommands();
const prodCommand = commands.find((item) => item.key === 'prod');
assert.ok(prodCommand);
assert.equal(prodCommand.serviceName, 'prod-app');
assert.match(prodCommand.commandScript, /\/etc\/commands\/server-command\/restart-prod\.sh$/);
});
test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => {
const commands = await listServerCommands();
const testCommand = commands.find((item) => item.key === 'test');
@@ -45,10 +54,11 @@ test('listServerCommands resolves restart script from main project when project
assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh');
});
test('test and release restart scripts fall back to Docker socket when docker CLI is unavailable', () => {
test('test, release and prod restart scripts fall back to Docker socket when docker CLI is unavailable', () => {
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8');
const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8');
const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8');
const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8');
@@ -58,10 +68,22 @@ test('test and release restart scripts fall back to Docker socket when docker CL
assert.match(testScript, /restart-via-docker-socket\.mjs/);
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
assert.match(relScript, /command -v docker >/);
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
assert.match(
relScript,
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
);
assert.match(relScript, /restart-via-docker-socket\.mjs/);
assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/);
assert.match(prodScript, /command -v docker >/);
assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_REMOTE="\$\{SERVER_COMMAND_PROD_GIT_REMOTE:-origin\}"/);
assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_BRANCH="\$\{SERVER_COMMAND_PROD_GIT_BRANCH:-main\}"/);
assert.match(
prodScript,
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
);
assert.match(prodScript, /restart-via-docker-socket\.mjs/);
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
assert.match(
workServerScript,
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
@@ -69,6 +91,15 @@ test('test and release restart scripts fall back to Docker socket when docker CL
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
});
test('prod restart script pulls the configured remote main branch before restart', () => {
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8');
assert.match(prodScript, /CURRENT_BRANCH=\$\(git rev-parse --abbrev-ref HEAD 2>\/dev\/null \|\| true\)/);
assert.match(prodScript, /git -c "credential\.helper=store --file=\$CREDENTIAL_STORE" pull --ff-only/);
assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
});
test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
const packageJsonPath = new URL('../../package.json', import.meta.url);
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {

View File

@@ -13,7 +13,7 @@ import {
const execFileAsync = promisify(execFile);
export const serverCommandKeys = ['test', 'rel', 'work-server', 'command-runner'] as const;
export const serverCommandKeys = ['test', 'rel', 'prod', 'work-server', 'command-runner'] as const;
export type ServerCommandKey = (typeof serverCommandKeys)[number];
@@ -135,6 +135,26 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
'/tmp/ai-code-test-app-dist/assets',
] as const;
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
const allowLocal = options?.allowLocal ?? false;
let latestBuiltAt: string | null = null;
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);
@@ -587,6 +607,26 @@ function getServerDefinitions(): ServerDefinition[] {
},
restartStrategy: 'wait',
},
{
key: 'prod',
label: 'PROD',
summary: '프로덕션 앱 컨테이너',
environment: 'production',
publicUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
checkUrl: normalizeUrl(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', [projectRoot, mainProjectRoot, '/workspace/main-project']),
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',
},
{
key: 'work-server',
label: 'WORK-SERVER',
@@ -1223,21 +1263,39 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
}
async function inspectAppContainerBuild(definition: ServerDefinition): Promise<BuildInspectionResult | null> {
if (definition.key !== 'test') {
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 });
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
const builtAt =
(await readLocalBuildTimestamp(targetPath)) ?? (await readContainerBuildTimestamp(definition, targetPath));
if (!builtAt) {
continue;
}
if (builtAt) {
return {
runningVersion: null,
runningBuiltAt: builtAt,