28 Commits

Author SHA1 Message Date
4e6e73dbd5 chore: test deploy snapshot 2026-05-29 18:51:12 +09:00
737ab0a34a chore: test deploy snapshot 2026-05-29 18:20:17 +09:00
ffbdbf46b6 chore: test deploy snapshot 2026-05-29 18:08:38 +09:00
5b3e70910c chore: test deploy snapshot 2026-05-29 17:42:33 +09:00
262ce4b627 chore: test deploy snapshot 2026-05-29 16:11:46 +09:00
b242d91ecb chore: test deploy snapshot 2026-05-29 07:57:56 +09:00
1e7212b862 chore: test deploy snapshot 2026-05-28 22:47:45 +09:00
753fd423db chore: test deploy snapshot 2026-05-28 19:44:56 +09:00
c7f29bdc33 chore: test deploy snapshot 2026-05-28 16:57:02 +09:00
a97d933cff chore: test deploy snapshot 2026-05-28 16:11:33 +09:00
b1bec9cb6f chore: test deploy snapshot 2026-05-28 15:47:24 +09:00
bb275c0534 chore: test deploy snapshot 2026-05-28 14:34:49 +09:00
82c46f4be4 chore: test deploy snapshot 2026-05-28 12:45:36 +09:00
983887dc05 chore: test deploy snapshot 2026-05-28 08:09:49 +09:00
e195ac8088 chore: test deploy snapshot 2026-05-27 19:32:28 +09:00
10805d242e chore: test deploy snapshot 2026-05-27 16:35:12 +09:00
e8a628ac34 chore: test deploy snapshot 2026-05-27 14:40:33 +09:00
58c5a7cfee chore: test deploy snapshot 2026-05-27 12:11:09 +09:00
26220577fc chore: test deploy snapshot 2026-05-27 12:04:45 +09:00
4984d74d39 chore: test deploy snapshot 2026-05-27 11:57:01 +09:00
215648bd8d chore: test deploy snapshot 2026-05-27 11:44:33 +09:00
4a88d3f430 chore: test deploy snapshot 2026-05-27 11:35:26 +09:00
7e9c3bd097 chore: test deploy snapshot 2026-05-27 11:19:49 +09:00
4c4b3c8d2c chore: test deploy snapshot 2026-05-27 10:43:01 +09:00
c1d0f4c1db feat: refresh shared chat and server workflows 2026-05-26 12:26:33 +09:00
51e0099bea feat: add play apps and layout tools 2026-05-25 17:29:21 +09:00
f59522ffc4 feat: update main chat and system chat UI 2026-05-25 17:26:37 +09:00
fb5ec649cd chore: sync backend and deployment changes 2026-05-25 17:25:52 +09:00
280 changed files with 132264 additions and 5262 deletions

View File

@@ -1,5 +1,5 @@
NODE_VERSION=22.22.2
CAPTURE_BASE_URL=https://test.sm-home.cloud/
CAPTURE_BASE_URL=https://preview.sm-home.cloud/
CAPTURE_REGISTERED_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
VITE_ALLOWED_REGISTRATION_TOKEN=usr_7f3a9c2d8e1b4a6f
PHOTOPRISM_PORT=2342

1
.tmp-chatshare-full.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -25,6 +25,8 @@
* `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다
* 사용자가 **명시적으로 요청한 경우를 제외하면** 구현 편의나 상태 갱신을 이유로 `polling`, `setInterval`, 주기적 재시도 루프 같은 반복 조회 구조를 추가하거나 유지하지 않는다
* 기존 기능에 `polling`, `setInterval`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다
* `work-server` 재기동이나 배포 절차는 **기존 연결을 끊는 단일 컨테이너 재시작 방식이 아니라, blue/green 슬롯 전환 기반 무중단 절차를 기본 규칙으로 사용**한다
* `work-server` 관련 문서, 스크립트, 운영 안내를 수정할 때는 **비활성 슬롯 기동 → health 확인 → 프록시 전환 → 이전 슬롯 정리** 순서를 유지하고, 연결이 끊기는 재시작을 기본 절차처럼 적지 않는다
### 요청 해석 규칙

View File

@@ -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` 환경변수를 사용합니다.

View File

@@ -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:
@@ -19,10 +21,18 @@ services:
PORT: 5173
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100}
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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
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"

View File

@@ -9,21 +9,28 @@ SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}"
SERVER_COMMAND_TEST_GIT_REMOTE="${SERVER_COMMAND_TEST_GIT_REMOTE:-origin}"
SERVER_COMMAND_TEST_GIT_BRANCH="${SERVER_COMMAND_TEST_GIT_BRANCH:-main}"
SERVER_COMMAND_TEST_GIT_SYNC="${SERVER_COMMAND_TEST_GIT_SYNC:-false}"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$MAIN_PROJECT_ROOT"
git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
if [ "$SERVER_COMMAND_TEST_GIT_SYNC" = "true" ]; then
git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then
git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \
|| git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"
if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then
git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \
|| git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"
fi
git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
fi
git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
TEST_BUILD_STAMP_FILE="${TEST_BUILD_STAMP_FILE:-$MAIN_PROJECT_ROOT/.server-command-test-app-built-at}"
if command -v docker >/dev/null 2>&1; then
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVER_COMMAND_SERVICE"
docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --build --no-deps --force-recreate "$SERVER_COMMAND_SERVICE"
date -Iseconds > "$TEST_BUILD_STAMP_FILE"
exit 0
fi
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then

479
etc/commands/server-command/restart-work-server.sh Normal file → Executable file
View File

@@ -5,7 +5,484 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
PROXY_SERVICE="${WORK_SERVER_PROXY_SERVICE:-work-server}"
PROXY_CONTAINER="${WORK_SERVER_PROXY_CONTAINER:-work-server}"
BLUE_SERVICE="${WORK_SERVER_BLUE_SERVICE:-work-server-blue}"
GREEN_SERVICE="${WORK_SERVER_GREEN_SERVICE:-work-server-green}"
BLUE_CONTAINER="${WORK_SERVER_BLUE_CONTAINER:-work-server-blue}"
GREEN_CONTAINER="${WORK_SERVER_GREEN_CONTAINER:-work-server-green}"
ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/active-slot}"
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"
exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server
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_STATUS === 'completed'
? null
: env.DEPLOY_LAST_ERROR || (!shouldResetForNewRun ? current.lastError : null) || null,
logExcerpt:
env.DEPLOY_STATUS === 'completed'
? null
: 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
SLOT=$(tr -d '[:space:]' <"$ACTIVE_SLOT_FILE")
if [ "$SLOT" = "blue" ] || [ "$SLOT" = "green" ]; then
printf '%s' "$SLOT"
return 0
fi
fi
printf 'blue'
}
container_is_running() {
CONTAINER_NAME="$1"
STATUS=$(docker inspect -f '{{.State.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)
[ "$STATUS" = "running" ]
}
resolve_active_slot() {
SLOT=$(read_active_slot)
if [ "$SLOT" = "blue" ] && ! container_is_running "$BLUE_CONTAINER" && container_is_running "$GREEN_CONTAINER"; then
printf 'green'
return 0
fi
if [ "$SLOT" = "green" ] && ! container_is_running "$GREEN_CONTAINER" && container_is_running "$BLUE_CONTAINER"; then
printf 'blue'
return 0
fi
printf '%s' "$SLOT"
}
write_proxy_config() {
SLOT="$1"
TARGET_CONTAINER="$BLUE_CONTAINER"
if [ "$SLOT" = "green" ]; then
TARGET_CONTAINER="$GREEN_CONTAINER"
fi
cat >"$PROXY_CONFIG_FILE" <<EOF2
server {
listen 3100;
server_name _;
location /ws/chat {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://$TARGET_CONTAINER:3100;
}
location / {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header Connection "";
proxy_pass http://$TARGET_CONTAINER:3100;
}
}
EOF2
}
wait_for_container_runtime_ready() {
TARGET_CONTAINER="$1"
TARGET_SLOT="$2"
ATTEMPT=0
STABLE_SUCCESS_COUNT=0
while [ "$ATTEMPT" -lt 90 ]; do
if docker exec "$TARGET_CONTAINER" node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then
STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1))
if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then
return 0
fi
else
STABLE_SUCCESS_COUNT=0
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 1
done
echo "runtime readiness check failed for $TARGET_CONTAINER slot $TARGET_SLOT" >&2
return 1
}
wait_for_proxy_slot_health() {
TARGET_SLOT="$1"
ATTEMPT=0
STABLE_SUCCESS_COUNT=0
while [ "$ATTEMPT" -lt 90 ]; do
if node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then
STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1))
if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then
return 0
fi
else
STABLE_SUCCESS_COUNT=0
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 1
done
echo "proxy runtime readiness check failed for slot $TARGET_SLOT via $HEALTH_ENDPOINT" >&2
return 1
}
read_runtime_value() {
TARGET_CONTAINER="$1"
FIELD_NAME="$2"
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then((response) => response.json()).then((payload) => { const value = payload?.[process.argv[2]]; if (typeof value === 'boolean') { process.stdout.write(value ? 'true' : 'false'); return; } if (value == null) { process.stdout.write(''); return; } process.stdout.write(String(value)); }).catch(() => process.exit(1));" "$RUNTIME_ENDPOINT" "$FIELD_NAME"
}
set_container_draining() {
TARGET_CONTAINER="$1"
DRAINING_VALUE="$2"
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
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
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
echo "drain timeout reached for $TARGET_CONTAINER" >&2
return 1
}
ensure_proxy_running() {
docker compose -f "$COMPOSE_FILE" up -d --no-deps "$PROXY_SERVICE" >/dev/null
docker exec "$PROXY_CONTAINER" nginx -s reload >/dev/null
}
ACTIVE_SLOT=$(resolve_active_slot)
TARGET_SLOT="green"
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"
TARGET_SERVICE="$BLUE_SERVICE"
TARGET_CONTAINER="$BLUE_CONTAINER"
PREVIOUS_SERVICE="$GREEN_SERVICE"
PREVIOUS_CONTAINER="$GREEN_CONTAINER"
PREVIOUS_SLOT="green"
fi
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 "새 슬롯 API 준비 상태를 확인합니다." "verify-target-health" running "대상 컨테이너 ${TARGET_CONTAINER}"
if wait_for_container_runtime_ready "$TARGET_CONTAINER" "$TARGET_SLOT"; then
write_deploy_state running verify-target-health "새 슬롯 API 준비 상태 확인이 완료되었습니다." "verify-target-health" completed "대상 슬롯 ${TARGET_SLOT} health/runtime 정상 응답"
else
LAST_DEPLOY_ERROR="새 슬롯 API 준비 상태 확인에 실패했습니다."
LAST_DEPLOY_LOG="runtime readiness check failed for ${TARGET_CONTAINER}"
write_deploy_state failed failed "새 슬롯 API 준비 상태 확인에 실패했습니다." "verify-target-health" failed "대상 슬롯 ${TARGET_SLOT} health/runtime 실패" "$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"
if ensure_proxy_running && wait_for_proxy_slot_health "$TARGET_SLOT"; then
printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE"
ACTIVE_SLOT="$TARGET_SLOT"
write_deploy_state running switch-proxy "프록시 전환을 완료했습니다." "switch-proxy" completed "프록시 3100 -> 대상 슬롯 ${TARGET_SLOT} 안정 응답 확인"
else
LAST_DEPLOY_ERROR="프록시 전환에 실패했습니다."
LAST_DEPLOY_LOG="nginx reload or proxy health verification failed"
write_deploy_state failed failed "프록시 전환에 실패했습니다." "switch-proxy" failed "대상 슬롯 ${TARGET_SLOT} 프록시 응답 확인 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
exit 1
fi
if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then
set_container_draining "$PREVIOUS_CONTAINER" true
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_runtime_ready "$PREVIOUS_CONTAINER" "$PREVIOUS_SLOT"; then
write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구했습니다." "rebuild-previous-slot" completed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 정상 응답"
else
LAST_DEPLOY_ERROR="이전 슬롯 복구 API 준비 상태 확인에 실패했습니다."
LAST_DEPLOY_LOG="runtime readiness check failed for ${PREVIOUS_CONTAINER}"
write_deploy_state failed failed "이전 슬롯 복구 API 준비 상태 확인에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
exit 1
fi
fi
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"

View File

@@ -37,12 +37,15 @@ SERVER_COMMAND_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
SERVER_COMMAND_API_BASE_URL=http://host.docker.internal:3211/api
SERVER_COMMAND_API_ACCESS_TOKEN=local-server-command-runner
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE=/api/server-commands/{key}/actions/restart
SERVER_COMMAND_PROJECT_ROOT=/workspace/auto_codex/repo
SERVER_COMMAND_PROJECT_ROOT=/workspace/main-project
SERVER_COMMAND_MAIN_PROJECT_ROOT=/workspace/main-project
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/
SERVER_COMMAND_TEST_CHECK_URL=http://ai-code-app-app-1:5173/
SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/
SERVER_COMMAND_REL_CHECK_URL=http://ai-code-app-release:5173/
SERVER_COMMAND_PROD_URL=https://sm-home.cloud/
SERVER_COMMAND_PROD_CHECK_URL=http://ai-code-app-prod:5173/
SERVER_COMMAND_PROD_GIT_REMOTE=origin
SERVER_COMMAND_PROD_GIT_BRANCH=main
SERVER_COMMAND_PROD_GIT_USERNAME=

View File

@@ -1,3 +1,4 @@
node_modules
dist
.dist-verify-actual
.env

View File

@@ -17,7 +17,19 @@ docker compose up -d
docker compose logs -f work-server
```
`work-server`HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
`work-server``3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다.
운영 기본 규칙:
- `work-server` 재기동은 기존 활성 슬롯을 바로 내리는 단일 컨테이너 재시작으로 처리하지 않습니다.
- 항상 `비활성 슬롯 기동 -> /health 확인 -> nginx upstream 전환 -> 이전 슬롯 정리` 순서를 유지합니다.
- 문서, 스크립트, 운영 가이드에 재기동 예시를 추가할 때도 무중단 전환 절차를 기본값으로 적고, 연결이 끊기는 재시작은 장애 대응이나 예외 상황으로만 취급합니다.
슬롯 로그까지 같이 보려면 아래처럼 확인합니다.
```bash
docker compose logs -f work-server work-server-blue work-server-green
```
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.
@@ -46,6 +58,7 @@ npm run server-command:runner
- `SERVER_COMMAND_DOCKER_SOCKET`: 서버 재기동 명령이 사용할 Docker Unix socket 경로. rootless Docker면 예: `/run/user/1000/docker.sock`
- `SERVER_COMMAND_API_BASE_URL`: `work-server`가 서버 재기동 요청을 위임할 host runner 주소
- `SERVER_COMMAND_API_ACCESS_TOKEN`: host runner 호출 토큰
- `SERVER_COMMAND_TEST_CHECK_URL`, `SERVER_COMMAND_REL_CHECK_URL`, `SERVER_COMMAND_PROD_CHECK_URL`: 외부 공개 URL과 별개로 재기동 성공 판정에 사용할 내부 확인 URL. 비워 두면 각 `SERVER_COMMAND_*_URL` 값을 그대로 사용합니다.
서버 재기동 기능을 쓰려면 `work-server` 컨테이너가 Docker에 접근할 수 있어야 합니다. 기본값은 `/var/run/docker.sock`이며, rootless Docker 환경이면 `.env``SERVER_COMMAND_DOCKER_SOCKET` 또는 `DOCKER_HOST=unix:///run/user/<uid>/docker.sock`를 맞춰 준 뒤 `work-server`를 다시 올려야 합니다.
@@ -59,9 +72,9 @@ npm run server-command:runner
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
`Codex Live``Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 현재 화면 및 최근 대화 문맥 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다.
`Codex Live``Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 채팅방 공통 문맥과 전용 메모는 충돌하지 않는 범위의 보조 문맥으로만 사용합니다. 현재 화면 및 최근 대화 문맥 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다.
브라우저 기준 운영 접속 확인은 **`https://test.sm-home.cloud/`**, 소스 변경 검증과 최종 화면 테스트는 **`https://preview.sm-home.cloud/`** 기준으로 진행합니다. 별도 요청이 없는 한 `sm-home.cloud``rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
브라우저 기준 화면 테스트, 소스 변경 검증, 최종 화면 확인은 **`https://preview.sm-home.cloud/`** 기준으로 진행합니다. `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다. 별도 요청이 없는 한 `sm-home.cloud``rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat/<chat-session-id>/resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다.
@@ -116,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` 상태를 확인할 수 있습니다.

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,25 @@
services:
work-server:
image: nginx:1.27-alpine
container_name: work-server
logging:
driver: json-file
options:
max-size: "200m"
max-file: "2"
mem_limit: 256m
ports:
- '127.0.0.1:3100:3100'
volumes:
- ./.docker/proxy/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- work-backend
work-server-blue:
build:
context: .
dockerfile: Dockerfile
container_name: work-server
container_name: work-server-blue
logging:
driver: json-file
options:
@@ -19,8 +35,6 @@ services:
required: false
- path: ./.env
required: false
ports:
- '127.0.0.1:3100:3100'
volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
@@ -42,7 +56,58 @@ services:
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
NPM_CONFIG_CACHE: /home/how2ice/.npm
WORK_SERVER_DIST_DIR: /tmp/work-server-dist
WORK_SERVER_DIST_DIR: /app/dist
WORK_SERVER_SLOT: blue
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
DOCKER_HOST: ${DOCKER_HOST:-}
networks:
- work-backend
extra_hosts:
- 'host.docker.internal:host-gateway'
work-server-green:
build:
context: .
dockerfile: Dockerfile
container_name: work-server-green
logging:
driver: json-file
options:
max-size: "200m"
max-file: "2"
user: "0:0"
group_add:
- "${SERVER_COMMAND_DOCKER_GID:-984}"
mem_limit: 2048m
working_dir: /app
env_file:
- path: ./.env.example
required: false
- path: ./.env
required: false
volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
- ../../../:/workspace/main-project
- ../../../.auto_codex:/workspace/auto_codex
- ../../../scripts:/workspace/repo-scripts:ro
- ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
- ./.docker/home:/home/how2ice
- ./.docker/codex-home:/codex-home
- ./.docker/codex-home-template:/codex-home-template
environment:
TZ: ${APP_TIME_ZONE:-Asia/Seoul}
HOME: /home/how2ice
CODEX_HOME: /codex-home
PLAN_CODEX_TEMPLATE_HOME: /codex-home-template
PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex}
PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false}
PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false}
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
NPM_CONFIG_CACHE: /home/how2ice/.npm
WORK_SERVER_DIST_DIR: /app/dist
WORK_SERVER_SLOT: green
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
DOCKER_HOST: ${DOCKER_HOST:-}
networks:

View File

@@ -7,6 +7,7 @@
"dev": "npm run build && npm run start",
"build": "sh -c 'tsc -p tsconfig.json --outDir \"${WORK_SERVER_DIST_DIR:-dist}\" && node ./scripts/write-build-info.mjs'",
"start": "sh -c 'node \"${WORK_SERVER_DIST_DIR:-dist}/server.js\"'",
"backfill:codex-live-resource-paths": "node --import tsx ./scripts/backfill-codex-live-resource-paths.ts",
"backfill:chat-request-links": "node --import tsx ./scripts/backfill-chat-request-links.ts",
"backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts",
"test": "node --import tsx --test src/**/*.test.ts"

View File

@@ -0,0 +1,292 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { inferSourceChangeScreenTitle } from '../src/services/chat-room-service.js';
const APPLY_FLAG = '--apply';
const repoRootPath = path.resolve(process.cwd(), '../../..');
const codexLiveRootPath = path.join(repoRootPath, 'resource', 'Codex Live');
const genericScreenRootPath = path.join(codexLiveRootPath, 'Codex Live');
type FeaturePlan = {
featureName: string;
sourcePath: string;
targetLabel: string;
targetPath: string;
filePaths: string[];
};
function normalizeWhitespace(value: string) {
return String(value ?? '').replace(/\s+/g, ' ').trim();
}
function getScreenLabelFromTitle(title: string) {
const segments = String(title ?? '')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean);
return segments.at(-1) ?? '';
}
function extractSourcePathsFromSpec(text: string) {
return Array.from(
new Set(
Array.from(text.matchAll(/`((?:src|etc|docs|public|scripts)\/[^`]+)`/g), (match) => normalizeWhitespace(match[1])),
),
).filter(Boolean);
}
function containsPattern(values: string[], pattern: RegExp) {
return values.some((value) => pattern.test(normalizeWhitespace(value)));
}
function inferScreenLabelFromFeatureMetadata(args: {
featureName: string;
filePaths: string[];
specTexts: string[];
}) {
const featureName = normalizeWhitespace(args.featureName);
const filePaths = args.filePaths.map((value) => normalizeWhitespace(value));
const specTexts = args.specTexts.map((value) => normalizeWhitespace(value));
if (
containsPattern(filePaths, /(?:resourceManagerApi|resource-manager-service|resource-manager|ResourceManagementPage)/iu) ||
containsPattern([featureName], /(?:resource manager| | | | CLI)/iu)
) {
return '리소스 관리';
}
if (
containsPattern(filePaths, /(?:ChatSourceChangesPage|chat-room-service)/iu) ||
containsPattern([featureName], /(?: |source change|source-changes)/iu)
) {
return '변경 이력';
}
if (
containsPattern(filePaths, /(?:PreviewAppOverlay|PreviewAppWindow|previewRuntime|appUpdate)/iu) ||
containsPattern([featureName], /(?: |Preview App)/iu)
) {
return '모바일 앱 열기';
}
if (
containsPattern(filePaths, /(?:MainHeader|HeaderMessageCenter|MainLayout\.css)/iu) ||
containsPattern([featureName], /(?:)/iu)
) {
return '헤더 표시';
}
if (
containsPattern(
filePaths,
/(?:MainChatPanel|ChatConversationView|mainChatPanel|ChatActivityChecklist|chatUtils)/iu,
) ||
containsPattern(
[featureName],
/(?: | ||prompt|| | | | ||MainChatPanel|ChatConversationView|mainChatPanel)/iu,
)
) {
return '채팅 말풍선';
}
return null;
}
function inferScreenLabelFromSpec(args: {
featureName: string;
filePaths: string[];
specTexts: string[];
}) {
const inferredTitle = inferSourceChangeScreenTitle(args.filePaths, 'Codex Live / Codex Live');
const inferredLabel = getScreenLabelFromTitle(inferredTitle);
if (inferredLabel && inferredLabel !== 'Codex Live' && inferredLabel !== '새 대화') {
return inferredLabel;
}
const metadataLabel = inferScreenLabelFromFeatureMetadata(args);
if (metadataLabel) {
return metadataLabel;
}
const normalizedFeatureName = normalizeWhitespace(args.featureName);
const hasHeaderSpecificFile = args.filePaths.some((filePath) =>
/^(?:src\/app\/main\/MainHeader\.(?:ts|tsx)|src\/app\/main\/HeaderMessageCenter\.(?:ts|tsx|css))$/u.test(filePath),
);
const hasOnlyHeaderLayoutFiles =
args.filePaths.length > 0 &&
args.filePaths.every((filePath) =>
/^(?:src\/app\/main\/MainLayout\.css|src\/app\/main\/HeaderMessageCenter\.css)$/u.test(filePath),
);
const hasPreviewSpecificFile = args.filePaths.some((filePath) =>
/^(?:src\/app\/main\/PreviewAppOverlay\.(?:ts|tsx)|src\/app\/main\/PreviewAppWindow\.(?:ts|tsx)|src\/app\/main\/previewRuntime\.(?:ts|tsx|js)|src\/app\/main\/appUpdate\.(?:ts|tsx|js))$/u.test(
filePath,
),
);
if (/^(?:preview\b| preview\b)/iu.test(normalizedFeatureName) || hasPreviewSpecificFile) {
return '모바일 앱 열기';
}
if (
/(?:|| | | )/u.test(normalizedFeatureName) &&
(hasHeaderSpecificFile || hasOnlyHeaderLayoutFiles || args.filePaths.length === 0)
) {
return '헤더 표시';
}
return 'Codex Live';
}
async function exists(targetPath: string) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function readFeaturePlans() {
if (!(await exists(genericScreenRootPath))) {
return [] as FeaturePlan[];
}
const featureEntries = await fs.readdir(genericScreenRootPath, { withFileTypes: true });
const plans: FeaturePlan[] = [];
for (const featureEntry of featureEntries) {
if (!featureEntry.isDirectory()) {
continue;
}
const featureName = featureEntry.name;
const featurePath = path.join(genericScreenRootPath, featureName);
const datedEntries = (await fs.readdir(featurePath, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const specTexts: string[] = [];
const filePaths = new Set<string>();
for (const datedEntry of datedEntries) {
const specPath = path.join(featurePath, datedEntry, 'docs', 'feature-spec.md');
if (!(await exists(specPath))) {
continue;
}
const specText = await fs.readFile(specPath, 'utf8');
specTexts.push(specText);
extractSourcePathsFromSpec(specText).forEach((filePath) => {
filePaths.add(filePath);
});
}
const targetLabel = inferScreenLabelFromSpec({
featureName,
filePaths: Array.from(filePaths),
specTexts,
});
plans.push({
featureName,
sourcePath: featurePath,
targetLabel,
targetPath: path.join(codexLiveRootPath, targetLabel, featureName),
filePaths: Array.from(filePaths),
});
}
return plans.sort((left, right) => left.featureName.localeCompare(right.featureName, 'ko'));
}
async function moveDirectoryContents(sourcePath: string, targetPath: string): Promise<void> {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
if (!(await exists(targetPath))) {
await fs.rename(sourcePath, targetPath);
return;
}
const sourceEntries = await fs.readdir(sourcePath, { withFileTypes: true });
for (const sourceEntry of sourceEntries) {
const nextSourcePath = path.join(sourcePath, sourceEntry.name);
const nextTargetPath = path.join(targetPath, sourceEntry.name);
if (sourceEntry.isDirectory()) {
await moveDirectoryContents(nextSourcePath, nextTargetPath);
continue;
}
if (await exists(nextTargetPath)) {
throw new Error(`대상 파일이 이미 존재합니다: ${path.relative(repoRootPath, nextTargetPath)}`);
}
await fs.mkdir(path.dirname(nextTargetPath), { recursive: true });
await fs.rename(nextSourcePath, nextTargetPath);
}
await fs.rm(sourcePath, { recursive: false });
}
async function applyMoves(plans: FeaturePlan[]) {
const applied: Array<{ featureName: string; from: string; to: string }> = [];
for (const plan of plans) {
if (plan.targetLabel === 'Codex Live') {
continue;
}
await moveDirectoryContents(plan.sourcePath, plan.targetPath);
applied.push({
featureName: plan.featureName,
from: path.relative(repoRootPath, plan.sourcePath),
to: path.relative(repoRootPath, plan.targetPath),
});
}
return applied;
}
try {
const plans = await readFeaturePlans();
const movablePlans = plans.filter((plan) => plan.targetLabel !== 'Codex Live');
const summary = {
mode: process.argv.includes(APPLY_FLAG) ? 'apply' : 'dry-run',
totalFeatureCount: plans.length,
movableFeatureCount: movablePlans.length,
groupedTargets: movablePlans.reduce<Record<string, number>>((accumulator, plan) => {
accumulator[plan.targetLabel] = (accumulator[plan.targetLabel] ?? 0) + 1;
return accumulator;
}, {}),
moves: movablePlans.map((plan) => ({
featureName: plan.featureName,
from: path.relative(repoRootPath, plan.sourcePath),
to: path.relative(repoRootPath, plan.targetPath),
filePaths: plan.filePaths,
})),
};
if (!process.argv.includes(APPLY_FLAG)) {
console.log(JSON.stringify(summary, null, 2));
process.exit(0);
}
const applied = await applyMoves(plans);
console.log(JSON.stringify({
...summary,
appliedCount: applied.length,
applied,
}, null, 2));
} catch (error) {
console.error(error);
process.exitCode = 1;
}

View File

@@ -4,6 +4,8 @@ set -eu
APP_ROOT="${APP_ROOT:-/app}"
STATE_DIR="${WORK_SERVER_STATE_DIR:-/tmp/work-server-runtime}"
DIST_DIR="${WORK_SERVER_DIST_DIR:-dist}"
DIST_ENTRY="$DIST_DIR/server.js"
LOCK_FILE="$APP_ROOT/package-lock.json"
LOCK_HASH_FILE="$STATE_DIR/package-lock.sha256"
CHILD_PID=""
@@ -43,6 +45,19 @@ prepare_runtime() {
npm run build
}
prepare_runtime_or_fallback() {
if prepare_runtime; then
return 0
fi
if [ -f "$DIST_ENTRY" ]; then
log "build failed; using existing dist at $DIST_ENTRY"
return 0
fi
return 1
}
start_child() {
log "starting server process"
npm run start &
@@ -72,7 +87,7 @@ request_stop() {
trap 'request_reload' HUP
trap 'request_stop' INT TERM
prepare_runtime
prepare_runtime_or_fallback
while :; do
start_child

View File

@@ -0,0 +1,66 @@
import { db } from '../src/db/client.js';
import { clearSharedResourceTokenFromRequests } from '../src/services/chat-room-service.js';
import { isLegacyChatShareTokenRowNeedingMigration } from '../src/services/shared-resource-token-service.js';
const TOKENS_TABLE = 'shared_resource_tokens';
const ACTIVITIES_TABLE = 'shared_resource_token_activities';
const ACCESS_PIN_SESSIONS_TABLE = 'shared_resource_access_pin_sessions';
async function main() {
const rows = await db(TOKENS_TABLE)
.select(
'id',
'name',
'resource_type',
'token_setting_id',
'token_setting_snapshot_json',
'resource_context_json',
'allowed_app_ids_json',
'share_path',
'deleted_at',
'created_at',
)
.where({ resource_type: 'chat-share' });
const legacyRows = rows.filter((row) => isLegacyChatShareTokenRowNeedingMigration(row));
const tokenIds = legacyRows.map((row) => String(row.id ?? '').trim()).filter(Boolean);
if (tokenIds.length === 0) {
console.log(JSON.stringify({ ok: true, deletedCount: 0, tokenIds: [] }, null, 2));
await db.destroy();
return;
}
await db.transaction(async (trx) => {
for (const tokenId of tokenIds) {
await clearSharedResourceTokenFromRequests(tokenId, trx);
}
const sharePaths = legacyRows.map((row) => String(row.share_path ?? '').trim()).filter(Boolean);
if (sharePaths.length > 0) {
await trx(ACCESS_PIN_SESSIONS_TABLE).whereIn('share_path', sharePaths).delete();
}
await trx(ACTIVITIES_TABLE).whereIn('token_id', tokenIds).delete();
await trx(TOKENS_TABLE).whereIn('id', tokenIds).delete();
});
console.log(JSON.stringify({
ok: true,
deletedCount: tokenIds.length,
tokenIds,
names: legacyRows.map((row) => ({
id: String(row.id ?? '').trim(),
name: String(row.name ?? '').trim(),
createdAt: row.created_at ?? null,
deletedAt: row.deleted_at ?? null,
})),
}, null, 2));
await db.destroy();
}
main().catch(async (error) => {
console.error(error);
await db.destroy();
process.exitCode = 1;
});

View File

@@ -7,40 +7,97 @@ import { registerDdlRoutes } from './routes/ddl.js';
import { registerErrorLogRoutes } from './routes/error-log.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerAppConfigRoutes } from './routes/app-config.js';
import { registerBaseballTicketBayRoutes } from './routes/baseball-ticket-bay.js';
import { registerChatRoutes } from './routes/chat.js';
import { registerNotificationRoutes } from './routes/notification.js';
import { registerPlanRoutes } from './routes/plan.js';
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
import { registerReaderRoutes } from './routes/reader.js';
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
import { registerPlayAppRoutes } from './routes/play-app.js';
import { registerRuntimeRoutes } from './routes/runtime.js';
import { registerServerCommandRoutes } from './routes/server-command.js';
import { registerSchemaRoutes } from './routes/schema.js';
import { registerSharedResourceTokenRoutes } from './routes/shared-resource-token.js';
import { registerStockAlertRoutes } from './routes/stock-alert.js';
import { registerTestAppRoutes } from './routes/test-app.js';
import { registerTextMemoRoutes } from './routes/text-memo.js';
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
import { shouldPersistNotFoundErrorLog } from './not-found.js';
import { createErrorLog } from './services/error-log-service.js';
import {
isRuntimeDraining,
trackHttpRequestFinished,
trackHttpRequestStarted,
} from './services/runtime-drain-service.js';
function isDrainAllowedPath(method: string, url: string) {
return method === 'OPTIONS'
|| url === '/'
|| url === '/api'
|| url === '/health'
|| url.startsWith('/api/runtime')
|| url.startsWith('/api/server-commands');
}
export function createApp() {
const app = Fastify({
logger: true,
routerOptions: {
maxParamLength: 20000,
},
});
app.register(cors, {
origin: true,
});
app.addHook('onRequest', async (request, reply) => {
trackHttpRequestStarted();
let finished = false;
const finalize = () => {
if (finished) {
return;
}
finished = true;
trackHttpRequestFinished();
reply.raw.off('finish', finalize);
reply.raw.off('close', finalize);
};
reply.raw.on('finish', finalize);
reply.raw.on('close', finalize);
if (isRuntimeDraining() && !isDrainAllowedPath(request.method, request.url)) {
reply.code(503).send({
ok: false,
message: '이 서버는 배포 전환 중이라 새 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
});
return reply;
}
});
registerJsonBodyParser(app);
app.register(registerBoardRoutes);
app.register(registerHealthRoutes);
app.register(registerAppConfigRoutes);
app.register(registerBaseballTicketBayRoutes);
app.register(registerChatRoutes);
app.register(registerSchemaRoutes);
app.register(registerDdlRoutes);
app.register(registerCrudRoutes);
app.register(registerStockAlertRoutes);
app.register(registerTestAppRoutes);
app.register(registerErrorLogRoutes);
app.register(registerNotificationRoutes);
app.register(registerPlanRoutes);
app.register(registerPhotoPrismRoutes);
app.register(registerPlayAppRoutes);
app.register(registerReaderRoutes);
app.register(registerResourceManagerRoutes);
app.register(registerRuntimeRoutes);
app.register(registerSharedResourceTokenRoutes);
app.register(registerServerCommandRoutes);
app.register(registerTextMemoRoutes);
app.register(registerVisitorHistoryRoutes);

View File

@@ -54,6 +54,8 @@ const envSchema = z.object({
WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(),
WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(),
WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'),
OPENAI_API_KEY: z.string().optional(),
OPENAI_ORGANIZATION_ID: z.string().optional(),
APNS_KEY_ID: z.string().optional(),
APNS_TEAM_ID: z.string().optional(),
APNS_BUNDLE_ID: z.string().optional(),
@@ -70,12 +72,16 @@ const envSchema = z.object({
SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')),
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
SERVER_COMMAND_TEST_URL: z.string().default('https://preview.sm-home.cloud/'),
SERVER_COMMAND_TEST_CHECK_URL: z.string().optional(),
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
SERVER_COMMAND_REL_CHECK_URL: z.string().optional(),
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
SERVER_COMMAND_PROD_CHECK_URL: z.string().optional(),
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE: z.string().optional(),
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),

View File

@@ -1,5 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
import {
getChatContextSettingsConfig,
getAppConfigSnapshot,
@@ -14,6 +16,126 @@ import {
upsertAutomationContextsConfig,
} from '../services/automation-context-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
import {
getTokenSettingsConfig,
getTokenSettingById,
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/';
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-chat-share-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function resolveChatSharePath(token: string) {
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
}
type TokenSettingsAccessContext =
| { scope: 'full' }
| { scope: 'shared'; tokenSetting: TokenSettingRecord };
type AppConfigAccessContext =
| { scope: 'full' }
| { scope: 'shared' };
async function resolveTokenSettingsAccessContext(
request: { headers: Record<string, string | string[] | undefined> },
) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return { scope: 'full' } satisfies TokenSettingsAccessContext;
}
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
if (managedResource.token.resourceType === 'chat-share') {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
const hasManagePermission = managedResource.token.permissions.includes('manage');
const canOpenTokenSetting = normalizedAllowedAppIds.has('token-setting');
const tokenSettingId = managedResource.token.tokenSettingId?.trim() ?? '';
if (!hasManagePermission || !canOpenTokenSetting || !tokenSettingId) {
return null;
}
const tokenSetting = await getTokenSettingById(tokenSettingId);
if (!tokenSetting) {
return null;
}
return { scope: 'shared', tokenSetting } satisfies TokenSettingsAccessContext;
}
async function resolveAppConfigAccessContext(
request: { headers: Record<string, string | string[] | undefined> },
) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return { scope: 'full' } satisfies AppConfigAccessContext;
}
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
if (managedResource.token.resourceType === 'chat-share') {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
const hasManagePermission = managedResource.token.permissions.includes('manage');
const canOpenAppSettings = normalizedAllowedAppIds.has('app-settings');
if (!hasManagePermission || !canOpenAppSettings) {
return null;
}
return { scope: 'shared' } satisfies AppConfigAccessContext;
}
function sendTokenSettingsAccessDenied(
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
) {
reply.code(403).send({
message: '권한 토큰 또는 토큰관리 관리 권한이 있는 공유 링크에서만 토큰 설정을 사용할 수 있습니다.',
});
}
function sendAppConfigAccessDenied(
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
) {
reply.code(403).send({
message: '권한 토큰 또는 앱 설정 관리 권한이 있는 공유 링크에서만 앱 설정을 사용할 수 있습니다.',
});
}
function getRequestAppOrigin(request: { headers: Record<string, string | string[] | undefined> }) {
const rawAppOrigin = request.headers['x-app-origin'];
@@ -50,7 +172,15 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
}
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async (request) => {
app.get('/api/app-config', async (request, reply) => {
const accessContext = await resolveAppConfigAccessContext(request);
const hasShareToken = Boolean(getRequestChatShareToken(request));
if (hasShareToken && !accessContext) {
sendAppConfigAccessDenied(reply);
return;
}
const appOrigin = getRequestAppOrigin(request);
const config = await getAppConfigSnapshot(appOrigin);
@@ -96,6 +226,22 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/token-settings', async (request, reply) => {
const accessContext = await resolveTokenSettingsAccessContext(request);
if (!accessContext) {
sendTokenSettingsAccessDenied(reply);
return;
}
const tokenSettings =
accessContext.scope === 'full' ? await getTokenSettingsConfig() : [accessContext.tokenSetting];
return {
ok: true,
tokenSettings,
};
});
app.put('/api/chat-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
@@ -219,7 +365,93 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
});
app.put('/api/token-settings', async (request, reply) => {
const accessContext = await resolveTokenSettingsAccessContext(request);
if (!accessContext) {
sendTokenSettingsAccessDenied(reply);
return;
}
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
tokenSettings: z.array(z.unknown()),
}).parse(payload ?? {});
const nextTokenSettingsInput = parsed.tokenSettings as Partial<TokenSettingRecord>[];
const savedTokenSettings =
accessContext.scope === 'full'
? await upsertTokenSettingsConfig(nextTokenSettingsInput, {
actorLabel: 'manager',
audit: extractRequestAuditContext(request),
})
: await (async () => {
const authorizedSettingId = accessContext.tokenSetting.id;
const requestedSetting = nextTokenSettingsInput.find(
(item) => typeof item?.id === 'string' && item.id.trim().toLowerCase() === authorizedSettingId,
);
if (!requestedSetting) {
throw new Error('공유 링크에서는 현재 연결된 토큰 설정만 저장할 수 있습니다.');
}
const currentTokenSettings = await getTokenSettingsConfig();
const nextTokenSettings = currentTokenSettings.map((item) =>
item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item,
);
return upsertTokenSettingsConfig(nextTokenSettings, {
actorLabel: 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
})();
return {
ok: true,
tokenSettings: accessContext.scope === 'full' ? savedTokenSettings : savedTokenSettings.filter((item) => item.id === accessContext.tokenSetting.id),
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.',
});
}
});
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) {
sendAppConfigAccessDenied(reply);
return;
}
try {
let payload: unknown = request.body ?? {};

View File

@@ -0,0 +1,300 @@
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,
deleteBaseballTicketBayLog,
deleteBaseballTicketBayAlert,
listBaseballTicketBayAlerts,
listBaseballTicketBayLogs,
runBaseballTicketBayAlert,
searchBaseballTicketBayListings,
updateBaseballTicketBayAlert,
} from '../services/baseball-ticket-bay-service.js';
const timeWindowSchema = z.object({
id: z.string().trim().min(1),
start: z.string().trim().regex(/^\d{2}:\d{2}$/),
end: z.string().trim().regex(/^\d{2}:\d{2}$/),
});
const alertPayloadSchema = z.object({
title: z.string().trim().min(1).max(255),
eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
team: z.string().trim().min(1).max(50),
zone: z.string().trim().min(1).max(100),
aisleSide: z.string().trim().min(1).max(100),
seatDirections: z.array(z.string().trim().min(1).max(50)).max(10),
maxPrice: z.number().finite().positive().nullable(),
seatCount: z.number().int().positive().max(10),
batchIntervalMinutes: z.number().int().min(1).max(120),
sameProductAlertEnabled: z.boolean(),
sameProductNotifyOnce: z.boolean(),
active: z.boolean().default(true),
timeWindows: z.array(timeWindowSchema).min(1).max(24),
});
function readHeader(request: { headers: Record<string, string | string[] | undefined> }, key: string) {
const raw = request.headers[key];
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
}
function hasBaseballTicketBayGlobalAccess(request: { headers: Record<string, string | string[] | undefined> }) {
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<BaseballTicketBayRouteAccessContext, { scope: 'all' }> | { 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<string, string | string[] | undefined> },
) : Promise<BaseballTicketBayRouteAccessContext | null> {
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 accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext) {
return reply.code(400).send({ message: '접근 식별값이 없어 알림 목록을 불러올 수 없습니다.' });
}
return {
ok: true,
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 accessContext = await resolveBaseballTicketBayAccessContext(request);
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,
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 accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext) {
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, toOwnerScope(accessContext));
if (!item) {
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
}
return {
ok: true,
item,
};
});
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext || accessContext.scope === 'all') {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 저장할 수 없습니다.' });
}
const payload = alertPayloadSchema.parse(request.body ?? {});
const item = await createBaseballTicketBayAlert(payload, {
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: 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',
status: 'info',
message: '알림 조건을 저장했습니다.',
detail: `${item.team} · ${item.eventDate}`,
});
return {
ok: true,
item,
};
});
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext) {
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,
accessContext.scope === 'all'
? {
scope: { kind: 'all' },
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
}
: {
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: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
alertId: item.id,
alertTitle: item.title,
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
status: 'info',
message:
payload.active === false
? '알림을 중지했습니다.'
: payload.active === true
? '알림을 다시 실행 상태로 전환했습니다.'
: '알림 조건을 수정 저장했습니다.',
detail: `${item.team} · ${item.eventDate}`,
});
return {
ok: true,
item,
};
});
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!accessContext) {
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, toOwnerScope(accessContext));
if (!item) {
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
}
await createBaseballTicketBayLog({
clientId: item.clientId,
ownerType: item.ownerType,
ownerId: item.ownerId,
alertId: item.id,
alertTitle: item.title,
action: 'delete',
status: 'info',
message: '알림 항목을 삭제했습니다.',
detail: `${item.team} · ${item.eventDate}`,
});
return {
ok: true,
item,
};
});
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
const accessContext = await resolveBaseballTicketBayAccessContext(request);
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,
scope: toOwnerScope(accessContext),
});
return {
ok: true,
alert: result.alert,
matches: result.matches,
notifiedMatches: result.notifiedMatches,
log: result.log,
};
});
}

