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

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ etc/**/.DS_Store
*.tsbuildinfo
*.swp
*.root-owned-backup
*.root-owned-backup-*/
vite.config.js
vite.config.d.ts

View File

@@ -126,7 +126,7 @@ docs/
- 앱 문서는 Vite `import.meta.glob`으로 Markdown 파일을 수집합니다.
- 작업일지는 날짜별 파일로 누적하며 캡처 이미지는 `docs/assets/worklogs/YYYY-MM-DD/` 기준으로 관리합니다.
- Plan 자동화 스크립트는 `scripts/run-plan-codex-once.mjs`를 사용합니다.
- 서버 재기동을 호스트 프로젝트 루트 기준으로 처리하려면 `npm run server-command:runner`를 실행합니다.
- command runner는 별도 명시적 요청이 있을 때만 `npm run server-command:runner`로 직접 기동하거나 재기동합니다.
- 문서/작업일지 일일 정리는 `npm run docs:daily``.github/workflows/daily-docs-maintenance.yml` 기준으로 실행합니다.
## 프로젝트 현황

View File

@@ -0,0 +1,34 @@
# 2026-04-23 작업일지
## 오늘 작업
- 화면 캡처 추가 예정
## 스크린샷
![settings-app](../assets/worklogs/2026-04-23/settings-app.png)
## 소스
### 파일 1: `path/to/file.tsx`
- 변경 목적과 핵심 수정 내용을 한 줄로 정리
```diff
# 이 파일의 핵심 diff
- before
+ after
```
### 파일 2: `path/to/another-file.ts`
- 필요 없으면 이 섹션은 삭제
## 실행 커맨드
```bash
```
## 변경 파일
-

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,

View File

