diff --git a/AGENTS.md b/AGENTS.md index f5d5733..9dd10b6 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ * `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다 * 사용자가 **명시적으로 요청한 경우를 제외하면** 구현 편의나 상태 갱신을 이유로 `polling`, `setInterval`, 주기적 재시도 루프 같은 반복 조회 구조를 추가하거나 유지하지 않는다 * 기존 기능에 `polling`, `setInterval`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다 +* `work-server` 재기동이나 배포 절차는 **기존 연결을 끊는 단일 컨테이너 재시작 방식이 아니라, blue/green 슬롯 전환 기반 무중단 절차를 기본 규칙으로 사용**한다 +* `work-server` 관련 문서, 스크립트, 운영 안내를 수정할 때는 **비활성 슬롯 기동 → health 확인 → 프록시 전환 → 이전 슬롯 정리** 순서를 유지하고, 연결이 끊기는 재시작을 기본 절차처럼 적지 않는다 ### 요청 해석 규칙 diff --git a/README.md b/README.md index 94cf1aa..1933c62 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ docker compose -f docker-compose.preview.yml up -d --build - 로컬 preview 컨테이너 접속 주소: `http://127.0.0.1:4173` - 외부 검증 도메인: `https://preview.sm-home.cloud/` -- preview 컨테이너는 현재 프로젝트 루트를 바인드 마운트하고 `vite build --watch`로 정적 산출물을 자동 재빌드합니다. -- 따라서 `https://preview.sm-home.cloud/`에서는 Vite HMR처럼 즉시 DOM이 바뀌지는 않지만, 소스 저장 후 재빌드가 끝나면 브라우저 새로고침만으로 최신 화면을 확인할 수 있습니다. +- preview 컨테이너는 현재 프로젝트 루트를 바인드 마운트하고 `vite dev` 서버로 실행됩니다. +- `https://preview.sm-home.cloud/`는 preview 컨테이너의 Vite dev server를 기준으로 사용하며, HMR이 연결되면 저장 후 새로고침 없이 변경이 반영됩니다. - API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다. - 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다. diff --git a/docker-compose.preview.yml b/docker-compose.preview.yml index 76d52fa..43ef802 100644 --- a/docker-compose.preview.yml +++ b/docker-compose.preview.yml @@ -2,12 +2,14 @@ services: preview-app: container_name: ai-code-app-preview image: node:${NODE_VERSION:-22.22.2}-bookworm - user: "0:0" + user: "${HOST_UID:-1000}:${HOST_GID:-1000}" working_dir: /app ports: - "${PREVIEW_APP_PORT:-4173}:5173" volumes: - ./:/app + - preview-app-hidden-dotdocker:/app/.docker + - preview-app-hidden-etc-servers:/app/etc/servers - ./.docker/preview-app/node_modules:/app/node_modules - ./.docker/preview-app/home:/home/how2ice networks: @@ -18,14 +20,19 @@ services: NPM_CONFIG_CACHE: /home/how2ice/.npm PORT: 5173 WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100} - VITE_PUBLIC_HMR_HOST: ${VITE_PUBLIC_HMR_HOST:-preview.sm-home.cloud} - VITE_PUBLIC_HMR_PROTOCOL: ${VITE_PUBLIC_HMR_PROTOCOL:-wss} - VITE_PUBLIC_HMR_CLIENT_PORT: ${VITE_PUBLIC_HMR_CLIENT_PORT:-443} VITE_DISABLE_APP_UPDATE: "true" + VITE_PUBLIC_HMR_HOST: preview.sm-home.cloud + VITE_PUBLIC_HMR_PROTOCOL: wss + VITE_PUBLIC_HMR_CLIENT_PORT: 443 + VITE_DISABLE_PWA: "true" command: > - sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173 --strictPort" + sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173" restart: unless-stopped +volumes: + preview-app-hidden-dotdocker: + preview-app-hidden-etc-servers: + networks: work-backend: external: true diff --git a/etc/commands/server-command/deploy-test.sh b/etc/commands/server-command/deploy-test.sh new file mode 100644 index 0000000..1e541ad --- /dev/null +++ b/etc/commands/server-command/deploy-test.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}" +REPO_ROOT="${REPO_ROOT:-$MAIN_PROJECT_ROOT}" +TEST_DEPLOY_GIT_REMOTE="${TEST_DEPLOY_GIT_REMOTE:-origin}" +TEST_DEPLOY_GIT_BRANCH="${TEST_DEPLOY_GIT_BRANCH:-main}" +TEST_BUILD_COMMAND="${TEST_BUILD_COMMAND:-npm run build:test-app}" +TEST_SERVER_RESTART_SCRIPT="${TEST_SERVER_RESTART_SCRIPT:-$SCRIPT_DIR/restart-test.sh}" +TEST_DEPLOY_COMMIT_MESSAGE="${TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot}" + +cd "$MAIN_PROJECT_ROOT" + +if ! command -v git >/dev/null 2>&1; then + echo "git CLI not found" >&2 + exit 127 +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "npm CLI not found" >&2 + exit 127 +fi + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true) + +if [ "$CURRENT_BRANCH" != "$TEST_DEPLOY_GIT_BRANCH" ]; then + echo "expected branch ${TEST_DEPLOY_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2 + exit 1 +fi + +echo "::step::commit-main-worktree" +git add -A -- . ':(exclude).server-command-test-app-built-at' ':(exclude,glob)tmp-*' ':(exclude,glob)tmp-verification/**' + +if git diff --cached --quiet; then + echo "no commit needed; main worktree already committed" +else + echo "staged files for TEST deploy commit:" + git diff --cached --name-status + git commit -m "$TEST_DEPLOY_COMMIT_MESSAGE" +fi + +echo "::step::push-origin-main" +git push "$TEST_DEPLOY_GIT_REMOTE" "$TEST_DEPLOY_GIT_BRANCH" + +echo "::step::build-test-app" +sh -lc "$TEST_BUILD_COMMAND" + +echo "::step::deploy-test-server" +REPO_ROOT="$REPO_ROOT" sh "$TEST_SERVER_RESTART_SCRIPT" diff --git a/etc/commands/server-command/restart-work-server.sh b/etc/commands/server-command/restart-work-server.sh index 77d7d95..dbc760f 100755 --- a/etc/commands/server-command/restart-work-server.sh +++ b/etc/commands/server-command/restart-work-server.sh @@ -15,11 +15,195 @@ ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-se PROXY_CONFIG_FILE="${WORK_SERVER_PROXY_CONFIG_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/proxy/default.conf}" HEALTH_ENDPOINT="${WORK_SERVER_HEALTH_ENDPOINT:-http://127.0.0.1:3100/health}" RUNTIME_ENDPOINT="${WORK_SERVER_RUNTIME_ENDPOINT:-http://127.0.0.1:3100/api/runtime}" +RECOVERY_ENDPOINT="${WORK_SERVER_RECOVERY_ENDPOINT:-http://127.0.0.1:3100/api/runtime/recover-interrupted-chat}" PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS="${WORK_SERVER_PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS:-900}" +LOCK_FILE="${WORK_SERVER_RESTART_LOCK_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/restart-in-progress.json}" +DEPLOY_STATE_FILE="${WORK_SERVER_DEPLOY_STATE_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/deployment-state.json}" +DEPLOY_FINISHED="false" +LAST_DEPLOY_ERROR="" +LAST_DEPLOY_LOG="" +PREVIOUS_ACTIVE_COUNT="" +PREVIOUS_QUEUED_COUNT="" +RECOVERED_SESSION_COUNT="" +RECOVERED_RESTARTED_COUNT="" +RECOVERED_REQUEUED_COUNT="" cd "$REPO_ROOT" -mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")" +mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")" "$(dirname "$LOCK_FILE")" "$(dirname "$DEPLOY_STATE_FILE")" +write_deploy_state() { + DEPLOY_STATUS="$1" + DEPLOY_PHASE="$2" + DEPLOY_SUMMARY="$3" + DEPLOY_STEP_KEY="${4:-}" + DEPLOY_STEP_STATUS="${5:-}" + DEPLOY_STEP_DETAIL="${6:-}" + DEPLOY_LAST_ERROR="${7:-}" + DEPLOY_LOG_EXCERPT="${8:-}" + DEPLOY_ACTIVE_SLOT_VALUE="${ACTIVE_SLOT:-}" + DEPLOY_TARGET_SLOT_VALUE="${TARGET_SLOT:-}" + DEPLOY_PREVIOUS_SLOT_VALUE="${PREVIOUS_SLOT:-}" + DEPLOY_TARGET_CONTAINER_VALUE="${TARGET_CONTAINER:-}" + DEPLOY_PREVIOUS_CONTAINER_VALUE="${PREVIOUS_CONTAINER:-}" + DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE="${PREVIOUS_ACTIVE_COUNT:-}" + DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE="${PREVIOUS_QUEUED_COUNT:-}" + DEPLOY_RECOVERED_SESSION_COUNT_VALUE="${RECOVERED_SESSION_COUNT:-}" + DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE="${RECOVERED_RESTARTED_COUNT:-}" + DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE="${RECOVERED_REQUEUED_COUNT:-}" + export \ + DEPLOY_STATUS \ + DEPLOY_PHASE \ + DEPLOY_SUMMARY \ + DEPLOY_STEP_KEY \ + DEPLOY_STEP_STATUS \ + DEPLOY_STEP_DETAIL \ + DEPLOY_LAST_ERROR \ + DEPLOY_LOG_EXCERPT \ + DEPLOY_ACTIVE_SLOT_VALUE \ + DEPLOY_TARGET_SLOT_VALUE \ + DEPLOY_PREVIOUS_SLOT_VALUE \ + DEPLOY_TARGET_CONTAINER_VALUE \ + DEPLOY_PREVIOUS_CONTAINER_VALUE \ + DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE \ + DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE \ + DEPLOY_RECOVERED_SESSION_COUNT_VALUE \ + DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE \ + DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE + node - "$DEPLOY_STATE_FILE" <<'NODE' +const fs = require('fs'); +const filePath = process.argv[2]; +const env = process.env; +const stepKeys = [ + 'build-target-slot', + 'verify-target-health', + 'switch-proxy', + 'drain-previous-slot', + 'rebuild-previous-slot', + 'recover-interrupted-chat', +]; +const readJson = () => { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +}; +const parseIso = (value) => { + if (!value || typeof value !== 'string') { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +}; +const parseSlot = (value) => (value === 'blue' || value === 'green' ? value : null); +const parseCount = (value) => { + if (value == null || value === '') { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; +const current = readJson() || {}; +const shouldResetForNewRun = + env.DEPLOY_STATUS === 'running' + && env.DEPLOY_PHASE === 'build-target-slot' + && !env.DEPLOY_STEP_KEY; +const now = new Date().toISOString(); +const stepsByKey = new Map(); +for (const stepKey of stepKeys) { + const existing = !shouldResetForNewRun && Array.isArray(current.steps) + ? current.steps.find((item) => item && item.key === stepKey) + : null; + stepsByKey.set(stepKey, { + key: stepKey, + status: + existing?.status === 'running' || existing?.status === 'completed' || existing?.status === 'failed' + ? existing.status + : 'pending', + detail: typeof existing?.detail === 'string' ? existing.detail : null, + updatedAt: parseIso(existing?.updatedAt) || null, + }); +} +if (env.DEPLOY_STEP_KEY && stepsByKey.has(env.DEPLOY_STEP_KEY)) { + const target = stepsByKey.get(env.DEPLOY_STEP_KEY); + target.status = + env.DEPLOY_STEP_STATUS === 'running' + || env.DEPLOY_STEP_STATUS === 'completed' + || env.DEPLOY_STEP_STATUS === 'failed' + ? env.DEPLOY_STEP_STATUS + : 'pending'; + target.detail = env.DEPLOY_STEP_DETAIL || target.detail || null; + target.updatedAt = now; +} +const payload = { + status: + env.DEPLOY_STATUS === 'running' || env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed' + ? env.DEPLOY_STATUS + : 'idle', + phase: + env.DEPLOY_PHASE === 'build-target-slot' + || env.DEPLOY_PHASE === 'verify-target-health' + || env.DEPLOY_PHASE === 'switch-proxy' + || env.DEPLOY_PHASE === 'drain-previous-slot' + || env.DEPLOY_PHASE === 'rebuild-previous-slot' + || env.DEPLOY_PHASE === 'recover-interrupted-chat' + || env.DEPLOY_PHASE === 'completed' + || env.DEPLOY_PHASE === 'failed' + ? env.DEPLOY_PHASE + : 'idle', + summary: env.DEPLOY_SUMMARY || (!shouldResetForNewRun ? current.summary : null) || null, + startedAt: shouldResetForNewRun ? now : parseIso(current.startedAt) || now, + updatedAt: now, + completedAt: + shouldResetForNewRun + ? null + : env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed' + ? now + : parseIso(current.completedAt), + activeSlot: parseSlot(env.DEPLOY_ACTIVE_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.activeSlot) : null), + targetSlot: parseSlot(env.DEPLOY_TARGET_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.targetSlot) : null), + previousSlot: parseSlot(env.DEPLOY_PREVIOUS_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.previousSlot) : null), + targetContainer: env.DEPLOY_TARGET_CONTAINER_VALUE || (!shouldResetForNewRun ? current.targetContainer : null) || null, + previousContainer: env.DEPLOY_PREVIOUS_CONTAINER_VALUE || (!shouldResetForNewRun ? current.previousContainer : null) || null, + previousSlotActiveChatRequestCount: + parseCount(env.DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE) + ?? (!shouldResetForNewRun ? parseCount(current.previousSlotActiveChatRequestCount) : null), + previousSlotQueuedChatRequestCount: + parseCount(env.DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE) + ?? (!shouldResetForNewRun ? parseCount(current.previousSlotQueuedChatRequestCount) : null), + recoveredSessionCount: + parseCount(env.DEPLOY_RECOVERED_SESSION_COUNT_VALUE) + ?? (!shouldResetForNewRun ? parseCount(current.recoveredSessionCount) : null), + recoveredRestartedCount: + parseCount(env.DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE) + ?? (!shouldResetForNewRun ? parseCount(current.recoveredRestartedCount) : null), + recoveredRequeuedCount: + parseCount(env.DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE) + ?? (!shouldResetForNewRun ? parseCount(current.recoveredRequeuedCount) : null), + lastError: env.DEPLOY_LAST_ERROR || (!shouldResetForNewRun ? current.lastError : null) || null, + logExcerpt: env.DEPLOY_LOG_EXCERPT || (!shouldResetForNewRun ? current.logExcerpt : null) || null, + steps: stepKeys.map((stepKey) => stepsByKey.get(stepKey)), +}; +fs.writeFileSync(filePath, JSON.stringify(payload) + '\n', 'utf8'); +NODE +} +cleanup_restart_lock() { + EXIT_CODE="$1" + if [ "$DEPLOY_FINISHED" != "true" ]; then + if [ "$EXIT_CODE" -ne 0 ]; then + SUMMARY="WORK-SERVER 배포가 중단되었습니다." + DETAIL="${LAST_DEPLOY_LOG:-알 수 없는 오류로 배포가 중단되었습니다.}" + ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}" + else + SUMMARY="WORK-SERVER 배포 완료 표기 전에 스크립트가 종료되었습니다." + DETAIL="${LAST_DEPLOY_LOG:-completed 상태를 기록하기 전에 스크립트가 종료되었습니다.}" + ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}" + fi + write_deploy_state failed failed "$SUMMARY" "" "" "" "$ERROR_TEXT" "$DETAIL" + fi + rm -f "$LOCK_FILE" +} +trap 'cleanup_restart_lock "$?"' EXIT INT TERM read_active_slot() { if [ -f "$ACTIVE_SLOT_FILE" ]; then @@ -125,6 +309,12 @@ set_container_draining() { docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ draining: process.argv[2] === 'true' }) }).then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));" "${RUNTIME_ENDPOINT}/drain" "$DRAINING_VALUE" } +recover_interrupted_chat_requests() { + TARGET_CONTAINER="$1" + + docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST' }).then(async (response) => { if (!response.ok) { process.stderr.write(await response.text()); process.exit(1); } process.stdout.write(await response.text()); }).catch((error) => { process.stderr.write(String(error)); process.exit(1); });" "$RECOVERY_ENDPOINT" +} + wait_for_previous_slot_drain() { TARGET_CONTAINER="$1" ELAPSED=0 @@ -132,6 +322,9 @@ wait_for_previous_slot_drain() { while [ "$ELAPSED" -lt "$PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS" ]; do ACTIVE_COUNT=$(read_runtime_value "$TARGET_CONTAINER" activeChatRequestCount 2>/dev/null || printf '0') QUEUED_COUNT=$(read_runtime_value "$TARGET_CONTAINER" queuedChatRequestCount 2>/dev/null || printf '0') + PREVIOUS_ACTIVE_COUNT="${ACTIVE_COUNT:-0}" + PREVIOUS_QUEUED_COUNT="${QUEUED_COUNT:-0}" + write_deploy_state running drain-previous-slot "이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다." "drain-previous-slot" running "active ${PREVIOUS_ACTIVE_COUNT} · queued ${PREVIOUS_QUEUED_COUNT}" if [ "${ACTIVE_COUNT:-0}" = "0" ] && [ "${QUEUED_COUNT:-0}" = "0" ]; then return 0 @@ -156,6 +349,7 @@ TARGET_SERVICE="$GREEN_SERVICE" TARGET_CONTAINER="$GREEN_CONTAINER" PREVIOUS_SERVICE="$BLUE_SERVICE" PREVIOUS_CONTAINER="$BLUE_CONTAINER" +PREVIOUS_SLOT="blue" if [ "$ACTIVE_SLOT" = "green" ]; then TARGET_SLOT="blue" @@ -163,19 +357,96 @@ if [ "$ACTIVE_SLOT" = "green" ]; then TARGET_CONTAINER="$BLUE_CONTAINER" PREVIOUS_SERVICE="$GREEN_SERVICE" PREVIOUS_CONTAINER="$GREEN_CONTAINER" + PREVIOUS_SLOT="green" fi -docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE" -wait_for_container_health "$TARGET_CONTAINER" +write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 시작했습니다." + +if BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE" 2>&1); then + [ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT" + write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 완료했습니다." "build-target-slot" completed "대상 슬롯 ${TARGET_SLOT} 준비 완료" +else + BUILD_STATUS=$? + LAST_DEPLOY_ERROR="대기 슬롯 빌드에 실패했습니다." + LAST_DEPLOY_LOG="${BUILD_OUTPUT:-docker compose build failed}" + [ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT" >&2 + write_deploy_state failed failed "대기 슬롯 빌드에 실패했습니다." "build-target-slot" failed "대상 슬롯 ${TARGET_SLOT} 빌드 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + exit "$BUILD_STATUS" +fi + +write_deploy_state running verify-target-health "새 슬롯 health 확인을 진행합니다." "verify-target-health" running "대상 컨테이너 ${TARGET_CONTAINER}" +if wait_for_container_health "$TARGET_CONTAINER"; then + write_deploy_state running verify-target-health "새 슬롯 health 확인이 완료되었습니다." "verify-target-health" completed "대상 슬롯 ${TARGET_SLOT} 정상 응답" +else + LAST_DEPLOY_ERROR="새 슬롯 health 확인에 실패했습니다." + LAST_DEPLOY_LOG="health check failed for ${TARGET_CONTAINER}" + write_deploy_state failed failed "새 슬롯 health 확인에 실패했습니다." "verify-target-health" failed "대상 슬롯 ${TARGET_SLOT} health 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + exit 1 +fi + +write_deploy_state running switch-proxy "프록시를 새 슬롯으로 전환합니다." "switch-proxy" running "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}" write_proxy_config "$TARGET_SLOT" -ensure_proxy_running +if ensure_proxy_running; then + write_deploy_state running switch-proxy "프록시 전환을 완료했습니다." "switch-proxy" completed "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}" +else + LAST_DEPLOY_ERROR="프록시 전환에 실패했습니다." + LAST_DEPLOY_LOG="nginx reload failed" + write_deploy_state failed failed "프록시 전환에 실패했습니다." "switch-proxy" failed "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + exit 1 +fi printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE" +ACTIVE_SLOT="$TARGET_SLOT" if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then set_container_draining "$PREVIOUS_CONTAINER" true - wait_for_previous_slot_drain "$PREVIOUS_CONTAINER" - docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$PREVIOUS_SERVICE" - wait_for_container_health "$PREVIOUS_CONTAINER" + if wait_for_previous_slot_drain "$PREVIOUS_CONTAINER"; then + write_deploy_state running drain-previous-slot "이전 슬롯 요청 이관이 완료되었습니다." "drain-previous-slot" completed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}" + else + LAST_DEPLOY_ERROR="이전 슬롯 드레인 대기 시간이 초과되었습니다." + LAST_DEPLOY_LOG="drain timeout reached for ${PREVIOUS_CONTAINER}" + write_deploy_state failed failed "이전 슬롯 요청 이관이 시간 안에 끝나지 않았습니다." "drain-previous-slot" failed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + exit 1 + fi + + write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구합니다." "rebuild-previous-slot" running "대상 컨테이너 ${PREVIOUS_CONTAINER}" + if PREVIOUS_BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$PREVIOUS_SERVICE" 2>&1); then + [ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT" + else + PREVIOUS_BUILD_STATUS=$? + LAST_DEPLOY_ERROR="이전 슬롯 대기 복구 빌드에 실패했습니다." + LAST_DEPLOY_LOG="${PREVIOUS_BUILD_OUTPUT:-docker compose rebuild failed}" + [ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT" >&2 + write_deploy_state failed failed "이전 슬롯 대기 복구 빌드에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} 복구 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + exit "$PREVIOUS_BUILD_STATUS" + fi + + if wait_for_container_health "$PREVIOUS_CONTAINER"; then + write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구했습니다." "rebuild-previous-slot" completed "대기 슬롯 ${PREVIOUS_SLOT} 정상 응답" + else + LAST_DEPLOY_ERROR="이전 슬롯 복구 health 확인에 실패했습니다." + LAST_DEPLOY_LOG="health check failed for ${PREVIOUS_CONTAINER}" + write_deploy_state failed failed "이전 슬롯 복구 health 확인에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} health 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + exit 1 + fi fi -printf 'work-server zero-downtime switch completed: %s -> %s\n' "$ACTIVE_SLOT" "$TARGET_SLOT" +write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구를 확인합니다." "recover-interrupted-chat" running "대상 슬롯 ${TARGET_SLOT}" +if RECOVERY_JSON=$(recover_interrupted_chat_requests "$TARGET_CONTAINER" 2>&1); then + [ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON" + RECOVERY_COUNTS=$(printf '%s' "$RECOVERY_JSON" | node -e "let raw=''; process.stdin.on('data', (chunk) => raw += chunk); process.stdin.on('end', () => { try { const parsed = JSON.parse(raw); const recovered = parsed?.recovered ?? {}; process.stdout.write([recovered.sessionCount ?? '', recovered.restartedCount ?? '', recovered.requeuedCount ?? ''].join('\t')); } catch { process.stdout.write('\t\t'); } });") + RECOVERED_SESSION_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $1}') + RECOVERED_RESTARTED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $2}') + RECOVERED_REQUEUED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $3}') + write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구 확인이 완료되었습니다." "recover-interrupted-chat" completed "session ${RECOVERED_SESSION_COUNT:-0} · restarted ${RECOVERED_RESTARTED_COUNT:-0} · requeued ${RECOVERED_REQUEUED_COUNT:-0}" +else + RECOVERY_STATUS=$? + LAST_DEPLOY_ERROR="중단된 채팅 요청 복구 확인에 실패했습니다." + LAST_DEPLOY_LOG="${RECOVERY_JSON:-recover interrupted chat failed}" + [ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON" >&2 + write_deploy_state failed failed "중단된 채팅 요청 복구 확인에 실패했습니다." "recover-interrupted-chat" failed "대상 슬롯 ${TARGET_SLOT}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + exit "$RECOVERY_STATUS" +fi + +DEPLOY_FINISHED="true" +write_deploy_state completed completed "WORK-SERVER 무중단 배포를 완료했습니다." +printf 'work-server zero-downtime switch completed: %s -> %s\n' "$PREVIOUS_SLOT" "$TARGET_SLOT" diff --git a/etc/servers/work-server/README.md b/etc/servers/work-server/README.md index bd26265..f7285ed 100644 --- a/etc/servers/work-server/README.md +++ b/etc/servers/work-server/README.md @@ -19,6 +19,12 @@ docker compose logs -f work-server `work-server`는 `3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다. +운영 기본 규칙: + +- `work-server` 재기동은 기존 활성 슬롯을 바로 내리는 단일 컨테이너 재시작으로 처리하지 않습니다. +- 항상 `비활성 슬롯 기동 -> /health 확인 -> nginx upstream 전환 -> 이전 슬롯 정리` 순서를 유지합니다. +- 문서, 스크립트, 운영 가이드에 재기동 예시를 추가할 때도 무중단 전환 절차를 기본값으로 적고, 연결이 끊기는 재시작은 장애 대응이나 예외 상황으로만 취급합니다. + 슬롯 로그까지 같이 보려면 아래처럼 확인합니다. ```bash @@ -123,8 +129,9 @@ npm run server-command:runner ## 웹푸쉬 호출 메모 -- `POST /api/notifications/send`는 `title`, `body`, `data`, `threadId` 외에 `targetClientIds`도 받을 수 있습니다. -- `targetClientIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 클라이언트에게만 알림을 보냅니다. +- `POST /api/notifications/send`는 `title`, `body`, `data`, `threadId` 외에 `targetDeviceIds`도 받을 수 있습니다. +- `targetDeviceIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 기기에만 알림을 보냅니다. +- 기존 `targetClientIds`도 호환 입력으로는 허용되지만, 새 호출은 `targetDeviceIds` 사용을 기준으로 합니다. - 웹푸시 구독과 PWA iOS 토큰 등록 시 서버는 `appOrigin`, `appDomain`도 함께 저장합니다. - `POST /api/notifications/send`에 `targetAppOrigins`, `targetAppDomains`를 넣으면 해당 앱 도메인/오리진으로 등록된 구독에만 발송할 수 있습니다. - `GET /api/notifications/subscriptions/web`로 현재 저장된 웹푸시 구독의 `deviceId`, `appOrigin`, `appDomain`, `enabled` 상태를 확인할 수 있습니다. diff --git a/etc/servers/work-server/data/e-reader-library.json b/etc/servers/work-server/data/e-reader-library.json index b1468f3..d434c2c 100644 --- a/etc/servers/work-server/data/e-reader-library.json +++ b/etc/servers/work-server/data/e-reader-library.json @@ -1,5 +1,397 @@ { "items": [ + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-a985ca2ada957429", + "title": "'불의 고리' 페루서 발생한 규모 6.1 지진으로 최소 27명 부상", + "sourceLabel": "연합뉴스", + "url": "https://n.news.naver.com/mnews/article/001/0016090015", + "lead": "페루 이카주 지진 [안디나통신 트위터 캡처. 재판매 및 DB금지]", + "body": "페루 이카주 지진 [안디나통신 트위터 캡처. 재판매 및 DB금지]\n\n(멕시코시티=연합뉴스) 송광호 특파원 = 지난 19일(현지시간) 페루 남부 이카주(州)에서 발생한 규모 6.1 지진으로 최소 27명이 다쳤다고 스페인 EFE통신이 보도했다.\n\n아마데오 플로레스 페루 국방부 장관은 \"현재까지 확인된 부상자는 27명이며 이들의 상태를 지속해 모니터링하고 있다\"고 말했다.\n\n지진이 나자 취약 지역을 중심으로 가옥이 붕괴하고, 통신이 두절되는 등 피해가 잇따랐다. 현지 언론은 지진 충격으로 이카의 공동묘지 납골당 벽면이 무너져 관이 외부에 노출됐으며, 교회 첨탑 일부가 파손되기도 했다고 전했다.\n\n이번 지진의 진앙은 이카주 주도인 이카시에서 남쪽으로 41㎞ 떨어진 지점이며 진원 깊이는 81㎞다. 본진 이후에도 규모 4.1과 3.6 여진이 추가로 발생했다.\n\n이카주는 페루 내에서도 지진 활동이 활발한 곳 중 하나다. 500명 이상의 사망자를 낸 지난 2007년 지진 당시에도 진앙이었다.\n\n페루는 전 세계 지진의 80% 이상이 발생하는 이른바 '태평양 불의 고리'에 자리 잡고 있어 평소에도 주민 대피 훈련을 실시한다고 EFE는 전했다.", + "htmlBody": "
\"페루

페루 이카주 지진 [안디나통신 트위터 캡처. 재판매 및 DB금지]

(멕시코시티=연합뉴스) 송광호 특파원 = 지난 19일(현지시간) 페루 남부 이카주(州)에서 발생한 규모 6.1 지진으로 최소 27명이 다쳤다고 스페인 EFE통신이 보도했다.

아마데오 플로레스 페루 국방부 장관은 "현재까지 확인된 부상자는 27명이며 이들의 상태를 지속해 모니터링하고 있다"고 말했다.

지진이 나자 취약 지역을 중심으로 가옥이 붕괴하고, 통신이 두절되는 등 피해가 잇따랐다. 현지 언론은 지진 충격으로 이카의 공동묘지 납골당 벽면이 무너져 관이 외부에 노출됐으며, 교회 첨탑 일부가 파손되기도 했다고 전했다.

이번 지진의 진앙은 이카주 주도인 이카시에서 남쪽으로 41㎞ 떨어진 지점이며 진원 깊이는 81㎞다. 본진 이후에도 규모 4.1과 3.6 여진이 추가로 발생했다.

이카주는 페루 내에서도 지진 활동이 활발한 곳 중 하나다. 500명 이상의 사망자를 낸 지난 2007년 지진 당시에도 진앙이었다.

페루는 전 세계 지진의 80% 이상이 발생하는 이른바 '태평양 불의 고리'에 자리 잡고 있어 평소에도 주민 대피 훈련을 실시한다고 EFE는 전했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "publishedAt": "2026-05-20T14:52:02.000Z" + }, + "createdAt": "2026-05-26T14:50:42.382Z", + "updatedAt": "2026-05-26T14:50:42.382Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-670d6ca2a479105f", + "title": "폭행 진실공방에 원정 의혹까지…울산시장 선거 ’진흙탕’", + "sourceLabel": "부산일보", + "url": "https://n.news.naver.com/mnews/article/082/0001382057", + "lead": "양측 의혹 네거티브 공방전 비화 혁신당, ‘폭력’ 김두겸 사퇴 촉구 김두겸 측 \"방어 손짓, 고소·고발\" 김상욱, 필리핀 단독일정 해명", + "body": "양측 의혹 네거티브 공방전 비화 혁신당, ‘폭력’ 김두겸 사퇴 촉구 김두겸 측 \"방어 손짓, 고소·고발\" 김상욱, 필리핀 단독일정 해명\n\n조국혁신당 울산시당이 22일 국민의힘 김두겸 울산시장의 폭력과 관련해 후보 사퇴를 촉구한 가운데 김두겸 캠프 문호철 대변인이 해당 주장들에 반박하고 있다. 오상민 기자\n\n울산시장 선거가 도덕성 시비와 폭로가 난무하는 네거티브 공방으로 치닫고 있다. 여야 후보 캠프는 서로의 의혹을 겨냥해 즉각적인 사퇴를 촉구하고 수사기관 고발을 예고했다.\n\n조국혁신당 울산시당은 22일 울산시의회 프레스센터에서 기자회견을 열고 언론인에 폭력을 행사한 국민의힘 김두겸 울산시장 후보의 사퇴를 촉구했다.\n\n조국혁신당은 “김 후보 캠프 측은 ‘방어적 손짓’이라며 사실관계를 왜곡하고 있다”며 “마음에 안 든다고 폭력을 행사하다니 평소 습관이 아니라면 나오기 어려운 행동으로, 심각한 도덕성 결핍과 자질 문제를 보여준다”고 비판했다.\n\n더불어민주당 울산시당도 논평을 통해 “공당의 광역단체장 후보라면 어떠한 상황에서도 물리력으로 대응해서는 안 된다”며 “피해 기자와 울산시민 앞에 공개 사과해야 한다”고 밝혔다.\n\n김두겸 후보 캠프 측은 크게 반발했다. 김두겸 후보 캠프 문호철 대변인은 조국혁신당의 가세에 대해 “진실 검증 한 번 거치지 않은 매체의 일방적 주장을 사실처럼 왜곡한 명백한 선거 개입”이라고 규탄했다.\n\n이어 “뉴스타파 측이 얼굴을 향해 카메라를 위협적으로 들이대며 무리한 취재를 강행했다”며 신체적 위협을 막기 위한 본능적인 방어적 손짓이었다고 해명했다.\n\n김두겸 후보 측은 해당 매체를 선거방해 행위로 울산시선관위에 고발 조치했다.\n\n취재진 카메라에 뒤통수를 부딪혀 부상당한 수행원에 대해서는 폭행이나 과실치상 등 혐의를 적용해 수사 기관에 고소한다는 방침이다.\n\n앞서 뉴스타파는 지난 21일 유세를 마친 김 후보에게 인터뷰를 시도하자, 카메라를 내리치고 기자의 턱을 움켜쥐는 등 폭력을 행사했다고 주장했다.\n\n(왼쪽부터)더불어민주당 김상욱, 국민의힘 김두겸 울산시장 후보. 오상민 기자\n\n한편 더불어민주당 김상욱 울산시장 후보는 이날 자신에게 제기된 필리핀 원정 의혹과 관련해 소셜미디어에 해명 글을 게시했다.\n\n김 후보는 “필리핀 인력을 합법적으로 공급하는 업무를 연구하고 사업 모델링을 하던 중이었다”며 “오가는 비행기만 같이 탔을 뿐 현지 일정은 단독으로 움직였다”고 설명했다.\n\n이에 김두겸 캠프는 “구체적 일정도, 상대방도, 증빙자료도 없는 해명은 의혹만 더 키웠다”면서 “네거티브라는 말 뒤에 숨지 말고 본인에게 제기된 의혹부터 객관적 자료로 명백히 해명하라”고 말했다.", + "htmlBody": "

양측 의혹 네거티브 공방전 비화 혁신당, ‘폭력’ 김두겸 사퇴 촉구 김두겸 측 "방어 손짓, 고소·고발" 김상욱, 필리핀 단독일정 해명

\"조국혁신당

조국혁신당 울산시당이 22일 국민의힘 김두겸 울산시장의 폭력과 관련해 후보 사퇴를 촉구한 가운데 김두겸 캠프 문호철 대변인이 해당 주장들에 반박하고 있다. 오상민 기자

울산시장 선거가 도덕성 시비와 폭로가 난무하는 네거티브 공방으로 치닫고 있다. 여야 후보 캠프는 서로의 의혹을 겨냥해 즉각적인 사퇴를 촉구하고 수사기관 고발을 예고했다.

조국혁신당 울산시당은 22일 울산시의회 프레스센터에서 기자회견을 열고 언론인에 폭력을 행사한 국민의힘 김두겸 울산시장 후보의 사퇴를 촉구했다.

조국혁신당은 “김 후보 캠프 측은 ‘방어적 손짓’이라며 사실관계를 왜곡하고 있다”며 “마음에 안 든다고 폭력을 행사하다니 평소 습관이 아니라면 나오기 어려운 행동으로, 심각한 도덕성 결핍과 자질 문제를 보여준다”고 비판했다.

더불어민주당 울산시당도 논평을 통해 “공당의 광역단체장 후보라면 어떠한 상황에서도 물리력으로 대응해서는 안 된다”며 “피해 기자와 울산시민 앞에 공개 사과해야 한다”고 밝혔다.

김두겸 후보 캠프 측은 크게 반발했다. 김두겸 후보 캠프 문호철 대변인은 조국혁신당의 가세에 대해 “진실 검증 한 번 거치지 않은 매체의 일방적 주장을 사실처럼 왜곡한 명백한 선거 개입”이라고 규탄했다.

이어 “뉴스타파 측이 얼굴을 향해 카메라를 위협적으로 들이대며 무리한 취재를 강행했다”며 신체적 위협을 막기 위한 본능적인 방어적 손짓이었다고 해명했다.

김두겸 후보 측은 해당 매체를 선거방해 행위로 울산시선관위에 고발 조치했다.

취재진 카메라에 뒤통수를 부딪혀 부상당한 수행원에 대해서는 폭행이나 과실치상 등 혐의를 적용해 수사 기관에 고소한다는 방침이다.

앞서 뉴스타파는 지난 21일 유세를 마친 김 후보에게 인터뷰를 시도하자, 카메라를 내리치고 기자의 턱을 움켜쥐는 등 폭력을 행사했다고 주장했다.

\"(왼쪽부터)더불어민주당

(왼쪽부터)더불어민주당 김상욱, 국민의힘 김두겸 울산시장 후보. 오상민 기자

한편 더불어민주당 김상욱 울산시장 후보는 이날 자신에게 제기된 필리핀 원정 의혹과 관련해 소셜미디어에 해명 글을 게시했다.

김 후보는 “필리핀 인력을 합법적으로 공급하는 업무를 연구하고 사업 모델링을 하던 중이었다”며 “오가는 비행기만 같이 탔을 뿐 현지 일정은 단독으로 움직였다”고 설명했다.

이에 김두겸 캠프는 “구체적 일정도, 상대방도, 증빙자료도 없는 해명은 의혹만 더 키웠다”면서 “네거티브라는 말 뒤에 숨지 말고 본인에게 제기된 의혹부터 객관적 자료로 명백히 해명하라”고 말했다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "listedDate": "2026-05-22", + "publishedAt": "2026-05-22T05:52:17.000Z" + }, + "createdAt": "2026-05-26T14:50:41.648Z", + "updatedAt": "2026-05-26T14:50:41.648Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-c22ae949c2fa3bf2", + "title": "與, 삼성전자 협상 극적 타결에 \"환영…대화로 해법 마련 의미\"", + "sourceLabel": "연합뉴스", + "url": "https://n.news.naver.com/mnews/article/001/0016090016", + "lead": "\"노동자 권리·기업 성장, 타협으로 풀어낼 수 있다는 것 확인\"", + "body": "\"노동자 권리·기업 성장, 타협으로 풀어낼 수 있다는 것 확인\"\n\n삼성전자 파업 유보 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 기자회견을 하고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr\n\n(서울=연합뉴스) 오규진 기자 = 더불어민주당은 20일 삼성전자 노사간 협상이 총파업을 목전에 두고 극적 타결된 것에 대해 \"끝까지 대화의 끈을 놓지 않고 타협과 결단을 선택한 노사 양측의 책임 있는 자세에 감사와 환영의 뜻을 전한다\"고 밝혔다.\n\n더불어민주당 박해철 대변인은 이날 서면 브리핑에서 \"이번 합의는 극한 대립과 파업에 대한 국민적 우려 속에서도, 대화와 조정을 통해 해법을 마련할 수 있음을 보여준 의미 있는 결과\"라며 이같이 평가했다.\n\n그는 \"노동자의 정당한 권리와 기업의 지속 가능한 성장이라는 과제를 충돌 아닌 타협으로 풀어낼 수 있다는 걸 재차 확인했다\"며 \"생산 현장의 안정과 노사 간 신뢰 회복은 대한민국 산업 경쟁력과 대외 신인도에 큰 보탬이 될 것\"이라고 했다.\n\n또 김영훈 고용노동부 장관이 노사 협상을 중재한 것과 관련, \"당장 몇 시간 뒤 파업이라는 긴박한 상황에서, 장관이 직접 교섭 조정에 나서며 혼신의 노력을 다한 점은 칭찬받아 마땅할 성과\"라고 평가했다.\n\n정청래 대표도 이날 페이스북에 \"참 잘됐다\"며 \"포기하지 않고 최선을 다해준 김 장관 등 관계자들에게 감사드린다. 고맙다\"고 적었다.", + "htmlBody": "

"노동자 권리·기업 성장, 타협으로 풀어낼 수 있다는 것 확인"

\"삼성전자

삼성전자 파업 유보 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 기자회견을 하고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr

(서울=연합뉴스) 오규진 기자 = 더불어민주당은 20일 삼성전자 노사간 협상이 총파업을 목전에 두고 극적 타결된 것에 대해 "끝까지 대화의 끈을 놓지 않고 타협과 결단을 선택한 노사 양측의 책임 있는 자세에 감사와 환영의 뜻을 전한다"고 밝혔다.

더불어민주당 박해철 대변인은 이날 서면 브리핑에서 "이번 합의는 극한 대립과 파업에 대한 국민적 우려 속에서도, 대화와 조정을 통해 해법을 마련할 수 있음을 보여준 의미 있는 결과"라며 이같이 평가했다.

그는 "노동자의 정당한 권리와 기업의 지속 가능한 성장이라는 과제를 충돌 아닌 타협으로 풀어낼 수 있다는 걸 재차 확인했다"며 "생산 현장의 안정과 노사 간 신뢰 회복은 대한민국 산업 경쟁력과 대외 신인도에 큰 보탬이 될 것"이라고 했다.

또 김영훈 고용노동부 장관이 노사 협상을 중재한 것과 관련, "당장 몇 시간 뒤 파업이라는 긴박한 상황에서, 장관이 직접 교섭 조정에 나서며 혼신의 노력을 다한 점은 칭찬받아 마땅할 성과"라고 평가했다.

정청래 대표도 이날 페이스북에 "참 잘됐다"며 "포기하지 않고 최선을 다해준 김 장관 등 관계자들에게 감사드린다. 고맙다"고 적었다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "publishedAt": "2026-05-20T14:53:53.000Z" + }, + "createdAt": "2026-05-26T14:43:14.242Z", + "updatedAt": "2026-05-26T14:43:14.242Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-8cef286382456977", + "title": "조국 \"제가 중심 잡겠다\"…김용남·김재연에 공동 공약 발표 제안", + "sourceLabel": "SBS", + "url": "https://n.news.naver.com/mnews/article/055/0001357812", + "lead": "▲ 20일 국회 의원회관에서 열린 조국혁신당 파란개비 선대위 '승리의 파란' 출정식에서 경기 평택을 국회의원 재선거에 출마한 조국 후보가 인사말을 하고 있다.", + "body": "▲ 20일 국회 의원회관에서 열린 조국혁신당 파란개비 선대위 '승리의 파란' 출정식에서 경기 평택을 국회의원 재선거에 출마한 조국 후보가 인사말을 하고 있다.\n\n경기도 평택을 재선거에 출마한 조국혁신당 조국 후보는 오늘(20일) 민주·진보 진영의 후보 단일화 문제에는 재차 선을 그으면서 더불어민주당 김용남·진보당 김재연 후보를 향해 공동 공약 발표를 제안했습니다.\n\n조 대표는 이날 국회 기자회견에서 \"우리는 민주개혁 진영의 원팀\"이라면서 이같이 말했습니다.\n\n그는 공약 내용과 관련, \"평택지원특별법 개정 등 평택의 미래를 위한 과제를 중심에 두고 대한민국의 미래가 달린 검찰·사법·정치개혁 등을 함께 약속하자\"고 언급했습니다.\n\n그는 또 \"민주개혁 진영의 연대와 통합을 잇는 견고한 다리가 되겠다\"며 \"민주개혁 진영의 연대와 통합을 충분히 예측 가능하고, 질서 정연하며, 안정감 속에 진행되도록 저 조국이 중심을 잡겠다\"고 말했습니다.\n\n조 후보는 '김어준의 겸손은 힘들다 뉴스공장'에서 \"지금 시대 정신이 검찰개혁이라고 한다면, 대통령의 소신과 다른 사람이 국회에 들어가게 되면 대통령도 통제가 안 된다\"며 자신과 경쟁하고 있는 민주당 김 후보를 임명직으로 보내야 한다고 말했습니다.\n\n또 후보 단일화 문제와 관련, \"현재 상황에서는 국민의힘 유의동 후보와 자유와혁신 황교안 후보 간 통합 (가능성이) 점점 줄어들고 있다. 시민들이 단일화를 거의 얘기 안 한다\"라고 강조했습니다.\n\n그는 그러면서 \"만약 그런 상황이 발생해 유 후보가 (여론조사에서) 1위가 되는 상황이 오면 국민의 명령에 따라야 한다\"고 말했습니다.", + "htmlBody": "
\"기사

▲ 20일 국회 의원회관에서 열린 조국혁신당 파란개비 선대위 '승리의 파란' 출정식에서 경기 평택을 국회의원 재선거에 출마한 조국 후보가 인사말을 하고 있다.

경기도 평택을 재선거에 출마한 조국혁신당 조국 후보는 오늘(20일) 민주&middot;진보 진영의 후보 단일화 문제에는 재차 선을 그으면서 더불어민주당 김용남&middot;진보당 김재연 후보를 향해 공동 공약 발표를 제안했습니다.

조 대표는 이날 국회 기자회견에서 "우리는 민주개혁 진영의 원팀"이라면서 이같이 말했습니다.

그는 공약 내용과 관련, "평택지원특별법 개정 등 평택의 미래를 위한 과제를 중심에 두고 대한민국의 미래가 달린 검찰&middot;사법&middot;정치개혁 등을 함께 약속하자"고 언급했습니다.

그는 또 "민주개혁 진영의 연대와 통합을 잇는 견고한 다리가 되겠다"며 "민주개혁 진영의 연대와 통합을 충분히 예측 가능하고, 질서 정연하며, 안정감 속에 진행되도록 저 조국이 중심을 잡겠다"고 말했습니다.

조 후보는 '김어준의 겸손은 힘들다 뉴스공장'에서 "지금 시대 정신이 검찰개혁이라고 한다면, 대통령의 소신과 다른 사람이 국회에 들어가게 되면 대통령도 통제가 안 된다"며 자신과 경쟁하고 있는 민주당 김 후보를 임명직으로 보내야 한다고 말했습니다.

또 후보 단일화 문제와 관련, "현재 상황에서는 국민의힘 유의동 후보와 자유와혁신 황교안 후보 간 통합 (가능성이) 점점 줄어들고 있다. 시민들이 단일화를 거의 얘기 안 한다"라고 강조했습니다.

그는 그러면서 "만약 그런 상황이 발생해 유 후보가 (여론조사에서) 1위가 되는 상황이 오면 국민의 명령에 따라야 한다"고 말했습니다.

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T07:52:35.000Z" + }, + "createdAt": "2026-05-26T14:43:13.708Z", + "updatedAt": "2026-05-26T14:43:13.708Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-a5860c2230d3c913", + "title": "삼성전자 \"성숙하고 건설적인 노사관계 구축하겠다\"", + "sourceLabel": "한국일보", + "url": "https://n.news.naver.com/mnews/article/469/0000931927", + "lead": "20일 경기 수원시 장안구 경기고용노동청에서 임금 협상을 마친 여명구(앞줄 왼쪽) 삼성전자 반도체(DS) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 손을 맞잡고 있다. 수원=연합뉴스", + "body": "20일 경기 수원시 장안구 경기고용노동청에서 임금 협상을 마친 여명구(앞줄 왼쪽) 삼성전자 반도체(DS) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 손을 맞잡고 있다. 수원=연합뉴스\n\n삼성전자가 노조와 20일 ‘임금 및 단체협약’에 잠정 합의한 직후 \"다시는 이런 일이 없도록 겸허한 자세로 보다 성숙하고 건설적인 노사관계를 구축해 나가겠다\"는 입장문을 발표했다.\n\n이어 \"뒤늦게나마 합의에 이르게 된 것은 국민과 주주, 고객 여러분의 성원, 정부의 헌신적인 조정, 그리고 묵묵히 자리를 지켜주신 임직원들이 있었기 때문\"이라며 \"진심으로 감사하다는 말씀과 함께 그동안 심려를 끼쳐드린 점, 깊이 사죄 드린다\"고 밝혔다.\n\n또한 \"기업 본연의 역할과 책임을 다함으로써 국가 경제에 더욱 기여하도록 최선을 다하겠다\"고 약속했다.", + "htmlBody": "

노조와 임금 및 단체협약 합의 입장문

\"20일

20일 경기 수원시 장안구 경기고용노동청에서 임금 협상을 마친 여명구(앞줄 왼쪽) 삼성전자 반도체(DS) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 손을 맞잡고 있다. 수원=연합뉴스

삼성전자가 노조와 20일 ‘임금 및 단체협약’에 잠정 합의한 직후 "다시는 이런 일이 없도록 겸허한 자세로 보다 성숙하고 건설적인 노사관계를 구축해 나가겠다"는 입장문을 발표했다.

이어 "뒤늦게나마 합의에 이르게 된 것은 국민과 주주, 고객 여러분의 성원, 정부의 헌신적인 조정, 그리고 묵묵히 자리를 지켜주신 임직원들이 있었기 때문"이라며 "진심으로 감사하다는 말씀과 함께 그동안 심려를 끼쳐드린 점, 깊이 사죄 드린다"고 밝혔다.

또한 "기업 본연의 역할과 책임을 다함으로써 국가 경제에 더욱 기여하도록 최선을 다하겠다"고 약속했다.

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:13:17.000Z" + }, + "createdAt": "2026-05-26T11:55:44.651Z", + "updatedAt": "2026-05-26T11:55:44.651Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-75ad7e7c29a2aa91", + "title": "인제대, 김종철 경남경찰청장 초청 특강 \"AI·로봇 융합 과학치안 중요\"", + "sourceLabel": "머니투데이", + "url": "https://n.news.naver.com/mnews/article/008/0005361346", + "lead": "급변하는 치안 패러다임 진단 및 과학치안 중요성 역설", + "body": "급변하는 치안 패러다임 진단 및 과학치안 중요성 역설\n\n김종철 경남경찰청장이 인제대 본관 1979홀에서 강연하고 있다./사진제공=인제대 인제대학교가 21일 본관 1979홀에서 김종철 경상남도경찰청장을 초청해 '경찰의 어제와 오늘, 그리고 미래' 특강을 열었다.\n\n이날 행사는 '혁신을 주도하는 대학을 만나다' 전문가 초청 프로그램의 일환으로 마련됐다. 특강에 참석한 교직원과 학생들은 미래 치안 환경 변화를 진단하고 공공 안전 분야의 인재 양성 방향에 대해 고민하는 시간을 가졌다.\n\n김 청장은 \"최근 10년간 살인, 강도 등 전통적인 강력범죄는 감소했다. 하지만 딥페이크 등 디지털 매체를 악용한 지능형 범죄와 스토킹 등 관계성 범죄는 복잡다단하게 진화하고 있다\"고 짚었다. 이어 \"이러한 환경 변화에 대응하기 위해서는 AI와 로봇 기술을 융합한 '미래 과학치안'이 필요하다\"고 덧붙였다.\n\n김 청장은 전국 관서에 도입 중인 수사지원 AI 시스템과 자율주행 순찰로봇 등 구체적인 혁신 사례를 소개하며 \"현장 중심의 첨단 기술을 적극 활용해 경남도민의 평온한 일상을 선제적으로 확보하겠다\"고 강조했다.\n\n또한 \"공동체의 안전을 위해서는 구성원 모두가 기본과 원칙에 충실한 치안 동반자로 참여해야 한다\"며 \"인제대의 우수한 인재들이 실패를 두려워하지 않고 창의적으로 임무를 완수하는 미래 주역으로 성장해 달라\"고 당부했다.\n\n전민현 총장은 \"바쁜 일정 중에도 인제대를 찾아 미래 과학치안의 비전을 공유해 주신 김 청장님께 깊이 감사드린다\"며 \"이번 특강은 학생들이 첨단 기술과 융합하는 미래 치안 환경을 입체적으로 이해하는 계기가 됐다. 앞으로도 공공 안전을 이끌어갈 융합형 인재를 양성하기 위해 지역 유관 기관과의 협력을 확대할 것\"이라고 전했다.", + "htmlBody": "

급변하는 치안 패러다임 진단 및 과학치안 중요성 역설

\"김종철

김종철 경남경찰청장이 인제대 본관 1979홀에서 강연하고 있다./사진제공=인제대 인제대학교가 21일 본관 1979홀에서 김종철 경상남도경찰청장을 초청해 '경찰의 어제와 오늘, 그리고 미래' 특강을 열었다.

이날 행사는 '혁신을 주도하는 대학을 만나다' 전문가 초청 프로그램의 일환으로 마련됐다. 특강에 참석한 교직원과 학생들은 미래 치안 환경 변화를 진단하고 공공 안전 분야의 인재 양성 방향에 대해 고민하는 시간을 가졌다.

김 청장은 "최근 10년간 살인, 강도 등 전통적인 강력범죄는 감소했다. 하지만 딥페이크 등 디지털 매체를 악용한 지능형 범죄와 스토킹 등 관계성 범죄는 복잡다단하게 진화하고 있다"고 짚었다. 이어 "이러한 환경 변화에 대응하기 위해서는 AI와 로봇 기술을 융합한 '미래 과학치안'이 필요하다"고 덧붙였다.

김 청장은 전국 관서에 도입 중인 수사지원 AI 시스템과 자율주행 순찰로봇 등 구체적인 혁신 사례를 소개하며 "현장 중심의 첨단 기술을 적극 활용해 경남도민의 평온한 일상을 선제적으로 확보하겠다"고 강조했다.

또한 "공동체의 안전을 위해서는 구성원 모두가 기본과 원칙에 충실한 치안 동반자로 참여해야 한다"며 "인제대의 우수한 인재들이 실패를 두려워하지 않고 창의적으로 임무를 완수하는 미래 주역으로 성장해 달라"고 당부했다.

전민현 총장은 "바쁜 일정 중에도 인제대를 찾아 미래 과학치안의 비전을 공유해 주신 김 청장님께 깊이 감사드린다"며 "이번 특강은 학생들이 첨단 기술과 융합하는 미래 치안 환경을 입체적으로 이해하는 계기가 됐다. 앞으로도 공공 안전을 이끌어갈 융합형 인재를 양성하기 위해 지역 유관 기관과의 협력을 확대할 것"이라고 전했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:40:38.000Z" + }, + "createdAt": "2026-05-26T11:55:44.105Z", + "updatedAt": "2026-05-26T11:55:44.105Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-a0034944ad4f4ee3", + "title": "[포토] 삼성전자 파업 유보", + "sourceLabel": "전자신문", + "url": "https://n.news.naver.com/mnews/article/030/0003429852", + "lead": "20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 기자회견을 하고 있다.", + "body": "20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 기자회견을 하고 있다.", + "htmlBody": "
\"기사

20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 기자회견을 하고 있다.

김민수 기자 mskim@etnews.com

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:17:16.000Z" + }, + "createdAt": "2026-05-26T11:03:25.618Z", + "updatedAt": "2026-05-26T11:03:25.618Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-9965a4c5c55bdb21", + "title": "탈북자 위장 남파 간첩, 출소 후 보안관찰 위반 재판행", + "sourceLabel": "연합뉴스", + "url": "https://n.news.naver.com/mnews/article/001/0016092578", + "lead": "(광주=연합뉴스) 정회성 기자 = 황장엽 전 북한노동당 비서를 살해하라는 지령을 받고 국내에 침투했던 북한 공작원이 교도소 출소 후 당국의 보안관찰 절차에 따르지 않아 또 재판에 넘겨졌다.", + "body": "(광주=연합뉴스) 정회성 기자 = 황장엽 전 북한노동당 비서를 살해하라는 지령을 받고 국내에 침투했던 북한 공작원이 교도소 출소 후 당국의 보안관찰 절차에 따르지 않아 또 재판에 넘겨졌다.\n\n광주지법 형사1-1부(강애란·남해인·정진화 부장판사)는 21일 북한 인민무력부 정찰총국 소속 남파 공작원 출신 A(52)씨의 보안관찰법 위반 등 혐의 사건 항소심 1차 공판을 열었다.\n\n전향을 거부한 채 북한 국적을 유지하며 국내에서 생활하는 A씨는 당국에 거주지 등 인적 사항을 신고하지 않은 혐의를 받는다. 1심에서 벌금 100만원을 선고받았다.\n\n그는 남한에 망명한 황 전 비서를 암살하라는 임무를 받고 2009년 12월 국내에 탈북자로 위장 잠입했다가 검거돼 국가보안법 위반 등의 혐의로 징역 10년형을 확정받은 전력이 있다.\n\n2020년 4월 만기 출소 후 지난해까지 총 20차례에 걸쳐 보안관찰법 의무 사항을 이행하지 않았다.\n\n검찰은 A씨에게 실형이 내려져야 한다며 1심의 판결에 불복해 항소했고, 이날 징역 6개월을 구형했다.\n\nA씨는 이날 최종진술에서 \"남한의 법을 잘 몰랐다\"며 단순한 실수였다고 항변했다.\n\n2심 재판부는 내달 11일 선고 공판을 열 예정이다.", + "htmlBody": "
\"법원\n[연합뉴스TV

(광주=연합뉴스) 정회성 기자 = 황장엽 전 북한노동당 비서를 살해하라는 지령을 받고 국내에 침투했던 북한 공작원이 교도소 출소 후 당국의 보안관찰 절차에 따르지 않아 또 재판에 넘겨졌다.

광주지법 형사1-1부(강애란·남해인·정진화 부장판사)는 21일 북한 인민무력부 정찰총국 소속 남파 공작원 출신 A(52)씨의 보안관찰법 위반 등 혐의 사건 항소심 1차 공판을 열었다.

전향을 거부한 채 북한 국적을 유지하며 국내에서 생활하는 A씨는 당국에 거주지 등 인적 사항을 신고하지 않은 혐의를 받는다. 1심에서 벌금 100만원을 선고받았다.

그는 남한에 망명한 황 전 비서를 암살하라는 임무를 받고 2009년 12월 국내에 탈북자로 위장 잠입했다가 검거돼 국가보안법 위반 등의 혐의로 징역 10년형을 확정받은 전력이 있다.

2020년 4월 만기 출소 후 지난해까지 총 20차례에 걸쳐 보안관찰법 의무 사항을 이행하지 않았다.

검찰은 A씨에게 실형이 내려져야 한다며 1심의 판결에 불복해 항소했고, 이날 징역 6개월을 구형했다.

A씨는 이날 최종진술에서 "남한의 법을 잘 몰랐다"며 단순한 실수였다고 항변했다.

2심 재판부는 내달 11일 선고 공판을 열 예정이다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:39:35.000Z" + }, + "createdAt": "2026-05-26T11:03:25.127Z", + "updatedAt": "2026-05-26T11:03:25.127Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-5e4c1c5322580ef1", + "title": "[포토]삼성전자 파업 유보", + "sourceLabel": "전자신문", + "url": "https://n.news.naver.com/mnews/article/030/0003429851", + "lead": "20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영훈 고용노동부 장관과 기념촬영을 하고 있다.", + "body": "20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영훈 고용노동부 장관과 기념촬영을 하고 있다.", + "htmlBody": "
\"기사

20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영훈 고용노동부 장관과 기념촬영을 하고 있다.

김민수 기자 mskim@etnews.com

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:17:14.000Z" + }, + "createdAt": "2026-05-26T10:46:15.978Z", + "updatedAt": "2026-05-26T10:46:15.978Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-db8fe5a4d32bc88d", + "title": "“다시 관문으로”… HMM 유조선 호르무즈 해협 통과에 기대감 키우는 韓 선사들", + "sourceLabel": "조선비즈", + "url": "https://n.news.naver.com/mnews/article/366/0001166287", + "lead": "나무호 피격 이후 관문 떠났던 선박들 복귀 해협 내 발 묶인 선박 25척 가운데 약 90% 모여 업계 “발 묶인 선박 단기간에 모두 나가긴 어려울 듯” 앞서 선박 피격 당한 일본·태국도 일부 선박만 빠져나가", + "body": "나무호 피격 이후 관문 떠났던 선박들 복귀 해협 내 발 묶인 선박 25척 가운데 약 90% 모여 업계 “발 묶인 선박 단기간에 모두 나가긴 어려울 듯” 앞서 선박 피격 당한 일본·태국도 일부 선박만 빠져나가\n\n이란과 미국의 봉쇄가 이어지고 있는 호르무즈 해협 내 발이 묶인 국적 선사의 선박들이 두바이를 비롯한 해협의 관문 지역으로 이동한 것으로 21일 나타났다.\n\nHMM 화물선인 나무호 피격으로 관문 지역을 떠나 있었으나, HMM의 초대형 원유 운반선(VLCC) 유니버셜 위너호가 해협을 빠져나가면서 운항 재개에 대한 기대감이 커졌기 때문인 것으로 보인다.\n\n이날 해운업계와 선박 위치 추적 정보사이트 마린트래픽등에 따르면 호르무즈 해협 내측 한국 국적 선박 25척 가운데 22척이 해협 관문 지역에 정박한 것으로 나타났다.\n\n해협 내 국적 선사 선박들은 지난 4일 나무호가 피격당한 직후에는 절반 이상이 두바이 등 관문 인근 지역을 떠나 카타르나 페르시아만 중앙 해역에 머물렀다. 그러다 전날을 기해 다시 관문 지역으로 돌아온 것이다.\n\n해운업계에서는 해협 봉쇄 기간이 3개월 차로 접어드는 상황에서 전날 HMM의 VLCC가 통행료를 내지 않고도 해협을 빠져나가는 데 성공하며 통행 기대감이 커졌기 때문으로 보고 있다.\n\n정부도 전날 VLCC 통과 이후 나머지 25척도 해협에서 나올 수 있도록 이란 측과 협의를 이어가고 있다고 밝혔고, 협의 대상 선박을 선정하고 있는 것으로 알려지며 통행 기대감이 어느 때보다 큰 상황이다.\n\n이 때문에 1척당 하루 약 21억원의 손실을 보고 있는 선사들로서는 한시라도 빨리 해협을 빠져나오기 위해 묘박지를 관문 지역으로 옮긴다는 것이다.\n\n호르무즈 해협 내 발이 묶인 선박들은 선박 문제와 이에 따른 예인 비용, 선원들의 건강 이상 등에 따른 비용은 보험으로 처리할 수 있으나 휴업 손실·유류비·인건비 등은 해결할 방법이 없는 것으로 알려졌다.\n\n이처럼 선사들이 한시라도 빠르게 해협을 빠져나가고자 하는 것과 달리 발이 묶인 25척이 모두 단기간 내에 해협 밖으로 나오기는 쉽지 않을 것이라는 전망도 나온다.\n\n정부는 나무호 피격과 VLCC 통행과의 관련성은 없다고 했으나, 해협을 빠져나온 VLCC가 나무호와 같은 HMM 운용 선박이기에 이후 통행하는 선박도 HMM 선박을 비롯한 일부에 그칠 수 있다는 것이다.\n\n지난 3월 일본은 상선미쓰이 소속 화물선이 피격당한 데 따라 이란과 협의해 같은 회사 소속 선박 3척을 호르무즈 해협 밖으로 움직였다.\n\n이후 지난 14일 일본 정유사 에네오스(ENEOS) 선박 1척이 추가로 해협을 빠져나왔다. 호르무즈 해협 내에 아직 발이 묶인 일본 선박은 현재 39척이다.\n\n일본과 같은 날 자국 관련 선박이 피격된 태국도 이란과 협의해 3척을 해협 밖으로 움직였다. 두 국가 모두 피격 이후 한정된 규모의 선박에 대한 통행만 보장받은 셈이다.\n\n한 해운 업계 관계자는 “해협 봉쇄 기간이 길어지면서 선사들의 부담이 계속 커지는 상황”이라며 “정부의 통행 협상이 선사들에게 실시간으로 공유되는 것은 아닌 만큼 관문 앞에서 상황을 지켜보기 위해 움직인 것”이라고 했다.", + "htmlBody": "

나무호 피격 이후 관문 떠났던 선박들 복귀 해협 내 발 묶인 선박 25척 가운데 약 90% 모여 업계 “발 묶인 선박 단기간에 모두 나가긴 어려울 듯” 앞서 선박 피격 당한 일본·태국도 일부 선박만 빠져나가

이란과 미국의 봉쇄가 이어지고 있는 호르무즈 해협 내 발이 묶인 국적 선사의 선박들이 두바이를 비롯한 해협의 관문 지역으로 이동한 것으로 21일 나타났다.

HMM 화물선인 나무호 피격으로 관문 지역을 떠나 있었으나, HMM의 초대형 원유 운반선(VLCC) 유니버셜 위너호가 해협을 빠져나가면서 운항 재개에 대한 기대감이 커졌기 때문인 것으로 보인다.

이날 해운업계와 선박 위치 추적 정보사이트 마린트래픽등에 따르면 호르무즈 해협 내측 한국 국적 선박 25척 가운데 22척이 해협 관문 지역에 정박한 것으로 나타났다.

해협 내 국적 선사 선박들은 지난 4일 나무호가 피격당한 직후에는 절반 이상이 두바이 등 관문 인근 지역을 떠나 카타르나 페르시아만 중앙 해역에 머물렀다. 그러다 전날을 기해 다시 관문 지역으로 돌아온 것이다.

해운업계에서는 해협 봉쇄 기간이 3개월 차로 접어드는 상황에서 전날 HMM의 VLCC가 통행료를 내지 않고도 해협을 빠져나가는 데 성공하며 통행 기대감이 커졌기 때문으로 보고 있다.

정부도 전날 VLCC 통과 이후 나머지 25척도 해협에서 나올 수 있도록 이란 측과 협의를 이어가고 있다고 밝혔고, 협의 대상 선박을 선정하고 있는 것으로 알려지며 통행 기대감이 어느 때보다 큰 상황이다.

이 때문에 1척당 하루 약 21억원의 손실을 보고 있는 선사들로서는 한시라도 빨리 해협을 빠져나오기 위해 묘박지를 관문 지역으로 옮긴다는 것이다.

호르무즈 해협 내 발이 묶인 선박들은 선박 문제와 이에 따른 예인 비용, 선원들의 건강 이상 등에 따른 비용은 보험으로 처리할 수 있으나 휴업 손실·유류비·인건비 등은 해결할 방법이 없는 것으로 알려졌다.

이처럼 선사들이 한시라도 빠르게 해협을 빠져나가고자 하는 것과 달리 발이 묶인 25척이 모두 단기간 내에 해협 밖으로 나오기는 쉽지 않을 것이라는 전망도 나온다.

정부는 나무호 피격과 VLCC 통행과의 관련성은 없다고 했으나, 해협을 빠져나온 VLCC가 나무호와 같은 HMM 운용 선박이기에 이후 통행하는 선박도 HMM 선박을 비롯한 일부에 그칠 수 있다는 것이다.

지난 3월 일본은 상선미쓰이 소속 화물선이 피격당한 데 따라 이란과 협의해 같은 회사 소속 선박 3척을 호르무즈 해협 밖으로 움직였다.

이후 지난 14일 일본 정유사 에네오스(ENEOS) 선박 1척이 추가로 해협을 빠져나왔다. 호르무즈 해협 내에 아직 발이 묶인 일본 선박은 현재 39척이다.

일본과 같은 날 자국 관련 선박이 피격된 태국도 이란과 협의해 3척을 해협 밖으로 움직였다. 두 국가 모두 피격 이후 한정된 규모의 선박에 대한 통행만 보장받은 셈이다.

한 해운 업계 관계자는 “해협 봉쇄 기간이 길어지면서 선사들의 부담이 계속 커지는 상황”이라며 “정부의 통행 협상이 선사들에게 실시간으로 공유되는 것은 아닌 만큼 관문 앞에서 상황을 지켜보기 위해 움직인 것”이라고 했다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:41:06.000Z" + }, + "createdAt": "2026-05-26T10:46:15.657Z", + "updatedAt": "2026-05-26T10:46:15.657Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-99bd1ff20ed6b8a5", + "title": "ICJ \"파업권, ILO협약이 보호\"…양대노총 \"하청노조도 보장해야\"", + "sourceLabel": "연합뉴스", + "url": "https://n.news.naver.com/mnews/article/001/0016094246", + "lead": "'ILO 87호 협약보호' 권고적의견…\"긴급조정 예외적 허용 등 무겁게 받아들여야\"", + "body": "'ILO 87호 협약보호' 권고적의견…\"긴급조정 예외적 허용 등 무겁게 받아들여야\"\n\n국제기구 로고 국제사법재판소 ICJ [연합뉴스 자료사진]\n\n(서울=연합뉴스) 옥성구 기자 = 국제사법재판소(ICJ)가 파업권을 국제노동기구(ILO) 제87호 협약이 보호한다는 권고적 의견을 내자, 양대노총은 22일 \"파업권이 양질의 노동 확보를 위한 근본적 수단임을 재확인했다\"며 환영 입장을 밝혔다.\n\n22일 노동계에 따르면 ICJ는 전날 '파업권은 ILO 제87호 협약(결사의 자유와 단결권) 하에 근로자와 그 조직의 파업권이 보호된다'는 권고적 의견을 냈다.\n\nICJ는 제87호 협약에 파업권이라는 명시적 언급은 없지만, 결사의 자유 보호 범위에 파업권 보호가 포함돼 있다고 판단했다.\n\n이 사안은 노동자 단체의 파업권이 1948년 채택된 ILO 국제협약 제87호 '결사의 자유 및 단결권 보호 협약'에 따라 보호되는 권리인지를 둘러싼 논쟁이다.\n\nILO 내 사용자 그룹을 중심으로 단결권이 있으면 파업권도 당연하다는 주장에 반대 목소리가 계속되자, ILO는 법률적 판단을 요청했다.\n\nICJ는 전날 재판관 10대 4 의견으로 파업권이 포함된다고 최종 판단했다. ILO는 이번 ICJ의 판단으로 파업권 해석에 법적 확실성이 확보됐다고 평가다.\n\n국제사법재판소(ICJ) 권고적 의견 원문 [국제사법재판소 홈페이지 캡처. 재판매 및 DB 금지]\n\n전국민주노동조합총연맹(민주노총)은 이날 성명을 통해 \"ICJ의 권고적 의견은 지난 70년간 ILO의 감독기구가 일관되게 견지해 온 확고한 원칙을 국제법 이름으로 다시 확인한 것\"이라며 환영의 입장을 밝혔다.\n\n그러면서 \"이번 권고적 의견으로 그동안 ILO '결사의 자유위원회'와 협약·권고 적용 전문가위원회'가 한국 정부에 제시해온 파업권 관련 권고의 권위는 한층 더 강화됐다\"며 \"사용자 그룹의 근거 없는 주장은 이제 완전히 설 자리를 잃었다\"고 목소리를 높였다.\n\n민주노총은 \"ILO 결사의 자유위원회는 1996년부터 파업권 보장에 관한 권고를 거듭해왔다\"면서 \"쟁의행위를 강제 종결시키는 '긴급조정'은 엄격한 의미의 필수서비스나 급박한 국가위기 상황 등에서만 예외적으로 허용돼야 한다는 게 그 사례\"라고 했다.\n\n민주노총은 또 \"정부와 국회, 법원은 국제법적 권위가 강화된 파업권에 관한 ILO 감독기구의 권고를 더욱 무겁게 받아들여야 한다\"며 \"이제 바뀌어야 할 것은 노동자들의 권리가 아니라 그 권리를 억압해 온 한국 사회과 제도\"라고 강조했다.\n\n한국노동조합총연맹(한국노총)도 \"ICJ의 권고적 의견을 환영한다\"며 \"특히 노란봉투법이 현장에 온전히 안착해 하청·간접고용 노동자들의 파업권이 실질적으로 보장되고, 노조에 대한 손해배상 청구가 중단되는 계기가 되어야 할 것\"이라고 환영 입장을 냈다.", + "htmlBody": "

'ILO 87호 협약보호' 권고적의견…"긴급조정 예외적 허용 등 무겁게 받아들여야"

\"국제기구

국제기구 로고 국제사법재판소 ICJ [연합뉴스 자료사진]

(서울=연합뉴스) 옥성구 기자 = 국제사법재판소(ICJ)가 파업권을 국제노동기구(ILO) 제87호 협약이 보호한다는 권고적 의견을 내자, 양대노총은 22일 "파업권이 양질의 노동 확보를 위한 근본적 수단임을 재확인했다"며 환영 입장을 밝혔다.

22일 노동계에 따르면 ICJ는 전날 '파업권은 ILO 제87호 협약(결사의 자유와 단결권) 하에 근로자와 그 조직의 파업권이 보호된다'는 권고적 의견을 냈다.

ICJ는 제87호 협약에 파업권이라는 명시적 언급은 없지만, 결사의 자유 보호 범위에 파업권 보호가 포함돼 있다고 판단했다.

이 사안은 노동자 단체의 파업권이 1948년 채택된 ILO 국제협약 제87호 '결사의 자유 및 단결권 보호 협약'에 따라 보호되는 권리인지를 둘러싼 논쟁이다.

ILO 내 사용자 그룹을 중심으로 단결권이 있으면 파업권도 당연하다는 주장에 반대 목소리가 계속되자, ILO는 법률적 판단을 요청했다.

ICJ는 전날 재판관 10대 4 의견으로 파업권이 포함된다고 최종 판단했다. ILO는 이번 ICJ의 판단으로 파업권 해석에 법적 확실성이 확보됐다고 평가다.

\"국제사법재판소(ICJ)

국제사법재판소(ICJ) 권고적 의견 원문 [국제사법재판소 홈페이지 캡처. 재판매 및 DB 금지]

전국민주노동조합총연맹(민주노총)은 이날 성명을 통해 "ICJ의 권고적 의견은 지난 70년간 ILO의 감독기구가 일관되게 견지해 온 확고한 원칙을 국제법 이름으로 다시 확인한 것"이라며 환영의 입장을 밝혔다.

그러면서 "이번 권고적 의견으로 그동안 ILO '결사의 자유위원회'와 협약·권고 적용 전문가위원회'가 한국 정부에 제시해온 파업권 관련 권고의 권위는 한층 더 강화됐다"며 "사용자 그룹의 근거 없는 주장은 이제 완전히 설 자리를 잃었다"고 목소리를 높였다.

민주노총은 "ILO 결사의 자유위원회는 1996년부터 파업권 보장에 관한 권고를 거듭해왔다"면서 "쟁의행위를 강제 종결시키는 '긴급조정'은 엄격한 의미의 필수서비스나 급박한 국가위기 상황 등에서만 예외적으로 허용돼야 한다는 게 그 사례"라고 했다.

민주노총은 또 "정부와 국회, 법원은 국제법적 권위가 강화된 파업권에 관한 ILO 감독기구의 권고를 더욱 무겁게 받아들여야 한다"며 "이제 바뀌어야 할 것은 노동자들의 권리가 아니라 그 권리를 억압해 온 한국 사회과 제도"라고 강조했다.

한국노동조합총연맹(한국노총)도 "ICJ의 권고적 의견을 환영한다"며 "특히 노란봉투법이 현장에 온전히 안착해 하청·간접고용 노동자들의 파업권이 실질적으로 보장되고, 노조에 대한 손해배상 청구가 중단되는 계기가 되어야 할 것"이라고 환영 입장을 냈다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "listedDate": "2026-05-22", + "publishedAt": "2026-05-22T05:52:58.000Z" + }, + "createdAt": "2026-05-26T10:46:15.082Z", + "updatedAt": "2026-05-26T10:46:15.082Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-a3cad9b5a512eeb5", + "title": "삼성전자 사측 “잠정합의 감사... 건설적 노사관계 구축”", + "sourceLabel": "조선비즈", + "url": "https://n.news.naver.com/mnews/article/366/0001165966", + "lead": "삼성전자 사측은 20일 입장문에서 “삼성전자 노사가 ‘임금 및 단체협약’에 잠정 합의했다”며 “뒤늦게나마 합의에 이른 것은 국민과 주주, 고객 여러분 성원, 정부의 헌신적인 조정, 그리고 묵묵히 자리를 지켜주신 임직원들이 있었기 때문”이라고 밝혔다.", + "body": "삼성전자 사측은 20일 입장문에서 “삼성전자 노사가 ‘임금 및 단체협약’에 잠정 합의했다”며 “뒤늦게나마 합의에 이른 것은 국민과 주주, 고객 여러분 성원, 정부의 헌신적인 조정, 그리고 묵묵히 자리를 지켜주신 임직원들이 있었기 때문”이라고 밝혔다.\n\n20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 김영환 고용노동부 장관(왼쪽)이 발언하고 있다./연합뉴스 삼성전자는 “진심으로 감사하다는 말씀과 함께, 그동안 심려를 끼쳐드린 점 깊이 사죄드린다”며 “다시는 이런 일이 없도록 겸허한 자세로 보다 성숙하고 건설적인 노사관계를 구축하겠다”고 말했다.\n\n이어 “아울러 기업 본연 역할과 책임을 다함으로써 국가 경제에 더욱 기여하도록 최선을 다하겠다”고 덧붙였다.\n\n삼성전자 노사는 20일 오전 중앙노동위원회가 주재했던 2차 사후조정 회의가 결렬됐지만, 김영훈 고용노동부 장관이 오후 4시부터 주재한 최종 교섭에서 잠정합의안을 도출했다.\n\n삼성전자 노동조합은 21일 예고했던 총파업을 유보하고, 22일 오후 2시부터 27일 오전 10시까지 2026년 임금협약 잠정합의안에 대해 조합원 찬반투표를 실시한다.", + "htmlBody": "

삼성전자 사측은 20일 입장문에서 “삼성전자 노사가 ‘임금 및 단체협약’에 잠정 합의했다”며 “뒤늦게나마 합의에 이른 것은 국민과 주주, 고객 여러분 성원, 정부의 헌신적인 조정, 그리고 묵묵히 자리를 지켜주신 임직원들이 있었기 때문”이라고 밝혔다.

\"20일

20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 김영환 고용노동부 장관(왼쪽)이 발언하고 있다./연합뉴스 삼성전자는 “진심으로 감사하다는 말씀과 함께, 그동안 심려를 끼쳐드린 점 깊이 사죄드린다”며 “다시는 이런 일이 없도록 겸허한 자세로 보다 성숙하고 건설적인 노사관계를 구축하겠다”고 말했다.

이어 “아울러 기업 본연 역할과 책임을 다함으로써 국가 경제에 더욱 기여하도록 최선을 다하겠다”고 덧붙였다.

삼성전자 노사는 20일 오전 중앙노동위원회가 주재했던 2차 사후조정 회의가 결렬됐지만, 김영훈 고용노동부 장관이 오후 4시부터 주재한 최종 교섭에서 잠정합의안을 도출했다.

삼성전자 노동조합은 21일 예고했던 총파업을 유보하고, 22일 오후 2시부터 27일 오전 10시까지 2026년 임금협약 잠정합의안에 대해 조합원 찬반투표를 실시한다.

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:26:14.000Z" + }, + "createdAt": "2026-05-26T10:06:25.997Z", + "updatedAt": "2026-05-26T10:06:25.997Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-ca798dabb4223d4a", + "title": "정광열 국민의힘 춘천시장 후보, 도의원·시의원 후보와 ‘원팀 공약’ 발표", + "sourceLabel": "강원일보", + "url": "https://n.news.naver.com/mnews/article/087/0001194765", + "lead": "【춘천】정광열 국민의힘 춘천시장 후보가 도의원·시의원 후보들과 ‘춘천지역 원팀 공동 공약’을 발표했다.", + "body": "【춘천】정광열 국민의힘 춘천시장 후보가 도의원·시의원 후보들과 ‘춘천지역 원팀 공동 공약’을 발표했다.\n\n정광열 후보와 강대규 국민의힘 춘천갑 조직위원장, 도의원·시의원 후보들은 21일 시청에서 동네 현안과 주민 삶의 질 향상을 위한 지역 맞춤형 공약을 선보였다.\n\n이날 한중일 도의원 춘천 1선거구 후보는 강원도향토공예관의 어린이 영어 도서관 전환, 춘천시 청소년도서관 연중무휴 운영을 약속했다. 같은 권역의 시의원 가선거구 김보건 후보는 성수고 이전 추진, 하수처리시설 상부 주민친화형 공간 조성을 공약했고 가선거구 정경옥 후보는 온의·삼천초교 설립 속도, 남산면 파크골프장 조성을 제시했다.\n\n이무철 도의원 춘천 4선거구 후보는 석사사거리 일대 거리형 평행주차장 설치, 석사사거리~효자사거리 전선 지중화를 공약했다. 같은 권역의 시의원 라선거구 남숙희 후보는 공공키즈카페 신설, 생활권 공영주차장 확충을 내세웠고 라선거구 김운기 후보는 석사천 거리 환경 개선과 구도심 정비 사업을 약속했다.\n\n박관희 도의원 춘천 8선거구 후보는 변함 없는 도청사 이전 추진과 학곡지구·거두지구 교통망 개선을 제시했다. 같은 권역의 아선거구 권준혁 후보는 청년 정주·취창업 여건 개선, 농가 직거래 및 판로 확대 지원을 공약했다. 아선거구 지승민 후보는 동내·동산면 파크골프장 유치를 약속했다.\n\n이날 정광열 후보는 “이 자리에서 발표된 공약은 정광열의 공약이자 김진태 도지사 후보의 공약”이라며 “춘천의 구체적인 실천 사항이 되도록 전폭적으로 지원하고 실행하겠다”고 했다. 정 후보 캠프는 나머지 선거구 공약을 해당 지역 후보들과 순차적으로 발표할 예정이다.", + "htmlBody": "
\"기사

【춘천】정광열 국민의힘 춘천시장 후보가 도의원·시의원 후보들과 ‘춘천지역 원팀 공동 공약’을 발표했다.

정광열 후보와 강대규 국민의힘 춘천갑 조직위원장, 도의원·시의원 후보들은 21일 시청에서 동네 현안과 주민 삶의 질 향상을 위한 지역 맞춤형 공약을 선보였다.

이날 한중일 도의원 춘천 1선거구 후보는 강원도향토공예관의 어린이 영어 도서관 전환, 춘천시 청소년도서관 연중무휴 운영을 약속했다. 같은 권역의 시의원 가선거구 김보건 후보는 성수고 이전 추진, 하수처리시설 상부 주민친화형 공간 조성을 공약했고 가선거구 정경옥 후보는 온의·삼천초교 설립 속도, 남산면 파크골프장 조성을 제시했다.

이무철 도의원 춘천 4선거구 후보는 석사사거리 일대 거리형 평행주차장 설치, 석사사거리~효자사거리 전선 지중화를 공약했다. 같은 권역의 시의원 라선거구 남숙희 후보는 공공키즈카페 신설, 생활권 공영주차장 확충을 내세웠고 라선거구 김운기 후보는 석사천 거리 환경 개선과 구도심 정비 사업을 약속했다.

박관희 도의원 춘천 8선거구 후보는 변함 없는 도청사 이전 추진과 학곡지구·거두지구 교통망 개선을 제시했다. 같은 권역의 아선거구 권준혁 후보는 청년 정주·취창업 여건 개선, 농가 직거래 및 판로 확대 지원을 공약했다. 아선거구 지승민 후보는 동내·동산면 파크골프장 유치를 약속했다.

이날 정광열 후보는 “이 자리에서 발표된 공약은 정광열의 공약이자 김진태 도지사 후보의 공약”이라며 “춘천의 구체적인 실천 사항이 되도록 전폭적으로 지원하고 실행하겠다”고 했다. 정 후보 캠프는 나머지 선거구 공약을 해당 지역 후보들과 순차적으로 발표할 예정이다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:41:08.000Z" + }, + "createdAt": "2026-05-26T10:06:25.638Z", + "updatedAt": "2026-05-26T10:06:25.638Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-4cb6984fa3176d44", + "title": "“빨간 옷 보자마자…” 국힘 대구 구의원 선거운동원 폭행한 60대 검거", + "sourceLabel": "서울신문", + "url": "https://n.news.naver.com/mnews/article/081/0003646176", + "lead": "지난 21일 오후 대구 수성구 범물동에서 선거운동을 하던 중 신원을 알 수 없는 남성에게 폭행을 당한 박새롬 국민의힘 수성구의원 후보의 선거운동원 A씨가 입술이 찢어지고 턱이 부어오르는 부상을 입었다. 박새롬 수성구의원 후보 제공", + "body": "지난 21일 오후 대구 수성구 범물동에서 선거운동을 하던 중 신원을 알 수 없는 남성에게 폭행을 당한 박새롬 국민의힘 수성구의원 후보의 선거운동원 A씨가 입술이 찢어지고 턱이 부어오르는 부상을 입었다. 박새롬 수성구의원 후보 제공\n\n대구 지역 국민의힘 소속 기초의원 후보의 선거운동원을 폭행한 60대가 경찰에 붙잡혔다.\n\n대구 수성경찰서는 공직선거법 위반 혐의로 A(60대)씨를 붙잡아 조사 중이라고 22일 밝혔다.\n\n경찰에 따르면 A씨는 전날 오후 5시 50분쯤 대구 수성구 범물동에서 길거리 유세 중이던 박새롬 국민의힘 수성구의원 후보의 선거운동원을 머리로 들이받는 등 폭행한 혐의를 받고 있다. A씨에게 맞은 선거운동원은 입술이 찢어지고 턱 부위가 부어오르는 부상을 입은 것으로 알려졌다.\n\n박 후보 측은 “A씨가 빨간 옷을 보자마자 욕설을 퍼부으며 다가오더니 선거운동원을 폭행했다”고 전했다.\n\n경찰은 신고를 받고 사건 현장 주변 폐쇄회로(CC)TV 분석을 통해 동선을 추적한 끝에 거주지에 있던 A씨를 검거했다.\n\n경찰 관계자는 “구체적인 범행 동기 등을 조사한 뒤 검찰에 송치할 예정”이라고 말했다.", + "htmlBody": "
\"지난

지난 21일 오후 대구 수성구 범물동에서 선거운동을 하던 중 신원을 알 수 없는 남성에게 폭행을 당한 박새롬 국민의힘 수성구의원 후보의 선거운동원 A씨가 입술이 찢어지고 턱이 부어오르는 부상을 입었다. 박새롬 수성구의원 후보 제공

대구 지역 국민의힘 소속 기초의원 후보의 선거운동원을 폭행한 60대가 경찰에 붙잡혔다.

대구 수성경찰서는 공직선거법 위반 혐의로 A(60대)씨를 붙잡아 조사 중이라고 22일 밝혔다.

경찰에 따르면 A씨는 전날 오후 5시 50분쯤 대구 수성구 범물동에서 길거리 유세 중이던 박새롬 국민의힘 수성구의원 후보의 선거운동원을 머리로 들이받는 등 폭행한 혐의를 받고 있다. A씨에게 맞은 선거운동원은 입술이 찢어지고 턱 부위가 부어오르는 부상을 입은 것으로 알려졌다.

박 후보 측은 “A씨가 빨간 옷을 보자마자 욕설을 퍼부으며 다가오더니 선거운동원을 폭행했다”고 전했다.

경찰은 신고를 받고 사건 현장 주변 폐쇄회로(CC)TV 분석을 통해 동선을 추적한 끝에 거주지에 있던 A씨를 검거했다.

경찰 관계자는 “구체적인 범행 동기 등을 조사한 뒤 검찰에 송치할 예정”이라고 말했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "listedDate": "2026-05-22", + "publishedAt": "2026-05-22T05:53:10.000Z" + }, + "createdAt": "2026-05-26T10:06:25.083Z", + "updatedAt": "2026-05-26T10:06:25.083Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-92a9c3361ad8843a", + "title": "희생된 소녀들과 전후 굶주린 삶… 흐린 날, 더 선명해진 ‘섬의 상처’[박경일기자의 여행]", + "sourceLabel": "문화일보", + "url": "https://n.news.naver.com/mnews/article/021/0002792657", + "lead": "박경일기자의 여행 - 2차대전의 비극 품은 오키나와 (下)", + "body": "박경일기자의 여행 - 2차대전의 비극 품은 오키나와 (下)\n\n여학생 등 240명 전쟁에 징집 동굴 생활하다 절반 이상 숨져 참상 기리는 ‘히메유리 기념관’\n\n전쟁 이후 심각했던 물자부족 엔진오일로 튀겼던 ‘모빌튀김’ 지금은 전통간식 ‘사타안다기’\n\n밀가루로 만든 ‘오키나와 소바’ 세대 이어 추억이 된 지역음식 엄격한 인증으로 정체성 지켜\n\n450년간 독립 왕국이었던 섬 은행·온천… 곳곳 ‘류큐’ 흔적 불타버린 슈리성 가을에 완공\n\n복원 작업 중인 슈리(首里)성의 세이덴(正殿·정전)을 비롯한 주요 건축물을 한눈에 다 내려다볼 수 있는 동쪽 전망대. 이 자리에서 나하 시내의 전경도 시원하게 펼쳐진다.\n\n일본 오키나와의 에메랄드빛 바다 뒤편에는 2차 대전 최대 지상전의 상흔이 남아 있다. 지상낙원의 풍경과 최악의 비극의 기억이 시차를 두고 같은 곳에 있는 셈이다. 맑은 날 오키나와 바다의 투명한 아름다움과 테마파크의 즐거움을 지난주 (上) 편에 썼으니, 이제는 비 오는 오키나와서 마주했던 과거의 기억 얘기다. 여행지에서 비극의 기억 얘기를 꺼내는 게 맞을까. 남국의 휴양지에 바다를 즐기러 온 여행자들에게 굳이 전쟁 얘기까지 말해야 하는 걸까. 흥분과 기대, 즐거움과 만족감을 나누기도 모자란 시간에 따분한 역사나 마음 불편해지는 전쟁의 참상을 보여주는 게 적절한 걸까. 비 오는 날의 차분한 여정 중에 정리된 생각은 이랬다. 여행이 오로지 흥분과 기대로만 완성되는 건 아니듯이, 불편한 마음이 꼭 여행의 걸림돌이 되는 건 아니라는 것.많은 걸 알면 불편할 수 있지만, 때로는 불편해서 ‘더 좋은 여행’이 될 수도 있다는 얘기다.\n\n오키나와를 여행하면 어디에서든 합병으로 인한 차별, 뒤이어 이어졌던 참혹한 전쟁, 전후의 극심했던 고통 등에 대한 이야기와 맞닥뜨리게 된다. 휴양 여행을 왔다 해도 오키나와에서는 절대로 그걸 비켜 갈 수 없다. 오키나와 사람 네 명 중의 한 명이 총알받이가 돼서 영문도 모르고, 명분도 없이 죽어 나갔던, 오키나와 전투의 상처가 워낙 깊어서 그렇다.\n\n여행지에서의 ‘불편한 마음’은 드문 게 아니다. 초호화 특급리조트 문만 나서면 빈민촌과 구걸하는 아이들이 있는 저개발국가 휴양지에서 자주 맞닥뜨리곤 하는 ‘죄책감을 수반한 불편함’이 대표적이다. 오키나와에서 경험하는 건 이런 것과는 결이 다르다. 오키나와에서의 불편함의 근원은 ‘지금’이 아니라 ‘과거’의 일. 두어 세대 전의 일이라 내가 뭘 어째 볼 수 있는 게 아니다. ‘도덕적 책무’가 없다는 의미다.\n\n비극의 공간에서 여행자들이 남길 수 있는 건 오로지 애도와 공감, 그리고 평화에 대한 지지다. 이걸 가장 잘할 수 있는 게 한국인 여행자다. 한국에서 온 여행자에게 오키나와가 겪은 차별과 전쟁의 참상은 ‘남 얘기’가 아니다. 비슷한 역사적 경험을 했던 한국에서 온 여행자는 전쟁과 죽음의 자취 앞에서 그만, 가슴이 먹먹해진다. 한국인 여행자라면 누구보다 더 그들의 아픔에 진심으로 공감할 수밖에 없다.\n\n오키나와에는 평화기념공원이 있다. 태평양 전쟁의 가장 치열했던 격전지에다 조성한 대표적인 추모 공원이다. 전쟁의 참혹함을 돌아보고 전쟁 희생자를 기리는 공간이다.\n\n1945년 3월부터 6월까지 벌어진 오키나와 전투에서 일본인 18만8136명이 죽었다. ‘일본인’이라 뭉뚱그려 말하지만, 사망자 중 오키나와 현 출신이 12만2228명이나 된다. 그 시절 오키나와 전체 인구가 57만4368명이었으니, 4명 중 1명이 오키나와 전투로 사망한 셈이었다. 전쟁에서 총알받이로 내몰렸다가 죽은 오키나와 사람들은 과연, 스스로를 일본인이라 생각했을까. 전쟁의 당위에 공감한 이가 있었을까, 아니 전쟁의 이유라도 알고 있었을까.\n\n평화기념공원이 규모도 크고 자료관도 방대하지만, 그곳보다 몇 배나 더 인상적이었던 곳은 ‘히메유리 평화기념자료관’이었다. 히메유리 기념관은 전쟁 당시 부상병 간호에 동원된 어린 여학생들의 희생을 통해 전쟁의 참혹함을 일깨우고 생명의 소중함을 알리고자 세운 장소다.\n\n‘히메유리’란 일본 육군이 미군의 오키나와 상륙 직전이던 1945년 3월, 두 학교 여학생과 교사 240명을 징집해 편성한 부대 ‘여학생 학도대’의 명칭이다. 부대 이름은 오키나와 현립제일고등학교의 교지(校誌) ‘오토히메(乙女·용궁선녀)’와 오키나와사범학교 여자부 교지 ‘시라유리(白百合)’를 조합해 만들었다. 오키나와 전투로 사라진 두 학교에서 전쟁 직전에 13세부터 19세까지 소녀 1150명이 재학하고 있었다.\n\n위 사진은 오키나와 전투가 벌어지기 1년쯤 전인 1944년 3월 오키나와 사범학교 여자부 학생들 수료식 날 촬영한 기념사진. 사진 속 50명 중 교장 선생님을 포함한 21명이 전투 중 사망했다. 아래는 히메유리 학도대의 희생을 위로하는 위령비. 위령비 앞의 동굴은 학도대원들이 숨어 부상병을 치료하던 방공호다.\n\n일본군은 “일주일만 근무하면 전쟁이 승리로 끝날 것”이라며 호언장담하며 여학생을 징집해 히메유리 학도대를 꾸렸다. 여학생들은 부상병 간호 등의 임무를 맡아 포격을 피해 방공호 동굴 속에서 비참하게 생활했다. 교육도 제대로 받지 않은 채 캄캄한 동굴 속에서 부상병을 옮기고 상처를 치료해야 했다. 결국 학도대의 절반 이상이 사망했다. 전투 중 사망한 경우도 있었고, 강요된 집단 자결로 세상을 뜬 경우도 있었으며, 무책임한 해산 명령으로 포격에 희생되기도 했다. 그저 전쟁의 도구였을 뿐, 아무도 어린 소녀들을 돌봐주지 않았다.\n\n히메유리 평화기념관은 오키나와 전투 이듬해인 1946년 유족들이 세운 ‘히메유리의 탑’ 뒤쪽에 40여 년의 시차를 두고 건립됐다.\n\n오키나와 전투에서 희생된 동급생과 교사에 대한 통한과 자신만 살아남았다는 자책에 시달리던 생존자들은 전후 오랫동안 당시의 비극에 대한 언급을 피했다. 그들이 전쟁의 실상을 이야기하기 시작했던 건 1980년대 들어서다. 이후 동창회가 주축이 돼서 7년여에 걸쳐 증언채록, 전시자료 수집 등을 거쳐 1989년 평화기념관을 완공했다.\n\n히메유리 기념관이 각별하게 느껴지는 건 13세 남짓 꽃다운 소녀의 사진과 소지품, 편지 등을 통해 죽은 이들의 비극을 실재적으로 얘기하기 때문이다. 평화기념공원의 전쟁으로 죽어간 이들의 이름을 새긴 비석이 전쟁이 가져온 ‘비극의 규모’를 말해준다면, 히메유리 기념관의 전시물은 희생자들이 한 명 한 명 실재했던 소중한 이들이었음을 보여준다. 기념관 입구에 걸어놓은 전쟁 직전에 찍은 단체 사진 속에서 교복을 입은 채 환하게 웃고 있는 여학생들의 단체 사진이 마음 아프다.\n\n전쟁이 끝난 뒤에도 비극은 한동안 계속됐다. 목숨을 건진 이들도 전후의 물자부족으로 인한 기아로 고통받아야 했다. 그 시절의 믿기 어려운 비극적 사건이 끝도 없다. 전쟁이 낳은 비극적인 식재료와 관련한 오키나와 이야기 중 대표적인 게 ‘모빌(Mobil) 튀김’이다.\n\n오키나와에는 전통 간식 ‘사타안다기’가 있다. 밀가루에다 흑당으로 단맛을 낸 동그란 도넛이다. 지금도 대중적인 간식으로 오키나와의 식당 등에서 맛볼 수 있다. 사타안다기는 미국에서 온 음식이다. 일본의 설탕 가격 폭락으로 오키나와 경제가 파탄에 이르자 오키나와 사람들이 해외로 이주했고, 그중 많은 수가 정착한 하와이에서 도넛이 들어오면서 변형을 거쳐 오키나와의 특산 음식이 됐다.\n\n전후 물자부족에 시달리던 오키나와 주민들에게 사타안다기는 언감생심이었다. 어찌어찌 구호용 밀가루나 수확한 사탕수수를 구할 수 있었지만 튀김용 기름을 구할 수 없었다. 그래서 나온 게 ‘모빌 튀김’이다. 모빌 튀김이란 사타안다기를 차량용 엔진오일에다 튀겨서 만든 음식을 말한다. 엔진오일을 식용 기름으로 쓰다니…. 좀처럼 믿기지 않는 얘기. 이걸 먹고 무사할 리 없었다. 복통이나 설사는 물론이고 심하면 사망에까지 이르는 사례가 속출했다.\n\n이 얘기에는 두 가지 설이 있다. 하나는 엔진오일인 줄 모르고 불법유통되는 기름을 튀김용으로 쓰다가 난 오용사고라는 것. 그게 괴담 형식으로 퍼져 일상 음식처럼 포장됐다는 이야기다. 다른 하나는 독성을 알면서도 당장의 굶주림을 면하기 위해 정제한 엔진오일을 튀김용 기름으로 상시로 썼다는 설이다. 둘 중 어떤 경우든 모빌 튀김은 전후 오키나와 주민들의 고통과 피폐한 경제 상황을 단적으로 보여 주는 사건이다.\n\n동중국해의 거친 파도가 몰아치는 오키나와 요미탄촌의 잔파곶(殘波岬) 단애 절벽과 등대.\n\n음식 얘기가 나온 김에 하나 더. 오키나와에는 ‘오키나와 소바’가 있다. 소바는 메밀로 만드는 국수인데, 오키나와 소바는 밀가루로 만든다.\n\n메밀 농사는 물론이고 밀 농사도 잘 안되는 오키나와에서 면 요리는 호사스러운 고급요리였다. 그러다 미국 원조물자로 밀가루가 들어오면서 오키나와에서는 밀가루로 소바를 만들어 먹었다.\n\n메밀을 구하기 어려워 냉면 대신 밀가루로 밀면을 만들어 먹었던 우리의 부산밀면의 사례와 거의 유사하다. 부산은 냉면 대신 ‘밀면’이란 이름을 썼지만, 오키나와는 ‘소바’란 이름을 고집했다.\n\n태평양 전쟁 이후 미군정기를 거쳐 오키나와가 일본에 반환된 이후 일본 공정거래위원회가 문제를 제기했다. ‘메밀로 만들지 않으면, 소바란 이름을 쓸 수 없다’는 유권해석이었다.\n\n오키나와 주민들은 반발했다. 2년여에 걸친 오키나와 민관의 노력에 힘입어 1978년 공정거래위원회는 소바 관련 규약을 개정했다. 밀가루로 만들었어도 ‘오키나와 소바’란 이름을 사용할 수 있도록 한 것이다. 오키나와에서는 규약이 개정된 10월 17일을 ‘오키나와 소바의 날’로 지정하고 해마다 축제를 열고 기념하고 있다.\n\n오키나와 주민들이 소바란 이름에 연연했던 건 오키나와 소바가 일상의 위로이자 세대를 잇는 추억이며 집단적 정체성을 확인하는 음식이어서다. 지역의 일상 음식이 법적 정의를 획득하자, 오키나와는 한발 더 나가 엄격하고 까다로운 인증체계까지 만들었다. 오키나와 소바라고 부르려면 제조장소가 반드시 오키나와 현 안에 있어야 하고 손반죽 공정을 거쳐야 하며, 단백질과 회분 수치가 규정된 밀가루를 쓰고 숙성시간은 짧아야 하며 삶기 전에 손으로 비벼 면 결을 살리는 공정을 거쳐야 한다. 심지어 면 삶는 물의 수소이온 농도와 삶는 시간까지 정해져 있다.\n\n오키나와 소바에는 보통 삶은 돼지고기를 고명으로 얹는데, 돼지고기에도 사연이 있다. 돼지고기는 류큐(琉球) 왕국 시기까지 전통이 이어지는 이른바 ‘소울푸드’ 같은 것이다. 명나라에서 돼지 사육기술을 받은 류큐 왕국은 일본이 불교 영향으로 육식을 금기시할 때, 귀한 손님을 대접하는 궁중요리로 돼지고기 요리를 발전시켜 왔다.\n\n마을행사나 명절이면 돼지를 잡아 온 마을이 고기를 나눠 먹기도 했다. 우리 돼지갈비 조림과 비슷한 ‘라프테’나 돼지 귀를 잘라 무친 ‘미미가’ 등은 어느 것 하나 버리지 않고 잡은 돼지를 귀하게 여겨 정성껏 조리했던 식문화를 반영한다.\n\n태평양 전쟁 직후, 오키나와 전역이 초토화되면서 주민들이 기아로 고통받을 때 일찌감치 하와이로 이주해 정착한 오키나와 교민들이 5만 달러를 모금했다. 사탕수수농장에서 몸이 부서져라 일해 번 돈이었다.\n\n그 돈으로 돼지 550마리를 구입해 배에 실어 고국으로 보낸 게 1948년의 일이었다. 그 550마리의 돼지가 불과 10여 년 만에 수만 마리로 불어나 고국 주민의 식탁에 올랐다. 낯선 타국 땅에서 고된 노동으로 번 피땀 어린 돈이 고향의 밥상을 다시 차리게 한 셈이었다.\n\n오키나와 소바는 맛의 편차가 큰 편이다. 국물은 돼지 뼈에다 가쓰오부시 향을 섞는데, 기름지지 않고 담박해서 우리 입맛에 잘 맞는다. 특히 살짝 덜 익은 듯한 밀가루 면의 질감이 매력적이다.\n\n오키나와에서 맛본 소바 중에서는 비를 피하려다가 들어갔던 식당 ‘슈리사보(首里茶房)’의 것이 압권이었다. 류큐 왕국 시대의 관료 집을 개조해 만든 마당이 있는 전통가옥의 분위기도 훌륭했다. 그날 먹은 소바에 고명으로 얹어 나온 돼지고기를 보며 전후의 고통받던 시절 서로를 보살폈던 동포애와 강인한 생존의식을 생각했다.\n\n류큐 왕국의 석조 건축문화를 보여주는 니키진 성터의 성벽. 간결하면서도 치밀하게 쌓은 성벽이 인상적이다.\n\n오키나와의 대표 명소는 슈리(首里)성이다. 오키나와는 1879년 일본에 강제 편입됐지만, 그 이전 450년 동안은 독립된 류큐 왕국이었다. 언어도, 음식도, 문화도 일본 본토와는 달랐다.\n\n슈리성은 류큐 왕국의 왕이 살던 성이었다. 그 얘길 하려면 류큐 왕국에서 오키나와로 넘어가는 과정의 역사 얘기를 하지 않을 수 없다.\n\n오키나와에서 문자로 기록된 역사는 12세기부터다. 그 이전에도 오키나와에 사람이 살고 있었고 문명도 있었겠지만, 글로 쓰인 역사는 그때부터다. 12세기 무렵 3개의 세력이 힘을 겨루던 오키나와는 1429년에 비로소 통일왕국이 됐다. ‘류큐 왕국’의 시작이었다.\n\n류큐의 이름은 지금도 오키나와 곳곳에 있다. 류큐 은행, 류큐 온천, 류큐 호텔, 류큐 신보(신문)…. 류큐란 이름을 앞세운 식당은 이루 다 세지 못할 정도다. 오키나와 사람들이 류큐 왕조를 잊지 않았고, 적어도 음식에서만큼은 왕국의 전통을 잇고 있다는 증거다.\n\n류큐 왕국은, 동아시아와 동남아시아를 잇는 지정학적 지위를 이용해 교역으로 번성한 나라였다. 류큐 왕국은 조선과 명나라는 물론이고, 안남(베트남), 시암(태국), 자바(인도네시아), 루손(필리핀), 믈라카(말레이시아)와도 활발한 중개 무역을 펼쳤다.\n\n류큐 왕국은 1709년 일본 규슈의 사쓰마번(지금의 가고시마현)의 침략으로 독립왕국의 지위를 잃고 일본의 간섭을 받아야만 했다. 일본이 제국주의로 나아가면서 1879년에는 나라 이름을 빼앗겼고 주권마저 잃었다. 류큐 왕국 정부를 해산하고 오키나와란 이름으로 일본의 변방으로 편입시키는 이른바 ‘류큐처분’ 결과였다.\n\n태평양 전쟁에서 일본의 궤멸적 패배로 오키나와는 미국의 지배를 받게 된다. 미국의 지배라고 뭐 그리 좋을 게 있었을까. 그러다가 1972년 오키나와는 일본으로 반환됐다. 여기까지가 일본이면서 어쩌면 일본이 아닌, 오키나와의 대략의 역사다.\n\n왼쪽 사진은 1945년 오키나와 전투 당시 돌사자(시사) 상. 오른쪽은 전쟁 때 모습에서 전혀 달라지지 않은 도모리(富盛) 돌사자 상.\n\n오키나와를 돌려받은 일본은 1986년 전쟁으로 폐허가 된 슈리성 정비사업을 시작했다. 설마하니 류큐 문화를 부활하려는 건 아니었을 테고, 미국에 돌려받은 자국의 ‘변방문화’를 재건해 오키나와 반환의 정당성을 입증하고 싶었던 것이었으리라.\n\n복원은 결정했으나 자료 부족으로 어려움을 겪다가 천신만고 끝에 발굴한 자료를 토대로 1700년대 슈리성을 모델로 슈리성을 지었다. 1988년 본격적인 복원작업을 시작해 2019년 1월 슈리성은 완전 복원이 됐다.\n\n그러나 1년이 채 못 된 2019년 10월 화재로 슈리성의 주요 전각들이 모두 다 불타버렸다. 다시 지난한 복원작업이 시작됐다. 슈리성 복원 작업은 약 7년만인 오는 가을쯤 마무리된다. 지금은 정전의 일부분만 가림막으로 가린 채 막바지 복원작업이 한창이다.\n\n새로 지은 슈리성은 짐짓 오래됐음을 가장하지 않는다. 세월을 인위적으로 묻히려는 시도도 없다. 문화재적 가치를 ‘원형’으로 잰다면 새로 짓다시피 한 슈리성은 새로 지어진 건물이라고 할 수 있다. 자료와 고증을 토대로 지었다지만, 성벽의 돌을 빼면 거의 모든 부재가 새것이나 다름없어서다.\n\n관광객의 눈으로 보면 시시해 보일 수도 있겠다. 하지만, 이런 왕국의 성은, 오래된 시간으로 가치가 평가되는 골동품이나 도자기와는 다르다.\n\n슈리성은 부자재가 얼마나 오래됐느냐로 가치를 평가해야 하는 게 아니라, 왕국의 존재를 상징한다는 점에서 의미를 찾아야 한다. 류큐 왕국을 기억하고, 왕국의 정체성을 잊지 않는 사람들에게 슈리성은 ‘기억’이자 ‘형태’다. 새로 지은 것이라 할지라도 의미는 변함이 없다. 봐야 하는 건, 보이는 것 그 너머다.\n\n오키나와에는 에메랄드 빛깔의 낭만적인 바다만 있는 건 아니다. 오키나와 중부의 명소로 꼽히는 ‘잔파곶(殘波岬)’은 거칠고 박력 넘치는 바다 풍경을 만날 수 있는 곳이다. 수직의 해안 직벽이 길게 이어진 경관도, 해안을 때리는 거센 파도도 비장미가 넘친다. 잔파곶의 등대에 오르면 짙푸른 바다와 해안절벽을 한눈에 내려다볼 수 있다. 잔파곶 산책로에는 류큐 왕국 시절 외교관이자 상인이었던 다이키(泰期) 동상이 있다. 동상은 거센 파도를 뚫고 먼 바다로 나간 그의 용기와 개척정신을 기린다.", + "htmlBody": "

박경일기자의 여행 - 2차대전의 비극 품은 오키나와 (下)

여학생 등 240명 전쟁에 징집 동굴 생활하다 절반 이상 숨져 참상 기리는 ‘히메유리 기념관’

전쟁 이후 심각했던 물자부족 엔진오일로 튀겼던 ‘모빌튀김’ 지금은 전통간식 ‘사타안다기’

밀가루로 만든 ‘오키나와 소바’ 세대 이어 추억이 된 지역음식 엄격한 인증으로 정체성 지켜

450년간 독립 왕국이었던 섬 은행·온천… 곳곳 ‘류큐’ 흔적 불타버린 슈리성 가을에 완공

\"복원
복원 작업 중인 슈리(首里)성의 세이덴(正殿·정전)을 비롯한 주요 건축물을 한눈에 다 내려다볼 수 있는 동쪽 전망대. 이 자리에서 나하 시내의 전경도 시원하게 펼쳐진다.

오키나와 = 글·사진 박경일 전임기자

일본 오키나와의 에메랄드빛 바다 뒤편에는 2차 대전 최대 지상전의 상흔이 남아 있다. 지상낙원의 풍경과 최악의 비극의 기억이 시차를 두고 같은 곳에 있는 셈이다. 맑은 날 오키나와 바다의 투명한 아름다움과 테마파크의 즐거움을 지난주 (上) 편에 썼으니, 이제는 비 오는 오키나와서 마주했던 과거의 기억 얘기다. 여행지에서 비극의 기억 얘기를 꺼내는 게 맞을까. 남국의 휴양지에 바다를 즐기러 온 여행자들에게 굳이 전쟁 얘기까지 말해야 하는 걸까. 흥분과 기대, 즐거움과 만족감을 나누기도 모자란 시간에 따분한 역사나 마음 불편해지는 전쟁의 참상을 보여주는 게 적절한 걸까. 비 오는 날의 차분한 여정 중에 정리된 생각은 이랬다. 여행이 오로지 흥분과 기대로만 완성되는 건 아니듯이, 불편한 마음이 꼭 여행의 걸림돌이 되는 건 아니라는 것.많은 걸 알면 불편할 수 있지만, 때로는 불편해서 ‘더 좋은 여행’이 될 수도 있다는 얘기다.

오키나와를 여행하면 어디에서든 합병으로 인한 차별, 뒤이어 이어졌던 참혹한 전쟁, 전후의 극심했던 고통 등에 대한 이야기와 맞닥뜨리게 된다. 휴양 여행을 왔다 해도 오키나와에서는 절대로 그걸 비켜 갈 수 없다. 오키나와 사람 네 명 중의 한 명이 총알받이가 돼서 영문도 모르고, 명분도 없이 죽어 나갔던, 오키나와 전투의 상처가 워낙 깊어서 그렇다.

여행지에서의 ‘불편한 마음’은 드문 게 아니다. 초호화 특급리조트 문만 나서면 빈민촌과 구걸하는 아이들이 있는 저개발국가 휴양지에서 자주 맞닥뜨리곤 하는 ‘죄책감을 수반한 불편함’이 대표적이다. 오키나와에서 경험하는 건 이런 것과는 결이 다르다. 오키나와에서의 불편함의 근원은 ‘지금’이 아니라 ‘과거’의 일. 두어 세대 전의 일이라 내가 뭘 어째 볼 수 있는 게 아니다. ‘도덕적 책무’가 없다는 의미다.

비극의 공간에서 여행자들이 남길 수 있는 건 오로지 애도와 공감, 그리고 평화에 대한 지지다. 이걸 가장 잘할 수 있는 게 한국인 여행자다. 한국에서 온 여행자에게 오키나와가 겪은 차별과 전쟁의 참상은 ‘남 얘기’가 아니다. 비슷한 역사적 경험을 했던 한국에서 온 여행자는 전쟁과 죽음의 자취 앞에서 그만, 가슴이 먹먹해진다. 한국인 여행자라면 누구보다 더 그들의 아픔에 진심으로 공감할 수밖에 없다.

오키나와에는 평화기념공원이 있다. 태평양 전쟁의 가장 치열했던 격전지에다 조성한 대표적인 추모 공원이다. 전쟁의 참혹함을 돌아보고 전쟁 희생자를 기리는 공간이다.

1945년 3월부터 6월까지 벌어진 오키나와 전투에서 일본인 18만8136명이 죽었다. ‘일본인’이라 뭉뚱그려 말하지만, 사망자 중 오키나와 현 출신이 12만2228명이나 된다. 그 시절 오키나와 전체 인구가 57만4368명이었으니, 4명 중 1명이 오키나와 전투로 사망한 셈이었다. 전쟁에서 총알받이로 내몰렸다가 죽은 오키나와 사람들은 과연, 스스로를 일본인이라 생각했을까. 전쟁의 당위에 공감한 이가 있었을까, 아니 전쟁의 이유라도 알고 있었을까.

평화기념공원이 규모도 크고 자료관도 방대하지만, 그곳보다 몇 배나 더 인상적이었던 곳은 ‘히메유리 평화기념자료관’이었다. 히메유리 기념관은 전쟁 당시 부상병 간호에 동원된 어린 여학생들의 희생을 통해 전쟁의 참혹함을 일깨우고 생명의 소중함을 알리고자 세운 장소다.

‘히메유리’란 일본 육군이 미군의 오키나와 상륙 직전이던 1945년 3월, 두 학교 여학생과 교사 240명을 징집해 편성한 부대 ‘여학생 학도대’의 명칭이다. 부대 이름은 오키나와 현립제일고등학교의 교지(校誌) ‘오토히메(乙女·용궁선녀)’와 오키나와사범학교 여자부 교지 ‘시라유리(白百合)’를 조합해 만들었다. 오키나와 전투로 사라진 두 학교에서 전쟁 직전에 13세부터 19세까지 소녀 1150명이 재학하고 있었다.

\"위
위 사진은 오키나와 전투가 벌어지기 1년쯤 전인 1944년 3월 오키나와 사범학교 여자부 학생들 수료식 날 촬영한 기념사진. 사진 속 50명 중 교장 선생님을 포함한 21명이 전투 중 사망했다. 아래는 히메유리 학도대의 희생을 위로하는 위령비. 위령비 앞의 동굴은 학도대원들이 숨어 부상병을 치료하던 방공호다.

# 얼굴과 이름이 있는 비극의 슬픔

일본군은 “일주일만 근무하면 전쟁이 승리로 끝날 것”이라며 호언장담하며 여학생을 징집해 히메유리 학도대를 꾸렸다. 여학생들은 부상병 간호 등의 임무를 맡아 포격을 피해 방공호 동굴 속에서 비참하게 생활했다. 교육도 제대로 받지 않은 채 캄캄한 동굴 속에서 부상병을 옮기고 상처를 치료해야 했다. 결국 학도대의 절반 이상이 사망했다. 전투 중 사망한 경우도 있었고, 강요된 집단 자결로 세상을 뜬 경우도 있었으며, 무책임한 해산 명령으로 포격에 희생되기도 했다. 그저 전쟁의 도구였을 뿐, 아무도 어린 소녀들을 돌봐주지 않았다.

히메유리 평화기념관은 오키나와 전투 이듬해인 1946년 유족들이 세운 ‘히메유리의 탑’ 뒤쪽에 40여 년의 시차를 두고 건립됐다.

오키나와 전투에서 희생된 동급생과 교사에 대한 통한과 자신만 살아남았다는 자책에 시달리던 생존자들은 전후 오랫동안 당시의 비극에 대한 언급을 피했다. 그들이 전쟁의 실상을 이야기하기 시작했던 건 1980년대 들어서다. 이후 동창회가 주축이 돼서 7년여에 걸쳐 증언채록, 전시자료 수집 등을 거쳐 1989년 평화기념관을 완공했다.

히메유리 기념관이 각별하게 느껴지는 건 13세 남짓 꽃다운 소녀의 사진과 소지품, 편지 등을 통해 죽은 이들의 비극을 실재적으로 얘기하기 때문이다. 평화기념공원의 전쟁으로 죽어간 이들의 이름을 새긴 비석이 전쟁이 가져온 ‘비극의 규모’를 말해준다면, 히메유리 기념관의 전시물은 희생자들이 한 명 한 명 실재했던 소중한 이들이었음을 보여준다. 기념관 입구에 걸어놓은 전쟁 직전에 찍은 단체 사진 속에서 교복을 입은 채 환하게 웃고 있는 여학생들의 단체 사진이 마음 아프다.

# 엔진오일로 튀김을 해먹었다고?

전쟁이 끝난 뒤에도 비극은 한동안 계속됐다. 목숨을 건진 이들도 전후의 물자부족으로 인한 기아로 고통받아야 했다. 그 시절의 믿기 어려운 비극적 사건이 끝도 없다. 전쟁이 낳은 비극적인 식재료와 관련한 오키나와 이야기 중 대표적인 게 ‘모빌(Mobil) 튀김’이다.

오키나와에는 전통 간식 ‘사타안다기’가 있다. 밀가루에다 흑당으로 단맛을 낸 동그란 도넛이다. 지금도 대중적인 간식으로 오키나와의 식당 등에서 맛볼 수 있다. 사타안다기는 미국에서 온 음식이다. 일본의 설탕 가격 폭락으로 오키나와 경제가 파탄에 이르자 오키나와 사람들이 해외로 이주했고, 그중 많은 수가 정착한 하와이에서 도넛이 들어오면서 변형을 거쳐 오키나와의 특산 음식이 됐다.

전후 물자부족에 시달리던 오키나와 주민들에게 사타안다기는 언감생심이었다. 어찌어찌 구호용 밀가루나 수확한 사탕수수를 구할 수 있었지만 튀김용 기름을 구할 수 없었다. 그래서 나온 게 ‘모빌 튀김’이다. 모빌 튀김이란 사타안다기를 차량용 엔진오일에다 튀겨서 만든 음식을 말한다. 엔진오일을 식용 기름으로 쓰다니…. 좀처럼 믿기지 않는 얘기. 이걸 먹고 무사할 리 없었다. 복통이나 설사는 물론이고 심하면 사망에까지 이르는 사례가 속출했다.

이 얘기에는 두 가지 설이 있다. 하나는 엔진오일인 줄 모르고 불법유통되는 기름을 튀김용으로 쓰다가 난 오용사고라는 것. 그게 괴담 형식으로 퍼져 일상 음식처럼 포장됐다는 이야기다. 다른 하나는 독성을 알면서도 당장의 굶주림을 면하기 위해 정제한 엔진오일을 튀김용 기름으로 상시로 썼다는 설이다. 둘 중 어떤 경우든 모빌 튀김은 전후 오키나와 주민들의 고통과 피폐한 경제 상황을 단적으로 보여 주는 사건이다.

\"동중국해의
동중국해의 거친 파도가 몰아치는 오키나와 요미탄촌의 잔파곶(殘波岬) 단애 절벽과 등대.

음식 얘기가 나온 김에 하나 더. 오키나와에는 ‘오키나와 소바’가 있다. 소바는 메밀로 만드는 국수인데, 오키나와 소바는 밀가루로 만든다.

메밀 농사는 물론이고 밀 농사도 잘 안되는 오키나와에서 면 요리는 호사스러운 고급요리였다. 그러다 미국 원조물자로 밀가루가 들어오면서 오키나와에서는 밀가루로 소바를 만들어 먹었다.

메밀을 구하기 어려워 냉면 대신 밀가루로 밀면을 만들어 먹었던 우리의 부산밀면의 사례와 거의 유사하다. 부산은 냉면 대신 ‘밀면’이란 이름을 썼지만, 오키나와는 ‘소바’란 이름을 고집했다.

태평양 전쟁 이후 미군정기를 거쳐 오키나와가 일본에 반환된 이후 일본 공정거래위원회가 문제를 제기했다. ‘메밀로 만들지 않으면, 소바란 이름을 쓸 수 없다’는 유권해석이었다.

오키나와 주민들은 반발했다. 2년여에 걸친 오키나와 민관의 노력에 힘입어 1978년 공정거래위원회는 소바 관련 규약을 개정했다. 밀가루로 만들었어도 ‘오키나와 소바’란 이름을 사용할 수 있도록 한 것이다. 오키나와에서는 규약이 개정된 10월 17일을 ‘오키나와 소바의 날’로 지정하고 해마다 축제를 열고 기념하고 있다.

오키나와 주민들이 소바란 이름에 연연했던 건 오키나와 소바가 일상의 위로이자 세대를 잇는 추억이며 집단적 정체성을 확인하는 음식이어서다. 지역의 일상 음식이 법적 정의를 획득하자, 오키나와는 한발 더 나가 엄격하고 까다로운 인증체계까지 만들었다. 오키나와 소바라고 부르려면 제조장소가 반드시 오키나와 현 안에 있어야 하고 손반죽 공정을 거쳐야 하며, 단백질과 회분 수치가 규정된 밀가루를 쓰고 숙성시간은 짧아야 하며 삶기 전에 손으로 비벼 면 결을 살리는 공정을 거쳐야 한다. 심지어 면 삶는 물의 수소이온 농도와 삶는 시간까지 정해져 있다.

# 고국으로 보낸 550마리 돼지

오키나와 소바에는 보통 삶은 돼지고기를 고명으로 얹는데, 돼지고기에도 사연이 있다. 돼지고기는 류큐(琉球) 왕국 시기까지 전통이 이어지는 이른바 ‘소울푸드’ 같은 것이다. 명나라에서 돼지 사육기술을 받은 류큐 왕국은 일본이 불교 영향으로 육식을 금기시할 때, 귀한 손님을 대접하는 궁중요리로 돼지고기 요리를 발전시켜 왔다.

마을행사나 명절이면 돼지를 잡아 온 마을이 고기를 나눠 먹기도 했다. 우리 돼지갈비 조림과 비슷한 ‘라프테’나 돼지 귀를 잘라 무친 ‘미미가’ 등은 어느 것 하나 버리지 않고 잡은 돼지를 귀하게 여겨 정성껏 조리했던 식문화를 반영한다.

태평양 전쟁 직후, 오키나와 전역이 초토화되면서 주민들이 기아로 고통받을 때 일찌감치 하와이로 이주해 정착한 오키나와 교민들이 5만 달러를 모금했다. 사탕수수농장에서 몸이 부서져라 일해 번 돈이었다.

그 돈으로 돼지 550마리를 구입해 배에 실어 고국으로 보낸 게 1948년의 일이었다. 그 550마리의 돼지가 불과 10여 년 만에 수만 마리로 불어나 고국 주민의 식탁에 올랐다. 낯선 타국 땅에서 고된 노동으로 번 피땀 어린 돈이 고향의 밥상을 다시 차리게 한 셈이었다.

오키나와 소바는 맛의 편차가 큰 편이다. 국물은 돼지 뼈에다 가쓰오부시 향을 섞는데, 기름지지 않고 담박해서 우리 입맛에 잘 맞는다. 특히 살짝 덜 익은 듯한 밀가루 면의 질감이 매력적이다.

오키나와에서 맛본 소바 중에서는 비를 피하려다가 들어갔던 식당 ‘슈리사보(首里茶房)’의 것이 압권이었다. 류큐 왕국 시대의 관료 집을 개조해 만든 마당이 있는 전통가옥의 분위기도 훌륭했다. 그날 먹은 소바에 고명으로 얹어 나온 돼지고기를 보며 전후의 고통받던 시절 서로를 보살폈던 동포애와 강인한 생존의식을 생각했다.

\"류큐
류큐 왕국의 석조 건축문화를 보여주는 니키진 성터의 성벽. 간결하면서도 치밀하게 쌓은 성벽이 인상적이다.

오키나와의 대표 명소는 슈리(首里)성이다. 오키나와는 1879년 일본에 강제 편입됐지만, 그 이전 450년 동안은 독립된 류큐 왕국이었다. 언어도, 음식도, 문화도 일본 본토와는 달랐다.

슈리성은 류큐 왕국의 왕이 살던 성이었다. 그 얘길 하려면 류큐 왕국에서 오키나와로 넘어가는 과정의 역사 얘기를 하지 않을 수 없다.

오키나와에서 문자로 기록된 역사는 12세기부터다. 그 이전에도 오키나와에 사람이 살고 있었고 문명도 있었겠지만, 글로 쓰인 역사는 그때부터다. 12세기 무렵 3개의 세력이 힘을 겨루던 오키나와는 1429년에 비로소 통일왕국이 됐다. ‘류큐 왕국’의 시작이었다.

류큐의 이름은 지금도 오키나와 곳곳에 있다. 류큐 은행, 류큐 온천, 류큐 호텔, 류큐 신보(신문)…. 류큐란 이름을 앞세운 식당은 이루 다 세지 못할 정도다. 오키나와 사람들이 류큐 왕조를 잊지 않았고, 적어도 음식에서만큼은 왕국의 전통을 잇고 있다는 증거다.

류큐 왕국은, 동아시아와 동남아시아를 잇는 지정학적 지위를 이용해 교역으로 번성한 나라였다. 류큐 왕국은 조선과 명나라는 물론이고, 안남(베트남), 시암(태국), 자바(인도네시아), 루손(필리핀), 믈라카(말레이시아)와도 활발한 중개 무역을 펼쳤다.

류큐 왕국은 1709년 일본 규슈의 사쓰마번(지금의 가고시마현)의 침략으로 독립왕국의 지위를 잃고 일본의 간섭을 받아야만 했다. 일본이 제국주의로 나아가면서 1879년에는 나라 이름을 빼앗겼고 주권마저 잃었다. 류큐 왕국 정부를 해산하고 오키나와란 이름으로 일본의 변방으로 편입시키는 이른바 ‘류큐처분’ 결과였다.

태평양 전쟁에서 일본의 궤멸적 패배로 오키나와는 미국의 지배를 받게 된다. 미국의 지배라고 뭐 그리 좋을 게 있었을까. 그러다가 1972년 오키나와는 일본으로 반환됐다. 여기까지가 일본이면서 어쩌면 일본이 아닌, 오키나와의 대략의 역사다.

\"왼쪽
왼쪽 사진은 1945년 오키나와 전투 당시 돌사자(시사) 상. 오른쪽은 전쟁 때 모습에서 전혀 달라지지 않은 도모리(富盛) 돌사자 상.

오키나와를 돌려받은 일본은 1986년 전쟁으로 폐허가 된 슈리성 정비사업을 시작했다. 설마하니 류큐 문화를 부활하려는 건 아니었을 테고, 미국에 돌려받은 자국의 ‘변방문화’를 재건해 오키나와 반환의 정당성을 입증하고 싶었던 것이었으리라.

복원은 결정했으나 자료 부족으로 어려움을 겪다가 천신만고 끝에 발굴한 자료를 토대로 1700년대 슈리성을 모델로 슈리성을 지었다. 1988년 본격적인 복원작업을 시작해 2019년 1월 슈리성은 완전 복원이 됐다.

그러나 1년이 채 못 된 2019년 10월 화재로 슈리성의 주요 전각들이 모두 다 불타버렸다. 다시 지난한 복원작업이 시작됐다. 슈리성 복원 작업은 약 7년만인 오는 가을쯤 마무리된다. 지금은 정전의 일부분만 가림막으로 가린 채 막바지 복원작업이 한창이다.

새로 지은 슈리성은 짐짓 오래됐음을 가장하지 않는다. 세월을 인위적으로 묻히려는 시도도 없다. 문화재적 가치를 ‘원형’으로 잰다면 새로 짓다시피 한 슈리성은 새로 지어진 건물이라고 할 수 있다. 자료와 고증을 토대로 지었다지만, 성벽의 돌을 빼면 거의 모든 부재가 새것이나 다름없어서다.

관광객의 눈으로 보면 시시해 보일 수도 있겠다. 하지만, 이런 왕국의 성은, 오래된 시간으로 가치가 평가되는 골동품이나 도자기와는 다르다.

슈리성은 부자재가 얼마나 오래됐느냐로 가치를 평가해야 하는 게 아니라, 왕국의 존재를 상징한다는 점에서 의미를 찾아야 한다. 류큐 왕국을 기억하고, 왕국의 정체성을 잊지 않는 사람들에게 슈리성은 ‘기억’이자 ‘형태’다. 새로 지은 것이라 할지라도 의미는 변함이 없다. 봐야 하는 건, 보이는 것 그 너머다.

\"기사

오키나와에는 에메랄드 빛깔의 낭만적인 바다만 있는 건 아니다. 오키나와 중부의 명소로 꼽히는 ‘잔파곶(殘波岬)’은 거칠고 박력 넘치는 바다 풍경을 만날 수 있는 곳이다. 수직의 해안 직벽이 길게 이어진 경관도, 해안을 때리는 거센 파도도 비장미가 넘친다. 잔파곶의 등대에 오르면 짙푸른 바다와 해안절벽을 한눈에 내려다볼 수 있다. 잔파곶 산책로에는 류큐 왕국 시절 외교관이자 상인이었던 다이키(泰期) 동상이 있다. 동상은 거센 파도를 뚫고 먼 바다로 나간 그의 용기와 개척정신을 기린다.

", + "tags": [ + "네이버뉴스", + "생활/문화" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T00:19:12.000Z" + }, + "createdAt": "2026-05-26T04:25:57.226Z", + "updatedAt": "2026-05-26T04:25:57.226Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-fc613d31a49872f0", + "title": "항공주 일제히 강세…트럼프", + "sourceLabel": "머니투데이", + "url": "https://n.news.naver.com/mnews/article/008/0005360969", + "lead": "국내 항공주가 21일 장 초반 나란히 상승세에 돌입했다. 미국-이란 전쟁 종전협상이 막바지에 접어들었다는 도널드 트럼프 대통령 발언에 국제유가가 하락한 영향으로 풀이된다.", + "body": "국내 항공주가 21일 장 초반 나란히 상승세에 돌입했다. 미국-이란 전쟁 종전협상이 막바지에 접어들었다는 도널드 트럼프 대통령 발언에 국제유가가 하락한 영향으로 풀이된다.\n\n이날 오전 9시11분 한국거래소에서 트리니티항공(옛 티웨이항공)은 전 거래일 대비 65원(7.86%) 오른 892원에 거래됐다.\n\n대한항공은 1600원(6.45%) 오른 2만6400원, 한진칼은 4200원(3.85%) 오른 11만3400원이다.\n\n진에어·제주항공은 3%대, 아시아나항공·에어부산은 2%대 강세에 진입했다. 항공주는 유가 민감성이 높은 종목군으로 분류된다.\n\n도널드 트럼프 미국 대통령은 20일(현지시간) 해안경비대 사관학교 졸업식 연설 전 취재진을 만나 \"이란과 종전협상이 최종단계에 들어섰다\"고 말했다.\n\n이 시각 뉴욕상품거래소에서 WTI(서부텍사스산중질유) 7월 인도분 선물은 배럴당 99달러대에 거래되고 있다.", + "htmlBody": "
\"기사

국내 항공주가 21일 장 초반 나란히 상승세에 돌입했다. 미국-이란 전쟁 종전협상이 막바지에 접어들었다는 도널드 트럼프 대통령 발언에 국제유가가 하락한 영향으로 풀이된다.

이날 오전 9시11분 한국거래소에서 트리니티항공(옛 티웨이항공)은 전 거래일 대비 65원(7.86%) 오른 892원에 거래됐다.

대한항공은 1600원(6.45%) 오른 2만6400원, 한진칼은 4200원(3.85%) 오른 11만3400원이다.

진에어·제주항공은 3%대, 아시아나항공·에어부산은 2%대 강세에 진입했다. 항공주는 유가 민감성이 높은 종목군으로 분류된다.

도널드 트럼프 미국 대통령은 20일(현지시간) 해안경비대 사관학교 졸업식 연설 전 취재진을 만나 "이란과 종전협상이 최종단계에 들어섰다"고 말했다.

이 시각 뉴욕상품거래소에서 WTI(서부텍사스산중질유) 7월 인도분 선물은 배럴당 99달러대에 거래되고 있다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T00:18:50.000Z" + }, + "createdAt": "2026-05-26T04:25:56.633Z", + "updatedAt": "2026-05-26T04:25:56.633Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-9c9b4beb2c20fb96", + "title": "'어색한 만남'", + "sourceLabel": "뉴스1", + "url": "https://n.news.naver.com/mnews/article/421/0008958514", + "lead": "(부산=뉴스1) 윤일지 기자 = 한동훈 무소속 부산 북구갑 국회의원 보궐선거 후보(왼쪽부터) 하정우 더불어민주당 부산 북구갑 국회의원 보궐선거 후보, 박민식 국민의힘 부산 북구갑 국회의원 보궐선거 후보가 21일 부산 북구 남산정종합사회복지관에서 열린 콩국수 나눔 행사에 참석해 콩국수 봉사를 하고 있다. 2026.5.21/뉴스1", + "body": "(부산=뉴스1) 윤일지 기자 = 한동훈 무소속 부산 북구갑 국회의원 보궐선거 후보(왼쪽부터) 하정우 더불어민주당 부산 북구갑 국회의원 보궐선거 후보, 박민식 국민의힘 부산 북구갑 국회의원 보궐선거 후보가 21일 부산 북구 남산정종합사회복지관에서 열린 콩국수 나눔 행사에 참석해 콩국수 봉사를 하고 있다. 2026.5.21/뉴스1", + "htmlBody": "
\"기사

(부산=뉴스1) 윤일지 기자 = 한동훈 무소속 부산 북구갑 국회의원 보궐선거 후보(왼쪽부터) 하정우 더불어민주당 부산 북구갑 국회의원 보궐선거 후보, 박민식 국민의힘 부산 북구갑 국회의원 보궐선거 후보가 21일 부산 북구 남산정종합사회복지관에서 열린 콩국수 나눔 행사에 참석해 콩국수 봉사를 하고 있다. 2026.5.21/뉴스1

", + "tags": [ + "네이버뉴스", + "정치" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T02:47:17.000Z" + }, + "createdAt": "2026-05-26T04:25:55.874Z", + "updatedAt": "2026-05-26T04:25:55.874Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-22293d4e12743f96", + "title": "\"먹으면서 관리한다\" 유통업계, 여름 맞이", + "sourceLabel": "뉴시스", + "url": "https://n.news.naver.com/mnews/article/003/0013959405", + "lead": "체형 관리 시즌 저당·저칼로리 등 상품 매출 증가세 저당 미숫가루·헬씨 소다 등 신상…마케팅도 강화", + "body": "체형 관리 시즌 저당·저칼로리 등 상품 매출 증가세 저당 미숫가루·헬씨 소다 등 신상…마케팅도 강화\n\n[서울=뉴시스] 이영환 기자 = 때이른 무더위가 이어지고 있는 지난 18일 서울 강남구 잠원한강공원 리버시티 수상스키장에서 수상스키어가 물살을 가르고 있다. 2026.05.18. 20hwan@newsis.com [서울=뉴시스]오제일 기자 = 본격적인 무더위를 앞두고 체형 관리 수요가 늘어나면서 유통 업계가 저당·저칼로리 상품 확대에 나서고 있다. 신상품 출시와 기획전을 앞세워 여름철 소비자 공략에 나선 모습이다.\n\n21일 유통 업계에 따르면 최근에는 체중 감량만을 목적으로 하는 다이어트보다 즐겁게 건강을 관리하는 라이프스타일을 말하는 '헬시플레저'가 주요 트렌드로 자리 잡고 있다. 저당·저칼로리·고단백 등을 앞세운 식품들이 트렌드에 올라타 쏟아지고 있다.\n\n수요 증가는 매출로도 확인된다. 롯데마트·슈퍼에서 판매되는 관련 상품군 매출은 최근 4년 연속 20% 이상 증가했다고 한다. 취급 상품 수(SKU)도 매년 전년 대비 40% 이상 늘었다. 올해 4월까지 해당 상품군의 매출은 전년 동기 대비 27%, 취급 상품 수는 41% 각각 증가했다.\n\n에이블리가 지난달 진행한 '식단관리 위크' 기획전에서도 '저당 간식' 거래액이 전년 동기 대비 4배 가까이(293%) 증가했고, '다이어트 간식'은 3.5배 이상(249%) 상승했다고 한다.\n\n'제로'를 전면에 내세운 편의점 '제로스토어'도 가파른 성장세를 보이고 있다. 저당 음료와 저칼로리 간편식 등을 앞세운 무인 편의점으로 지난해 1월 출발 이후 190호점까지 오픈을 완료하는 등 빠르게 점포 수를 늘리고 있다.\n\n[서울=뉴시스] 롯데마트 저당 미숫가루. (사진=롯데마트 제공) *재판매 및 DB 금지\n\n유통업계는 건강 관리 트렌드와 계절적 수요가 맞물리며 관련 상품군 확대에 속도를 내고 있다.\n\n롯데마트·슈퍼는 여름철 대표 전통 간식인 미숫가루에 저당·고단백 트렌드를 접목한 상품 라인업을 확대하고 있다. 지난 4일 출시 2주 만에 1만개가 팔린 '맛있는 아이스 미숫가루 오리지널/블랙(500g/봉)' 인기에 힘입어 최근 신상품 3종을 추가로 출시했다.\n\n세븐일레븐은 헬시플레저족 공략을 위해 최근 건강 간편식 브랜드 '밸런스푼(BalanSpoon)'을 최근 새롭게 론칭했다. '균형 잡힌 식단'을 내세우며 단백질, 저당, 저칼로리 등 건강 콘셉트를 기반으로 상품들이 개발됐다. '밸런스푼 반숙란롤샌드위치' '밸런스푼 닭가슴살롤샌드위치' 등이다. 다이어트족을 위한 맞춤형 샐러드인 '밸런스푼 에그듬뿍샐러드'도 최근 매대에 깔렸다.\n\n편의점 CU는 저당, 제로 칼로리, 프리바이오틱스 등 기능성 요소를 결합한 탄산음료 '헬씨 소다' 영역을 넓혀 나가고 있다. CU가 운영 중인 헬씨 소다의 가짓수는 5년 전 25개에 비해 현재 135개로 5배 늘어났다고 한다. 최근에 출시된 '애사비 콜라 제로'도 이러한 흐름 속에서 탄생한 제품이다.\n\nKT알파 쇼핑은 내달 7일까지 진행되는 '썸머이즈커밍' 프로모션에서 다이어트 상품을 한데 모은 '씬바람난 특집전'을 진행한다. '비에날씬 다이어트 유산균'을 9개월분 구성에 비에날씬 프로틴 14포를 추가 증정한다.\n\n본격적인 무더위를 앞두고 유통 업체들의 관련 마케팅은 이어질 전망이다. 업계 관계자는 \"최근에는 단순히 살을 빼는 다이어트 보다는 건강한 식습관과 자기관리를 함께 추구하는 소비자들이 늘고 있다\"며 \"저칼로리·저당 제품에서 나아가 기능성이 더해진 제품 출시와 함께 마케팅 경쟁이 이어질 것\"이라고 말했다.\n\n[서울=뉴시스] 세븐일레븐, 건강간편식브랜드 밸런스푼 론칭 (사진=세븐일레븐 제공) 2026.05.10. photo@newsis.com *재판매 및 DB 금지", + "htmlBody": "

체형 관리 시즌 저당·저칼로리 등 상품 매출 증가세 저당 미숫가루·헬씨 소다 등 신상…마케팅도 강화

\"[서울=뉴시스]

[서울=뉴시스] 이영환 기자 = 때이른 무더위가 이어지고 있는 지난 18일 서울 강남구 잠원한강공원 리버시티 수상스키장에서 수상스키어가 물살을 가르고 있다. 2026.05.18. 20hwan@newsis.com [서울=뉴시스]오제일 기자 = 본격적인 무더위를 앞두고 체형 관리 수요가 늘어나면서 유통 업계가 저당·저칼로리 상품 확대에 나서고 있다. 신상품 출시와 기획전을 앞세워 여름철 소비자 공략에 나선 모습이다.

21일 유통 업계에 따르면 최근에는 체중 감량만을 목적으로 하는 다이어트보다 즐겁게 건강을 관리하는 라이프스타일을 말하는 '헬시플레저'가 주요 트렌드로 자리 잡고 있다. 저당·저칼로리·고단백 등을 앞세운 식품들이 트렌드에 올라타 쏟아지고 있다.

수요 증가는 매출로도 확인된다. 롯데마트·슈퍼에서 판매되는 관련 상품군 매출은 최근 4년 연속 20% 이상 증가했다고 한다. 취급 상품 수(SKU)도 매년 전년 대비 40% 이상 늘었다. 올해 4월까지 해당 상품군의 매출은 전년 동기 대비 27%, 취급 상품 수는 41% 각각 증가했다.

에이블리가 지난달 진행한 '식단관리 위크' 기획전에서도 '저당 간식' 거래액이 전년 동기 대비 4배 가까이(293%) 증가했고, '다이어트 간식'은 3.5배 이상(249%) 상승했다고 한다.

'제로'를 전면에 내세운 편의점 '제로스토어'도 가파른 성장세를 보이고 있다. 저당 음료와 저칼로리 간편식 등을 앞세운 무인 편의점으로 지난해 1월 출발 이후 190호점까지 오픈을 완료하는 등 빠르게 점포 수를 늘리고 있다.

\"[서울=뉴시스]

[서울=뉴시스] 롯데마트 저당 미숫가루. (사진=롯데마트 제공) *재판매 및 DB 금지

유통업계는 건강 관리 트렌드와 계절적 수요가 맞물리며 관련 상품군 확대에 속도를 내고 있다.

롯데마트·슈퍼는 여름철 대표 전통 간식인 미숫가루에 저당·고단백 트렌드를 접목한 상품 라인업을 확대하고 있다. 지난 4일 출시 2주 만에 1만개가 팔린 '맛있는 아이스 미숫가루 오리지널/블랙(500g/봉)' 인기에 힘입어 최근 신상품 3종을 추가로 출시했다.

세븐일레븐은 헬시플레저족 공략을 위해 최근 건강 간편식 브랜드 '밸런스푼(BalanSpoon)'을 최근 새롭게 론칭했다. '균형 잡힌 식단'을 내세우며 단백질, 저당, 저칼로리 등 건강 콘셉트를 기반으로 상품들이 개발됐다. '밸런스푼 반숙란롤샌드위치' '밸런스푼 닭가슴살롤샌드위치' 등이다. 다이어트족을 위한 맞춤형 샐러드인 '밸런스푼 에그듬뿍샐러드'도 최근 매대에 깔렸다.

편의점 CU는 저당, 제로 칼로리, 프리바이오틱스 등 기능성 요소를 결합한 탄산음료 '헬씨 소다' 영역을 넓혀 나가고 있다. CU가 운영 중인 헬씨 소다의 가짓수는 5년 전 25개에 비해 현재 135개로 5배 늘어났다고 한다. 최근에 출시된 '애사비 콜라 제로'도 이러한 흐름 속에서 탄생한 제품이다.

KT알파 쇼핑은 내달 7일까지 진행되는 '썸머이즈커밍' 프로모션에서 다이어트 상품을 한데 모은 '씬바람난 특집전'을 진행한다. '비에날씬 다이어트 유산균'을 9개월분 구성에 비에날씬 프로틴 14포를 추가 증정한다.

본격적인 무더위를 앞두고 유통 업체들의 관련 마케팅은 이어질 전망이다. 업계 관계자는 "최근에는 단순히 살을 빼는 다이어트 보다는 건강한 식습관과 자기관리를 함께 추구하는 소비자들이 늘고 있다"며 "저칼로리·저당 제품에서 나아가 기능성이 더해진 제품 출시와 함께 마케팅 경쟁이 이어질 것"이라고 말했다.

\"[서울=뉴시스]

[서울=뉴시스] 세븐일레븐, 건강간편식브랜드 밸런스푼 론칭 (사진=세븐일레븐 제공) 2026.05.10. photo@newsis.com *재판매 및 DB 금지

", + "tags": [ + "네이버뉴스", + "생활/문화" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T02:43:50.000Z" + }, + "createdAt": "2026-05-26T04:25:55.382Z", + "updatedAt": "2026-05-26T04:25:55.382Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-1612337e8afb7548", + "title": "[속보] 삼전, 반도체 특별성과급 신설…한도없이 사업성과의 10.5%", + "sourceLabel": "연합뉴스", + "url": "https://n.news.naver.com/mnews/article/001/0016090008", + "lead": "삼성전자 노사 잠정 합의안 서명 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명하고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr", + "body": "삼성전자 노사 잠정 합의안 서명 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명하고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr", + "htmlBody": "
\"삼성전자

삼성전자 노사 잠정 합의안 서명 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명하고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:44:28.000Z" + }, + "createdAt": "2026-05-26T04:25:54.735Z", + "updatedAt": "2026-05-26T04:25:54.735Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-f4eae60e56a2737d", + "title": "김포고촌농협, 국내 최대 농산물 가공식품 박람회 ‘메가쇼’ 참가", + "sourceLabel": "농민신문", + "url": "https://n.news.naver.com/mnews/article/662/0000095729", + "lead": "쌀빵과 즉석 도정 ‘김포금쌀’ 홍보 현장 관심 속 쌀 소비 촉진 ‘앞장’", + "body": "쌀빵과 즉석 도정 ‘김포금쌀’ 홍보 현장 관심 속 쌀 소비 촉진 ‘앞장’\n\n조동환 김포고촌농협 조합장(맨 왼쪽)이 임직원 및 관계자들과 함께 일산 킨텍스(KINTEX) ‘메가쇼’에서 김포금쌀 쌀빵 제품을 선보이고 있다. 경기 김포고촌농협(조합장 조동환)이 14~17일 경기 고양시 일산서구 킨텍스(KINTEX)에서 열린 ‘메가쇼&트래블쇼 2026 시즌1’에 참가해 ‘김포금쌀’ 가공식품 홍보와 판로 확대에 나섰다.\n\n김포고촌농협은 이번 박람회에서 로컬푸드직매장에서 판매 중인 쌀빵과 즉석 도정한 김포금쌀 등을 선보였다.\n\n쌀빵은 조합원들이 생산한 김포금쌀을 활용해 만든 제품으로, 쌀가루 100%를 사용한 것이 특징이다. 즉석 도정 김포금쌀은 직매장 내 즉석도정기를 통해 소비자가 원하는 정도로 바로 도정해 제공한다.\n\n현장을 찾은 관람객들은 김포금쌀을 활용한 가공식품과 즉석 도정 쌀에 관심을 보이며 제품을 살펴봤다.\n\n메가쇼는 관람객 45만여 명, 바이어 9만여 명이 찾는 국내 최대 규모 소비재 박람회다. 이번 행사에는 전국 650개 업체가 참여해 1000개 부스를 운영했다.\n\n조동환 조합장은 “김포금쌀이 밥상 위의 쌀에 머물지 않고 다양한 가공식품으로 소비자와 만나는 것이 쌀 소비 촉진과 농가 소득 증대를 동시에 이루는 길”이라며 “앞으로도 소비자 선호와 건강 트렌드에 맞는 제품 개발과 판로 확대에 힘쓰겠다”고 말했다.", + "htmlBody": "

쌀빵과 즉석 도정 ‘김포금쌀’ 홍보 현장 관심 속 쌀 소비 촉진 ‘앞장’

\"조동환

조동환 김포고촌농협 조합장(맨 왼쪽)이 임직원 및 관계자들과 함께 일산 킨텍스(KINTEX) ‘메가쇼’에서 김포금쌀 쌀빵 제품을 선보이고 있다. 경기 김포고촌농협(조합장 조동환)이 14~17일 경기 고양시 일산서구 킨텍스(KINTEX)에서 열린 ‘메가쇼&트래블쇼 2026 시즌1’에 참가해 ‘김포금쌀’ 가공식품 홍보와 판로 확대에 나섰다.

김포고촌농협은 이번 박람회에서 로컬푸드직매장에서 판매 중인 쌀빵과 즉석 도정한 김포금쌀 등을 선보였다.

쌀빵은 조합원들이 생산한 김포금쌀을 활용해 만든 제품으로, 쌀가루 100%를 사용한 것이 특징이다. 즉석 도정 김포금쌀은 직매장 내 즉석도정기를 통해 소비자가 원하는 정도로 바로 도정해 제공한다.

현장을 찾은 관람객들은 김포금쌀을 활용한 가공식품과 즉석 도정 쌀에 관심을 보이며 제품을 살펴봤다.

메가쇼는 관람객 45만여 명, 바이어 9만여 명이 찾는 국내 최대 규모 소비재 박람회다. 이번 행사에는 전국 650개 업체가 참여해 1000개 부스를 운영했다.

조동환 조합장은 “김포금쌀이 밥상 위의 쌀에 머물지 않고 다양한 가공식품으로 소비자와 만나는 것이 쌀 소비 촉진과 농가 소득 증대를 동시에 이루는 길”이라며 “앞으로도 소비자 선호와 건강 트렌드에 맞는 제품 개발과 판로 확대에 힘쓰겠다”고 말했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T06:22:17.000Z" + }, + "createdAt": "2026-05-26T04:25:54.250Z", + "updatedAt": "2026-05-26T04:25:54.250Z" + }, { "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", "article": { diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts index d17fc2e..e142913 100644 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -22,6 +22,8 @@ import { upsertTokenSettingsConfig, type TokenSettingRecord, } from '../services/token-setting-config-service.js'; +import { listTokenSettingActivities } from '../services/token-setting-activity-service.js'; +import { extractRequestAuditContext } from '../utils/request-audit.js'; const CHAT_SHARE_PATH_PREFIX = '/chat/share/'; @@ -388,7 +390,10 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { const nextTokenSettingsInput = parsed.tokenSettings as Partial[]; const savedTokenSettings = accessContext.scope === 'full' - ? await upsertTokenSettingsConfig(nextTokenSettingsInput) + ? await upsertTokenSettingsConfig(nextTokenSettingsInput, { + actorLabel: 'manager', + audit: extractRequestAuditContext(request), + }) : await (async () => { const authorizedSettingId = accessContext.tokenSetting.id; const requestedSetting = nextTokenSettingsInput.find( @@ -403,7 +408,10 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { const nextTokenSettings = currentTokenSettings.map((item) => item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item, ); - return upsertTokenSettingsConfig(nextTokenSettings); + return upsertTokenSettingsConfig(nextTokenSettings, { + actorLabel: 'shared-link-manager', + audit: extractRequestAuditContext(request), + }); })(); return { @@ -417,6 +425,26 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { } }); + app.get('/api/token-settings/:settingId/activities', async (request, reply) => { + const accessContext = await resolveTokenSettingsAccessContext(request); + if (!accessContext) { + sendTokenSettingsAccessDenied(reply); + return; + } + + const settingId = z.string().trim().min(1).parse((request.params as { settingId: string }).settingId); + if (accessContext.scope === 'shared' && accessContext.tokenSetting.id !== settingId) { + return reply.code(403).send({ + message: '현재 공유 링크에서는 연결된 토큰 설정 이력만 볼 수 있습니다.', + }); + } + + return { + ok: true, + activities: await listTokenSettingActivities(settingId), + }; + }); + app.put('/api/app-config', async (request, reply) => { const accessContext = await resolveAppConfigAccessContext(request); if (!accessContext) { diff --git a/etc/servers/work-server/src/routes/baseball-ticket-bay.ts b/etc/servers/work-server/src/routes/baseball-ticket-bay.ts index 2c0d8b2..64c7ec1 100644 --- a/etc/servers/work-server/src/routes/baseball-ticket-bay.ts +++ b/etc/servers/work-server/src/routes/baseball-ticket-bay.ts @@ -1,5 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; +import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; +import { getSharedResourceTokenDetailByShareToken } from '../services/shared-resource-token-service.js'; import { createBaseballTicketBayAlert, createBaseballTicketBayLog, @@ -39,46 +41,115 @@ function readHeader(request: { headers: Record }) { + return hasErrorLogViewAccessToken(request.headers['x-access-token']); +} + +type BaseballTicketBayRouteAccessContext = + | { scope: 'all' } + | { scope: 'client'; clientId: string } + | { scope: 'shared-token'; clientId: string; tokenId: string }; + +function toOwnerScope(accessContext: Exclude | { scope: 'all' }) { + if (accessContext.scope === 'all') { + return { kind: 'all' } as const; + } + + if (accessContext.scope === 'shared-token') { + return { kind: 'owner', ownerType: 'shared-token', ownerId: accessContext.tokenId } as const; + } + + return { kind: 'owner', ownerType: 'client', ownerId: accessContext.clientId } as const; +} + +async function resolveBaseballTicketBayAccessContext( + request: { headers: Record }, +) : Promise { + const clientId = readHeader(request, 'x-client-id'); + + if (hasBaseballTicketBayGlobalAccess(request)) { + return { scope: 'all' }; + } + + const accessToken = readHeader(request, 'x-access-token'); + + if (accessToken) { + const sharedTokenDetail = await getSharedResourceTokenDetailByShareToken(accessToken); + + if ( + sharedTokenDetail + && sharedTokenDetail.token.enabled !== false + && !sharedTokenDetail.token.revokedAt + && sharedTokenDetail.token.allowedAppIds.some((item) => item.trim().toLowerCase() === 'baseball-ticket-bay') + ) { + if (!clientId) { + return null; + } + + return { + scope: 'shared-token', + clientId, + tokenId: sharedTokenDetail.token.id, + }; + } + } + + if (!clientId) { + return null; + } + + return { + scope: 'client', + clientId, + }; +} + export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {})); app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => { - const clientId = readHeader(request, 'x-client-id'); + const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!clientId) { - return reply.code(400).send({ message: '클라이언트 ID가 없어 알림 목록을 불러올 수 없습니다.' }); + if (!accessContext) { + return reply.code(400).send({ message: '접근 식별값이 없어 알림 목록을 불러올 수 없습니다.' }); } return { ok: true, - items: await listBaseballTicketBayAlerts(clientId), + includeAllClients: accessContext.scope === 'all', + accessScope: accessContext.scope, + scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null, + items: await listBaseballTicketBayAlerts(toOwnerScope(accessContext)), }; }); app.get('/api/baseball-ticket-bay/logs', async (request, reply) => { - const clientId = readHeader(request, 'x-client-id'); + const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!clientId) { - return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 불러올 수 없습니다.' }); + if (!accessContext) { + return reply.code(400).send({ message: '접근 식별값이 없어 로그를 불러올 수 없습니다.' }); } const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {}); return { ok: true, - items: await listBaseballTicketBayLogs(clientId, query.alertId), + includeAllClients: accessContext.scope === 'all', + accessScope: accessContext.scope, + scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null, + items: await listBaseballTicketBayLogs(toOwnerScope(accessContext), query.alertId), }; }); app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => { - const clientId = readHeader(request, 'x-client-id'); + const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!clientId) { - return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 삭제할 수 없습니다.' }); + if (!accessContext || accessContext.scope === 'all') { + return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); - const item = await deleteBaseballTicketBayLog(params.id, clientId); + const item = await deleteBaseballTicketBayLog(params.id, toOwnerScope(accessContext)); if (!item) { return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' }); @@ -91,20 +162,24 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { }); app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => { - const clientId = readHeader(request, 'x-client-id'); + const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!clientId) { - return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 저장할 수 없습니다.' }); + if (!accessContext || accessContext.scope === 'all') { + return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 저장할 수 없습니다.' }); } const payload = alertPayloadSchema.parse(request.body ?? {}); const item = await createBaseballTicketBayAlert(payload, { - clientId, + clientId: accessContext.clientId, + ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', + ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, appOrigin: readHeader(request, 'x-app-origin'), appDomain: readHeader(request, 'x-app-domain'), }); await createBaseballTicketBayLog({ - clientId, + clientId: accessContext.clientId, + ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', + ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, alertId: item.id, alertTitle: item.title, action: 'create', @@ -120,21 +195,25 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { }); app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => { - const clientId = readHeader(request, 'x-client-id'); + const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!clientId) { - return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 수정할 수 없습니다.' }); + if (!accessContext || accessContext.scope === 'all') { + return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); const payload = alertPayloadSchema.partial().parse(request.body ?? {}); const item = await updateBaseballTicketBayAlert(params.id, payload, { - clientId, + clientId: accessContext.clientId, + ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', + ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, appOrigin: readHeader(request, 'x-app-origin'), appDomain: readHeader(request, 'x-app-domain'), }); await createBaseballTicketBayLog({ - clientId, + clientId: accessContext.clientId, + ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', + ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, alertId: item.id, alertTitle: item.title, action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run', @@ -155,21 +234,23 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { }); app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => { - const clientId = readHeader(request, 'x-client-id'); + const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!clientId) { - return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 삭제할 수 없습니다.' }); + if (!accessContext || accessContext.scope === 'all') { + return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); - const item = await deleteBaseballTicketBayAlert(params.id, clientId); + const item = await deleteBaseballTicketBayAlert(params.id, toOwnerScope(accessContext)); if (!item) { return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' }); } await createBaseballTicketBayLog({ - clientId, + clientId: accessContext.clientId, + ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client', + ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId, alertId: item.id, alertTitle: item.title, action: 'delete', @@ -185,14 +266,22 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) { }); app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => { - const clientId = readHeader(request, 'x-client-id'); + const accessContext = await resolveBaseballTicketBayAccessContext(request); - if (!clientId) { - return reply.code(400).send({ message: '클라이언트 ID가 없어 즉시 실행할 수 없습니다.' }); + if (!accessContext) { + return reply.code(400).send({ message: '접근 식별값이 없어 즉시 실행할 수 없습니다.' }); } const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {}); - const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true }); + + if (accessContext.scope === 'all') { + return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' }); + } + + const result = await runBaseballTicketBayAlert(params.id, { + ignoreTimeWindow: true, + scope: toOwnerScope(accessContext), + }); return { ok: true, diff --git a/etc/servers/work-server/src/routes/chat.test.ts b/etc/servers/work-server/src/routes/chat.test.ts index e7a7209..95c3c5b 100644 --- a/etc/servers/work-server/src/routes/chat.test.ts +++ b/etc/servers/work-server/src/routes/chat.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { resolveStaticContentType, shouldAutoCompleteShareReplyParentVerification } from './chat.js'; +import { resolvePromptFollowupMode, resolveStaticContentType } from './chat.js'; test('resolveStaticContentType returns html content type for chat resource html files', () => { assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8'); @@ -12,40 +12,9 @@ test('resolveStaticContentType keeps plain text content type for code resources' assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8'); }); -test('shouldAutoCompleteShareReplyParentVerification only completes answered requests that are not already verified', () => { - assert.equal( - shouldAutoCompleteShareReplyParentVerification({ - responseMessageId: 101, - responseText: '', - manualVerificationCompletedAt: null, - }), - true, - ); - - assert.equal( - shouldAutoCompleteShareReplyParentVerification({ - responseMessageId: null, - responseText: '답변 본문', - manualVerificationCompletedAt: null, - }), - true, - ); - - assert.equal( - shouldAutoCompleteShareReplyParentVerification({ - responseMessageId: null, - responseText: '', - manualVerificationCompletedAt: null, - }), - false, - ); - - assert.equal( - shouldAutoCompleteShareReplyParentVerification({ - responseMessageId: 102, - responseText: '답변 본문', - manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z', - }), - false, - ); +test('resolvePromptFollowupMode defaults to queue and preserves direct mode', () => { + assert.equal(resolvePromptFollowupMode(undefined), 'queue'); + assert.equal(resolvePromptFollowupMode(null), 'queue'); + assert.equal(resolvePromptFollowupMode('queue'), 'queue'); + assert.equal(resolvePromptFollowupMode('direct'), 'direct'); }); diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 5a3f950..375a30c 100644 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -18,7 +18,6 @@ import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRuntimeController, - shouldAutoCompleteReplyParentVerification, } from '../services/chat-service.js'; import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js'; import { @@ -43,10 +42,12 @@ import { listChatConversationDetailPage, listChatConversations, markChatConversationRequestManualCompletion, + ChatConversationManualCompletionBlockedError, markChatConversationResponsesRead, persistChatConversationPromptSelection, upsertChatConversationRequest, updateChatConversationContext, + hasPendingAttentionVerificationRequest, } from '../services/chat-room-service.js'; import { chatRuntimeService } from '../services/chat-runtime-service.js'; import { resolveMainProjectRoot } from '../services/main-project-root-service.js'; @@ -123,6 +124,10 @@ async function findExistingActivePromptFollowupRequest( ) ?? null; } +export function resolvePromptFollowupMode(mode?: 'queue' | 'direct' | null) { + return mode === 'direct' ? 'direct' : 'queue'; +} + function encodeBase64Url(value: string) { return Buffer.from(value, 'utf-8').toString('base64url'); } @@ -223,19 +228,6 @@ function resolveChatShareTokenSettingSnapshot(tokenPayload: ChatShareTokenPayloa }; } -export function shouldAutoCompleteShareReplyParentVerification(request: { - responseMessageId?: number | null; - responseText?: string | null; - manualVerificationCompletedAt?: string | null; -} | null | undefined) { - return shouldAutoCompleteReplyParentVerification({ - requestOrigin: 'composer', - responseMessageId: request?.responseMessageId ?? null, - responseText: request?.responseText ?? '', - manualVerificationCompletedAt: request?.manualVerificationCompletedAt ?? null, - }); -} - function createManagedChatShareTokenId() { return `chat_share_${randomUUID().replace(/-/g, '').slice(0, 20)}`; } @@ -802,36 +794,40 @@ function hasUnresolvedPromptPart(message: ListedChatConversationMessage) { function hasPendingPromptRequest( request: ListedChatConversationRequest, relatedMessages: ListedChatConversationMessage[], + promptSubmittedCount = 0, ) { if (request.manualPromptCompletedAt) { return false; } - return relatedMessages.some( - (message) => (message.author === 'codex' || message.author === 'system') && hasUnresolvedPromptPart(message), - ); -} + const unresolvedPromptCount = relatedMessages.reduce((count, message) => { + if (message.author !== 'codex' && message.author !== 'system') { + return count; + } -function hasVerificationTargetMessage(message: ListedChatConversationMessage) { - if (message.author !== 'codex' && message.author !== 'system') { + const promptParts = (message.parts ?? []).filter( + ( + part: NonNullable[number], + ): part is Extract[number], { type: 'prompt' }> => part.type === 'prompt', + ); + + return count + promptParts.filter( + (part: Extract[number], { type: 'prompt' }>) => !isPromptPartResolved(part), + ).length; + }, 0); + + if (unresolvedPromptCount === 0) { return false; } - const text = String(message.text ?? '').trim(); - - if (!text) { - return false; - } - - if (text.length > 720) { - return true; - } - - return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(text); + return unresolvedPromptCount > Math.max(0, promptSubmittedCount); } -function hasVerificationTargetRequest(relatedMessages: ListedChatConversationMessage[]) { - return relatedMessages.some((message) => hasVerificationTargetMessage(message)); +function hasVerificationTargetRequest( + request: ListedChatConversationRequest, + relatedMessages: ListedChatConversationMessage[], +) { + return hasPendingAttentionVerificationRequest(request, relatedMessages); } function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) { @@ -850,6 +846,30 @@ function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) { return nextMap; } +function buildPromptFollowupCountMap(requests: ListedChatConversationRequest[]) { + const nextMap = new Map(); + + requests.forEach((request) => { + if (request.requestOrigin !== 'prompt') { + return; + } + + const parentRequestId = request.parentRequestId?.trim() || ''; + + if (!parentRequestId) { + return; + } + + nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1); + }); + + return nextMap; +} + +function isPromptFollowupRoomRequest(request: ListedChatConversationRequest) { + return request.requestOrigin === 'prompt'; +} + function isRequestInFlight(status: ListedChatConversationRequest['status']) { return status === 'accepted' || status === 'queued' || status === 'started'; } @@ -858,20 +878,29 @@ function isPendingCompletionRoomRequest( request: ListedChatConversationRequest, relatedMessages: ListedChatConversationMessage[], childRequestCountByParentId?: Map, + promptFollowupCountByParentId?: Map, ) { + if (isPromptFollowupRoomRequest(request)) { + return false; + } + if (isRequestInFlight(request.status)) { return true; } - if (hasPendingPromptRequest(request, relatedMessages)) { + if (hasPendingPromptRequest(request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0)) { return true; } + if (request.manualPromptCompletedAt) { + return false; + } + if ((childRequestCountByParentId?.get(request.requestId.trim()) ?? 0) > 0) { return false; } - if (!hasVerificationTargetRequest(relatedMessages)) { + if (!hasVerificationTargetRequest(request, relatedMessages)) { return false; } @@ -884,6 +913,7 @@ function buildRoomRequestCounts( ) { const requestMessagesById = new Map(); const childRequestCountByParentId = buildChildRequestCountMap(requests); + const promptFollowupCountByParentId = buildPromptFollowupCountMap(requests); messages.forEach((message) => { const requestId = message.clientRequestId?.trim() || ''; @@ -897,12 +927,15 @@ function buildRoomRequestCounts( requestMessagesById.set(requestId, current); }); - const processingCount = requests.filter((request) => isRequestInFlight(request.status)).length; + const processingCount = requests.filter( + (request) => !isPromptFollowupRoomRequest(request) && isRequestInFlight(request.status), + ).length; const unansweredCount = requests.filter((request) => isPendingCompletionRoomRequest( request, requestMessagesById.get(request.requestId.trim()) ?? [], childRequestCountByParentId, + promptFollowupCountByParentId, ), ).length; @@ -937,8 +970,10 @@ function buildManagedSharePlaceholderRequest(tokenPayload: ChatShareTokenPayload requestOrigin: 'composer', sharedResourceTokenId: tokenPayload.managedResourceTokenId?.trim() || null, parentRequestId: null, + promptContextRef: null, status: 'completed', statusMessage: '공유 채팅방 시작 요청을 복원했습니다.', + retryCount: 0, userMessageId: null, userText: '', responseMessageId: null, @@ -1554,6 +1589,9 @@ export async function registerChatRoutes(app: FastifyInstance) { const payload = z.object({ accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(), accessPinPromptTtlMinutes: z.number().int().min(0).max(7 * 24 * 60).optional().nullable(), + chatTypeId: z.string().trim().min(1).max(120).optional().nullable(), + chatTypeLabel: z.string().trim().min(1).max(200).optional().nullable(), + notifyOffline: z.boolean().optional().nullable(), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -1615,10 +1653,35 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } + let updatedConversation = await getChatConversation(tokenPayload.sessionId, getRequestClientId(request)); + + if (payload.chatTypeId || payload.notifyOffline != null) { + updatedConversation = await updateChatConversationContext(tokenPayload.sessionId, { + clientId: getRequestClientId(request), + chatTypeId: payload.chatTypeId?.trim() || undefined, + lastChatTypeId: payload.chatTypeId?.trim() || undefined, + contextLabel: payload.chatTypeLabel?.trim() || undefined, + contextDescription: payload.chatTypeId ? null : undefined, + notifyOffline: payload.notifyOffline ?? undefined, + }); + } + return { ok: true, hasAccessPin: saved.token.hasAccessPin, accessPinPromptTtlMinutes: saved.token.accessPinPromptTtlMinutes, + conversation: updatedConversation + ? { + sessionId: updatedConversation.sessionId, + title: updatedConversation.title, + requestBadgeLabel: updatedConversation.requestBadgeLabel ?? null, + chatTypeId: updatedConversation.chatTypeId ?? null, + lastChatTypeId: updatedConversation.lastChatTypeId ?? null, + contextLabel: updatedConversation.contextLabel ?? null, + contextDescription: updatedConversation.contextDescription ?? null, + notifyOffline: updatedConversation.notifyOffline, + } + : null, }; }); @@ -1866,6 +1929,11 @@ export async function registerChatRoutes(app: FastifyInstance) { sessionId: shareSnapshot.conversation?.sessionId ?? tokenPayload.sessionId, title: shareSnapshot.conversation?.title ?? '공유 채팅', requestBadgeLabel: shareSnapshot.conversation?.requestBadgeLabel ?? null, + chatTypeId: shareSnapshot.conversation?.chatTypeId ?? null, + lastChatTypeId: shareSnapshot.conversation?.lastChatTypeId ?? null, + contextLabel: shareSnapshot.conversation?.contextLabel ?? null, + contextDescription: shareSnapshot.conversation?.contextDescription ?? null, + notifyOffline: shareSnapshot.conversation?.notifyOffline === true, }, rootRequestId: shareSnapshot.rootRequestId, targetRequest: shareSnapshot.targetRequest, @@ -1955,6 +2023,7 @@ export async function registerChatRoutes(app: FastifyInstance) { }).parse(request.params ?? {}); const payload = z.object({ text: z.string().trim().min(1).max(20000), + mode: z.enum(['queue', 'direct']).optional(), parentRequestId: z.string().trim().min(1).max(120).optional().nullable(), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); @@ -2012,11 +2081,13 @@ export async function registerChatRoutes(app: FastifyInstance) { } const requestedParentRequestId = payload.parentRequestId?.trim() || ''; - const resolvedParentRequestId = resolveRecoveredShareParentRequestId( - shareSnapshot, - requestedParentRequestId, - [shareSnapshot.targetRequest.requestId], - ); + const resolvedParentRequestId = requestedParentRequestId + ? resolveRecoveredShareParentRequestId( + shareSnapshot, + requestedParentRequestId, + [shareSnapshot.targetRequest.requestId], + ) + : null; if ( resolvedParentRequestId @@ -2028,7 +2099,7 @@ export async function registerChatRoutes(app: FastifyInstance) { } const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.text, { - mode: 'direct', + mode: payload.mode === 'direct' ? 'direct' : 'queue', requestOrigin: 'composer', sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, parentRequestId: resolvedParentRequestId, @@ -2041,22 +2112,6 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const parentRequest = resolvedParentRequestId - ? shareSnapshot.requests.find((request) => request.requestId.trim() === resolvedParentRequestId) ?? null - : null; - - if (resolvedParentRequestId && shouldAutoCompleteShareReplyParentVerification(parentRequest)) { - const updatedParentRequest = await markChatConversationRequestManualCompletion( - tokenPayload.sessionId, - resolvedParentRequestId, - 'verification', - ); - - if (updatedParentRequest) { - getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, updatedParentRequest); - } - } - if (managedContext.managedResource) { await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { actorLabel: 'share-viewer', @@ -2179,6 +2234,7 @@ export async function registerChatRoutes(app: FastifyInstance) { summaryText: z.string().max(10000).optional().nullable(), attachments: z.array(chatComposerAttachmentSchema).max(20).optional(), followupText: z.string().trim().min(1).max(20000), + mode: z.enum(['queue', 'direct']).optional(), contextRef: chatPromptContextRefSchema, }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); @@ -2261,7 +2317,7 @@ export async function registerChatRoutes(app: FastifyInstance) { ); const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.followupText, { - mode: 'direct', + mode: resolvePromptFollowupMode(payload.mode), requestOrigin: 'prompt', sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, parentRequestId: normalizedParentRequestId, @@ -2363,11 +2419,23 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const item = await markChatConversationRequestManualCompletion( - tokenPayload.sessionId, - normalizedParentRequestId, - payload.type, - ); + let item = null; + + try { + item = await markChatConversationRequestManualCompletion( + tokenPayload.sessionId, + normalizedParentRequestId, + payload.type, + ); + } catch (error) { + if (error instanceof ChatConversationManualCompletionBlockedError) { + return reply.code(409).send({ + message: error.message, + }); + } + + throw error; + } if (!item) { return reply.code(404).send({ @@ -2587,6 +2655,7 @@ export async function registerChatRoutes(app: FastifyInstance) { } const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, { + requestId: normalizedParentRequestId, mode: 'direct', requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer', sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, @@ -2928,11 +2997,23 @@ export async function registerChatRoutes(app: FastifyInstance) { type: z.enum(['prompt', 'verification']), }).parse(request.body ?? {}); - const item = await markChatConversationRequestManualCompletion( - params.sessionId, - params.requestId, - payload.type, - ); + let item = null; + + try { + item = await markChatConversationRequestManualCompletion( + params.sessionId, + params.requestId, + payload.type, + ); + } catch (error) { + if (error instanceof ChatConversationManualCompletionBlockedError) { + return reply.code(409).send({ + message: error.message, + }); + } + + throw error; + } if (!item) { return reply.code(404).send({ @@ -3044,7 +3125,7 @@ export async function registerChatRoutes(app: FastifyInstance) { ); const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(params.sessionId, payload.followupText, { - mode: payload.mode === 'direct' ? 'direct' : 'queue', + mode: resolvePromptFollowupMode(payload.mode), requestOrigin: 'prompt', parentRequestId: params.requestId, promptContextRef: payload.contextRef ?? null, diff --git a/etc/servers/work-server/src/routes/runtime.ts b/etc/servers/work-server/src/routes/runtime.ts index 6b97c95..0ba84d4 100644 --- a/etc/servers/work-server/src/routes/runtime.ts +++ b/etc/servers/work-server/src/routes/runtime.ts @@ -28,6 +28,19 @@ function buildRuntimeResponse() { export async function registerRuntimeRoutes(app: FastifyInstance) { app.get('/api/runtime', async () => buildRuntimeResponse()); + app.post('/api/runtime/recover-interrupted-chat', async () => { + const recovered = await getActiveChatService()?.recoverInterruptedSessions(); + + return { + ok: true, + recovered: recovered ?? { + sessionCount: 0, + restartedCount: 0, + requeuedCount: 0, + }, + }; + }); + app.post('/api/runtime/drain', async (request) => { const { draining } = runtimeDrainBodySchema.parse(request.body ?? {}); diff --git a/etc/servers/work-server/src/routes/server-command.ts b/etc/servers/work-server/src/routes/server-command.ts index c932cda..fe26a47 100644 --- a/etc/servers/work-server/src/routes/server-command.ts +++ b/etc/servers/work-server/src/routes/server-command.ts @@ -2,7 +2,15 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js'; -import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js'; +import { + deployTestServerCommand, + deployWorkServerCommand, + listServerCommands, + readWorkServerDeploymentState, + restartServerCommand, + serverCommandKeys, +} from '../services/server-command-service.js'; +import { readTestServerDeploymentState } from '../services/test-server-deployment-service.js'; import { cancelServerRestartReservation, confirmServerRestartReservation, @@ -43,16 +51,7 @@ function getImmediateRestartBlockInfo( } if (key === 'work-server') { - const pendingCount = codexPendingCount + automationPendingCount; - - if (pendingCount === 0) { - return null; - } - - return { - pendingCount, - message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`, - }; + return null; } return null; @@ -92,7 +91,7 @@ async function resolveSharedServerCommandAccessContext(request: FastifyRequest) return { scope: 'shared' as const, - allowedKeys: new Set(['work-server']), + allowedKeys: new Set(['work-server', 'test']), }; } @@ -182,6 +181,12 @@ export async function registerServerCommandRoutes(app: FastifyInstance) { }; } catch (error) { const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.'; + const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null; + + if (statusCode === 409) { + reply.status(409); + return { ok: false, message }; + } if (key !== 'test' && key !== 'work-server') { throw error; @@ -207,6 +212,99 @@ export async function registerServerCommandRoutes(app: FastifyInstance) { } }); + app.get('/api/server-commands/work-server/deployment', async (request, reply) => { + const accessContext = await resolveServerCommandAccessContext(request); + if (!accessContext) { + sendAccessDenied(reply); + return; + } + + if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) { + reply.status(403); + return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버 배포 상태를 확인할 수 없습니다.' }; + } + + return { + ok: true, + item: (await readWorkServerDeploymentState()) ?? null, + }; + }); + + app.post('/api/server-commands/work-server/actions/deploy', async (request, reply) => { + const accessContext = await resolveServerCommandAccessContext(request); + if (!accessContext) { + sendAccessDenied(reply); + return; + } + + if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) { + reply.status(403); + return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버를 배포할 수 없습니다.' }; + } + + const result = await deployWorkServerCommand(); + + return { + ok: true, + item: result.server, + commandOutput: result.commandOutput, + restartState: result.restartState, + deployment: result.deployment ?? result.server.deployment ?? null, + }; + }); + + app.get('/api/server-commands/test/deployment', async (request, reply) => { + const accessContext = await resolveServerCommandAccessContext(request); + if (!accessContext) { + sendAccessDenied(reply); + return; + } + + if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) { + reply.status(403); + return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버 배포 상태를 확인할 수 없습니다.' }; + } + + return { + ok: true, + item: (await readTestServerDeploymentState()) ?? null, + }; + }); + + app.post('/api/server-commands/test/actions/deploy', async (request, reply) => { + const accessContext = await resolveServerCommandAccessContext(request); + if (!accessContext) { + sendAccessDenied(reply); + return; + } + + if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) { + reply.status(403); + return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버를 배포할 수 없습니다.' }; + } + + try { + const result = await deployTestServerCommand(); + + return { + ok: true, + item: result.server, + commandOutput: result.commandOutput, + restartState: result.restartState, + testDeployment: result.testDeployment ?? null, + }; + } catch (error) { + const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null; + + if (statusCode === 409) { + reply.status(409); + return { ok: false, message: error instanceof Error ? error.message : 'TEST 서버 배포가 이미 진행 중입니다.' }; + } + + throw error; + } + }); + app.get('/api/server-commands/restart-reservation', async (request, reply) => { const accessContext = await resolveServerCommandAccessContext(request); if (!accessContext) { diff --git a/etc/servers/work-server/src/routes/shared-resource-token.ts b/etc/servers/work-server/src/routes/shared-resource-token.ts index d7951b6..bd2f048 100644 --- a/etc/servers/work-server/src/routes/shared-resource-token.ts +++ b/etc/servers/work-server/src/routes/shared-resource-token.ts @@ -14,6 +14,7 @@ import { sharedResourceTokenSchema, upsertSharedResourceToken, } from '../services/shared-resource-token-service.js'; +import { extractRequestAuditContext } from '../utils/request-audit.js'; function getRequestAccessToken(request: { headers: Record }) { const tokenHeader = request.headers['x-access-token']; @@ -142,7 +143,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { }); } - const saved = await upsertSharedResourceToken(payload); + const saved = await upsertSharedResourceToken(payload, { + actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager', + audit: extractRequestAuditContext(request), + }); return { ok: true, ...saved, @@ -174,7 +178,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { }); } - const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason); + const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason, { + actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager', + audit: extractRequestAuditContext(request), + }); return { ok: true, @@ -201,7 +208,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { reason: z.string().trim().max(500).optional().nullable(), }) .parse(request.body ?? {}); - const saved = await revokeSharedResourceToken(tokenId, payload.reason); + const saved = await revokeSharedResourceToken(tokenId, payload.reason, { + actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager', + audit: extractRequestAuditContext(request), + }); if (!saved) { return reply.code(404).send({ @@ -229,7 +239,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { }); } - const saved = await restoreSharedResourceToken(tokenId); + const saved = await restoreSharedResourceToken(tokenId, { + actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager', + audit: extractRequestAuditContext(request), + }); if (!saved) { return reply.code(404).send({ @@ -265,7 +278,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(), }) .parse(request.body ?? {}); - const saved = await recordSharedResourceTokenUsage(tokenId, payload); + const saved = await recordSharedResourceTokenUsage(tokenId, { + ...payload, + audit: extractRequestAuditContext(request), + }); if (!saved) { return reply.code(404).send({ @@ -298,7 +314,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { }); } - const result = await deleteSharedResourceTokens(payload.tokenIds); + const result = await deleteSharedResourceTokens(payload.tokenIds, { + actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager', + audit: extractRequestAuditContext(request), + }); return { ok: true, @@ -320,7 +339,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) { }); } - const deleted = await deleteSharedResourceToken(tokenId); + const deleted = await deleteSharedResourceToken(tokenId, { + actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager', + audit: extractRequestAuditContext(request), + }); if (!deleted) { return reply.code(404).send({ diff --git a/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts b/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts index f4f3d62..283951e 100644 --- a/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts +++ b/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts @@ -1,3 +1,4 @@ +import type { Knex } from 'knex'; import { z } from 'zod'; import { db } from '../db/client.js'; import { sendNotifications } from './notification-service.js'; @@ -8,6 +9,7 @@ const MAX_CATEGORY_COUNT = 16; const MAX_RESULT_COUNT = 12; const TICKET_BAY_FETCH_TIMEOUT_MS = 12_000; const TICKET_BAY_PRODUCT_PAGE_SIZE = 100; +const BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY = 741_205_261; const teamKeywordMap: Record = { LG: 'LG', @@ -690,6 +692,9 @@ export type BaseballTicketBayTimeWindow = { export type BaseballTicketBayAlertItem = { id: string; + clientId: string; + ownerType: BaseballTicketBayOwnerType; + ownerId: string; title: string; eventDate: string; team: string; @@ -714,6 +719,9 @@ export type BaseballTicketBayAlertLogStatus = 'info' | 'success' | 'warning' | ' export type BaseballTicketBayAlertLogItem = { id: string; + clientId: string; + ownerType: BaseballTicketBayOwnerType; + ownerId: string; alertId: string | null; alertTitle: string; action: BaseballTicketBayAlertLogAction; @@ -759,9 +767,21 @@ export type BaseballTicketBayAlertMutation = { timeWindows: BaseballTicketBayTimeWindow[]; }; +type BaseballTicketBayOwnerType = 'client' | 'shared-token'; + +type BaseballTicketBayOwnerScope = + | { kind: 'all' } + | { + kind: 'owner'; + ownerType: BaseballTicketBayOwnerType; + ownerId: string; + }; + type BaseballTicketBayAlertRow = { id: string; client_id: string; + owner_type: BaseballTicketBayOwnerType; + owner_id: string; app_origin: string | null; app_domain: string | null; title: string; @@ -786,6 +806,8 @@ type BaseballTicketBayAlertRow = { type BaseballTicketBayLogRow = { id: string; client_id: string; + owner_type: BaseballTicketBayOwnerType; + owner_id: string; alert_id: string | null; alert_title: string; action: BaseballTicketBayAlertLogAction; @@ -810,6 +832,25 @@ function createId(prefix: string) { return `${prefix}-${crypto.randomUUID()}`; } +function normalizeOwnerType(value: unknown): BaseballTicketBayOwnerType { + return normalizeText(value) === 'shared-token' ? 'shared-token' : 'client'; +} + +function normalizeOwnerId(row: { owner_id?: unknown; client_id?: unknown }) { + return normalizeText(row.owner_id) || normalizeText(row.client_id); +} + +function applyOwnerScope(query: Knex.QueryBuilder, scope: BaseballTicketBayOwnerScope) { + if (scope.kind === 'all') { + return query; + } + + return query.where({ + owner_type: scope.ownerType, + owner_id: scope.ownerId, + }); +} + function normalizeNumericValue(value: unknown) { if (typeof value === 'number' && Number.isFinite(value)) { return value; @@ -858,6 +899,9 @@ function parseTimeWindows(value: string) { function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem { return { id: normalizeText(row.id), + clientId: normalizeText(row.client_id), + ownerType: normalizeOwnerType(row.owner_type), + ownerId: normalizeOwnerId(row), title: normalizeText(row.title), eventDate: normalizeText(row.event_date), team: normalizeText(row.team) || '전체', @@ -891,6 +935,9 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem return { id: normalizeText(row.id), + clientId: normalizeText(row.client_id), + ownerType: normalizeOwnerType(row.owner_type), + ownerId: normalizeOwnerId(row), alertId: row.alert_id ? normalizeText(row.alert_id) : null, alertTitle: normalizeText(row.alert_title), action: row.action, @@ -978,6 +1025,8 @@ export async function ensureBaseballTicketBayTables() { await db.schema.createTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => { table.string('id', 120).primary(); table.string('client_id', 200).notNullable().index(); + table.string('owner_type', 40).notNullable().defaultTo('client').index(); + table.string('owner_id', 200).notNullable().defaultTo('').index(); table.text('app_origin').nullable(); table.string('app_domain', 255).nullable(); table.string('title', 255).notNullable(); @@ -1006,6 +1055,8 @@ export async function ensureBaseballTicketBayTables() { await db.schema.createTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => { table.string('id', 120).primary(); table.string('client_id', 200).notNullable().index(); + table.string('owner_type', 40).notNullable().defaultTo('client').index(); + table.string('owner_id', 200).notNullable().defaultTo('').index(); table.string('alert_id', 120).nullable().index(); table.string('alert_title', 255).notNullable(); table.string('action', 40).notNullable(); @@ -1025,6 +1076,60 @@ export async function ensureBaseballTicketBayTables() { }); } + const hasAlertOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_type'); + + if (!hasAlertOwnerTypeColumn) { + await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => { + table.string('owner_type', 40).notNullable().defaultTo('client').index(); + }); + } + + const hasAlertOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_id'); + + if (!hasAlertOwnerIdColumn) { + await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => { + table.string('owner_id', 200).notNullable().defaultTo('').index(); + }); + } + + await db(BASEBALL_TICKET_BAY_ALERT_TABLE) + .where((builder) => { + builder.whereNull('owner_type').orWhere('owner_type', ''); + }) + .update({ owner_type: 'client' }); + await db(BASEBALL_TICKET_BAY_ALERT_TABLE) + .where((builder) => { + builder.whereNull('owner_id').orWhere('owner_id', ''); + }) + .update({ owner_id: db.ref('client_id') }); + + const hasLogOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_type'); + + if (!hasLogOwnerTypeColumn) { + await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => { + table.string('owner_type', 40).notNullable().defaultTo('client').index(); + }); + } + + const hasLogOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_id'); + + if (!hasLogOwnerIdColumn) { + await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => { + table.string('owner_id', 200).notNullable().defaultTo('').index(); + }); + } + + await db(BASEBALL_TICKET_BAY_LOG_TABLE) + .where((builder) => { + builder.whereNull('owner_type').orWhere('owner_type', ''); + }) + .update({ owner_type: 'client' }); + await db(BASEBALL_TICKET_BAY_LOG_TABLE) + .where((builder) => { + builder.whereNull('owner_id').orWhere('owner_id', ''); + }) + .update({ owner_id: db.ref('client_id') }); + const hasSeenTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE); if (!hasSeenTable) { @@ -1046,33 +1151,39 @@ export async function ensureBaseballTicketBayTables() { return baseballTicketBayTableSetupPromise; } -export async function listBaseballTicketBayAlerts(clientId: string) { +export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerScope) { await ensureBaseballTicketBayTables(); - const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE) - .select('*') - .where({ client_id: clientId }) - .orderBy([{ column: 'event_date', order: 'asc' }, { column: 'created_at', order: 'desc' }]); - return rows.map((row) => mapAlertRow(row as BaseballTicketBayAlertRow)); + const query = applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*'), scope); + + const rows = (await query.orderBy([ + { column: 'event_date', order: 'asc' }, + { column: 'owner_type', order: 'asc' }, + { column: 'owner_id', order: 'asc' }, + { column: 'client_id', order: 'asc' }, + { column: 'created_at', order: 'desc' }, + ])) as BaseballTicketBayAlertRow[]; + return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)); } -export async function listBaseballTicketBayLogs(clientId: string, alertId?: string) { +export async function listBaseballTicketBayLogs( + scope: BaseballTicketBayOwnerScope, + alertId?: string, +) { await ensureBaseballTicketBayTables(); - let query = db(BASEBALL_TICKET_BAY_LOG_TABLE) - .select('*') - .where({ client_id: clientId }) - .orderBy('created_at', 'desc') - .limit(200); + let query = applyOwnerScope(db(BASEBALL_TICKET_BAY_LOG_TABLE).select('*'), scope).orderBy('created_at', 'desc').limit(200); if (alertId) { query = query.andWhere({ alert_id: alertId }); } - const rows = await query; - return rows.map((row) => mapLogRow(row as BaseballTicketBayLogRow)); + const rows = (await query) as BaseballTicketBayLogRow[]; + return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)); } export async function createBaseballTicketBayLog(args: { clientId: string; + ownerType: BaseballTicketBayOwnerType; + ownerId: string; alertId?: string | null; alertTitle: string; action: BaseballTicketBayAlertLogAction; @@ -1085,6 +1196,8 @@ export async function createBaseballTicketBayLog(args: { const row: BaseballTicketBayLogRow = { id: createId('log'), client_id: args.clientId, + owner_type: args.ownerType, + owner_id: args.ownerId, alert_id: args.alertId ?? null, alert_title: args.alertTitle, action: args.action, @@ -1098,40 +1211,43 @@ export async function createBaseballTicketBayLog(args: { return mapLogRow(row); } -export async function deleteBaseballTicketBayLog(logId: string, clientId: string) { +export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) { await ensureBaseballTicketBayTables(); - const existing = await db(BASEBALL_TICKET_BAY_LOG_TABLE) - .select('*') - .where({ - id: logId, - client_id: clientId, - }) - .first(); + const existing = await applyOwnerScope( + db(BASEBALL_TICKET_BAY_LOG_TABLE) + .select('*') + .where({ + id: logId, + }), + scope, + ).first(); if (!existing) { return null; } - await db(BASEBALL_TICKET_BAY_LOG_TABLE) - .where({ + await applyOwnerScope( + db(BASEBALL_TICKET_BAY_LOG_TABLE).where({ id: logId, - client_id: clientId, - }) - .delete(); + }), + scope, + ).delete(); return mapLogRow(existing as BaseballTicketBayLogRow); } export async function createBaseballTicketBayAlert( payload: BaseballTicketBayAlertMutation, - context: { clientId: string; appOrigin?: string; appDomain?: string }, + context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string }, ) { await ensureBaseballTicketBayTables(); const now = new Date().toISOString(); const row: BaseballTicketBayAlertRow = { id: createId('alert'), client_id: context.clientId, + owner_type: context.ownerType, + owner_id: context.ownerId, app_origin: normalizeText(context.appOrigin) || null, app_domain: normalizeText(context.appDomain) || null, title: payload.title.trim(), @@ -1159,13 +1275,15 @@ export async function createBaseballTicketBayAlert( export async function updateBaseballTicketBayAlert( alertId: string, payload: Partial, - context: { clientId: string; appOrigin?: string; appDomain?: string }, + context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string }, ) { await ensureBaseballTicketBayTables(); - const current = await db(BASEBALL_TICKET_BAY_ALERT_TABLE) - .select('*') - .where({ id: alertId, client_id: context.clientId }) - .first(); + const current = await applyOwnerScope( + db(BASEBALL_TICKET_BAY_ALERT_TABLE) + .select('*') + .where({ id: alertId }), + { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId }, + ).first(); if (!current) { throw new Error('수정할 알림을 찾지 못했습니다.'); @@ -1191,37 +1309,53 @@ export async function updateBaseballTicketBayAlert( if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin); if (context.appDomain) patch.app_domain = normalizeText(context.appDomain); - await db(BASEBALL_TICKET_BAY_ALERT_TABLE) - .where({ id: alertId, client_id: context.clientId }) - .update(patch); + await applyOwnerScope( + db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), + { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId }, + ).update(patch); - const updated = await db(BASEBALL_TICKET_BAY_ALERT_TABLE) - .select('*') - .where({ id: alertId, client_id: context.clientId }) - .first(); + const updated = await applyOwnerScope( + db(BASEBALL_TICKET_BAY_ALERT_TABLE) + .select('*') + .where({ id: alertId }), + { kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId }, + ).first(); return mapAlertRow(updated as BaseballTicketBayAlertRow); } -export async function deleteBaseballTicketBayAlert(alertId: string, clientId: string) { +export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) { await ensureBaseballTicketBayTables(); - const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE) - .select('*') - .where({ id: alertId, client_id: clientId }) - .first(); + const row = await applyOwnerScope( + db(BASEBALL_TICKET_BAY_ALERT_TABLE) + .select('*') + .where({ id: alertId }), + scope, + ).first(); if (!row) { return null; } - await db(BASEBALL_TICKET_BAY_ALERT_TABLE) - .where({ id: alertId, client_id: clientId }) - .delete(); + await applyOwnerScope( + db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), + scope, + ).delete(); return mapAlertRow(row as BaseballTicketBayAlertRow); } -async function getAlertRow(alertId: string) { +async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) { await ensureBaseballTicketBayTables(); - const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ id: alertId }).first(); + const scopedQuery = scope + ? applyOwnerScope( + db(BASEBALL_TICKET_BAY_ALERT_TABLE) + .select('*') + .where({ id: alertId }), + scope, + ) + : db(BASEBALL_TICKET_BAY_ALERT_TABLE) + .select('*') + .where({ id: alertId }); + const row = await scopedQuery.first(); return row ? (row as BaseballTicketBayAlertRow) : null; } @@ -1268,8 +1402,11 @@ async function updateAlertRunTimestamp(alertId: string, patch: { lastRunAt: stri }); } -export async function runBaseballTicketBayAlert(alertId: string, options?: { ignoreTimeWindow?: boolean }) { - const row = await getAlertRow(alertId); +export async function runBaseballTicketBayAlert( + alertId: string, + options?: { ignoreTimeWindow?: boolean; scope?: BaseballTicketBayOwnerScope }, +) { + const row = await getAlertRow(alertId, options?.scope); if (!row) { throw new Error('실행할 알림을 찾지 못했습니다.'); @@ -1281,6 +1418,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign if (!options?.ignoreTimeWindow && isWithinBlockedTime(alert.timeWindows, now)) { const log = await createBaseballTicketBayLog({ clientId: row.client_id, + ownerType: normalizeOwnerType(row.owner_type), + ownerId: normalizeOwnerId(row), alertId: alert.id, alertTitle: alert.title, action: 'run', @@ -1328,6 +1467,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign : []; const log = await createBaseballTicketBayLog({ clientId: row.client_id, + ownerType: normalizeOwnerType(row.owner_type), + ownerId: normalizeOwnerId(row), alertId: alert.id, alertTitle: alert.title, action: 'run', @@ -1372,6 +1513,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign ].join('\n'); const log = await createBaseballTicketBayLog({ clientId: row.client_id, + ownerType: normalizeOwnerType(row.owner_type), + ownerId: normalizeOwnerId(row), alertId: alert.id, alertTitle: alert.title, action: 'run', @@ -1414,36 +1557,61 @@ function isAlertDue(alert: BaseballTicketBayAlertItem, now: Date) { return now.getTime() - lastRunAt >= alert.batchIntervalMinutes * 60 * 1000; } +function readBooleanLikeValue(value: unknown) { + return value === true || value === 't' || value === 'true' || value === 1 || value === '1'; +} + +async function tryAcquireBaseballTicketBayBatchLock() { + const result = (await db.raw('select pg_try_advisory_lock(?) as locked', [ + BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY, + ])) as { rows?: Array<{ locked?: unknown }> }; + return readBooleanLikeValue(result.rows?.[0]?.locked); +} + +async function releaseBaseballTicketBayBatchLock() { + await db.raw('select pg_advisory_unlock(?)', [BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY]); +} + export async function processDueBaseballTicketBayAlerts(now = new Date()) { - await ensureBaseballTicketBayTables(); - const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true }); - const results: Array<{ alertId: string; ok: boolean; message?: string }> = []; - - for (const row of rows as BaseballTicketBayAlertRow[]) { - const alert = mapAlertRow(row); - - if (!isAlertDue(alert, now)) { - continue; - } - - try { - await runBaseballTicketBayAlert(alert.id); - results.push({ alertId: alert.id, ok: true }); - } catch (error) { - const handledError = error instanceof Error ? error : new Error(String(error)); - await createBaseballTicketBayLog({ - clientId: row.client_id, - alertId: alert.id, - alertTitle: alert.title, - action: 'run', - status: 'error', - message: handledError.message || '배치 실행에 실패했습니다.', - detail: '', - }); - await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt }); - results.push({ alertId: alert.id, ok: false, message: handledError.message }); - } + if (!(await tryAcquireBaseballTicketBayBatchLock())) { + return []; } - return results; + try { + await ensureBaseballTicketBayTables(); + const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true }); + const results: Array<{ alertId: string; ok: boolean; message?: string }> = []; + + for (const row of rows as BaseballTicketBayAlertRow[]) { + const alert = mapAlertRow(row); + + if (!isAlertDue(alert, now)) { + continue; + } + + try { + await runBaseballTicketBayAlert(alert.id); + results.push({ alertId: alert.id, ok: true }); + } catch (error) { + const handledError = error instanceof Error ? error : new Error(String(error)); + await createBaseballTicketBayLog({ + clientId: row.client_id, + ownerType: normalizeOwnerType(row.owner_type), + ownerId: normalizeOwnerId(row), + alertId: alert.id, + alertTitle: alert.title, + action: 'run', + status: 'error', + message: handledError.message || '배치 실행에 실패했습니다.', + detail: '', + }); + await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt }); + results.push({ alertId: alert.id, ok: false, message: handledError.message }); + } + } + + return results; + } finally { + await releaseBaseballTicketBayBatchLock(); + } } diff --git a/etc/servers/work-server/src/services/chat-room-service.test.ts b/etc/servers/work-server/src/services/chat-room-service.test.ts index 33998a2..0e9d6b7 100644 --- a/etc/servers/work-server/src/services/chat-room-service.test.ts +++ b/etc/servers/work-server/src/services/chat-room-service.test.ts @@ -19,6 +19,8 @@ import { selectStaleOfflineNotificationClientIds, resolveNextConversationContextValue, resolveNextConversationChatTypeId, + hasPendingAttentionVerificationRequest, + isConversationAttentionPending, shouldClearConversationJobState, selectChatConversationResponseCandidate, } from './chat-room-service.js'; @@ -97,6 +99,69 @@ test('buildChatConversationContextUpdateFields ignores undefined payload keys so ); }); +test('buildChatConversationContextUpdateFields updates shared room chat type metadata together', () => { + assert.deepEqual( + buildChatConversationContextUpdateFields({ + current: { + title: '공유채팅', + chat_type_id: 'general-request', + last_chat_type_id: 'general-request', + context_label: '일반 요청', + context_description: 'old', + notify_offline: true, + }, + payload: { + chatTypeId: 'codex-live-default', + lastChatTypeId: 'codex-live-default', + contextLabel: 'Codex Live 기본', + contextDescription: null, + }, + }), + { + chat_type_id: 'codex-live-default', + last_chat_type_id: 'codex-live-default', + context_label: 'Codex Live 기본', + context_description: null, + }, + ); +}); + +test('buildChatConversationContextUpdateFields writes global notify flag only when no client is bound', () => { + assert.deepEqual( + buildChatConversationContextUpdateFields({ + current: { + title: '공유채팅', + chat_type_id: 'general-request', + last_chat_type_id: 'general-request', + notify_offline: false, + }, + payload: { + notifyOffline: true, + }, + }), + { + notify_offline: true, + }, + ); + + assert.deepEqual( + buildChatConversationContextUpdateFields({ + current: { + title: '공유채팅', + chat_type_id: 'general-request', + last_chat_type_id: 'general-request', + client_id: 'client-1', + notify_offline: false, + }, + payload: { + clientId: 'client-1', + notifyOffline: true, + }, + }), + {}, + ); +}); + test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => { assert.deepEqual( selectStaleOfflineNotificationClientIds( @@ -147,6 +212,149 @@ test('collectRegisteredNotificationClientIds keeps both web push client ids and ); }); +test('hasPendingAttentionVerificationRequest keeps 일반 답변 in pending attention until manual completion', () => { + assert.equal( + hasPendingAttentionVerificationRequest( + { + status: 'completed', + responseMessageId: 101, + responseText: '일반 답변입니다.', + requestOrigin: 'composer', + }, + [], + ), + true, + ); + + assert.equal( + hasPendingAttentionVerificationRequest( + { + status: 'completed', + responseMessageId: null, + responseText: '', + requestOrigin: 'composer', + }, + [ + { + author: 'codex', + text: '```diff\n+ hello\n```', + }, + ], + ), + true, + ); + + assert.equal( + hasPendingAttentionVerificationRequest( + { + status: 'completed', + responseMessageId: null, + responseText: '', + requestOrigin: 'composer', + }, + [ + { + author: 'codex', + text: '짧은 진행 로그', + }, + ], + ), + false, + ); +}); + +test('isConversationAttentionPending clears verification attention when 답변하기 child request exists', () => { + assert.equal( + isConversationAttentionPending({ + request: { + sessionId: 'session-1', + requestId: 'parent-request', + requesterClientId: null, + chatTypeId: null, + chatTypeLabel: '기본처리', + requestOrigin: 'composer', + sharedResourceTokenId: null, + parentRequestId: null, + promptContextRef: null, + status: 'completed', + statusMessage: null, + retryCount: 0, + userMessageId: 1, + userText: '원본 질문', + responseMessageId: 2, + responseText: '원본 답변', + usageSnapshot: null, + totalTokens: null, + hasResponse: true, + canDelete: false, + manualPromptCompletedAt: null, + manualVerificationCompletedAt: null, + createdAt: '2026-05-26T00:00:00.000Z', + updatedAt: '2026-05-26T00:01:00.000Z', + answeredAt: '2026-05-26T00:01:00.000Z', + terminalAt: '2026-05-26T00:01:00.000Z', + }, + relatedMessages: [ + { + id: 2, + author: 'codex', + text: '원본 답변', + timestamp: '2026-05-26T00:01:00.000Z', + }, + ], + childRequestCountByParentId: new Map([['parent-request', 1]]), + promptFollowupCountByParentId: new Map(), + }), + false, + ); +}); + +test('isConversationAttentionPending keeps completed 일반 답변 visible when no child request exists', () => { + assert.equal( + isConversationAttentionPending({ + request: { + sessionId: 'session-1', + requestId: 'standalone-request', + requesterClientId: null, + chatTypeId: null, + chatTypeLabel: '기본처리', + requestOrigin: 'composer', + sharedResourceTokenId: null, + parentRequestId: null, + promptContextRef: null, + status: 'completed', + statusMessage: null, + retryCount: 0, + userMessageId: 1, + userText: '독립 질문', + responseMessageId: 2, + responseText: '독립 답변', + usageSnapshot: null, + totalTokens: null, + hasResponse: true, + canDelete: false, + manualPromptCompletedAt: null, + manualVerificationCompletedAt: null, + createdAt: '2026-05-26T00:00:00.000Z', + updatedAt: '2026-05-26T00:01:00.000Z', + answeredAt: '2026-05-26T00:01:00.000Z', + terminalAt: '2026-05-26T00:01:00.000Z', + }, + relatedMessages: [ + { + id: 2, + author: 'codex', + text: '독립 답변', + timestamp: '2026-05-26T00:01:00.000Z', + }, + ], + childRequestCountByParentId: new Map(), + promptFollowupCountByParentId: new Map(), + }), + true, + ); +}); + test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => { assert.equal( buildChatConversationRequestPatchFromMessage({ @@ -227,6 +435,38 @@ test('applyChatPromptSelectionPatch resolves the matched prompt with persisted s assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']); }); +test('applyChatPromptSelectionPatch keeps followup text for free-text-only prompt submissions', () => { + const promptPart = { + type: 'prompt' as const, + title: '다음 단계 선택', + description: '원하는 작업을 고르세요.', + submitLabel: '선택 전달', + mode: 'queue' as const, + selectedValues: [], + options: [], + steps: [], + }; + + const patched = applyChatPromptSelectionPatch( + [promptPart], + { + promptIndex: 0, + promptTitle: promptPart.title, + promptSignature: buildChatPromptTargetSignature(promptPart), + selectedValues: [], + freeText: '', + followupText: '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.', + summaryText: '', + }, + '2026-05-18T08:25:00.000Z', + ); + + assert.ok(patched); + assert.equal(patched?.[0]?.type, 'prompt'); + assert.equal(patched?.[0]?.resolvedBy, 'user'); + assert.equal(patched?.[0]?.resultText, '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.'); +}); + test('collectPromptSelectionCandidateRequestIds includes descendant requests and prefers recent responses first', () => { assert.deepEqual( collectPromptSelectionCandidateRequestIds( @@ -552,6 +792,28 @@ test('shouldClearConversationJobState keeps placeholder-only started responses w ); }); +test('shouldClearConversationJobState clears in-progress state immediately after process restart when runtime is gone', () => { + assert.equal( + shouldClearConversationJobState({ + currentRequestId: 'chat-req-9', + currentJobStatus: 'started', + currentStatusUpdatedAt: '2026-05-27T00:57:53.000Z', + runtimeActive: false, + nowMs: Date.parse('2026-05-27T01:03:10.000Z'), + processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'), + request: { + requestId: 'chat-req-9', + status: 'started', + responseMessageId: null, + responseText: '', + terminalAt: null, + updatedAt: '2026-05-27T00:58:10.000Z', + }, + }), + true, + ); +}); + test('normalizeStaleRequestItem keeps queued requests when another request is currently active', () => { assert.deepEqual( normalizeStaleRequestItem( @@ -562,8 +824,10 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu requestOrigin: null, sharedResourceTokenId: null, parentRequestId: null, + promptContextRef: null, status: 'queued', statusMessage: '대기열 1건', + retryCount: 0, userMessageId: 11, userText: '다음 요청', responseMessageId: null, @@ -591,6 +855,7 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu parentRequestId: null, status: 'queued', statusMessage: '대기열 1건', + retryCount: 0, userMessageId: 11, userText: '다음 요청', responseMessageId: null, @@ -606,3 +871,68 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu }, ); }); + +test('normalizeStaleRequestItem marks detached queued requests as failed after process restart when runtime is gone', () => { + assert.deepEqual( + normalizeStaleRequestItem( + { + sessionId: 'session-1', + requestId: 'chat-req-detached', + requesterClientId: null, + requestOrigin: null, + sharedResourceTokenId: null, + parentRequestId: null, + promptContextRef: null, + status: 'queued', + statusMessage: '대기열 1건', + retryCount: 0, + userMessageId: 12, + userText: '끊긴 요청', + responseMessageId: null, + responseText: '', + usageSnapshot: null, + totalTokens: null, + hasResponse: false, + canDelete: false, + createdAt: '2026-05-27T00:57:53.000Z', + updatedAt: '2026-05-27T00:58:10.000Z', + answeredAt: null, + terminalAt: null, + }, + { + current_request_id: 'chat-req-running', + current_job_status: 'started', + current_status_updated_at: '2026-05-27T01:03:05.000Z', + }, + { + runtimeActive: false, + nowMs: Date.parse('2026-05-27T01:03:10.000Z'), + processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'), + }, + ), + { + sessionId: 'session-1', + requestId: 'chat-req-detached', + requesterClientId: null, + requestOrigin: null, + sharedResourceTokenId: null, + parentRequestId: null, + promptContextRef: null, + status: 'failed', + statusMessage: '대기열 1건', + retryCount: 0, + userMessageId: 12, + userText: '끊긴 요청', + responseMessageId: null, + responseText: '', + usageSnapshot: null, + totalTokens: null, + hasResponse: false, + canDelete: true, + createdAt: '2026-05-27T00:57:53.000Z', + updatedAt: '2026-05-27T00:58:10.000Z', + answeredAt: null, + terminalAt: '2026-05-27T00:58:10.000Z', + }, + ); +}); diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index 71e6a37..b5489c8 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -19,8 +19,10 @@ export const CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES = [ 'chat_type_label', 'request_origin', 'parent_request_id', + 'prompt_context_ref', 'status', 'status_message', + 'retry_count', 'user_message_id', 'user_text', 'response_message_id', @@ -38,6 +40,7 @@ export const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000; const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000; +const PROCESS_RESTART_STALE_GRACE_MS = 10_000; const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const; const conversationPayloadSchema = z.object({ @@ -125,6 +128,16 @@ export type ChatConversationRequestUsageSnapshot = { totalTokens: number; }; +export class ChatConversationManualCompletionBlockedError extends Error { + readonly code: 'child-followup-exists'; + + constructor(message: string) { + super(message); + this.name = 'ChatConversationManualCompletionBlockedError'; + this.code = 'child-followup-exists'; + } +} + export type ChatConversationRequestItem = { sessionId: string; requestId: string; @@ -134,8 +147,15 @@ export type ChatConversationRequestItem = { requestOrigin: 'composer' | 'prompt' | null; sharedResourceTokenId: string | null; parentRequestId: string | null; + promptContextRef: { + key: 'prompt_parent_question'; + promptTitle: string; + promptDescription?: string | null; + parentQuestionText?: string | null; + } | null; status: ChatConversationRequestStatus; statusMessage: string | null; + retryCount: number; userMessageId: number | null; userText: string; responseMessageId: number | null; @@ -174,6 +194,7 @@ type ChatPromptSelectionPatch = { sourceMessageId?: number; selectedValues: string[]; freeText?: string | null; + followupText?: string | null; stepSelections?: ChatPromptStepSelectionPatch[]; summaryText?: string | null; attachments?: Array<{ @@ -624,6 +645,9 @@ export function applyChatPromptSelectionPatch( .map((step) => String(step.stepKey ?? '').trim()) .filter(Boolean) .at(-1); + const normalizedSummaryText = String(selection.summaryText ?? '').trim(); + const normalizedFreeText = String(selection.freeText ?? '').trim(); + const normalizedFollowupText = String(selection.followupText ?? '').trim(); nextParts[matchedPrompt.index] = { ...currentPart, @@ -633,7 +657,7 @@ export function applyChatPromptSelectionPatch( readOnly: true, resolvedBy: 'user', resolvedAt, - resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null, + resultText: normalizedSummaryText || normalizedFreeText || normalizedFollowupText || null, attachments: Array.isArray(selection.attachments) ? selection.attachments : [], }; @@ -667,6 +691,58 @@ function normalizePromptSelectionSourceMessageId(selection: ChatPromptSelectionP : null; } +function normalizePromptContextText(value: string | null | undefined, maxLength = 1000) { + const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return ''; + } + + if (normalized.length <= maxLength) { + return normalized; + } + + return normalized.slice(0, maxLength).trimEnd() + '...'; +} + +function normalizeStoredPromptContextRef( + value: unknown, +): ChatConversationRequestItem['promptContextRef'] { + if (!value) { + return null; + } + + const parsedValue = + typeof value === 'string' + ? (() => { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } + })() + : value; + + if (!parsedValue || typeof parsedValue !== 'object' || (parsedValue as { key?: unknown }).key !== 'prompt_parent_question') { + return null; + } + + const promptTitle = normalizePromptContextText((parsedValue as { promptTitle?: string }).promptTitle, 500); + + if (!promptTitle) { + return null; + } + + return { + key: 'prompt_parent_question', + promptTitle, + promptDescription: + normalizePromptContextText((parsedValue as { promptDescription?: string | null }).promptDescription, 1000) || null, + parentQuestionText: + normalizePromptContextText((parsedValue as { parentQuestionText?: string | null }).parentQuestionText, 1000) || null, + }; +} + export function collectPromptSelectionCandidateRequestIds( requestRows: Array<{ request_id?: unknown; @@ -1223,38 +1299,77 @@ function hasPendingAttentionVerificationTarget(text: string | null | undefined) return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized); } -function isConversationAttentionPending(options: { +export function hasPendingAttentionVerificationRequest( + request: Pick, + relatedMessages: Pick[], +) { + if (request.responseMessageId != null) { + return true; + } + + if (String(request.responseText ?? '').trim().length > 0) { + return true; + } + + if (request.status === 'completed' && request.requestOrigin === 'composer') { + return relatedMessages.some( + (message) => + (message.author === 'codex' || message.author === 'system') + && hasPendingAttentionVerificationTarget(message.text), + ); + } + + return false; +} + +function hasChildFollowupRequest( + request: Pick, + childRequestCountByParentId: Map, +) { + return (childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0; +} + +export function isConversationAttentionPending(options: { request: ChatConversationRequestItem; relatedMessages: StoredChatMessage[]; childRequestCountByParentId: Map; + promptFollowupCountByParentId: Map; }) { - const { request, relatedMessages, childRequestCountByParentId } = options; + const { request, relatedMessages, childRequestCountByParentId, promptFollowupCountByParentId } = options; + + if (request.requestOrigin === 'prompt') { + return false; + } if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') { return true; } if (!request.manualPromptCompletedAt) { - const hasOpenPrompt = relatedMessages.some( - (message) => - (message.author === 'codex' || message.author === 'system') - && hasPendingAttentionPromptMessageParts(message.parts), - ); + const unresolvedPromptCount = relatedMessages.reduce((count, message) => { + if (message.author !== 'codex' && message.author !== 'system') { + return count; + } + + return count + (message.parts ?? []).filter((part) => isPendingAttentionPromptPart(part)).length; + }, 0); + const promptSubmittedCount = promptFollowupCountByParentId.get(request.requestId.trim()) ?? 0; + const hasOpenPrompt = unresolvedPromptCount > Math.max(0, promptSubmittedCount); if (hasOpenPrompt) { return true; } } - if ((childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0) { + if (request.manualPromptCompletedAt) { return false; } - const hasVerificationTarget = relatedMessages.some( - (message) => - (message.author === 'codex' || message.author === 'system') - && hasPendingAttentionVerificationTarget(message.text), - ); + if (hasChildFollowupRequest(request, childRequestCountByParentId)) { + return false; + } + + const hasVerificationTarget = hasPendingAttentionVerificationRequest(request, relatedMessages); if (!hasVerificationTarget) { return false; @@ -1321,6 +1436,19 @@ async function getConversationPendingAttentionMap(sessionIds: string[]) { return map; }, new Map()); + const promptFollowupCountByParentId = requests.reduce>((map, request) => { + if (request.requestOrigin !== 'prompt') { + return map; + } + + const parentRequestId = request.parentRequestId?.trim() || ''; + + if (parentRequestId) { + map.set(parentRequestId, (map.get(parentRequestId) ?? 0) + 1); + } + + return map; + }, new Map()); const requestMessagesById = messages.reduce>((map, message) => { const requestId = message.clientRequestId?.trim() || ''; @@ -1341,6 +1469,7 @@ async function getConversationPendingAttentionMap(sessionIds: string[]) { request, relatedMessages: requestMessagesById.get(request.requestId.trim()) ?? [], childRequestCountByParentId, + promptFollowupCountByParentId, }), ), ); @@ -1596,8 +1725,15 @@ function mapRequestRow(row: Record): ChatConversationRequestIte requestOrigin: requestOrigin === 'prompt' || requestOrigin === 'composer' ? requestOrigin : null, sharedResourceTokenId: row.shared_resource_token_id == null ? null : String(row.shared_resource_token_id), parentRequestId: parentRequestId || null, + promptContextRef: normalizeStoredPromptContextRef(row.prompt_context_ref), status, statusMessage: row.status_message == null ? null : String(row.status_message), + retryCount: + row.retry_count == null || row.retry_count === '' + ? 0 + : Number.isFinite(Number(row.retry_count)) + ? Math.max(0, Math.round(Number(row.retry_count))) + : 0, userMessageId: row.user_message_id == null ? null : Number(row.user_message_id), userText: String(row.user_text ?? ''), responseMessageId: row.response_message_id == null ? null : Number(row.response_message_id), @@ -1781,6 +1917,78 @@ function isConversationRequestActive( return currentJobStatus === 'queued' || currentJobStatus === 'started'; } +function resolveCurrentProcessStartedAtMs(nowMs = Date.now()) { + const uptimeMs = Math.max(0, Math.floor(process.uptime() * 1000)); + return Math.max(0, nowMs - uptimeMs); +} + +function shouldFailStaleInProgressRequest(params: { + currentRequestId?: string | null; + currentJobStatus?: ChatConversationItem['currentJobStatus']; + currentStatusUpdatedAt?: string | null; + runtimeActive?: boolean; + nowMs?: number; + processStartedAtMs?: number; + request: + | { + requestId?: string | null; + status?: ChatConversationRequestStatus | null; + responseMessageId?: number | null; + responseText?: string | null; + terminalAt?: string | null; + updatedAt?: string | null; + } + | null + | undefined; +}) { + const requestStatus = params.request?.status ?? null; + const hasStoredResponse = hasStoredRequestResponse(params.request ?? {}); + const runtimeActive = params.runtimeActive === true; + const currentRequestId = params.currentRequestId?.trim() || null; + const requestId = params.request?.requestId?.trim() || null; + const currentJobStatus = params.currentJobStatus ?? null; + const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now(); + const processStartedAtMs = Number.isFinite(params.processStartedAtMs) + ? Number(params.processStartedAtMs) + : resolveCurrentProcessStartedAtMs(nowMs); + const isInProgressRequest = + (requestStatus === 'accepted' || requestStatus === 'queued' || requestStatus === 'started') + && !hasStoredResponse + && !isTerminalRequestStatus(requestStatus); + const isCurrentTrackedRequest = + Boolean(currentRequestId) + && Boolean(requestId) + && currentRequestId === requestId + && (currentJobStatus === 'queued' || currentJobStatus === 'started'); + const isDetachedInProgressRequest = + Boolean(requestId) + && !isCurrentTrackedRequest + && (requestStatus === 'queued' || requestStatus === 'started'); + const lastUpdatedAt = isCurrentTrackedRequest + ? Math.max( + getTimeValue(params.currentStatusUpdatedAt), + getTimeValue(params.request?.updatedAt), + ) + : getTimeValue(params.request?.updatedAt); + + if (!requestId || !isInProgressRequest || runtimeActive || lastUpdatedAt <= 0) { + return false; + } + + if ( + processStartedAtMs > 0 && + lastUpdatedAt <= processStartedAtMs - PROCESS_RESTART_STALE_GRACE_MS && + (isCurrentTrackedRequest || isDetachedInProgressRequest) + ) { + return true; + } + + return ( + nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS && + (isCurrentTrackedRequest || isDetachedInProgressRequest) + ); +} + function hasConversationMetadata( conversation: { title?: unknown; @@ -1820,20 +2028,13 @@ export function normalizeStaleRequestItem( current_job_status?: unknown; current_status_updated_at?: unknown; } | null | undefined, + options?: { + runtimeActive?: boolean; + nowMs?: number; + processStartedAtMs?: number; + }, ) { - const runtimeActive = isRuntimeRequestActive(item.requestId); - const lastUpdatedAt = Math.max( - getTimeValue(conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at)), - getTimeValue(item.updatedAt), - ); - const isDetachedStaleInProgressState = - !runtimeActive && - !isConversationRequestActive(conversation, item.requestId) && - (item.status === 'queued' || item.status === 'started') && - !hasStoredRequestResponse(item) && - !isTerminalRequestStatus(item.status) && - lastUpdatedAt > 0 && - Date.now() - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS; + const runtimeActive = options?.runtimeActive ?? isRuntimeRequestActive(item.requestId); if ( shouldClearConversationJobState({ @@ -1845,13 +2046,27 @@ export function normalizeStaleRequestItem( currentStatusUpdatedAt: conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at), runtimeActive, + nowMs: options?.nowMs, + processStartedAtMs: options?.processStartedAtMs, request: item, - }) || isDetachedStaleInProgressState + }) || shouldFailStaleInProgressRequest({ + currentRequestId: String(conversation?.current_request_id ?? ''), + currentJobStatus: + conversation?.current_job_status == null + ? null + : String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'], + currentStatusUpdatedAt: + conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at), + runtimeActive, + nowMs: options?.nowMs, + processStartedAtMs: options?.processStartedAtMs, + request: item, + }) ) { return { ...item, status: 'failed' as const, - statusMessage: item.statusMessage ?? '중단된 오래된 요청', + statusMessage: '중단된 오래된 요청', canDelete: true, terminalAt: item.terminalAt ?? item.updatedAt, }; @@ -1860,12 +2075,49 @@ export function normalizeStaleRequestItem( return item; } +async function reconcileStaleConversationRequests( + sessionId: string, + rows: Record[], + conversation: { + current_request_id?: unknown; + current_job_status?: unknown; + current_status_updated_at?: unknown; + } | null | undefined, +) { + const normalizedSessionId = sessionId.trim(); + const normalizedItems = rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); + const staleRequestIds = normalizedItems + .flatMap((item, index) => { + const persistedStatus = String(rows[index]?.status ?? '').trim(); + return item.status === 'failed' && (persistedStatus === 'accepted' || persistedStatus === 'queued' || persistedStatus === 'started') + ? [item.requestId.trim()] + : []; + }) + .filter(Boolean); + + if (staleRequestIds.length > 0) { + await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ session_id: normalizedSessionId }) + .whereIn('request_id', staleRequestIds) + .whereIn('status', ['accepted', 'queued', 'started']) + .update({ + status: 'failed', + status_message: '중단된 오래된 요청', + terminal_at: db.fn.now(), + updated_at: db.fn.now(), + }); + } + + return normalizedItems; +} + export function shouldClearConversationJobState(params: { currentRequestId?: string | null; currentJobStatus?: ChatConversationItem['currentJobStatus']; currentStatusUpdatedAt?: string | null; runtimeActive?: boolean; nowMs?: number; + processStartedAtMs?: number; request: | { requestId?: string | null; @@ -1899,23 +2151,19 @@ export function shouldClearConversationJobState(params: { } const runtimeActive = params.runtimeActive === true; - const lastUpdatedAt = Math.max( - getTimeValue(params.currentStatusUpdatedAt), - getTimeValue(params.request?.updatedAt), - ); - const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now(); - const isStaleInProgressState = - !runtimeActive && - (currentJobStatus === 'queued' || currentJobStatus === 'started') && - !hasStoredRequestResponse(params.request ?? {}) && - !isTerminalRequestStatus(params.request?.status ?? null) && - lastUpdatedAt > 0 && - nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS; return ( (requestStatus != null && requestStatus !== 'completed' && isTerminalRequestStatus(requestStatus)) || hasStoredResponse || - isStaleInProgressState + shouldFailStaleInProgressRequest({ + currentRequestId, + currentJobStatus, + currentStatusUpdatedAt: params.currentStatusUpdatedAt, + runtimeActive, + nowMs: params.nowMs, + processStartedAtMs: params.processStartedAtMs, + request: params.request, + }) ); } @@ -1961,6 +2209,9 @@ function getDefaultChatConversationRequestStatusMessage(status: ChatConversation export function mergeChatConversationRequestStatus( currentStatus: ChatConversationRequestStatus | null | undefined, incomingStatus: ChatConversationRequestStatus | null | undefined, + options?: { + allowTerminalStatusReset?: boolean; + }, ): ChatConversationRequestStatus { const normalizedCurrent = currentStatus ?? null; const normalizedIncoming = incomingStatus ?? null; @@ -1977,7 +2228,11 @@ export function mergeChatConversationRequestStatus( return normalizedCurrent; } - if (isTerminalRequestStatus(normalizedCurrent) && !isTerminalRequestStatus(normalizedIncoming)) { + if ( + isTerminalRequestStatus(normalizedCurrent) + && !isTerminalRequestStatus(normalizedIncoming) + && options?.allowTerminalStatusReset !== true + ) { return normalizedCurrent; } @@ -2455,8 +2710,10 @@ export async function ensureChatConversationTables() { table.string('request_origin', 40).nullable(); table.string('shared_resource_token_id', 120).nullable().index(); table.string('parent_request_id', 120).nullable(); + table.text('prompt_context_ref').nullable(); table.string('status', 40).notNullable().defaultTo('accepted'); table.text('status_message').nullable(); + table.integer('retry_count').notNullable().defaultTo(0); table.bigInteger('user_message_id').nullable(); table.text('user_text').notNullable().defaultTo(''); table.bigInteger('response_message_id').nullable(); @@ -2482,8 +2739,10 @@ export async function ensureChatConversationTables() { ['request_origin', (table) => table.string('request_origin', 40).nullable()], ['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).nullable().index()], ['parent_request_id', (table) => table.string('parent_request_id', 120).nullable()], + ['prompt_context_ref', (table) => table.text('prompt_context_ref').nullable()], ['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')], ['status_message', (table) => table.text('status_message').nullable()], + ['retry_count', (table) => table.integer('retry_count').notNullable().defaultTo(0)], ['user_message_id', (table) => table.bigInteger('user_message_id').nullable()], ['user_text', (table) => table.text('user_text').notNullable().defaultTo('')], ['response_message_id', (table) => table.bigInteger('response_message_id').nullable()], @@ -3347,7 +3606,7 @@ export async function listChatConversationDetailPage( .limit(normalizedLimit); const orderedRequestRows = [...requestRows].reverse(); - const requests = orderedRequestRows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); + const requests = await reconcileStaleConversationRequests(normalizedSessionId, orderedRequestRows, conversation); const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean); if (requestIds.length === 0) { @@ -3420,7 +3679,7 @@ export async function listChatConversationRequests(sessionId: string, limit = 20 .orderBy('created_at', 'asc') .limit(Math.max(1, Math.min(1000, Math.round(limit)))); - return rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); + return reconcileStaleConversationRequests(normalizedSessionId, rows, conversation); } export async function listChatSourceChangeSnapshots(clientId?: string | null, limit = 200) { @@ -3682,7 +3941,12 @@ export async function getChatConversationRequest(sessionId: string, requestId: s }) .first(); - return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null; + if (!row) { + return null; + } + + const [item] = await reconcileStaleConversationRequests(normalizedSessionId, [row], conversation); + return item ?? null; } async function refreshConversationPreview(sessionId: string) { @@ -3996,6 +4260,19 @@ export async function listRecoverableChatConversationRequests(): Promise 0 ? current?.answered_at ?? db.fn.now() @@ -4159,8 +4464,13 @@ export async function upsertChatConversationRequest( request_origin: normalizedRequestOrigin ?? current?.request_origin ?? null, shared_resource_token_id: normalizedSharedResourceTokenId ?? current?.shared_resource_token_id ?? null, parent_request_id: normalizedParentRequestId ?? current?.parent_request_id ?? null, + prompt_context_ref: + normalizedPromptContextRef != null + ? JSON.stringify(normalizedPromptContextRef) + : current?.prompt_context_ref ?? null, status: nextStatus, status_message: payload.statusMessage?.trim() || defaultStatusMessage || current?.status_message || null, + retry_count: nextRetryCount, user_message_id: payload.userMessageId ?? current?.user_message_id ?? null, user_text: payload.userText ?? current?.user_text ?? '', response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null, @@ -4247,6 +4557,35 @@ export async function markChatConversationRequestManualCompletion( return null; } + const existingRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .first(); + + if (!existingRow) { + return null; + } + + if (completionType === 'verification') { + const childRequestCountRow = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + parent_request_id: normalizedRequestId, + }) + .count<{ count: string | number }[]>({ count: '*' }) + .first(); + + const childRequestCount = Number(childRequestCountRow?.count ?? 0) || 0; + + if (childRequestCount > 0) { + throw new ChatConversationManualCompletionBlockedError( + '후속 요청이 있는 답변은 응답 확인 완료로 처리할 수 없습니다.', + ); + } + } + const targetColumn = completionType === 'prompt' ? 'manual_prompt_completed_at' : 'manual_verification_completed_at'; diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 4d0591d..1c1487f 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -26,7 +26,6 @@ import { resolveChatContextAppDomain, rewriteCodexOutputWithChatResources, summarizeActivityProgressLine, - shouldAutoCompleteReplyParentVerification, shouldSendOfflineChatNotification, shouldUseAgenticCodexReply, shouldUseTemplateMacroReply, @@ -154,58 +153,6 @@ test('shouldSendOfflineChatNotification blocks chat push when app setting disabl ); }); -test('shouldAutoCompleteReplyParentVerification only completes answered composer followups that are not already verified', () => { - assert.equal( - shouldAutoCompleteReplyParentVerification({ - requestOrigin: 'composer', - responseMessageId: 101, - responseText: '', - manualVerificationCompletedAt: null, - }), - true, - ); - - assert.equal( - shouldAutoCompleteReplyParentVerification({ - requestOrigin: 'composer', - responseMessageId: null, - responseText: '답변 본문', - manualVerificationCompletedAt: null, - }), - true, - ); - - assert.equal( - shouldAutoCompleteReplyParentVerification({ - requestOrigin: 'prompt', - responseMessageId: 101, - responseText: '답변 본문', - manualVerificationCompletedAt: null, - }), - false, - ); - - assert.equal( - shouldAutoCompleteReplyParentVerification({ - requestOrigin: 'composer', - responseMessageId: null, - responseText: '', - manualVerificationCompletedAt: null, - }), - false, - ); - - assert.equal( - shouldAutoCompleteReplyParentVerification({ - requestOrigin: 'composer', - responseMessageId: 102, - responseText: '답변 본문', - manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z', - }), - false, - ); -}); - test('resolveChatContextAppOrigin returns normalized origin from session page url', () => { assert.equal( resolveChatContextAppOrigin({ diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 2dc5cb6..ad37b80 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -18,7 +18,6 @@ import { appendChatConversationMessage, appendChatConversationActivityLine, getChatConversationRequest, - markChatConversationRequestManualCompletion, type ChatConversationRequestItem, type ChatConversationRequestUsageSnapshot, getChatConversation, @@ -46,6 +45,7 @@ import { import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js'; import { resolveMainProjectRoot } from './main-project-root-service.js'; import { isRuntimeDraining, trackWebSocketConnectionClosed, trackWebSocketConnectionOpened } from './runtime-drain-service.js'; +import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js'; import { findLatestPlanItem, findPlanItemByPreviewUrl, @@ -117,23 +117,6 @@ type ChatPromptContextRef = { parentQuestionText?: string | null; }; -export function shouldAutoCompleteReplyParentVerification(options: { - requestOrigin?: 'composer' | 'prompt' | null; - responseMessageId?: number | null; - responseText?: string | null; - manualVerificationCompletedAt?: string | null; -} | null | undefined) { - if (!options || options.requestOrigin !== 'composer' || options.manualVerificationCompletedAt) { - return false; - } - - if (options.responseMessageId != null) { - return true; - } - - return String(options.responseText ?? '').trim().length > 0; -} - type ChatInboundMessage = | { type: 'context:update'; @@ -4428,6 +4411,21 @@ export class ChatService { } async recoverInterruptedSessions() { + if (!(await isCurrentWorkServerSlotActive())) { + this.logger.info( + { + slot: process.env.WORK_SERVER_SLOT?.trim() || null, + }, + 'skip interrupted chat recovery on inactive work-server slot', + ); + + return { + sessionCount: 0, + restartedCount: 0, + requeuedCount: 0, + }; + } + const recoverableRequests = await listRecoverableChatConversationRequests(); if (recoverableRequests.length === 0) { @@ -5748,31 +5746,6 @@ export class ChatService { omitPromptHistory: requestOptions?.omitPromptHistory === true, context: cloneChatContext(state.context), }; - const parentRequest = request.parentRequestId - ? await getChatConversationRequest(state.sessionId, request.parentRequestId) - : null; - const shouldAutoCompleteParentVerification = shouldAutoCompleteReplyParentVerification({ - requestOrigin, - responseMessageId: parentRequest?.responseMessageId ?? null, - responseText: parentRequest?.responseText ?? '', - manualVerificationCompletedAt: parentRequest?.manualVerificationCompletedAt ?? null, - }); - const completeParentVerificationIfNeeded = async () => { - if (!request.parentRequestId || !shouldAutoCompleteParentVerification) { - return; - } - - const updatedParentRequest = await markChatConversationRequestManualCompletion( - state.sessionId, - request.parentRequestId, - 'verification', - ); - - if (updatedParentRequest) { - this.broadcastRequestUpdate(state.sessionId, updatedParentRequest); - } - }; - if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) { const queuedUserMessage = { ...createMessage('user', trimmed, nextRequestId), @@ -5807,6 +5780,7 @@ export class ChatService { requestOrigin: request.requestOrigin, sharedResourceTokenId: request.sharedResourceTokenId, parentRequestId: request.parentRequestId, + promptContextRef: request.promptContextRef, status: 'queued', statusMessage: `대기열 ${state.queue.length}건`, userMessageId: queuedUserMessage.id, @@ -5814,11 +5788,9 @@ export class ChatService { }).catch((error: unknown) => { this.logger.error(error, 'failed to persist queued chat request'); }); - await completeParentVerificationIfNeeded(); return nextRequestId; } - await completeParentVerificationIfNeeded(); void this.executeRequest(state, request).catch((error: unknown) => { this.logger.error(error, 'direct chat reply build failed'); this.sendToSession(state, { @@ -5851,6 +5823,10 @@ export class ChatService { const compactActivityLineMap = new Map(); session.activeRequestCount += 1; const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId); + const isRetryAttempt = + existingRequest != null + && !existingRequest.hasResponse + && (existingRequest.status === 'failed' || existingRequest.status === 'cancelled'); const hasStoredUserMessage = existingRequest?.userMessageId != null; let userMessageId = existingRequest?.userMessageId ?? null; @@ -5873,7 +5849,11 @@ export class ChatService { requestOrigin: request.requestOrigin, sharedResourceTokenId: request.sharedResourceTokenId, parentRequestId: request.parentRequestId, + promptContextRef: request.promptContextRef, status: request.mode === 'direct' ? 'accepted' : existingRequest?.status ?? 'queued', + statusMessage: isRetryAttempt ? '재처리 요청 접수' : undefined, + incrementRetryCount: isRetryAttempt, + allowTerminalStatusReset: isRetryAttempt, userMessageId, userText: request.text, }); diff --git a/etc/servers/work-server/src/services/notification-service.ts b/etc/servers/work-server/src/services/notification-service.ts index da14eae..4b185d0 100644 --- a/etc/servers/work-server/src/services/notification-service.ts +++ b/etc/servers/work-server/src/services/notification-service.ts @@ -67,6 +67,7 @@ export const sendIosNotificationSchema = z.object({ body: z.string().trim().min(1), data: z.record(z.string(), z.string()).default({}), threadId: z.string().trim().min(1).optional(), + targetDeviceIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(), targetClientIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(), targetAppOrigins: z.array(z.string().trim().url().max(500)).max(50).optional(), targetAppDomains: z.array(z.string().trim().min(1).max(255)).max(50).optional(), @@ -81,8 +82,12 @@ type NotificationPreferenceTarget = { id: string; }; -function normalizeTargetClientIds(targetClientIds: string[] | undefined) { - return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))]; +function normalizeTargetDeviceIds(payload: { + targetDeviceIds?: string[]; + targetClientIds?: string[]; +}) { + const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds; + return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))]; } function normalizeRegistrationCleanupIds(...values: Array) { @@ -121,21 +126,18 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) { return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))]; } -function isAllowedTargetClientId( +function isAllowedTargetDeviceId( target: { deviceId?: string; - clientId?: string; }, - targetClientIds: string[], + targetDeviceIds: string[], ) { - if (targetClientIds.length === 0) { + if (targetDeviceIds.length === 0) { return true; } - return [target.deviceId, target.clientId] - .map((value) => String(value ?? '').trim()) - .filter(Boolean) - .some((value) => targetClientIds.includes(value)); + const deviceId = String(target.deviceId ?? '').trim(); + return Boolean(deviceId) && targetDeviceIds.includes(deviceId); } function normalizeAppOrigin(value: unknown) { @@ -914,7 +916,7 @@ async function isNotificationRecipientAllowed( export async function sendIosNotifications(payload: IosNotificationPayload) { const env = getEnv(); const provider = await getProvider(); - const targetClientIds = normalizeTargetClientIds(payload.targetClientIds); + const targetDeviceIds = normalizeTargetDeviceIds(payload); const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins); const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains); @@ -950,7 +952,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) { .filter( (row) => row.allowed && - isAllowedTargetClientId({ deviceId: row.deviceId }, targetClientIds) && + isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) && isAllowedAppTarget(row, targetAppOrigins, targetAppDomains), ) .map((row) => row.token); @@ -999,7 +1001,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) { async function sendWebPushNotifications(payload: IosNotificationPayload) { const env = getEnv(); - const targetClientIds = normalizeTargetClientIds(payload.targetClientIds); + const targetDeviceIds = normalizeTargetDeviceIds(payload); const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins); const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains); if (!ensureWebPushConfigured(env)) { @@ -1029,7 +1031,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) { ).filter( (row) => row.allowed && - isAllowedTargetClientId(row, targetClientIds) && + isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) && isAllowedAppTarget(row, targetAppOrigins, targetAppDomains), ); const matchedSubscriptions = subscriptions.map((row) => ({ diff --git a/etc/servers/work-server/src/services/server-command-service.test.ts b/etc/servers/work-server/src/services/server-command-service.test.ts index 0b09046..878adfd 100644 --- a/etc/servers/work-server/src/services/server-command-service.test.ts +++ b/etc/servers/work-server/src/services/server-command-service.test.ts @@ -103,12 +103,17 @@ test('test, release and prod restart scripts fall back to Docker socket when doc /docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$TARGET_SERVICE"/, ); assert.match(workServerScript, /RUNTIME_ENDPOINT="\$\{WORK_SERVER_RUNTIME_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\}"/); + assert.match( + workServerScript, + /RECOVERY_ENDPOINT="\$\{WORK_SERVER_RECOVERY_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\/recover-interrupted-chat\}"/, + ); assert.match(workServerScript, /set_container_draining "\$PREVIOUS_CONTAINER" true/); assert.match(workServerScript, /wait_for_previous_slot_drain "\$PREVIOUS_CONTAINER"/); assert.match( workServerScript, /docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$PREVIOUS_SERVICE"/, ); + assert.match(workServerScript, /recover_interrupted_chat_requests "\$TARGET_CONTAINER"/); assert.match(workServerScript, /docker exec "\$PROXY_CONTAINER" nginx -s reload/); assert.match(workServerScript, /work-server zero-downtime switch completed/); assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/); @@ -132,6 +137,22 @@ test('test restart script pulls the configured remote main branch before restart assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); }); +test('test deploy script commits the main worktree before pushing and restarting the preview server', () => { + const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); + const deployScript = fs.readFileSync(new URL('deploy-test.sh', commandsRoot), 'utf8'); + + assert.match(deployScript, /TEST_BUILD_COMMAND="\$\{TEST_BUILD_COMMAND:-npm run build:test-app\}"/); + assert.match(deployScript, /TEST_DEPLOY_COMMIT_MESSAGE="\$\{TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot\}"/); + assert.match(deployScript, /echo "::step::commit-main-worktree"/); + assert.match(deployScript, /git add -A -- \./); + assert.match(deployScript, /git commit -m "\$TEST_DEPLOY_COMMIT_MESSAGE"/); + assert.match(deployScript, /echo "::step::push-origin-main"/); + assert.match(deployScript, /git push "\$TEST_DEPLOY_GIT_REMOTE" "\$TEST_DEPLOY_GIT_BRANCH"/); + assert.match(deployScript, /TEST_SERVER_RESTART_SCRIPT="\$\{TEST_SERVER_RESTART_SCRIPT:-\$SCRIPT_DIR\/restart-test\.sh\}"/); + assert.doesNotMatch(deployScript, /restart-work-server\.sh/); + assert.match(deployScript, /REPO_ROOT="\$REPO_ROOT" sh "\$TEST_SERVER_RESTART_SCRIPT"/); +}); + 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 { @@ -416,3 +437,45 @@ test('listServerCommands ignores work-server test-only source changes when compu await rm(tempRoot, { recursive: true, force: true }); } }); + +test('listServerCommands keeps work-server updateAvailable false when only a standby rebuild is newer', async () => { + const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT; + const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT; + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-standby-build-')); + + try { + const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server'); + await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8'); + await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8'); + await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true }); + await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true }); + await mkdir(path.join(workServerRoot, 'dist'), { recursive: true }); + await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8'); + await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8'); + await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8'); + await writeFile( + path.join(workServerRoot, 'dist', 'build-info.json'), + JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-26T16:10:05.960Z', builtAt: '2026-05-26T16:10:05.960Z' }), + 'utf8', + ); + + const sourceDate = new Date('2026-05-26T16:06:46.162Z'); + await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), sourceDate, sourceDate); + await fs.promises.utimes(path.join(workServerRoot, 'package.json'), sourceDate, sourceDate); + await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), sourceDate, sourceDate); + + env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot; + env.SERVER_COMMAND_PROJECT_ROOT = tempRoot; + + const commands = await listServerCommands(); + const workServerCommand = commands.find((item) => item.key === 'work-server'); + + assert.ok(workServerCommand); + assert.equal(workServerCommand.buildRequired, false); + assert.equal(workServerCommand.updateAvailable, false); + } finally { + env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot; + env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot; + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts index 8e7e476..adde632 100644 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -1,11 +1,16 @@ import { execFile, spawn } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; -import { readFile, rm, stat } from 'node:fs/promises'; +import { mkdir, open, readFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { env } from '../config/env.js'; import { resolveMainProjectRoot } from './main-project-root-service.js'; +import { + readTestServerDeploymentState, + startTestServerDeployment, + type TestServerDeploymentSnapshot, +} from './test-server-deployment-service.js'; import { getRuntimeWorkServerBuildInfo, readLatestWorkServerBuildInfo, @@ -66,12 +71,21 @@ export type ServerCommandSnapshot = { commandScript: string; commandWorkingDirectory: string; errorMessage: string | null; + deployment: WorkServerDeploymentSnapshot | null; }; export type ServerCommandRestartResult = { server: ServerCommandSnapshot; commandOutput: string | null; restartState: 'completed' | 'accepted'; + deployment?: WorkServerDeploymentSnapshot | null; + testDeployment?: TestServerDeploymentSnapshot | null; +}; + +type ServerCommandScriptExecutionOptions = { + commandScript?: string; + environment?: Record; + timeoutMs?: number; }; type ExecFileFailure = Error & { @@ -119,6 +133,56 @@ type BuildInspectionResult = { type WorkServerSlot = 'blue' | 'green'; +export type WorkServerDeploymentStepKey = + | 'build-target-slot' + | 'verify-target-health' + | 'switch-proxy' + | 'drain-previous-slot' + | 'rebuild-previous-slot' + | 'recover-interrupted-chat'; + +export type WorkServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed'; + +export type WorkServerDeploymentStepSnapshot = { + key: WorkServerDeploymentStepKey; + status: WorkServerDeploymentStepStatus; + detail: string | null; + updatedAt: string | null; +}; + +export type WorkServerDeploymentPhase = + | 'idle' + | 'build-target-slot' + | 'verify-target-health' + | 'switch-proxy' + | 'drain-previous-slot' + | 'rebuild-previous-slot' + | 'recover-interrupted-chat' + | 'completed' + | 'failed'; + +export type WorkServerDeploymentSnapshot = { + status: 'idle' | 'running' | 'completed' | 'failed'; + phase: WorkServerDeploymentPhase; + summary: string | null; + startedAt: string | null; + updatedAt: string | null; + completedAt: string | null; + activeSlot: WorkServerSlot | null; + targetSlot: WorkServerSlot | null; + previousSlot: WorkServerSlot | null; + targetContainer: string | null; + previousContainer: string | null; + previousSlotActiveChatRequestCount: number | null; + previousSlotQueuedChatRequestCount: number | null; + recoveredSessionCount: number | null; + recoveredRestartedCount: number | null; + recoveredRequeuedCount: number | null; + lastError: string | null; + logExcerpt: string | null; + steps: WorkServerDeploymentStepSnapshot[]; +}; + const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000; const DEFERRED_RESTART_DELAY_MS = 2_000; const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500; @@ -141,6 +205,35 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [ const APP_BUILD_STAMP_RELATIVE_PATH = '.server-command-test-app-built-at'; const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const; const APP_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const; +const WORK_SERVER_RESTART_LOCK_STALE_MS = 20 * 60 * 1000; + +type WorkServerRestartLockPayload = { + startedAt: string; + key: ServerCommandKey; + pid: number; +}; + +type WorkServerDeploymentStateFilePayload = { + status?: unknown; + phase?: unknown; + summary?: unknown; + startedAt?: unknown; + updatedAt?: unknown; + completedAt?: unknown; + activeSlot?: unknown; + targetSlot?: unknown; + previousSlot?: unknown; + targetContainer?: unknown; + previousContainer?: unknown; + previousSlotActiveChatRequestCount?: unknown; + previousSlotQueuedChatRequestCount?: unknown; + recoveredSessionCount?: unknown; + recoveredRestartedCount?: unknown; + recoveredRequeuedCount?: unknown; + lastError?: unknown; + logExcerpt?: unknown; + steps?: unknown; +}; export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) { const allowLocal = options?.allowLocal ?? false; @@ -642,8 +735,8 @@ function getServerDefinitions(): ServerDefinition[] { return [ { key: 'test', - label: 'TEST', - summary: '메인 프로젝트의 테스트 앱 컨테이너', + label: 'PREVIEW', + summary: 'preview.sm-home.cloud 테스트 앱 컨테이너', environment: 'test', publicUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL), checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_CHECK_URL || env.SERVER_COMMAND_TEST_URL), @@ -751,6 +844,25 @@ function getServerDefinition(key: ServerCommandKey) { return definition; } +async function executeServerCommandScript( + definition: ServerDefinition, + options: ServerCommandScriptExecutionOptions = {}, +) { + const commandScript = options.commandScript ?? definition.commandScript; + const timeoutMs = options.timeoutMs ?? 30000; + + return execFileAsync('sh', [commandScript], { + cwd: definition.commandWorkingDirectory, + timeout: timeoutMs, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + ...definition.commandEnvironment, + ...options.environment, + }, + }); +} + function trimPreview(value: string | null | undefined, maxLength = 220) { const normalized = value?.replace(/\s+/g, ' ').trim() ?? ''; @@ -772,6 +884,209 @@ function normalizeDateTimeValue(value: string | null | undefined) { return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } +function getWorkServerRestartLockPath() { + return path.join(resolveMainProjectRoot(), "etc", "servers", "work-server", ".docker", "runtime", "restart-in-progress.json"); +} + +function getWorkServerDeploymentStatePath() { + return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'deployment-state.json'); +} + +const WORK_SERVER_DEPLOYMENT_STEP_KEYS: WorkServerDeploymentStepKey[] = [ + 'build-target-slot', + 'verify-target-health', + 'switch-proxy', + 'drain-previous-slot', + 'rebuild-previous-slot', + 'recover-interrupted-chat', +]; + +function normalizeWorkServerDeploymentStepKey(value: unknown): WorkServerDeploymentStepKey | null { + return WORK_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as WorkServerDeploymentStepKey) + ? (value as WorkServerDeploymentStepKey) + : null; +} + +function normalizeWorkServerSlotValue(value: unknown): WorkServerSlot | null { + return value === 'blue' || value === 'green' ? value : null; +} + +function normalizeWorkServerDeploymentPhase(value: unknown): WorkServerDeploymentPhase { + return value === 'build-target-slot' + || value === 'verify-target-health' + || value === 'switch-proxy' + || value === 'drain-previous-slot' + || value === 'rebuild-previous-slot' + || value === 'recover-interrupted-chat' + || value === 'completed' + || value === 'failed' + ? value + : 'idle'; +} + +function normalizeWorkServerDeploymentStatus(value: unknown): WorkServerDeploymentSnapshot['status'] { + return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle'; +} + +function normalizeNumberOrNull(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function buildEmptyWorkServerDeploymentSnapshot(): WorkServerDeploymentSnapshot { + return { + status: 'idle', + phase: 'idle', + summary: null, + startedAt: null, + updatedAt: null, + completedAt: null, + activeSlot: null, + targetSlot: null, + previousSlot: null, + targetContainer: null, + previousContainer: null, + previousSlotActiveChatRequestCount: null, + previousSlotQueuedChatRequestCount: null, + recoveredSessionCount: null, + recoveredRestartedCount: null, + recoveredRequeuedCount: null, + lastError: null, + logExcerpt: null, + steps: WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({ + key, + status: 'pending', + detail: null, + updatedAt: null, + })), + }; +} + +function normalizeWorkServerDeploymentSteps(value: unknown) { + const fallback = buildEmptyWorkServerDeploymentSnapshot().steps; + + if (!Array.isArray(value)) { + return fallback; + } + + const normalizedByKey = new Map(); + + value.forEach((item) => { + if (!item || typeof item !== 'object') { + return; + } + + const candidate = item as Record; + const key = normalizeWorkServerDeploymentStepKey(candidate.key); + + if (!key) { + return; + } + + const status = + candidate.status === 'running' + || candidate.status === 'completed' + || candidate.status === 'failed' + || candidate.status === 'pending' + ? candidate.status + : 'pending'; + + normalizedByKey.set(key, { + key, + status, + detail: typeof candidate.detail === 'string' ? candidate.detail : null, + updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), + }); + }); + + return WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!); +} + +function normalizeWorkServerDeploymentSnapshot(value: unknown): WorkServerDeploymentSnapshot { + if (!value || typeof value !== 'object') { + return buildEmptyWorkServerDeploymentSnapshot(); + } + + const candidate = value as WorkServerDeploymentStateFilePayload; + + return { + status: normalizeWorkServerDeploymentStatus(candidate.status), + phase: normalizeWorkServerDeploymentPhase(candidate.phase), + summary: typeof candidate.summary === 'string' ? candidate.summary : null, + startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null), + updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), + completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null), + activeSlot: normalizeWorkServerSlotValue(candidate.activeSlot), + targetSlot: normalizeWorkServerSlotValue(candidate.targetSlot), + previousSlot: normalizeWorkServerSlotValue(candidate.previousSlot), + targetContainer: typeof candidate.targetContainer === 'string' ? candidate.targetContainer : null, + previousContainer: typeof candidate.previousContainer === 'string' ? candidate.previousContainer : null, + previousSlotActiveChatRequestCount: normalizeNumberOrNull(candidate.previousSlotActiveChatRequestCount), + previousSlotQueuedChatRequestCount: normalizeNumberOrNull(candidate.previousSlotQueuedChatRequestCount), + recoveredSessionCount: normalizeNumberOrNull(candidate.recoveredSessionCount), + recoveredRestartedCount: normalizeNumberOrNull(candidate.recoveredRestartedCount), + recoveredRequeuedCount: normalizeNumberOrNull(candidate.recoveredRequeuedCount), + lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null, + logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null, + steps: normalizeWorkServerDeploymentSteps(candidate.steps), + }; +} + +export async function readWorkServerDeploymentState(): Promise { + try { + const raw = await readFile(getWorkServerDeploymentStatePath(), 'utf8'); + return normalizeWorkServerDeploymentSnapshot(JSON.parse(raw)); + } catch { + return null; + } +} + +async function acquireWorkServerRestartLock() { + const lockPath = getWorkServerRestartLockPath(); + await mkdir(path.dirname(lockPath), { recursive: true }); + const startedAt = new Date().toISOString(); + + try { + const handle = await open(lockPath, "wx"); + + try { + await handle.writeFile(JSON.stringify({ startedAt, key: "work-server", pid: process.pid }) + "\n", "utf8"); + } finally { + await handle.close(); + } + + return lockPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + + let existingStartedAt: string | null = null; + + try { + const raw = await readFile(lockPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === "string" ? parsed.startedAt : null); + const lockStat = await stat(lockPath).catch(() => null); + const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null); + + if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) { + await rm(lockPath, { force: true }).catch(() => undefined); + return acquireWorkServerRestartLock(); + } + } catch { + // ignore read failures and keep conflict response below + } + + const conflictError = new Error( + existingStartedAt + ? "WORK-SERVER 무중단 재기동이 이미 진행 중입니다. 시작 시각 " + existingStartedAt + : "WORK-SERVER 무중단 재기동이 이미 진행 중입니다.", + ); + (conflictError as Error & { statusCode?: number }).statusCode = 409; + throw conflictError; + } +} + function buildRestartCommandPreview(definition: ServerDefinition) { return `sh ${definition.commandScript}`; } @@ -817,6 +1132,7 @@ function buildAcceptedRestartSnapshot(definition: ServerDefinition): ServerComma commandScript: definition.commandScript, commandWorkingDirectory: definition.commandWorkingDirectory, errorMessage: null, + deployment: definition.key === 'work-server' ? buildEmptyWorkServerDeploymentSnapshot() : null, }; } @@ -969,6 +1285,7 @@ async function waitForDeferredRestartResult( async function restartServerCommandDeferred(definition: ServerDefinition): Promise { const { logPath, statusPath } = buildDeferredRestartProbePaths(definition); + const workServerLockPath = definition.key === "work-server" ? await acquireWorkServerRestartLock() : null; const shellCommand = [ `sleep ${Math.ceil(DEFERRED_RESTART_DELAY_MS / 1000)}`, `sh ${JSON.stringify(definition.commandScript)} >${JSON.stringify(logPath)} 2>&1`, @@ -976,7 +1293,8 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi `printf '%s' \"$status\" >${JSON.stringify(statusPath)}`, ].join('; '); - await new Promise((resolve, reject) => { + try { + await new Promise((resolve, reject) => { const child = spawn('sh', ['-c', shellCommand], { cwd: definition.commandWorkingDirectory, detached: true, @@ -984,21 +1302,30 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi env: { ...process.env, ...definition.commandEnvironment, + ...(workServerLockPath ? { WORK_SERVER_RESTART_LOCK_FILE: workServerLockPath } : {}), }, }); child.once('error', reject); - child.once('spawn', () => { - child.unref(); - resolve(); + child.once('spawn', () => { + child.unref(); + resolve(); + }); }); - }); + } catch (error) { + if (workServerLockPath) { + await rm(workServerLockPath, { force: true }).catch(() => undefined); + } + + throw error; + } if (definition.deferredResponseMode === 'accept-immediately') { return { server: buildAcceptedRestartSnapshot(definition), commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`, restartState: 'accepted', + deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null, }; } @@ -1008,6 +1335,7 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi server: buildAcceptedRestartSnapshot(definition), commandOutput: commandOutput ?? `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`, restartState: 'accepted', + deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null, }; } @@ -1463,7 +1791,12 @@ async function inspectBuild(definition: ServerDefinition): Promise latestBuild.builtAt : false; const updateAvailable = - Boolean(runningBuild?.buildId) && Boolean(latestBuild?.buildId) && runningBuild?.buildId !== latestBuild?.buildId; + !buildRequired && + Boolean(runningBuild?.builtAt) && + Boolean(latestBuild?.builtAt) && + Boolean(latestSourceChangedAt) && + runningBuild!.builtAt < latestBuild!.builtAt && + runningBuild!.builtAt < latestSourceChangedAt!; return { runningVersion: runningBuild?.buildId ?? null, @@ -1519,6 +1852,7 @@ async function checkServer(definition: ServerDefinition): Promise attempt.errorMessage) @@ -1557,12 +1891,26 @@ async function checkServer(definition: ServerDefinition): Promise { + return restartServerCommand('work-server'); +} + +export async function deployTestServerCommand(): Promise { + const testDefinition = getServerDefinition('test'); + const testDeployment = await startTestServerDeployment(); + const server = await checkServer(testDefinition); + + return { + server, + commandOutput: 'TEST 배포를 시작했습니다. origin/main 푸시, 테스트 빌드, 테스트 배포 과정을 확인합니다.', + restartState: 'accepted', + testDeployment: testDeployment ?? (await readTestServerDeploymentState()), }; } diff --git a/etc/servers/work-server/src/services/server-restart-reservation-service.ts b/etc/servers/work-server/src/services/server-restart-reservation-service.ts index 3776361..89dbac0 100644 --- a/etc/servers/work-server/src/services/server-restart-reservation-service.ts +++ b/etc/servers/work-server/src/services/server-restart-reservation-service.ts @@ -577,7 +577,11 @@ async function requestCommandRunner(requestPath: string, init?: RequestInit) { throw lastError ?? new Error('command-runner에 연결하지 못했습니다.'); } -function buildWaitingReason(summary: RestartReservationWorkloadSummary) { +function buildWaitingReason(target: RestartReservationTarget, summary: RestartReservationWorkloadSummary) { + if (target === 'work-server') { + return null; + } + const reasons: string[] = []; const codexPending = summary.codexRunningCount + summary.codexQueuedCount; @@ -1232,7 +1236,9 @@ export async function scheduleServerRestartReservation(options?: { requested_at: db.fn.now(), requested_by_client_id: options?.clientId?.trim() || null, last_checked_at: null, - waiting_reason: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.', + waiting_reason: target === 'work-server' + ? 'WORK 서버 무중단 재기동 가능 여부를 확인하는 중입니다.' + : '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.', workload_summary_json: getDefaultWorkloadSummary(), started_at: null, completed_at: null, @@ -1367,7 +1373,7 @@ export class ServerRestartReservationWorker { } const workloadSummary = await getRestartReservationWorkloadSummary(); - const waitingReason = buildWaitingReason(workloadSummary); + const waitingReason = buildWaitingReason(normalizeReservationTarget(row.target), workloadSummary); if (!waitingReason && row.status === 'ready' && isReservationAutoExecuteDue(row)) { await confirmServerRestartReservation(this.logger); diff --git a/etc/servers/work-server/src/services/shared-resource-token-service.ts b/etc/servers/work-server/src/services/shared-resource-token-service.ts index de17d8e..1d276dd 100644 --- a/etc/servers/work-server/src/services/shared-resource-token-service.ts +++ b/etc/servers/work-server/src/services/shared-resource-token-service.ts @@ -8,6 +8,7 @@ import { listChatConversationRequests, } from './chat-room-service.js'; import { getTokenSettingById, getTokenSettingsConfig, type TokenSettingRecord } from './token-setting-config-service.js'; +import type { RequestAuditContext } from '../utils/request-audit.js'; const SHARED_RESOURCE_TOKENS_TABLE = 'shared_resource_tokens'; const SHARED_RESOURCE_TOKEN_ACTIVITIES_TABLE = 'shared_resource_token_activities'; @@ -130,6 +131,15 @@ export type SharedResourceTokenActivityRecord = { summary: string; detail: string | null; usageDelta: number; + clientIp: string | null; + externalIp: string | null; + forwardedFor: string | null; + realIp: string | null; + host: string | null; + origin: string | null; + referer: string | null; + userAgent: string | null; + clientId: string | null; createdAt: string; }; @@ -831,29 +841,36 @@ async function attachLinkedTokenSettings(tokens: SharedResourceTokenRecord[]) { }); } -async function attachRequestUsageSummaries(tokens: SharedResourceTokenRecord[]) { +async function attachRequestUsageSummaries( + tokens: SharedResourceTokenRecord[], + options?: { + includeFallback?: boolean; + }, +) { if (tokens.length === 0) { return tokens; } const summaries = await listChatConversationRequestUsageBySharedResourceTokenIds(tokens.map((token) => token.id)); const summaryByTokenId = new Map(summaries.map((summary) => [summary.sharedResourceTokenId, summary] as const)); - const unresolvedTokens = tokens.filter((token) => !summaryByTokenId.has(token.id)); - const fallbackSummaries = await Promise.all(unresolvedTokens.map((token) => resolveChatShareFallbackUsageSummary(token))); + if (options?.includeFallback !== false) { + const unresolvedTokens = tokens.filter((token) => !summaryByTokenId.has(token.id)); + const fallbackSummaries = await Promise.all(unresolvedTokens.map((token) => resolveChatShareFallbackUsageSummary(token))); - fallbackSummaries.forEach((summary) => { - if (!summary) { - return; - } + fallbackSummaries.forEach((summary) => { + if (!summary) { + return; + } - summaryByTokenId.set(summary.tokenId, { - sharedResourceTokenId: summary.tokenId, - requestCount: summary.usageRequestCount, - completedRequestCount: summary.usageCompletedRequestCount, - totalTokens: summary.usageTokenTotal, - lastUsedAt: summary.lastTokenUsedAt, + summaryByTokenId.set(summary.tokenId, { + sharedResourceTokenId: summary.tokenId, + requestCount: summary.usageRequestCount, + completedRequestCount: summary.usageCompletedRequestCount, + totalTokens: summary.usageTokenTotal, + lastUsedAt: summary.lastTokenUsedAt, + }); }); - }); + } return tokens.map((token) => { const summary = summaryByTokenId.get(token.id); @@ -954,6 +971,15 @@ function mapActivityRow(row: Record): SharedResourceTokenActivi summary: normalizeText(row.summary), detail: normalizeOptionalText(row.detail), usageDelta: normalizePositiveInteger(row.usage_delta, 0, 0, 1_000_000), + clientIp: normalizeOptionalText(row.client_ip), + externalIp: normalizeOptionalText(row.external_ip), + forwardedFor: normalizeOptionalText(row.forwarded_for), + realIp: normalizeOptionalText(row.real_ip), + host: normalizeOptionalText(row.host), + origin: normalizeOptionalText(row.origin), + referer: normalizeOptionalText(row.referer), + userAgent: normalizeOptionalText(row.user_agent), + clientId: normalizeOptionalText(row.client_id), createdAt: normalizeDateTime(row.created_at) ?? new Date().toISOString(), }; } @@ -1110,6 +1136,15 @@ async function ensureSharedResourceTokenTables() { table.string('summary', 400).notNullable(); table.text('detail').nullable(); table.integer('usage_delta').notNullable().defaultTo(0); + table.string('client_ip', 120).nullable(); + table.string('external_ip', 120).nullable(); + table.text('forwarded_for').nullable(); + table.string('real_ip', 120).nullable(); + table.string('host', 255).nullable(); + table.text('origin').nullable(); + table.text('referer').nullable(); + table.text('user_agent').nullable(); + table.string('client_id', 255).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); return; @@ -1122,6 +1157,15 @@ async function ensureSharedResourceTokenTables() { ['summary', (table) => table.string('summary', 400).notNullable().defaultTo('')], ['detail', (table) => table.text('detail').nullable()], ['usage_delta', (table) => table.integer('usage_delta').notNullable().defaultTo(0)], + ['client_ip', (table) => table.string('client_ip', 120).nullable()], + ['external_ip', (table) => table.string('external_ip', 120).nullable()], + ['forwarded_for', (table) => table.text('forwarded_for').nullable()], + ['real_ip', (table) => table.string('real_ip', 120).nullable()], + ['host', (table) => table.string('host', 255).nullable()], + ['origin', (table) => table.text('origin').nullable()], + ['referer', (table) => table.text('referer').nullable()], + ['user_agent', (table) => table.text('user_agent').nullable()], + ['client_id', (table) => table.string('client_id', 255).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ]; @@ -1243,7 +1287,11 @@ async function persistSharedResourceAccessPinSession( }); } -async function appendActivity(trx: any, tokenId: string, input: SharedResourceActivityInput) { +async function appendActivity( + trx: any, + tokenId: string, + input: SharedResourceActivityInput & { audit?: RequestAuditContext | null }, +) { const payload = sharedResourceActivityInputSchema.parse(input); await trx(SHARED_RESOURCE_TOKEN_ACTIVITIES_TABLE).insert({ @@ -1253,6 +1301,15 @@ async function appendActivity(trx: any, tokenId: string, input: SharedResourceAc summary: payload.summary, detail: payload.detail ?? null, usage_delta: payload.usageDelta ?? 0, + client_ip: input.audit?.clientIp ?? null, + external_ip: input.audit?.externalIp ?? null, + forwarded_for: input.audit?.forwardedFor ?? null, + real_ip: input.audit?.realIp ?? null, + host: input.audit?.host ?? null, + origin: input.audit?.origin ?? null, + referer: input.audit?.referer ?? null, + user_agent: input.audit?.userAgent ?? null, + client_id: input.audit?.clientId ?? null, created_at: db.fn.now(), }); @@ -1302,7 +1359,7 @@ export async function listSharedResourceTokens() { .map((row) => mapTokenRow(row as Record)) .filter((item): item is SharedResourceTokenRecord => Boolean(item)); - return attachRequestUsageSummaries(await attachLinkedTokenSettings(tokens)); + return attachRequestUsageSummaries(await attachLinkedTokenSettings(tokens), { includeFallback: false }); } async function getSharedResourceTokenDetailInternal(tokenId: string, options?: { includeDeleted?: boolean }) { @@ -1370,6 +1427,38 @@ export async function getSharedResourceTokenDetailBySharePath(sharePath: string) }; } +export async function getSharedResourceTokenDetailByShareToken(shareToken: string) { + await ensureSharedResourceTokenTables(); + + const normalizedShareToken = normalizeText(shareToken); + + if (!normalizedShareToken) { + return null; + } + + const row = await db(SHARED_RESOURCE_TOKENS_TABLE) + .where({ share_token: normalizedShareToken }) + .whereNull('deleted_at') + .first(); + + if (!row) { + return null; + } + + const token = mapTokenRow(row as Record); + + if (!token) { + return null; + } + + const [linkedToken] = await attachRequestUsageSummaries(await attachLinkedTokenSettings([token])); + + return { + token: linkedToken, + activities: await listActivitiesByTokenId(token.id), + }; +} + export async function validateSharedResourceAccessPinBySharePath( sharePath: string, providedPin?: string | null, @@ -1468,7 +1557,10 @@ export async function validateSharedResourceAccessPinBySharePath( } as const; } -export async function upsertSharedResourceToken(input: SharedResourceTokenInput) { +export async function upsertSharedResourceToken( + input: SharedResourceTokenInput, + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +) { await ensureSharedResourceTokenTables(); const parsed = sharedResourceTokenSchema.parse(input); @@ -1640,9 +1732,10 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput) await appendActivity(trx, nextRecord.id, { type: existing ? 'updated' : 'created', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: existing ? '공유 리소스 토큰을 수정했습니다.' : '공유 리소스 토큰을 생성했습니다.', detail: nextRecord.resourceLabel, + audit: options?.audit, }); const previousPermissions = new Set(existing?.token.permissions ?? []); @@ -1651,18 +1744,20 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput) for (const permission of nextRecord.permissions.filter((item) => !previousPermissions.has(item))) { await appendActivity(trx, nextRecord.id, { type: 'permission-granted', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: `${permission} 권한을 추가했습니다.`, detail: nextRecord.resourceLabel, + audit: options?.audit, }); } for (const permission of (existing?.token.permissions ?? []).filter((item) => !nextPermissions.has(item))) { await appendActivity(trx, nextRecord.id, { type: 'permission-revoked', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: `${permission} 권한을 회수했습니다.`, detail: nextRecord.resourceLabel, + audit: options?.audit, }); } @@ -1672,18 +1767,20 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput) for (const appId of nextRecord.allowedAppIds.filter((item) => !previousAllowedApps.has(item))) { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: `${appId} 앱 권한을 추가했습니다.`, detail: nextRecord.resourceLabel, + audit: options?.audit, }); } for (const appId of (existing?.token.allowedAppIds ?? []).filter((item) => !nextAllowedApps.has(item))) { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: `${appId} 앱 권한을 제거했습니다.`, detail: nextRecord.resourceLabel, + audit: options?.audit, }); } @@ -1695,56 +1792,62 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput) if (!previousHasAccessPin && nextHasAccessPin) { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '공유 접근 비밀번호를 설정했습니다.', detail: nextRecord.resourceLabel, + audit: options?.audit, }); } if (previousHasAccessPin && !nextHasAccessPin) { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '공유 접근 비밀번호를 해제했습니다.', detail: nextRecord.resourceLabel, + audit: options?.audit, }); } if (previousHasAccessPin && nextHasAccessPin && typeof parsed.accessPin === 'string') { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '공유 접근 비밀번호를 변경했습니다.', detail: nextRecord.resourceLabel, + audit: options?.audit, }); } if (previousAccessPinPromptTtlMinutes !== nextRecord.accessPinPromptTtlMinutes) { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: nextRecord.accessPinPromptTtlMinutes ? `공유 비밀번호 재입력 유지시간을 ${nextRecord.accessPinPromptTtlMinutes}분으로 변경했습니다.` : '공유 비밀번호 재입력 방식을 매번 묻기로 변경했습니다.', detail: nextRecord.resourceLabel, + audit: options?.audit, }); } if (!previousAllowAccessPinChangeWithoutManage && nextRecord.allowAccessPinChangeWithoutManage) { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '관리 권한 없이 비밀번호 변경 가능한 모드를 켰습니다.', detail: nextRecord.resourceLabel, + audit: options?.audit, }); } if (previousAllowAccessPinChangeWithoutManage && !nextRecord.allowAccessPinChangeWithoutManage) { await appendActivity(trx, nextRecord.id, { type: 'updated', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '관리 권한 없이 비밀번호 변경 가능한 모드를 껐습니다.', detail: nextRecord.resourceLabel, + audit: options?.audit, }); } }); @@ -1772,7 +1875,11 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput) return getSharedResourceTokenDetail(savedRecord.id); } -export async function revokeSharedResourceToken(tokenId: string, reason?: string | null) { +export async function revokeSharedResourceToken( + tokenId: string, + reason?: string | null, + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +) { await ensureSharedResourceTokenTables(); const normalizedTokenId = normalizeText(tokenId); @@ -1793,16 +1900,21 @@ export async function revokeSharedResourceToken(tokenId: string, reason?: string await appendActivity(trx, normalizedTokenId, { type: 'revoked', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '공유 토큰을 회수했습니다.', detail: normalizeText(reason) || existing.token.resourceLabel, + audit: options?.audit, }); }); return getSharedResourceTokenDetail(normalizedTokenId); } -export async function revokeSharedResourceTokens(tokenIds: string[], reason?: string | null): Promise { +export async function revokeSharedResourceTokens( + tokenIds: string[], + reason?: string | null, + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +): Promise { await ensureSharedResourceTokenTables(); const requestedTokenIds = Array.from(new Set(tokenIds.map((tokenId) => normalizeText(tokenId)).filter(Boolean))); @@ -1853,9 +1965,10 @@ export async function revokeSharedResourceTokens(tokenIds: string[], reason?: st await appendActivity(trx, tokenId, { type: 'revoked', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '공유 토큰을 일괄 회수했습니다.', detail: normalizeText(reason) || existing.resourceLabel, + audit: options?.audit, }); processedTokenIds.push(tokenId); @@ -1870,7 +1983,10 @@ export async function revokeSharedResourceTokens(tokenIds: string[], reason?: st }; } -export async function restoreSharedResourceToken(tokenId: string) { +export async function restoreSharedResourceToken( + tokenId: string, + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +) { await ensureSharedResourceTokenTables(); const normalizedTokenId = normalizeText(tokenId); @@ -1891,16 +2007,20 @@ export async function restoreSharedResourceToken(tokenId: string) { await appendActivity(trx, normalizedTokenId, { type: 'restored', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '공유 토큰을 다시 활성화했습니다.', detail: existing.token.resourceLabel, + audit: options?.audit, }); }); return getSharedResourceTokenDetail(normalizedTokenId); } -export async function deleteSharedResourceToken(tokenId: string) { +export async function deleteSharedResourceToken( + tokenId: string, + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +) { await ensureSharedResourceTokenTables(); const normalizedTokenId = normalizeText(tokenId); @@ -1927,16 +2047,20 @@ export async function deleteSharedResourceToken(tokenId: string) { await appendActivity(trx, normalizedTokenId, { type: 'deleted', - actorLabel: 'manager', + actorLabel: options?.actorLabel ?? 'manager', summary: '공유 토큰을 삭제 목록에서 숨기고 사용 이력을 보존했습니다.', detail: existing.token.resourceLabel, + audit: options?.audit, }); }); return true; } -export async function deleteSharedResourceTokens(tokenIds: string[]): Promise { +export async function deleteSharedResourceTokens( + tokenIds: string[], + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +): Promise { await ensureSharedResourceTokenTables(); const requestedTokenIds = Array.from(new Set(tokenIds.map((tokenId) => normalizeText(tokenId)).filter(Boolean))); @@ -1981,9 +2105,10 @@ export async function deleteSharedResourceTokens(tokenIds: string[]): Promise maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; +} + +function getTestServerDeploymentStatePath() { + return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-state.json'); +} + +function getTestServerDeploymentLockPath() { + return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-in-progress.json'); +} + +function buildEmptyTestServerDeploymentSnapshot(): TestServerDeploymentSnapshot { + return { + status: 'idle', + phase: 'idle', + summary: null, + startedAt: null, + updatedAt: null, + completedAt: null, + lastError: null, + logExcerpt: null, + steps: TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({ + key, + status: 'pending', + detail: null, + updatedAt: null, + })), + }; +} + +function normalizeTestServerDeploymentStepKey(value: unknown): TestServerDeploymentStepKey | null { + return TEST_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as TestServerDeploymentStepKey) + ? (value as TestServerDeploymentStepKey) + : null; +} + +function normalizeTestServerDeploymentPhase(value: unknown): TestServerDeploymentPhase { + return value === 'commit-main-worktree' + || value === 'push-origin-main' + || value === 'build-test-app' + || value === 'deploy-test-server' + || value === 'completed' + || value === 'failed' + ? value + : 'idle'; +} + +function normalizeTestServerDeploymentStatus(value: unknown): TestServerDeploymentSnapshot['status'] { + return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle'; +} + +function normalizeTestServerDeploymentSteps(value: unknown) { + const fallback = buildEmptyTestServerDeploymentSnapshot().steps; + + if (!Array.isArray(value)) { + return fallback; + } + + const normalizedByKey = new Map(); + + value.forEach((item) => { + if (!item || typeof item !== 'object') { + return; + } + + const candidate = item as Record; + const key = normalizeTestServerDeploymentStepKey(candidate.key); + + if (!key) { + return; + } + + normalizedByKey.set(key, { + key, + status: + candidate.status === 'running' + || candidate.status === 'completed' + || candidate.status === 'failed' + || candidate.status === 'pending' + ? candidate.status + : 'pending', + detail: typeof candidate.detail === 'string' ? candidate.detail : null, + updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), + }); + }); + + return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!); +} + +function normalizeTestServerDeploymentSnapshot(value: unknown): TestServerDeploymentSnapshot { + if (!value || typeof value !== 'object') { + return buildEmptyTestServerDeploymentSnapshot(); + } + + const candidate = value as TestServerDeploymentStateFilePayload; + + return { + status: normalizeTestServerDeploymentStatus(candidate.status), + phase: normalizeTestServerDeploymentPhase(candidate.phase), + summary: typeof candidate.summary === 'string' ? candidate.summary : null, + startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null), + updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null), + completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null), + lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null, + logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null, + steps: normalizeTestServerDeploymentSteps(candidate.steps), + }; +} + +export async function readTestServerDeploymentState(): Promise { + try { + const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8'); + return normalizeTestServerDeploymentSnapshot(JSON.parse(raw)); + } catch { + return null; + } +} + +async function writeTestServerDeploymentState(snapshot: TestServerDeploymentSnapshot) { + const statePath = getTestServerDeploymentStatePath(); + await mkdir(path.dirname(statePath), { recursive: true }); + await writeFile(statePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8'); +} + +async function clearTestServerDeploymentState() { + await rm(getTestServerDeploymentStatePath(), { force: true }).catch(() => undefined); +} + +async function acquireTestServerDeploymentLock() { + const lockPath = getTestServerDeploymentLockPath(); + await mkdir(path.dirname(lockPath), { recursive: true }); + const startedAt = new Date().toISOString(); + + try { + const handle = await open(lockPath, 'wx'); + + try { + await handle.writeFile(JSON.stringify({ startedAt, key: 'test', pid: process.pid } satisfies RestartLockPayload) + '\n', 'utf8'); + } finally { + await handle.close(); + } + + return lockPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + + let existingStartedAt: string | null = null; + + try { + const raw = await readFile(lockPath, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === 'string' ? parsed.startedAt : null); + const lockStat = await stat(lockPath).catch(() => null); + const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null); + + if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) { + await rm(lockPath, { force: true }).catch(() => undefined); + return acquireTestServerDeploymentLock(); + } + } catch { + // ignore read failures and keep conflict response below + } + + const conflictError = new Error( + existingStartedAt + ? `TEST 배포가 이미 진행 중입니다. 시작 시각 ${existingStartedAt}` + : 'TEST 배포가 이미 진행 중입니다.', + ); + (conflictError as Error & { statusCode?: number }).statusCode = 409; + throw conflictError; + } +} + +function buildTestServerDeploymentSummary(phase: TestServerDeploymentPhase) { + switch (phase) { + case 'commit-main-worktree': + return 'main 작업트리 커밋 진행 중'; + case 'push-origin-main': + return 'origin/main 푸시 진행 중'; + case 'build-test-app': + return '테스트 앱 빌드 진행 중'; + case 'deploy-test-server': + return '테스트 서버 배포 진행 중'; + case 'completed': + return 'origin/main 푸시, 테스트 빌드, 테스트 배포가 완료되었습니다.'; + case 'failed': + return 'TEST 배포에 실패했습니다.'; + default: + return '테스트 배포 준비 중'; + } +} + +function buildTestDeploymentFailureMessage( + snapshot: Pick, + error: unknown, +) { + const failure = error instanceof Error ? (error as Error & { code?: number | string; signal?: string | null }) : null; + const exitInfo = [ + failure?.code != null ? `exit:${String(failure.code)}` : null, + failure?.signal ? `signal:${String(failure.signal)}` : null, + ].filter(Boolean).join(' '); + const logLines = (snapshot.logExcerpt ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const lastMeaningfulLog = logLines.length > 0 ? logLines[logLines.length - 1] : null; + + return trimPreview([ + lastMeaningfulLog && lastMeaningfulLog !== failure?.message ? lastMeaningfulLog : null, + failure?.message || null, + exitInfo || null, + ].filter(Boolean).join(' | '), 500) ?? 'TEST 배포에 실패했습니다.'; +} + +function appendTestServerDeploymentLog(previous: string | null, chunk: string) { + const normalizedChunk = chunk.trim(); + + if (!normalizedChunk) { + return previous; + } + + const combined = [previous, normalizedChunk].filter(Boolean).join('\n'); + return combined.length > TEST_SERVER_DEPLOYMENT_LOG_LIMIT + ? combined.slice(combined.length - TEST_SERVER_DEPLOYMENT_LOG_LIMIT) + : combined; +} + +function updateTestServerDeploymentStep( + snapshot: TestServerDeploymentSnapshot, + key: TestServerDeploymentStepKey, + status: TestServerDeploymentStepStatus, + detail?: string | null, +) { + const now = new Date().toISOString(); + snapshot.steps = snapshot.steps.map((step) => { + if (step.key !== key) { + return step; + } + + return { + ...step, + status, + detail: detail === undefined ? step.detail : detail, + updatedAt: now, + }; + }); + snapshot.updatedAt = now; +} + +function markPreviousRunningStepCompleted(snapshot: TestServerDeploymentSnapshot, nextKey: TestServerDeploymentStepKey) { + const previousRunning = snapshot.steps.find((step) => step.status === 'running' && step.key !== nextKey); + if (previousRunning) { + updateTestServerDeploymentStep(snapshot, previousRunning.key, 'completed'); + } +} + +function scheduleTestServerDeploymentCleanup(completedAt: string) { + const timer = setTimeout(() => { + void (async () => { + const snapshot = await readTestServerDeploymentState(); + if (snapshot?.status === 'completed' && snapshot.completedAt === completedAt) { + await clearTestServerDeploymentState(); + } + })(); + }, TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS); + + if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') { + timer.unref(); + } +} + +async function runTestServerDeployment( + lockPath: string, + snapshot: TestServerDeploymentSnapshot, + persist: () => Promise, +) { + const mainProjectRoot = resolveMainProjectRoot(); + const deployScript = path.join(mainProjectRoot, 'etc', 'commands', 'server-command', 'deploy-test.sh'); + + const moveToStep = (key: TestServerDeploymentStepKey) => { + markPreviousRunningStepCompleted(snapshot, key); + snapshot.phase = key; + snapshot.summary = buildTestServerDeploymentSummary(key); + updateTestServerDeploymentStep(snapshot, key, 'running'); + void persist(); + }; + + const appendOutput = (line: string) => { + snapshot.logExcerpt = appendTestServerDeploymentLog(snapshot.logExcerpt, line); + snapshot.updatedAt = new Date().toISOString(); + void persist(); + }; + + const fail = async (message: string) => { + const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key ?? 'commit-main-worktree'; + snapshot.status = 'failed'; + snapshot.phase = 'failed'; + snapshot.summary = buildTestServerDeploymentSummary('failed'); + snapshot.lastError = message; + snapshot.updatedAt = new Date().toISOString(); + updateTestServerDeploymentStep(snapshot, activeStep, 'failed', message); + await persist(); + }; + + try { + await new Promise((resolve, reject) => { + const child = spawn('sh', [deployScript], { + cwd: mainProjectRoot, + env: { + ...process.env, + MAIN_PROJECT_ROOT: mainProjectRoot, + REPO_ROOT: mainProjectRoot, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdoutBuffer = ''; + let stderrBuffer = ''; + + const processLine = (line: string) => { + const trimmed = line.trim(); + const marker = trimmed.match(/^::step::([a-z-]+)$/); + + if (marker) { + const nextStep = normalizeTestServerDeploymentStepKey(marker[1]); + if (nextStep) { + moveToStep(nextStep); + } + return; + } + + appendOutput(line); + }; + + const flushBufferedLines = (buffer: string) => { + const normalized = buffer.replace(/\r$/, '').trim(); + if (normalized) { + processLine(normalized); + } + }; + + const attachReader = (stream: NodeJS.ReadableStream | null, target: 'stdout' | 'stderr') => { + if (!stream) { + return; + } + + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + if (target === 'stdout') { + stdoutBuffer += chunk; + while (stdoutBuffer.includes('\n')) { + const index = stdoutBuffer.indexOf('\n'); + const line = stdoutBuffer.slice(0, index).replace(/\r$/, ''); + stdoutBuffer = stdoutBuffer.slice(index + 1); + processLine(line); + } + return; + } + + stderrBuffer += chunk; + while (stderrBuffer.includes('\n')) { + const index = stderrBuffer.indexOf('\n'); + const line = stderrBuffer.slice(0, index).replace(/\r$/, ''); + stderrBuffer = stderrBuffer.slice(index + 1); + processLine(line); + } + }); + }; + + attachReader(child.stdout, 'stdout'); + attachReader(child.stderr, 'stderr'); + + child.once('error', reject); + child.once('close', (code, signal) => { + flushBufferedLines(stdoutBuffer); + flushBufferedLines(stderrBuffer); + + if (code === 0) { + resolve(); + return; + } + + reject(Object.assign(new Error(`deploy-test exited with ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`), { + code, + signal, + })); + }); + }); + + const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key; + if (activeStep) { + updateTestServerDeploymentStep(snapshot, activeStep, 'completed'); + } + const completedAt = new Date().toISOString(); + snapshot.status = 'completed'; + snapshot.phase = 'completed'; + snapshot.summary = buildTestServerDeploymentSummary('completed'); + snapshot.completedAt = completedAt; + snapshot.updatedAt = completedAt; + snapshot.lastError = null; + await persist(); + scheduleTestServerDeploymentCleanup(completedAt); + } catch (error) { + const message = buildTestDeploymentFailureMessage(snapshot, error); + await fail(message); + } finally { + await rm(lockPath, { force: true }).catch(() => undefined); + } +} + +export async function startTestServerDeployment() { + const lockPath = await acquireTestServerDeploymentLock(); + const startedAt = new Date().toISOString(); + const snapshot = buildEmptyTestServerDeploymentSnapshot(); + snapshot.status = 'running'; + snapshot.phase = 'commit-main-worktree'; + snapshot.summary = buildTestServerDeploymentSummary('commit-main-worktree'); + snapshot.startedAt = startedAt; + snapshot.updatedAt = startedAt; + updateTestServerDeploymentStep(snapshot, 'commit-main-worktree', 'running', 'main 작업트리 변경을 커밋합니다.'); + await writeTestServerDeploymentState(snapshot); + + let persistQueue = Promise.resolve(); + const persist = async () => { + persistQueue = persistQueue.then(() => writeTestServerDeploymentState(snapshot)).catch(() => undefined); + await persistQueue; + }; + + void runTestServerDeployment(lockPath, snapshot, persist); + return snapshot; +} diff --git a/etc/servers/work-server/src/services/token-setting-activity-service.ts b/etc/servers/work-server/src/services/token-setting-activity-service.ts new file mode 100644 index 0000000..ea6a16f --- /dev/null +++ b/etc/servers/work-server/src/services/token-setting-activity-service.ts @@ -0,0 +1,154 @@ +import { db } from '../db/client.js'; +import type { RequestAuditContext } from '../utils/request-audit.js'; + +const TOKEN_SETTING_ACTIVITIES_TABLE = 'token_setting_activities'; + +export type TokenSettingActivityRecord = { + id: number; + settingId: string; + activityType: 'created' | 'updated' | 'deleted'; + actorLabel: string | null; + summary: string; + detail: string | null; + clientIp: string | null; + externalIp: string | null; + forwardedFor: string | null; + realIp: string | null; + host: string | null; + origin: string | null; + referer: string | null; + userAgent: string | null; + clientId: string | null; + createdAt: string; +}; + +export type TokenSettingActivityInput = { + settingId: string; + activityType: TokenSettingActivityRecord['activityType']; + actorLabel?: string | null; + summary: string; + detail?: string | null; + audit?: RequestAuditContext | null; +}; + +function normalizeText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeOptionalText(value: unknown) { + const normalized = normalizeText(value); + return normalized || null; +} + +function normalizeDateTime(value: unknown) { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + const timestamp = Date.parse(normalized); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null; +} + +export async function ensureTokenSettingActivityTable() { + const hasTable = await db.schema.hasTable(TOKEN_SETTING_ACTIVITIES_TABLE); + + if (!hasTable) { + await db.schema.createTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => { + table.increments('id').primary(); + table.string('setting_id', 120).notNullable().index(); + table.string('activity_type', 40).notNullable(); + table.string('actor_label', 120).nullable(); + table.string('summary', 400).notNullable(); + table.text('detail').nullable(); + table.string('client_ip', 120).nullable(); + table.string('external_ip', 120).nullable(); + table.text('forwarded_for').nullable(); + table.string('real_ip', 120).nullable(); + table.string('host', 255).nullable(); + table.text('origin').nullable(); + table.text('referer').nullable(); + table.text('user_agent').nullable(); + table.string('client_id', 255).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['setting_id', (table) => table.string('setting_id', 120).notNullable().defaultTo('').index()], + ['activity_type', (table) => table.string('activity_type', 40).notNullable().defaultTo('updated')], + ['actor_label', (table) => table.string('actor_label', 120).nullable()], + ['summary', (table) => table.string('summary', 400).notNullable().defaultTo('')], + ['detail', (table) => table.text('detail').nullable()], + ['client_ip', (table) => table.string('client_ip', 120).nullable()], + ['external_ip', (table) => table.string('external_ip', 120).nullable()], + ['forwarded_for', (table) => table.text('forwarded_for').nullable()], + ['real_ip', (table) => table.string('real_ip', 120).nullable()], + ['host', (table) => table.string('host', 255).nullable()], + ['origin', (table) => table.text('origin').nullable()], + ['referer', (table) => table.text('referer').nullable()], + ['user_agent', (table) => table.text('user_agent').nullable()], + ['client_id', (table) => table.string('client_id', 255).nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(TOKEN_SETTING_ACTIVITIES_TABLE, columnName); + if (!hasColumn) { + await db.schema.alterTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function appendTokenSettingActivity(input: TokenSettingActivityInput) { + await ensureTokenSettingActivityTable(); + + await db(TOKEN_SETTING_ACTIVITIES_TABLE).insert({ + setting_id: normalizeText(input.settingId), + activity_type: input.activityType, + actor_label: normalizeOptionalText(input.actorLabel), + summary: normalizeText(input.summary), + detail: normalizeOptionalText(input.detail), + client_ip: normalizeOptionalText(input.audit?.clientIp), + external_ip: normalizeOptionalText(input.audit?.externalIp), + forwarded_for: normalizeOptionalText(input.audit?.forwardedFor), + real_ip: normalizeOptionalText(input.audit?.realIp), + host: normalizeOptionalText(input.audit?.host), + origin: normalizeOptionalText(input.audit?.origin), + referer: normalizeOptionalText(input.audit?.referer), + user_agent: normalizeOptionalText(input.audit?.userAgent), + client_id: normalizeOptionalText(input.audit?.clientId), + created_at: db.fn.now(), + }); +} + +export async function listTokenSettingActivities(settingId: string) { + await ensureTokenSettingActivityTable(); + + const rows = await db(TOKEN_SETTING_ACTIVITIES_TABLE) + .select('*') + .where({ setting_id: normalizeText(settingId) }) + .orderBy('created_at', 'desc') + .limit(200); + + return rows.map((row) => ({ + id: Number(row.id), + settingId: normalizeText(row.setting_id), + activityType: (normalizeText(row.activity_type) as TokenSettingActivityRecord['activityType']) || 'updated', + actorLabel: normalizeOptionalText(row.actor_label), + summary: normalizeText(row.summary), + detail: normalizeOptionalText(row.detail), + clientIp: normalizeOptionalText(row.client_ip), + externalIp: normalizeOptionalText(row.external_ip), + forwardedFor: normalizeOptionalText(row.forwarded_for), + realIp: normalizeOptionalText(row.real_ip), + host: normalizeOptionalText(row.host), + origin: normalizeOptionalText(row.origin), + referer: normalizeOptionalText(row.referer), + userAgent: normalizeOptionalText(row.user_agent), + clientId: normalizeOptionalText(row.client_id), + createdAt: normalizeDateTime(row.created_at) ?? new Date().toISOString(), + })); +} diff --git a/etc/servers/work-server/src/services/token-setting-config-service.ts b/etc/servers/work-server/src/services/token-setting-config-service.ts index 442563b..4853258 100644 --- a/etc/servers/work-server/src/services/token-setting-config-service.ts +++ b/etc/servers/work-server/src/services/token-setting-config-service.ts @@ -1,4 +1,6 @@ import { db } from '../db/client.js'; +import { appendTokenSettingActivity, ensureTokenSettingActivityTable } from './token-setting-activity-service.js'; +import type { RequestAuditContext } from '../utils/request-audit.js'; const TOKEN_SETTINGS_TABLE = 'token_settings'; const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER; @@ -173,6 +175,7 @@ async function ensureTokenSettingsTable() { table.boolean('enabled').notNullable().defaultTo(true); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); + await ensureTokenSettingActivityTable(); return; } @@ -200,6 +203,8 @@ async function ensureTokenSettingsTable() { }); } } + + await ensureTokenSettingActivityTable(); } function parseAllowedAppIds(row: Record) { @@ -289,10 +294,45 @@ async function readTokenSettingsFromTable() { ); } -async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) { +function buildActivityDetail(previous: TokenSettingRecord | null, next: TokenSettingRecord | null) { + if (!previous && next) { + return `앱 ${next.allowedAppIds.join(', ') || '-'} / 기본 ${next.defaultExpiresInMinutes}분`; + } + + if (previous && !next) { + return `삭제 전 앱 ${previous.allowedAppIds.join(', ') || '-'} / 기본 ${previous.defaultExpiresInMinutes}분`; + } + + if (!previous || !next) { + return null; + } + + const changedFields: string[] = []; + if (previous.name !== next.name) changedFields.push(`이름 ${previous.name} -> ${next.name}`); + if (previous.description !== next.description) changedFields.push('설명'); + if (previous.defaultExpiresInMinutes !== next.defaultExpiresInMinutes) changedFields.push(`기본만료 ${previous.defaultExpiresInMinutes} -> ${next.defaultExpiresInMinutes}`); + if (previous.maxTokensPer30Days !== next.maxTokensPer30Days) changedFields.push(`30일 ${previous.maxTokensPer30Days} -> ${next.maxTokensPer30Days}`); + if (previous.maxTokensPer7Days !== next.maxTokensPer7Days) changedFields.push(`7일 ${previous.maxTokensPer7Days} -> ${next.maxTokensPer7Days}`); + if (previous.maxTokensPer5Hours !== next.maxTokensPer5Hours) changedFields.push(`5시간 ${previous.maxTokensPer5Hours} -> ${next.maxTokensPer5Hours}`); + if (previous.oneTimeTokenLimit !== next.oneTimeTokenLimit) changedFields.push(`1회 ${previous.oneTimeTokenLimit} -> ${next.oneTimeTokenLimit}`); + if (previous.enabled !== next.enabled) changedFields.push(`사용 ${previous.enabled} -> ${next.enabled}`); + if (JSON.stringify(previous.allowedAppIds) !== JSON.stringify(next.allowedAppIds)) { + changedFields.push(`앱 ${previous.allowedAppIds.join(', ') || '-'} -> ${next.allowedAppIds.join(', ') || '-'}`); + } + + return changedFields.join(' / ') || null; +} + +async function replaceTokenSettingsInTable( + items: TokenSettingRecord[], + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +) { await ensureTokenSettingsTable(); + const previousItems = await readTokenSettingsFromTable(); const nextItems = sanitizeTokenSettings(items); + const previousById = new Map(previousItems.map((item) => [item.id, item] as const)); + const nextById = new Map(nextItems.map((item) => [item.id, item] as const)); await db.transaction(async (trx) => { await trx(TOKEN_SETTINGS_TABLE).del(); @@ -318,6 +358,48 @@ async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) { } }); + const affectedIds = Array.from(new Set([...previousById.keys(), ...nextById.keys()])); + + for (const settingId of affectedIds) { + const previous = previousById.get(settingId) ?? null; + const next = nextById.get(settingId) ?? null; + + if (!previous && next) { + await appendTokenSettingActivity({ + settingId, + activityType: 'created', + actorLabel: options?.actorLabel ?? 'manager', + summary: '토큰 설정을 생성했습니다.', + detail: buildActivityDetail(previous, next), + audit: options?.audit, + }); + continue; + } + + if (previous && !next) { + await appendTokenSettingActivity({ + settingId, + activityType: 'deleted', + actorLabel: options?.actorLabel ?? 'manager', + summary: '토큰 설정을 삭제했습니다.', + detail: buildActivityDetail(previous, next), + audit: options?.audit, + }); + continue; + } + + if (previous && next && JSON.stringify(previous) !== JSON.stringify(next)) { + await appendTokenSettingActivity({ + settingId, + activityType: 'updated', + actorLabel: options?.actorLabel ?? 'manager', + summary: '토큰 설정을 수정했습니다.', + detail: buildActivityDetail(previous, next), + audit: options?.audit, + }); + } + } + return nextItems; } @@ -325,8 +407,11 @@ export async function getTokenSettingsConfig() { return readTokenSettingsFromTable(); } -export async function upsertTokenSettingsConfig(items: Partial[] | null | undefined) { - return replaceTokenSettingsInTable(sanitizeTokenSettings(items)); +export async function upsertTokenSettingsConfig( + items: Partial[] | null | undefined, + options?: { actorLabel?: string | null; audit?: RequestAuditContext | null }, +) { + return replaceTokenSettingsInTable(sanitizeTokenSettings(items), options); } export async function getTokenSettingById(id: string) { diff --git a/etc/servers/work-server/src/services/work-server-slot-service.ts b/etc/servers/work-server/src/services/work-server-slot-service.ts new file mode 100644 index 0000000..09983b1 --- /dev/null +++ b/etc/servers/work-server/src/services/work-server-slot-service.ts @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { env } from '../config/env.js'; + +type WorkServerSlot = 'blue' | 'green'; + +function normalizeSlot(value: string | null | undefined): WorkServerSlot | null { + const normalized = String(value ?? '').trim().toLowerCase(); + return normalized === 'blue' || normalized === 'green' ? normalized : null; +} + +function buildActiveSlotFileCandidates() { + const candidates = [ + env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim(), + path.join(env.SERVER_COMMAND_MAIN_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'), + path.join(env.SERVER_COMMAND_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'), + path.join(env.SERVER_COMMAND_PROJECT_ROOT, '.docker', 'runtime', 'active-slot'), + ] + .map((value) => String(value ?? '').trim()) + .filter(Boolean); + + return [...new Set(candidates)]; +} + +async function readActiveSlotFromFile() { + for (const candidate of buildActiveSlotFileCandidates()) { + try { + const value = await readFile(candidate, 'utf8'); + const slot = normalizeSlot(value); + + if (slot) { + return slot; + } + } catch { + // Ignore missing or unreadable candidates and continue. + } + } + + return null; +} + +export async function isCurrentWorkServerSlotActive() { + const currentSlot = normalizeSlot(process.env.WORK_SERVER_SLOT); + + if (!currentSlot) { + return true; + } + + const activeSlot = (await readActiveSlotFromFile()) ?? 'blue'; + return currentSlot === activeSlot; +} diff --git a/etc/servers/work-server/src/utils/request-audit.ts b/etc/servers/work-server/src/utils/request-audit.ts new file mode 100644 index 0000000..cabe5a3 --- /dev/null +++ b/etc/servers/work-server/src/utils/request-audit.ts @@ -0,0 +1,105 @@ +import type { FastifyRequest } from 'fastify'; + +export type RequestAuditContext = { + clientIp: string | null; + externalIp: string | null; + forwardedFor: string | null; + realIp: string | null; + host: string | null; + origin: string | null; + referer: string | null; + userAgent: string | null; + clientId: string | null; +}; + +function normalizeText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeHeaderValue(value: unknown) { + if (Array.isArray(value)) { + return normalizeText(value[0]); + } + + return normalizeText(value); +} + +function splitForwardedFor(value: string) { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function stripIpDecorations(value: string) { + const normalized = value.replace(/^for=/iu, '').replace(/^"|"$/g, '').trim(); + if (normalized.startsWith('[') && normalized.includes(']')) { + return normalized.slice(1, normalized.indexOf(']')).trim(); + } + const ipv4PortMatch = normalized.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/u); + if (ipv4PortMatch) { + return ipv4PortMatch[1] ?? normalized; + } + return normalized; +} + +function isPrivateOrLocalIp(value: string) { + const normalized = stripIpDecorations(value).toLowerCase(); + if (!normalized) { + return true; + } + + if (normalized === '::1' || normalized === 'localhost') { + return true; + } + + if (normalized.startsWith('127.') || normalized.startsWith('10.') || normalized.startsWith('192.168.')) { + return true; + } + + if (/^172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) { + return true; + } + + if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:')) { + return true; + } + + if (normalized.startsWith('::ffff:127.') || normalized.startsWith('::ffff:10.') || normalized.startsWith('::ffff:192.168.')) { + return true; + } + + if (/^::ffff:172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) { + return true; + } + + return false; +} + +function resolveExternalIp(candidates: string[]) { + const cleaned = candidates.map((item) => stripIpDecorations(item)).filter(Boolean); + return cleaned.find((item) => !isPrivateOrLocalIp(item)) ?? cleaned[0] ?? null; +} + +export function extractRequestAuditContext(request: FastifyRequest): RequestAuditContext { + const forwardedFor = normalizeHeaderValue(request.headers['x-forwarded-for']); + const realIp = normalizeHeaderValue(request.headers['x-real-ip']) || normalizeHeaderValue(request.headers['cf-connecting-ip']); + const clientIp = normalizeText(request.ip) || normalizeText(request.raw.socket.remoteAddress) || null; + + return { + clientIp: clientIp ? stripIpDecorations(clientIp) : null, + externalIp: resolveExternalIp([ + ...splitForwardedFor(forwardedFor), + realIp, + normalizeText(request.headers['x-client-ip']), + clientIp ?? '', + ]), + forwardedFor: forwardedFor || null, + realIp: realIp ? stripIpDecorations(realIp) : null, + host: normalizeHeaderValue(request.headers.host) || null, + origin: normalizeHeaderValue(request.headers.origin) || null, + referer: normalizeHeaderValue(request.headers.referer) || null, + userAgent: normalizeHeaderValue(request.headers['user-agent']) || null, + clientId: normalizeHeaderValue(request.headers['x-client-id']) || null, + }; +} diff --git a/etc/servers/work-server/src/workers/baseball-ticket-bay-worker.ts b/etc/servers/work-server/src/workers/baseball-ticket-bay-worker.ts index be2a58c..37bc9e3 100644 --- a/etc/servers/work-server/src/workers/baseball-ticket-bay-worker.ts +++ b/etc/servers/work-server/src/workers/baseball-ticket-bay-worker.ts @@ -1,5 +1,6 @@ import type { FastifyBaseLogger } from 'fastify'; import { processDueBaseballTicketBayAlerts } from '../services/baseball-ticket-bay-service.js'; +import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js'; const DEFAULT_INTERVAL_MS = 60_000; @@ -38,6 +39,10 @@ export class BaseballTicketBayWorker { return; } + if (!(await isCurrentWorkServerSlotActive())) { + return; + } + this.running = true; try { diff --git a/index.html b/index.html index 803fd91..2a5c9a1 100755 --- a/index.html +++ b/index.html @@ -15,6 +15,116 @@
+ - + diff --git a/package.json b/package.json index 44c75fb..873755e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "plan:codex:once": "node scripts/run-plan-codex-once.mjs", "server-command:runner": "node scripts/run-server-command-runner.mjs", "build:app": "node scripts/prepare-app-dist.mjs && tsc -b && VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true vite build --outDir app-dist", + "build:preview-app": "node scripts/prepare-app-dist.mjs && VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir app-dist", "build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist", "build:lib": "tsc -p tsconfig.lib.json", "build": "npm run build:lib && npm run build:app", diff --git a/public/e-reader.webmanifest b/public/e-reader.webmanifest index 4ec9112..a8ec595 100644 --- a/public/e-reader.webmanifest +++ b/public/e-reader.webmanifest @@ -7,7 +7,7 @@ "background_color": "#eff7fb", "display": "standalone", "lang": "ko", - "scope": "/", + "scope": "/play/apps", "start_url": "/play/apps?app=e-reader", "icons": [ { diff --git a/public/resource/Codex Live/공유 리소스 관리/채팅 열기 Drawer/20260526/docs/feature-spec.md b/public/resource/Codex Live/공유 리소스 관리/채팅 열기 Drawer/20260526/docs/feature-spec.md new file mode 100644 index 0000000..0620b9f --- /dev/null +++ b/public/resource/Codex Live/공유 리소스 관리/채팅 열기 Drawer/20260526/docs/feature-spec.md @@ -0,0 +1,21 @@ +# 공유 리소스 관리 채팅 열기 Drawer + +## 기능 설명 +- 공유 리소스 관리 목록의 `열기` 버튼을 새 창 실행 대신 우측 `Drawer`로 열도록 변경했다. +- Drawer 폭은 `100vw`로 고정해 데스크톱과 모바일 모두 화면을 가득 채우는 슬라이드 오픈 형태로 맞췄다. +- Drawer 내부에는 공유 채팅 URL을 `iframe`으로 로드하고, 상단 액션에서 `새로고침`과 `새 창`을 추가했다. +- 후속 조정으로 Drawer 상단 헤더 패딩과 타이틀 높이를 조금 줄여 본문 영역을 더 넓게 확보했다. + +## 변경 범위 +- `src/app/main/SharedResourceManagementPage.tsx` +- `src/app/main/SharedResourceManagementPage.css` + +## 데이터/API 영향 +- 신규 API 호출이나 데이터 스키마 변경은 없다. +- 기존 공유 채팅 URL 계산 로직을 그대로 사용하고, 표시 방식만 인앱 Drawer로 변경했다. + +## 확인 포인트 +- 목록 `열기` 버튼 클릭 시 Drawer가 오른쪽에서 전체 폭으로 열린다. +- 상세 패널의 `채팅창 열기`도 동일 Drawer를 연다. +- Drawer 내부 `새 창` 버튼은 기존 외부 창 열기 동작을 유지한다. +- Drawer 상단 헤더가 이전보다 낮아지고 iframe 본문 높이가 함께 맞춰진다. diff --git a/public/resource/Codex Live/공유 리소스 관리/채팅 열기 Drawer/20260526/verification/verification-summary.md b/public/resource/Codex Live/공유 리소스 관리/채팅 열기 Drawer/20260526/verification/verification-summary.md new file mode 100644 index 0000000..f094f7a --- /dev/null +++ b/public/resource/Codex Live/공유 리소스 관리/채팅 열기 Drawer/20260526/verification/verification-summary.md @@ -0,0 +1,17 @@ +# 검증 요약 + +## 실행 결과 +- `npm run build:test-app` 성공 +- `npx tsc -b --pretty false` 실패 + +## 실패 사유 +- 전체 타입체크 실패는 이번 수정과 무관한 저장소 기존 오류들 때문에 발생했다. +- 대표적으로 `src/app/main/MainChatPanel.tsx`, `src/app/main/pages/ChatSharePage.tsx`, `src/features/layout/draw/LayoutDrawPage.tsx` 등 다수 파일에서 기존 타입 오류가 남아 있다. + +## 이번 변경 확인 범위 +- `SharedResourceManagementPage`가 프로덕션 번들 기준으로 정상 빌드되는지 확인했다. +- 전체 폭 Drawer와 iframe 렌더링은 정적 빌드 성공으로 문법/번들 기준 이상 없음을 확인했다. +- Drawer 헤더 축소 후 iframe 최소 높이 계산도 함께 조정해 레이아웃 공백이 생기지 않도록 확인했다. + +## 미실행 항목 +- 브라우저 실화면 캡처와 모바일 스크린샷은 이번 턴에서 생성하지 못했다. diff --git a/public/resource/Codex Live/공유채팅/채팅방 설정 정리/20260526/docs/feature-spec.md b/public/resource/Codex Live/공유채팅/채팅방 설정 정리/20260526/docs/feature-spec.md new file mode 100644 index 0000000..0ad6c12 --- /dev/null +++ b/public/resource/Codex Live/공유채팅/채팅방 설정 정리/20260526/docs/feature-spec.md @@ -0,0 +1,44 @@ +# 공유채팅 채팅방 설정 정리 + +## 변경 목표 +- 공유채팅방의 채팅방 설정 입력 항목이 많아도 부모 레이아웃이 흔들리지 않도록 전체폭 우측 Drawer 구조로 정리한다. +- 공유 링크 권한만으로도 채팅유형과 채팅 알림 수신 여부가 정상 저장/재조회되도록 맞춘다. +- 공통 문맥과 방 전용 문맥이 "상속"과 "방 전용 override"를 구분해 저장되도록 정리한다. + +## 변경 범위 +- `src/app/main/pages/ChatSharePage.tsx` + - 채팅방 설정 UI를 `Modal`에서 `Drawer` + `Tabs` 구조로 변경 + - 채팅유형, 공통 문맥, 방 전용 문맥, 채팅 알림, 보안 탭 분리 + - 공유 스냅샷의 `conversation.chatTypeId`, `lastChatTypeId`, `notifyOffline` 우선 사용 + - 공통 문맥 기본값 계산 시 빈 배열을 "없음"이 아니라 "채팅유형 기본값 상속"으로 처리 +- `src/app/main/pages/ChatSharePage.css` + - 전체폭 Drawer 및 탭/카드형 설정 레이아웃 스타일 추가 +- `src/app/main/mainChatPanel/chatUtils.ts` + - 공유 채팅방 설정 저장 API helper를 채팅유형/알림/비밀번호 통합 저장 형태로 확장 + - 공유 스냅샷 `conversation` 필드에 채팅유형/알림 메타데이터 파싱 추가 +- `etc/servers/work-server/src/routes/chat.ts` + - `/api/chat/shares/:token/room-settings`가 채팅유형/알림 수신까지 저장하도록 확장 + - `/api/chat/shares/:token` 응답에 채팅유형/알림 상태 포함 +- `etc/servers/work-server/src/services/chat-room-service.test.ts` + - 채팅방 컨텍스트 update field 계산 테스트 보강 + +## 저장/적용 기준 +- 채팅유형 + - 공유 스냅샷의 현재 `conversation.chatTypeId` 또는 `lastChatTypeId`를 우선 기준으로 사용한다. + - 저장 시 `chatTypeId`, `lastChatTypeId`, `contextLabel`을 함께 반영한다. +- 공통 문맥 + - 선택값이 비어 있고 방 전용 override가 없으면 채팅유형 기본 공통 문맥을 상속한다. + - 선택값이 채팅유형 기본값과 동일하면 room override를 별도로 저장하지 않는다. +- 방 전용 문맥 + - 제목/본문 중 하나라도 있으면 room context로 저장한다. + - 둘 다 비면 room context에서 제거한다. +- 채팅 알림 + - 공유 링크 현재 클라이언트 기준 `notifyOffline`을 저장한다. + - 실제 푸시는 브라우저 권한과 전체 앱 알림 사용 상태가 모두 허용된 경우에만 수신된다. + +## 확인 포인트 +- 전체폭 Drawer가 열려도 부모 화면 레이아웃이 흔들리지 않는지 +- 채팅유형을 바꾼 뒤 다시 설정을 열었을 때 방금 저장한 유형이 재표시되는지 +- 공통 문맥을 비워 두면 채팅유형 기본 문맥 상속으로 동작하는지 +- 방 전용 문맥 제목/본문 저장 후 다시 열었을 때 유지되는지 +- 채팅 알림 토글 상태가 저장 후 공유 스냅샷 응답에 반영되는지 diff --git a/public/resource/Codex Live/공유채팅/채팅방 설정 정리/20260526/verification/verification-summary.md b/public/resource/Codex Live/공유채팅/채팅방 설정 정리/20260526/verification/verification-summary.md new file mode 100644 index 0000000..4025328 --- /dev/null +++ b/public/resource/Codex Live/공유채팅/채팅방 설정 정리/20260526/verification/verification-summary.md @@ -0,0 +1,40 @@ +# 공유채팅 채팅방 설정 정리 검증 + +## 실행 검증 +- `npm run build:test-app` + - 결과: 성공 + - 목적: 프런트 번들 및 타입 레벨 오류 확인 +- `npm run build` + - 위치: `etc/servers/work-server` + - 결과: 성공 + - 목적: 공유 채팅방 설정 API 변경 후 서버 타입/빌드 확인 + +## 분기 검증 +- 채팅유형 저장 + - `conversation.chatTypeId` 우선 사용 + - `conversation.lastChatTypeId` fallback 사용 + - 요청 이력(`targetRequest.chatTypeId`, `requests[].chatTypeId`) fallback 유지 +- 공통 문맥 계산 + - room override 있음: override 사용 + - room override 비어 있음: 채팅유형 기본 공통 문맥 상속 + - 저장값이 채팅유형 기본값과 동일: room override 제거 +- 방 전용 문맥 + - 제목만 입력: 저장 대상 + - 본문만 입력: 저장 대상 + - 제목/본문 모두 비움: room context 제거 +- 채팅 알림 + - 공유 링크 클라이언트 기준 `notifyOffline=true`: 알림 수신 대상 + - `notifyOffline=false`: 현재 클라이언트 제외 + - clientId 없음: 글로벌 `notify_offline` 필드 업데이트 분기 유지 +- 비밀번호 + - 새로 켜기 + 입력 없음: 경고 + - 숫자 4자리 아님: 경고 + - 유지시간 변경만 있는 경우: 저장 + - 사용 안 함 전환: 기존 잠금 해제 + +## 테스트 메모 +- `node --import tsx --test src/services/chat-room-service.test.ts` 전체 파일은 저장소 기존 실패 케이스가 이미 포함되어 있어 전체 green 상태는 아님 +- 이번 변경과 직접 관련된 `buildChatConversationContextUpdateFields` 보강 케이스는 통과 확인 + +## 미실행 항목 +- 실제 `preview.sm-home.cloud` 브라우저 캡처와 모바일 스크린샷은 이번 턴에서 수행하지 못함 diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 0b8e4b5..9632072 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -138,6 +138,7 @@ import type { import { consumeCodexLiveDraft } from './codexLiveDraftBridge'; import { useChatActionContextSnapshot } from './chatActionContextStore'; import { getOrCreateClientId } from './clientIdentity'; +import { getSavedNotificationDeviceId } from './notificationIdentity'; import { requestChatWindowAction } from './chatWindowActions'; import { buildIsolatedChatRoomContextSupplement, @@ -1952,6 +1953,28 @@ function hasConversationAttentionResponseTarget(message: ChatMessage) { return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalizedText); } +function normalizePromptContextRef(value: ChatPromptContextRef | null | undefined): ChatPromptContextRef | null { + if (!value || value.key !== 'prompt_parent_question') { + return null; + } + + const promptTitle = String(value.promptTitle ?? '').trim(); + + if (!promptTitle) { + return null; + } + + const promptDescription = String(value.promptDescription ?? '').trim(); + const parentQuestionText = String(value.parentQuestionText ?? '').trim(); + + return { + key: 'prompt_parent_question', + promptTitle, + promptDescription: promptDescription || null, + parentQuestionText: parentQuestionText || null, + }; +} + function mergeConversationRequestPreservingContent( previousItem: ChatConversationRequest | null | undefined, nextItem: ChatConversationRequest, @@ -1970,15 +1993,22 @@ function mergeConversationRequestPreservingContent( chatTypeLabel: nextItem.chatTypeLabel?.trim() || previousItem.chatTypeLabel?.trim() || '', requestOrigin: nextItem.requestOrigin ?? previousItem.requestOrigin ?? null, parentRequestId: nextItem.parentRequestId?.trim() || previousItem.parentRequestId?.trim() || null, + promptContextRef: normalizePromptContextRef(nextItem.promptContextRef) ?? normalizePromptContextRef(previousItem.promptContextRef), statusMessage: nextStatusMessage, + retryCount: Math.max(nextItem.retryCount ?? 0, previousItem.retryCount ?? 0), userMessageId: nextItem.userMessageId ?? previousItem.userMessageId, userText: nextUserText, responseMessageId: nextItem.responseMessageId ?? previousItem.responseMessageId, responseText: nextResponseText, hasResponse: nextItem.hasResponse || previousItem.hasResponse || nextResponseText.length > 0, - manualPromptCompletedAt: nextItem.manualPromptCompletedAt ?? previousItem.manualPromptCompletedAt ?? null, + manualPromptCompletedAt: + nextItem.manualPromptCompletedAt !== undefined + ? nextItem.manualPromptCompletedAt + : previousItem.manualPromptCompletedAt ?? null, manualVerificationCompletedAt: - nextItem.manualVerificationCompletedAt ?? previousItem.manualVerificationCompletedAt ?? null, + nextItem.manualVerificationCompletedAt !== undefined + ? nextItem.manualVerificationCompletedAt + : previousItem.manualVerificationCompletedAt ?? null, answeredAt: nextItem.answeredAt ?? previousItem.answeredAt, terminalAt: nextItem.terminalAt ?? previousItem.terminalAt, }; @@ -2554,7 +2584,7 @@ function mergeConversationSummaryPreservingChatType( contextDescription: nextItem.contextDescription?.trim() || null, isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false, pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null, - hasPendingAttention: nextItem.hasPendingAttention === true || previousItem.hasPendingAttention === true, + hasPendingAttention: nextItem.hasPendingAttention === true, lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '', lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(), lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '', @@ -4429,6 +4459,7 @@ export function MainChatPanel({ Object.entries(notificationData).flatMap(([key, value]) => (value ? [[key, String(value)]] : [])), ); const currentClientId = getOrCreateClientId().trim(); + const notificationDeviceId = getSavedNotificationDeviceId().trim(); if (!requestOwnerClientId || !currentClientId || requestOwnerClientId !== currentClientId) { return; @@ -4451,7 +4482,8 @@ export function MainChatPanel({ body: notificationBody, threadId: `chat:${sessionId}`, data: serializedNotificationData, - targetClientIds: currentClientId ? [currentClientId] : undefined, + targetDeviceIds: notificationDeviceId ? [notificationDeviceId] : undefined, + targetAppOrigins: typeof window !== 'undefined' ? [window.location.origin] : undefined, }), ]).then(async ([storedResult, pushResult]) => { if (pushResult.status === 'rejected') { @@ -5150,7 +5182,6 @@ export function MainChatPanel({ activeView, isMobileViewport, previewItems, - selectedChatTypeId, composerRef, setActiveSystemStatus, setComposerAttachments, @@ -7361,6 +7392,129 @@ export function MainChatPanel({ [activeSessionId, handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage], ); + const resolvePromptContextRefForRequest = useCallback( + (requestId: string): ChatPromptContextRef | null => { + const normalizedRequestId = requestId.trim(); + + if (!normalizedRequestId) { + return null; + } + + const requestMap = new Map( + requestItemsRef.current + .filter((item) => item.sessionId === activeSessionId) + .map((item) => [item.requestId.trim(), item] as const), + ); + const visitedRequestIds = new Set(); + let currentRequestId = normalizedRequestId; + + while (currentRequestId && !visitedRequestIds.has(currentRequestId)) { + visitedRequestIds.add(currentRequestId); + const currentRequest = requestMap.get(currentRequestId); + + if (!currentRequest) { + break; + } + + const promptContextRef = normalizePromptContextRef(currentRequest.promptContextRef); + + if (promptContextRef) { + return promptContextRef; + } + + currentRequestId = currentRequest.parentRequestId?.trim() || ''; + } + + return null; + }, + [activeSessionId], + ); + + const sendReplyToRequest = useCallback( + ({ + text, + parentRequestId, + mode, + origin = 'composer', + }: { + text: string; + parentRequestId: string; + mode: 'queue' | 'direct'; + origin?: 'composer' | 'prompt'; + }): SendMessageResult => { + const trimmed = text.trim(); + const normalizedParentRequestId = parentRequestId.trim(); + const targetSessionId = activeSessionId.trim(); + + if (!trimmed || !normalizedParentRequestId) { + return 'blocked'; + } + + if (!effectiveChatType) { + setMessages((previous) => [ + ...previous.slice(-39), + createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 답글 요청을 전송하지 못했습니다.'), + ]); + return 'blocked'; + } + + if (!targetSessionId) { + setMessages((previous) => [ + ...previous.slice(-39), + createLocalMessage('활성 대화방이 없어서 답글 요청을 전송하지 못했습니다.'), + ]); + return 'blocked'; + } + + void executeSendMessageSafely({ + sessionId: targetSessionId, + mode, + text: trimmed, + origin, + parentRequestId: normalizedParentRequestId, + promptContextRef: resolvePromptContextRefForRequest(normalizedParentRequestId), + codexModel: effectiveCodexModel, + chatTypeId: effectiveChatType.id, + chatTypeLabel: effectiveChatType.name, + chatTypeDescription: effectiveChatTypeDescription, + chatTypeBaseDescription: effectiveChatType.description, + defaultContextIds: effectiveDefaultContextIds, + defaultContexts: effectiveDefaultContexts, + customContextTitle: effectiveRoomCustomContextTitle || null, + customContextContent: effectiveRoomCustomContextContent || null, + includedContextCount: 0, + omittedContextCount: 0, + }); + + return 'sent'; + }, + [ + activeSessionId, + createLocalMessage, + effectiveCodexModel, + effectiveDefaultContextIds, + effectiveDefaultContexts, + effectiveChatType, + effectiveChatTypeDescription, + effectiveRoomCustomContextContent, + effectiveRoomCustomContextTitle, + executeSendMessageSafely, + resolvePromptContextRefForRequest, + setMessages, + ], + ); + + const handleSendReplyToResponse = useCallback( + ({ draftText, parentRequestId }: { draftText?: string; parentRequestId: string }): SendMessageResult => + sendReplyToRequest({ + text: buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments), + parentRequestId, + mode: isImmediateSendPinned ? 'direct' : 'queue', + origin: 'composer', + }), + [buildOutgoingMessageText, composerAttachments, draftRef, isImmediateSendPinned, sendReplyToRequest], + ); + const handlePromoteQueuedRequest = useCallback( async (requestId: string, text: string) => { const normalizedRequestId = requestId.trim(); @@ -7370,6 +7524,11 @@ export function MainChatPanel({ return; } + const existingRequest = + requestItemsRef.current.find( + (item) => item.sessionId === activeSessionId && item.requestId === normalizedRequestId, + ) ?? null; + try { await removeChatRuntimeJob(normalizedRequestId); } catch (error) { @@ -7390,9 +7549,20 @@ export function MainChatPanel({ : item, ), ); + + if (existingRequest?.parentRequestId?.trim()) { + sendReplyToRequest({ + text: normalizedText, + parentRequestId: existingRequest.parentRequestId, + mode: 'direct', + origin: existingRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer', + }); + return; + } + handleComposerSendImmediate(normalizedText); }, - [activeSessionId, handleComposerSendImmediate, messageApi, setRequestItems], + [activeSessionId, handleComposerSendImmediate, messageApi, sendReplyToRequest, setRequestItems], ); const handleToggleImmediateSendPinned = useCallback(() => { @@ -7503,6 +7673,38 @@ export function MainChatPanel({ mode: resolvedMode, contextRef: contextRef ?? null, }); + const queuedRequestId = persistedSelection.queuedRequestId.trim(); + const submittedAt = new Date().toISOString(); + + if (queuedRequestId) { + upsertRequestItem({ + sessionId: targetSessionId, + requestId: queuedRequestId, + chatTypeId: effectiveChatType.id, + chatTypeLabel: effectiveChatType.name, + requestOrigin: 'prompt', + parentRequestId: parentRequestId.trim(), + promptContextRef: contextRef ?? null, + status: resolvedMode === 'queue' ? 'queued' : 'accepted', + statusMessage: resolvedMode === 'queue' ? 'prompt 후속 요청을 접수했습니다.' : 'prompt 후속 요청을 즉시 접수했습니다.', + userMessageId: null, + userText: trimmed, + responseMessageId: null, + responseText: '', + hasResponse: false, + canDelete: resolvedMode !== 'queue', + createdAt: submittedAt, + updatedAt: submittedAt, + answeredAt: null, + terminalAt: null, + }); + syncConversationPreviewForRequest(targetSessionId, trimmed, submittedAt, { + requestId: queuedRequestId, + mode: resolvedMode, + queueSize: resolvedMode === 'queue' ? 1 : 0, + jobMessage: resolvedMode === 'queue' ? 'prompt 후속 요청 대기 중' : 'prompt 후속 요청 실행 대기 중', + }); + } setRequestItems((previous) => previous.map((item) => @@ -7562,6 +7764,7 @@ export function MainChatPanel({ effectiveRoomCustomContextTitle, executeSendMessageSafely, messageApi, + resolvePromptContextRefForRequest, setMessages, setRequestItems, ], @@ -7569,61 +7772,16 @@ export function MainChatPanel({ const handleSubmitChildRequest = useCallback( async ({ text, parentRequestId }: { text: string; parentRequestId: string }) => { - const trimmed = text.trim(); - const normalizedParentRequestId = parentRequestId.trim(); - const targetSessionId = activeSessionId.trim(); - - if (!trimmed || !normalizedParentRequestId) { - return false; - } - - if (!effectiveChatType) { - setMessages((previous) => [ - ...previous.slice(-39), - createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 하위 즉시 요청을 전송하지 못했습니다.'), - ]); - return false; - } - - if (!targetSessionId) { - setMessages((previous) => [ - ...previous.slice(-39), - createLocalMessage('활성 대화방이 없어서 하위 즉시 요청을 전송하지 못했습니다.'), - ]); - return false; - } - - return executeSendMessageSafely({ - sessionId: targetSessionId, - mode: 'direct', - text: trimmed, - parentRequestId: normalizedParentRequestId, - codexModel: effectiveCodexModel, - chatTypeId: effectiveChatType.id, - chatTypeLabel: effectiveChatType.name, - chatTypeDescription: effectiveChatTypeDescription, - chatTypeBaseDescription: effectiveChatType.description, - defaultContextIds: effectiveDefaultContextIds, - defaultContexts: effectiveDefaultContexts, - customContextTitle: effectiveRoomCustomContextTitle || null, - customContextContent: effectiveRoomCustomContextContent || null, - includedContextCount: 0, - omittedContextCount: 0, - }); + return ( + sendReplyToRequest({ + text, + parentRequestId, + mode: 'direct', + origin: 'composer', + }) === 'sent' + ); }, - [ - activeSessionId, - createLocalMessage, - effectiveCodexModel, - effectiveDefaultContextIds, - effectiveDefaultContexts, - effectiveChatType, - effectiveChatTypeDescription, - effectiveRoomCustomContextContent, - effectiveRoomCustomContextTitle, - executeSendMessageSafely, - setMessages, - ], + [sendReplyToRequest], ); const handleCompleteManualRequestBadge = useCallback( @@ -7693,10 +7851,14 @@ export function MainChatPanel({ executeSendMessageSafely({ sessionId: activeSessionId, + requestId: request.requestId, mode: 'direct', text: normalizedText, origin: request.requestOrigin === 'prompt' ? 'prompt' : 'composer', - parentRequestId: request.requestOrigin === 'prompt' ? request.parentRequestId?.trim() || null : null, + parentRequestId: request.parentRequestId?.trim() || null, + promptContextRef: + normalizePromptContextRef(request.promptContextRef) + ?? (request.parentRequestId?.trim() ? resolvePromptContextRefForRequest(request.parentRequestId) : null), codexModel: effectiveCodexModel, chatTypeId: effectiveChatType.id, chatTypeLabel: effectiveChatType.name, @@ -7721,6 +7883,7 @@ export function MainChatPanel({ effectiveRoomCustomContextTitle, executeSendMessageSafely, messageApi, + resolvePromptContextRefForRequest, setMessages, ], ); @@ -8551,6 +8714,7 @@ export function MainChatPanel({ setSelectedCodexModel(normalizeCodexModel(nextCodexModel)); }} onSend={handleComposerSend} + onSendReplyToResponse={handleSendReplyToResponse} onSendImmediate={handleComposerSendImmediate} isSendWithoutContextEnabled={isSendWithoutContextEnabled} isImmediateSendPinned={isImmediateSendPinned} diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index 56f1eea..5d980c4 100644 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -68,7 +68,6 @@ import { ensureWebPushSubscriptionRegistered, syncExistingWebPushSubscriptionRegistration, } from './webPushRegistration'; -import { resetNonAuthClientState } from './appMaintenance'; import { ALLOWED_REGISTRATION_TOKEN, setRegisteredAccessToken, @@ -158,19 +157,15 @@ type RestartCompletionConfirmOptions = { title: string; targetLabel: string; action: RestartCompletionAction; - autoActionSeconds?: number; okText?: string; cancelText?: string; promptText?: string; - countdownText?: (remainingSeconds: number, actionLabel: string) => string; customAction?: () => Promise; onCancel?: () => void; onActionError?: (message: string) => void; }; const SERVER_RESTART_CACHE_BUST_PARAM = '__serverRestartedAt'; const RESERVED_RESTART_WORK_SERVER_DELAY_MS = 5_000; -const RESERVED_RESTART_VERIFICATION_INTERVAL_MS = 2_000; -const RESERVED_RESTART_VERIFICATION_TIMEOUT_MS = 90_000; const RESERVED_RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000; const HEADER_THEME_STORAGE_KEY = 'work-server.header-theme'; const DESKTOP_HEADER_HEIGHT_STORAGE_KEY = 'work-server.desktop-header-height'; @@ -1104,6 +1099,24 @@ function hasServerRuntimeChanged(previous: ServerCommandItem | null, next: Serve ); } +function getWorkServerRuntimeMarker(item: ServerCommandItem | null) { + if (!item) { + return ''; + } + + const runningVersion = item.runningVersion?.trim(); + if (runningVersion) { + return runningVersion; + } + + const runningBuiltAt = item.runningBuiltAt?.trim(); + if (runningBuiltAt) { + return runningBuiltAt; + } + + return item.startedAt?.trim() || ''; +} + function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) { if (!previous || !next) { return next ? !next.buildRequired && !next.updateAvailable : false; @@ -1137,7 +1150,18 @@ function hasServerRestartBeenVerified( } if (key === 'work-server') { - return Boolean(next.runningVersion ?? next.runningBuiltAt) && !next.buildRequired && !next.updateAvailable; + if (next.buildRequired || next.updateAvailable) { + return false; + } + + const previousRuntimeMarker = getWorkServerRuntimeMarker(previous); + const nextRuntimeMarker = getWorkServerRuntimeMarker(next); + + if (!nextRuntimeMarker) { + return false; + } + + return previousRuntimeMarker !== nextRuntimeMarker || hasServerUpdateStateImproved(previous, next); } return false; @@ -1174,7 +1198,7 @@ function hasReservedRestartServerBeenVerified( return Boolean(item.runningBuiltAt) && !item.buildRequired; } - return Boolean(item.runningVersion ?? item.runningBuiltAt) && !item.buildRequired && !item.updateAvailable; + return Boolean(getWorkServerRuntimeMarker(item)) && !item.buildRequired && !item.updateAvailable; } function shouldResetClientStateAfterRestart( @@ -1591,9 +1615,6 @@ export function MainHeader({ const [runtimeLogDetail, setRuntimeLogDetail] = useState(null); const [updateCheckFeedback, setUpdateCheckFeedback] = useState(null); const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState(null); - const [clientResetting, setClientResetting] = useState(false); - const [clientResetFeedback, setClientResetFeedback] = useState(null); - const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState(null); const [testServerStatus, setTestServerStatus] = useState(null); const [prodServerStatus, setProdServerStatus] = useState(null); const [workServerStatus, setWorkServerStatus] = useState(null); @@ -1612,7 +1633,6 @@ export function MainHeader({ const restartProgressAbortRef = useRef(null); const serverRestartReservationCompletedBootstrapRef = useRef(false); const handledServerRestartReservationCompletedAtRef = useRef(null); - const serverRestartReservationReloadTaskIdRef = useRef(0); const { registeredToken, hasAccess } = useTokenAccess(); const appConfig = useAppConfig(); useEffect(() => { @@ -1759,6 +1779,102 @@ export function MainHeader({ serverRestartReservationNowTimestamp, serverRestartReservationReloadPending, ); + const resolveServerManagementProgressLabel = (key: 'test' | 'work' | 'prod') => { + if ( + (key === 'test' && serverRestartingKey === 'test') + || (key === 'work' && serverRestartingKey === 'work-server') + || (key === 'prod' && serverRestartingKey === 'prod') + ) { + return restartProgress?.stepLabel ?? '재기동 진행 중'; + } + + if (key === 'prod') { + return null; + } + + const matchesReservation = key === 'work' + ? serverRestartReservation?.target === 'work-server' || serverRestartReservation?.target === 'all' + : serverRestartReservation?.target === 'test' || serverRestartReservation?.target === 'all'; + + if (!matchesReservation) { + return null; + } + + if (serverRestartReservation?.status === 'recovering') { + return serverRestartReservation.autoFix.summary?.trim() || 'Codex 자동 개선 중'; + } + + if (serverRestartReservation?.status === 'executing') { + if (serverRestartReservation.executionPhase === 'commit-main-worktree') { + return '재기동 실행 준비 중'; + } + + if (serverRestartReservation.executionPhase === 'verify-runtime') { + return '정상 기동 확인 중'; + } + + if (serverRestartReservation.executionPhase === 'restart-work-server') { + return 'WORK 재기동 진행 중'; + } + + if (serverRestartReservation.executionPhase === 'restart-test') { + return 'TEST 재기동 진행 중'; + } + + return '재기동 진행 중'; + } + + if (serverRestartReservation?.status === 'ready') { + return '자동 실행 대기 중'; + } + + if (serverRestartReservation?.status === 'waiting') { + return '재기동 대기 중'; + } + + if (serverRestartReservation?.status === 'completed') { + return '최근 재기동 완료'; + } + + if (serverRestartReservation?.status === 'failed') { + return '최근 재기동 실패'; + } + + if (serverRestartReservation?.status === 'cancelled') { + return '최근 재기동 취소'; + } + + return null; + }; + const serverManagementStatusItems = [ + { + key: 'test', + label: 'TEST', + item: testServerStatus, + checkedLabel: `확인 ${formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}`, + detailLabel: `소스 수정일 ${getServerLastSourceChangedDateLabel(testServerStatus)}`, + hint: getServerLastSourceChangedHint(testServerStatus), + progressLabel: resolveServerManagementProgressLabel('test'), + }, + { + key: 'work', + label: 'WORK', + item: workServerStatus, + checkedLabel: `확인 ${formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}`, + detailLabel: `소스 수정일 ${getServerLastSourceChangedDateLabel(workServerStatus)}`, + hint: getServerLastSourceChangedHint(workServerStatus), + progressLabel: resolveServerManagementProgressLabel('work'), + }, + { + key: 'prod', + label: 'PROD', + item: prodServerStatus, + checkedLabel: `확인 ${formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}`, + detailLabel: `빌드 시각 ${formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}`, + hint: null, + progressLabel: resolveServerManagementProgressLabel('prod'), + }, + ] as const; const renderTopMenuOptionLabel = ( menu: 'docs' | 'plans' | 'play', label: ReactNode, @@ -1861,8 +1977,11 @@ export function MainHeader({ setRuntimeLogLoading(false); } }; - const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey; - const canResetClientState = !clientResetting; + const canRefreshWorkServerStatus = + hasAccess && + !workServerStatusLoading && + !serverRestartReservationLoading && + !serverRestartingKey; const canRestartServers = hasAccess && !workServerStatusLoading && @@ -2126,8 +2245,7 @@ export function MainHeader({ return; } - void refreshUpdateTargets(true); - void refreshServerRestartReservation(true); + void refreshServerManagementStatus(true); }, [hasAccess]); useEffect(() => { @@ -2135,26 +2253,9 @@ export function MainHeader({ return; } - void refreshUpdateTargets(true); - void refreshServerRestartReservation(true); + void refreshServerManagementStatus(true); }, [activeSettingsModal, hasAccess, settingsModalOpen]); - useEffect(() => { - if (!hasAccess) { - return; - } - - void refreshServerRestartReservation(true); - - const timer = window.setInterval(() => { - void refreshServerRestartReservation(true); - }, serverRestartReservation?.enabled ? 2_000 : 5_000); - - return () => { - window.clearInterval(timer); - }; - }, [hasAccess, serverRestartReservation?.enabled]); - useEffect(() => { if (!serverRestartReservation) { return; @@ -2180,13 +2281,11 @@ export function MainHeader({ useEffect(() => { if (!serverRestartReservation) { setServerRestartReservationReloadPending(false); - serverRestartReservationReloadTaskIdRef.current += 1; return; } if (serverRestartReservation.status !== 'completed') { setServerRestartReservationReloadPending(false); - serverRestartReservationReloadTaskIdRef.current += 1; } if (!serverRestartReservationCompletedBootstrapRef.current) { @@ -2204,70 +2303,44 @@ export function MainHeader({ } handledServerRestartReservationCompletedAtRef.current = serverRestartReservation.completedAt; - setServerRestartReservationReloadPending(true); - setServerRestartReservationFeedback({ - tone: 'info', - message: '예약된 재기동 완료 신호를 받았습니다. TEST/WORK 서버 새 런타임을 최종 확인한 뒤 화면을 새로고침합니다.', - }); - - const taskId = serverRestartReservationReloadTaskIdRef.current + 1; - serverRestartReservationReloadTaskIdRef.current = taskId; - void (async () => { - const deadline = Date.now() + RESERVED_RESTART_VERIFICATION_TIMEOUT_MS; + setServerRestartReservationReloadPending(true); + setServerRestartReservationFeedback({ + tone: 'info', + message: '예약된 재기동 완료 신호를 받았습니다. 자동 새로고침 없이 현재 상태만 한 번 다시 확인합니다.', + }); - while (serverRestartReservationReloadTaskIdRef.current === taskId && Date.now() <= deadline) { - try { - const statuses = await refreshServerStatuses(); - const testVerified = hasReservedRestartServerBeenVerified('test', statuses.test, serverRestartReservation.startedAt); - const workVerified = hasReservedRestartServerBeenVerified( - 'work-server', - statuses['work-server'], - serverRestartReservation.startedAt, - ); + try { + const statuses = await refreshServerStatuses(); + const testVerified = hasReservedRestartServerBeenVerified('test', statuses.test, serverRestartReservation.startedAt); + const workVerified = hasReservedRestartServerBeenVerified( + 'work-server', + statuses['work-server'], + serverRestartReservation.startedAt, + ); + setServerRestartReservationReloadPending(false); if (testVerified && workVerified) { - setServerRestartReservationFeedback({ - tone: 'info', - message: '예약된 재기동 뒤 TEST/WORK 서버 새 런타임을 확인했습니다. 캐시를 정리한 뒤 화면을 새로고침합니다.', - }); - try { - await executeRestartCompletionAction('reset-client-state'); - } catch (error) { - const message = - error instanceof Error ? error.message : '재기동 뒤 브라우저 상태를 정리하지 못했습니다.'; - setServerRestartReservationReloadPending(false); - setServerRestartReservationFeedback({ - tone: 'error', - message, - }); - } - return; - } - - const pendingTargets = [ - !testVerified ? 'TEST' : null, - !workVerified ? 'WORK' : null, - ].filter((value): value is string => Boolean(value)); setServerRestartReservationFeedback({ - tone: 'info', - message: `${pendingTargets.join('/')} 서버 새 런타임 확인 전이라 새로고침을 보류합니다.`, - }); - } catch { - setServerRestartReservationFeedback({ - tone: 'info', - message: '재기동 뒤 서버 응답을 다시 확인하는 중입니다. 확인 전까지 새로고침을 보류합니다.', + tone: 'success', + message: '예약된 재기동 뒤 TEST/WORK 서버 새 런타임을 확인했습니다. 필요할 때만 화면 새로고침을 직접 실행해 주세요.', }); + return; } - await waitForDuration(RESERVED_RESTART_VERIFICATION_INTERVAL_MS); - } - - if (serverRestartReservationReloadTaskIdRef.current === taskId) { + const pendingTargets = [ + !testVerified ? 'TEST' : null, + !workVerified ? 'WORK' : null, + ].filter((value): value is string => Boolean(value)); + setServerRestartReservationFeedback({ + tone: 'warning', + message: `${pendingTargets.join('/')} 서버 새 런타임은 아직 확인되지 않았습니다. 자동 재시도 없이 상태 새로고침에서 다시 확인해 주세요.`, + }); + } catch { setServerRestartReservationReloadPending(false); setServerRestartReservationFeedback({ tone: 'warning', - message: '재기동 완료 뒤 새 런타임 최종 확인이 지연되어 자동 새로고침을 보류했습니다.', + message: '재기동 뒤 상태를 한 번 다시 확인하지 못했습니다. 자동 재시도 없이 상태 새로고침에서 다시 확인해 주세요.', }); } })(); @@ -2498,6 +2571,13 @@ export function MainHeader({ } }; + const refreshServerManagementStatus = async (silent = false) => { + await Promise.all([ + refreshUpdateTargets(silent), + refreshServerRestartReservation(silent), + ]); + }; + const waitForServerRestart = async ( key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null, @@ -2581,43 +2661,6 @@ export function MainHeader({ }; }; - const handleResetClientState = async () => { - if (clientResetting) { - return; - } - - setClientResetCopyFeedback(null); - setClientResetFeedback(null); - setClientResetting(true); - - try { - const result = await resetNonAuthClientState(); - const changedCount = - result.removedLocalStorageKeys.length + - result.removedSessionStorageKeys.length + - result.removedCacheKeys.length + - result.unregisteredServiceWorkerCount; - - setClientResetFeedback({ - tone: 'success', - message: - changedCount > 0 - ? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.` - : '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.', - }); - window.setTimeout(() => { - window.location.replace(buildCacheBustedReloadUrl()); - }, 700); - } catch (error) { - setClientResetFeedback({ - tone: 'error', - message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.', - }); - } finally { - setClientResetting(false); - } - }; - const executeRestartCompletionAction = async (action: RestartCompletionAction) => { if (action === 'reset-client-state') { await resetNonAuthClientState(); @@ -2637,47 +2680,29 @@ export function MainHeader({ title, targetLabel, action, - autoActionSeconds = 4, okText, cancelText, promptText, - countdownText, customAction, onCancel, onActionError, } = options; const actionLabel = getRestartCompletionActionLabel(action); - let remainingSeconds = Math.max(1, Math.round(autoActionSeconds)); let settled = false; - let countdownTimerId: number | null = null; - let autoActionTimerId: number | null = null; - const buildContent = (seconds: number) => ( + const buildContent = () => ( {promptText ?? `실제로 ${targetLabel} 부팅 완료까지 확인했습니다. 지금 ${actionLabel.toLowerCase()}할까요?`} - - {countdownText?.(seconds, actionLabel) ?? `${seconds}초 동안 반응이 없으면 자동으로 ${actionLabel.toLowerCase()}합니다.`} - + 자동으로 진행하지 않습니다. 필요할 때만 직접 선택해 주세요. ); - const cleanupTimers = () => { - if (countdownTimerId !== null) { - window.clearInterval(countdownTimerId); - } - - if (autoActionTimerId !== null) { - window.clearTimeout(autoActionTimerId); - } - }; - const runAction = async () => { if (settled) { return; } settled = true; - cleanupTimers(); try { if (action === 'custom') { await customAction?.(); @@ -2700,7 +2725,7 @@ export function MainHeader({ const confirmModal = modalApi.confirm({ title, - content: buildContent(remainingSeconds), + content: buildContent(), okText: okText ?? actionLabel, cancelText: cancelText ?? '나중에', autoFocusButton: 'ok', @@ -2708,31 +2733,9 @@ export function MainHeader({ onOk: () => runAction(), onCancel: () => { settled = true; - cleanupTimers(); onCancel?.(); }, }); - - countdownTimerId = window.setInterval(() => { - if (settled) { - cleanupTimers(); - return; - } - - remainingSeconds = Math.max(0, remainingSeconds - 1); - confirmModal.update({ - content: buildContent(remainingSeconds), - }); - }, 1000); - - autoActionTimerId = window.setTimeout(() => { - if (settled) { - return; - } - - confirmModal.destroy(); - void runAction(); - }, remainingSeconds * 1000); }; const restartServerWithVerification = async ( @@ -2765,7 +2768,7 @@ export function MainHeader({ updateRestartProgress( progressTaskId, - '재기동 요청 완료', + '재기동 요청 접수', `${targetLabel} 재기동 요청이 접수되었습니다. 실제 부팅 완료를 확인할 때까지 기다립니다.`, ); const verificationBaseline = result.restartState === 'accepted' ? result.item : baseline; @@ -4856,6 +4859,16 @@ export function MainHeader({ open={settingsModalOpen} footer={null} confirmLoading={notificationLoading} + width={activeSettingsModal === 'update' && isMobileViewport ? 'calc(100vw - 16px)' : undefined} + styles={ + activeSettingsModal === 'update' + ? { + body: { + padding: isMobileViewport ? 16 : 24, + }, + } + : undefined + } onCancel={() => { setSettingsModalOpen(false); }} @@ -4950,156 +4963,115 @@ export function MainHeader({ ) : null} {activeSettingsModal === 'update' ? ( - - 업데이트 확인 - - 테스트 - - - 소스 수정일: {getServerLastSourceChangedDateLabel(testServerStatus)} - - {getServerLastSourceChangedHint(testServerStatus) ? ( - {getServerLastSourceChangedHint(testServerStatus)} - ) : null} - - 워크 - - - 소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)} - - {getServerLastSourceChangedHint(workServerStatus) ? ( - {getServerLastSourceChangedHint(workServerStatus)} - ) : null} +
+
+
+ 서버 재기동 + 핵심 작업을 먼저 두고 상태는 한 화면에서 바로 확인하게 정리했습니다. +
+ +
+
+ {serverManagementStatusItems.map((statusItem) => ( +
+
+ {statusItem.label} +
+ {statusItem.checkedLabel} + {statusItem.detailLabel} + {statusItem.progressLabel ? 진행상태 {statusItem.progressLabel} : null} + {statusItem.hint ? {statusItem.hint} : null} +
+ ))} +
{workServerStatus?.buildRequired ? ( - - 빨간 점은 장애가 아니라 빌드 미반영 상태입니다. 워크 재기동만으로는 그대로 남을 수 있습니다. - - ) : null} - - 운영 - - {formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)} - {renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)} - - - 캐시 / 스토리지 초기화 - - - 권한, 앱 설정, 푸시/서비스워커 등록은 유지하고 화면 상태와 앱 캐시만 초기화합니다. - - {renderFeedback(clientResetFeedback, clientResetCopyFeedback, setClientResetCopyFeedback)} - - - 서버 재기동 - - 전체 재기동은 TEST와 WORK 서버만 순서대로 예약 실행합니다. - - 테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)} - - - 워크 마지막 확인: {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)} - - {!hasAccess ? ( - 서버 재기동은 권한 토큰 등록 후 사용할 수 있습니다. ) : null} + {!hasAccess ? ( + + ) : null} + {renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)} {renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)} - - - +
+ 즉시 실행 +
+ + + +
+ 전체 재기동은 TEST와 WORK 서버만 순서대로 예약 실행합니다. +
+
+ 운영 반영 + + PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다. + - - - PROD 빌드 반영 - - 운영 마지막 확인: {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)} - - PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다. - - - +
+
) : null}
diff --git a/src/app/main/MainLayout.css b/src/app/main/MainLayout.css index 7ae2fb6..c0b14e0 100644 --- a/src/app/main/MainLayout.css +++ b/src/app/main/MainLayout.css @@ -850,6 +850,71 @@ margin: 0; } +.app-header__server-management { + display: flex; + flex-direction: column; + gap: 12px; +} + +.app-header__server-management-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.app-header__server-management-hero-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 4px; +} + +.app-header__server-management-status-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.app-header__server-management-status-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 14px; + background: #f8fafc; +} + +.app-header__server-management-status-card .ant-typography { + margin-bottom: 0; +} + +.app-header__server-management-status-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.app-header__server-management-action-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.app-header__server-management-action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.app-header__server-management-action-grid-primary { + grid-column: 1 / -1; +} + .app-header__runtime-summary { display: flex; flex-wrap: wrap; @@ -1289,6 +1354,15 @@ overflow: hidden; } +.app-shell:has(.server-command-page), +.app-shell:has(.server-command-page) > .ant-layout, +.app-main-content.ant-layout-content:has(.server-command-page), +.app-main-panel:has(.server-command-page), +.app-main-layout:has(.server-command-page) { + overscroll-behavior: none; + overscroll-behavior-y: none; +} + .app-main-layout:has(.layout-draw-page) { grid-template-columns: minmax(0, 1fr); gap: 0; @@ -2798,6 +2872,22 @@ body.preview-app-overlay-console-dragging { gap: 8px; } + .app-header__server-management-hero { + flex-direction: column; + } + + .app-header__server-management-hero > .ant-btn { + width: 100%; + } + + .app-header__server-management-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .app-header__server-management-action-grid { + grid-template-columns: 1fr; + } + .app-sider.ant-layout-sider { position: static; } diff --git a/src/app/main/SharedChatManagementPage.tsx b/src/app/main/SharedChatManagementPage.tsx index ed76aa4..fac580a 100644 --- a/src/app/main/SharedChatManagementPage.tsx +++ b/src/app/main/SharedChatManagementPage.tsx @@ -94,13 +94,14 @@ export function SharedChatManagementPage() { ]; const openManagedShareWindow = (url: string) => { openExternalLinkInNewWindow(url, { + allowSameTabFallback: false, onUnsupportedStandalone: (fallbackUrl) => { void copyTextToClipboard(fallbackUrl) .then(() => { - message.info('현재 모바일 PWA에서는 preview 앱 열기도 막혀 URL을 복사했습니다. 브라우저에서 붙여 열어 주세요.'); + message.info('현재 창은 유지하고 공유 URL만 복사했습니다. 브라우저나 새 PWA 창에서 붙여 열어 주세요.'); }) .catch(() => { - message.info('현재 모바일 PWA에서는 새 창과 preview 앱 열기가 막힐 수 있습니다. URL 복사 후 브라우저에서 열어 주세요.'); + message.info('현재 창은 유지했습니다. 새 창 열기가 막히면 URL 복사 후 브라우저에서 열어 주세요.'); }); }, }); diff --git a/src/app/main/SharedResourceManagementPage.css b/src/app/main/SharedResourceManagementPage.css index 17cc17a..851bb27 100644 --- a/src/app/main/SharedResourceManagementPage.css +++ b/src/app/main/SharedResourceManagementPage.css @@ -382,6 +382,39 @@ text-align: left; } +.shared-resource-management-page__conversation-drawer .ant-drawer-content-wrapper { + width: 100vw !important; + max-width: 100vw; +} + +.shared-resource-management-page__conversation-drawer .ant-drawer-header { + padding: 10px 16px 9px; +} + +.shared-resource-management-page__conversation-drawer .ant-drawer-title { + font-size: 15px; + line-height: 1.2; +} + +.shared-resource-management-page__conversation-drawer .ant-drawer-extra { + display: flex; + align-items: center; +} + +.shared-resource-management-page__conversation-drawer .ant-drawer-body { + display: flex; + min-height: 0; +} + +.shared-resource-management-page__conversation-frame { + width: 100%; + height: 100%; + min-height: calc(100vh - 48px); + border: 0; + background: #fff; + flex: 1 1 auto; +} + @media (max-width: 1100px) { .shared-resource-management-page__summary-strip { align-items: flex-start; diff --git a/src/app/main/SharedResourceManagementPage.tsx b/src/app/main/SharedResourceManagementPage.tsx index 3cd9c74..68e2def 100644 --- a/src/app/main/SharedResourceManagementPage.tsx +++ b/src/app/main/SharedResourceManagementPage.tsx @@ -1,6 +1,6 @@ import { DeleteOutlined, LinkOutlined, PlusOutlined, QrcodeOutlined, ReloadOutlined, SaveOutlined, StopOutlined, UnorderedListOutlined } from '@ant-design/icons'; -import { Alert, App, Button, Card, Checkbox, Empty, Flex, Form, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tabs, Tag, Typography } from 'antd'; -import { useEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEvent } from 'react'; +import { Alert, App, Button, Card, Checkbox, Drawer, Empty, Flex, Form, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tabs, Tag, Typography } from 'antd'; +import { useEffect, useLayoutEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEvent } from 'react'; import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry'; import { copyTextToClipboard } from '../../utils/clipboard'; import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation'; @@ -22,6 +22,7 @@ import { type SharedResourceTokenRecord, type SharedResourceType, } from './sharedResourceTokenAccess'; +import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from './pwa/installManifest'; import './SharedResourceManagementPage.css'; const { Paragraph, Text, Title } = Typography; @@ -45,6 +46,7 @@ const RESOURCE_TYPE_OPTIONS: Array<{ value: SharedResourceType; label: string }> const MANAGEMENT_APP_OPTIONS = [ { value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' }, { value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' }, + { value: 'text-memo-widget', label: '메모', description: '공유채팅 Apps에서 메모 컴포넌트 실행', category: '관리' }, { value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' }, { value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' }, { value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' }, @@ -83,6 +85,12 @@ type SharedResourceManagementSharedAccess = { managedResourceTokenId?: string | null; }; +type ConversationDrawerState = { + tokenId: string; + tokenName: string; + url: string; +}; + function isChatShareToken>( item: T | null | undefined, ): item is T & { resourceType: 'chat-share' } { @@ -105,6 +113,14 @@ type SharedResourceTokenFormValue = { usageLimit: number; }; +const SHARED_RESOURCE_INSTALL_THEME_COLOR = '#0f766e'; +const SHARED_RESOURCE_INSTALL_BACKGROUND_COLOR = '#f3fbf9'; +const SHARED_RESOURCE_INSTALL_TITLE = '공유 리소스 관리'; + +function buildSharedResourceInstallTitle(isSharedManageMode: boolean) { + return isSharedManageMode ? '공유 리소스 관리 링크' : SHARED_RESOURCE_INSTALL_TITLE; +} + const EMPTY_FORM_VALUE: SharedResourceTokenFormValue = { name: '', description: '', @@ -417,6 +433,12 @@ function buildChatConversationUrl(item: Pick('basic'); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [qrPreviewTokenId, setQrPreviewTokenId] = useState(null); + const [conversationDrawer, setConversationDrawer] = useState(null); + const [conversationDrawerKey, setConversationDrawerKey] = useState(0); const [form] = Form.useForm(); const [modalApi, modalContextHolder] = Modal.useModal(); + useLayoutEffect(() => { + if (disableInstallMetadata || typeof window === 'undefined') { + return undefined; + } + + const startPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + const installTitle = buildSharedResourceInstallTitle(isSharedManageMode); + const manifestObjectUrl = createInstallManifestObjectUrl({ + startPath, + scope: window.location.pathname, + name: installTitle, + shortName: '공유 리소스', + description: isSharedManageMode + ? '공유 리소스 관리 링크를 홈 화면 앱으로 바로 엽니다.' + : '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.', + themeColor: SHARED_RESOURCE_INSTALL_THEME_COLOR, + backgroundColor: SHARED_RESOURCE_INSTALL_BACKGROUND_COLOR, + }); + const restoreManifest = swapInstallDocumentMetadata({ + manifestHref: manifestObjectUrl, + title: installTitle, + themeColor: SHARED_RESOURCE_INSTALL_THEME_COLOR, + }); + + return () => { + restoreManifest(); + if (manifestObjectUrl) { + window.URL.revokeObjectURL(manifestObjectUrl); + } + }; + }, [disableInstallMetadata, isSharedManageMode]); + useEffect(() => { const intervalId = window.setInterval(() => { setNowMs(Date.now()); @@ -632,17 +690,26 @@ export function SharedResourceManagementPage({ const openConversationWindow = (url: string, event?: ReactMouseEvent) => { openExternalLinkInNewWindow(url, { event, + allowSameTabFallback: false, onUnsupportedStandalone: (fallbackUrl) => { void copyTextToClipboard(fallbackUrl) .then(() => { - message.info('현재 모바일 PWA에서는 preview 앱 열기도 막혀 URL을 복사했습니다. 브라우저에서 붙여 열어 주세요.'); + message.info('현재 창은 유지하고 공유채팅 URL만 복사했습니다. 브라우저나 새 PWA 창에서 붙여 열어 주세요.'); }) .catch(() => { - message.info('현재 모바일 PWA에서는 새 창과 preview 앱 열기가 막힐 수 있습니다. QR 코드나 URL 복사로 이어서 열어 주세요.'); + message.info('현재 창은 유지했습니다. 새 창 열기가 막히면 QR 코드나 URL 복사로 이어서 열어 주세요.'); }); }, }); }; + const openConversationDrawer = (tokenId: string, tokenName: string, url: string) => { + setConversationDrawer({ + tokenId, + tokenName, + url, + }); + setConversationDrawerKey((current) => current + 1); + }; const listColumns = useMemo( () => [ @@ -740,7 +807,7 @@ export function SharedResourceManagementPage({ return; } - openConversationWindow(conversationUrl, event); + openConversationDrawer(item.id, item.name, conversationUrl); }} > 열기 @@ -796,6 +863,21 @@ export function SharedResourceManagementPage({ key: 'detail', render: (value: string | null) => sanitizeActivityDetail(value) ?? '-', }, + { + title: '접속 정보', + key: 'ip', + render: (_value: unknown, item: SharedResourceTokenActivityRecord) => { + const lines = [ + item.externalIp ? `외부 ${item.externalIp}` : null, + item.clientIp ? `서버 ${item.clientIp}` : null, + item.forwardedFor ? `XFF ${item.forwardedFor}` : null, + item.clientId ? `client ${item.clientId}` : null, + ].filter(Boolean); + return lines.length > 0 ? ( + {lines.join('\n')} + ) : '-'; + }, + }, { title: '사용량', dataIndex: 'usageDelta', @@ -1148,6 +1230,38 @@ export function SharedResourceManagementPage({ )} + { + setConversationDrawer(null); + }} + extra={ + conversationDrawer ? ( + + + + + ) : null + } + styles={{ + body: { + padding: 0, + }, + }} + > + {conversationDrawer ? ( +