View File

@@ -1,6 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { resolveStaticContentType } from './chat.js';
import Fastify from 'fastify';
import { registerChatRoutes, resolvePromptFollowupMode, resolveStaticContentType } from './chat.js';
const repoRoot = path.resolve(process.cwd(), '../../..');
async function removeSessionUploads(sessionId: string) {
await fs.rm(path.join(repoRoot, 'public', '.codex_chat', sessionId), {
recursive: true,
force: true,
});
}
test('resolveStaticContentType returns html content type for chat resource html files', () => {
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
@@ -11,3 +23,73 @@ test('resolveStaticContentType keeps plain text content type for code resources'
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
});
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');
});
test('chat attachments accept binary octet-stream uploads without base64 expansion', async () => {
const app = Fastify();
await registerChatRoutes(app);
const sessionId = `binary-upload-${Date.now()}`;
const payload = Buffer.alloc(829_627, 1);
try {
const response = await app.inject({
method: 'POST',
url: '/api/chat/attachments',
headers: {
'content-type': 'application/octet-stream',
'x-chat-attachment-session-id': sessionId,
'x-chat-attachment-file-name': encodeURIComponent('image.png'),
'x-chat-attachment-mime-type': encodeURIComponent('image/png'),
},
payload,
});
assert.equal(response.statusCode, 200);
const body = response.json() as { ok: boolean; item: { path: string; size: number; mimeType: string } };
assert.equal(body.ok, true);
assert.equal(body.item.size, payload.byteLength);
assert.equal(body.item.mimeType, 'image/png');
assert.match(body.item.path, new RegExp(`^public/\\.codex_chat/${sessionId}/resource/uploads/.+image\\.png$`));
} finally {
await removeSessionUploads(sessionId);
await app.close();
}
});
test('chat attachments keep legacy JSON base64 uploads working', async () => {
const app = Fastify();
await registerChatRoutes(app);
const sessionId = `json-upload-${Date.now()}`;
try {
const response = await app.inject({
method: 'POST',
url: '/api/chat/attachments',
headers: {
'content-type': 'application/json',
},
payload: JSON.stringify({
sessionId,
fileName: 'note.txt',
mimeType: 'text/plain',
contentBase64: Buffer.from('hello', 'utf8').toString('base64'),
}),
});
assert.equal(response.statusCode, 200);
const body = response.json() as { ok: boolean; item: { size: number; mimeType: string; name: string } };
assert.equal(body.ok, true);
assert.equal(body.item.size, 5);
assert.equal(body.item.mimeType, 'text/plain');
assert.equal(body.item.name, 'note.txt');
} finally {
await removeSessionUploads(sessionId);
await app.close();
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,28 @@
import type { FastifyInstance } from 'fastify';
import { getActiveChatService } from '../services/chat-service.js';
import { getRuntimeDrainSnapshot } from '../services/runtime-drain-service.js';
import { getRuntimeWorkServerBuildInfo } from '../services/work-server-build-service.js';
export async function registerHealthRoutes(app: FastifyInstance) {
const respondHealth = async () => ({
ok: true,
service: 'work-server',
timestamp: new Date().toISOString(),
});
const respondHealth = async () => {
const buildInfo = getRuntimeWorkServerBuildInfo();
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
return {
ok: true,
service: 'work-server',
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
timestamp: new Date().toISOString(),
buildId: buildInfo?.buildId ?? null,
builtAt: buildInfo?.builtAt ?? null,
...getRuntimeDrainSnapshot(),
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
};
};
app.get('/', respondHealth);
app.get('/api', respondHealth);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { db } from '../db/client.js';
type PlayAppEnvironment = 'preview' | 'test' | 'prod';
type PlayAppSeedEntry = {
id: string;
name: string;
accentClassName: string;
statusLabel: string;
isReady: boolean;
iconName: string;
usagePriority?: number;
supportedEnvironments: PlayAppEnvironment[];
searchKeywords?: string[];
searchDescription?: string;
};
const PLAY_APP_TABLE = 'play_apps';
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function ensurePlayAppWriteAuthorized(request: FastifyRequest, reply: FastifyReply) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
}
reply.code(403).send({
message: '권한 토큰이 필요합니다.',
});
return false;
}
const DEFAULT_ENTRIES: PlayAppSeedEntry[] = [
{
id: 'baseball-ticket-bay',
name: '야구-티켓베이',
accentClassName: 'apps-library__card--baseball-ticket-bay',
statusLabel: '알림',
isReady: true,
iconName: 'BellOutlined',
usagePriority: 100,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'],
searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.',
},
{
id: 'e-reader',
name: 'E-Reader',
accentClassName: 'apps-library__card--reader',
statusLabel: '읽기',
isReady: true,
iconName: 'BookOutlined',
usagePriority: 80,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
},
{
id: 'photoprism',
name: 'PhotoPrism',
accentClassName: 'apps-library__card--photoprism',
statusLabel: '연결',
isReady: true,
iconName: 'FileImageOutlined',
usagePriority: 70,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
},
{
id: 'photo-puzzle',
name: '사진 퍼즐',
accentClassName: 'apps-library__card--puzzle',
statusLabel: '실행',
isReady: true,
iconName: 'PictureOutlined',
usagePriority: 60,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
},
{
id: 'the-quest',
name: 'The Quest',
accentClassName: 'apps-library__card--the-quest',
statusLabel: '신규',
isReady: true,
iconName: 'ThunderboltOutlined',
usagePriority: 50,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
},
{
id: 'template1',
name: 'Template1',
accentClassName: 'apps-library__card--template1',
statusLabel: '템플릿',
isReady: true,
iconName: 'AppstoreAddOutlined',
usagePriority: 45,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['template1', 'template', '앱 템플릿', '레이아웃', '기본 UI', 'layout'],
searchDescription: '다른 앱 개발 시 공통 레이아웃을 빠르게 적용하기 위한 템플릿 화면입니다.',
},
{
id: 'tetris',
name: 'Tetris',
accentClassName: 'apps-library__card--tetris',
statusLabel: '실행',
isReady: true,
iconName: 'FundProjectionScreenOutlined',
usagePriority: 40,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',
},
{
id: 'beat-lab',
name: 'Beat Lab',
accentClassName: 'apps-library__card--beat',
statusLabel: '준비',
isReady: false,
iconName: 'SoundOutlined',
usagePriority: 35,
supportedEnvironments: ['preview'],
searchDescription: '향후 추가 예정인 Beat Lab 앱',
},
{
id: 'sticker-booth',
name: 'Sticker Booth',
accentClassName: 'apps-library__card--sticker',
statusLabel: '준비',
isReady: false,
iconName: 'StarOutlined',
usagePriority: 30,
supportedEnvironments: ['preview'],
searchDescription: '향후 추가 예정인 Sticker Booth 앱',
},
{
id: 'launch-note',
name: 'Launch Note',
accentClassName: 'apps-library__card--launch',
statusLabel: '예정',
isReady: false,
iconName: 'RocketOutlined',
usagePriority: 20,
supportedEnvironments: ['preview'],
searchDescription: '향후 추가 예정인 Launch Note 앱',
},
{
id: 'arcade-pack',
name: 'Arcade Pack',
accentClassName: 'apps-library__card--arcade',
statusLabel: '예정',
isReady: false,
iconName: 'FireOutlined',
usagePriority: 10,
supportedEnvironments: ['preview'],
searchDescription: '향후 추가 예정인 Arcade Pack 앱',
},
{
id: 'app-vault',
name: 'App Vault',
accentClassName: 'apps-library__card--vault',
statusLabel: '테마',
isReady: false,
iconName: 'AppstoreOutlined',
usagePriority: 0,
supportedEnvironments: ['preview'],
searchDescription: '향후 추가 예정인 App Vault 앱',
},
] ;
const listQuerySchema = z.object({
environment: z.enum(['preview', 'test', 'prod']).optional(),
});
const playAppIdSchema = z.string().trim().min(1).max(100);
const supportedEnvironmentSchema = z.enum(['preview', 'test', 'prod']);
const iconNameSchema = z.enum([
'AppstoreOutlined',
'AppstoreAddOutlined',
'BellOutlined',
'BookOutlined',
'FireOutlined',
'FundProjectionScreenOutlined',
'FileImageOutlined',
'PictureOutlined',
'RocketOutlined',
'SoundOutlined',
'StarOutlined',
'ThunderboltOutlined',
]);
const playAppCreatePayloadSchema = z.object({
id: playAppIdSchema,
name: z.string().trim().min(1).max(120),
accentClassName: z.string().trim().min(1).max(80),
statusLabel: z.string().trim().min(1).max(80),
isReady: z.boolean().default(false),
iconName: iconNameSchema,
usagePriority: z.number().int().min(0).max(1_000_000).optional(),
supportedEnvironments: z.array(supportedEnvironmentSchema).min(1).default(['preview']),
searchKeywords: z
.array(z.string().trim().min(1).max(80))
.default([])
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0)))),
searchDescription: z.string().trim().max(4000).default(''),
});
const playAppUpdatePayloadSchema = z
.object({
id: playAppIdSchema.optional(),
name: z.string().trim().min(1).max(120).optional(),
accentClassName: z.string().trim().min(1).max(80).optional(),
statusLabel: z.string().trim().min(1).max(80).optional(),
isReady: z.boolean().optional(),
iconName: iconNameSchema.optional(),
usagePriority: z.number().int().min(0).max(1_000_000).nullable().optional(),
supportedEnvironments: z.array(supportedEnvironmentSchema).optional(),
searchKeywords: z
.array(z.string().trim().min(1).max(80))
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0))))
.optional(),
searchDescription: z.string().trim().max(4000).optional(),
})
.refine((payload) => Object.keys(payload).length > 0, {
message: '수정할 항목이 없습니다.',
});
const playAppIdParamsSchema = z.object({
id: playAppIdSchema,
});
type SupportedEnvironment = Array<PlayAppEnvironment>;
type PlayAppRegistryRecord = {
id: string;
name: string;
accent_class_name: string;
status_label: string;
is_ready: boolean;
icon_name: string;
usage_priority: number | null;
supported_environments: string | null;
search_keywords: string | null;
search_description: string | null;
};
type PlayAppRow = {
id: string;
name: string;
accentClassName: string;
statusLabel: string;
isReady: boolean;
iconName: string;
usagePriority?: number;
supportedEnvironments: SupportedEnvironment;
searchKeywords: string[];
searchDescription: string;
};
type PlayAppCreateInput = z.infer<typeof playAppCreatePayloadSchema>;
type PlayAppUpdateInput = z.infer<typeof playAppUpdatePayloadSchema>;
function normalizeSupportedEnvironments(value: string | null): SupportedEnvironment {
if (!value) {
return ['preview'];
}
const trimmed = value.trim();
if (!trimmed) {
return ['preview'];
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.map((item) => String(item).trim())
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
}
} catch {
// fall through to comma parser below.
}
}
return value
.trim()
.split(',')
.map((item) => item.trim().toLowerCase())
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
}
function parseJsonArrayList(value: string | null): string[] {
if (!value) {
return [];
}
const trimmed = value.trim();
if (!trimmed) {
return [];
}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
} catch {
// fallthrough to legacy parser below.
}
}
return trimmed
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function toResponseItem(row: PlayAppRegistryRecord): PlayAppRow {
return {
id: row.id,
name: row.name,
accentClassName: row.accent_class_name,
statusLabel: row.status_label,
isReady: !!row.is_ready,
iconName: row.icon_name,
usagePriority: row.usage_priority ?? undefined,
supportedEnvironments: normalizeSupportedEnvironments(row.supported_environments),
searchKeywords: parseJsonArrayList(row.search_keywords),
searchDescription: row.search_description ?? '',
};
}
function encodeJsonList(values: readonly string[] | null | undefined) {
if (!values || values.length === 0) {
return '[]';
}
return JSON.stringify(Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))));
}
function toDbRowPayload(input: PlayAppCreateInput | Omit<PlayAppUpdateInput, 'id'>) {
const payload: Record<string, unknown> = {
id: 'id' in input ? input.id?.trim() : undefined,
name: input.name?.trim(),
accent_class_name: input.accentClassName?.trim(),
status_label: input.statusLabel?.trim(),
is_ready: input.isReady,
icon_name: input.iconName,
usage_priority: input.usagePriority ?? null,
supported_environments: input.supportedEnvironments ? encodeJsonList(input.supportedEnvironments) : undefined,
search_keywords: input.searchKeywords ? encodeJsonList(input.searchKeywords) : undefined,
search_description: input.searchDescription ? input.searchDescription.trim() : undefined,
};
Object.entries(payload).forEach(([key, value]) => {
if (value === undefined) {
delete payload[key];
}
});
if ('is_ready' in payload && payload.is_ready === undefined) {
payload.is_ready = false;
}
return payload;
}
function isDbUpdateResultEmpty(result: unknown) {
if (Array.isArray(result)) {
return result.length === 0;
}
return typeof result === 'number' ? result === 0 : false;
}
function parsePlayAppErrorWithCode(error: unknown, fallbackMessage: string) {
if (error instanceof Error) {
const code = (error as { code?: string }).code;
if (code === 'ER_DUP_ENTRY' || code === '23505') {
const duplicateError = error as Error & { statusCode?: number; details?: string };
duplicateError.statusCode = 409;
duplicateError.message = '이미 등록된 앱 ID입니다.';
return duplicateError;
}
}
if (error instanceof Error) {
const duplicateError = error as Error & { statusCode?: number; details?: string };
duplicateError.message = fallbackMessage;
return duplicateError;
}
const next = new Error(fallbackMessage) as Error & { statusCode?: number };
next.statusCode = 500;
return next;
}
async function ensurePlayAppTable() {
const exists = await db.schema.hasTable(PLAY_APP_TABLE);
if (!exists) {
await db.schema.createTable(PLAY_APP_TABLE, (table) => {
table.string('id', 100).primary();
table.string('name', 120).notNullable();
table.string('accent_class_name', 80).notNullable();
table.string('status_label', 80).notNullable();
table.boolean('is_ready').notNullable().defaultTo(false);
table.string('icon_name', 80).notNullable();
table.integer('usage_priority').nullable();
table.text('supported_environments').nullable();
table.text('search_keywords').nullable();
table.text('search_description').nullable();
table.index('is_ready', 'play_apps_is_ready_idx');
});
}
const existingRows = await db(PLAY_APP_TABLE).select('id');
const existingIds = new Set(existingRows.map((row) => row.id));
const rowsToInsert = DEFAULT_ENTRIES.filter((entry) => !existingIds.has(entry.id)).map((entry) => ({
id: entry.id,
name: entry.name,
accent_class_name: entry.accentClassName,
status_label: entry.statusLabel,
is_ready: entry.isReady,
icon_name: entry.iconName,
usage_priority: entry.usagePriority,
supported_environments: encodeJsonList(entry.supportedEnvironments),
search_keywords: encodeJsonList(entry.searchKeywords ?? []),
search_description: entry.searchDescription ?? '',
}));
if (rowsToInsert.length === 0) {
return;
}
await db(PLAY_APP_TABLE).insert(rowsToInsert);
}
async function listPlayAppEntries(environment?: PlayAppEnvironment | null) {
await ensurePlayAppTable();
const rows = (await db(PLAY_APP_TABLE)
.select<PlayAppRegistryRecord[]>('*')
.orderBy('usage_priority', 'desc')
.orderBy('id', 'asc')) as PlayAppRegistryRecord[];
const normalizedRows = rows.map(toResponseItem);
const filteredRows = environment ? normalizedRows.filter((row) => row.supportedEnvironments.includes(environment)) : normalizedRows;
return filteredRows;
}
async function createPlayAppEntry(body: unknown) {
await ensurePlayAppTable();
const payload = playAppCreatePayloadSchema.parse(body);
const dbPayload = toDbRowPayload(payload);
try {
await db(PLAY_APP_TABLE).insert(dbPayload);
} catch (error) {
throw parsePlayAppErrorWithCode(error, `앱 등록에 실패했습니다: ${payload.id}`);
}
const insertedRow = await db(PLAY_APP_TABLE)
.where({ id: payload.id })
.first<PlayAppRegistryRecord>();
if (!insertedRow) {
const notFoundError = new Error('등록된 앱을 조회하지 못했습니다.') as Error & { statusCode?: number };
notFoundError.statusCode = 500;
throw notFoundError;
}
return {
ok: true,
item: toResponseItem(insertedRow),
};
}
async function updatePlayAppEntry(params: unknown, body: unknown) {
await ensurePlayAppTable();
const { id } = playAppIdParamsSchema.parse(params);
const payload = playAppUpdatePayloadSchema.parse(body);
if (payload.id && payload.id !== id) {
const invalidError = new Error('요청 경로 ID와 본문 ID가 일치하지 않습니다.') as Error & { statusCode?: number };
invalidError.statusCode = 409;
throw invalidError;
}
const dbPayload = toDbRowPayload(payload);
const updated = await db(PLAY_APP_TABLE).where({ id }).update(dbPayload as Record<string, unknown>);
if (isDbUpdateResultEmpty(updated)) {
const notFoundError = new Error(`수정할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
notFoundError.statusCode = 404;
throw notFoundError;
}
const updatedRow = await db(PLAY_APP_TABLE).where({ id }).first<PlayAppRegistryRecord>();
if (!updatedRow) {
const notFoundError = new Error(`수정한 앱을 조회할 수 없습니다: ${id}`) as Error & { statusCode?: number };
notFoundError.statusCode = 404;
throw notFoundError;
}
return {
ok: true,
item: toResponseItem(updatedRow),
};
}
async function deletePlayAppEntry(params: unknown) {
await ensurePlayAppTable();
const { id } = playAppIdParamsSchema.parse(params);
const deleted = await db(PLAY_APP_TABLE).where({ id }).delete('*');
if (isDbUpdateResultEmpty(deleted)) {
const notFoundError = new Error(`삭제할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
notFoundError.statusCode = 404;
throw notFoundError;
}
if (Array.isArray(deleted) && deleted[0]) {
return {
ok: true,
deletedId: id,
item: toResponseItem(deleted[0] as PlayAppRegistryRecord),
};
}
return {
ok: true,
deletedId: id,
};
}
export async function registerPlayAppRoutes(app: FastifyInstance) {
app.get('/api/play-apps', async (request) => {
const query = listQuerySchema.parse(request.query);
const items = await listPlayAppEntries(query.environment);
return {
ok: true,
items,
};
});
app.post('/api/play-apps', async (request, reply) => {
if (!ensurePlayAppWriteAuthorized(request, reply)) {
return;
}
return createPlayAppEntry(request.body);
});
app.put('/api/play-apps/:id', async (request, reply) => {
if (!ensurePlayAppWriteAuthorized(request, reply)) {
return;
}
return updatePlayAppEntry(request.params, request.body);
});
app.delete('/api/play-apps/:id', async (request, reply) => {
if (!ensurePlayAppWriteAuthorized(request, reply)) {
return;
}
return deletePlayAppEntry(request.params);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import Fastify from 'fastify';
import { env } from '../config/env.js';
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
const legacyPublicResourceRoot = path.resolve(process.cwd(), '../../../public/resource');
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
isValid: true,
start: 5,
end: 19,
});
assert.deepEqual(resolveSingleRange('bytes=-4', 20), {
isValid: true,
start: 16,
end: 19,
});
});
test('resolveSingleRange rejects malformed or out-of-bounds values', () => {
assert.deepEqual(resolveSingleRange('bytes=', 20), { isValid: false });
assert.deepEqual(resolveSingleRange('bytes=25-30', 20), { isValid: false });
assert.deepEqual(resolveSingleRange('bytes=4-3', 20), { isValid: false });
});
test('resource manager preview serves 206 partial content for byte ranges', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = `range-test-${Date.now()}.wav`;
const absolutePath = path.join(fallbackResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, Buffer.from('0123456789', 'utf8'));
try {
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${encodeURIComponent(relativePath)}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
headers: {
range: 'bytes=2-5',
},
});
assert.equal(response.statusCode, 206);
assert.equal(response.headers['accept-ranges'], 'bytes');
assert.equal(response.headers['content-range'], 'bytes 2-5/10');
assert.equal(response.headers['content-length'], '4');
assert.equal(response.body, '2345');
} finally {
await fs.rm(absolutePath, { force: true });
await app.close();
}
});
test('resource manager preview falls back to public/resource legacy artifacts', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = path.join('legacy-preview-test', `sample-${Date.now()}.html`);
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, '<!doctype html><html><body>legacy preview</body></html>', 'utf8');
try {
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
});
assert.equal(response.statusCode, 200);
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
assert.match(response.body, /legacy preview/);
} finally {
await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true });
await app.close();
}
});
test('resource manager preview restores encoded hash fragments in the file name', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`);
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, '<!doctype html><html><body>encoded hash preview</body></html>', 'utf8');
try {
for (const encodedSuffix of ['%23option-a', '%2523option-a']) {
const encodedPath = relativePath
.split(path.sep)
.map((segment, index, list) =>
index === list.length - 1
? `${encodeURIComponent(segment)}${encodedSuffix}`
: encodeURIComponent(segment),
)
.join('/');
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
});
assert.equal(response.statusCode, 200);
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
assert.match(response.body, /encoded hash preview/);
}
} finally {
await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true });
await app.close();
}
});

View File

@@ -48,6 +48,51 @@ const copyMoveBodySchema = z.object({
nextName: z.string().trim().max(255).optional().nullable(),
});
export function resolveSingleRange(rangeHeader: string | undefined, fileSize: number) {
const rangeValue = String(rangeHeader ?? '').trim();
if (!rangeValue) {
return null;
}
const match = /^bytes=(\d*)-(\d*)$/u.exec(rangeValue);
if (!match) {
return { isValid: false } as const;
}
const [, startRaw, endRaw] = match;
if (!startRaw && !endRaw) {
return { isValid: false } as const;
}
if (!startRaw) {
const suffixLength = Number(endRaw);
if (!Number.isInteger(suffixLength) || suffixLength <= 0) {
return { isValid: false } as const;
}
const start = Math.max(fileSize - suffixLength, 0);
const end = fileSize - 1;
return start <= end ? { isValid: true, start, end } as const : { isValid: false } as const;
}
const start = Number(startRaw);
const end = endRaw ? Number(endRaw) : fileSize - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= fileSize) {
return { isValid: false } as const;
}
return {
isValid: true,
start,
end: Math.min(end, fileSize - 1),
} as const;
}
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
@@ -123,10 +168,29 @@ export async function registerResourceManagerRoutes(app: FastifyInstance) {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
const preview = await openResourceManagerPreviewStream(resolveRepoRootPath(), decodeURIComponent(wildcard));
const rangeHeader = Array.isArray(request.headers.range) ? request.headers.range[0] : request.headers.range;
const range = resolveSingleRange(rangeHeader, preview.size);
reply.header('Cache-Control', 'no-store');
reply.header('Accept-Ranges', 'bytes');
reply.type(preview.contentType);
return reply.send(preview.stream);
if (range) {
if (!range.isValid) {
reply.status(416);
reply.header('Content-Range', `bytes */${preview.size}`);
return reply.send();
}
const contentLength = range.end - range.start + 1;
reply.status(206);
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${preview.size}`);
reply.header('Content-Length', String(contentLength));
return reply.send(preview.createStream({ start: range.start, end: range.end }));
}
reply.header('Content-Length', String(preview.size));
return reply.send(preview.createStream());
});
app.post('/api/resource-manager/directories', async (request, reply) => {

View File

@@ -0,0 +1,55 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getActiveChatService } from '../services/chat-service.js';
import {
beginRuntimeDrain,
endRuntimeDrain,
getRuntimeDrainSnapshot,
} from '../services/runtime-drain-service.js';
const runtimeDrainBodySchema = z.object({
draining: z.boolean(),
});
function buildRuntimeResponse() {
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
return {
ok: true,
...getRuntimeDrainSnapshot(),
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
};
}
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 ?? {});
if (draining) {
beginRuntimeDrain();
} else {
endRuntimeDrain();
}
return buildRuntimeResponse();
});
}

View File

@@ -1,7 +1,16 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-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,
@@ -16,8 +25,10 @@ const serverCommandParamSchema = z.object({
});
const restartReservationBodySchema = z.object({
target: z.enum(['all', 'test', 'work-server']).optional(),
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
});
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
function getImmediateRestartBlockInfo(
key: z.infer<typeof serverCommandParamSchema>['key'],
@@ -39,11 +50,8 @@ function getImmediateRestartBlockInfo(
return null;
}
if (key === 'work-server' && automationPendingCount > 0) {
return {
pendingCount: automationPendingCount,
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
};
if (key === 'work-server') {
return null;
}
return null;
@@ -54,6 +62,39 @@ function getRequestAccessToken(request: FastifyRequest) {
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-chat-share-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function resolveChatSharePath(token: string) {
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
}
async function resolveSharedServerCommandAccessContext(request: FastifyRequest) {
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('server-command')) {
return null;
}
return {
scope: 'shared' as const,
allowedKeys: new Set<string>(['work-server', 'test']),
};
}
function getRequestClientId(request: FastifyRequest) {
const clientIdHeader = request.headers['x-client-id'];
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
@@ -72,36 +113,48 @@ function getRequestAppOrigin(request: FastifyRequest) {
return origin?.trim() ?? '';
}
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
async function resolveServerCommandAccessContext(request: FastifyRequest) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
return { scope: 'full' as const };
}
return resolveSharedServerCommandAccessContext(request);
}
function sendAccessDenied(reply: FastifyReply) {
reply.status(403);
void reply.send({
message: '권한 토큰 필요합니다.',
message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.',
});
return false;
}
export async function registerServerCommandRoutes(app: FastifyInstance) {
app.get('/api/server-commands', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const items = await listServerCommands();
return {
ok: true,
items: await listServerCommands(),
items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)),
};
});
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const { key } = serverCommandParamSchema.parse(request.params);
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has(key)) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 이 서버를 재기동할 수 없습니다.' };
}
if (key === 'test' || key === 'work-server') {
const workloadSummary = await getRestartReservationWorkloadSummary();
@@ -128,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;
@@ -153,8 +212,103 @@ 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) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -165,7 +319,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -181,9 +337,14 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
const parsed = restartReservationBodySchema.parse(payload ?? {});
if (accessContext.scope !== 'full' && parsed.target !== 'work-server') {
return reply.status(403).send({ message: '현재 공유채팅 링크로는 WORK 서버 재기동 예약만 사용할 수 있습니다.' });
}
return {
ok: true,
item: await scheduleServerRestartReservation({
target: parsed.target,
clientId: getRequestClientId(request),
appOrigin: getRequestAppOrigin(request),
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
@@ -192,7 +353,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -203,7 +366,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}

View File

@@ -0,0 +1,359 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import {
deleteSharedResourceTokens,
deleteSharedResourceToken,
getSharedResourceTokenDetail,
getSharedResourceTokenDetailBySharePath,
listSharedResourceTokens,
recordSharedResourceTokenUsage,
restoreSharedResourceToken,
revokeSharedResourceToken,
revokeSharedResourceTokens,
sharedResourceTokenSchema,
upsertSharedResourceToken,
} from '../services/shared-resource-token-service.js';
import { extractRequestAuditContext } from '../utils/request-audit.js';
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-chat-share-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function resolveChatSharePath(token: string) {
return `/chat/share/${encodeURIComponent(token)}`;
}
type SharedResourceTokenAccessContext =
| { scope: 'full' }
| { scope: 'shared'; tokenId: string };
async function resolveSharedResourceTokenAccessContext(
request: { headers: Record<string, string | string[] | undefined> },
) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return { scope: 'full' } satisfies SharedResourceTokenAccessContext;
}
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('shared-resource')) {
return null;
}
return {
scope: 'shared',
tokenId: managedResource.token.id,
} satisfies SharedResourceTokenAccessContext;
}
function sendAccessDenied(
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
) {
reply.code(403).send({
message: '권한 토큰 또는 shared-resource 관리 권한이 있는 공유 링크에서만 공유 리소스 관리를 사용할 수 있습니다.',
});
}
function isAllowedSharedTokenTarget(accessContext: SharedResourceTokenAccessContext, tokenId: string) {
return accessContext.scope === 'full' || accessContext.tokenId === tokenId;
}
export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
app.get('/api/shared-resource-tokens', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const sharedTokenDetail =
accessContext.scope === 'shared' ? await getSharedResourceTokenDetail(accessContext.tokenId) : null;
const items =
accessContext.scope === 'full'
? await listSharedResourceTokens()
: sharedTokenDetail?.token
? [sharedTokenDetail.token]
: [];
return {
ok: true,
items,
};
});
app.get('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크로는 이 공유 토큰 상세를 열 수 없습니다.',
});
}
const item = await getSharedResourceTokenDetail(tokenId);
if (!item) {
return reply.code(404).send({
message: '공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...item,
};
});
app.put('/api/shared-resource-tokens', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
try {
const payload = sharedResourceTokenSchema.parse(request.body ?? {});
if (accessContext.scope === 'shared' && payload.id !== accessContext.tokenId) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰 상세만 수정할 수 있습니다.',
});
}
const saved = await upsertSharedResourceToken(payload, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
return {
ok: true,
...saved,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.',
});
}
});
app.post('/api/shared-resource-tokens/bulk-revoke', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const payload = z
.object({
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
reason: z.string().trim().max(500).optional().nullable(),
})
.parse(request.body ?? {});
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
});
}
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
return {
ok: true,
...result,
};
});
app.post('/api/shared-resource-tokens/:tokenId/revoke', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
});
}
const payload = z
.object({
reason: z.string().trim().max(500).optional().nullable(),
})
.parse(request.body ?? {});
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({
message: '회수할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...saved,
};
});
app.post('/api/shared-resource-tokens/:tokenId/restore', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 복원할 수 있습니다.',
});
}
const saved = await restoreSharedResourceToken(tokenId, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
if (!saved) {
return reply.code(404).send({
message: '복원할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...saved,
};
});
app.post('/api/shared-resource-tokens/:tokenId/usage', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 기록할 수 있습니다.',
});
}
const payload = z
.object({
actorLabel: z.string().trim().max(120).optional().nullable(),
summary: z.string().trim().max(400).optional().nullable(),
detail: z.string().trim().max(2000).optional().nullable(),
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
})
.parse(request.body ?? {});
const saved = await recordSharedResourceTokenUsage(tokenId, {
...payload,
audit: extractRequestAuditContext(request),
});
if (!saved) {
return reply.code(404).send({
message: '사용량을 기록할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...saved,
};
});
app.post('/api/shared-resource-tokens/bulk-delete', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const payload = z
.object({
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
})
.parse(request.body ?? {});
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
});
}
const result = await deleteSharedResourceTokens(payload.tokenIds, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
return {
ok: true,
...result,
};
});
app.delete('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
});
}
const deleted = await deleteSharedResourceToken(tokenId, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
if (!deleted) {
return reply.code(404).send({
message: '삭제할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
deleted: true,
tokenId,
};
});
}