@@ -28,20 +28,6 @@ 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',
@@ -78,6 +64,18 @@ const commandDefinitions = {
},
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'),
@@ -133,46 +131,6 @@ function translateWorkspacePathToHost(inputPath) {
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) {
@@ -223,26 +181,6 @@ async function writeHeartbeat() {
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,
@@ -251,127 +189,6 @@ async function writeHeartbeat() {
);
}
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();
@@ -1030,12 +847,5 @@ server.listen(port, host, () => {
});
}, 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`);
});

View File

@@ -1784,9 +1784,20 @@
}
.app-chat-panel__preview-rich--markdown {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
max-height: min(420px, 70vh);
padding: 4px 2px 0;
}
.app-chat-panel__preview-rich--markdown .markdown-preview {
width: 100%;
min-width: 0;
}
.app-chat-message__preview-image,
.app-chat-message__preview-video,
.app-chat-message__preview-frame {
@@ -2175,6 +2186,7 @@
.app-chat-panel__preview-stage > * {
width: 100%;
height: 100%;
min-height: 0;
}
.app-chat-panel__preview-loading {
@@ -2366,12 +2378,32 @@
padding: 0 20px 12px;
}
.app-chat-panel__preview-modal-footer {
.app-chat-panel__preview-modal-title {
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
gap: 12px;
width: 100%;
min-width: 0;
}
.app-chat-panel__preview-modal-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-chat-panel__preview-modal-findbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px 12px;
}
.app-chat-panel__preview-modal-findbar .ant-input-affix-wrapper {
flex: 1 1 auto;
min-width: 0;
}
.app-chat-panel__preview-modal .app-chat-panel__preview-rich,
@@ -2387,6 +2419,12 @@
max-width: none;
}
.app-chat-panel__preview-modal .app-chat-panel__preview-rich--markdown,
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich--markdown {
height: 100%;
max-height: none;
}
.app-chat-panel__preview-modal .previewer-ui__editor,
.app-chat-panel__preview-modal .codex-diff-previewer__diff-section,
.app-chat-panel__preview-modal .app-chat-panel__preview-image,
@@ -2403,8 +2441,17 @@
}
@media (max-width: 720px) {
.app-chat-panel__preview-modal-footer {
justify-content: flex-end;
.app-chat-panel__preview-modal-title {
align-items: flex-start;
flex-direction: column;
}
.app-chat-panel__preview-modal-findbar {
flex-wrap: wrap;
}
.app-chat-panel__preview-modal-findbar .ant-btn {
flex: 1 1 calc(50% - 4px);
}
}

View File

@@ -17,8 +17,9 @@ import {
DeleteOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd';
import type { InputRef } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAppStore } from '../../store';
import { useAppConfig } from './appConfig';
@@ -192,6 +193,267 @@ function createConversationPreviewText(text: string) {
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
}
type PreviewTextMatch = {
node: Text;
start: number;
end: number;
};
type PreviewMatchAnchor = {
match: PreviewTextMatch;
target: HTMLElement;
offsetTop: number;
};
function collectPreviewTextMatches(root: HTMLElement, keyword: string) {
const normalizedKeyword = keyword.trim().toLocaleLowerCase();
if (!normalizedKeyword || typeof document === 'undefined') {
return [] as PreviewTextMatch[];
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const matches: PreviewTextMatch[] = [];
let currentNode = walker.nextNode();
while (currentNode) {
if (currentNode.nodeType === Node.TEXT_NODE) {
const textNode = currentNode as Text;
const textContent = textNode.textContent ?? '';
const normalizedText = textContent.toLocaleLowerCase();
if (normalizedText) {
let fromIndex = 0;
while (fromIndex < normalizedText.length) {
const matchIndex = normalizedText.indexOf(normalizedKeyword, fromIndex);
if (matchIndex < 0) {
break;
}
matches.push({
node: textNode,
start: matchIndex,
end: matchIndex + normalizedKeyword.length,
});
fromIndex = matchIndex + Math.max(1, normalizedKeyword.length);
}
}
}
currentNode = walker.nextNode();
}
return matches;
}
function focusPreviewTextMatch(match: PreviewTextMatch) {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return false;
}
const selection = window.getSelection();
if (!selection) {
return false;
}
const range = document.createRange();
range.setStart(match.node, match.start);
range.setEnd(match.node, match.end);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function resolvePreviewMatchTargetElement(match: PreviewTextMatch) {
return (
match.node.parentElement?.closest<HTMLElement>(
'.previewer-ui__line, .markdown-preview__block, .codex-diff-previewer__diff-line, p, li, h1, h2, h3, pre, code, div',
) ?? match.node.parentElement
);
}
function resolvePreviewScrollContainer(root: HTMLElement) {
const preferredSelectors = [
'.app-chat-panel__preview-rich--markdown',
'.app-chat-panel__preview-rich',
'.codex-diff-previewer__diff-body',
'.previewer-ui__editor-body',
];
if (root.scrollHeight > root.clientHeight + 4) {
return root;
}
for (const selector of preferredSelectors) {
const candidate = root.querySelector<HTMLElement>(selector);
if (candidate && candidate.scrollHeight > candidate.clientHeight + 4) {
return candidate;
}
}
return root;
}
function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLElement) {
const targetRect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return targetRect.top - containerRect.top + container.scrollTop;
}
function getPreviewMatchOffsetWithinContainer(match: PreviewTextMatch, container: HTMLElement) {
if (typeof document === 'undefined') {
return null;
}
const range = document.createRange();
range.setStart(match.node, match.start);
range.setEnd(match.node, match.end);
const rects = range.getClientRects();
const firstRect = rects.item(0);
if (!firstRect) {
return null;
}
const containerRect = container.getBoundingClientRect();
return firstRect.top - containerRect.top + container.scrollTop;
}
function getPreviewMatchHeight(match: PreviewTextMatch) {
if (typeof document === 'undefined') {
return null;
}
const range = document.createRange();
range.setStart(match.node, match.start);
range.setEnd(match.node, match.end);
const rects = range.getClientRects();
const firstRect = rects.item(0);
if (!firstRect) {
return null;
}
return firstRect.height;
}
function buildPreviewMatchAnchors(matches: PreviewTextMatch[], container: HTMLElement) {
const anchors: PreviewMatchAnchor[] = [];
matches.forEach((match) => {
const target = resolvePreviewMatchTargetElement(match);
if (!target) {
return;
}
anchors.push({
match,
target,
offsetTop: getPreviewMatchOffsetWithinContainer(match, container) ?? getElementOffsetWithinContainer(target, container),
});
});
return anchors;
}
function resolvePreviewSearchAnchorIndex(
anchors: PreviewMatchAnchor[],
container: HTMLElement,
direction: 'forward' | 'backward',
) {
if (anchors.length === 0) {
return -1;
}
const viewportAnchor = container.scrollTop + container.clientHeight / 2;
const threshold = 4;
if (direction === 'backward') {
for (let index = anchors.length - 1; index >= 0; index -= 1) {
if (anchors[index].offsetTop < viewportAnchor - threshold) {
return index;
}
}
return anchors.length - 1;
}
for (let index = 0; index < anchors.length; index += 1) {
if (anchors[index].offsetTop > viewportAnchor + threshold) {
return index;
}
}
return 0;
}
function resolvePreviewSearchResultIndex(args: {
anchors: PreviewMatchAnchor[];
container: HTMLElement;
direction: 'forward' | 'backward';
currentIndex: number;
}) {
const { anchors, container, direction, currentIndex } = args;
if (anchors.length === 0) {
return -1;
}
if (currentIndex >= 0 && currentIndex < anchors.length) {
if (direction === 'backward') {
return currentIndex === 0 ? anchors.length - 1 : currentIndex - 1;
}
return currentIndex === anchors.length - 1 ? 0 : currentIndex + 1;
}
return resolvePreviewSearchAnchorIndex(anchors, container, direction);
}
function focusPreviewTextMatchInContainer(match: PreviewTextMatch, container: HTMLElement) {
if (!focusPreviewTextMatch(match)) {
return false;
}
const matchTop = getPreviewMatchOffsetWithinContainer(match, container);
if (matchTop != null) {
const matchHeight = getPreviewMatchHeight(match) ?? 0;
const nextScrollTop = Math.max(0, matchTop - container.clientHeight / 2 + matchHeight / 2);
container.scrollTo({
top: nextScrollTop,
behavior: 'smooth',
});
return true;
}
const target = resolvePreviewMatchTargetElement(match);
if (!target) {
return false;
}
const targetTop = getElementOffsetWithinContainer(target, container);
const nextScrollTop = Math.max(0, targetTop - container.clientHeight / 2 + target.clientHeight / 2);
container.scrollTo({
top: nextScrollTop,
behavior: 'smooth',
});
return true;
}
function resolveConversationListPreviewText(preview: string) {
const normalized = createConversationPreviewText(preview);
@@ -646,12 +908,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const [isMaximized, setIsMaximized] = useState(false);
const [isResourceStripOpen, setIsResourceStripOpen] = useState(false);
const [isTitleClusterOpen, setIsTitleClusterOpen] = useState(false);
const [isPreviewFindOpen, setIsPreviewFindOpen] = useState(false);
const [previewFindQuery, setPreviewFindQuery] = useState('');
const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null);
const [messageApi, messageContextHolder] = message.useMessage();
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<TextAreaRef | null>(null);
const previewFindInputRef = useRef<InputRef | null>(null);
const previewSearchRootRef = useRef<HTMLDivElement | null>(null);
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
const previewSearchMatchIndexRef = useRef(-1);
const previewSearchKeyRef = useRef('');
const titleClusterRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<number | null>(null);
const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
@@ -661,6 +930,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
const shouldRestoreConversationAfterReconnectRef = useRef(false);
const handledRequestedSessionIdRef = useRef('');
const isClosingConversationRef = useRef(false);
const lastChatTypeSessionIdRef = useRef('');
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
@@ -746,6 +1016,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
clientId: null,
title: '새 대화',
chatTypeId: selectedChatType?.id ?? null,
lastChatTypeId: selectedChatType?.id ?? null,
contextLabel: selectedChatType?.name ?? null,
contextDescription: selectedChatType?.description ?? null,
notifyOffline: true,
@@ -771,6 +1042,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
sessionId,
title: '새 대화',
chatTypeId: selectedChatType?.id ?? null,
lastChatTypeId: selectedChatType?.id ?? null,
contextLabel: selectedChatType?.name,
contextDescription: selectedChatType?.description,
notifyOffline: true,
@@ -1344,6 +1616,130 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
}, [activePreview, messageApi]);
const canSearchActivePreview =
Boolean(activePreview) &&
!isPreviewLoading &&
!previewError.trim() &&
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
const resetActivePreviewSearchState = useCallback(() => {
previewSearchMatchesRef.current = [];
previewSearchMatchIndexRef.current = -1;
previewSearchKeyRef.current = '';
}, []);
const clearActivePreviewSearchSelection = useCallback(() => {
if (typeof window !== 'undefined') {
window.getSelection()?.removeAllRanges();
}
}, []);
useEffect(() => {
if (!isPreviewModalOpen) {
setIsPreviewFindOpen(false);
setPreviewFindQuery('');
resetActivePreviewSearchState();
clearActivePreviewSearchSelection();
return;
}
if (isPreviewFindOpen) {
window.setTimeout(() => {
previewFindInputRef.current?.focus();
}, 0);
}
}, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]);
useEffect(() => {
resetActivePreviewSearchState();
clearActivePreviewSearchSelection();
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
useEffect(() => {
resetActivePreviewSearchState();
}, [previewFindQuery, resetActivePreviewSearchState]);
const handlePreviewSearchRootPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!isMobileViewport || !isPreviewFindOpen) {
return;
}
const inputElement = previewFindInputRef.current?.input;
if (!inputElement || document.activeElement !== inputElement) {
return;
}
if (event.target instanceof Node && inputElement.contains(event.target)) {
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
event.preventDefault();
inputElement.blur();
},
[isMobileViewport, isPreviewFindOpen],
);
const handleFindActivePreview = useCallback(
(direction: 'forward' | 'backward' = 'forward') => {
const keyword = previewFindQuery.trim();
if (!keyword) {
void messageApi.info('찾을 단어를 입력해주세요.');
return;
}
const root = previewSearchRootRef.current;
if (!root) {
void messageApi.error('미리보기 본문을 아직 찾을 수 없습니다.');
return;
}
const searchKey = `${activePreview?.id ?? 'preview'}:${keyword.toLocaleLowerCase()}`;
const matches =
previewSearchKeyRef.current === searchKey
? previewSearchMatchesRef.current
: collectPreviewTextMatches(root, keyword);
if (previewSearchKeyRef.current !== searchKey) {
previewSearchKeyRef.current = searchKey;
previewSearchMatchesRef.current = matches;
previewSearchMatchIndexRef.current = -1;
}
if (matches.length === 0) {
void messageApi.info('일치하는 단어를 찾지 못했습니다.');
return;
}
const scrollContainer = resolvePreviewScrollContainer(root);
const anchors = buildPreviewMatchAnchors(matches, scrollContainer);
const nextIndex = resolvePreviewSearchResultIndex({
anchors,
container: scrollContainer,
direction,
currentIndex: previewSearchMatchIndexRef.current,
});
const nextAnchor = nextIndex >= 0 ? anchors[nextIndex] : null;
if (!nextAnchor || !focusPreviewTextMatchInContainer(nextAnchor.match, scrollContainer)) {
void messageApi.error('검색 결과 위치로 이동하지 못했습니다.');
return;
}
previewSearchMatchIndexRef.current = nextIndex;
},
[activePreview?.id, messageApi, previewFindQuery],
);
const markConversationReadLocally = (sessionId: string) => {
const normalizedSessionId = sessionId.trim();
@@ -1532,7 +1928,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, [location.pathname, location.search, navigate]);
const openConversationSession = (sessionId: string) => {
isClosingConversationRef.current = false;
replaceChatSessionInUrl(sessionId);
clearRequestedRuntimeLogInUrl();
setActiveView('chat');
const now = new Date().toISOString();
const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? [];
const hasCachedMessages = cachedMessages.length > 0;
@@ -1566,6 +1965,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
clientId: null,
title: '대화 내용을 불러오는 중입니다.',
chatTypeId: null,
lastChatTypeId: null,
contextLabel: null,
contextDescription: null,
notifyOffline: true,
@@ -1842,7 +2242,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
if (activeSessionId) {
if (hasSessionChanged) {
const lastUsedChatTypeId = getStoredChatSessionLastTypeId(activeSessionId);
const lastUsedChatTypeId =
activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId);
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
if (selectedChatTypeId !== lastUsedChatTypeId) {
@@ -1865,7 +2266,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
}, [activeSessionId, availableChatTypes, selectedChatTypeId]);
}, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
useEffect(() => {
if (!activeSessionId || !selectedChatTypeId) {
@@ -1873,7 +2274,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
}, [activeSessionId, selectedChatTypeId]);
setConversationItems((previous) =>
previous.map((item) =>
item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId
? { ...item, lastChatTypeId: selectedChatTypeId }
: item,
),
);
const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
if (currentLastChatTypeId === selectedChatTypeId) {
return;
}
void chatGateway.updateConversation(activeSessionId, {
lastChatTypeId: selectedChatTypeId,
}).catch(() => {
// Ignore background sync failures and keep local in-memory fallback.
});
}, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]);
useEffect(() => {
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
@@ -1925,7 +2345,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return;
}
if (requestedSessionId && !requestedChatView) {
setActiveView('chat');
}
if (!requestedSessionId) {
isClosingConversationRef.current = false;
handledRequestedSessionIdRef.current = '';
return;
}
@@ -1934,21 +2359,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return;
}
if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) {
if (isClosingConversationRef.current && requestedSessionId === activeSessionId) {
return;
}
if (
requestedSessionId === activeSessionId &&
handledRequestedSessionIdRef.current === requestedSessionId &&
activeView === 'chat' &&
!isConversationPaneClosed &&
(!isMobileViewport || isMobileConversationView)
) {
return;
}
handledRequestedSessionIdRef.current = requestedSessionId;
if (requestedSessionId === activeSessionId) {
if (isMobileViewport && !isConversationPaneClosed) {
setIsMobileConversationView(true);
}
openConversationSession(requestedSessionId);
return;
}
openConversationSession(requestedSessionId);
}, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, location.pathname, requestedSessionId]);
}, [
activeSessionId,
activeView,
conversationItems,
isConversationListLoading,
isConversationPaneClosed,
isMobileConversationView,
isMobileViewport,
location.pathname,
requestedChatView,
requestedSessionId,
]);
useEffect(() => {
if (requestedSessionId) {
@@ -2329,6 +2773,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
icon={<CloseOutlined />}
aria-label="대화창 닫기"
onClick={() => {
isClosingConversationRef.current = true;
handledRequestedSessionIdRef.current = '';
replaceChatSessionInUrl('');
setIsConversationPaneClosed(true);
@@ -2594,17 +3039,30 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
</Modal>
<Modal
open={isPreviewModalOpen && Boolean(activePreview)}
title={activePreview ? `${activePreview.label} preview` : 'preview'}
footer={
title={
activePreview ? (
<div className="app-chat-panel__preview-modal-footer">
<Space wrap>
<div className="app-chat-panel__preview-modal-title">
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
<Space size={4} wrap>
{canSearchActivePreview ? (
<Button
type={isPreviewFindOpen ? 'default' : 'text'}
aria-label="단어 찾기"
icon={<SearchOutlined />}
onClick={() => {
setIsPreviewFindOpen((current) => !current);
}}
/>
) : null}
<Button type="text" aria-label="복사" icon={<CopyOutlined />} onClick={() => void handleCopyActivePreview()} />
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadActivePreview} />
</Space>
</div>
) : null
) : (
'preview'
)
}
footer={null}
onCancel={() => {
setIsPreviewModalOpen(false);
}}
@@ -2620,13 +3078,52 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
</Space>
</div>
<div className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal">
{canSearchActivePreview && isPreviewFindOpen ? (
<div className="app-chat-panel__preview-modal-findbar">
<Input
ref={previewFindInputRef}
size="small"
value={previewFindQuery}
placeholder="단어 찾기"
allowClear
onChange={(event) => {
setPreviewFindQuery(event.target.value);
}}
onPressEnter={(event) => {
handleFindActivePreview(event.shiftKey ? 'backward' : 'forward');
}}
/>
<Button
size="small"
onClick={() => {
handleFindActivePreview('backward');
}}
>
</Button>
<Button
size="small"
type="primary"
onClick={() => {
handleFindActivePreview('forward');
}}
>
</Button>
</div>
) : null}
<div
ref={previewSearchRootRef}
className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root"
onPointerDownCapture={handlePreviewSearchRootPointerDown}
>
<ChatPreviewBody
target={activePreview}
previewText={previewText}
isPreviewLoading={isPreviewLoading}
previewError={previewError}
previewContentType={previewContentType}
maxMarkdownBlocks={undefined}
/>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
Alert,
Button,
Checkbox,
Divider,
Drawer,
Dropdown,
Grid,
@@ -869,6 +870,7 @@ export function MainHeader({
void contentExpanded;
void onToggleContentExpanded;
const screens = useBreakpoint();
const [modalApi, modalContextHolder] = Modal.useModal();
const navigate = useNavigate();
const location = useLocation();
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -911,9 +913,12 @@ export function MainHeader({
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null);
const [serverRestartingKey, setServerRestartingKey] = useState<
'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null
>(null);
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
const { registeredToken, hasAccess } = useTokenAccess();
@@ -941,9 +946,11 @@ export function MainHeader({
: 'app-header__status-dot--inactive';
const testServerPendingUpdateCount =
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
const prodServerPendingUpdateCount =
prodServerStatus && (prodServerStatus.updateAvailable || prodServerStatus.buildRequired) ? 1 : 0;
const workServerPendingUpdateCount =
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount;
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
const settingsStatusClassName =
totalPendingUpdateCount >= 2
? 'app-header__status-dot--inactive'
@@ -989,6 +996,8 @@ export function MainHeader({
const searchParams = new URLSearchParams(location.search);
searchParams.set('topMenu', 'chat');
searchParams.set('sessionId', sessionId);
searchParams.delete('chatView');
searchParams.delete('runtimeRequestId');
navigate({
pathname: buildChatPath('live'),
search: `?${searchParams.toString()}`,
@@ -1482,18 +1491,22 @@ export function MainHeader({
const refreshServerStatuses = async () => {
const items = await fetchServerCommands();
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
const nextProdServerStatus = items.find((item) => item.key === 'prod') ?? null;
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
setTestServerStatus(nextTestServerStatus);
setProdServerStatus(nextProdServerStatus);
setWorkServerStatus(nextWorkServerStatus);
return {
test: nextTestServerStatus,
prod: nextProdServerStatus,
'work-server': nextWorkServerStatus,
} satisfies Record<'test' | 'work-server', ServerCommandItem | null>;
} satisfies Record<'test' | 'prod' | 'work-server', ServerCommandItem | null>;
};
const refreshUpdateTargets = async (silent = false) => {
if (!hasAccess) {
setTestServerStatus(null);
setProdServerStatus(null);
setWorkServerStatus(null);
if (!silent) {
setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
@@ -1515,7 +1528,7 @@ export function MainHeader({
if (!silent) {
setUpdateCheckFeedback({
tone: 'error',
message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
message: error instanceof Error ? error.message : 'TEST/PROD/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
});
}
return null;
@@ -1524,7 +1537,7 @@ export function MainHeader({
}
};
const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => {
const waitForServerRestart = async (key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null) => {
for (let attempt = 0; attempt < 16; attempt += 1) {
await waitForDuration(2500);
@@ -1549,7 +1562,10 @@ export function MainHeader({
}
}
return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus };
return {
ok: false,
item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus,
};
};
const handleResetClientState = async () => {
@@ -1589,14 +1605,19 @@ export function MainHeader({
}
};
const restartServerWithVerification = async (key: 'test' | 'work-server', busyKey: 'test' | 'work-server' | 'all') => {
const baseline = key === 'test' ? testServerStatus : workServerStatus;
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
const restartServerWithVerification = async (
key: 'test' | 'prod' | 'work-server',
busyKey: 'test' | 'prod' | 'work-server' | 'all',
) => {
const baseline = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus;
const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
const result = await restartServerCommand(key);
if (key === 'test') {
setTestServerStatus(result.item);
} else if (key === 'prod') {
setProdServerStatus(result.item);
} else {
setWorkServerStatus(result.item);
}
@@ -1627,7 +1648,7 @@ export function MainHeader({
return true;
};
const handleRestartSingleServer = async (key: 'test' | 'work-server') => {
const handleRestartSingleServer = async (key: 'test' | 'prod' | 'work-server') => {
if (!hasAccess || serverRestartingKey) {
return false;
}
@@ -1639,7 +1660,7 @@ export function MainHeader({
try {
return await restartServerWithVerification(key, key);
} catch (error) {
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
setServerRestartFeedback({
tone: 'error',
message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`,
@@ -1650,6 +1671,67 @@ export function MainHeader({
}
};
const handleConfirmRestartProdServer = () => {
if (!hasAccess || serverRestartingKey) {
return;
}
modalApi.confirm({
title: 'PROD 빌드 반영',
content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?',
okText: '빌드 및 재기동',
cancelText: '취소',
okButtonProps: { danger: true },
onOk: async () => {
await handleRestartSingleServer('prod');
},
});
};
const handleRestartCommandRunner = async () => {
if (!hasAccess || serverRestartingKey) {
return;
}
setServerRestartCopyFeedback(null);
setServerRestartFeedback(null);
setServerRestartingKey('command-runner');
try {
const result = await restartServerCommand('command-runner');
setServerRestartFeedback({
tone: 'success',
message:
result.restartState === 'accepted'
? 'Command runner 배포 및 재기동 요청을 접수했습니다.'
: 'Command runner 배포 및 재기동을 완료했습니다.',
});
} catch (error) {
setServerRestartFeedback({
tone: 'error',
message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.',
});
} finally {
setServerRestartingKey(null);
}
};
const handleConfirmRestartCommandRunner = () => {
if (!hasAccess || serverRestartingKey) {
return;
}
modalApi.confirm({
title: 'Command runner 배포 및 재기동',
content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?',
okText: '배포 및 재기동',
cancelText: '취소',
onOk: async () => {
await handleRestartCommandRunner();
},
});
};
const handleRestartBothServers = async () => {
if (!hasAccess || serverRestartingKey) {
return;
@@ -1737,7 +1819,7 @@ export function MainHeader({
};
const handleResetNotificationIdentity = () => {
Modal.confirm({
modalApi.confirm({
title: '알림 클라이언트 초기화',
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
okText: '초기화',
@@ -2718,6 +2800,7 @@ export function MainHeader({
return (
<>
{modalContextHolder}
<Header className="app-header">
<Space size={12} className="app-header__row">
<Space size={12} className="app-header__menu-side">
@@ -3027,6 +3110,24 @@ export function MainHeader({
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
<Divider style={{ marginBlock: 4 }} />
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong>Command runner</Text>
<Text type="secondary">
command runner .
</Text>
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
<Button
block
type="primary"
icon={serverRestartingKey === 'command-runner' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'command-runner'}
disabled={!canRestartServers}
onClick={handleConfirmRestartCommandRunner}
>
command runner
</Button>
</Space>
</>
) : null}
{activeSettingsModal === 'notification' ? (
@@ -3154,6 +3255,17 @@ export function MainHeader({
<Text type="secondary">
: {getServerLastSourceChangedDateLabel(workServerStatus)}
</Text>
<Text type="secondary">
<span
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(prodServerStatus)}`}
aria-label={getServerVersionStatusTitle(prodServerStatus, '운영')}
title={getServerVersionStatusTitle(prodServerStatus, '운영')}
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
aria-hidden="true"
/>
</Text>
<Text type="secondary">{formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}</Text>
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
<Button
block
@@ -3188,6 +3300,7 @@ export function MainHeader({
<Text strong style={{ marginTop: 8 }}>
</Text>
<Text type="secondary"> TEST와 WORK .</Text>
<Text type="secondary">
: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
</Text>
@@ -3234,6 +3347,24 @@ export function MainHeader({
</Button>
</Space>
<Text strong style={{ marginTop: 8 }}>
PROD
</Text>
<Text type="secondary"> : {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}</Text>
<Text type="secondary">
PROD , .
</Text>
<Button
type="primary"
danger
block
icon={serverRestartingKey === 'prod' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'prod'}
disabled={!canRestartServers}
onClick={handleConfirmRestartProdServer}
>
PROD
</Button>
</Space>
) : null}
</Space>

View File

@@ -392,6 +392,7 @@ function InlineMessagePreview({
isPreviewLoading={isPreviewLoading}
previewError={previewError}
previewContentType={previewContentType}
maxMarkdownBlocks={12}
/>
</div>
) : null}

View File

@@ -36,6 +36,7 @@ export type ChatGateway = {
sessionId: string;
title: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -44,7 +45,10 @@ export type ChatGateway = {
updateConversation: (
sessionId: string,
payload: Partial<
Pick<ChatConversationSummary, 'title' | 'chatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'>
Pick<
ChatConversationSummary,
'title' | 'chatTypeId' | 'lastChatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'
>
>,
) => Promise<ChatConversationSummary>;
deleteConversation: (sessionId: string) => Promise<void>;

View File

@@ -553,6 +553,7 @@ function InlineMessagePreview({
isPreviewLoading={isLoading}
previewError={previewError}
previewContentType={previewContentType}
maxMarkdownBlocks={12}
/>
</div>
) : null}

View File

@@ -209,6 +209,7 @@ type ChatPreviewBodyProps = {
isPreviewLoading: boolean;
previewError: string;
previewContentType?: string;
maxMarkdownBlocks?: number;
};
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
@@ -236,6 +237,7 @@ export function ChatPreviewBody({
isPreviewLoading,
previewError,
previewContentType,
maxMarkdownBlocks,
}: ChatPreviewBodyProps) {
if (!target) {
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
@@ -294,7 +296,10 @@ export function ChatPreviewBody({
if (target.kind === 'markdown') {
return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
<MarkdownPreviewContent content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'} maxBlocks={12} />
<MarkdownPreviewContent
content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'}
maxBlocks={maxMarkdownBlocks}
/>
</div>
);
}

View File

@@ -1,10 +1,11 @@
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined } from '@ant-design/icons';
import { Button, Drawer, Empty, Modal, Space, Typography } from 'antd';
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons';
import { Button, Drawer, Empty, Modal, Space, Typography, message } from 'antd';
import { useEffect, useRef, useState } from 'react';
import {
cancelChatRuntimeJob,
fetchChatRuntimeJobDetail,
removeChatRuntimeJob,
rollbackChatRuntimeJob,
} from './chatUtils';
import type { ChatRuntimeJobDetail, ChatRuntimeJobItem, ChatRuntimeSnapshot } from './types';
@@ -157,10 +158,14 @@ function RecentRuntimeList({
items,
onSelectSession,
onOpenLog,
onRollbackJob,
pendingActionRequestId,
}: {
items: ChatRuntimeSnapshot['recent'];
onSelectSession: (sessionId: string) => void;
onOpenLog: (requestId: string) => void;
onRollbackJob: (requestId: string, sessionId: string) => void;
pendingActionRequestId: string | null;
}) {
return (
<section className="app-chat-runtime__section app-chat-runtime__section--recent">
@@ -181,22 +186,35 @@ function RecentRuntimeList({
<Text strong>{buildTerminalLabel(item.terminalStatus)}</Text>
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
</div>
<Button
size="small"
onClick={() => {
onOpenLog(item.requestId);
}}
>
</Button>
<Button
size="small"
onClick={() => {
onSelectSession(item.sessionId);
}}
>
</Button>
<Space size={8} wrap className="app-chat-runtime__job-actions">
<Button
size="small"
onClick={() => {
onOpenLog(item.requestId);
}}
>
</Button>
<Button
size="small"
icon={<UndoOutlined />}
disabled={item.terminalStatus !== 'completed'}
loading={pendingActionRequestId === item.requestId}
onClick={() => {
onRollbackJob(item.requestId, item.sessionId);
}}
>
</Button>
<Button
size="small"
onClick={() => {
onSelectSession(item.sessionId);
}}
>
</Button>
</Space>
</div>
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
<div className="app-chat-runtime__job-meta">
@@ -233,6 +251,8 @@ export function ChatRuntimeDashboard({
onRequestedLogHandled?: () => void;
}) {
const sessions = snapshot?.sessions ?? [];
const [messageApi, messageContextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [selectedDetail, setSelectedDetail] = useState<ChatRuntimeJobDetail | null>(null);
const [isLogModalOpen, setIsLogModalOpen] = useState(false);
const [logLoadError, setLogLoadError] = useState('');
@@ -240,6 +260,23 @@ export function ChatRuntimeDashboard({
const [pendingActionRequestId, setPendingActionRequestId] = useState<string | null>(null);
const logViewerRef = useRef<HTMLPreElement | null>(null);
const confirmAction = (options: {
title: string;
content: string;
okText: string;
cancelText?: string;
}) =>
new Promise<boolean>((resolve) => {
modalApi.confirm({
title: options.title,
content: options.content,
okText: options.okText,
cancelText: options.cancelText ?? '닫기',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
});
const loadLogDetail = async (requestId: string) => {
setIsLogLoading(true);
setLogLoadError('');
@@ -261,15 +298,10 @@ export function ChatRuntimeDashboard({
};
const handleCancel = async (requestId: string) => {
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: '실행 중 요청을 취소할까요?',
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
okText: '취소 실행',
cancelText: '닫기',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
const confirmed = await confirmAction({
title: '실행 중 요청을 취소할까요?',
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
okText: '취소 실행',
});
if (!confirmed) {
@@ -280,21 +312,22 @@ export function ChatRuntimeDashboard({
try {
await cancelChatRuntimeJob(requestId);
messageApi.success('취소 요청을 보냈습니다.');
if (selectedDetail?.item?.requestId === requestId) {
await loadLogDetail(requestId);
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '실행 취소 요청에 실패했습니다.');
} finally {
setPendingActionRequestId(null);
}
};
const handleRemove = async (requestId: string) => {
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: '대기 요청 제거할까요?',
content: '아직 실행되지 않은 대기 요청만 제거됩니다.',
okText: '제거',
cancelText: '닫기',
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
const confirmed = await confirmAction({
title: '대기열 요청을 제거할까요?',
content: '아직 실행되지 않은 대기 요청 제거됩니다.',
okText: '제거',
});
if (!confirmed) {
@@ -305,6 +338,38 @@ export function ChatRuntimeDashboard({
try {
await removeChatRuntimeJob(requestId);
messageApi.success('대기 요청을 제거했습니다.');
if (selectedDetail?.item?.requestId === requestId) {
await loadLogDetail(requestId);
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '대기 요청 제거에 실패했습니다.');
} finally {
setPendingActionRequestId(null);
}
};
const handleRollback = async (requestId: string, sessionId: string) => {
const confirmed = await confirmAction({
title: '최근 실행 변경을 롤백할까요?',
content: '이 실행이 남긴 diff만 역적용합니다. 이후 다른 세션이 같은 라인을 수정했다면 실패할 수 있습니다.',
okText: '롤백',
});
if (!confirmed) {
return;
}
setPendingActionRequestId(requestId);
try {
await rollbackChatRuntimeJob(requestId, sessionId);
messageApi.success('최근 실행 변경을 롤백했습니다.');
if (selectedDetail?.item?.requestId === requestId) {
await loadLogDetail(requestId);
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.');
} finally {
setPendingActionRequestId(null);
}
@@ -362,6 +427,8 @@ export function ChatRuntimeDashboard({
return (
<>
{messageContextHolder}
{modalContextHolder}
<div className="app-chat-runtime">
<div className="app-chat-runtime__summary-strip">
<div className="app-chat-runtime__summary-card">
@@ -431,7 +498,13 @@ export function ChatRuntimeDashboard({
onRemoveJob={handleRemove}
pendingActionRequestId={pendingActionRequestId}
/>
<RecentRuntimeList items={snapshot?.recent ?? []} onSelectSession={onSelectSession} onOpenLog={openLog} />
<RecentRuntimeList
items={snapshot?.recent ?? []}
onSelectSession={onSelectSession}
onOpenLog={openLog}
onRollbackJob={handleRollback}
pendingActionRequestId={pendingActionRequestId}
/>
</div>
</div>

View File

@@ -1003,6 +1003,19 @@ export async function removeChatRuntimeJob(requestId: string) {
return response.removed;
}
export async function rollbackChatRuntimeJob(requestId: string, sessionId?: string | null) {
const response = await requestChatApi<{ ok: boolean; rolledBack: boolean }>(
`/runtime/jobs/${encodeURIComponent(requestId)}/rollback`,
{
method: 'POST',
body: JSON.stringify({
sessionId: sessionId?.trim() || undefined,
}),
},
);
return response.rolledBack;
}
export async function uploadChatComposerFile(sessionId: string, file: File) {
const normalizedSessionId = sessionId.trim();
@@ -1028,6 +1041,7 @@ export async function createChatConversationRoom(args: {
sessionId: string;
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -1040,6 +1054,7 @@ export async function createChatConversationRoom(args: {
sessionId: args.sessionId,
title: args.title ?? '새 대화',
chatTypeId: args.chatTypeId ?? null,
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
contextLabel: args.contextLabel ?? null,
contextDescription: args.contextDescription ?? null,
notifyOffline,
@@ -1076,6 +1091,7 @@ export async function updateChatConversationRoom(
payload: {
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean;

View File

@@ -38,6 +38,7 @@ export type ChatConversationSummary = {
clientId: string | null;
title: string;
chatTypeId: string | null;
lastChatTypeId: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;

View File

@@ -93,6 +93,25 @@
padding: 4px;
}
.previewer-ui__findbar {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: center;
gap: 8px;
margin: -4px -4px 12px;
padding: 8px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
backdrop-filter: blur(10px);
}
.previewer-ui__findbar .ant-input-affix-wrapper {
flex: 1 1 auto;
min-width: 0;
}
.previewer-ui__action-button {
display: inline-flex;
align-items: center;
@@ -124,6 +143,12 @@
0 12px 28px rgba(15, 23, 42, 0.16);
}
.previewer-ui--expanded .previewer-ui__header {
position: sticky;
top: 0;
z-index: 4;
}
.previewer-ui--expanded .previewer-ui__body {
height: calc(100vh - 53px) !important;
}
@@ -305,3 +330,24 @@
.previewer-ui__token--option {
color: #c586c0;
}
@media (max-width: 720px) {
.previewer-ui__header {
align-items: flex-start;
flex-direction: column;
}
.previewer-ui__toolbar {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
}
.previewer-ui__findbar {
flex-wrap: wrap;
}
.previewer-ui__findbar .ant-btn {
flex: 1 1 calc(50% - 4px);
}
}

View File

@@ -1,7 +1,13 @@
import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button, Empty, Select, message } from 'antd';
import {
CopyOutlined,
DownloadOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { Button, Empty, Input, Select, message } from 'antd';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { InlineImage } from '../common/InlineImage';
import { CodexDiffBlock } from './CodexDiffBlock';
import type { PreviewerUIProps } from './types';
@@ -283,13 +289,17 @@ export function PreviewerUI({
}: PreviewerUIProps) {
const [messageApi, contextHolder] = message.useMessage();
const [isExpanded, setIsExpanded] = useState(false);
const [isFindOpen, setIsFindOpen] = useState(false);
const [findQuery, setFindQuery] = useState('');
const findInputRef = useRef<HTMLInputElement | null>(null);
const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0;
const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value });
const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue });
const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName });
const canCopy = copyable && resolvedCopyValue.trim().length > 0;
const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar);
const canFind = type !== 'image' && type !== 'empty';
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || canFind || Boolean(toolbar);
useEffect(() => {
if (!isExpanded || typeof document === 'undefined') {
@@ -312,6 +322,18 @@ export function PreviewerUI({
};
}, [isExpanded]);
useEffect(() => {
if (!isExpanded) {
setIsFindOpen(false);
setFindQuery('');
return;
}
if (isFindOpen) {
window.setTimeout(() => findInputRef.current?.focus(), 0);
}
}, [isExpanded, isFindOpen]);
async function handleCopy() {
if (!canCopy) {
return;
@@ -333,6 +355,44 @@ export function PreviewerUI({
setIsExpanded((previous) => !previous);
}
function handleFind(direction: 'forward' | 'backward' = 'forward') {
const keyword = findQuery.trim();
if (!keyword) {
messageApi.info('찾을 단어를 입력해주세요.');
return;
}
const browserWindow = typeof window === 'undefined' ? null : (window as Window & {
find?: (
text: string,
caseSensitive?: boolean,
backwards?: boolean,
wrapAround?: boolean,
wholeWord?: boolean,
searchInFrames?: boolean,
showDialog?: boolean,
) => boolean;
});
if (!browserWindow || typeof browserWindow.find !== 'function') {
messageApi.error('이 브라우저에서는 단어 찾기를 지원하지 않습니다.');
return;
}
const matched = browserWindow.find(keyword, false, direction === 'backward', true, false, false, false);
if (!matched) {
messageApi.info('일치하는 단어를 찾지 못했습니다.');
}
}
function toggleFind() {
if (!canFind) {
return;
}
setIsFindOpen((previous) => !previous);
}
function handleDownload() {
if (!canDownload) {
return;
@@ -405,6 +465,16 @@ export function PreviewerUI({
onClick={() => void toggleFullscreen()}
/>
) : null}
{canFind ? (
<Button
size="small"
type="text"
className="previewer-ui__action-button"
aria-label="단어 찾기"
icon={<SearchOutlined />}
onClick={toggleFind}
/>
) : null}
{toolbar}
</>
);
@@ -433,6 +503,27 @@ export function PreviewerUI({
) : null}
<div className="previewer-ui__body previewer-ui__scroll" style={{ height }}>
{!showHeader && shouldShowActions ? <div className="previewer-ui__floating-toolbar">{actionContent}</div> : null}
{isExpanded && isFindOpen ? (
<div className="previewer-ui__findbar">
<Input
ref={(node) => {
findInputRef.current = node?.input ?? null;
}}
size="small"
value={findQuery}
placeholder="단어 찾기"
allowClear
onChange={(event) => setFindQuery(event.target.value)}
onPressEnter={(event) => handleFind(event.shiftKey ? 'backward' : 'forward')}
/>
<Button size="small" onClick={() => handleFind('backward')}>
</Button>
<Button size="small" type="primary" onClick={() => handleFind('forward')}>
</Button>
</div>
) : null}
{renderContent({
type,
value,

View File

@@ -30,7 +30,6 @@ export function SearchCommandModal({
submitHint,
}: SearchCommandModalProps) {
const inputRef = useRef<InputRef | null>(null);
const selectionLockRef = useRef(false);
const [query, setQuery] = useState('');
const [isMobileViewport, setIsMobileViewport] = useState(() => {
if (typeof window === 'undefined') {
@@ -47,11 +46,10 @@ export function SearchCommandModal({
}, [open]);
const submitOption = (option: SearchKeywordOption | undefined) => {
if (!option || selectionLockRef.current) {
if (!option) {
return;
}
selectionLockRef.current = true;
onSelectOption(option);
onClose();
};
@@ -127,7 +125,6 @@ export function SearchCommandModal({
return;
}
selectionLockRef.current = false;
window.requestAnimationFrame(() => {
inputRef.current?.focus({
cursor: 'all',

View File

@@ -108,6 +108,7 @@ export function ServerCommandPage() {
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
test: { output: null, executedAt: '', restartState: 'completed' },
rel: { output: null, executedAt: '', restartState: 'completed' },
prod: { output: null, executedAt: '', restartState: 'completed' },
'work-server': { output: null, executedAt: '', restartState: 'completed' },
'command-runner': { output: null, executedAt: '', restartState: 'completed' },
});
@@ -211,7 +212,7 @@ export function ServerCommandPage() {
Server Command
</Title>
<Paragraph className="server-command-page__copy">
TEST, REL, WORK-SERVER, COMMAND-RUNNER .
TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER .
</Paragraph>
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
<Col xs={12} md={6}>

View File

@@ -129,7 +129,7 @@ function normalizeServerCommandItem(value: unknown): ServerCommandItem {
const item = value as Partial<Record<keyof ServerCommandItem, unknown>>;
const key = typeof item.key === 'string' ? item.key : '';
if (key !== 'test' && key !== 'rel' && key !== 'work-server' && key !== 'command-runner') {
if (key !== 'test' && key !== 'rel' && key !== 'prod' && key !== 'work-server' && key !== 'command-runner') {
throw new Error('지원하지 않는 서버 키입니다.');
}

View File

@@ -1,4 +1,4 @@
export type ServerCommandKey = 'test' | 'rel' | 'work-server' | 'command-runner';
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
export type ServerCommandItem = {
key: ServerCommandKey;

View File

@@ -2,7 +2,6 @@ import {
createContext,
useContext,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from 'react';
@@ -18,14 +17,12 @@ type SearchLayerContextValue = SearchLayerSnapshot & {
};
const SearchLayerContext = createContext<SearchLayerContextValue | null>(null);
const WINDOW_SELECTION_DEDUP_MS = 500;
export function SearchLayerProvider({ children }: PropsWithChildren) {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<SearchKeywordOption[]>([]);
const [mode, setMode] = useState<SearchOpenMode>('navigate');
const [windowSelections, setWindowSelections] = useState<SearchWindowSelection[]>([]);
const lastWindowSelectionRef = useRef<{ id: string; at: number } | null>(null);
const value = useMemo<SearchLayerContextValue>(
() => ({
@@ -35,7 +32,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) {
windowSelections,
setOptions,
openSearch: (nextMode = 'navigate') => {
lastWindowSelectionRef.current = null;
setMode(nextMode);
setOpen(true);
},
@@ -72,22 +68,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) {
onClose={value.closeSearch}
onSelectOption={(option) => {
if (mode === 'window') {
const now = Date.now();
const previousSelection = lastWindowSelectionRef.current;
if (
previousSelection &&
previousSelection.id === option.id &&
now - previousSelection.at <= WINDOW_SELECTION_DEDUP_MS
) {
return;
}
lastWindowSelectionRef.current = {
id: option.id,
at: now,
};
setOpen(false);
setWindowSelections((previous) => [
...previous,