View File

@@ -0,0 +1,357 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../db/client.js';
const TEST_APP_TABLE = 'test_app_maintenance_requests';
const TEST_APP_SEED_COUNT = 7000;
const PRIORITY_VALUES = ['긴급', '높음', '보통', '낮음'] as const;
const STATUS_VALUES = ['접수', '배정완료', '조치중', '부품대기', '완료'] as const;
const ISSUE_TYPES = ['센서 오차', '진동 이상', '누유 감지', '온도 상승', '부품 마모', '통신 장애'] as const;
const REQUESTERS = ['김민재', '박서윤', '이도윤', '최하린', '정서준', '한지민'] as const;
const ASSIGNEES = ['윤태호', '장우진', '서가은', '임현수', '강다온', '문시우'] as const;
const LINE_EQUIPMENT_MAP = {
PKG: ['실링기 1호', '실링기 2호', '포장로봇 1호', '라벨러 2호'],
MFG: ['혼합기 A', '혼합기 B', '압출기 3호', '컨베이어 7호'],
UTL: ['냉각펌프 1호', '공조기 2호', '콤프레서 1호', '보일러 1호'],
QC: ['비전검사기 1호', '중량선별기 2호', '샘플러 1호', '검사컨베이어 1호'],
} as const;
const LINE_CODES = Object.keys(LINE_EQUIPMENT_MAP) as Array<keyof typeof LINE_EQUIPMENT_MAP>;
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(100),
keyword: z.string().trim().default(''),
lineCode: z.string().trim().optional(),
priority: z.string().trim().optional(),
status: z.string().trim().optional(),
requestedFrom: z.string().trim().optional(),
requestedTo: z.string().trim().optional(),
});
const updateItemSchema = z.object({
id: z.coerce.number().int().positive(),
priority: z.enum(PRIORITY_VALUES),
status: z.enum(STATUS_VALUES),
assigneeName: z.string().trim().max(80).optional(),
});
const saveBodySchema = z.object({
items: z.array(updateItemSchema).min(1).max(200),
});
const deleteParamsSchema = z.object({
id: z.coerce.number().int().positive(),
});
type TestAppRow = {
id: number;
request_no: string;
line_code: string;
equipment_name: string;
issue_type: (typeof ISSUE_TYPES)[number];
priority: (typeof PRIORITY_VALUES)[number];
requester_name: string;
assignee_name: string | null;
status: (typeof STATUS_VALUES)[number];
requested_at: Date | string;
last_action_at: Date | string;
};
function pad2(value: number) {
return String(value).padStart(2, '0');
}
function formatDateTime(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
}
function toResponseRow(row: TestAppRow) {
return {
id: row.id,
requestNo: row.request_no,
lineCode: row.line_code,
equipmentName: row.equipment_name,
issueType: row.issue_type,
priority: row.priority,
requesterName: row.requester_name,
assigneeName: row.assignee_name ?? '',
status: row.status,
requestedAt: formatDateTime(row.requested_at),
lastActionAt: formatDateTime(row.last_action_at),
};
}
function pickPriority(index: number) {
const ratio = index % 100;
if (ratio < 6) {
return '긴급' as const;
}
if (ratio < 24) {
return '높음' as const;
}
if (ratio < 72) {
return '보통' as const;
}
return '낮음' as const;
}
function pickStatus(index: number, priority: (typeof PRIORITY_VALUES)[number]) {
const ratio = index % 100;
if (priority === '긴급') {
if (ratio < 28) {
return '접수' as const;
}
if (ratio < 54) {
return '배정완료' as const;
}
if (ratio < 86) {
return '조치중' as const;
}
if (ratio < 93) {
return '부품대기' as const;
}
return '완료' as const;
}
if (ratio < 22) {
return '접수' as const;
}
if (ratio < 45) {
return '배정완료' as const;
}
if (ratio < 70) {
return '조치중' as const;
}
if (ratio < 79) {
return '부품대기' as const;
}
return '완료' as const;
}
function buildSeedRow(index: number) {
const lineCode = LINE_CODES[index % LINE_CODES.length];
const equipmentList = LINE_EQUIPMENT_MAP[lineCode];
const equipmentName = equipmentList[Math.floor(index / LINE_CODES.length) % equipmentList.length];
const priority = pickPriority(index);
const status = pickStatus(index, priority);
const issueType = ISSUE_TYPES[(index * 3 + Math.floor(index / 7)) % ISSUE_TYPES.length];
const requesterName = REQUESTERS[(index * 5 + 1) % REQUESTERS.length];
const assigneeName = status === '접수' ? null : ASSIGNEES[(index * 7 + 2) % ASSIGNEES.length];
const requestedAt = new Date(Date.now() - ((index % (45 * 48)) * 30 + (index % 3) * 10) * 60_000);
const lastActionAt =
status === '접수'
? requestedAt
: new Date(requestedAt.getTime() + (((index % 9) + 1) * 45 + (priority === '긴급' ? 20 : 0)) * 60_000);
const requestNo = `JR-${requestedAt.getFullYear()}${pad2(requestedAt.getMonth() + 1)}${pad2(requestedAt.getDate())}-${String(1000 + index).padStart(4, '0')}`;
return {
request_no: requestNo,
line_code: lineCode,
equipment_name: equipmentName,
issue_type: issueType,
priority,
requester_name: requesterName,
assignee_name: assigneeName,
status,
requested_at: requestedAt,
last_action_at: lastActionAt,
};
}
async function ensureTestAppTable() {
const tableExists = await db.schema.hasTable(TEST_APP_TABLE);
if (!tableExists) {
await db.schema.createTable(TEST_APP_TABLE, (table) => {
table.increments('id').primary();
table.string('request_no', 40).notNullable().unique();
table.string('line_code', 20).notNullable();
table.string('equipment_name', 120).notNullable();
table.string('issue_type', 80).notNullable();
table.string('priority', 20).notNullable();
table.string('requester_name', 80).notNullable();
table.string('assignee_name', 80).nullable();
table.string('status', 40).notNullable();
table.timestamp('requested_at').notNullable().defaultTo(db.fn.now());
table.timestamp('last_action_at').notNullable().defaultTo(db.fn.now());
table.index(['requested_at', 'id'], 'test_app_requests_requested_at_idx');
table.index(['line_code', 'priority', 'status'], 'test_app_requests_filter_idx');
});
}
const countResult = await db(TEST_APP_TABLE).count<{ count: string }[]>({ count: '*' }).first();
const currentCount = Number(countResult?.count ?? 0);
if (currentCount >= TEST_APP_SEED_COUNT) {
return;
}
const chunkSize = 500;
const rowsToInsert = Array.from({ length: TEST_APP_SEED_COUNT - currentCount }, (_, offset) =>
buildSeedRow(currentCount + offset),
);
for (let index = 0; index < rowsToInsert.length; index += chunkSize) {
await db(TEST_APP_TABLE).insert(rowsToInsert.slice(index, index + chunkSize));
}
}
function applyListFilters(baseQuery: ReturnType<typeof db>, query: z.infer<typeof listQuerySchema>) {
if (query.keyword) {
baseQuery.where((builder) => {
builder
.whereILike('request_no', `%${query.keyword}%`)
.orWhereILike('equipment_name', `%${query.keyword}%`)
.orWhereILike('requester_name', `%${query.keyword}%`);
});
}
if (query.lineCode && query.lineCode !== '전체') {
baseQuery.where('line_code', query.lineCode);
}
if (query.priority && query.priority !== '전체') {
baseQuery.where('priority', query.priority);
}
if (query.status && query.status !== '전체') {
baseQuery.where('status', query.status);
}
if (query.requestedFrom) {
baseQuery.where('requested_at', '>=', new Date(`${query.requestedFrom}T00:00:00+09:00`));
}
if (query.requestedTo) {
baseQuery.where('requested_at', '<=', new Date(`${query.requestedTo}T23:59:59+09:00`));
}
}
async function handleListRequests(request: { query?: unknown }) {
await ensureTestAppTable();
const query = listQuerySchema.parse(request.query ?? {});
const offset = (query.page - 1) * query.pageSize;
const baseQuery = db(TEST_APP_TABLE);
applyListFilters(baseQuery, query);
const [countResult, rows] = await Promise.all([
baseQuery.clone().count<{ count: string }[]>({ count: '*' }).first(),
baseQuery
.clone()
.select<TestAppRow[]>('*')
.orderBy('requested_at', 'desc')
.orderBy('id', 'desc')
.limit(query.pageSize)
.offset(offset),
]);
const total = Number(countResult?.count ?? 0);
return {
ok: true,
items: rows.map(toResponseRow),
page: query.page,
pageSize: query.pageSize,
total,
hasNext: offset + rows.length < total,
filters: {
keyword: query.keyword,
lineCode: query.lineCode ?? '전체',
priority: query.priority ?? '전체',
status: query.status ?? '전체',
requestedFrom: query.requestedFrom ?? null,
requestedTo: query.requestedTo ?? null,
},
};
}
async function handleSaveRequests(request: { body?: unknown }) {
await ensureTestAppTable();
const payload = saveBodySchema.parse(request.body ?? {});
const ids = payload.items.map((item) => item.id);
const existingRows = await db(TEST_APP_TABLE).select<TestAppRow[]>('*').whereIn('id', ids);
const existingIdSet = new Set(existingRows.map((row) => row.id));
const missingIds = ids.filter((id) => !existingIdSet.has(id));
if (missingIds.length) {
const error = new Error(`저장할 요청을 찾을 수 없습니다: ${missingIds.join(', ')}`) as Error & { statusCode?: number };
error.statusCode = 404;
throw error;
}
const updatedRows: TestAppRow[] = [];
for (const item of payload.items) {
const [updatedRow] = await db(TEST_APP_TABLE)
.where({ id: item.id })
.update(
{
priority: item.priority,
status: item.status,
assignee_name: item.assigneeName?.trim() || null,
last_action_at: db.fn.now(),
},
'*',
);
if (updatedRow) {
updatedRows.push(updatedRow as TestAppRow);
}
}
return {
ok: true,
count: updatedRows.length,
items: updatedRows.map(toResponseRow),
};
}
async function handleDeleteRequest(request: { params?: unknown }) {
await ensureTestAppTable();
const { id } = deleteParamsSchema.parse(request.params ?? {});
const [deletedRow] = await db(TEST_APP_TABLE).where({ id }).delete('*');
if (!deletedRow) {
const error = new Error(`삭제할 요청을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
error.statusCode = 404;
throw error;
}
return {
ok: true,
deletedId: id,
item: toResponseRow(deletedRow as TestAppRow),
};
}
export async function registerTestAppRoutes(app: FastifyInstance) {
app.get('/api/test-app/maintenance-requests', handleListRequests);
app.get('/api/test-app/measurements', handleListRequests);
app.put('/api/test-app/maintenance-requests', handleSaveRequests);
app.put('/api/test-app/measurements', handleSaveRequests);
app.delete('/api/test-app/maintenance-requests/:id', handleDeleteRequest);
}

View File

@@ -5,10 +5,12 @@ import { ChatService } from './services/chat-service.js';
import { ensureChatConversationTables } from './services/chat-room-service.js';
import { shutdownNotificationProvider } from './services/notification-service.js';
import { ServerRestartReservationWorker } from './services/server-restart-reservation-service.js';
import { BaseballTicketBayWorker } from './workers/baseball-ticket-bay-worker.js';
import { PlanWorker } from './workers/plan-worker.js';
const app = createApp();
const planWorker = new PlanWorker(app.log);
const baseballTicketBayWorker = new BaseballTicketBayWorker(app.log);
const serverRestartReservationWorker = new ServerRestartReservationWorker(app.log);
const chatService = new ChatService(app.log);
const startedAt = Date.now();
@@ -24,6 +26,7 @@ async function start() {
port: env.PORT,
});
planWorker.start();
baseballTicketBayWorker.start();
serverRestartReservationWorker.start();
} catch (error) {
app.log.error(error);
@@ -46,6 +49,7 @@ async function shutdown(signal: string) {
try {
await planWorker.stop();
await baseballTicketBayWorker.stop();
await serverRestartReservationWorker.stop();
chatService.close();
await app.close();

View File

@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createDefaultChatTypeExecutionPolicy,
migrateLegacyChatTypeContexts,
sanitizePersistedChatTypes,
synchronizeBuiltinCodexChatTypes,
resolveAppConfigByOrigin,
resolveCanonicalChatTypesFromConfig,
resolveCanonicalChatContextSettingsFromConfig,
@@ -138,6 +140,34 @@ test('sanitizePersistedChatTypes keeps all saved chat types without special filt
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'general-request', 'plan-checklist-execution']);
});
test('synchronizeBuiltinCodexChatTypes upgrades legacy codex summary execution policy', () => {
const synced = synchronizeBuiltinCodexChatTypes([
{
id: 'codex-summary',
name: 'Codex 종합',
sortOrder: 13,
description:
'## 처리 범위\n- Codex 봇들의 대화와 의견 수렴 과정을 거친 뒤 최종 결과물을 정리해 제공하는 채팅에 사용합니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 결과를 정리합니다.\n\n## 응답 방식\n- 중간 대화에서 나온 핵심 의견, 판단 근거, 남은 쟁점을 압축해 정리합니다.\n- 최종 답변에는 필요한 산출물, 검증 결과, 적용 결론을 함께 제공합니다.',
executionPolicy: createDefaultChatTypeExecutionPolicy(),
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-17T00:00:00.000Z',
},
]);
const codexSummary = synced.find((item) => item.id === 'codex-summary');
const codexDispatcher = synced.find((item) => item.id === 'codex-dispatcher-workers');
const codexLiveDefault = synced.find((item) => item.id === 'codex-live-default');
assert.ok(codexSummary);
assert.equal(codexSummary.executionPolicy.mode, 'summary-free-talking');
assert.match(codexSummary.description, /회의 기록자 1명/);
assert.ok(codexDispatcher);
assert.equal(codexDispatcher.executionPolicy.mode, 'dispatcher-workers');
assert.ok(codexLiveDefault);
assert.equal(codexLiveDefault.executionPolicy.mode, 'default');
});
test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => {
const migrated = migrateLegacyChatTypeContexts(
{
@@ -157,6 +187,7 @@ test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into d
name: 'Plan 체크리스트 실행',
sortOrder: 1,
description: 'legacy plan context',
executionPolicy: createDefaultChatTypeExecutionPolicy(),
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',

View File

@@ -11,6 +11,20 @@ const CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings';
const SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs';
const SCOPED_CONTEXT_CONFIG_BACKUPS_KEY = 'scopedContextConfigBackups';
const SHARED_CHAT_CONTEXT_APP_ORIGIN = 'https://preview.sm-home.cloud';
const CODEX_LIVE_DEFAULT_CHAT_TYPE_ID = 'codex-live-default';
const CODEX_LIVE_DEFAULT_CHAT_TYPE_NAME = '기본처리';
const CODEX_LIVE_DEFAULT_CHAT_TYPE_DESCRIPTION =
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type=\"resource\"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type=\"html\"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인, 화면 테스트, 최종 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
const CODEX_SUMMARY_CHAT_TYPE_ID = 'codex-summary';
const CODEX_SUMMARY_CHAT_TYPE_NAME = 'Codex 종합';
const CODEX_SUMMARY_LEGACY_DESCRIPTION =
'## 처리 범위\n- Codex 봇들의 대화와 의견 수렴 과정을 거친 뒤 최종 결과물을 정리해 제공하는 채팅에 사용합니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 결과를 정리합니다.\n\n## 응답 방식\n- 중간 대화에서 나온 핵심 의견, 판단 근거, 남은 쟁점을 압축해 정리합니다.\n- 최종 답변에는 필요한 산출물, 검증 결과, 적용 결론을 함께 제공합니다.';
const CODEX_SUMMARY_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- 회의 기록자 1명과 프리토킹 Codex들이 함께 논점을 정리한 뒤 최종 결과를 보고하는 채팅에 사용합니다.\n- 사용자는 최종 결과를 우선 확인하고, 필요할 때만 중간 대화 흐름을 다시 확인합니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 결과를 정리합니다.\n\n## 응답 방식\n- 첫 Codex는 회의 기록자 겸 중재자로서 논점, 검증 기준, 확인 포인트를 정리합니다.\n- 이어지는 Codex들은 프리토킹으로 자유롭게 보완·반박·구현 의견을 제시합니다.\n- 마지막에는 회의 기록자가 최종 결론, 검증 결과, 남은 쟁점을 종합해 보고합니다.';
const CODEX_DISPATCHER_CHAT_TYPE_ID = 'codex-dispatcher-workers';
const CODEX_DISPATCHER_CHAT_TYPE_NAME = 'Codex 작업형';
const CODEX_DISPATCHER_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- 중계 지시자 1명과 실작업자 Codex들이 역할을 나눠 실제 작업을 진행하는 채팅에 사용합니다.\n- 필요하면 중계 지시자가 직접 최종 검토를 수행하고, 설정에 따라 별도 검토자를 지정할 수도 있습니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 실행합니다.\n\n## 응답 방식\n- 첫 Codex는 중계 지시자로서 작업을 역할·기준·검증 축으로 분해하고 담당을 배분합니다.\n- 이어지는 Codex들은 실작업자로서 구현, 설계, 검증, 반례를 구체적으로 제시합니다.\n- 마지막에는 중계 지시자가 결과물, 검토 결과, 남은 리스크와 후속 액션을 종합 보고합니다.';
const DEFAULT_CHAT_APP_CONFIG = {
maxContextMessages: 12,
maxContextChars: 3200,
@@ -21,12 +35,29 @@ const DEFAULT_CHAT_APP_CONFIG = {
} as const;
type ChatPermissionRole = 'guest' | 'token-user';
export type ChatTypeExecutionMode = 'default' | 'summary-free-talking' | 'dispatcher-workers';
export type ChatTypeReviewPolicy = 'self' | 'reviewer';
export type ChatTypeResourceReportPolicy = 'none' | 'if-generated' | 'always';
export type ChatTypeParticipantBinding =
| 'manual'
| 'first-moderator-rest-conversation'
| 'first-moderator-rest-conversation-last-reviewer';
export type ChatTypeExecutionPolicy = {
mode: ChatTypeExecutionMode;
participantBinding: ChatTypeParticipantBinding;
reviewPolicy: ChatTypeReviewPolicy;
resourceReportPolicy: ChatTypeResourceReportPolicy;
allowModeratorIntervention: boolean;
finalSummaryRequired: boolean;
};
type ChatTypeRecord = {
id: string;
name: string;
sortOrder: number;
description: string;
executionPolicy: ChatTypeExecutionPolicy;
permissions: ChatPermissionRole[];
enabled: boolean;
updatedAt: string;
@@ -53,11 +84,22 @@ type ChatTypeDefaultContextSelection = {
updatedAt: string;
};
type ChatRoomCodexParticipant = {
id: string;
name: string;
model: string;
prompt: string;
chatTypeId: string | null;
defaultContextIds: string[];
role: 'default' | 'moderator' | 'conversation' | 'reviewer';
};
type ChatRoomContextSettings = {
sessionId: string;
defaultContextIds: string[];
customContextTitle: string;
customContextContent: string;
codexParticipants: ChatRoomCodexParticipant[];
updatedAt: string;
};
@@ -595,17 +637,67 @@ function sanitizeRoomContexts(items: unknown) {
return;
}
const sanitizeCodexParticipants = (participants: unknown) => {
const sourceParticipants = Array.isArray(participants) ? participants : [];
return Array.from(
new Map(
sourceParticipants
.map((participant, index) => {
if (!participant || typeof participant !== 'object' || Array.isArray(participant)) {
return null;
}
const record = participant as Partial<ChatRoomCodexParticipant>;
const id = normalizeText(record.id) || `codex-participant-${index + 1}`;
const name = normalizeText(record.name);
const model = normalizeText(record.model);
const prompt = normalizeText(record.prompt);
const chatTypeId = normalizeText(record.chatTypeId) || null;
const defaultContextIds = normalizeDefaultContextIds(record.defaultContextIds);
const role =
normalizeText(record.role) === 'moderator'
? 'moderator'
: normalizeText(record.role) === 'conversation'
? 'conversation'
: normalizeText(record.role) === 'reviewer'
? 'reviewer'
: 'default';
if (!name || !model) {
return null;
}
return [
id,
{
id,
name,
model,
prompt,
chatTypeId,
defaultContextIds,
role,
} satisfies ChatRoomCodexParticipant,
] as const;
})
.filter((entry): entry is readonly [string, ChatRoomCodexParticipant] => Boolean(entry)),
).values(),
);
};
const nextRecord: ChatRoomContextSettings = {
sessionId,
defaultContextIds: normalizeDefaultContextIds(record.defaultContextIds),
customContextTitle: normalizeText(record.customContextTitle),
customContextContent: normalizeText(record.customContextContent),
codexParticipants: sanitizeCodexParticipants(record.codexParticipants),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent);
const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0;
const hasCodexParticipants = nextRecord.codexParticipants.length > 0;
if (!hasCustomContext && !hasDefaultOverrides) {
if (!hasCustomContext && !hasDefaultOverrides && !hasCodexParticipants) {
return;
}
@@ -646,12 +738,83 @@ function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null {
name,
sortOrder: normalizePositiveSortOrder(record.sortOrder),
description: normalizeText(record.description),
executionPolicy: normalizeChatTypeExecutionPolicy((record as { executionPolicy?: unknown }).executionPolicy),
permissions: normalizePermissions(record.permissions),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function createDefaultChatTypeExecutionPolicy(
mode: ChatTypeExecutionMode = 'default',
): ChatTypeExecutionPolicy {
if (mode === 'summary-free-talking') {
return {
mode,
participantBinding: 'first-moderator-rest-conversation',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: false,
finalSummaryRequired: true,
};
}
if (mode === 'dispatcher-workers') {
return {
mode,
participantBinding: 'first-moderator-rest-conversation',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: true,
finalSummaryRequired: true,
};
}
return {
mode,
participantBinding: 'manual',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: true,
finalSummaryRequired: false,
};
}
function normalizeChatTypeExecutionMode(value: unknown): ChatTypeExecutionMode {
if (value === 'summary-free-talking' || value === 'dispatcher-workers') {
return value;
}
return 'default';
}
function normalizeChatTypeExecutionPolicy(value: unknown): ChatTypeExecutionPolicy {
const record = normalizeConfigRecord(value);
const mode = normalizeChatTypeExecutionMode(record.mode);
const defaults = createDefaultChatTypeExecutionPolicy(mode);
return {
mode,
participantBinding:
record.participantBinding === 'first-moderator-rest-conversation' ||
record.participantBinding === 'first-moderator-rest-conversation-last-reviewer' ||
record.participantBinding === 'manual'
? record.participantBinding
: defaults.participantBinding,
reviewPolicy: record.reviewPolicy === 'reviewer' ? 'reviewer' : defaults.reviewPolicy,
resourceReportPolicy:
record.resourceReportPolicy === 'none' || record.resourceReportPolicy === 'always'
? record.resourceReportPolicy
: defaults.resourceReportPolicy,
allowModeratorIntervention:
typeof record.allowModeratorIntervention === 'boolean'
? record.allowModeratorIntervention
: defaults.allowModeratorIntervention,
finalSummaryRequired:
typeof record.finalSummaryRequired === 'boolean' ? record.finalSummaryRequired : defaults.finalSummaryRequired,
};
}
function normalizePositiveSortOrder(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return Number.NaN;
@@ -744,6 +907,86 @@ function stripLegacyMigratedChatTypes(items: ChatTypeRecord[]) {
return items.filter((item) => !isLegacyMigratedChatTypeId(item.id));
}
function createBuiltinChatTypeRecord(
overrides: Partial<ChatTypeRecord> & Pick<ChatTypeRecord, 'id' | 'name' | 'description' | 'executionPolicy'>,
): ChatTypeRecord {
return {
id: overrides.id,
name: overrides.name,
sortOrder: overrides.sortOrder ?? Number.NaN,
description: overrides.description,
executionPolicy: overrides.executionPolicy,
permissions: overrides.permissions ?? ['token-user'],
enabled: overrides.enabled ?? true,
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
};
}
function buildBuiltinCodexChatTypes() {
return [
createBuiltinChatTypeRecord({
id: CODEX_LIVE_DEFAULT_CHAT_TYPE_ID,
name: CODEX_LIVE_DEFAULT_CHAT_TYPE_NAME,
description: CODEX_LIVE_DEFAULT_CHAT_TYPE_DESCRIPTION,
executionPolicy: createDefaultChatTypeExecutionPolicy('default'),
}),
createBuiltinChatTypeRecord({
id: CODEX_SUMMARY_CHAT_TYPE_ID,
name: CODEX_SUMMARY_CHAT_TYPE_NAME,
description: CODEX_SUMMARY_CHAT_TYPE_DESCRIPTION,
executionPolicy: createDefaultChatTypeExecutionPolicy('summary-free-talking'),
}),
createBuiltinChatTypeRecord({
id: CODEX_DISPATCHER_CHAT_TYPE_ID,
name: CODEX_DISPATCHER_CHAT_TYPE_NAME,
description: CODEX_DISPATCHER_CHAT_TYPE_DESCRIPTION,
executionPolicy: createDefaultChatTypeExecutionPolicy('dispatcher-workers'),
}),
] satisfies ChatTypeRecord[];
}
function shouldUpgradeLegacyCodexSummaryChatType(record: ChatTypeRecord) {
if (record.id !== CODEX_SUMMARY_CHAT_TYPE_ID) {
return false;
}
return (
record.executionPolicy.mode === 'default' &&
(record.description === '' || record.description === CODEX_SUMMARY_LEGACY_DESCRIPTION)
);
}
export function synchronizeBuiltinCodexChatTypes(items: ChatTypeRecord[]) {
const builtins = buildBuiltinCodexChatTypes();
const builtinById = new Map(builtins.map((item) => [item.id, item] as const));
const merged = items.map((item) => {
const builtin = builtinById.get(item.id);
if (!builtin) {
return item;
}
if (item.id === CODEX_SUMMARY_CHAT_TYPE_ID && shouldUpgradeLegacyCodexSummaryChatType(item)) {
return {
...item,
description: builtin.description,
executionPolicy: builtin.executionPolicy,
};
}
return item;
});
const existingIds = new Set(merged.map((item) => item.id));
builtins.forEach((builtin) => {
if (!existingIds.has(builtin.id)) {
merged.push(builtin);
}
});
return sanitizePersistedChatTypes(merged);
}
function buildPlanChecklistDefaultContext(record?: ChatTypeRecord | null, existing?: ChatDefaultContextRecord | null) {
const content = normalizeText(record?.description) || normalizeText(existing?.content) || PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT;
@@ -798,6 +1041,12 @@ function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
item.id === target.id &&
item.name === target.name &&
item.description === target.description &&
item.executionPolicy.mode === target.executionPolicy.mode &&
item.executionPolicy.participantBinding === target.executionPolicy.participantBinding &&
item.executionPolicy.reviewPolicy === target.executionPolicy.reviewPolicy &&
item.executionPolicy.resourceReportPolicy === target.executionPolicy.resourceReportPolicy &&
item.executionPolicy.allowModeratorIntervention === target.executionPolicy.allowModeratorIntervention &&
item.executionPolicy.finalSummaryRequired === target.executionPolicy.finalSummaryRequired &&
item.enabled === target.enabled &&
item.sortOrder === target.sortOrder &&
item.updatedAt === target.updatedAt &&
@@ -990,13 +1239,15 @@ export async function getChatTypesConfig(appOrigin?: string | null): Promise<Cha
const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin();
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin);
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
const chatTypes = sanitizePersistedChatTypes(migratedChatTypeList);
const chatTypes = synchronizeBuiltinCodexChatTypes(sanitizePersistedChatTypes(migratedChatTypeList));
const migratedSettings = migrateLegacyChatTypeContexts(
resolveCanonicalChatContextSettingsFromConfig(rawConfig, sharedChatContextAppOrigin),
chatTypes,
);
const globalChatTypes = Array.isArray(rawConfig[CHAT_TYPES_CONFIG_KEY])
? stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]))
? synchronizeBuiltinCodexChatTypes(
stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])),
)
: [];
const globalSettings = sanitizeChatContextSettings(rawConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
const { changed, scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
@@ -1025,7 +1276,7 @@ export async function getChatTypesConfig(appOrigin?: string | null): Promise<Cha
export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) {
const current = await getRawAppConfigRecord();
const nextChatTypes = sanitizePersistedChatTypes(chatTypes);
const nextChatTypes = synchronizeBuiltinCodexChatTypes(sanitizePersistedChatTypes(chatTypes));
const { scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
current,
resolveSharedChatContextAppOrigin(),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { extractChatMessageParts, parseChatMessageParts } from './chat-message-parts.js';
test('extractChatMessageParts normalizes absolute legacy dot-codex prompt preview urls to api chat resource urls', () => {
const input = [
'문서 미리보기',
'[[prompt:{"title":"확인","options":[{"label":"legacy","value":"legacy","preview":{"type":"resource","url":"https://preview.sm-home.cloud/public/.codex_chat/chat-room/resource/source/chat-room-reference.md"}}]}]]',
].join('\n');
const parsed = extractChatMessageParts(input);
const prompt = parsed.parts.find((part): part is Extract<(typeof parsed.parts)[number], { type: 'prompt' }> => part.type === 'prompt');
assert.ok(prompt);
assert.equal(
prompt.options[0]?.preview?.url,
'/api/chat/resources/.codex_chat/chat-room/resource/source/chat-room-reference.md',
);
});
test('parseChatMessageParts normalizes absolute legacy link card urls to api chat resource urls', () => {
const parsed = parseChatMessageParts([
{
type: 'link_card',
title: 'legacy resource',
url: 'https://preview.sm-home.cloud/.codex_chat/chat-room/resource/uploads/spec.png',
actionLabel: '열기',
},
]);
assert.deepEqual(parsed, [
{
type: 'link_card',
title: 'legacy resource',
url: '/api/chat/resources/.codex_chat/chat-room/resource/uploads/spec.png',
actionLabel: '열기',
},
]);
});

View File

@@ -1,3 +1,12 @@
export type ChatComposerAttachment = {
id: string;
name: string;
path: string;
publicUrl: string;
size: number;
mimeType: string;
};
export type ChatMessagePart =
| {
type: 'link_card';
@@ -48,6 +57,7 @@ export type ChatMessagePart =
resolvedBy?: 'user' | 'timeout' | 'system' | null;
resolvedAt?: string | null;
resultText?: string | null;
attachments?: ChatComposerAttachment[];
options: Array<{
value: string;
label: string;
@@ -71,7 +81,7 @@ type PromptStep = NonNullable<PromptPart['steps']>[number];
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\((.+)\)\s*$/;
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
@@ -80,13 +90,132 @@ const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
function buildCanonicalChatApiResourcePath(relativePath: string) {
const normalizedRelativePath = normalizeText(relativePath).replace(/^\/+/, '');
if (!normalizedRelativePath) {
return '';
}
if (normalizedRelativePath.startsWith('.codex_chat/')) {
return `${CHAT_API_RESOURCE_MARKER}${normalizedRelativePath}`;
}
return `${CHAT_API_RESOURCE_MARKER}.codex_chat/${normalizedRelativePath}`;
}
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function unwrapMarkdownLinkTarget(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const matched = normalized.match(/^<([\s\S]+)>$/);
return matched?.[1]?.trim() ?? normalized;
}
function normalizeResourceManagerPathSegment(segment: string) {
const normalized = normalizeText(segment);
if (!normalized) {
return '';
}
try {
return encodeURIComponent(decodeURIComponent(normalized));
} catch {
return encodeURIComponent(normalized);
}
}
function normalizeUrlFragmentValue(value: string) {
const normalized = normalizeText(value).replace(/^#+/, '');
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewPathHash(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
const isRootRelative = normalized.startsWith('/');
try {
const parsed = new URL(normalized, 'https://local.invalid');
const segments = parsed.pathname.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0 || parsed.hash) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = encodeURIComponent(fileName);
parsed.pathname = segments.join('/');
parsed.hash = fragment;
if (isAbsoluteUrl) {
return parsed.toString();
}
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
} catch {
return normalized;
}
}
function buildResourceManagerPreviewUrl(value: string) {
const normalized = normalizeText(value).replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
const hashIndex = normalized.indexOf('#');
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
if (!resourcePath) {
@@ -102,19 +231,38 @@ function buildResourceManagerPreviewUrl(value: string) {
const encodedPath = relativePath
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.map((segment) => normalizeResourceManagerPathSegment(segment))
.join('/');
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
}
function normalizeUrl(value: string) {
const normalized = normalizeText(value);
const normalized = unwrapMarkdownLinkTarget(value);
if (!normalized) {
return '';
}
try {
const parsed = new URL(normalized, 'https://local.invalid');
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
return normalizePreviewPathHash(pathname);
}
if (pathname.startsWith(CHAT_PUBLIC_DOT_CODEX_MARKER)) {
return buildCanonicalChatApiResourcePath(pathname.slice(CHAT_PUBLIC_DOT_CODEX_MARKER.length));
}
if (pathname.startsWith(CHAT_DOT_CODEX_MARKER)) {
return buildCanonicalChatApiResourcePath(pathname.slice(CHAT_DOT_CODEX_MARKER.length));
}
} catch {
// Fall through to handle relative and embedded resource paths below.
}
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
if (malformedResourceMatch?.[1]) {
return `/${malformedResourceMatch[1]}`;
@@ -125,18 +273,18 @@ function normalizeUrl(value: string) {
const apiPath = normalized.slice(apiMarkerIndex);
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
return dotCodexIndex >= 0
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
? buildCanonicalChatApiResourcePath(apiPath.slice(dotCodexIndex + 1))
: apiPath;
}
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
if (publicDotCodexIndex >= 0) {
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
return buildCanonicalChatApiResourcePath(normalized.slice(publicDotCodexIndex + 8));
}
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
if (dotCodexIndex >= 0) {
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
return buildCanonicalChatApiResourcePath(normalized.slice(dotCodexIndex + 1));
}
if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) {
@@ -220,6 +368,53 @@ function normalizePromptSelectedValues(value: unknown) {
.filter((item, index, array) => array.indexOf(item) === index);
}
function normalizePromptAttachment(value: unknown): ChatComposerAttachment | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeText(record.id);
const name = normalizeText(record.name);
const path = normalizeText(record.path);
const publicUrl = normalizeText(record.publicUrl);
const size = Number(record.size);
const mimeType = normalizeText(record.mimeType);
if (!id || !name || !path || !publicUrl || !Number.isFinite(size) || size < 0 || !mimeType) {
return null;
}
return {
id,
name,
path,
publicUrl,
size,
mimeType,
};
}
function normalizePromptAttachments(value: unknown) {
if (!Array.isArray(value)) {
return [] as ChatComposerAttachment[];
}
const seen = new Set<string>();
return value
.map((item) => normalizePromptAttachment(item))
.filter((item): item is ChatComposerAttachment => Boolean(item))
.filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}
function normalizePromptSteps(value: unknown): PromptStep[] {
if (!Array.isArray(value)) {
return [];
@@ -420,11 +615,12 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
currentStepKey: normalizeText(record.currentStepKey) || null,
steps: steps.length > 0 ? steps : undefined,
readOnly: record.readOnly === true || selectedValues.length > 0,
readOnly: record.readOnly === true || resolvedBy != null,
selectedValues,
resolvedBy,
resolvedAt: normalizeText(record.resolvedAt) || null,
resultText: normalizeText(record.resultText) || null,
attachments: normalizePromptAttachments(record.attachments),
options,
};
}

View File

@@ -2,14 +2,25 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES,
applyChatPromptSelectionPatch,
buildChatConversationRequestUsageBySharedResourceTokenIdsQuery,
buildChatConversationContextUpdateFields,
buildChatPromptTargetSignature,
buildChatConversationRequestPatchFromMessage,
collectPromptSelectionCandidateRequestIds,
collectRegisteredNotificationClientIds,
hasMeaningfulChatSourceArtifacts,
inferSourceChangeScreenTitle,
isManagedChatShareSessionId,
isVisibleConversationMessage,
mergeChatConversationRequestStatus,
normalizeStaleRequestItem,
selectStaleOfflineNotificationClientIds,
resolveNextConversationContextValue,
resolveNextConversationChatTypeId,
hasPendingAttentionVerificationRequest,
isConversationAttentionPending,
shouldClearConversationJobState,
selectChatConversationResponseCandidate,
} from './chat-room-service.js';
@@ -39,6 +50,118 @@ test('resolveNextConversationContextValue prefers the requested chat type contex
assert.equal(resolveNextConversationContextValue('old context', undefined, false), 'old context');
});
test('isManagedChatShareSessionId detects managed shared chat rooms only', () => {
assert.equal(isManagedChatShareSessionId('chat-share-room-mb2p1-1234abcd'), true);
assert.equal(isManagedChatShareSessionId('chat-room-mb2p1-1234abcd'), false);
assert.equal(isManagedChatShareSessionId(''), false);
});
test('buildChatConversationContextUpdateFields leaves title untouched when unrelated metadata changes', () => {
assert.deepEqual(
buildChatConversationContextUpdateFields({
current: {
title: '사용자 저장 제목',
chat_type_id: 'codex-live',
last_chat_type_id: 'codex-live',
client_id: 'client-1',
notify_offline: true,
},
payload: {
clientId: 'client-1',
codexModel: 'gpt-5.4',
},
}),
{
codex_model: 'gpt-5.4',
},
);
});
test('buildChatConversationContextUpdateFields ignores undefined payload keys so title is not reset', () => {
assert.deepEqual(
buildChatConversationContextUpdateFields({
current: {
title: '브라우저 실검증 제목',
request_badge_label: '기존 배지',
chat_type_id: 'codex-live',
last_chat_type_id: 'codex-live',
client_id: 'client-1',
notify_offline: true,
},
payload: {
title: undefined,
requestBadgeLabel: '새 배지',
},
}),
{
request_badge_label: '새 배지',
},
);
});
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(
@@ -72,6 +195,166 @@ test('selectStaleOfflineNotificationClientIds keeps current or registered client
);
});
test('collectRegisteredNotificationClientIds keeps both web push client ids and device ids', () => {
assert.deepEqual(
Array.from(
collectRegisteredNotificationClientIds([
{
device_id: 'web-device-1',
client_id: 'client-preview-1',
},
{
device_id: 'ios-device-1',
},
]),
).sort(),
['client-preview-1', 'ios-device-1', 'web-device-1'],
);
});
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({
@@ -84,10 +367,161 @@ test('buildChatConversationRequestPatchFromMessage ignores system progress messa
);
});
test('applyChatPromptSelectionPatch resolves the matched prompt with persisted selections', () => {
const promptPart = {
type: 'prompt' as const,
title: '다음 단계 선택',
description: '원하는 작업을 고르세요.',
submitLabel: '선택 전달',
mode: 'queue' as const,
selectedValues: [],
options: [],
steps: [
{
key: 'scope',
title: '범위',
selectedValues: [],
options: [
{
value: 'ui',
label: 'UI',
},
{
value: 'api',
label: 'API',
},
],
},
],
};
const patched = applyChatPromptSelectionPatch(
[promptPart],
{
promptIndex: 0,
promptTitle: promptPart.title,
promptSignature: buildChatPromptTargetSignature(promptPart),
selectedValues: ['ui'],
stepSelections: [
{
stepKey: 'scope',
selectedValues: ['ui'],
freeText: '',
},
],
summaryText: '범위: UI',
attachments: [
{
id: 'attachment-1',
name: 'spec.png',
path: 'public/.codex_chat/test/resource/uploads/spec.png',
publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
size: 128,
mimeType: 'image/png',
},
],
},
'2026-05-18T08:20:00.000Z',
);
assert.ok(patched);
assert.equal(patched?.[0]?.type, 'prompt');
assert.deepEqual(patched?.[0]?.selectedValues, ['ui']);
assert.equal(patched?.[0]?.readOnly, true);
assert.equal(patched?.[0]?.resolvedBy, 'user');
assert.equal(patched?.[0]?.resolvedAt, '2026-05-18T08:20:00.000Z');
assert.equal(patched?.[0]?.resultText, '범위: UI');
assert.equal(patched?.[0]?.attachments?.[0]?.name, 'spec.png');
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(
[
{
request_id: 'root-request',
parent_request_id: null,
created_at: '2026-05-18T02:00:00.000Z',
},
{
request_id: 'prompt-child',
parent_request_id: 'root-request',
created_at: '2026-05-18T02:01:00.000Z',
},
{
request_id: 'composer-grandchild',
parent_request_id: 'prompt-child',
created_at: '2026-05-18T02:02:00.000Z',
},
{
request_id: 'other-root',
parent_request_id: null,
created_at: '2026-05-18T02:03:00.000Z',
},
],
'root-request',
),
['composer-grandchild', 'prompt-child', 'root-request'],
);
});
test('CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH allows long chat type guidance text', () => {
assert.ok(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH >= 2691);
});
test('chat request schema requires chat type columns for codex live persistence', () => {
assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_id'), true);
assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_label'), true);
});
test('shared resource token usage summary query keeps aggregate aliases outside function bodies', () => {
const query = buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(['token-1', 'token-2']);
assert.ok(query);
const sql = query?.toSQL().sql ?? '';
assert.match(sql, /count\(\*\) as request_count/i);
assert.match(sql, /sum\(COALESCE\(total_tokens, 0\)\) as total_tokens/i);
assert.match(sql, /sum\(CASE WHEN status = 'completed' THEN 1 ELSE 0 END\) as completed_request_count/i);
assert.match(sql, /max\(COALESCE\(answered_at, terminal_at, updated_at, created_at\)\) as last_used_at/i);
assert.doesNotMatch(sql, /END as completed_request_count\)/i);
assert.doesNotMatch(sql, /created_at\) as last_used_at\)/i);
});
test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => {
assert.equal(
isVisibleConversationMessage({
@@ -130,6 +564,50 @@ test('hasMeaningfulChatSourceArtifacts requires real file or diff artifacts', ()
);
});
test('inferSourceChangeScreenTitle prefers changed source menu over generic Codex Live title', () => {
assert.equal(
inferSourceChangeScreenTitle(
['src/app/main/ResourceManagementPage.tsx'],
'Codex Live / Codex Live',
),
'리소스 관리 / 리소스 관리',
);
assert.equal(
inferSourceChangeScreenTitle(
['src/app/main/PreviewAppOverlay.tsx'],
'Codex Live / Codex Live',
),
'Preview App / 모바일 앱 열기',
);
assert.equal(
inferSourceChangeScreenTitle(
['etc/servers/work-server/src/routes/resource-manager.ts'],
'Codex Live / Codex Live',
),
'리소스 관리 / 리소스 관리',
);
});
test('inferSourceChangeScreenTitle falls back to docs and stored title when no menu rule matches', () => {
assert.equal(
inferSourceChangeScreenTitle(
['docs/project/overview.md'],
'Codex Live / Codex Live',
),
'Docs / 프로젝트 구조',
);
assert.equal(
inferSourceChangeScreenTitle(
['scripts/dev/check.sh'],
'직접 지정 제목',
),
'직접 지정 제목',
);
});
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
assert.deepEqual(
buildChatConversationRequestPatchFromMessage({
@@ -314,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(
@@ -321,12 +821,19 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
sessionId: 'session-1',
requestId: 'chat-req-queued',
requesterClientId: null,
requestOrigin: null,
sharedResourceTokenId: null,
parentRequestId: null,
promptContextRef: null,
status: 'queued',
statusMessage: '대기열 1건',
retryCount: 0,
userMessageId: 11,
userText: '다음 요청',
responseMessageId: null,
responseText: '',
usageSnapshot: null,
totalTokens: null,
hasResponse: false,
canDelete: false,
createdAt: '2026-05-11T00:00:00.000Z',
@@ -344,18 +851,88 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
sessionId: 'session-1',
requestId: 'chat-req-queued',
requesterClientId: null,
requestOrigin: null,
parentRequestId: null,
status: 'queued',
statusMessage: '대기열 1건',
retryCount: 0,
userMessageId: 11,
userText: '다음 요청',
userText: '다음 요청',
responseMessageId: null,
responseText: '',
usageSnapshot: null,
totalTokens: null,
hasResponse: false,
canDelete: false,
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:30.000Z',
answeredAt: null,
terminalAt: null,
},
);
});
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: false,
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:30.000Z',
canDelete: true,
createdAt: '2026-05-27T00:57:53.000Z',
updatedAt: '2026-05-27T00:58:10.000Z',
answeredAt: null,
terminalAt: null,
terminalAt: '2026-05-27T00:58:10.000Z',
},
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { env } from '../config/env.js';
import {
ChatService,
collectOfflineNotificationClientIds,
createActivityLogMessage,
buildAgenticCodexPrompt,
@@ -17,9 +18,14 @@ import {
fitActivityLogLines,
isChatClientActivelyViewing,
isAutomationRegistrationCountRequest,
buildParticipantRequestInput,
resolveCodexExecutionStages,
resolveCodexParticipantsForExecution,
resolveResponseTimestamp,
resolveChatContextAppOrigin,
resolveChatContextAppDomain,
rewriteCodexOutputWithChatResources,
sanitizeChatContextOverride,
summarizeActivityProgressLine,
shouldSendOfflineChatNotification,
shouldUseAgenticCodexReply,
@@ -28,9 +34,76 @@ import {
} from './chat-service.js';
import { extractChatMessageParts } from './chat-message-parts.js';
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
test('ChatService rebinds a websocket to the payload session before handling a send request', () => {
const logger = {
error() {},
warn() {},
info() {},
} as any;
const service = new ChatService(logger);
let supersededSocketClosed = false;
try {
const currentSocket = {
readyState: 1,
close() {},
} as any;
const supersededTargetSocket = {
readyState: 1,
close() {
supersededSocketClosed = true;
},
} as any;
const currentSession = {
sessionId: 'session-a',
clientId: 'client-1',
socket: currentSocket,
lastSeenAt: 0,
isDeleted: false,
context: null,
queue: [],
activeRequestCount: 0,
pendingQueueReleaseEventId: null,
nextEventId: 1,
eventHistory: [],
messagePersistenceTail: Promise.resolve(),
watchedRuntimeRequestId: null,
};
const targetSession = {
sessionId: 'session-b',
clientId: 'client-1',
socket: supersededTargetSocket,
lastSeenAt: 0,
isDeleted: false,
context: null,
queue: [],
activeRequestCount: 0,
pendingQueueReleaseEventId: null,
nextEventId: 1,
eventHistory: [],
messagePersistenceTail: Promise.resolve(),
watchedRuntimeRequestId: null,
};
(service as any).sessions.set(currentSession.sessionId, currentSession);
(service as any).sessions.set(targetSession.sessionId, targetSession);
(service as any).clientStates.set(currentSocket, currentSession);
const rebound = (service as any).rebindSocketToSession(currentSocket, 'session-b');
assert.equal(rebound, targetSession);
assert.equal(currentSession.socket, null);
assert.equal(targetSession.socket, currentSocket);
assert.equal((service as any).clientStates.get(currentSocket), targetSession);
assert.equal(supersededSocketClosed, true);
} finally {
service.close();
}
});
test('collectOfflineNotificationClientIds keeps only explicit notification targets without duplicates', () => {
assert.deepEqual(
collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']),
collectOfflineNotificationClientIds(['client-b', ' client-a ', '', 'client-c', 'client-b']),
['client-b', 'client-a', 'client-c'],
);
});
@@ -55,6 +128,29 @@ test('filterInactiveOfflineNotificationClientIds excludes only actively viewing
);
});
test('sanitizeChatContextOverride drops undefined codexModel without touching explicit null', () => {
assert.deepEqual(
sanitizeChatContextOverride({
codexModel: undefined,
chatTypeId: 'general-request',
} as any),
{
chatTypeId: 'general-request',
},
);
assert.deepEqual(
sanitizeChatContextOverride({
codexModel: null,
chatTypeId: 'general-request',
}),
{
codexModel: null,
chatTypeId: 'general-request',
},
);
});
test('shouldSendOfflineChatNotification blocks chat push when app setting disables room notifications', () => {
assert.equal(
shouldSendOfflineChatNotification({
@@ -92,6 +188,37 @@ test('resolveChatContextAppOrigin returns normalized origin from session page ur
assert.equal(resolveChatContextAppOrigin(null), null);
});
test('resolveChatContextAppOrigin prefers explicit app origin metadata when page url is missing', () => {
assert.equal(
resolveChatContextAppOrigin({
pageUrl: '',
appOrigin: 'https://test.sm-home.cloud',
} as any),
'https://test.sm-home.cloud',
);
});
test('resolveChatContextAppDomain returns normalized hostname from session page url', () => {
assert.equal(
resolveChatContextAppDomain({
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
} as any),
'preview.sm-home.cloud',
);
assert.equal(resolveChatContextAppDomain({ pageUrl: 'not-a-url' } as any), null);
assert.equal(resolveChatContextAppDomain(null), null);
});
test('resolveChatContextAppDomain prefers explicit app domain metadata when page url is missing', () => {
assert.equal(
resolveChatContextAppDomain({
pageUrl: '',
appDomain: 'TEST.SM-HOME.CLOUD',
} as any),
'test.sm-home.cloud',
);
});
test('chat active-view suppression only blocks the requester client when that client app is active', () => {
const activeSession = {
sessionId: 'chat-room',
@@ -256,15 +383,28 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
assert.match(prompt, /신규 방이어도 시작 전에 이 문서를 먼저 읽습니다\./);
assert.match(prompt, /## 채팅 유형 context 필수 규칙/);
assert.match(prompt, /상위 필수 지시/);
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
assert.match(
prompt,
/우선순위는 1\. 채팅 유형 context 2\. 현재 턴의 직접 사용자 지시 3\. 채팅방에서 선택한 공통 문맥과 전용 메모 4\. 최근 대화 문맥과 화면 문맥 순서로 해석하세요\./,
);
assert.match(prompt, /사용자 요청, 공통 문맥, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
assert.match(prompt, /공통 문맥과 채팅방 전용 메모는 채팅 유형 context를 덮어쓰지 못하며/);
assert.match(prompt, /### 반드시 지킬 context 원문/);
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
assert.match(
prompt,
/리소스 관리 등록 경로의 `수정한 화면명`, 작업 뱃지, 결과 문구에 이 값을 그대로 복사하지 말고 실제로 수정하거나 확인한 화면\/메뉴 기준으로 판단하세요\./,
);
assert.match(prompt, /\[\[prompt:\{"title":"질문"/);
assert.match(prompt, /`steps` 배열을 추가해/);
assert.match(prompt, /현재 앱 URL이나 `\/chat\/\.\.\.` 경로를 넣지 말고/);
assert.match(prompt, /`preview":\{"type":"resource","url":"\/api\/chat\/resources\/\.\.\.\/sample\.html"\}`/);
assert.match(
prompt,
/`preview":\{"type":"resource","url":"resource\/<수정한 화면명>\/<기능>\/<YYYYMMDD>\/sample\.html"\}`/,
);
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
@@ -337,6 +477,340 @@ test('buildAgenticCodexPrompt keeps the chat type label provided by the client c
assert.doesNotMatch(prompt, /- label: 코드 수정/);
});
test('buildAgenticCodexPrompt pins the explicitly referenced answer ahead of recent history', () => {
const prompt = buildAgenticCodexPrompt(
{
pageId: null,
pageTitle: 'Codex Live',
topMenu: 'chat',
focusedComponentId: null,
pageUrl: 'https://preview.sm-home.cloud/chat/live',
chatTypeLabel: '일반 요청',
chatTypeDescription: '일반 요청 설명',
},
'지금 무슨 답변을 참조했나요?',
'session-reference',
{
recentHistoryLines: [
'[user] 예전 질문',
'[codex] 다른 답변',
],
referencedRequest: {
sessionId: 'session-reference',
requestId: 'request-123',
requesterClientId: null,
chatTypeId: 'general-request',
chatTypeLabel: '일반 요청',
requestOrigin: 'composer',
sharedResourceTokenId: null,
parentRequestId: null,
promptContextRef: null,
status: 'completed',
statusMessage: '요청 처리 완료',
retryCount: 0,
userMessageId: 1,
userText: 'preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼?',
responseMessageId: 2,
responseText: '배포 누락이 아니라 iframe 차단입니다.',
usageSnapshot: null,
totalTokens: null,
hasResponse: true,
canDelete: false,
manualPromptCompletedAt: null,
manualVerificationCompletedAt: null,
createdAt: '2026-05-27T14:50:23.000Z',
updatedAt: '2026-05-27T14:51:00.000Z',
answeredAt: '2026-05-27T14:51:00.000Z',
terminalAt: '2026-05-27T14:51:00.000Z',
},
},
);
assert.match(prompt, /## 답변 참조/);
assert.match(prompt, /참조 requestId: request-123/);
assert.match(prompt, /참조 사용자 요청: preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼\?/);
assert.match(prompt, /참조 답변 본문: 배포 누락이 아니라 iframe 차단입니다\./);
assert.match(prompt, /다른 최근 답변을 임의로 섞지 마세요\./);
assert.ok(prompt.indexOf('## 답변 참조') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
});
test('resolveCodexParticipantsForExecution expands moderator into opening and closing turns', () => {
const participants = resolveCodexParticipantsForExecution({
pageId: null,
pageTitle: 'Codex Live',
topMenu: 'chat',
focusedComponentId: null,
pageUrl: 'https://preview.sm-home.cloud/chat/live',
codexParticipants: [
{
id: 'codex-1',
name: 'Codex 1',
model: 'gpt-5.4',
role: 'moderator',
},
{
id: 'codex-2',
name: 'Codex 2',
model: 'gpt-5.4',
role: 'conversation',
},
{
id: 'codex-3',
name: 'Codex 3',
model: 'gpt-5.4',
role: 'conversation',
},
],
} as any);
assert.deepEqual(
participants.map((participant) => `${participant.name}:${participant.turn}`),
['Codex 1:opening', 'Codex 2:discussion', 'Codex 3:discussion', 'Codex 1:closing'],
);
});
test('resolveCodexParticipantsForExecution applies dispatcher policy with reviewer slot', () => {
const participants = resolveCodexParticipantsForExecution({
pageId: null,
pageTitle: 'Codex Live',
topMenu: 'chat',
focusedComponentId: null,
pageUrl: 'https://preview.sm-home.cloud/chat/live',
chatTypeExecutionPolicy: {
mode: 'dispatcher-workers',
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
reviewPolicy: 'reviewer',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: true,
finalSummaryRequired: true,
},
codexParticipants: [
{
id: 'codex-1',
name: 'Codex 1',
model: 'gpt-5.4',
role: 'default',
},
{
id: 'codex-2',
name: 'Codex 2',
model: 'gpt-5.4',
role: 'default',
},
{
id: 'codex-3',
name: 'Codex 3',
model: 'gpt-5.4',
role: 'default',
},
],
} as any);
assert.deepEqual(
participants.map((participant) => `${participant.name}:${participant.turn}:${participant.role}`),
[
'Codex 1:opening:moderator',
'Codex 2:discussion:conversation',
'Codex 3:review:reviewer',
'Codex 1:closing:moderator',
],
);
});
test('resolveCodexExecutionStages runs direct multi-Codex default requests in parallel without closing summary', () => {
const stages = resolveCodexExecutionStages(
{
codexModel: 'gpt-5.4',
codexParticipants: [
{ name: 'Codex 1', model: 'gpt-5.4', role: 'moderator' },
{ name: 'Codex 2', model: 'gpt-5.4-mini', role: 'conversation' },
{ name: 'Codex 3', model: 'gpt-5.4', role: 'default' },
],
chatTypeExecutionPolicy: {
mode: 'default',
participantBinding: 'manual',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: true,
finalSummaryRequired: false,
},
} as any,
'direct',
);
assert.equal(stages.length, 1);
assert.equal(stages[0]?.parallel, true);
assert.deepEqual(
stages[0]?.participants.map((participant) => `${participant.name}:${participant.turn}`),
['Codex 1:standard', 'Codex 2:discussion', 'Codex 3:standard'],
);
});
test('resolveCodexExecutionStages keeps moderator summary flow while parallelizing discussion stage', () => {
const stages = resolveCodexExecutionStages(
{
codexModel: 'gpt-5.4',
codexParticipants: [
{ name: '회의기록자', model: 'gpt-5.4', role: 'moderator' },
{ name: '구현자 A', model: 'gpt-5.4-mini', role: 'conversation' },
{ name: '구현자 B', model: 'gpt-5.4', role: 'conversation' },
],
chatTypeExecutionPolicy: {
mode: 'summary-free-talking',
participantBinding: 'manual',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: false,
finalSummaryRequired: true,
},
} as any,
'direct',
);
assert.deepEqual(
stages.map((stage) => ({
parallel: stage.parallel,
participants: stage.participants.map((participant) => `${participant.name}:${participant.turn}`),
})),
[
{ parallel: false, participants: ['회의기록자:opening'] },
{ parallel: true, participants: ['구현자 A:discussion', '구현자 B:discussion'] },
{ parallel: false, participants: ['회의기록자:closing'] },
],
);
});
test('buildParticipantRequestInput gives moderator and discussion participants distinct instructions', () => {
const participants = [
{
name: 'Codex 1',
model: 'gpt-5.4',
prompt: '',
chatTypeId: null,
role: 'moderator' as const,
turn: 'opening' as const,
},
{
name: 'Codex 2',
model: 'gpt-5.4',
prompt: '',
chatTypeId: null,
role: 'conversation' as const,
turn: 'discussion' as const,
},
{
name: 'Codex 1',
model: 'gpt-5.4',
prompt: '',
chatTypeId: null,
role: 'moderator' as const,
turn: 'closing' as const,
},
];
const openingInput = buildParticipantRequestInput('요청 본문', participants[0], participants, []);
const closingInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [
{
name: 'Codex 1',
model: 'gpt-5.4',
text: '쟁점을 정리합니다.',
},
{
name: 'Codex 2',
model: 'gpt-5.4',
text: '구현 관점 보완입니다.',
},
]);
assert.match(openingInput, /회의 기록자 겸 중재자/);
assert.match(openingInput, /실행 정책: default/);
assert.match(openingInput, /Codex 1\(gpt-5\.4, 중재 시작\) -> Codex 2\(gpt-5\.4, 프리토킹\) -> Codex 1\(gpt-5\.4, 최종 정리\)/);
assert.match(closingInput, /최종 결론과 남은 쟁점을 정리/);
assert.match(closingInput, /이전 Codex 발언/);
});
test('buildParticipantRequestInput uses dispatcher and reviewer instructions from execution policy', () => {
const participants = [
{
name: 'Codex 1',
model: 'gpt-5.4',
prompt: '',
chatTypeId: null,
role: 'moderator' as const,
turn: 'opening' as const,
},
{
name: 'Codex 2',
model: 'gpt-5.4',
prompt: '',
chatTypeId: null,
role: 'conversation' as const,
turn: 'discussion' as const,
},
{
name: 'Codex 3',
model: 'gpt-5.4',
prompt: '',
chatTypeId: null,
role: 'reviewer' as const,
turn: 'review' as const,
},
];
const dispatcherInput = buildParticipantRequestInput('요청 본문', participants[0], participants, [], null, {
mode: 'dispatcher-workers',
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
reviewPolicy: 'reviewer',
resourceReportPolicy: 'always',
allowModeratorIntervention: true,
finalSummaryRequired: true,
});
const reviewerInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [], null, {
mode: 'dispatcher-workers',
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
reviewPolicy: 'reviewer',
resourceReportPolicy: 'always',
allowModeratorIntervention: true,
finalSummaryRequired: true,
});
assert.match(dispatcherInput, /중계 지시자/);
assert.match(dispatcherInput, /결과물 보고 정책: always/);
assert.match(reviewerInput, /최종 검토자/);
assert.match(reviewerInput, /최종 종합 강제: 예/);
});
test('buildParticipantRequestInput keeps prompt parent question context in a separate server block', () => {
const participant = {
name: 'Codex 1',
model: 'gpt-5.4',
prompt: '',
chatTypeId: null,
role: 'moderator' as const,
turn: 'opening' as const,
};
const input = buildParticipantRequestInput(
'실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요.\n\n추가 요청:\n테스트',
participant,
[participant],
[],
null,
null,
{
key: 'prompt_parent_question',
promptTitle: '다음 확인 선택',
promptDescription: '이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다.',
parentQuestionText: 'prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요(질의 응답 부모 명확하게 전달)',
},
);
assert.match(input, /^실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요\./);
assert.match(input, /prompt 문맥 참조:/);
assert.match(input, /상위 사용자 질의: prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요/);
assert.match(input, /대상 질의: 다음 확인 선택/);
assert.match(input, /질의 설명: 이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다\./);
});
test('buildAgenticCodexPrompt includes room-selected default contexts as structured sections', () => {
const prompt = buildAgenticCodexPrompt(
{
@@ -370,11 +844,13 @@ test('buildAgenticCodexPrompt includes room-selected default contexts as structu
assert.match(prompt, /## 채팅 유형 context 원문/);
assert.match(prompt, /채팅 유형 원문 규칙/);
assert.match(prompt, /## 채팅방에서 선택한 공통 문맥/);
assert.match(prompt, /채팅 유형 context와 충돌하지 않는 범위에서만 보조로 적용하세요\./);
assert.match(prompt, /### 권한 관리 공통 문맥/);
assert.match(prompt, /채팅방에서 선택된 공통 문맥도 항상 반영합니다\./);
assert.match(prompt, /### 방 전용 공통 문맥/);
assert.match(prompt, /신규 방에서도 같은 규칙으로 동작해야 합니다\./);
assert.match(prompt, /## 채팅방 전용 Context · 운영 메모/);
assert.match(prompt, /채팅 유형 context를 바꾸지 않으며, 충돌하지 않는 범위에서만 보조로 해석하세요\./);
assert.match(prompt, /preview 기준으로 검증합니다\./);
});
@@ -590,6 +1066,7 @@ test('ensureChatSessionReferenceResource summarizes default contexts without cop
const content = await readFile(absolutePath, 'utf8');
assert.match(content, /## 현재 채팅 유형 context 요약/);
assert.match(content, /채팅 유형 context가 최상위이며, 공통 문맥과 채팅방 전용 메모는 그 아래 보조 문맥으로만 사용합니다\./);
assert.match(content, /### 적용 중인 공통 문맥/);
assert.match(content, /- 개발 리소스 관리/);
assert.match(content, /- 리소스 출력/);
@@ -831,6 +1308,54 @@ test('extractChatMessageParts keeps readonly auto-selected prompt state', () =>
);
});
test('extractChatMessageParts keeps prompt writable when only selectedValues exist', () => {
assert.deepEqual(
extractChatMessageParts(
[
'이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
'[[prompt:{"title":"후속 범위 선택","description":"미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.","selectedValues":["mobile-cleanup"],"options":[{"label":"모바일 정리","value":"mobile-cleanup","description":"모바일 여백 정리"},{"label":"데스크톱 정리","value":"desktop-cleanup","description":"데스크톱 여백 정리"}]}]]',
].join('\n'),
),
{
strippedText: '이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
parts: [
{
type: 'prompt',
title: '후속 범위 선택',
description: '미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.',
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: ['mobile-cleanup'],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: '모바일 정리',
value: 'mobile-cleanup',
description: '모바일 여백 정리',
preview: null,
},
{
label: '데스크톱 정리',
value: 'desktop-cleanup',
description: '데스크톱 여백 정리',
preview: null,
},
],
},
],
},
);
});
test('extractChatMessageParts keeps prompt preview payloads for image markdown html and resource', () => {
assert.deepEqual(
extractChatMessageParts(
@@ -915,6 +1440,96 @@ test('extractChatMessageParts keeps prompt preview payloads for image markdown h
);
});
test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => {
assert.deepEqual(
extractChatMessageParts(
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]',
),
{
strippedText: '',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: 'A안',
value: 'option-a',
description: null,
preview: {
type: 'resource',
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
content: null,
alt: null,
title: null,
},
},
],
},
],
},
);
});
test('extractChatMessageParts restores encoded resource preview hash fragments', () => {
assert.deepEqual(
extractChatMessageParts(
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]',
),
{
strippedText: '',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: 'A안',
value: 'option-a',
description: null,
preview: {
type: 'resource',
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
content: null,
alt: null,
title: null,
},
},
],
},
],
},
);
});
test('extractChatMessageParts supports stepper prompt steps', () => {
assert.deepEqual(
extractChatMessageParts(
@@ -1154,6 +1769,39 @@ test('extractChatMessageParts promotes standalone markdown links into structured
);
});
test('extractChatMessageParts keeps angle-bracket internal resource markdown links as plain text', () => {
assert.deepEqual(
extractChatMessageParts(
['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
'\n',
),
),
{
strippedText: ['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
'\n',
),
parts: [],
},
);
});
test('extractChatMessageParts promotes standalone markdown links with angle-bracket external targets into structured link cards', () => {
assert.deepEqual(
extractChatMessageParts('- [판매글 열기](<https://www.daangn.com/kr/buy-sell/mac studio>)'),
{
strippedText: '',
parts: [
{
type: 'link_card',
title: '판매글 열기',
url: 'https://www.daangn.com/kr/buy-sell/mac studio',
actionLabel: null,
},
],
},
);
});
test('extractChatMessageParts promotes standalone urls with the previous line as the card title', () => {
assert.deepEqual(
extractChatMessageParts(
@@ -1257,6 +1905,48 @@ test('parseStructuredCodexStdoutLine strips nested command execution JSON from r
activityLog: '# 결과: 완료(0)\n# 출력: model = "gpt-5.4"',
completedText: '',
deltaText: '',
usageSnapshot: null,
shouldKeepRaw: false,
},
);
});
test('parseStructuredCodexStdoutLine keeps usage snapshots from response.completed JSON', () => {
assert.deepEqual(
parseStructuredCodexStdoutLine(
JSON.stringify({
type: 'response.completed',
response: {
output: [
{
type: 'message',
content: [{ type: 'output_text', text: '최종 응답입니다.' }],
},
],
usage: {
input_tokens: 120,
output_tokens: 45,
cached_input_tokens: 30,
reasoning_output_tokens: 10,
total_tokens: 165,
},
},
}),
),
{
activityLog: '',
completedText: '최종 응답입니다.',
deltaText: '',
usageSnapshot: {
tokenTotals: {
total: 165,
input: 120,
output: 45,
cached: 30,
reasoning: 10,
},
totalTokens: 165,
},
shouldKeepRaw: false,
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,514 @@
import { db } from '../db/client.js';
import {
CHAT_CONVERSATION_TABLE,
ensureChatConversationTables,
} from './chat-room-service.js';
const CHAT_SHARE_TOKEN_ROOM_MAP_TABLE = 'chat_share_token_room_maps';
export type ChatShareTokenRoomMapItem = {
tokenId: string;
sessionId: string;
rootRequestId: string;
isDefault: boolean;
sortOrder: number;
createdByClientId: string | null;
title: string;
requestBadgeLabel: string | null;
chatTypeId: string | null;
lastChatTypeId: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
linkContext: ChatShareRoomLinkContext | null;
createdAt: string | null;
updatedAt: string | null;
conversationUpdatedAt: string | null;
};
export type ChatShareRoomLinkContext = {
kind: 'linked-session';
sourceSessionId: string;
sourceRequestId: string;
sourceTitle: string | null;
sourceRequestPreview: string | null;
sourceChatTypeLabel: string | null;
linkedAt: string | null;
};
function normalizeOptionalText(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
return normalized || null;
}
function normalizeRequiredText(value: unknown) {
if (typeof value !== 'string') {
return '';
}
return value.trim();
}
function normalizeBoolean(value: unknown) {
return value === true;
}
function normalizeInteger(value: unknown, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.trunc(parsed);
}
function normalizeDateTime(value: unknown) {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === 'string') {
const normalized = value.trim();
return normalized || null;
}
return null;
}
function parseChatShareRoomLinkContext(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
if (!normalized) {
return null;
}
try {
const parsed = JSON.parse(normalized) as Record<string, unknown>;
if (parsed.kind !== 'linked-session') {
return null;
}
const sourceSessionId = normalizeRequiredText(parsed.sourceSessionId);
const sourceRequestId = normalizeRequiredText(parsed.sourceRequestId);
if (!sourceSessionId || !sourceRequestId) {
return null;
}
return {
kind: 'linked-session',
sourceSessionId,
sourceRequestId,
sourceTitle: normalizeOptionalText(parsed.sourceTitle),
sourceRequestPreview: normalizeOptionalText(parsed.sourceRequestPreview),
sourceChatTypeLabel: normalizeOptionalText(parsed.sourceChatTypeLabel),
linkedAt: normalizeDateTime(parsed.linkedAt),
} satisfies ChatShareRoomLinkContext;
} catch {
return null;
}
}
function stringifyChatShareRoomLinkContext(value: ChatShareRoomLinkContext | null | undefined) {
if (!value) {
return null;
}
if (value.kind !== 'linked-session') {
return null;
}
const sourceSessionId = normalizeRequiredText(value.sourceSessionId);
const sourceRequestId = normalizeRequiredText(value.sourceRequestId);
if (!sourceSessionId || !sourceRequestId) {
return null;
}
return JSON.stringify({
kind: 'linked-session',
sourceSessionId,
sourceRequestId,
sourceTitle: normalizeOptionalText(value.sourceTitle),
sourceRequestPreview: normalizeOptionalText(value.sourceRequestPreview),
sourceChatTypeLabel: normalizeOptionalText(value.sourceChatTypeLabel),
linkedAt: normalizeDateTime(value.linkedAt),
});
}
function mapChatShareTokenRoomRow(row: Record<string, unknown>): ChatShareTokenRoomMapItem {
return {
tokenId: normalizeRequiredText(row.shared_resource_token_id),
sessionId: normalizeRequiredText(row.session_id),
rootRequestId: normalizeRequiredText(row.root_request_id),
isDefault: normalizeBoolean(row.is_default),
sortOrder: normalizeInteger(row.sort_order),
createdByClientId: normalizeOptionalText(row.created_by_client_id),
title: normalizeRequiredText(row.title) || '공유 채팅방',
requestBadgeLabel: normalizeOptionalText(row.request_badge_label),
chatTypeId: normalizeOptionalText(row.chat_type_id),
lastChatTypeId: normalizeOptionalText(row.last_chat_type_id),
contextLabel: normalizeOptionalText(row.context_label),
contextDescription: normalizeOptionalText(row.context_description),
notifyOffline: normalizeBoolean(row.notify_offline),
linkContext: parseChatShareRoomLinkContext(row.link_context_json),
createdAt: normalizeDateTime(row.created_at),
updatedAt: normalizeDateTime(row.updated_at),
conversationUpdatedAt: normalizeDateTime(row.conversation_updated_at),
};
}
export async function ensureChatShareTokenRoomMapTable() {
await ensureChatConversationTables();
const hasTable = await db.schema.hasTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE);
if (!hasTable) {
await db.schema.createTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => {
table.increments('id').primary();
table.string('shared_resource_token_id', 120).notNullable().index();
table.string('session_id', 120).notNullable().index();
table.string('root_request_id', 120).notNullable();
table.boolean('is_default').notNullable().defaultTo(false);
table.integer('sort_order').notNullable().defaultTo(0);
table.string('created_by_client_id', 120).nullable();
table.timestamp('archived_at', { useTz: true }).nullable().index();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.unique(['shared_resource_token_id', 'session_id']);
});
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).notNullable().index()],
['session_id', (table) => table.string('session_id', 120).notNullable().index()],
['root_request_id', (table) => table.string('root_request_id', 120).notNullable().defaultTo('')],
['is_default', (table) => table.boolean('is_default').notNullable().defaultTo(false)],
['sort_order', (table) => table.integer('sort_order').notNullable().defaultTo(0)],
['created_by_client_id', (table) => table.string('created_by_client_id', 120).nullable()],
['link_context_json', (table) => table.text('link_context_json').nullable()],
['archived_at', (table) => table.timestamp('archived_at', { useTz: true }).nullable().index()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => {
createColumn(table);
});
}
}
}
export async function listChatShareTokenRoomMaps(tokenId: string) {
const normalizedTokenId = tokenId.trim();
if (!normalizedTokenId) {
return [] as ChatShareTokenRoomMapItem[];
}
await ensureChatShareTokenRoomMapTable();
const rows = await db(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`)
.leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id')
.select(
'room_map.shared_resource_token_id',
'room_map.session_id',
'room_map.root_request_id',
'room_map.is_default',
'room_map.sort_order',
'room_map.created_by_client_id',
'room_map.created_at',
'room_map.updated_at',
'conversation.title',
'conversation.request_badge_label',
'conversation.chat_type_id',
'conversation.last_chat_type_id',
'conversation.context_label',
'conversation.context_description',
'conversation.notify_offline',
'room_map.link_context_json',
'conversation.updated_at as conversation_updated_at',
)
.where({ 'room_map.shared_resource_token_id': normalizedTokenId })
.whereNull('room_map.archived_at')
.orderBy('room_map.is_default', 'desc')
.orderBy('room_map.sort_order', 'asc')
.orderBy('room_map.created_at', 'asc');
return rows.map((row) => mapChatShareTokenRoomRow(row));
}
export async function getChatShareTokenRoomMap(tokenId: string, sessionId: string) {
const normalizedTokenId = tokenId.trim();
const normalizedSessionId = sessionId.trim();
if (!normalizedTokenId || !normalizedSessionId) {
return null;
}
const rooms = await listChatShareTokenRoomMaps(normalizedTokenId);
return rooms.find((item) => item.sessionId === normalizedSessionId) ?? null;
}
export async function upsertChatShareTokenRoomMap(args: {
tokenId: string;
sessionId: string;
rootRequestId: string;
isDefault?: boolean;
sortOrder?: number | null;
createdByClientId?: string | null;
linkContext?: ChatShareRoomLinkContext | null;
}) {
const normalizedTokenId = args.tokenId.trim();
const normalizedSessionId = args.sessionId.trim();
const normalizedRootRequestId = args.rootRequestId.trim();
if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) {
return null;
}
await ensureChatShareTokenRoomMapTable();
await db.transaction(async (trx) => {
const current = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
.where({
shared_resource_token_id: normalizedTokenId,
session_id: normalizedSessionId,
})
.whereNull('archived_at')
.first();
const maxSortOrderRow = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
.where({ shared_resource_token_id: normalizedTokenId })
.whereNull('archived_at')
.max<{ max_sort_order?: number | string | null }>('sort_order as max_sort_order')
.first();
const nextSortOrder = args.sortOrder != null
? Math.max(0, Math.trunc(Number(args.sortOrder) || 0))
: Math.max(0, normalizeInteger(maxSortOrderRow?.max_sort_order) + (current ? 0 : 1));
if (args.isDefault === true) {
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
.where({ shared_resource_token_id: normalizedTokenId })
.whereNull('archived_at')
.update({
is_default: false,
updated_at: db.fn.now(),
});
}
const payload = {
shared_resource_token_id: normalizedTokenId,
session_id: normalizedSessionId,
root_request_id: normalizedRootRequestId,
is_default: args.isDefault === true,
sort_order: nextSortOrder,
created_by_client_id: normalizeOptionalText(args.createdByClientId),
link_context_json:
args.linkContext === undefined
? (current?.link_context_json ?? null)
: stringifyChatShareRoomLinkContext(args.linkContext),
archived_at: null,
updated_at: db.fn.now(),
};
if (current) {
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
.where({
shared_resource_token_id: normalizedTokenId,
session_id: normalizedSessionId,
})
.update(payload);
return;
}
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE).insert({
...payload,
created_at: db.fn.now(),
});
});
return getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId);
}
export async function ensureDefaultChatShareTokenRoomMap(args: {
tokenId: string;
sessionId: string;
rootRequestId: string;
createdByClientId?: string | null;
}) {
const normalizedTokenId = args.tokenId.trim();
const normalizedSessionId = args.sessionId.trim();
const normalizedRootRequestId = args.rootRequestId.trim();
if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) {
return [];
}
const existing = await getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId);
if (!existing) {
await upsertChatShareTokenRoomMap({
tokenId: normalizedTokenId,
sessionId: normalizedSessionId,
rootRequestId: normalizedRootRequestId,
isDefault: true,
createdByClientId: args.createdByClientId ?? null,
});
}
const rooms = await listChatShareTokenRoomMaps(normalizedTokenId);
if (rooms.some((item) => item.isDefault)) {
return rooms;
}
await upsertChatShareTokenRoomMap({
tokenId: normalizedTokenId,
sessionId: normalizedSessionId,
rootRequestId: normalizedRootRequestId,
isDefault: true,
createdByClientId: args.createdByClientId ?? null,
});
return listChatShareTokenRoomMaps(normalizedTokenId);
}
export async function resolveChatShareTokenRoomSessionIds(tokenId: string) {
const rooms = await listChatShareTokenRoomMaps(tokenId);
return rooms.map((item) => item.sessionId).filter(Boolean);
}
export async function archiveChatShareTokenRoomMap(tokenId: string, sessionId: string) {
const normalizedTokenId = tokenId.trim();
const normalizedSessionId = sessionId.trim();
if (!normalizedTokenId || !normalizedSessionId) {
return {
archived: false,
archivedRoom: null,
nextDefaultRoom: null,
} as const;
}
await ensureChatShareTokenRoomMapTable();
return db.transaction(async (trx) => {
const current = await trx(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`)
.leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id')
.select(
'room_map.shared_resource_token_id',
'room_map.session_id',
'room_map.root_request_id',
'room_map.is_default',
'room_map.sort_order',
'room_map.created_by_client_id',
'room_map.created_at',
'room_map.updated_at',
'conversation.title',
'conversation.request_badge_label',
'conversation.chat_type_id',
'conversation.last_chat_type_id',
'conversation.context_label',
'conversation.context_description',
'conversation.notify_offline',
'conversation.updated_at as conversation_updated_at',
)
.where({
'room_map.shared_resource_token_id': normalizedTokenId,
'room_map.session_id': normalizedSessionId,
})
.whereNull('room_map.archived_at')
.first();
if (!current) {
return {
archived: false,
archivedRoom: null,
nextDefaultRoom: null,
} as const;
}
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
.where({
shared_resource_token_id: normalizedTokenId,
session_id: normalizedSessionId,
})
.whereNull('archived_at')
.update({
archived_at: db.fn.now(),
updated_at: db.fn.now(),
});
let nextDefaultRoom: ChatShareTokenRoomMapItem | null = null;
if (current.is_default) {
const nextDefaultRow = await trx(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`)
.leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id')
.select(
'room_map.shared_resource_token_id',
'room_map.session_id',
'room_map.root_request_id',
'room_map.is_default',
'room_map.sort_order',
'room_map.created_by_client_id',
'room_map.created_at',
'room_map.updated_at',
'conversation.title',
'conversation.request_badge_label',
'conversation.chat_type_id',
'conversation.last_chat_type_id',
'conversation.context_label',
'conversation.context_description',
'conversation.notify_offline',
'conversation.updated_at as conversation_updated_at',
)
.where({ 'room_map.shared_resource_token_id': normalizedTokenId })
.whereNull('room_map.archived_at')
.orderBy('room_map.sort_order', 'asc')
.orderBy('room_map.created_at', 'asc')
.first();
if (nextDefaultRow) {
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
.where({
shared_resource_token_id: normalizedTokenId,
session_id: nextDefaultRow.session_id,
})
.whereNull('archived_at')
.update({
is_default: true,
updated_at: db.fn.now(),
});
nextDefaultRoom = mapChatShareTokenRoomRow({
...nextDefaultRow,
is_default: true,
});
}
}
return {
archived: true,
archivedRoom: mapChatShareTokenRoomRow(current),
nextDefaultRoom,
} as const;
});
}

View File

@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = exports.UI_IMPROVEMENT_CHAT_TYPE_ID = void 0;
exports.UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement';
exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선';
exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = '## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = '## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 화면 테스트, 소스 변경 검증, 최종 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';

View File

@@ -1,7 +1,7 @@
export const UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement';
export const UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선';
export const UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION =
'## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
'## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 화면 테스트, 소스 변경 검증, 최종 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';

View File

@@ -186,7 +186,7 @@ test('syncMainProjectBranchForReservedRestart commits local changes and pushes t
}
});
test('syncMainProjectBranchForReservedRestart keeps reserved restart local when local main mode is enabled', async () => {
test('syncMainProjectBranchForReservedRestart skips git sync when local main mode is enabled', async () => {
const { repoPath } = await createRepo();
const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE;
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
@@ -195,25 +195,26 @@ test('syncMainProjectBranchForReservedRestart keeps reserved restart local when
await runGit(repoPath, ['switch', 'main']);
await writeFile(path.join(repoPath, 'note.txt'), 'hello local reserved restart\n', 'utf8');
const headBefore = await runGit(repoPath, ['rev-parse', 'HEAD']);
const remoteHeadBefore = await runGit(repoPath, ['rev-parse', 'origin/main']);
const statusBefore = await runGit(repoPath, ['status', '--porcelain']);
const result = await syncMainProjectBranchForReservedRestart(
repoPath,
'main',
'chore: sync main before reserved restart',
);
const head = await runGit(repoPath, ['rev-parse', 'HEAD']);
const headAfter = await runGit(repoPath, ['rev-parse', 'HEAD']);
const remoteHeadAfter = await runGit(repoPath, ['rev-parse', 'origin/main']);
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']);
const statusAfter = await runGit(repoPath, ['status', '--porcelain']);
assert.equal(result.committed, true);
assert.equal(result.commitMessage, 'chore: sync main before reserved restart');
assert.equal(result.head, head);
assert.equal(result.committed, false);
assert.equal(result.commitMessage, null);
assert.equal(result.head, null);
assert.equal(result.syncMode, 'local');
assert.equal(remoteHeadAfter, remoteHeadBefore);
assert.notEqual(remoteHeadAfter, head);
assert.equal(mainMessage, 'chore: sync main before reserved restart');
assert.equal(noteContent, 'hello local reserved restart');
assert.equal(headAfter, headBefore);
assert.equal(statusBefore, '?? note.txt');
assert.equal(statusAfter, '?? note.txt');
} finally {
if (previousLocalMainMode === undefined) {
delete process.env.PLAN_LOCAL_MAIN_MODE;

View File

@@ -174,22 +174,25 @@ export async function syncMainProjectBranchForReservedRestart(
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
if (useLocalMainMode) {
await assertBranchExists(repoPath, branchName);
await runGit(repoPath, ['switch', branchName]);
} else {
await runGit(repoPath, ['fetch', 'origin', branchName]);
await ensureLocalBranchFromRemote(repoPath, branchName);
return {
branchName,
commitMessage: null,
committed: false,
head: null,
syncMode: 'local' as const,
};
}
await runGit(repoPath, ['fetch', 'origin', branchName]);
await ensureLocalBranchFromRemote(repoPath, branchName);
const hadChanges = await hasWorkingTreeChanges(repoPath);
if (hadChanges) {
await commitAllChanges(repoPath, commitMessage);
}
if (!useLocalMainMode) {
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
await pushBranch(repoPath, branchName);
}
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
await pushBranch(repoPath, branchName);
const { stdout: head } = await runGit(repoPath, ['rev-parse', 'HEAD']);
@@ -198,7 +201,7 @@ export async function syncMainProjectBranchForReservedRestart(
commitMessage: hadChanges ? commitMessage : null,
committed: hadChanges,
head,
syncMode: useLocalMainMode ? 'local' as const : 'remote' as const,
syncMode: 'remote' as const,
};
}

View File

@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveNotificationAggregateResult } from './notification-service.js';
import { resolveNotificationAggregateResult, withInferredNotificationOriginData } from './notification-service.js';
test('resolveNotificationAggregateResult marks managed-service web failures as failed when iOS is disabled', () => {
const result = resolveNotificationAggregateResult(
@@ -35,3 +35,43 @@ test('resolveNotificationAggregateResult treats fully skipped enabled channels a
skipped: true,
});
});
test('notification payload infers app origin metadata from target filters when missing', () => {
const resolved = withInferredNotificationOriginData({
title: 'Codex Live test',
body: 'body',
data: {},
targetAppOrigins: ['https://test.sm-home.cloud'],
} as any);
assert.equal(resolved.data.appOrigin, 'https://test.sm-home.cloud');
assert.equal(resolved.data.appDomain, 'test.sm-home.cloud');
});
test('notification payload infers app domain metadata from target filters when only domain is provided', () => {
const resolved = withInferredNotificationOriginData({
title: 'Codex Live test',
body: 'body',
data: {},
targetAppDomains: ['test.sm-home.cloud'],
} as any);
assert.equal(resolved.data.appOrigin, undefined);
assert.equal(resolved.data.appDomain, 'test.sm-home.cloud');
});
test('notification payload keeps explicit app origin metadata when already present', () => {
const resolved = withInferredNotificationOriginData({
title: 'Codex Live test',
body: 'body',
data: {
appOrigin: 'https://preview.sm-home.cloud',
appDomain: 'preview.sm-home.cloud',
},
targetAppOrigins: ['https://test.sm-home.cloud'],
targetAppDomains: ['test.sm-home.cloud'],
} as any);
assert.equal(resolved.data.appOrigin, 'https://preview.sm-home.cloud');
assert.equal(resolved.data.appDomain, 'preview.sm-home.cloud');
});

View File

@@ -51,6 +51,7 @@ export const registerWebPushSubscriptionSchema = z.object({
}),
}),
deviceId: z.string().trim().min(1).max(200).optional(),
clientId: z.string().trim().min(1).max(200).optional(),
userAgent: z.string().trim().max(500).optional(),
appOrigin: z.string().trim().url().max(500).optional(),
appDomain: z.string().trim().min(1).max(255).optional(),
@@ -66,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(),
@@ -80,8 +82,44 @@ 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[];
}) {
return [...new Set((payload.targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeTargetClientIds(payload: {
targetClientIds?: string[];
}) {
return [...new Set((payload.targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
return [...new Set(values.map((value) => String(value ?? '').trim()).filter(Boolean))];
}
async function removeLegacyWebPushSubscriptionsForRegistration(args: {
endpoint: string;
deviceId?: string;
clientId?: string;
userAgent?: string;
appOrigin?: string;
}) {
const appOrigin = normalizeAppOrigin(args.appOrigin);
const userAgent = String(args.userAgent ?? '').trim();
const deviceId = String(args.deviceId ?? '').trim();
const clientId = String(args.clientId ?? '').trim();
if (!appOrigin || !userAgent || !deviceId || !clientId || deviceId === clientId) {
return 0;
}
return db(WEB_PUSH_SUBSCRIPTION_TABLE)
.whereNot({ endpoint: args.endpoint })
.andWhere({ app_origin: appOrigin, user_agent: userAgent })
.whereNull('client_id')
.whereNotIn('device_id', [deviceId, clientId])
.delete();
}
function normalizeTargetAppOrigins(targetAppOrigins: string[] | undefined) {
@@ -92,12 +130,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
function isAllowedTargetClientId(deviceId: string, targetClientIds: string[]) {
if (targetClientIds.length === 0) {
function isAllowedTargetRecipient(
target: {
deviceId?: string;
clientId?: string;
},
targetDeviceIds: string[],
targetClientIds: string[],
) {
if (targetDeviceIds.length === 0 && targetClientIds.length === 0) {
return true;
}
return Boolean(deviceId) && targetClientIds.includes(deviceId);
const deviceId = String(target.deviceId ?? '').trim();
const clientId = String(target.clientId ?? '').trim();
return (Boolean(deviceId) && targetDeviceIds.includes(deviceId)) || (Boolean(clientId) && targetClientIds.includes(clientId));
}
function normalizeAppOrigin(value: unknown) {
@@ -218,6 +265,31 @@ function normalizeNotificationDetailText(text?: string | null) {
return normalized || undefined;
}
export function withInferredNotificationOriginData(payload: IosNotificationPayload): IosNotificationPayload {
const targetAppOrigin = normalizeTargetAppOrigins(payload.targetAppOrigins)[0] ?? '';
const targetAppDomain =
normalizeTargetAppDomains(payload.targetAppDomains)[0] ?? resolveAppDomainFromOrigin(targetAppOrigin);
const currentData = payload.data ?? {};
const currentAppOrigin = normalizeAppOrigin(currentData.appOrigin);
const currentAppDomain = normalizeAppDomain(currentData.appDomain);
if (
(!targetAppOrigin || currentAppOrigin === targetAppOrigin) &&
(!targetAppDomain || currentAppDomain === targetAppDomain)
) {
return payload;
}
return {
...payload,
data: {
...currentData,
...(currentAppOrigin ? {} : targetAppOrigin ? { appOrigin: targetAppOrigin } : {}),
...(currentAppDomain ? {} : targetAppDomain ? { appDomain: targetAppDomain } : {}),
},
};
}
function isChatNotificationPayload(payload: IosNotificationPayload) {
const category = String(payload.data?.category ?? '').trim().toLowerCase();
const threadId = String(payload.threadId ?? '').trim().toLowerCase();
@@ -375,6 +447,7 @@ async function ensureWebPushSubscriptionTable() {
table.string('endpoint', 1000).notNullable().unique();
table.jsonb('subscription_json').notNullable();
table.string('device_id', 200).nullable();
table.string('client_id', 200).nullable();
table.text('user_agent').nullable();
table.string('app_origin', 500).nullable();
table.string('app_domain', 255).nullable();
@@ -391,6 +464,7 @@ async function ensureWebPushSubscriptionTable() {
['endpoint', (table) => table.string('endpoint', 1000).notNullable()],
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
['device_id', (table) => table.string('device_id', 200).nullable()],
['client_id', (table) => table.string('client_id', 200).nullable()],
['user_agent', (table) => table.text('user_agent').nullable()],
['app_origin', (table) => table.string('app_origin', 500).nullable()],
['app_domain', (table) => table.string('app_domain', 255).nullable()],
@@ -500,6 +574,7 @@ export async function listWebPushSubscriptions() {
id: row.id,
endpoint: String(row.endpoint ?? ''),
deviceId: row.device_id ? String(row.device_id) : '',
clientId: row.client_id ? String(row.client_id) : '',
userAgent: row.user_agent ? String(row.user_agent) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
@@ -641,6 +716,7 @@ export async function registerWebPushSubscription(
await ensureWebPushSubscriptionTable();
const appOrigin = normalizeAppOrigin(payload.appOrigin);
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
const cleanupTargetIds = normalizeRegistrationCleanupIds(payload.deviceId, payload.clientId);
if (!payload.enabled) {
await unregisterWebPushSubscription(payload.subscription.endpoint);
@@ -657,6 +733,7 @@ export async function registerWebPushSubscription(
endpoint: payload.subscription.endpoint,
subscription_json: payload.subscription,
device_id: payload.deviceId ?? null,
client_id: payload.clientId ?? null,
user_agent: payload.userAgent ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
@@ -668,6 +745,7 @@ export async function registerWebPushSubscription(
.merge({
subscription_json: payload.subscription,
device_id: payload.deviceId ?? null,
client_id: payload.clientId ?? null,
user_agent: payload.userAgent ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
@@ -676,13 +754,23 @@ export async function registerWebPushSubscription(
updated_at: db.fn.now(),
});
if (payload.deviceId?.trim()) {
if (cleanupTargetIds.length > 0) {
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
.where({ device_id: payload.deviceId.trim() })
.whereNot({ endpoint: payload.subscription.endpoint })
.andWhere((builder) => {
builder.whereIn('device_id', cleanupTargetIds).orWhereIn('client_id', cleanupTargetIds);
})
.delete();
}
await removeLegacyWebPushSubscriptionsForRegistration({
endpoint: payload.subscription.endpoint,
deviceId: payload.deviceId,
clientId: payload.clientId,
userAgent: payload.userAgent,
appOrigin,
});
return {
ok: true,
endpoint: payload.subscription.endpoint,
@@ -726,12 +814,13 @@ async function getEnabledWebPushSubscriptions() {
.where({
is_enabled: true,
})
.select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain');
.select('endpoint', 'subscription_json', 'device_id', 'client_id', 'app_origin', 'app_domain');
return rows.map((row) => ({
endpoint: String(row.endpoint),
subscription: row.subscription_json as WebPushSubscriptionPayload,
deviceId: row.device_id ? String(row.device_id) : '',
clientId: row.client_id ? String(row.client_id) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
}));
@@ -834,7 +923,8 @@ 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 targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
@@ -870,7 +960,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
.filter(
(row) =>
row.allowed &&
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
isAllowedTargetRecipient({ deviceId: row.deviceId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token);
@@ -919,7 +1009,8 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
async function sendWebPushNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
const targetDeviceIds = normalizeTargetDeviceIds(payload);
const targetClientIds = normalizeTargetClientIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!ensureWebPushConfigured(env)) {
@@ -940,17 +1031,25 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
[
{ kind: 'web-endpoint', id: row.endpoint },
{ kind: 'client', id: row.deviceId },
{ kind: 'client', id: row.clientId },
],
payload,
),
})),
)
).filter(
(row) =>
(row) =>
row.allowed &&
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
isAllowedTargetRecipient({ deviceId: row.deviceId, clientId: row.clientId }, targetDeviceIds, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
const matchedSubscriptions = subscriptions.map((row) => ({
endpoint: row.endpoint,
deviceId: row.deviceId,
clientId: row.clientId,
appOrigin: row.appOrigin,
appDomain: row.appDomain,
}));
if (!subscriptions.length) {
return {
@@ -959,6 +1058,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
reason: '등록된 Web Push 구독이 없습니다.',
sentCount: 0,
failedCount: 0,
matchedCount: 0,
matchedSubscriptions,
};
}
@@ -1026,6 +1127,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
sentCount,
failedCount,
invalidEndpoints,
matchedCount: matchedSubscriptions.length,
matchedSubscriptions,
};
}
@@ -1036,6 +1139,7 @@ export async function sendNotifications(
disableWebPush?: boolean;
},
) {
const resolvedPayload = withInferredNotificationOriginData(payload);
const [ios, web] = await Promise.all([
options?.disableIos
? Promise.resolve({
@@ -1046,7 +1150,7 @@ export async function sendNotifications(
failedCount: 0,
invalidTokens: [],
})
: sendIosNotifications(payload),
: sendIosNotifications(resolvedPayload),
options?.disableWebPush
? Promise.resolve({
ok: true,
@@ -1056,7 +1160,7 @@ export async function sendNotifications(
failedCount: 0,
invalidEndpoints: [],
})
: sendWebPushNotifications(payload),
: sendWebPushNotifications(resolvedPayload),
]);
const aggregate = resolveNotificationAggregateResult(

View File

@@ -25,6 +25,7 @@ import {
import { getKstNowParts } from './worklog-automation-utils.js';
export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks';
const PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE = 741_205_262;
const scheduleModes = ['interval', 'daily'] as const;
const repeatIntervalUnits = ['second', 'minute', 'hour', 'day', 'week', 'month'] as const;
const scheduleExecutionModes = ['codex', 'managed-service'] as const;
@@ -515,6 +516,39 @@ function normalizeBoolean(value: unknown, fallback: boolean) {
return fallback;
}
function readBooleanLikeValue(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === 't' || normalized === '1';
}
return false;
}
async function tryAcquirePlanScheduleRegistrationLock(scheduleId: number) {
const result = (await db.raw('select pg_try_advisory_lock(?, ?) as locked', [
PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE,
scheduleId,
])) as { rows?: Array<{ locked?: unknown }> };
return readBooleanLikeValue(result.rows?.[0]?.locked);
}
async function releasePlanScheduleRegistrationLock(scheduleId: number) {
await db.raw('select pg_advisory_unlock(?, ?)', [
PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE,
scheduleId,
]);
}
function buildManagedServiceFailureSummary(result: {
title?: string;
skipped?: boolean;
@@ -1510,162 +1544,179 @@ export async function registerDuePlanScheduledTasks(now = new Date()) {
}
async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: Date) {
const executionMode = normalizeScheduleExecutionMode(row.execution_mode);
const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json);
const shouldRefreshSnapshot =
!row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false);
const scheduleSnapshot = shouldRefreshSnapshot
? await ensureSchedulePromptSnapshot({
scheduleId: Number(row.id),
workId: buildScheduledPlanWorkIdBase(row),
note: String(row.note ?? ''),
forceRefresh: true,
})
: {
directory: `.auto_codex/schedule/${row.id}`,
requestPath: `.auto_codex/schedule/${row.id}/request.md`,
contextPath: `.auto_codex/schedule/${row.id}/context.md`,
manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`,
};
const managedServiceReady = await ensureManagedServiceExecutionReady({
row,
scheduleSnapshot,
automationContextIds,
});
const effectiveRow = managedServiceReady.row;
const scheduleNote = [
String(effectiveRow.note ?? '').trim(),
'',
'## 스케줄 전용 참조 문서',
`- ${scheduleSnapshot.requestPath}`,
`- ${scheduleSnapshot.contextPath}`,
'',
'위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.',
executionMode === 'managed-service'
? [
'',
'## 스케줄 관리 서비스',
`- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`,
`- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`)}`,
managedServiceReady.ready
? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.'
: `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`,
managedServiceReady.reason
? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}`
: null,
'- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.',
].join('\n')
: null,
]
.filter((value): value is string => Boolean(value))
.join('\n')
.trim();
const scheduleId = Number(row.id);
if (executionMode === 'managed-service') {
if (!managedServiceReady.ready) {
return {
createdPlan: managedServiceReady.createdPlan,
createdBoardPosts: managedServiceReady.createdBoardPosts,
};
}
const managedServiceDirectory = String(
effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`,
);
const managedServiceResult = await runManagedScheduleService(managedServiceDirectory);
if (!managedServiceResult.ok) {
throw new Error(
`스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`,
);
}
const createdPlan = await createCompletedPlanExecutionLogItem({
workId: buildScheduledPlanWorkIdBase(effectiveRow),
note: scheduleNote,
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
automationContextIds,
releaseTarget: String(effectiveRow.release_target ?? 'release'),
jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true),
suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false),
repeatRequestEnabled: false,
repeatIntervalMinutes: 60,
});
const managedServiceChangedFiles = [
`${managedServiceDirectory}/README.md`,
`${managedServiceDirectory}/service.ts`,
`${managedServiceDirectory}/service.mjs`,
`${managedServiceDirectory}/service-manifest.json`,
];
await createPlanSourceWorkHistory(Number(createdPlan.id), {
summary: [
`스케줄 서비스 실행: schedule #${effectiveRow.id}`,
`서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`,
`결과: ${
managedServiceResult.skipped
? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})`
: `${managedServiceResult.itemCount}건 전송 시도`
}`,
].join('\n'),
branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'),
commitHash: null,
changedFiles: managedServiceChangedFiles,
commandLog: [
`schedule-managed-service run scheduleId=${String(effectiveRow.id)}`,
`servicePath=${managedServiceDirectory}/service.mjs`,
`itemCount=${managedServiceResult.itemCount}`,
`webSent=${managedServiceResult.web.sentCount}`,
`webFailed=${managedServiceResult.web.failedCount}`,
`skipped=${managedServiceResult.skipped ? 'true' : 'false'}`,
`reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`,
].join('\n'),
diffText: null,
sourceFiles: [],
});
await createPlanActionHistory(
Number(createdPlan.id),
'스케줄서비스실행',
`Plan 스케줄 #${effectiveRow.id} 전용 서비스 파일을 직접 실행했습니다.`,
);
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id: effectiveRow.id })
.update({
last_registered_at: now,
context_snapshot_generated_at: now,
context_snapshot_refresh_requested: false,
managed_service_generated_at: db.fn.now(),
updated_at: db.fn.now(),
});
if (!Number.isInteger(scheduleId) || scheduleId <= 0) {
throw new Error('유효하지 않은 스케줄 ID입니다.');
}
if (!(await tryAcquirePlanScheduleRegistrationLock(scheduleId))) {
return {
createdPlan,
createdPlan: null,
createdBoardPosts: [],
};
}
const boardPost = await createBoardPost({
title: buildScheduledBoardPostTitle(effectiveRow),
content: scheduleNote,
attachments: [],
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
automationContextIds,
requestExecutionMode: 'all_at_once',
requestItems: [],
});
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id: effectiveRow.id })
.update({
last_registered_at: now,
context_snapshot_generated_at: now,
context_snapshot_refresh_requested: false,
updated_at: db.fn.now(),
try {
const executionMode = normalizeScheduleExecutionMode(row.execution_mode);
const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json);
const shouldRefreshSnapshot =
!row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false);
const scheduleSnapshot = shouldRefreshSnapshot
? await ensureSchedulePromptSnapshot({
scheduleId: Number(row.id),
workId: buildScheduledPlanWorkIdBase(row),
note: String(row.note ?? ''),
forceRefresh: true,
})
: {
directory: `.auto_codex/schedule/${row.id}`,
requestPath: `.auto_codex/schedule/${row.id}/request.md`,
contextPath: `.auto_codex/schedule/${row.id}/context.md`,
manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`,
};
const managedServiceReady = await ensureManagedServiceExecutionReady({
row,
scheduleSnapshot,
automationContextIds,
});
return {
createdPlan: null,
createdBoardPosts: [boardPost],
};
const effectiveRow = managedServiceReady.row;
const scheduleNote = [
String(effectiveRow.note ?? '').trim(),
'',
'## 스케줄 전용 참조 문서',
`- ${scheduleSnapshot.requestPath}`,
`- ${scheduleSnapshot.contextPath}`,
'',
'위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.',
executionMode === 'managed-service'
? [
'',
'## 스케줄 관리 서비스',
`- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${scheduleId}-service`)}`,
`- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${scheduleId}`)}`,
managedServiceReady.ready
? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.'
: `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`,
managedServiceReady.reason
? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}`
: null,
'- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.',
].join('\n')
: null,
]
.filter((value): value is string => Boolean(value))
.join('\n')
.trim();
if (executionMode === 'managed-service') {
if (!managedServiceReady.ready) {
return {
createdPlan: managedServiceReady.createdPlan,
createdBoardPosts: managedServiceReady.createdBoardPosts,
};
}
const managedServiceDirectory = String(
effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${scheduleId}`,
);
const managedServiceResult = await runManagedScheduleService(managedServiceDirectory);
if (!managedServiceResult.ok) {
throw new Error(
`스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`,
);
}
const createdPlan = await createCompletedPlanExecutionLogItem({
workId: buildScheduledPlanWorkIdBase(effectiveRow),
note: scheduleNote,
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
automationContextIds,
releaseTarget: String(effectiveRow.release_target ?? 'release'),
jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true),
autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true),
suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false),
repeatRequestEnabled: false,
repeatIntervalMinutes: 60,
});
const managedServiceChangedFiles = [
`${managedServiceDirectory}/README.md`,
`${managedServiceDirectory}/service.ts`,
`${managedServiceDirectory}/service.mjs`,
`${managedServiceDirectory}/service-manifest.json`,
];
await createPlanSourceWorkHistory(Number(createdPlan.id), {
summary: [
`스케줄 서비스 실행: schedule #${scheduleId}`,
`서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${scheduleId}-service`)}`,
`결과: ${
managedServiceResult.skipped
? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})`
: `${managedServiceResult.itemCount}건 전송 시도`
}`,
].join('\n'),
branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'),
commitHash: null,
changedFiles: managedServiceChangedFiles,
commandLog: [
`schedule-managed-service run scheduleId=${String(effectiveRow.id)}`,
`servicePath=${managedServiceDirectory}/service.mjs`,
`itemCount=${managedServiceResult.itemCount}`,
`webSent=${managedServiceResult.web.sentCount}`,
`webFailed=${managedServiceResult.web.failedCount}`,
`skipped=${managedServiceResult.skipped ? 'true' : 'false'}`,
`reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`,
].join('\n'),
diffText: null,
sourceFiles: [],
});
await createPlanActionHistory(
Number(createdPlan.id),
'스케줄서비스실행',
`Plan 스케줄 #${scheduleId} 전용 서비스 파일을 직접 실행했습니다.`,
);
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id: scheduleId })
.update({
last_registered_at: now,
context_snapshot_generated_at: now,
context_snapshot_refresh_requested: false,
managed_service_generated_at: db.fn.now(),
updated_at: db.fn.now(),
});
return {
createdPlan,
createdBoardPosts: [],
};
}
const boardPost = await createBoardPost({
title: buildScheduledBoardPostTitle(effectiveRow),
content: scheduleNote,
attachments: [],
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
automationContextIds,
requestExecutionMode: 'all_at_once',
requestItems: [],
});
await db(PLAN_SCHEDULED_TASK_TABLE)
.where({ id: scheduleId })
.update({
last_registered_at: now,
context_snapshot_generated_at: now,
context_snapshot_refresh_requested: false,
updated_at: db.fn.now(),
});
return {
createdPlan: null,
createdBoardPosts: [boardPost],
};
} finally {
await releasePlanScheduleRegistrationLock(scheduleId);
}
}
export async function registerPlanScheduledTaskNow(

View File

@@ -30,6 +30,13 @@ test('resolveStaticContentType returns video content types for common video file
assert.equal(resolveStaticContentType('/tmp/sample.mov'), 'video/quicktime');
});
test('resolveStaticContentType returns audio content types for common audio files', () => {
assert.equal(resolveStaticContentType('/tmp/sample.wav'), 'audio/wav');
assert.equal(resolveStaticContentType('/tmp/sample.mp3'), 'audio/mpeg');
assert.equal(resolveStaticContentType('/tmp/sample.ogg'), 'audio/ogg');
assert.equal(resolveStaticContentType('/tmp/sample.m4a'), 'audio/mp4');
});
async function withTempRepo(callback: (repoRoot: string) => Promise<void>) {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'resource-manager-test-'));
@@ -120,3 +127,27 @@ test('directory modifiedAt reflects the latest nested descendant change', async
assert.equal(docsNode.modifiedAt, latestModifiedAt.toISOString());
});
});
test('resource manager tree and directory listing include dot-prefixed entries', async () => {
await withTempRepo(async (repoRoot) => {
await createResourceManagerDirectory(repoRoot, '', '.codex_chat');
await createResourceManagerFile(repoRoot, '', '.env', 'TOKEN=1');
await createResourceManagerFile(repoRoot, '.codex_chat', 'note.md', '# hidden');
const directory = await listResourceManagerDirectory(repoRoot, '');
assert.deepEqual(
directory.items.map((item) => item.path),
['.codex_chat', '.env'],
);
const tree = await getResourceManagerTree(repoRoot);
assert.deepEqual(
tree.tree.children?.map((item) => item.path),
['.codex_chat', '.env'],
);
assert.deepEqual(
tree.tree.children?.[0]?.children?.map((item) => item.path),
['.codex_chat/note.md'],
);
});
});

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { createReadStream, type ReadStream } from 'node:fs';
import { accessSync, existsSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -45,6 +45,12 @@ export type ResourceManagerFileDetail = {
content: string | null;
};
export type ResourceManagerPreviewStream = {
contentType: string;
size: number;
createStream: (range?: { start?: number; end?: number }) => ReadStream;
};
class ResourceManagerError extends Error {
statusCode: number;
@@ -57,6 +63,7 @@ class ResourceManagerError extends Error {
const RESOURCE_MANAGER_ROOT_DIR = 'resource';
const RESOURCE_MANAGER_ROOT_LABEL = 'resource';
const LEGACY_PUBLIC_RESOURCE_ROOT_DIR = path.join('public', 'resource');
const TEXT_FILE_EXTENSIONS = new Set([
'.txt',
@@ -133,6 +140,14 @@ export function resolveStaticContentType(filePath: string) {
return 'image/gif';
case '.webp':
return 'image/webp';
case '.wav':
return 'audio/wav';
case '.mp3':
return 'audio/mpeg';
case '.ogg':
return 'audio/ogg';
case '.m4a':
return 'audio/mp4';
case '.mp4':
return 'video/mp4';
case '.webm':
@@ -185,6 +200,52 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) {
return normalized.replace(/^\/+/, '');
}
function decodeRepeatedly(value: string, maxIterations = 3) {
let current = value;
for (let index = 0; index < maxIterations; index += 1) {
try {
const decoded = decodeURIComponent(current);
if (!decoded || decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}
function normalizePreviewTargetPath(targetPath: string) {
const normalized = normalizeRelativeTarget(targetPath);
if (!normalized) {
return normalized;
}
const segments = normalized.split('/');
const lastSegment = segments.at(-1) ?? '';
const decodedLastSegment = decodeRepeatedly(lastSegment);
const hashIndex = decodedLastSegment.lastIndexOf('#');
if (hashIndex <= 0) {
return normalized;
}
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
if (!fileName || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
return normalized;
}
segments[segments.length - 1] = fileName;
return normalizeRelativeTarget(segments.join('/'));
}
function resolveRepoRoot(candidateRootPath: string) {
const candidates = [
candidateRootPath,
@@ -207,6 +268,30 @@ export function resolveResourceManagerRoot(repoRootPath: string) {
return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR);
}
function resolveLegacyPublicResourceRoot(repoRootPath: string) {
return path.join(resolveRepoRoot(repoRootPath), LEGACY_PUBLIC_RESOURCE_ROOT_DIR);
}
function resolveLegacyPublicResourcePreviewPath(repoRootPath: string, targetPath: string) {
const normalizedRelativePath = normalizeRelativeTarget(targetPath);
if (!normalizedRelativePath) {
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
}
const rootPath = resolveLegacyPublicResourceRoot(repoRootPath);
const absolutePath = path.resolve(rootPath, normalizedRelativePath);
if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) {
throw new ResourceManagerError('허용되지 않은 경로입니다.', 403);
}
return {
absolutePath,
relativePath: normalizedRelativePath,
};
}
function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) {
const rootPath = resolveResourceManagerRoot(repoRootPath);
const normalizedRelativePath = normalizeRelativeTarget(relativePath);
@@ -301,10 +386,6 @@ async function resolveDirectoryLatestModifiedAt(absolutePath: string, stats?: Aw
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.')) {
continue;
}
const entryAbsolutePath = path.join(absolutePath, entry.name);
const entryStats = await fs.stat(entryAbsolutePath);
const entryModifiedAt = entry.isDirectory()
@@ -336,7 +417,6 @@ async function buildTreeNode(absolutePath: string, relativePath: string): Promis
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
const children = await Promise.all(
entries
.filter((entry) => !entry.name.startsWith('.'))
.sort((left, right) => {
if (left.isDirectory() && !right.isDirectory()) {
return -1;
@@ -395,7 +475,6 @@ export async function listResourceManagerDirectory(repoRootPath: string, directo
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
const items: ResourceManagerDirectoryEntry[] = await Promise.all(
entries
.filter((entry) => !entry.name.startsWith('.'))
.sort((left, right) => {
if (left.isDirectory() && !right.isDirectory()) {
return -1;
@@ -596,7 +675,12 @@ export async function deleteResourceManagerItem(repoRootPath: string, targetPath
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
return withResourceManagerError(async () => {
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
const normalizedTargetPath = normalizePreviewTargetPath(targetPath);
const primaryTarget = resolveResourceManagerTargetPath(repoRootPath, normalizedTargetPath);
const legacyTarget = resolveLegacyPublicResourcePreviewPath(repoRootPath, normalizedTargetPath);
const absolutePath = existsSync(primaryTarget.absolutePath)
? primaryTarget.absolutePath
: legacyTarget.absolutePath;
if (!existsSync(absolutePath)) {
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
@@ -609,8 +693,9 @@ export async function openResourceManagerPreviewStream(repoRootPath: string, tar
}
return {
stream: createReadStream(absolutePath),
contentType: resolveStaticContentType(absolutePath),
};
size: stats.size,
createStream: (range?: { start?: number; end?: number }) => createReadStream(absolutePath, range),
} satisfies ResourceManagerPreviewStream;
});
}

View File

@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
beginRuntimeDrain,
endRuntimeDrain,
getRuntimeDrainSnapshot,
isRuntimeDraining,
trackHttpRequestFinished,
trackHttpRequestStarted,
trackWebSocketConnectionClosed,
trackWebSocketConnectionOpened,
} from './runtime-drain-service.js';
test('runtime drain service tracks drain and connection counters without going negative', () => {
endRuntimeDrain();
trackHttpRequestFinished();
trackWebSocketConnectionClosed();
assert.equal(isRuntimeDraining(), false);
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 0);
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 0);
beginRuntimeDrain();
trackHttpRequestStarted();
trackHttpRequestStarted();
trackWebSocketConnectionOpened();
assert.equal(isRuntimeDraining(), true);
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 2);
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 1);
trackHttpRequestFinished();
trackHttpRequestFinished();
trackHttpRequestFinished();
trackWebSocketConnectionClosed();
trackWebSocketConnectionClosed();
endRuntimeDrain();
const snapshot = getRuntimeDrainSnapshot();
assert.equal(snapshot.draining, false);
assert.equal(snapshot.drainStartedAt, null);
assert.equal(snapshot.activeHttpRequestCount, 0);
assert.equal(snapshot.activeWebSocketConnectionCount, 0);
});

View File

@@ -0,0 +1,56 @@
type RuntimeDrainState = {
draining: boolean;
drainStartedAt: string | null;
activeHttpRequestCount: number;
activeWebSocketConnectionCount: number;
};
const state: RuntimeDrainState = {
draining: false,
drainStartedAt: null,
activeHttpRequestCount: 0,
activeWebSocketConnectionCount: 0,
};
function clampCount(value: number) {
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
}
export function beginRuntimeDrain() {
state.draining = true;
state.drainStartedAt = new Date().toISOString();
}
export function endRuntimeDrain() {
state.draining = false;
state.drainStartedAt = null;
}
export function isRuntimeDraining() {
return state.draining;
}
export function trackHttpRequestStarted() {
state.activeHttpRequestCount += 1;
}
export function trackHttpRequestFinished() {
state.activeHttpRequestCount = clampCount(state.activeHttpRequestCount - 1);
}
export function trackWebSocketConnectionOpened() {
state.activeWebSocketConnectionCount += 1;
}
export function trackWebSocketConnectionClosed() {
state.activeWebSocketConnectionCount = clampCount(state.activeWebSocketConnectionCount - 1);
}
export function getRuntimeDrainSnapshot() {
return {
draining: state.draining,
drainStartedAt: state.drainStartedAt,
activeHttpRequestCount: state.activeHttpRequestCount,
activeWebSocketConnectionCount: state.activeWebSocketConnectionCount,
};
}

View File

@@ -13,6 +13,7 @@ import {
listServerCommands,
resolveDockerSocketPath,
restartServerCommand,
readWorkServerDeploymentState,
} from './server-command-service.js';
test('buildRestartFailureMessage includes exit info and stderr output', () => {
@@ -69,8 +70,12 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/);
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
assert.match(
testScript,
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --no-deps --force-recreate "\$SERVER_COMMAND_SERVICE"/,
);
assert.match(testScript, /TEST_BUILD_STAMP_FILE="\$\{TEST_BUILD_STAMP_FILE:-\$MAIN_PROJECT_ROOT\/\.server-command-test-app-built-at\}"/);
assert.match(testScript, /date -Iseconds > "\$TEST_BUILD_STAMP_FILE"/);
assert.match(testScript, /restart-via-docker-socket\.mjs/);
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
assert.match(relScript, /command -v docker >/);
@@ -92,9 +97,32 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
assert.match(
workServerScript,
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
/ACTIVE_SLOT_FILE="\$\{WORK_SERVER_ACTIVE_SLOT_FILE:-\$REPO_ROOT\/etc\/servers\/work-server\/\.docker\/runtime\/active-slot\}"/,
);
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
assert.match(
workServerScript,
/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, /wait_for_container_runtime_ready "\$TARGET_CONTAINER" "\$TARGET_SLOT"/);
assert.match(workServerScript, /wait_for_proxy_slot_health "\$TARGET_SLOT"/);
assert.match(workServerScript, /Promise.all\(\[fetch\(process.argv\[1\]\), fetch\(process.argv\[2\]\)\]\)/);
assert.match(workServerScript, /payload\?\.slot !== expectedSlot/);
assert.match(workServerScript, /runtime readiness check failed/);
assert.match(workServerScript, /STABLE_SUCCESS_COUNT=\$\(\(STABLE_SUCCESS_COUNT \+ 1\)\)/);
assert.match(workServerScript, /work-server zero-downtime switch completed/);
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
});
@@ -116,6 +144,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 {
@@ -301,6 +345,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
await mkdir(path.join(tempRoot, 'src'), { recursive: true });
await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true });
await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8');
await writeFile(path.join(tempRoot, 'src', 'main.test.ts'), 'export const testOnly = true;\n', 'utf8');
await writeFile(path.join(tempRoot, 'index.html'), '<!doctype html>\n', 'utf8');
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8');
await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8');
@@ -309,6 +354,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8');
await Promise.all([
fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'src', 'main.test.ts'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate),
@@ -323,6 +369,8 @@ test('listServerCommands ignores public codex chat resources when checking app s
const resourceDate = new Date('2026-04-28T00:00:00.000Z');
await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate);
const excludedTestDate = new Date('2026-05-01T00:00:00.000Z');
await fs.promises.utimes(path.join(tempRoot, 'src', 'main.test.ts'), excludedTestDate, excludedTestDate);
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
@@ -333,6 +381,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
assert.ok(testCommand);
assert.equal(testCommand.buildRequired, false);
assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
assert.notEqual(testCommand.latestSourceChangePath, 'src/main.test.ts');
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
@@ -349,3 +398,193 @@ test('listServerCommands ignores public codex chat resources when checking app s
await rm(tempRoot, { recursive: true, force: true });
}
});
test('listServerCommands ignores work-server test-only source changes when computing buildRequired', 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-source-scan-'));
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 writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), 'export const testOnly = 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 mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
await writeFile(
path.join(workServerRoot, 'dist', 'build-info.json'),
JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-25T07:58:59.046Z', builtAt: '2026-05-25T07:58:59.046Z' }),
'utf8',
);
const staleDate = new Date('2026-05-20T00:00:00.000Z');
const excludedTestDate = new Date('2026-06-01T00:00:00.000Z');
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), staleDate, staleDate);
await fs.promises.utimes(path.join(workServerRoot, 'package.json'), staleDate, staleDate);
await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), staleDate, staleDate);
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), excludedTestDate, excludedTestDate);
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.notEqual(workServerCommand.latestSourceChangePath, 'src/services/service.test.ts');
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
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 });
}
});
test('readWorkServerDeploymentState transitions stale running deployment to failed when the lock is gone', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-'));
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
try {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8');
const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime');
await mkdir(runtimeDir, { recursive: true });
await writeFile(
path.join(runtimeDir, 'deployment-state.json'),
JSON.stringify({
status: 'running',
phase: 'drain-previous-slot',
summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.',
startedAt: '2026-05-28T01:45:37.000Z',
updatedAt: '2026-05-28T01:54:20.000Z',
activeSlot: 'blue',
targetSlot: 'blue',
previousSlot: 'green',
steps: [
{ key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' },
{ key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' },
{ key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' },
{ key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' },
{ key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null },
{ key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null },
],
}) + '\n',
'utf8',
);
const snapshot = await readWorkServerDeploymentState();
assert.ok(snapshot);
assert.equal(snapshot.status, 'failed');
assert.equal(snapshot.phase, 'failed');
assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'failed');
assert.match(String(snapshot.lastError ?? ''), /lock 파일이 없어서/);
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
await rm(tempRoot, { recursive: true, force: true });
}
});
test('readWorkServerDeploymentState keeps running deployment when the lock is active', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-lock-'));
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
try {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8');
const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime');
await mkdir(runtimeDir, { recursive: true });
await writeFile(
path.join(runtimeDir, 'deployment-state.json'),
JSON.stringify({
status: 'running',
phase: 'drain-previous-slot',
summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.',
startedAt: '2026-05-28T01:45:37.000Z',
updatedAt: '2026-05-28T01:54:20.000Z',
steps: [
{ key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' },
{ key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' },
{ key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' },
{ key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' },
{ key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null },
{ key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null },
],
}) + '\n',
'utf8',
);
await writeFile(
path.join(runtimeDir, 'restart-in-progress.json'),
JSON.stringify({ startedAt: new Date().toISOString(), key: 'work-server', pid: 1234 }) + '\n',
'utf8',
);
const snapshot = await readWorkServerDeploymentState();
assert.ok(snapshot);
assert.equal(snapshot.status, 'running');
assert.equal(snapshot.phase, 'drain-previous-slot');
assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'running');
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -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, writeFile } 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,
@@ -32,6 +37,7 @@ type ServerDefinition = {
commandWorkingDirectory: string;
commandEnvironment: Record<string, string>;
restartStrategy: 'wait' | 'deferred';
deferredResponseMode?: 'wait-for-result' | 'accept-immediately';
};
export type ServerCommandSnapshot = {
@@ -65,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<string, string>;
timeoutMs?: number;
};
type ExecFileFailure = Error & {
@@ -116,6 +131,58 @@ type BuildInspectionResult = {
updateSummary: string | null;
};
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;
@@ -135,11 +202,55 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
'/tmp/ai-code-test-app-dist/assets',
] as const;
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;
let latestBuiltAt: string | null = null;
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
const buildStampCandidates = [
path.join(mainProjectRoot, APP_BUILD_STAMP_RELATIVE_PATH),
path.join(normalizePath(env.SERVER_COMMAND_PROJECT_ROOT), APP_BUILD_STAMP_RELATIVE_PATH),
].filter((value, index, array) => array.indexOf(value) === index);
for (const targetPath of buildStampCandidates) {
const candidate = allowLocal ? await readLocalBuildTimestamp(targetPath) : null;
if (candidate && (!latestBuiltAt || candidate > latestBuiltAt)) {
latestBuiltAt = candidate;
}
}
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
const candidates = [
@@ -195,7 +306,13 @@ type SourceChangeInfo = {
function isExcludedAppSourcePath(rootPath: string, targetPath: string) {
const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/');
return APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix));
if (APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix))) {
return true;
}
const baseName = path.basename(relativePath);
return APP_SOURCE_EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(baseName));
}
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
@@ -462,6 +579,41 @@ export function resolveDockerSocketPath(source: NodeJS.ProcessEnv | Record<strin
return '/var/run/docker.sock';
}
function getWorkServerActiveSlotFileCandidates() {
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
return [
env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim() || null,
path.join(mainProjectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
path.join(projectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
path.join(projectRoot, '.docker', 'runtime', 'active-slot'),
].filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index);
}
async function readWorkServerActiveSlot(): Promise<WorkServerSlot> {
for (const candidate of getWorkServerActiveSlotFileCandidates()) {
try {
const value = (await readFile(candidate, 'utf8')).trim();
if (value === 'blue' || value === 'green') {
return value;
}
} catch {
continue;
}
}
return 'blue';
}
function resolveWorkServerContainerName(slot: WorkServerSlot) {
return slot === 'green' ? 'work-server-green' : 'work-server-blue';
}
function appendComposeDetails(detailParts: Array<string | null | undefined>) {
return trimPreview(detailParts.filter(Boolean).join(' '));
}
function shouldRetryWithDockerSocket(error: unknown) {
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
@@ -575,21 +727,23 @@ async function restartViaDockerSocket(definition: ServerDefinition) {
}
function getServerDefinitions(): ServerDefinition[] {
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
const projectRoot = normalizePath(useLocalMainMode ? mainProjectRoot : env.SERVER_COMMAND_PROJECT_ROOT);
const scriptRootCandidates = [mainProjectRoot, projectRoot, '/workspace/main-project'];
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_URL),
checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_CHECK_URL || env.SERVER_COMMAND_TEST_URL),
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
serviceName: env.SERVER_COMMAND_TEST_SERVICE,
containerName: 'ai-code-app-app-1',
commandScript: resolveCommandScriptPath('restart-test.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
commandScript: resolveCommandScriptPath('restart-test.sh', scriptRootCandidates),
commandWorkingDirectory: mainProjectRoot,
commandEnvironment: {
MAIN_PROJECT_ROOT: mainProjectRoot,
@@ -607,11 +761,11 @@ function getServerDefinitions(): ServerDefinition[] {
summary: 'release 브랜치를 서비스하는 릴리즈 앱 컨테이너',
environment: 'release',
publicUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL),
checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL),
checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_CHECK_URL || env.SERVER_COMMAND_REL_URL),
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
serviceName: env.SERVER_COMMAND_REL_SERVICE,
containerName: 'ai-code-app-release',
commandScript: resolveCommandScriptPath('restart-rel.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
commandScript: resolveCommandScriptPath('restart-rel.sh', scriptRootCandidates),
commandWorkingDirectory: mainProjectRoot,
commandEnvironment: {
MAIN_PROJECT_ROOT: mainProjectRoot,
@@ -626,11 +780,11 @@ function getServerDefinitions(): ServerDefinition[] {
summary: '프로덕션 앱 컨테이너',
environment: 'production',
publicUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_CHECK_URL || env.SERVER_COMMAND_PROD_URL),
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
serviceName: env.SERVER_COMMAND_PROD_SERVICE,
containerName: 'ai-code-app-prod',
commandScript: resolveCommandScriptPath('restart-prod.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
commandScript: resolveCommandScriptPath('restart-prod.sh', scriptRootCandidates),
commandWorkingDirectory: mainProjectRoot,
commandEnvironment: {
MAIN_PROJECT_ROOT: mainProjectRoot,
@@ -639,6 +793,7 @@ function getServerDefinitions(): ServerDefinition[] {
SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-prod',
},
restartStrategy: 'deferred',
deferredResponseMode: 'wait-for-result',
},
{
key: 'work-server',
@@ -650,12 +805,13 @@ function getServerDefinitions(): ServerDefinition[] {
composeFile: path.join(projectRoot, 'etc', 'servers', 'work-server', 'docker-compose.yml'),
serviceName: env.SERVER_COMMAND_WORK_SERVER_SERVICE,
containerName: 'work-server',
commandScript: resolveCommandScriptPath('restart-work-server.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
commandWorkingDirectory: projectRoot,
commandScript: resolveCommandScriptPath('restart-work-server.sh', scriptRootCandidates),
commandWorkingDirectory: mainProjectRoot,
commandEnvironment: {
REPO_ROOT: projectRoot,
REPO_ROOT: mainProjectRoot,
},
restartStrategy: 'deferred',
deferredResponseMode: 'accept-immediately',
},
{
key: 'command-runner',
@@ -667,12 +823,13 @@ function getServerDefinitions(): ServerDefinition[] {
composeFile: path.join(projectRoot, 'scripts', 'run-server-command-runner.mjs'),
serviceName: 'server-command-runner',
containerName: 'server-command-runner',
commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
commandWorkingDirectory: projectRoot,
commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', scriptRootCandidates),
commandWorkingDirectory: mainProjectRoot,
commandEnvironment: {
PROJECT_ROOT: projectRoot,
PROJECT_ROOT: mainProjectRoot,
},
restartStrategy: 'deferred',
deferredResponseMode: 'wait-for-result',
},
];
}
@@ -687,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() ?? '';
@@ -708,6 +884,296 @@ 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<WorkServerDeploymentStepKey, WorkServerDeploymentStepSnapshot>();
value.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const candidate = item as Record<string, unknown>;
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),
};
}
function isWorkServerDeploymentCompleted(snapshot: WorkServerDeploymentSnapshot) {
return snapshot.steps.every((step) => step.status === 'completed');
}
function reconcileStaleWorkServerDeploymentState(snapshot: WorkServerDeploymentSnapshot): WorkServerDeploymentSnapshot {
if (snapshot.status !== 'running') {
return snapshot;
}
const now = new Date().toISOString();
if (isWorkServerDeploymentCompleted(snapshot)) {
return {
...snapshot,
status: 'completed',
phase: 'completed',
summary: snapshot.summary || 'WORK-SERVER 무중단 배포를 완료했습니다.',
updatedAt: now,
completedAt: snapshot.completedAt ?? now,
lastError: null,
};
}
return {
...snapshot,
status: 'failed',
phase: 'failed',
summary: 'WORK-SERVER 배포 상태가 중간 단계에서 종료되었습니다.',
updatedAt: now,
completedAt: snapshot.completedAt ?? now,
lastError:
snapshot.lastError
|| '배포 lock 파일이 없어서 진행 중 상태를 종료된 상태로 보정했습니다.',
steps: snapshot.steps.map((step) => (
step.status === 'running'
? {
...step,
status: 'failed',
updatedAt: now,
}
: step
)),
};
}
async function hasActiveWorkServerRestartLock() {
const lockPath = getWorkServerRestartLockPath();
try {
const [raw, lockStat] = await Promise.all([
readFile(lockPath, 'utf8').catch(() => ''),
stat(lockPath),
]);
const parsed = raw ? (JSON.parse(raw) as Partial<WorkServerRestartLockPayload>) : null;
const freshnessSource =
normalizeDateTimeValue(typeof parsed?.startedAt === 'string' ? parsed.startedAt : null)
?? normalizeDateTimeValue(lockStat.mtime.toISOString());
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) {
await rm(lockPath, { force: true }).catch(() => undefined);
return false;
}
return true;
} catch {
return false;
}
}
async function persistWorkServerDeploymentState(snapshot: WorkServerDeploymentSnapshot) {
const targetPath = getWorkServerDeploymentStatePath();
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, JSON.stringify(snapshot) + '\n', 'utf8');
}
export async function readWorkServerDeploymentState(): Promise<WorkServerDeploymentSnapshot | null> {
try {
const raw = await readFile(getWorkServerDeploymentStatePath(), 'utf8');
const snapshot = normalizeWorkServerDeploymentSnapshot(JSON.parse(raw));
if (snapshot.status !== 'running' || await hasActiveWorkServerRestartLock()) {
return snapshot;
}
const reconciled = reconcileStaleWorkServerDeploymentState(snapshot);
if (JSON.stringify(reconciled) !== JSON.stringify(snapshot)) {
await persistWorkServerDeploymentState(reconciled).catch(() => undefined);
}
return reconciled;
} 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<WorkServerRestartLockPayload>;
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}`;
}
@@ -753,6 +1219,7 @@ function buildAcceptedRestartSnapshot(definition: ServerDefinition): ServerComma
commandScript: definition.commandScript,
commandWorkingDirectory: definition.commandWorkingDirectory,
errorMessage: null,
deployment: definition.key === 'work-server' ? buildEmptyWorkServerDeploymentSnapshot() : null,
};
}
@@ -905,6 +1372,7 @@ async function waitForDeferredRestartResult(
async function restartServerCommandDeferred(definition: ServerDefinition): Promise<ServerCommandRestartResult> {
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`,
@@ -912,7 +1380,8 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
`printf '%s' \"$status\" >${JSON.stringify(statusPath)}`,
].join('; ');
await new Promise<void>((resolve, reject) => {
try {
await new Promise<void>((resolve, reject) => {
const child = spawn('sh', ['-c', shellCommand], {
cwd: definition.commandWorkingDirectory,
detached: true,
@@ -920,15 +1389,32 @@ 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,
};
}
const commandOutput = await waitForDeferredRestartResult(definition, statusPath, logPath);
@@ -936,6 +1422,7 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
server: buildAcceptedRestartSnapshot(definition),
commandOutput: commandOutput ?? `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
restartState: 'accepted',
deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null,
};
}
@@ -1102,11 +1589,16 @@ async function inspectComposeStatus(definition: ServerDefinition) {
}
}
async function inspectContainerRuntime(definition: ServerDefinition): Promise<RuntimeInspectionResult> {
async function inspectContainerRuntime(
definition: ServerDefinition,
containerNameOverride?: string,
): Promise<RuntimeInspectionResult> {
const containerName = containerNameOverride ?? definition.containerName;
try {
const { stdout } = await execFileAsync(
'docker',
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', definition.containerName],
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', containerName],
{
cwd: definition.commandWorkingDirectory,
timeout: 8000,
@@ -1123,7 +1615,7 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
} catch (error) {
if (shouldRetryWithDockerSocket(error)) {
try {
const inspected = await inspectContainerViaSocket(definition.containerName);
const inspected = await inspectContainerViaSocket(containerName);
return {
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
composeStatus: inspected.State?.Status?.trim() || null,
@@ -1263,10 +1755,27 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
}
if (definition.key === 'work-server') {
const primarySlot = await readWorkServerActiveSlot();
const candidateSlots: WorkServerSlot[] = primarySlot === 'green' ? ['green', 'blue'] : ['blue', 'green'];
for (const slot of candidateSlots) {
const runtimeInfo = await inspectContainerRuntime(definition, resolveWorkServerContainerName(slot));
if (runtimeInfo.startedAt) {
return {
...runtimeInfo,
composeDetails: appendComposeDetails([`slot:${slot}`, runtimeInfo.composeDetails]),
};
}
}
const runtimeInfo = await inspectContainerRuntime(definition);
if (runtimeInfo.startedAt) {
return runtimeInfo;
return {
...runtimeInfo,
composeDetails: appendComposeDetails(['slot:proxy', runtimeInfo.composeDetails]),
};
}
return inspectCurrentProcessRuntime();
@@ -1369,7 +1878,12 @@ async function inspectBuild(definition: ServerDefinition): Promise<BuildInspecti
? !latestBuild?.builtAt || latestSourceChangedAt > 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,
@@ -1425,6 +1939,7 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
const runtimeInfo = await inspectRuntime(definition);
const buildInfo = await inspectBuild(definition);
const deployment = definition.key === 'work-server' ? await readWorkServerDeploymentState() : null;
const fallbackAttempt = selectedAttempt.url !== definition.checkUrl ? `fallback health check succeeded via ${selectedAttempt.url}` : null;
const collectedErrors = attempts
.filter((attempt) => attempt.errorMessage)
@@ -1463,12 +1978,26 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
updateAvailable: buildInfo.updateAvailable,
updateSummary: buildInfo.updateSummary,
responseTimeMs: Date.now() - startedAt,
composeStatus: runtimeInfo.composeStatus,
composeDetails: runtimeInfo.composeDetails,
composeStatus:
definition.key === 'work-server' && deployment?.status === 'running'
? 'deploying'
: runtimeInfo.composeStatus,
composeDetails:
definition.key === 'work-server' && deployment
? appendComposeDetails([
runtimeInfo.composeDetails,
deployment.status !== 'idle'
? `deploy:${deployment.status}${deployment.targetSlot ? `:${deployment.targetSlot}` : ''}`
: null,
])
: runtimeInfo.composeDetails,
lastCommand: buildRestartCommandPreview(definition),
commandScript: definition.commandScript,
commandWorkingDirectory: definition.commandWorkingDirectory,
errorMessage,
errorMessage: deployment?.status === 'failed' && deployment.lastError
? trimPreview([deployment.lastError, errorMessage].filter(Boolean).join(' | '), 400)
: errorMessage,
deployment,
};
}
@@ -1497,15 +2026,7 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
}
try {
const commandResult = await execFileAsync('sh', [definition.commandScript], {
cwd: definition.commandWorkingDirectory,
timeout: 30000,
maxBuffer: 1024 * 1024,
env: {
...process.env,
...definition.commandEnvironment,
},
});
const commandResult = await executeServerCommandScript(definition);
stdout = commandResult.stdout;
stderr = commandResult.stderr;
} catch (error) {
@@ -1532,5 +2053,23 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
server,
commandOutput: trimPreview([stdout, stderr].filter(Boolean).join('\n'), 400),
restartState: 'completed',
deployment: server.deployment,
};
}
export async function deployWorkServerCommand(): Promise<ServerCommandRestartResult> {
return restartServerCommand('work-server');
}
export async function deployTestServerCommand(): Promise<ServerCommandRestartResult> {
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()),
};
}

View File

@@ -56,6 +56,22 @@ test('hasReservedRestartVerification keeps test restart pending until a new star
),
true,
);
assert.equal(
hasReservedRestartVerification(
'test',
{
availability: 'online',
startedAt: '2026-05-06T00:00:03.000Z',
runningBuiltAt: '2026-05-06T00:00:05.000Z',
runningVersion: null,
buildRequired: true,
updateAvailable: false,
},
reservationStartedAt,
),
true,
);
});
test('hasReservedRestartVerification keeps work-server restart pending until new runtime and build info are ready', () => {
@@ -104,7 +120,23 @@ test('hasReservedRestartVerification keeps work-server restart pending until new
},
reservationStartedAt,
),
false,
true,
);
assert.equal(
hasReservedRestartVerification(
'work-server',
{
availability: 'online',
startedAt: '2026-05-06T00:00:03.000Z',
runningBuiltAt: '2026-05-06T00:00:04.000Z',
runningVersion: '0.1.0@2026-05-06T00:00:04.000Z',
buildRequired: true,
updateAvailable: true,
},
reservationStartedAt,
),
true,
);
});

View File

@@ -16,6 +16,7 @@ import {
} from './server-command-service.js';
import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js';
import { syncMainProjectBranchForReservedRestart } from './git-service.js';
import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js';
const SERVER_RESTART_RESERVATION_TABLE = 'server_restart_reservations';
const SERVER_RESTART_RESERVATION_ROW_ID = 1;
@@ -128,6 +129,34 @@ function normalizeExecutionPhase(value: unknown): RestartReservationExecutionPha
: 'idle';
}
function normalizeReservationTarget(value: unknown): RestartReservationTarget {
return value === 'test' || value === 'work-server' ? value : 'all';
}
function getReservationTargetKeys(target: RestartReservationTarget): Array<'test' | 'work-server'> {
if (target === 'test') {
return ['test'];
}
if (target === 'work-server') {
return ['work-server'];
}
return ['test', 'work-server'];
}
function getReservationTargetLabel(target: RestartReservationTarget) {
if (target === 'test') {
return 'TEST 서버';
}
if (target === 'work-server') {
return 'WORK 서버';
}
return 'TEST / WORK 서버';
}
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
return {
codexRunningCount: 0,
@@ -331,7 +360,7 @@ function mapReservationRow(
return {
enabled: Boolean(row?.enabled),
target: row?.target === 'all' ? 'all' : 'all',
target: normalizeReservationTarget(row?.target),
status: row?.status ?? 'idle',
requestedAt: row?.requested_at ?? null,
requestedByClientId: row?.requested_by_client_id ?? null,
@@ -549,7 +578,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;
@@ -868,6 +901,32 @@ function hasRestartStartedAfterReservation(
return serverStartedTime + RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime;
}
function hasRuntimeMarkerAfterReservation(
runtimeMarkerAt: string | null | undefined,
reservationStartedAt: string | null | undefined,
) {
const runtimeMarkerTime = Date.parse(runtimeMarkerAt ?? '');
const reservationStartedTime = Date.parse(reservationStartedAt ?? '');
if (!Number.isFinite(runtimeMarkerTime) || !Number.isFinite(reservationStartedTime)) {
return false;
}
return runtimeMarkerTime + RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime;
}
function getWorkServerRuntimeMarkerAt(
server: Pick<ServerCommandSnapshot, 'runningBuiltAt' | 'runningVersion'>,
) {
if (server.runningBuiltAt?.trim()) {
return server.runningBuiltAt;
}
const versionText = server.runningVersion?.trim() ?? '';
const versionMarker = versionText.includes('@') ? versionText.split('@').at(-1)?.trim() ?? '' : '';
return versionMarker || null;
}
export function hasReservedRestartVerification(
key: 'test' | 'work-server',
server: Pick<
@@ -881,23 +940,29 @@ export function hasReservedRestartVerification(
}
if (key === 'test') {
return Boolean(server.runningBuiltAt) && !server.buildRequired;
return hasRuntimeMarkerAfterReservation(server.runningBuiltAt, reservationStartedAt);
}
return Boolean(server.runningVersion ?? server.runningBuiltAt) && !server.buildRequired && !server.updateAvailable;
return hasRuntimeMarkerAfterReservation(getWorkServerRuntimeMarkerAt(server), reservationStartedAt);
}
async function finalizeReservedRestart(row: RestartReservationRow) {
const statuses = await listServerCommands();
const testServer = statuses.find((item) => item.key === 'test') ?? null;
const workServer = statuses.find((item) => item.key === 'work-server') ?? null;
const testVerified = hasReservedRestartVerification('test', testServer, row.started_at);
const workVerified = hasReservedRestartVerification('work-server', workServer, row.started_at);
const target = normalizeReservationTarget(row.target);
const targetKeys = getReservationTargetKeys(target);
const verificationResults = {
test: targetKeys.includes('test') ? hasReservedRestartVerification('test', testServer, row.started_at) : true,
'work-server': targetKeys.includes('work-server')
? hasReservedRestartVerification('work-server', workServer, row.started_at)
: true,
};
if (!testVerified || !workVerified) {
if (!verificationResults.test || !verificationResults['work-server']) {
const waitingTargets = [
!testVerified ? 'TEST 서버' : null,
!workVerified ? 'WORK 서버' : null,
!verificationResults.test ? 'TEST 서버' : null,
!verificationResults['work-server'] ? 'WORK 서버' : null,
].filter((value): value is string => Boolean(value));
await updateReservationRow({
@@ -1055,6 +1120,9 @@ export async function requestImmediateRestartRecovery(
}
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
const target = normalizeReservationTarget(row.target);
const targetLabel = getReservationTargetLabel(target);
const targetKeys = getReservationTargetKeys(target);
const activeClients = await listActiveClients();
await updateReservationRow({
enabled: true,
@@ -1062,7 +1130,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
started_at: row.started_at ?? db.fn.now(),
last_checked_at: db.fn.now(),
execution_phase: 'commit-main-worktree',
waiting_reason: 'main 작업트리 커밋 단계를 확인한 뒤 예약된 재기동을 이어갑니다.',
waiting_reason: `${targetLabel} 무중단 재기동을 위해 main 작업트리 상태를 확인합니다.`,
active_client_count: activeClients.length,
last_error: null,
});
@@ -1070,12 +1138,12 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
if (activeClients.length > 0) {
await createNotificationMessage({
title: '예약된 재기동 시작',
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 TEST / WORK 서버 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 ${targetLabel} 무중단 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
category: 'system',
source: SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE,
priority: 'high',
metadata: {
previewText: `예약된 재기동 시작 · 활성 클라이언트 ${activeClients.length}`,
previewText: `${targetLabel} 재기동 시작 · 활성 클라이언트 ${activeClients.length}`,
linkUrl: '/?topMenu=plans',
linkLabel: '작업 화면 열기',
},
@@ -1102,31 +1170,40 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'restart-test',
execution_phase: targetKeys.includes('test') ? 'restart-test' : 'restart-work-server',
waiting_reason: syncResult.committed
? 'main 변경을 정리한 뒤 TEST 서버 재기동을 시작합니다.'
: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
? `main 변경을 정리한 뒤 ${targetLabel} 재기동을 시작합니다.`
: `main 작업트리 상태를 확인한 뒤 ${targetLabel} 재기동을 시작합니다.`,
last_checked_at: db.fn.now(),
});
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
if (targetKeys.includes('test')) {
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
}
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'restart-work-server',
waiting_reason: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
last_checked_at: db.fn.now(),
});
if (targetKeys.includes('test') && targetKeys.includes('work-server')) {
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
}
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
if (targetKeys.includes('work-server')) {
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'restart-work-server',
waiting_reason: target === 'work-server'
? 'WORK 서버 무중단 재기동 후 정상 기동을 확인하는 중입니다.'
: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
last_checked_at: db.fn.now(),
});
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
}
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'verify-runtime',
waiting_reason: 'TEST / WORK 서버 새 런타임과 정상 기동을 확인하는 중입니다.',
waiting_reason: `${targetLabel} 새 런타임과 정상 기동을 확인하는 중입니다.`,
last_checked_at: db.fn.now(),
});
}
@@ -1146,19 +1223,23 @@ export async function getServerRestartReservation() {
}
export async function scheduleServerRestartReservation(options?: {
target?: RestartReservationTarget | null;
clientId?: string | null;
appOrigin?: string | null;
autoExecuteDelaySeconds?: number | null;
}) {
const autoExecuteDelaySeconds = resolveAutoExecuteDelaySeconds(options?.autoExecuteDelaySeconds);
const target = normalizeReservationTarget(options?.target);
const row = await updateReservationRow({
enabled: true,
target: 'all',
target,
status: 'waiting',
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,
@@ -1215,7 +1296,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
status: 'executing',
started_at: db.fn.now(),
last_checked_at: db.fn.now(),
waiting_reason: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
waiting_reason: `${getReservationTargetLabel(normalizeReservationTarget(row.target))} 재기동 준비를 시작합니다.`,
last_error: null,
auto_execute_at: null,
execution_phase: 'commit-main-worktree',
@@ -1273,6 +1354,10 @@ export class ServerRestartReservationWorker {
this.running = true;
try {
if (!(await isCurrentWorkServerSlotActive())) {
return;
}
const row = await readReservationRow();
if (!row?.enabled) {
@@ -1293,7 +1378,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);
@@ -1301,6 +1386,7 @@ export class ServerRestartReservationWorker {
}
const autoExecuteDelaySeconds = await resolveReservationAutoExecuteDelaySeconds(row);
const targetLabel = getReservationTargetLabel(normalizeReservationTarget(row.target));
const autoExecuteAt = buildAutoExecuteAt(
row.status === 'ready' && row.auto_execute_at ? row.auto_execute_at : new Date().toISOString(),
autoExecuteDelaySeconds,
@@ -1310,7 +1396,7 @@ export class ServerRestartReservationWorker {
status: waitingReason ? 'waiting' : 'ready',
last_checked_at: db.fn.now(),
waiting_reason: waitingReason
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 TEST/WORK 서버 재기동을 자동 시작합니다.`,
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 ${targetLabel} 무중단 재기동을 자동 시작합니다.`,
workload_summary_json: workloadSummary,
auto_execute_at: waitingReason
? null

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isLegacyChatShareTokenRowNeedingMigration } from './shared-resource-token-service.js';
const completeSnapshot = {
id: 'token-setting',
name: 'Token Setting',
defaultExpiresInMinutes: 60,
maxTokensPer30Days: 0,
maxTokensPer7Days: 0,
maxTokensPer5Hours: 0,
oneTimeTokenLimit: 0,
allowedAppIds: [],
};
const completeContext = {
kind: 'request-bundle',
sessionId: 'session-1',
requestId: 'request-1',
};
test('isLegacyChatShareTokenRowNeedingMigration flags rows with legacy token_setting_id', () => {
assert.equal(
isLegacyChatShareTokenRowNeedingMigration({
token_setting_id: 'legacy-setting',
token_setting_snapshot_json: completeSnapshot,
resource_context_json: completeContext,
allowed_app_ids_json: '[]',
}),
true,
);
});
test('isLegacyChatShareTokenRowNeedingMigration flags rows with missing resource context', () => {
assert.equal(
isLegacyChatShareTokenRowNeedingMigration({
token_setting_id: null,
token_setting_snapshot_json: completeSnapshot,
resource_context_json: null,
allowed_app_ids_json: '[]',
}),
true,
);
});
test('isLegacyChatShareTokenRowNeedingMigration keeps valid current rows even when allowed apps are empty', () => {
assert.equal(
isLegacyChatShareTokenRowNeedingMigration({
token_setting_id: null,
token_setting_snapshot_json: completeSnapshot,
resource_context_json: completeContext,
allowed_app_ids_json: '[]',
}),
false,
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,573 @@
import { spawn } from 'node:child_process';
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { resolveMainProjectRoot } from './main-project-root-service.js';
const TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS = 20 * 60 * 1000;
const TEST_SERVER_DEPLOYMENT_LOG_LIMIT = 4000;
const TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS = 15_000;
export type TestServerDeploymentStepKey = 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server';
export type TestServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
export type TestServerDeploymentStepSnapshot = {
key: TestServerDeploymentStepKey;
status: TestServerDeploymentStepStatus;
detail: string | null;
updatedAt: string | null;
};
export type TestServerDeploymentPhase =
| 'idle'
| 'commit-main-worktree'
| 'push-origin-main'
| 'build-test-app'
| 'deploy-test-server'
| 'completed'
| 'failed';
export type TestServerDeploymentSnapshot = {
status: 'idle' | 'running' | 'completed' | 'failed';
phase: TestServerDeploymentPhase;
summary: string | null;
startedAt: string | null;
updatedAt: string | null;
completedAt: string | null;
lastError: string | null;
logExcerpt: string | null;
steps: TestServerDeploymentStepSnapshot[];
};
type TestServerDeploymentStateFilePayload = {
status?: unknown;
phase?: unknown;
summary?: unknown;
startedAt?: unknown;
updatedAt?: unknown;
completedAt?: unknown;
lastError?: unknown;
logExcerpt?: unknown;
steps?: unknown;
};
type RestartLockPayload = {
startedAt: string;
key: 'test';
pid: number;
};
const TEST_SERVER_DEPLOYMENT_STEP_KEYS: TestServerDeploymentStepKey[] = [
'commit-main-worktree',
'push-origin-main',
'build-test-app',
'deploy-test-server',
];
function normalizeDateTimeValue(value: string | null | undefined) {
const normalized = value?.trim();
if (!normalized) {
return null;
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
function trimPreview(value: string | null | undefined, maxLength = 220) {
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
if (!normalized) {
return null;
}
return normalized.length > 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<TestServerDeploymentStepKey, TestServerDeploymentStepSnapshot>();
value.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const candidate = item as Record<string, unknown>;
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<TestServerDeploymentSnapshot | null> {
try {
const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8');
const snapshot = normalizeTestServerDeploymentSnapshot(JSON.parse(raw));
return await resolveStaleRunningTestDeployment(snapshot);
} 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);
}
function buildStaleTestServerDeploymentFailure(snapshot: TestServerDeploymentSnapshot) {
const stalledAt = snapshot.updatedAt ?? snapshot.startedAt;
const stalledLabel = stalledAt ? `마지막 상태 갱신 ${stalledAt}` : '상태 갱신 시각 확인 불가';
return trimPreview(`TEST 배포 상태가 오래 갱신되지 않았고 잠금 파일도 없어 중단된 배포로 처리했습니다. ${stalledLabel}`, 500)
?? 'TEST 배포 상태가 오래 갱신되지 않아 중단된 배포로 처리했습니다.';
}
async function finalizeStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) {
const failureMessage = buildStaleTestServerDeploymentFailure(snapshot);
const now = new Date().toISOString();
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key;
snapshot.status = 'failed';
snapshot.phase = 'failed';
snapshot.summary = buildTestServerDeploymentSummary('failed');
snapshot.completedAt = now;
snapshot.updatedAt = now;
snapshot.lastError = failureMessage;
if (activeStep) {
updateTestServerDeploymentStep(snapshot, activeStep, 'failed', failureMessage);
}
await writeTestServerDeploymentState(snapshot);
return snapshot;
}
async function resolveStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) {
if (snapshot.status !== 'running') {
return snapshot;
}
const freshnessSource = snapshot.updatedAt ?? snapshot.startedAt;
if (!freshnessSource) {
return snapshot;
}
const staleForMs = Date.now() - Date.parse(freshnessSource);
if (!Number.isFinite(staleForMs) || staleForMs < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
return snapshot;
}
const lockPath = getTestServerDeploymentLockPath();
const lockStat = await stat(lockPath).catch(() => null);
if (lockStat?.isFile()) {
const lockFreshnessSource = normalizeDateTimeValue(lockStat.mtime.toISOString() ?? null);
if (lockFreshnessSource && Date.now() - Date.parse(lockFreshnessSource) < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
return snapshot;
}
}
await rm(lockPath, { force: true }).catch(() => undefined);
return finalizeStaleRunningTestDeployment(snapshot);
}
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<RestartLockPayload>;
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<TestServerDeploymentSnapshot, 'logExcerpt'>,
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<void>,
) {
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<void>((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;
}

View File

@@ -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(),
}));
}

View File

@@ -0,0 +1,448 @@
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;
export type TokenSettingRecord = {
id: string;
name: string;
description: string;
defaultExpiresInMinutes: number;
maxExpiresInMinutes: number;
maxTokensPer30Days: number;
maxTokensPer7Days: number;
maxTokensPer5Hours: number;
oneTimeTokenLimit: number;
allowedAppIds: string[];
enabled: boolean;
updatedAt: string;
};
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeEnabled(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
return false;
}
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
return true;
}
}
return value !== false;
}
function normalizeSettingId(value: unknown) {
return normalizeText(value)
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]/g, '');
}
function normalizePositiveInteger(value: unknown, fallback: number, min: number, max: number) {
const resolved = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(resolved)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(resolved)));
}
function normalizeAllowedAppIds(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(
new Set(
value
.map((item) => normalizeText(item))
.filter(Boolean),
),
).sort((left, right) => left.localeCompare(right, 'en'));
}
function normalizeTokenSetting(record: Partial<TokenSettingRecord>): TokenSettingRecord | null {
const id = normalizeSettingId(record.id);
const name = normalizeText(record.name);
if (!id || !name) {
return null;
}
const defaultExpiresInMinutes = normalizePositiveInteger(record.defaultExpiresInMinutes, 60, 0, UNBOUNDED_NUMERIC_LIMIT);
const resolvedMaxExpiresInMinutes = normalizePositiveInteger(
record.maxExpiresInMinutes,
defaultExpiresInMinutes <= 0 ? 0 : 10_080,
0,
UNBOUNDED_NUMERIC_LIMIT,
);
const maxExpiresInMinutes =
defaultExpiresInMinutes <= 0 || resolvedMaxExpiresInMinutes <= 0
? 0
: Math.max(defaultExpiresInMinutes, resolvedMaxExpiresInMinutes);
const legacyMaxTotalTokens =
'maxTotalTokens' in record
? normalizePositiveInteger((record as { maxTotalTokens?: number }).maxTotalTokens, 100_000, 0, UNBOUNDED_NUMERIC_LIMIT)
: 100_000;
return {
id,
name,
description: normalizeText(record.description),
defaultExpiresInMinutes,
maxExpiresInMinutes,
maxTokensPer30Days: normalizePositiveInteger(record.maxTokensPer30Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
maxTokensPer7Days: normalizePositiveInteger(record.maxTokensPer7Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
maxTokensPer5Hours: normalizePositiveInteger(record.maxTokensPer5Hours, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
oneTimeTokenLimit: normalizePositiveInteger(record.oneTimeTokenLimit, 0, 0, UNBOUNDED_NUMERIC_LIMIT),
allowedAppIds: normalizeAllowedAppIds(record.allowedAppIds),
enabled: normalizeEnabled(record.enabled),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function compareUpdatedAt(left: TokenSettingRecord, right: TokenSettingRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
export function sanitizeTokenSettings(items: Partial<TokenSettingRecord>[] | null | undefined) {
const byId = new Map<string, TokenSettingRecord>();
for (const item of items ?? []) {
const normalized = normalizeTokenSetting(item);
if (!normalized) {
continue;
}
const current = byId.get(normalized.id);
if (!current || compareUpdatedAt(current, normalized) <= 0) {
byId.set(normalized.id, normalized);
}
}
return Array.from(byId.values()).sort((left, right) => {
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
if (nameCompare !== 0) {
return nameCompare;
}
return left.id.localeCompare(right.id, 'en');
});
}
async function ensureTokenSettingsTable() {
const hasTable = await db.schema.hasTable(TOKEN_SETTINGS_TABLE);
if (!hasTable) {
await db.schema.createTable(TOKEN_SETTINGS_TABLE, (table) => {
table.string('id').primary();
table.string('name').notNullable();
table.text('description').notNullable().defaultTo('');
table.integer('default_expires_in_minutes').notNullable().defaultTo(60);
table.integer('max_expires_in_minutes').notNullable().defaultTo(10_080);
table.bigInteger('max_total_tokens').notNullable().defaultTo(100_000);
table.bigInteger('max_tokens_per_30_days').notNullable().defaultTo(100_000);
table.bigInteger('max_tokens_per_7_days').notNullable().defaultTo(100_000);
table.bigInteger('max_tokens_per_5_hours').notNullable().defaultTo(100_000);
table.bigInteger('one_time_token_limit').notNullable().defaultTo(0);
table.text('allowed_app_ids_json').notNullable().defaultTo('[]');
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
await ensureTokenSettingActivityTable();
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['name', (table) => table.string('name').notNullable().defaultTo('')],
['description', (table) => table.text('description').notNullable().defaultTo('')],
['default_expires_in_minutes', (table) => table.integer('default_expires_in_minutes').notNullable().defaultTo(60)],
['max_expires_in_minutes', (table) => table.integer('max_expires_in_minutes').notNullable().defaultTo(10_080)],
['max_total_tokens', (table) => table.bigInteger('max_total_tokens').notNullable().defaultTo(100_000)],
['max_tokens_per_30_days', (table) => table.bigInteger('max_tokens_per_30_days').notNullable().defaultTo(100_000)],
['max_tokens_per_7_days', (table) => table.bigInteger('max_tokens_per_7_days').notNullable().defaultTo(100_000)],
['max_tokens_per_5_hours', (table) => table.bigInteger('max_tokens_per_5_hours').notNullable().defaultTo(100_000)],
['one_time_token_limit', (table) => table.bigInteger('one_time_token_limit').notNullable().defaultTo(0)],
['allowed_app_ids_json', (table) => table.text('allowed_app_ids_json').notNullable().defaultTo('[]')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(TOKEN_SETTINGS_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(TOKEN_SETTINGS_TABLE, (table) => {
createColumn(table);
});
}
}
await ensureTokenSettingActivityTable();
}
function parseAllowedAppIds(row: Record<string, unknown>) {
const rawValue = row.allowed_app_ids_json;
if (typeof rawValue !== 'string') {
return [];
}
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toTokenSettingRecord(row: Record<string, unknown>) {
return normalizeTokenSetting({
id: typeof row.id === 'string' ? row.id : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
description: typeof row.description === 'string' ? row.description : undefined,
defaultExpiresInMinutes:
typeof row.default_expires_in_minutes === 'number' || typeof row.default_expires_in_minutes === 'string'
? Number(row.default_expires_in_minutes)
: undefined,
maxExpiresInMinutes:
typeof row.max_expires_in_minutes === 'number' || typeof row.max_expires_in_minutes === 'string'
? Number(row.max_expires_in_minutes)
: undefined,
maxTokensPer30Days:
typeof row.max_tokens_per_30_days === 'number' || typeof row.max_tokens_per_30_days === 'string'
? Number(row.max_tokens_per_30_days)
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
? Number(row.max_total_tokens)
: undefined,
maxTokensPer7Days:
typeof row.max_tokens_per_7_days === 'number' || typeof row.max_tokens_per_7_days === 'string'
? Number(row.max_tokens_per_7_days)
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
? Number(row.max_total_tokens)
: undefined,
maxTokensPer5Hours:
typeof row.max_tokens_per_5_hours === 'number' || typeof row.max_tokens_per_5_hours === 'string'
? Number(row.max_tokens_per_5_hours)
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
? Number(row.max_total_tokens)
: undefined,
oneTimeTokenLimit:
typeof row.one_time_token_limit === 'number' || typeof row.one_time_token_limit === 'string'
? Number(row.one_time_token_limit)
: undefined,
allowedAppIds: parseAllowedAppIds(row),
enabled:
typeof row.enabled === 'boolean' || typeof row.enabled === 'number' || typeof row.enabled === 'string'
? normalizeEnabled(row.enabled)
: undefined,
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
});
}
async function readTokenSettingsFromTable() {
await ensureTokenSettingsTable();
const rows = await db(TOKEN_SETTINGS_TABLE)
.select(
'id',
'name',
'description',
'default_expires_in_minutes',
'max_expires_in_minutes',
'max_tokens_per_30_days',
'max_tokens_per_7_days',
'max_tokens_per_5_hours',
'one_time_token_limit',
'max_total_tokens',
'allowed_app_ids_json',
'enabled',
'updated_at',
)
.orderBy('name', 'asc');
return sanitizeTokenSettings(
rows
.map((row) => toTokenSettingRecord(row as Record<string, unknown>))
.filter((item): item is TokenSettingRecord => Boolean(item)),
);
}
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();
if (nextItems.length > 0) {
await trx(TOKEN_SETTINGS_TABLE).insert(
nextItems.map((item) => ({
id: item.id,
name: item.name,
description: item.description,
default_expires_in_minutes: item.defaultExpiresInMinutes,
max_expires_in_minutes: item.maxExpiresInMinutes,
max_total_tokens: item.maxTokensPer30Days,
max_tokens_per_30_days: item.maxTokensPer30Days,
max_tokens_per_7_days: item.maxTokensPer7Days,
max_tokens_per_5_hours: item.maxTokensPer5Hours,
one_time_token_limit: item.oneTimeTokenLimit,
allowed_app_ids_json: JSON.stringify(item.allowedAppIds),
enabled: item.enabled,
updated_at: item.updatedAt,
})),
);
}
});
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;
}
export async function getTokenSettingsConfig() {
return readTokenSettingsFromTable();
}
export async function upsertTokenSettingsConfig(
items: Partial<TokenSettingRecord>[] | null | undefined,
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
) {
return replaceTokenSettingsInTable(sanitizeTokenSettings(items), options);
}
export async function getTokenSettingById(id: string) {
const normalizedId = normalizeSettingId(id);
if (!normalizedId) {
return null;
}
await ensureTokenSettingsTable();
const row = await db(TOKEN_SETTINGS_TABLE)
.where({ id: normalizedId })
.first(
'id',
'name',
'description',
'default_expires_in_minutes',
'max_expires_in_minutes',
'max_tokens_per_30_days',
'max_tokens_per_7_days',
'max_tokens_per_5_hours',
'one_time_token_limit',
'max_total_tokens',
'allowed_app_ids_json',
'enabled',
'updated_at',
);
if (!row) {
return null;
}
return toTokenSettingRecord(row as Record<string, unknown>);
}

View File

@@ -19,6 +19,7 @@ export type WorkServerSourceChangeInfo = {
const MODULE_DIR_PATH = path.dirname(fileURLToPath(import.meta.url));
const WORK_SERVER_ROOT_PATH = path.resolve(MODULE_DIR_PATH, '..', '..');
const SOURCE_TARGET_PATH_NAMES = ['src', 'scripts', 'package.json', 'tsconfig.json'] as const;
const WORK_SERVER_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const;
function normalizeRootPath(value: string | null | undefined) {
const normalized = String(value ?? '').trim();
@@ -26,18 +27,17 @@ function normalizeRootPath(value: string | null | undefined) {
}
function resolveSourceTargetRoots() {
const roots = [WORK_SERVER_ROOT_PATH];
const mainProjectRoot = normalizeRootPath(resolveMainProjectRoot());
if (mainProjectRoot) {
const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server');
if (!roots.includes(mirroredWorkServerRoot)) {
roots.push(mirroredWorkServerRoot);
if (fs.existsSync(mirroredWorkServerRoot)) {
return [mirroredWorkServerRoot];
}
}
return roots;
return [WORK_SERVER_ROOT_PATH];
}
function resolveBuildInfoDirectoryPath(rootPath: string, configuredDistDir: string) {
@@ -138,8 +138,18 @@ export function getRuntimeWorkServerBuildInfo() {
return runtimeWorkServerBuildInfo;
}
function isExcludedWorkServerSourcePath(rootPath: string, targetPath: string) {
const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/');
const baseName = path.basename(relativePath);
return WORK_SERVER_SOURCE_EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(baseName));
}
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<WorkServerSourceChangeInfo | null> {
try {
if (isExcludedWorkServerSourcePath(rootPath, targetPath)) {
return null;
}
const stats = await fs.promises.stat(targetPath);
if (stats.isFile()) {

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,62 @@
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;
export class BaseballTicketBayWorker {
private readonly logger: FastifyBaseLogger;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(logger: FastifyBaseLogger) {
this.logger = logger;
}
start() {
if (this.timer) {
return;
}
this.timer = setInterval(() => {
void this.tick();
}, DEFAULT_INTERVAL_MS);
this.timer.unref?.();
this.logger.info({ intervalMs: DEFAULT_INTERVAL_MS }, 'Baseball Ticket Bay worker started');
}
async stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private async tick() {
if (this.running) {
return;
}
if (!(await isCurrentWorkServerSlotActive())) {
return;
}
this.running = true;
try {
const results = await processDueBaseballTicketBayAlerts(new Date());
const executed = results.length;
const failed = results.filter((item) => !item.ok).length;
if (executed > 0) {
this.logger.info({ executed, failed }, 'Baseball Ticket Bay batch processed');
}
} catch (error) {
this.logger.error({ error }, 'Baseball Ticket Bay worker tick failed');
} finally {
this.running = false;
}
}
}

View File

@@ -44,6 +44,7 @@ import {
} from '../services/git-service.js';
import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js';
import { registerDuePlanScheduledTasks } from '../services/plan-schedule-service.js';
import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js';
const STREAM_CAPTURE_LIMIT = 256 * 1024;
const FIRST_PROGRESS_NOTIFICATION_MS = 60_000;
@@ -857,6 +858,11 @@ export class PlanWorker {
this.running = true;
try {
if (!(await isCurrentWorkServerSlotActive())) {
this.logger.info({ workerId: this.workerId }, 'Plan worker skipped on inactive work-server slot');
return;
}
const env = getEnv();
const appConfig = await getAppConfigSnapshot();
const autoRefreshEnabled = appConfig.automation?.autoRefreshEnabled ?? true;

View File

@@ -12,9 +12,120 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
<title>AI Code App</title>
<script>
(() => {
const { pathname, search, hash, origin } = window.location;
const searchParams = new URLSearchParams(search);
const playAppInstallMetadata = {
'baseball-ticket-bay': { title: 'Baseball Ticket Bay', themeColor: '#1b3f91' },
photoprism: { title: 'PhotoPrism', themeColor: '#0f766e' },
'photo-puzzle': { title: 'Photo Puzzle', themeColor: '#d97706' },
'the-quest': { title: 'The Quest', themeColor: '#7c3aed' },
tetris: { title: 'Tetris', themeColor: '#0f172a' },
'e-reader': { title: 'E-Reader', themeColor: '#165dff' },
};
let installMetadata = null;
if (pathname === '/play/apps') {
const appId = searchParams.get('app')?.trim() ?? '';
const appMetadata = playAppInstallMetadata[appId];
if (appMetadata) {
installMetadata = {
title: appMetadata.title,
shortName: appMetadata.title,
description: `${appMetadata.title} 앱을 홈 화면에서 바로 엽니다.`,
themeColor: appMetadata.themeColor,
scope: pathname,
};
}
} else if (pathname === '/plans/shared-resource') {
installMetadata = {
title: '공유 리소스 관리',
shortName: '공유 리소스',
description: '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.',
themeColor: '#0f766e',
scope: pathname,
};
} else if (pathname.startsWith('/chat/share/') || pathname.startsWith('/shares/')) {
installMetadata = {
title: '리소스 공유 채팅방',
shortName: '공유채팅',
description: '리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.',
themeColor: '#165dff',
scope: pathname,
};
}
if (!installMetadata) {
return;
}
const startUrl = new URL(`${pathname}${search}${hash}`, origin).toString();
const scopeUrl = new URL(installMetadata.scope, origin).toString();
const manifest = {
id: startUrl,
name: installMetadata.title,
short_name: installMetadata.shortName,
description: installMetadata.description,
theme_color: installMetadata.themeColor,
background_color: '#eff5ff',
display: 'standalone',
lang: 'ko',
scope: scopeUrl,
start_url: startUrl,
icons: [
{
src: new URL('/pwa-192x192.svg', origin).toString(),
sizes: '192x192',
type: 'image/svg+xml',
},
{
src: new URL('/pwa-512x512.svg', origin).toString(),
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
};
const manifestHref = URL.createObjectURL(
new Blob([JSON.stringify(manifest, null, 2)], {
type: 'application/manifest+json',
}),
);
let manifestLink = document.querySelector('link[rel="manifest"]');
if (!manifestLink) {
manifestLink = document.createElement('link');
manifestLink.rel = 'manifest';
document.head.appendChild(manifestLink);
}
manifestLink.href = manifestHref;
document.title = installMetadata.title;
let appleTitleMeta = document.querySelector('meta[name="apple-mobile-web-app-title"]');
if (!appleTitleMeta) {
appleTitleMeta = document.createElement('meta');
appleTitleMeta.name = 'apple-mobile-web-app-title';
document.head.appendChild(appleTitleMeta);
}
appleTitleMeta.content = installMetadata.title;
let themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (!themeColorMeta) {
themeColorMeta = document.createElement('meta');
themeColorMeta.name = 'theme-color';
document.head.appendChild(themeColorMeta);
}
themeColorMeta.content = installMetadata.themeColor;
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

42
package-lock.json generated
View File

@@ -12,10 +12,12 @@
"ag-grid-community": "^35.2.1",
"ag-grid-react": "^35.2.1",
"antd": "^5.27.0",
"phaser": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/node": "^25.6.0",
@@ -5115,6 +5117,15 @@
"node": "20 || >=22"
}
},
"node_modules/phaser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/phaser/-/phaser-4.1.0.tgz",
"integrity": "sha512-ZXv5Bhyg2BqJGAAxNI2xvmzGXW9q+TwUG1RLri5ZDBYGGtcma6aWUO/eJ7EbozeqRd5fKdpo4ycNMQt+Bi5iYg==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.4"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7538,6 +7549,35 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/zustand": {
"version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -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",
@@ -54,10 +55,12 @@
"ag-grid-community": "^35.2.1",
"ag-grid-react": "^35.2.1",
"antd": "^5.27.0",
"phaser": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/node": "^25.6.0",

BIN
public/..codex? Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -0,0 +1,25 @@
{
"id": "/chat/share/",
"name": "리소스 공유 채팅방",
"short_name": "공유채팅",
"description": "리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.",
"theme_color": "#165dff",
"background_color": "#f4f7fb",
"display": "standalone",
"lang": "ko",
"scope": "/chat/share/",
"start_url": "/chat/share/",
"icons": [
{
"src": "/pwa-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "/pwa-512x512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "/play/apps?app=e-reader",
"name": "E-Reader",
"short_name": "E-Reader",
"description": "인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽는 전용 앱",
"theme_color": "#2175ad",
"background_color": "#eff7fb",
"display": "standalone",
"lang": "ko",
"scope": "/play/apps",
"start_url": "/play/apps?app=e-reader",
"icons": [
{
"src": "/pwa-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "/pwa-512x512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

View File

@@ -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 본문 높이가 함께 맞춰진다.

View File

@@ -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 최소 높이 계산도 함께 조정해 레이아웃 공백이 생기지 않도록 확인했다.
## 미실행 항목
- 브라우저 실화면 캡처와 모바일 스크린샷은 이번 턴에서 생성하지 못했다.

View File

@@ -0,0 +1,28 @@
# 공유채팅방 개선
## 변경 목적
- stepper prompt에서 HTML preview가 객체 재생성마다 다시 fetch/reset 되며 멈춘 것처럼 보이던 흐름을 줄입니다.
- 공유채팅방 이동 시 이미 본 방은 즉시 복원하고, 최신화는 뒤에서 다시 받아 체감 로딩을 줄입니다.
- 재접속 시 마지막으로 사용한 공유채팅방을 다시 열 때, 마지막 방 ID뿐 아니라 해당 방 스냅샷도 세션 기준으로 복원합니다.
## 변경 범위
- `src/app/main/mainChatPanel/ChatPromptCard.tsx`
- preview fetch effect 의존성을 안정화했습니다.
- preview 본문/`content-type`을 메모리 캐시에 저장해 같은 HTML/markdown/resource preview 재진입 시 재요청을 줄였습니다.
- preview viewed / selection change effect에서 객체 참조 의존성을 줄여 stepper 렌더 루프 가능성을 낮췄습니다.
- `src/app/main/pages/ChatSharePage.tsx`
- 공유채팅방 스냅샷을 `sessionStorage`에도 저장하도록 추가했습니다.
- 토큰별 마지막 방 복원 시 세션 캐시 스냅샷을 먼저 적용하도록 보강했습니다.
- 방 전환 시 메모리 캐시가 없더라도 세션 캐시가 있으면 즉시 그 스냅샷으로 전환하도록 보강했습니다.
## 데이터 / API 영향
- 새 저장소 키
- `sessionStorage`: `codex-live-share-room-snapshot:<token>:<sessionId>`
- 기존 API 계약 변경 없음
- `/api/chat/shares/:token`
- `/api/chat/shares/:token?sessionId=...`
## 확인 포인트
- 같은 stepper prompt preview를 다시 펼쳐도 로딩 스피너가 계속 반복되지 않는지
- 이미 열어본 공유채팅방을 다시 눌렀을 때 화면이 캐시로 먼저 복원되는지
- 새로고침 후 마지막 사용 방 URL / 선택 상태가 유지되는지

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,24 @@
# 공유채팅방 개선 검증
## 실행 결과
- `npm exec tsc --noEmit`
- 성공
- `npm run build:test-app`
- 성공
- `curl http://127.0.0.1:5173/api/chat/shares/5e578dd5e91a4fa8b32cfe3c?sharePin=1459`
- 성공
- 응답 기준: `ok: true`, `title: 관리자`, `sessionId: chat-share-room-mpihlq67-ae86e941`, `requestCount: 5`
## 브라우저 확인
- 로컬 test-app 빌드로 `/chat/share/:token` 진입 시 공유채팅 셸 자체는 열렸습니다.
- 다만 이 격리 빌드 환경에서는 화면이 로딩 스피너 상태에 머무는 케이스가 있어, 이번 턴에서는 신뢰 가능한 기능 완료 화면 캡처까지는 확보하지 못했습니다.
- 따라서 이번 검증 결론은 다음 범위로 한정합니다.
- 타입 오류 없음
- 프로덕션 테스트 빌드 성공
- 공유채팅 스냅샷 API 정상 응답
- 로컬 브라우저 진입 자체는 가능
## 판정
- 코드 변경은 정상 반영됨
- stepper HTML preview 안정화와 공유채팅방 캐시/복원 로직은 정적 검증 + API 검증까지 완료
- 실서버 UI 최종 체감 확인은 공유채팅 실환경에서 한 번 더 보는 것이 안전함

View File

@@ -0,0 +1,27 @@
# 공유채팅방 멈춤 완화
## 변경 배경
- 1차 수정으로 `sessionStorage`에 공유방 스냅샷을 직렬화하던 경로는 제거했지만, 큰 관리형 공유채팅방에서는 여전히 서버가 최근 1000건 요청/메시지를 한 번에 내려주고 있었습니다.
- 공유채팅 페이지도 검색 모달이 닫힌 상태에서 질문·응답·리소스·활동로그 통합검색 인덱스를 매 렌더마다 전수 계산하고 있어, 큰 방에서는 첫 진입과 갱신 시 추가 부담이 남아 있었습니다.
## 변경 내용
- 기존 `sessionStorage` 제거 상태는 유지합니다.
- `etc/servers/work-server/src/routes/chat.ts`
- 관리형 공유채팅방(`MANAGED_CHAT_SHARE_SESSION_PREFIX`) 스냅샷은 최근 80건 요청 기준의 detail page만 내려주도록 바꿨습니다.
- 공유채팅 초기 진입과 실시간 갱신이 더 이상 최근 1000건 전체 요청/메시지를 항상 읽지 않게 했습니다.
- `src/app/main/pages/ChatSharePage.tsx`
- 통합검색 결과 계산은 검색 모달이 실제로 열렸을 때만 수행하도록 바꿨습니다.
- 방 진입 직후에는 닫혀 있는 검색 패널 때문에 질문/응답/리소스/활동로그 전체를 훑지 않습니다.
## 기대 효과
- 큰 공유채팅방에서도 초기 진입과 자동 새로고침이 최근 이력 중심으로 동작해 멈춤 체감이 줄어듭니다.
- 재접속 시 마지막 방 복원 기능은 유지하면서, 서버/프런트 양쪽의 불필요한 대량 계산을 줄입니다.
## 영향 범위
- 공유채팅 페이지의 검색 계산 시점과 공유 스냅샷 응답 범위를 조정했습니다.
- DB 스키마와 공유채팅 권한 로직은 변경하지 않았습니다.
## 확인 포인트
- 관리형 공유채팅방 첫 진입 시 최근 이력 기준으로 빠르게 열리는지 확인
- 메시지/활동로그가 많은 방에서도 방 이동·새로고침·실시간 갱신 시 멈춤 체감이 줄었는지 확인
- 검색 모달을 열지 않았을 때는 통합검색 전수 계산이 돌지 않는지 확인

View File

@@ -0,0 +1,15 @@
# 검증 요약
## 실행한 검증
- `npm exec tsc --noEmit`
- `npx tsc -p etc/servers/work-server/tsconfig.json --noEmit`
- `npm run build:test-app`
## 결과
- 프런트 타입 검사와 `work-server` 타입 검사는 모두 통과했습니다.
- 테스트 번들은 재빌드까지 통과했습니다.
- 이번 수정으로 관리형 공유채팅방 스냅샷은 최근 80건 detail page 기준으로 줄였고, 검색 모달이 닫혀 있을 때는 통합검색 계산을 건너뜁니다.
## 비고
- 이번 수정은 UI 레이아웃 변경이 아니라 응답량·계산량 축소라서, 최종 화면 캡처 대신 타입/빌드 검증과 코드 경로 문서화를 남겼습니다.
- `preview.sm-home.cloud` 실접속/잠금 해제 재현은 이번 턴에서 다시 수행하지 못했습니다.

View File

@@ -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가 열려도 부모 화면 레이아웃이 흔들리지 않는지
- 채팅유형을 바꾼 뒤 다시 설정을 열었을 때 방금 저장한 유형이 재표시되는지
- 공통 문맥을 비워 두면 채팅유형 기본 문맥 상속으로 동작하는지
- 방 전용 문맥 제목/본문 저장 후 다시 열었을 때 유지되는지
- 채팅 알림 토글 상태가 저장 후 공유 스냅샷 응답에 반영되는지

View File

@@ -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` 브라우저 캡처와 모바일 스크린샷은 이번 턴에서 수행하지 못함

View File

@@ -0,0 +1,20 @@
# 공유채팅 채팅방 이동 소도 개선
## 변경 요약
- 공유채팅방 마지막 선택 방 저장을 `localStorage`에서 `sessionStorage`로 변경했습니다.
- 같은 탭 안에서는 마지막으로 보던 방을 복원하지만, 브라우저를 완전히 닫으면 기억을 남기지 않습니다.
- 채팅방 선택 시 `roomSessionId`를 URL에 반영할 때 사용자 선택은 `pushState`, 자동 보정은 `replaceState`로 나눴습니다.
- 브라우저 뒤로가기/앞으로가기 시 현재 URL의 `roomSessionId`를 다시 읽어 선택 방과 동기화합니다.
## 변경 범위
- 공유채팅 화면의 방 선택/복원/URL 동기화 로직
- 영구 저장 제거에 따른 탭 세션 단위 이동 상태 복원
## 데이터 및 API 영향
- 서버 API 스펙 변경은 없습니다.
- 클라이언트 저장소 사용 범위만 `localStorage` -> `sessionStorage`로 바뀝니다.
## 확인 포인트
- 공유채팅에서 방을 바꾼 뒤 새로고침하면 같은 탭에서는 마지막 방이 유지되는지
- 브라우저 뒤로가기/앞으로가기 때 이전/다음 방으로 이동되는지
- 브라우저를 완전히 닫았다가 다시 열면 이전 방이 영구 복원되지 않는지

View File

@@ -0,0 +1,11 @@
# 검증 요약
## 수행 내용
- `npm exec tsc --noEmit`
## 결과
- 타입체크 통과
## 미수행 항목
- `https://preview.sm-home.cloud/` 브라우저 실접속 검증은 이번 턴에서 수행하지 못했습니다.
- 시각 레이아웃 변경이 아니라서 별도 UI 스크린샷은 생성하지 않았습니다.

View File

@@ -0,0 +1,709 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>공유채팅 헤더 재배치 제안</title>
<style>
:root {
--bg-top: #edf3fb;
--bg-bottom: #e4edf8;
--surface: rgba(255, 255, 255, 0.84);
--surface-strong: rgba(255, 255, 255, 0.94);
--surface-soft: rgba(248, 250, 252, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #64748b;
--blue: #2563eb;
--blue-soft: rgba(219, 234, 254, 0.96);
--green: #166534;
--green-soft: rgba(220, 252, 231, 0.98);
--amber: #92400e;
--amber-soft: rgba(254, 243, 199, 0.98);
--red: #b91c1c;
--red-soft: rgba(254, 226, 226, 0.98);
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.14);
--shadow-md: 0 12px 28px rgba(148, 163, 184, 0.16);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 18px;
--radius-sm: 14px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Pretendard", "Inter", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 28%),
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
}
.page {
width: min(1480px, calc(100vw - 40px));
margin: 0 auto;
padding: 28px 0 40px;
}
.hero {
display: grid;
gap: 14px;
padding: 22px 24px;
border: 1px solid rgba(196, 210, 226, 0.88);
border-radius: 28px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 250, 252, 0.86));
box-shadow: var(--shadow-lg);
backdrop-filter: blur(18px);
}
.eyebrow,
.chip,
.tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: -0.01em;
}
.eyebrow {
width: fit-content;
color: var(--blue);
background: var(--blue-soft);
}
.hero h1 {
margin: 0;
font-size: 34px;
line-height: 1.1;
letter-spacing: -0.04em;
}
.hero p {
margin: 0;
max-width: 920px;
color: var(--muted);
line-height: 1.6;
}
.dna {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
color: #334155;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(196, 210, 226, 0.92);
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-top: 22px;
}
.proposal {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 26px;
border: 1px solid rgba(196, 210, 226, 0.96);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(244, 248, 253, 0.94));
box-shadow: var(--shadow-md);
}
.proposal h2 {
margin: 0;
font-size: 22px;
letter-spacing: -0.03em;
}
.proposal p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
padding: 6px 10px;
color: #334155;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(196, 210, 226, 0.86);
}
.phone {
display: grid;
gap: 10px;
min-height: 740px;
padding: 14px;
border-radius: 30px;
background:
linear-gradient(180deg, rgba(237, 243, 251, 0.98), rgba(228, 237, 248, 0.94)),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.1), transparent 32%);
border: 1px solid rgba(196, 210, 226, 0.98);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.4),
0 18px 38px rgba(15, 23, 42, 0.12);
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 0;
font-size: 11px;
color: #475569;
}
.header {
display: grid;
gap: 10px;
padding: 12px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.78));
border: 1px solid rgba(196, 210, 226, 0.9);
backdrop-filter: blur(18px);
}
.header-top,
.header-bottom,
.header-split {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.room-trigger,
.action-pill,
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.42),
0 8px 18px rgba(148, 163, 184, 0.14);
}
.room-trigger {
min-width: 0;
flex: 1 1 auto;
justify-content: flex-start;
}
.room-avatar {
width: 28px;
height: 28px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.94), rgba(59, 130, 246, 0.72));
color: white;
display: grid;
place-items: center;
font-weight: 800;
font-size: 13px;
flex: 0 0 auto;
}
.room-copy {
display: grid;
min-width: 0;
}
.room-copy strong {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.room-copy span {
color: var(--muted);
font-size: 11px;
}
.icon-circle {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(219, 234, 254, 0.94);
color: var(--blue);
font-size: 14px;
font-weight: 800;
}
.handle {
width: 44px;
height: 5px;
margin: 2px auto 0;
border-radius: 999px;
background: rgba(148, 163, 184, 0.52);
}
.dashboard {
display: grid;
gap: 10px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.metric {
padding: 14px;
border-radius: 20px;
border: 1px solid rgba(196, 210, 226, 0.92);
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
}
.metric strong {
display: block;
font-size: 24px;
letter-spacing: -0.04em;
}
.metric span {
color: var(--muted);
font-size: 12px;
}
.metric.blue {
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98), rgba(219, 234, 254, 0.94));
}
.metric.green {
background: linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.94));
}
.metric.amber {
background: linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(254, 243, 199, 0.94));
}
.metric.red {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.94));
}
.sheet {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 26px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.92));
border: 1px solid rgba(196, 210, 226, 0.9);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.32),
0 14px 28px rgba(148, 163, 184, 0.14);
}
.sheet-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.sheet-title strong {
font-size: 15px;
}
.section {
display: grid;
gap: 8px;
}
.section-label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.list,
.feed {
display: grid;
gap: 8px;
}
.list-item,
.feed-item {
display: grid;
gap: 4px;
padding: 12px 13px;
border-radius: 18px;
background: var(--surface-strong);
border: 1px solid rgba(196, 210, 226, 0.86);
}
.list-item-top,
.feed-item-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.list-item strong,
.feed-item strong {
font-size: 13px;
}
.list-item span,
.feed-item span,
.feed-item p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.badge {
padding: 5px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.badge.blue {
color: #1d4ed8;
background: rgba(219, 234, 254, 0.96);
}
.badge.green {
color: var(--green);
background: var(--green-soft);
}
.badge.amber {
color: var(--amber);
background: var(--amber-soft);
}
.badge.red {
color: var(--red);
background: var(--red-soft);
}
.message {
flex: 1 1 auto;
display: grid;
gap: 10px;
align-content: start;
padding: 8px 2px 2px;
}
.bubble {
max-width: 86%;
padding: 12px 14px;
border-radius: 18px;
line-height: 1.55;
font-size: 13px;
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.1);
}
.bubble.self {
justify-self: end;
color: white;
background: linear-gradient(135deg, #2563eb, #3b82f6);
}
.bubble.other {
justify-self: start;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(196, 210, 226, 0.82);
}
.footer-note {
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.8);
border: 1px dashed rgba(148, 163, 184, 0.4);
color: #475569;
line-height: 1.6;
}
@media (max-width: 1280px) {
.grid {
grid-template-columns: 1fr;
}
.phone {
min-height: auto;
}
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<span class="eyebrow">공유채팅 실제 테마 기반 제안</span>
<h1>채팅방 헤더를 방 목록 + 알림센터 허브로 재구성</h1>
<p>
현재 공유채팅이 쓰는 옅은 블루 그라데이션, 반투명 화이트 surface, 파란 pill 액션 톤을 유지하면서
헤더의 역할을 명확히 분리했습니다. 공통 방향은 “제목/아이콘 클릭으로 방 목록”, “중앙 손잡이 드래그로
iOS 알림센터 스타일”, “현재 방과 다른 방 알림을 한 시트에서 함께 확인”입니다.
</p>
<div class="dna">
<span class="chip">현재 테마: #edf3fb → #e4edf8</span>
<span class="chip">액션 톤: white pill + #2563eb</span>
<span class="chip">재질감: blur + soft shadow</span>
<span class="chip">상태칩: blue / green / amber / red</span>
</div>
</section>
<section class="grid">
<article class="proposal" id="option-a">
<div>
<h2>A안. Capsule Rail</h2>
<p>가장 자연스럽게 익숙한 안입니다. 제목 캡슐이 방 목록 진입점이 되고, 중앙 손잡이를 내려 전체 알림센터를 펼칩니다.</p>
</div>
<div class="tag-row">
<span class="tag">추천: 모바일 우선</span>
<span class="tag">방 목록 발견성 높음</span>
<span class="tag">알림센터 구분 명확</span>
</div>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>Live 5G 92%</span></div>
<div class="header">
<div class="header-top">
<div class="room-trigger">
<div class="room-avatar">CC</div>
<div class="room-copy">
<strong>공유채팅 운영룸</strong>
<span>제목/아이콘 탭: 방 목록 + 필터 열기</span>
</div>
</div>
<div class="action-pill">설정</div>
<div class="icon-circle">-</div>
<div class="icon-circle">×</div>
</div>
<div class="header-bottom">
<div class="filter-pill">진행중 6 · 다른 방 새답변 4</div>
<div class="filter-pill">apps 2건</div>
</div>
<div class="handle"></div>
</div>
<div class="sheet">
<div class="sheet-title">
<strong>알림센터</strong>
<span class="badge blue">전체 채팅 + apps</span>
</div>
<div class="dashboard">
<div class="dashboard-grid">
<div class="metric blue"><strong>12</strong><span>처리중 요청</span></div>
<div class="metric green"><strong>4</strong><span>다른 방 새 답변</span></div>
<div class="metric amber"><strong>2</strong><span>apps 경고</span></div>
<div class="metric red"><strong>1</strong><span>확인 필요 실패</span></div>
</div>
</div>
<div class="section">
<div class="section-label">방 목록 + 필터</div>
<div class="list">
<div class="list-item">
<div class="list-item-top"><strong>전체 방</strong><span class="badge blue">18</span></div>
<span>최근답변, 처리중, 안읽음, apps 연결방 필터를 같은 레이어에서 전환</span>
</div>
<div class="list-item">
<div class="list-item-top"><strong>개발 운영방</strong><span class="badge green">새 답변</span></div>
<span>3분 전 Codex 응답 도착 · 앱 연결 2개</span>
</div>
</div>
</div>
<div class="section">
<div class="section-label">알림 피드</div>
<div class="feed">
<div class="feed-item">
<div class="feed-item-top"><strong>자동화 공유방</strong><span class="badge blue">진행중</span></div>
<p>배포 확인 캡처 업로드 완료. 검증 스크린샷 2장 확인 필요.</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>Apps 알림</strong><span class="badge amber">권한</span></div>
<p>캘린더 동기화 1건 지연. 알림센터에서 바로 상세 열기 버튼 제공.</p>
</div>
</div>
</div>
</div>
<div class="message">
<div class="bubble other">헤더 제목을 누르면 바로 방 목록과 필터가 한 번에 보여야 합니다.</div>
<div class="bubble self">A안은 그 요구를 가장 직접적으로 충족합니다.</div>
</div>
</div>
</article>
<article class="proposal" id="option-b">
<div>
<h2>B안. Split Status Bar</h2>
<p>좌측은 현재 방, 우측은 전체 알림과 apps 상태를 쌓아 두는 데스크톱 친화형입니다. 헤더 하단은 세그먼트 필터로 남깁니다.</p>
</div>
<div class="tag-row">
<span class="tag">추천: 데스크톱 확장</span>
<span class="tag">필터 접근 가장 빠름</span>
<span class="tag">정보량 많음</span>
</div>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>Workspace online</span></div>
<div class="header">
<div class="header-split">
<div class="room-trigger">
<div class="room-avatar">PM</div>
<div class="room-copy">
<strong>프로젝트 메인룸</strong>
<span>아이콘/제목 클릭: 방 전환</span>
</div>
</div>
<div class="action-pill">다른 방 4</div>
<div class="action-pill">apps 2</div>
</div>
<div class="header-bottom">
<div class="filter-pill">전체</div>
<div class="filter-pill">진행중</div>
<div class="filter-pill">안읽음</div>
<div class="filter-pill">apps</div>
</div>
<div class="handle"></div>
</div>
<div class="sheet">
<div class="sheet-title">
<strong>Notification Center Dashboard</strong>
<span class="badge green">실시간 집계</span>
</div>
<div class="dashboard-grid">
<div class="metric blue"><strong>08</strong><span>현재 방 처리 흐름</span></div>
<div class="metric green"><strong>03</strong><span>다른 방 확인대기</span></div>
<div class="metric amber"><strong>05</strong><span>apps 작업 알림</span></div>
<div class="metric red"><strong>02</strong><span>실패/재시도</span></div>
</div>
<div class="section">
<div class="section-label">요약 콘텐츠</div>
<div class="feed">
<div class="feed-item">
<div class="feed-item-top"><strong>현재 방</strong><span class="badge blue">3건</span></div>
<p>작업중인 요청, 마지막 응답 시간, 첨부 리소스 생성 수를 카드형으로 고정 배치</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>다른 채팅방</strong><span class="badge green">새 답변</span></div>
<p>현재 방이 아니어도 읽지 않은 응답과 mention 성격 요청을 한 섹션에 모아 보여줌</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>Apps</strong><span class="badge amber">확인 필요</span></div>
<p>캘린더, 알림, 연결 앱의 상태 메시지를 채팅 알림과 동일한 카드 리듬으로 정렬</p>
</div>
</div>
</div>
</div>
<div class="message">
<div class="bubble other">필터를 자주 바꾸는 운영자라면 헤더 안에서 바로 전환하고 싶습니다.</div>
<div class="bubble self">B안은 필터 중심 운영에 가장 유리합니다.</div>
</div>
</div>
</article>
<article class="proposal" id="option-c">
<div>
<h2>C안. Focus Stack</h2>
<p>현재 작업중인 방을 더 크게 인지시키는 집중형입니다. 알림센터는 “현재 방 집중 + 다른 방 보조” 우선순위가 드러납니다.</p>
</div>
<div class="tag-row">
<span class="tag">추천: 집중 작업</span>
<span class="tag">브랜드감 강함</span>
<span class="tag">운영감시형 대시보드</span>
</div>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>Preview theme sync</span></div>
<div class="header">
<div class="header-top">
<div class="room-trigger" style="min-height: 52px;">
<div class="room-avatar" style="width: 34px; height: 34px; border-radius: 14px;">UX</div>
<div class="room-copy">
<strong>UX 검토 채팅방</strong>
<span>Hero chip 탭: 방 목록 / 필터 / 최근방</span>
</div>
</div>
<div class="icon-circle"></div>
</div>
<div class="header-bottom">
<div class="filter-pill">현재 방 진행중 4</div>
<div class="filter-pill">전체 알림 9</div>
</div>
<div class="handle"></div>
</div>
<div class="sheet">
<div class="sheet-title">
<strong>집중 대시보드</strong>
<span class="badge blue">현재 방 우선</span>
</div>
<div class="dashboard-grid">
<div class="metric blue"><strong>4</strong><span>현재 방 처리중</span></div>
<div class="metric green"><strong>2</strong><span>완료 직전</span></div>
<div class="metric amber"><strong>3</strong><span>다른 방 확인</span></div>
<div class="metric red"><strong>1</strong><span>apps 경고</span></div>
</div>
<div class="section">
<div class="section-label">우선순위 피드</div>
<div class="feed">
<div class="feed-item">
<div class="feed-item-top"><strong>현재 방 요청</strong><span class="badge blue">우선</span></div>
<p>마지막 응답 이후 7분 경과. 첨부 preview 3개 생성됨. 바로 이어보기 버튼 노출.</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>다른 방 답변</strong><span class="badge green">보조</span></div>
<p>메인 운영방에 새 응답 2건. 눌러서 방 전환 없이 quick peek 가능.</p>
</div>
<div class="feed-item">
<div class="feed-item-top"><strong>Apps 이벤트</strong><span class="badge amber">연결</span></div>
<p>배포 완료, 캘린더 일정, 리소스 등록 완료 이벤트를 낮은 대비 카드로 정렬.</p>
</div>
</div>
</div>
</div>
<div class="message">
<div class="bubble other">작업 중인 방을 잃지 않으면서도 다른 방 알림은 놓치고 싶지 않습니다.</div>
<div class="bubble self">C안은 집중감은 가장 좋지만, 운영형 전체 목록성은 A안보다 약합니다.</div>
</div>
</div>
</article>
</section>
<section class="footer-note">
추천 순서는 A안 → B안 → C안입니다. A안은 현재 공유채팅의 둥근 pill 액션과 blur 헤더 감성을 가장 자연스럽게 이어가면서,
“제목/아이콘으로 방 목록”, “손잡이로 알림센터”라는 두 행동을 가장 덜 헷갈리게 분리합니다.
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,53 @@
# 공유채팅 채팅방 헤더 재배치 제안
## 목적
- 실제 공유채팅에서 사용하는 라이트 블루 테마를 유지한 채, 헤더를 방 선택과 알림센터 진입의 허브로 재구성한다.
- 채팅방 제목 또는 아이콘 선택 시 필터와 채팅방 목록을 함께 노출하고, 헤더 손잡이를 아래로 내리면 iOS 알림센터 같은 요약 대시보드와 알림 피드를 보여준다.
- 기존 기능은 유지하되, 설정/최소화/닫기 같은 툴 액션은 헤더 집중도를 해치지 않도록 보조 영역으로 정리한다.
## 현재 테마 확인 기준
- 공유채팅 카드 바디는 `#edf3fb → #e4edf8` 계열 세로 그라데이션과 얕은 inset border, soft shadow를 사용한다.
- 헤더 액션은 흰색 pill 버튼 위에 `#2563eb` 아이콘 포인트를 두고 hover 시 더 밝은 블루 계열로 반응한다.
- 헤더 배경은 반투명 흰색보다 한 단계 채도 낮은 블루톤이며 blur가 들어간다.
- 방 상태/요청 상태는 회색, 파랑, 초록, 빨강 계열 pill로 구분한다.
## 제안 방향 공통 원칙
- 헤더 1행은 현재 방 인지와 이동, 헤더 2행은 상태/필터/알림센터 진입으로 역할을 명확히 분리한다.
- 방 목록은 제목 또는 아이콘을 누르는 행위 하나로 열리도록 통합하고, 목록 안에서 필터 칩과 최근 대화 프리뷰를 동시에 보여준다.
- 알림센터는 현재 방 전용이 아니라 전체 채팅방과 apps 알림을 함께 집계한다.
- 손잡이 드래그는 모달보다 시스템 오버레이처럼 느껴지게 하고, 상단에는 다시보드형 요약 카드를 고정한다.
## 제안안 구성
### A안 Capsule Rail
- 제목 캡슐 자체가 방 목록 트리거다.
- 헤더 중앙 손잡이를 내려 알림센터를 여는 패턴으로 가장 직관적이다.
- 알림센터 상단은 처리중, 새 답변, apps 경고, 캘린더성 일정 같은 4분할 다시보드다.
### B안 Split Status Bar
- 좌측은 방 정보, 우측은 앱 알림과 빠른 전환 상태를 모은 split bar 구조다.
- 필터를 헤더 하단의 segment row로 남겨 자주 쓰는 상태 전환을 더 빠르게 한다.
- 데스크톱 확장성은 좋지만 모바일에서는 약간 더 촘촘해질 수 있다.
### C안 Focus Stack
- 방 아이콘과 제목을 한 덩어리 hero chip으로 키워 현재 방 인지를 강화한다.
- 알림센터 대시보드 카드를 더 크게 두고, 현재 처리중 요청 중심의 집중도를 높인다.
- 정보량이 많을 때보다 “현재 작업 집중” 상황에 적합하다.
## 유지되어야 할 기존 기능
- 방 이동
- 필터 전환
- 설정
- 최소화
- 닫기
- 현재 연결 상태 표시
- 현재 방 상태 요약
## 알림센터 추천 콘텐츠
- 상단 고정 다시보드: 처리중 요청 수, 읽지 않은 다른 채팅방, apps 경고/완료, 오늘 일정 또는 예약 작업
- 중단: 현재 방 진행 카드와 다른 방 새 답변 카드 혼합 피드
- 하단: 앱별 알림 묶음, 빠른 액션, 전체 읽음/필터 토글
## 검토 포인트
- 방 목록과 알림센터를 둘 다 헤더에 얹되 탭 충돌 없이 한 손 조작이 가능한지
- 모바일에서 드래그 손잡이와 브라우저 스크롤 제스처가 충돌하지 않는지
- 현재 존재하는 설정/최소화/닫기 액션을 보조 영역으로 빼도 발견 가능성이 유지되는지

View File

@@ -58,6 +58,53 @@ const CODEX_LIVE_FINISHED_RETENTION_MS = Math.max(
);
const activeCodexExecutions = new Map();
const recentCodexExecutions = new Map();
let runnerShutdownSignal = null;
function logRunner(message) {
process.stdout.write("[server-command-runner] " + new Date().toISOString() + " " + message + "\n");
}
function summarizeActiveExecutionIds(limit = 8) {
const requestIds = Array.from(activeCodexExecutions.keys()).slice(0, limit);
const suffix = activeCodexExecutions.size > requestIds.length ? " +" + (activeCodexExecutions.size - requestIds.length) + " more" : "";
return requestIds.length > 0 ? requestIds.join(", ") + suffix : "none";
}
function resolveSignalExitCode(signal) {
switch (signal) {
case "SIGINT":
return 130;
case "SIGHUP":
return 129;
case "SIGTERM":
return 143;
default:
return 1;
}
}
function shutdownRunnerFromSignal(signal) {
if (runnerShutdownSignal) {
return;
}
runnerShutdownSignal = signal;
logRunner("received " + signal + "; activeExecutions=" + activeCodexExecutions.size + "; requestIds=" + summarizeActiveExecutionIds());
process.exit(resolveSignalExitCode(signal));
}
process.once("SIGTERM", () => shutdownRunnerFromSignal("SIGTERM"));
process.once("SIGINT", () => shutdownRunnerFromSignal("SIGINT"));
process.once("SIGHUP", () => shutdownRunnerFromSignal("SIGHUP"));
process.on("exit", (code) => {
const shutdownLabel = runnerShutdownSignal === null ? "none" : runnerShutdownSignal;
logRunner("exiting with code " + code + "; shutdownSignal=" + shutdownLabel + "; activeExecutions=" + activeCodexExecutions.size);
});
function resolveCodexLiveModel(value) {
const normalized = String(value ?? '').trim();
return /^[A-Za-z0-9._:-]+$/u.test(normalized) ? normalized : CODEX_LIVE_MODEL;
}
function createCodexExecutionRecord({ requestId, child, tempDir }) {
return {
@@ -134,6 +181,99 @@ function scheduleCodexExecutionCleanup(record) {
record.cleanupTimer.unref?.();
}
function normalizeCodexUsageMetricValue(value) {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.max(0, Math.round(value));
}
if (typeof value === 'string' && value.trim()) {
const normalized = Number(value);
if (Number.isFinite(normalized)) {
return Math.max(0, Math.round(normalized));
}
}
return null;
}
function extractCodexUsageSnapshot(parsed) {
if (!parsed || typeof parsed !== 'object') {
return null;
}
const usage =
parsed.usage && typeof parsed.usage === 'object'
? parsed.usage
: parsed.response &&
typeof parsed.response === 'object' &&
parsed.response !== null &&
parsed.response.usage &&
typeof parsed.response.usage === 'object'
? parsed.response.usage
: null;
if (!usage) {
return null;
}
const input = normalizeCodexUsageMetricValue(usage.input_tokens);
const output = normalizeCodexUsageMetricValue(usage.output_tokens);
const cached = normalizeCodexUsageMetricValue(usage.cached_input_tokens);
const reasoning = normalizeCodexUsageMetricValue(usage.reasoning_output_tokens ?? usage.reasoning_tokens);
const total =
normalizeCodexUsageMetricValue(usage.total_tokens) ??
normalizeCodexUsageMetricValue(usage.totalTokens) ??
[input ?? 0, output ?? 0].reduce((sum, value) => sum + value, 0);
if (input === null && output === null && cached === null && reasoning === null && total === null) {
return null;
}
return {
tokenTotals: {
total: total ?? 0,
input: input ?? 0,
output: output ?? 0,
cached: cached ?? 0,
reasoning: reasoning ?? 0,
},
totalTokens: total ?? 0,
};
}
function extractCodexUsageSnapshotFromText(output) {
const text = String(output ?? '');
if (!text.trim()) {
return null;
}
const lines = text
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index] ?? '';
if (!line.startsWith('{')) {
continue;
}
try {
const usageSnapshot = extractCodexUsageSnapshot(JSON.parse(line));
if (usageSnapshot) {
return usageSnapshot;
}
} catch {
// ignore malformed JSON lines during fallback scan
}
}
return null;
}
function finalizeCodexExecution(record) {
if (record.completed) {
return;
@@ -642,6 +782,7 @@ async function runCodexLiveExecution(payload, response) {
const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource');
const uploadDir = path.join(resourceDir, 'uploads');
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex';
const codexModel = resolveCodexLiveModel(payload?.model);
const configuredIdleTimeoutMs = resolveCodexLiveIdleTimeoutMs(payload?.idleTimeoutSeconds);
const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds, configuredIdleTimeoutMs);
@@ -673,13 +814,14 @@ async function runCodexLiveExecution(payload, response) {
let stderrTail = '';
let jsonLineBuffer = '';
let completedText = '';
let streamedUsageSnapshot = null;
let idleTimer = null;
let executionTimer = null;
let terminationRequested = false;
const child = spawn(
codexBin,
['exec', '--model', CODEX_LIVE_MODEL, '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'],
['exec', '--model', codexModel, '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'],
{
cwd: repoPath,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -698,11 +840,13 @@ async function runCodexLiveExecution(payload, response) {
child,
tempDir,
});
logRunner("spawned Codex child pid=" + (child.pid ?? "unknown") + " requestId=" + requestId + " sessionId=" + sessionId + " model=" + codexModel + " idleTimeoutMs=" + configuredIdleTimeoutMs + " maxExecutionMs=" + configuredMaxExecutionMs);
activeCodexExecutions.set(requestId, executionRecord);
attachCodexExecutionSubscriber(executionRecord, response);
broadcastCodexExecutionEvent(executionRecord, {
type: 'started',
pid: child.pid ?? null,
model: codexModel,
configuredIdleTimeoutSeconds: Math.round(configuredIdleTimeoutMs / 1000),
configuredMaxExecutionSeconds: Math.round(configuredMaxExecutionMs / 1000),
});
@@ -719,7 +863,7 @@ async function runCodexLiveExecution(payload, response) {
}
};
const requestTermination = (message) => {
const requestTermination = (message, reason = 'runner-termination') => {
if (terminationRequested) {
return;
}
@@ -732,6 +876,7 @@ async function runCodexLiveExecution(payload, response) {
message,
});
logRunner("terminating Codex child pid=" + (child.pid ?? "unknown") + " requestId=" + requestId + " reason=" + reason + " message=" + message);
child.kill('SIGTERM');
setTimeout(() => {
if (!child.killed) {
@@ -752,6 +897,7 @@ async function runCodexLiveExecution(payload, response) {
idleTimer = setTimeout(() => {
requestTermination(
`Codex Live 실행이 ${Math.round(configuredIdleTimeoutMs / 1000)}초 동안 출력이 없어 중단되었습니다.`,
'idle-timeout',
);
}, configuredIdleTimeoutMs);
idleTimer.unref?.();
@@ -760,6 +906,7 @@ async function runCodexLiveExecution(payload, response) {
executionTimer = setTimeout(() => {
requestTermination(
`Codex Live 실행이 ${Math.round(configuredMaxExecutionMs / 1000)}초를 넘어 중단되었습니다.`,
'max-execution-timeout',
);
}, configuredMaxExecutionMs);
executionTimer.unref?.();
@@ -785,16 +932,7 @@ async function runCodexLiveExecution(payload, response) {
}
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
if (nextCompletedText) {
refreshIdleTimer();
completedText = nextCompletedText;
broadcastCodexExecutionEvent(executionRecord, {
type: 'completed',
text: nextCompletedText,
});
return true;
}
const usageSnapshot = extractCodexUsageSnapshot(parsed);
if (deltaText) {
refreshIdleTimer();
@@ -802,10 +940,28 @@ async function runCodexLiveExecution(payload, response) {
type: 'delta',
text: deltaText,
});
return true;
}
return false;
if (usageSnapshot) {
streamedUsageSnapshot = usageSnapshot;
refreshIdleTimer();
broadcastCodexExecutionEvent(executionRecord, {
type: 'usage',
usageSnapshot,
});
}
if (nextCompletedText) {
refreshIdleTimer();
completedText = nextCompletedText;
broadcastCodexExecutionEvent(executionRecord, {
type: 'completed',
text: nextCompletedText,
usageSnapshot,
});
}
return Boolean(deltaText || usageSnapshot || nextCompletedText || activityLog);
};
child.stdout?.on('data', (chunk) => {
@@ -858,6 +1014,7 @@ async function runCodexLiveExecution(payload, response) {
child.on('error', async (error) => {
clearExecutionTimers();
logRunner("Codex child process error requestId=" + requestId + " pid=" + (child.pid ?? "unknown") + " message=" + (error instanceof Error ? error.message : String(error)));
broadcastCodexExecutionEvent(executionRecord, {
type: 'error',
message: error instanceof Error ? error.message : String(error),
@@ -869,13 +1026,26 @@ async function runCodexLiveExecution(payload, response) {
finalizeCodexExecution(executionRecord);
});
child.on('close', async (code) => {
child.on('close', async (code, signal) => {
clearExecutionTimers();
logRunner("Codex child closed requestId=" + requestId + " pid=" + (child.pid ?? "unknown") + " exitCode=" + (code ?? "null") + " signal=" + (signal ?? "none") + " terminationRequested=" + terminationRequested);
const trailingLine = jsonLineBuffer.trim();
if (trailingLine) {
handleCodexJsonLine(trailingLine);
}
if (!streamedUsageSnapshot) {
const fallbackUsageSnapshot = extractCodexUsageSnapshotFromText([stdoutTail, trailingLine].filter(Boolean).join('\n'));
if (fallbackUsageSnapshot) {
streamedUsageSnapshot = fallbackUsageSnapshot;
broadcastCodexExecutionEvent(executionRecord, {
type: 'usage',
usageSnapshot: fallbackUsageSnapshot,
});
}
}
if (code !== 0) {
broadcastCodexExecutionEvent(executionRecord, {
type: 'error',
@@ -1082,6 +1252,7 @@ const server = createServer(async (request, response) => {
}
try {
logRunner("received cancel request for requestId=" + requestId + "; forwarding SIGTERM to child pid=" + (activeExecution.child.pid ?? "unknown"));
activeExecution.child.kill('SIGTERM');
setTimeout(() => {
const current = activeCodexExecutions.get(requestId);
@@ -1117,5 +1288,5 @@ server.listen(port, host, () => {
});
}, 10_000);
heartbeatTimer.unref();
process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`);
logRunner("listening on http://" + host + ":" + port + "; pid=" + process.pid + "; ppid=" + process.ppid + "; startedAt=" + startedAt + "; logFile=" + runnerLogFile);
});

View File

@@ -9,7 +9,7 @@ const port = Number(process.env.PORT ?? 5173);
const distDirName = process.env.APP_DIST_DIR ?? 'app-dist';
const rootDir = normalize(isAbsolute(distDirName) ? distDirName : join(process.cwd(), distDirName));
const workServerUrl = new URL(process.env.WORK_SERVER_URL ?? 'http://127.0.0.1:3100');
const proxyPrefixes = ['/api', '/.codex_chat', '/ws/chat'];
const proxyPrefixes = ['/api', '/.codex_chat', '/public/.codex_chat', '/ws/chat'];
const mimeTypes = {
'.css': 'text/css; charset=utf-8',
@@ -25,6 +25,53 @@ const mimeTypes = {
'.woff2': 'font/woff2',
};
function canListenOnPort(candidatePort, host = '0.0.0.0') {
return new Promise((resolve, reject) => {
const probeServer = createServer();
probeServer.once('error', (error) => {
probeServer.close(() => {
if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
resolve(false);
return;
}
reject(error);
});
});
probeServer.once('listening', () => {
probeServer.close((closeError) => {
if (closeError) {
reject(closeError);
return;
}
resolve(true);
});
});
probeServer.listen(candidatePort, host);
});
}
async function findAvailablePort(initialPort, host = '0.0.0.0', maxAttempts = 20) {
for (let offset = 0; offset < maxAttempts; offset += 1) {
const candidatePort = initialPort + offset;
const available = await canListenOnPort(candidatePort, host);
if (available) {
return candidatePort;
}
if (offset === 0) {
console.warn(`Port ${initialPort} is in use, trying another one...`);
}
}
throw new Error(`No available port found from ${initialPort} to ${initialPort + maxAttempts - 1}.`);
}
function resolveCacheControl(resolvedPath, extension) {
const normalizedPath = resolvedPath.replace(/\\/g, '/');
@@ -218,6 +265,9 @@ server.on('upgrade', (request, socket, head) => {
});
});
server.listen(port, '0.0.0.0', () => {
console.log(`${distDirName} server listening on http://0.0.0.0:${port}`);
const host = '0.0.0.0';
const resolvedPort = await findAvailablePort(port, host);
server.listen(resolvedPort, host, () => {
console.log(`${distDirName} server listening on http://${host}:${resolvedPort}`);
});

0
scripts/server-command-runner-supervisor.sh Normal file → Executable file
View File

View File

@@ -1,15 +1,21 @@
import { App as AntdApp } from 'antd';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { getOrCreateClientId } from './app/main/clientIdentity';
import { reportClientError } from './app/main/errorLogApi';
import { AppShell } from './app/main';
import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay';
import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal';
import { buildChatPath } from './app/main/routes';
import { isPreviewRuntime } from './app/main/previewRuntime';
import { bindViewportCssVars } from './app/main/viewportCssVars';
import { reportVisitorPageView } from './features/history/api';
import { useAppStore } from './store';
const CHUNK_LOAD_RETRY_SESSION_KEY = 'ai-code-app.chunk-load-retried';
const CACHE_RECOVERY_SESSION_KEY = 'ai-code-app.cache-recovery-completed';
const INITIAL_LOADING_MIN_VISIBLE_MS = 450;
const CACHE_RECOVERY_NOTICE = '캐시된 화면 정보가 맞지 않아 홈으로 이동합니다. 다시 열어 주세요.';
const CACHE_RECOVERY_DELAY_MS = 900;
function shouldRetryChunkLoad(errorMessage: string) {
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError/i.test(
@@ -17,6 +23,12 @@ function shouldRetryChunkLoad(errorMessage: string) {
);
}
function shouldRecoverFromCacheError(errorMessage: string) {
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError|Loading chunk|Failed to load module script|does not provide an export named|Cannot find module/i.test(
errorMessage,
);
}
function retryChunkLoadOnce(errorMessage: string) {
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
return false;
@@ -26,16 +38,58 @@ function retryChunkLoadOnce(errorMessage: string) {
return false;
}
if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') {
try {
if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') {
return false;
}
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
window.location.reload();
return true;
} catch {
return false;
}
}
function getHomeRecoveryUrl() {
if (typeof window === 'undefined') {
return buildChatPath('live');
}
return new URL(buildChatPath('live'), window.location.origin).toString();
}
function tryRecoverToHomeFromCacheError(errorMessage: string, notify: (text: string) => void) {
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
return false;
}
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
window.location.reload();
return true;
if (isPreviewRuntime()) {
return false;
}
if (!shouldRecoverFromCacheError(errorMessage)) {
return false;
}
try {
if (sessionStorage.getItem(CACHE_RECOVERY_SESSION_KEY) === '1') {
return false;
}
sessionStorage.setItem(CACHE_RECOVERY_SESSION_KEY, '1');
notify(CACHE_RECOVERY_NOTICE);
window.setTimeout(() => {
window.location.replace(getHomeRecoveryUrl());
}, CACHE_RECOVERY_DELAY_MS);
return true;
} catch {
return false;
}
}
function App() {
const { message } = AntdApp.useApp();
const { currentPage } = useAppStore();
const lastTrackedPageIdRef = useRef<string | null>(null);
const [showInitialLoading, setShowInitialLoading] = useState(true);
@@ -47,6 +101,13 @@ function App() {
return undefined;
}
const notifyCacheRecovery = (text: string) => {
message.warning({
content: text,
duration: 1.5,
});
};
const handleError = (event: ErrorEvent) => {
const reportedError = event.error instanceof Error ? event.error : null;
const errorMessage = event.message || reportedError?.message || '클라이언트 오류가 발생했습니다.';
@@ -67,6 +128,8 @@ function App() {
column: event.colno || null,
},
});
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
@@ -89,6 +152,8 @@ function App() {
reasonType: typeof reason,
},
});
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
};
window.addEventListener('error', handleError);
@@ -98,7 +163,7 @@ function App() {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
}, [message]);
useEffect(() => {
getOrCreateClientId();

View File

@@ -2,21 +2,28 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { MainLayout } from './layout/MainLayout';
import { ApisPage } from './pages/ApisPage';
import { ChatPage } from './pages/ChatPage';
import { ChatSharePage } from './pages/ChatSharePage';
import { DocsPage } from './pages/DocsPage';
import { PlansPage } from './pages/PlansPage';
import { PlayPage } from './pages/PlayPage';
import { buildChatPath, buildDocsPath } from './routes';
import { isPreviewRuntime } from './previewRuntime';
export function AppShell() {
return (
<Routes>
<Route path="/shares/:token" element={<ChatSharePage />} />
<Route path="/chat-share/:token" element={<ChatSharePage />} />
<Route path="/chat/share/:token" element={<ChatSharePage />} />
<Route path="/" element={<MainLayout />}>
<Route index element={<Navigate to={buildChatPath('live')} replace />} />
<Route index element={<Navigate to={isPreviewRuntime() ? buildDocsPath() : buildChatPath('live')} replace />} />
<Route path="docs/:folder" element={<DocsPage />} />
<Route path="apis/:section" element={<ApisPage />} />
<Route path="plans/:section" element={<PlansPage />} />
<Route path="chat/:section" element={<ChatPage />} />
<Route path="play/layout" element={<PlayPage />} />
<Route path="play/draw" element={<PlayPage />} />
<Route path="play/apps" element={<PlayPage />} />
<Route path="play/test" element={<PlayPage />} />
<Route path="play/cbt" element={<PlayPage />} />
<Route path="play/layout-record/:layoutId" element={<PlayPage />} />

View File

@@ -1,6 +1,6 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Empty, Form, Input, List, Modal, Space, Switch, Typography } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteAutomationContext,
@@ -8,6 +8,7 @@ import {
upsertAutomationContext,
useAutomationContextRegistry,
} from './automationContextAccess';
import { confirmWithKeyboard } from './modalKeyboard';
import { useTokenAccess } from './tokenAccess';
import './AutomationContextManagementPage.css';
@@ -51,6 +52,8 @@ export function AutomationContextManagementPage() {
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationContextFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const selectedAutomationContext = useMemo(
() => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null,
@@ -67,12 +70,20 @@ export function AutomationContextManagementPage() {
useEffect(() => {
if (detailMode !== 'detail') {
lastHydratedFormKeyRef.current = '';
return;
}
const nextFormKey = isCreating ? '__create__' : selectedAutomationContext?.id ?? '__empty__';
if (lastHydratedFormKeyRef.current === nextFormKey) {
return;
}
lastHydratedFormKeyRef.current = nextFormKey;
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext));
}, [detailMode, form, isCreating, selectedAutomationContext]);
}, [detailMode, form, isCreating, selectedAutomationContext?.id]);
const openCreateForm = () => {
setIsCreating(true);
@@ -98,7 +109,14 @@ export function AutomationContextManagementPage() {
return;
}
if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) {
const confirmed = await confirmWithKeyboard(modalApi, {
title: `"${selectedAutomationContext.title}" Context를 삭제할까요?`,
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return;
}
@@ -122,19 +140,23 @@ export function AutomationContextManagementPage() {
if (!hasAccess) {
return (
<Card title="Context 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
/>
</Card>
<>
{modalContextHolder}
<Card title="Context 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
/>
</Card>
</>
);
}
return (
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
{modalContextHolder}
{detailMode === 'list' ? (
<Card
title="Context 관리"

View File

@@ -7,8 +7,8 @@ import {
ShrinkOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Empty, Form, Input, List, Modal, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteAutomationType,
@@ -17,6 +17,7 @@ import {
type AutomationBehaviorType,
type AutomationTypeRecord,
} from './automationTypeAccess';
import { confirmWithKeyboard } from './modalKeyboard';
import { useTokenAccess } from './tokenAccess';
import './AutomationTypeManagementPage.css';
@@ -63,6 +64,8 @@ export function AutomationTypeManagementPage() {
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationTypeFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const isPaneMaximized = maximizedPane !== 'none';
const selectedAutomationType = useMemo(
@@ -80,12 +83,20 @@ export function AutomationTypeManagementPage() {
useEffect(() => {
if (detailMode !== 'detail') {
lastHydratedFormKeyRef.current = '';
return;
}
const nextFormKey = isCreating ? '__create__' : selectedAutomationType?.id ?? '__empty__';
if (lastHydratedFormKeyRef.current === nextFormKey) {
return;
}
lastHydratedFormKeyRef.current = nextFormKey;
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
}, [detailMode, form, isCreating, selectedAutomationType]);
}, [detailMode, form, isCreating, selectedAutomationType?.id]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -150,7 +161,14 @@ export function AutomationTypeManagementPage() {
return;
}
if (!window.confirm(`"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`)) {
const confirmed = await confirmWithKeyboard(modalApi, {
title: `"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`,
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return;
}
@@ -209,14 +227,17 @@ export function AutomationTypeManagementPage() {
if (!hasAccess) {
return (
<Card title="자동화 유형 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
/>
</Card>
<>
{modalContextHolder}
<Card title="자동화 유형 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
/>
</Card>
</>
);
}
@@ -226,6 +247,7 @@ export function AutomationTypeManagementPage() {
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`}
>
{modalContextHolder}
{detailMode === 'list' ? (
<Card
title="자동화 유형 관리"

Some files were not shown because too many files have changed in this diff Show More