chore: sync backend and deployment changes

This commit is contained in:
2026-05-25 17:25:52 +09:00
parent d38d022872
commit fb5ec649cd
58 changed files with 17575 additions and 378 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

10
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v node >/dev/null 2>&1; then
if [ -s "$HOME/.nvm/nvm.sh" ]; then
. "$HOME/.nvm/nvm.sh"
fi
fi
node scripts/guard-staged-assets.mjs

8
.gitignore vendored
View File

@@ -23,6 +23,14 @@ node_modules.root-owned-backup/
.env.*
.tmp
!.env.example
tmp-*.png
tmp-*.jpg
tmp-*.jpeg
tmp-*.webp
tmp-*.gif
tmp-*.mp4
tmp-*.mov
tmp-*.webm
# etc workspace
etc/**/.env

View File

@@ -9,8 +9,8 @@
### Codex / AI 기본 규칙
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다
* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env``CAPTURE_BASE_URL=https://test.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://preview.sm-home.cloud/` 기준으로 사용**한다
* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env``CAPTURE_BASE_URL=https://preview.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다
* 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다
* `test.sm-home.cloud` nginx 프록시는 **화면 `/``5174` 앱 테스트 서버로 보내고, `/api/``/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다
* `test.sm-home.cloud``/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다
@@ -18,9 +18,13 @@
* `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
* 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다
* Git 작업을 수행하더라도 `public/assets/` 아래 대용량 리소스, `tmp-*` 캡처 파일, 임시 산출물은 기본적으로 커밋 대상에 포함하지 않는다
* 이미지/동영상/PDF 같은 바이너리 자산이 정말 필요할 때만 예외적으로 커밋하고, 그 외에는 코드 변경과 분리해 별도 확인 후 처리한다
* 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다
* 현재는 브랜치 전략보다 **로컬 실행 가능 상태 유지, 코드 수정, 문서 갱신, 메모 반영 속도**를 우선한다
* `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다
* 사용자가 **명시적으로 요청한 경우를 제외하면** 구현 편의나 상태 갱신을 이유로 `polling`, `setInterval`, 주기적 재시도 루프 같은 반복 조회 구조를 추가하거나 유지하지 않는다
* 기존 기능에 `polling`, `setInterval`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다
### 요청 해석 규칙
@@ -40,6 +44,7 @@
* 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다
* `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다
* 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다
* 임시 스크린샷, 테스트 캡처, 대용량 리소스 파일은 기본적으로 Git 커밋을 차단하고, 의도적 자산 커밋일 때만 명시적으로 예외 처리한다
* 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다
---
@@ -47,7 +52,8 @@
## Codex Live / 채팅 / 작업 메모 규칙
* `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다
* 외부 도메인 기준 동작 확인과 최종 검증은 기본적으로 `https://preview.sm-home.cloud/`를 우선 기준으로 본다
* `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인한다
* `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
* 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다
@@ -57,11 +63,21 @@
* 채팅 답변에서 링크 카드는 외부 공개 링크에만 사용하고 `[[link-card:제목|URL|버튼라벨]]` 형식을 정확히 지킨다
* 세션 리소스, 내부 문서, 코드, 로그, 테이블 파일처럼 `/api/chat/resources/...`, `/.codex_chat/...`, `/public/.codex_chat/...` 아래 내부 리소스에는 링크 카드를 사용하지 않는다
* 링크 카드 URL 안에 구분자 `|``%7C`로 인코딩하거나 `https:/api/...`처럼 잘못 줄여 쓰지 않는다
* `[[prompt:...]]` 내부의 `preview`, preview card 성격의 미리보기는 실제로 생성과 접근이 확인된 리소스에만 연결한다
* `[[prompt:...]]``preview.url`에는 로컬 파일 시스템 경로(`/home/...`, `C:\\...`)나 원본 `public/...` 경로를 직접 넣지 말고, 외부 `https://...` URL 또는 `/api/chat/resources/...`, `resource/...`, `/api/resource-manager/preview/...`처럼 실제 미리보기 가능한 경로만 사용한다
* `[[prompt:...]]`에서 내부 문서/HTML/표/산출물을 미리 보여줄 때는 링크 카드로 우회하지 말고 `preview.type:"resource"` 또는 용도에 맞는 preview 타입을 우선 사용한다
* `[[prompt:...]]`는 본문 문장 사이에 들어가더라도 prompt 컴포넌트로 안정적으로 파싱되어야 하며, HTML/문서 preview가 있는 prompt는 raw 텍스트로 남지 않게 확인한다
* prompt 안의 preview 리소스가 열리지 않을 때는 곧바로 `리소스를 못 찾겠다`고 답하지 말고, 먼저 파일 생성 여부, 세션 리소스 복사 여부, `preview.url` 문법, preview 타입 선택이 맞는지 순서대로 다시 확인한다
* 사용자가 `전체 케이스`, `모든 케이스`, `전수 검증`을 요청하면 실제 분기 함수를 직접 확인하고 `null` 반환, 기본값, 우선순위 예외까지 빠짐없이 표나 목록으로 정리한 뒤 검증했다고 답한다
* 전수 검증 요청에서 대표 예시 몇 개만 적거나 상태 enum 이름만 나열한 뒤 `전체 케이스`라고 단정하지 않는다
* HTML 미리보기 산출물은 fallback 안내문이나 앱 화면 URL로 대신하지 말고, 실제 `.html` 리소스를 만든 뒤 그 리소스가 열리는지 다시 확인하고 제공한다
* 모바일 캡처 결과나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 형식으로 제공한다
* 모바일 캡처 결과를 설명하는 본문 옆에 링크 카드를 덧붙이더라도, 같은 리소스의 `[[preview:URL]]` 표시는 생략하지 않는다
* 내부 문서성 리소스는 일반 경로나 자동 프리뷰 가능한 리소스 URL로 제공하고, 표 형태 확인이 필요하면 preview 컴포넌트에서 바로 열 수 있는 형식을 우선 사용한다
* markdown/document preview를 최대화해서 볼 때는 본문 배경과 텍스트 대비가 유지되도록 확인하고, 다크 배경 위에 검정 본문만 남는 상태로 두지 않는다
* 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
* 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다
* `/play/apps` 아래에서 실행되는 앱 화면은 기본적으로 부모 앱 헤더를 다시 노출하지 말고, 개별 앱 콘텐츠가 화면을 가득 채우는 레이아웃을 우선 적용한다
---
@@ -76,7 +92,7 @@
## 한 줄 요약
👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나
👉 외부 확인과 검증 기본 도메인은 `https://preview.sm-home.cloud/`
👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는다
👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다

View File

@@ -10,6 +10,8 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입
- `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
- 자동화 접수된 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다.
- 채팅 리소스와 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래를 사용합니다.
- `public/assets/` 아래 리소스, `tmp-*` 캡처 파일, 대용량 바이너리 파일은 기본적으로 커밋하지 않습니다.
- 저장소에는 staged 자산 차단 훅이 연결되어 있으며, 의도적인 자산 커밋이 꼭 필요할 때만 `ALLOW_ASSET_COMMIT=1 git commit ...`으로 예외 처리합니다.
## 시작하기
@@ -37,8 +39,8 @@ docker compose -f docker-compose.preview.yml up -d --build
- 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다.
- 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다.
- 운영 프록시 확인은 `https://test.sm-home.cloud/` 기준으로 유지합니다.
- 소스 변경 검증과 최종 화면 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.
- 화면 테스트, 소스 변경 검증, 최종 화면 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.
- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.
- 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다.
- 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다.
- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다.

View File

@@ -18,6 +18,9 @@ services:
NPM_CONFIG_CACHE: /home/how2ice/.npm
PORT: 5173
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100}
VITE_PUBLIC_HMR_HOST: ${VITE_PUBLIC_HMR_HOST:-preview.sm-home.cloud}
VITE_PUBLIC_HMR_PROTOCOL: ${VITE_PUBLIC_HMR_PROTOCOL:-wss}
VITE_PUBLIC_HMR_CLIENT_PORT: ${VITE_PUBLIC_HMR_CLIENT_PORT:-443}
VITE_DISABLE_APP_UPDATE: "true"
command: >
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173 --strictPort"

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

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

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

@@ -46,6 +46,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 +60,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을 다시 적어 줍니다.

File diff suppressed because one or more lines are too long

View File

@@ -42,7 +42,7 @@ 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
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

@@ -10,10 +10,14 @@ import { registerAppConfigRoutes } from './routes/app-config.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 { 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';
@@ -22,6 +26,9 @@ import { createErrorLog } from './services/error-log-service.js';
export function createApp() {
const app = Fastify({
logger: true,
routerOptions: {
maxParamLength: 20000,
},
});
app.register(cors, {
@@ -37,10 +44,14 @@ export function createApp() {
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(registerReaderRoutes);
app.register(registerResourceManagerRoutes);
app.register(registerSharedResourceTokenRoutes);
app.register(registerServerCommandRoutes);
app.register(registerTextMemoRoutes);
app.register(registerVisitorHistoryRoutes);

View File

@@ -70,8 +70,11 @@ 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'),

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,124 @@ 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';
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 +170,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 +224,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 +363,67 @@ 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)
: 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);
})();
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.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

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveStaticContentType } from './chat.js';
import { resolveStaticContentType, shouldAutoCompleteShareReplyParentVerification } from './chat.js';
test('resolveStaticContentType returns html content type for chat resource html files', () => {
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
@@ -11,3 +11,41 @@ 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('shouldAutoCompleteShareReplyParentVerification only completes answered requests that are not already verified', () => {
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: 101,
responseText: '',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: null,
responseText: '답변 본문',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: null,
responseText: '',
manualVerificationCompletedAt: null,
}),
false,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: 102,
responseText: '답변 본문',
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
}),
false,
);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
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');
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();
}
});

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

@@ -39,10 +39,16 @@ function getImmediateRestartBlockInfo(
return null;
}
if (key === 'work-server' && automationPendingCount > 0) {
if (key === 'work-server') {
const pendingCount = codexPendingCount + automationPendingCount;
if (pendingCount === 0) {
return null;
}
return {
pendingCount: automationPendingCount,
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
pendingCount,
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
};
}

View File

@@ -0,0 +1,337 @@
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';
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);
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);
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);
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);
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);
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);
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);
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

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

View File

@@ -71,7 +71,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/';
@@ -84,6 +84,17 @@ 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 buildResourceManagerPreviewUrl(value: string) {
const normalized = normalizeText(value).replace(/\\/g, '/');
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
@@ -109,7 +120,7 @@ function buildResourceManagerPreviewUrl(value: string) {
}
function normalizeUrl(value: string) {
const normalized = normalizeText(value);
const normalized = unwrapMarkdownLinkTarget(value);
if (!normalized) {
return '';
@@ -420,7 +431,7 @@ 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,

View File

@@ -2,8 +2,17 @@ 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,
@@ -39,6 +48,55 @@ 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('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => {
assert.deepEqual(
selectStaleOfflineNotificationClientIds(
@@ -72,6 +130,23 @@ 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('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
assert.equal(
buildChatConversationRequestPatchFromMessage({
@@ -84,10 +159,118 @@ 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',
},
'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.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
});
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 +313,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({
@@ -321,12 +548,17 @@ 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,
status: 'queued',
statusMessage: '대기열 1건',
userMessageId: 11,
userText: '다음 요청',
responseMessageId: null,
responseText: '',
usageSnapshot: null,
totalTokens: null,
hasResponse: false,
canDelete: false,
createdAt: '2026-05-11T00:00:00.000Z',
@@ -344,18 +576,22 @@ 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건',
userMessageId: 11,
userText: '다음 요청',
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: false,
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:30.000Z',
answeredAt: null,
terminalAt: null,
},
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,
},
);
});

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,10 +18,15 @@ import {
fitActivityLogLines,
isChatClientActivelyViewing,
isAutomationRegistrationCountRequest,
buildParticipantRequestInput,
resolveCodexExecutionStages,
resolveCodexParticipantsForExecution,
resolveResponseTimestamp,
resolveChatContextAppOrigin,
resolveChatContextAppDomain,
rewriteCodexOutputWithChatResources,
summarizeActivityProgressLine,
shouldAutoCompleteReplyParentVerification,
shouldSendOfflineChatNotification,
shouldUseAgenticCodexReply,
shouldUseTemplateMacroReply,
@@ -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'],
);
});
@@ -81,6 +154,58 @@ test('shouldSendOfflineChatNotification blocks chat push when app setting disabl
);
});
test('shouldAutoCompleteReplyParentVerification only completes answered composer followups that are not already verified', () => {
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: 101,
responseText: '',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: null,
responseText: '답변 본문',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'prompt',
responseMessageId: 101,
responseText: '답변 본문',
manualVerificationCompletedAt: null,
}),
false,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: null,
responseText: '',
manualVerificationCompletedAt: null,
}),
false,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: 102,
responseText: '답변 본문',
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
}),
false,
);
});
test('resolveChatContextAppOrigin returns normalized origin from session page url', () => {
assert.equal(
resolveChatContextAppOrigin({
@@ -92,6 +217,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 +412,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 +506,283 @@ test('buildAgenticCodexPrompt keeps the chat type label provided by the client c
assert.doesNotMatch(prompt, /- label: 코드 수정/);
});
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 +816,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 +1038,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 +1280,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(
@@ -1154,6 +1651,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 +1787,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

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

@@ -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(),
@@ -84,6 +85,34 @@ function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
return [...new Set((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) {
return [...new Set((targetAppOrigins ?? []).map((value) => normalizeAppOrigin(value)).filter(Boolean))];
}
@@ -92,12 +121,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
function isAllowedTargetClientId(deviceId: string, targetClientIds: string[]) {
function isAllowedTargetClientId(
target: {
deviceId?: string;
clientId?: string;
},
targetClientIds: string[],
) {
if (targetClientIds.length === 0) {
return true;
}
return Boolean(deviceId) && targetClientIds.includes(deviceId);
return [target.deviceId, target.clientId]
.map((value) => String(value ?? '').trim())
.filter(Boolean)
.some((value) => targetClientIds.includes(value));
}
function normalizeAppOrigin(value: unknown) {
@@ -218,6 +256,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 +438,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 +455,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()],
@@ -641,6 +706,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 +723,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 +735,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 +744,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 +804,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) : '',
}));
@@ -870,7 +949,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
.filter(
(row) =>
row.allowed &&
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
isAllowedTargetClientId({ deviceId: row.deviceId }, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token);
@@ -940,15 +1019,16 @@ 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) &&
isAllowedTargetClientId(row, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
@@ -1036,6 +1116,7 @@ export async function sendNotifications(
disableWebPush?: boolean;
},
) {
const resolvedPayload = withInferredNotificationOriginData(payload);
const [ios, web] = await Promise.all([
options?.disableIos
? Promise.resolve({
@@ -1046,7 +1127,7 @@ export async function sendNotifications(
failedCount: 0,
invalidTokens: [],
})
: sendIosNotifications(payload),
: sendIosNotifications(resolvedPayload),
options?.disableWebPush
? Promise.resolve({
ok: true,
@@ -1056,7 +1137,7 @@ export async function sendNotifications(
failedCount: 0,
invalidEndpoints: [],
})
: sendWebPushNotifications(payload),
: sendWebPushNotifications(resolvedPayload),
]);
const aggregate = resolveNotificationAggregateResult(

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;
@@ -133,6 +139,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':
@@ -301,10 +315,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 +346,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 +404,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;
@@ -609,8 +617,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

@@ -69,8 +69,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 --force-recreate --no-deps "\$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 >/);
@@ -301,6 +305,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 +314,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 +329,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 +341,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 +358,49 @@ 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 });
}
});

View File

@@ -32,6 +32,7 @@ type ServerDefinition = {
commandWorkingDirectory: string;
commandEnvironment: Record<string, string>;
restartStrategy: 'wait' | 'deferred';
deferredResponseMode?: 'wait-for-result' | 'accept-immediately';
};
export type ServerCommandSnapshot = {
@@ -135,11 +136,26 @@ 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;
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 +211,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> {
@@ -575,8 +597,10 @@ 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 [
{
@@ -585,11 +609,11 @@ function getServerDefinitions(): ServerDefinition[] {
summary: '메인 프로젝트의 테스트 앱 컨테이너',
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 +631,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 +650,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 +663,7 @@ function getServerDefinitions(): ServerDefinition[] {
SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-prod',
},
restartStrategy: 'deferred',
deferredResponseMode: 'wait-for-result',
},
{
key: 'work-server',
@@ -650,12 +675,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 +693,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',
},
];
}
@@ -930,6 +957,14 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
});
});
if (definition.deferredResponseMode === 'accept-immediately') {
return {
server: buildAcceptedRestartSnapshot(definition),
commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
restartState: 'accepted',
};
}
const commandOutput = await waitForDeferredRestartResult(definition, statusPath, logPath);
return {

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

@@ -868,6 +868,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,10 +907,10 @@ 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) {

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,363 @@
import { db } from '../db/client.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());
});
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);
});
}
}
}
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)),
);
}
async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) {
await ensureTokenSettingsTable();
const nextItems = sanitizeTokenSettings(items);
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,
})),
);
}
});
return nextItems;
}
export async function getTokenSettingsConfig() {
return readTokenSettingsFromTable();
}
export async function upsertTokenSettingsConfig(items: Partial<TokenSettingRecord>[] | null | undefined) {
return replaceTokenSettingsInTable(sanitizeTokenSettings(items));
}
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()) {

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

@@ -54,10 +54,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",

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": "/",
"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"
}
]
}

95
scripts/guard-staged-assets.mjs Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { statSync } from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const allowAssetCommit = process.env.ALLOW_ASSET_COMMIT === '1';
const maxBinarySizeBytes = 5 * 1024 * 1024;
const tmpCapturePattern = /^tmp-.*\.(png|jpe?g|webp|gif|mp4|mov|webm)$/i;
const binaryAssetPattern = /\.(png|jpe?g|webp|gif|svg|mp4|mov|webm|pdf|zip|7z)$/i;
const blockedAssetPrefixes = ['public/assets/'];
function getStagedPaths() {
const output = execFileSync(
'git',
['diff', '--cached', '--name-only', '--diff-filter=ACMR'],
{ cwd: repoRoot, encoding: 'utf8' }
);
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function toDisplaySize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function isBlockedAssetPath(filePath) {
return blockedAssetPrefixes.some((prefix) => filePath.startsWith(prefix));
}
function main() {
const stagedPaths = getStagedPaths();
const violations = [];
for (const relativePath of stagedPaths) {
const absolutePath = path.join(repoRoot, relativePath);
let stats;
try {
stats = statSync(absolutePath);
} catch {
continue;
}
if (!stats.isFile()) continue;
const baseName = path.basename(relativePath);
const isTmpCapture = tmpCapturePattern.test(baseName);
const isBinaryAsset = binaryAssetPattern.test(relativePath);
const isOversizedBinary = isBinaryAsset && stats.size > maxBinarySizeBytes;
const isBlockedAsset = isBinaryAsset && isBlockedAssetPath(relativePath);
if (!isTmpCapture && !isOversizedBinary && !isBlockedAsset) continue;
const reasons = [];
if (isTmpCapture) reasons.push('temporary capture file');
if (isBlockedAsset) reasons.push('asset path under public/assets');
if (isOversizedBinary) {
reasons.push(`binary file exceeds ${toDisplaySize(maxBinarySizeBytes)}`);
}
violations.push({
relativePath,
size: stats.size,
reasons,
});
}
if (violations.length === 0 || allowAssetCommit) {
return;
}
console.error('');
console.error('Blocked commit: staged asset files need an explicit override.');
console.error('Set ALLOW_ASSET_COMMIT=1 only when the asset commit is intentional.');
console.error('');
for (const violation of violations) {
console.error(
`- ${violation.relativePath} (${toDisplaySize(violation.size)}): ${violation.reasons.join(', ')}`
);
}
console.error('');
process.exit(1);
}
main();

View File

@@ -59,6 +59,11 @@ const CODEX_LIVE_FINISHED_RETENTION_MS = Math.max(
const activeCodexExecutions = new Map();
const recentCodexExecutions = new Map();
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 {
requestId,
@@ -134,6 +139,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 +740,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 +772,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'],
@@ -703,6 +803,7 @@ async function runCodexLiveExecution(payload, response) {
broadcastCodexExecutionEvent(executionRecord, {
type: 'started',
pid: child.pid ?? null,
model: codexModel,
configuredIdleTimeoutSeconds: Math.round(configuredIdleTimeoutMs / 1000),
configuredMaxExecutionSeconds: Math.round(configuredMaxExecutionMs / 1000),
});
@@ -785,16 +886,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 +894,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) => {
@@ -876,6 +986,18 @@ async function runCodexLiveExecution(payload, response) {
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',

View File

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

View File

@@ -74,7 +74,13 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
const controller = new AbortController();
const abortFromExternalSignal = () => controller.abort(init?.signal?.reason);
const hasExternalSignal = Boolean(init?.signal);
const timeoutId = globalThis.setTimeout(() => controller.abort(), 8000);
const extendedInit = init as (RequestInit & { timeoutMs?: number }) | undefined;
const timeoutMs = typeof init?.keepalive === 'boolean'
? 8000
: typeof extendedInit?.timeoutMs === 'number'
? Math.max(1000, extendedInit.timeoutMs ?? 8000)
: 8000;
const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs);
if (init?.signal) {
if (init.signal.aborted) {
@@ -163,7 +169,17 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
throw new ServerCommandApiError(text || '서버 명령 요청에 실패했습니다.', response.status);
}
return response.json() as Promise<T>;
const responseText = await response.text();
if (!responseText.trim()) {
throw new ServerCommandApiError('서버 명령 응답이 비어 있습니다.', 502);
}
try {
return JSON.parse(responseText) as T;
} catch {
throw new ServerCommandApiError('서버 명령 응답을 해석하지 못했습니다.', 502);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
@@ -238,7 +254,19 @@ function normalizeServerCommandItem(value: unknown): ServerCommandItem {
function extractServerCommandItems(response: unknown) {
if (Array.isArray(response)) {
return response.map((item) => normalizeServerCommandItem(item));
const normalizedItems = response.flatMap((item) => {
try {
return [normalizeServerCommandItem(item)];
} catch {
return [];
}
});
if (normalizedItems.length === 0) {
throw new Error('서버 명령 목록을 읽지 못했습니다.');
}
return normalizedItems;
}
if (!response || typeof response !== 'object') {
@@ -247,21 +275,51 @@ function extractServerCommandItems(response: unknown) {
const payload = response as {
items?: unknown;
data?: { items?: unknown } | unknown[];
data?: { items?: unknown; data?: { items?: unknown } | unknown[] } | unknown[];
result?: { items?: unknown; data?: { items?: unknown } | unknown[] } | unknown[];
};
const payloadData =
payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) ? payload.data : null;
const payloadResult =
payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? payload.result : null;
const nestedPayloadData =
payloadData?.data && typeof payloadData.data === 'object' && !Array.isArray(payloadData.data) ? payloadData.data : null;
const nestedPayloadResult =
payloadResult?.data && typeof payloadResult.data === 'object' && !Array.isArray(payloadResult.data) ? payloadResult.data : null;
const items = Array.isArray(payload.items)
? payload.items
: payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data) && Array.isArray(payload.data.items)
? payload.data.items
: Array.isArray(payload.data)
? payload.data
: null;
: Array.isArray(payloadData?.items)
? payloadData.items
: Array.isArray(payloadResult?.items)
? payloadResult.items
: Array.isArray(nestedPayloadData?.items)
? nestedPayloadData.items
: Array.isArray(nestedPayloadResult?.items)
? nestedPayloadResult.items
: Array.isArray(payload.data)
? payload.data
: Array.isArray(payload.result)
? payload.result
: null;
if (!items) {
throw new Error('서버 명령 목록을 읽지 못했습니다.');
}
return items.map((item) => normalizeServerCommandItem(item));
const normalizedItems = items.flatMap((item) => {
try {
return [normalizeServerCommandItem(item)];
} catch {
return [];
}
});
if (normalizedItems.length === 0) {
throw new Error('서버 명령 목록을 읽지 못했습니다.');
}
return normalizedItems;
}
function extractServerCommandActionResult(response: unknown): ServerCommandActionResult {
@@ -464,6 +522,7 @@ export async function restartServerCommand(key: ServerCommandKey, options?: { si
method: 'POST',
body: JSON.stringify({}),
signal: options?.signal,
timeoutMs: key === 'test' || key === 'rel' ? 30000 : 12000,
});
return extractServerCommandActionResult(response);

View File

@@ -82,6 +82,42 @@ function createDevAppUpdatePlugin() {
};
}
function createDevServiceWorkerPlugin() {
return {
name: 'dev-service-worker-entry',
apply: 'serve' as const,
configureServer(server: ViteDevServer) {
server.middlewares.use((request, response, next) => {
const requestUrl = request.url?.trim() ?? '';
const requestPath = requestUrl.split('?', 1)[0];
if (requestPath !== '/sw.js' && requestPath !== '/dev-sw.js') {
next();
return;
}
void server
.transformRequest('/src/sw.js')
.then((result) => {
if (!result?.code) {
response.statusCode = 404;
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.end('Not Found');
return;
}
response.statusCode = 200;
response.setHeader('Content-Type', 'application/javascript; charset=utf-8');
response.setHeader('Cache-Control', 'no-store');
response.setHeader('Service-Worker-Allowed', '/');
response.end(result.code);
})
.catch(next);
});
},
};
}
function copyPublicAssetsExceptCodexChat() {
let resolvedConfig: ResolvedConfig | null = null;
@@ -155,7 +191,7 @@ export default defineConfig({
protocol: VITE_PUBLIC_HMR_PROTOCOL as 'ws' | 'wss',
clientPort: VITE_PUBLIC_HMR_CLIENT_PORT,
},
allowedHosts: ['sm-home.cloud', 'test.sm-home.cloud', 'rel.sm-home.cloud'],
allowedHosts: ['sm-home.cloud', 'test.sm-home.cloud', 'preview.sm-home.cloud', 'rel.sm-home.cloud'],
watch: {
ignored: (watchedPath) => shouldIgnoreDevUpdatePath(watchedPath),
},
@@ -178,28 +214,31 @@ export default defineConfig({
preview: {
host: '0.0.0.0',
port: 5173,
allowedHosts: ['sm-home.cloud', 'test.sm-home.cloud', 'rel.sm-home.cloud'],
allowedHosts: ['sm-home.cloud', 'test.sm-home.cloud', 'preview.sm-home.cloud', 'rel.sm-home.cloud'],
},
plugins: [
react(),
createDevAppUpdatePlugin(),
createDevServiceWorkerPlugin(),
copyPublicAssetsExceptCodexChat(),
!VITE_DISABLE_PWA &&
VitePWA({
injectRegister: null,
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.js',
registerType: 'prompt',
includeAssets: ['favicon.svg', 'apple-touch-icon.svg'],
workbox: {
globPatterns: ['**/*.{js,css,html,ico,svg,woff2,webmanifest}'],
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
},
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
globIgnores: ['**/.codex_chat/**'],
globIgnores: ['**/.codex_chat/**', '**/*.png', '**/*.webp'],
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
},
manifest: {
id: '/',
name: 'AI Code App',
short_name: 'AI Code App',
description: 'Ant Design 기반 UI 샘플과 문서를 확인하는 AI Code App',
@@ -222,9 +261,24 @@ export default defineConfig({
purpose: 'any maskable',
},
],
shortcuts: [
{
name: 'E-Reader',
short_name: 'E-Reader',
description: '인터넷 기사와 웹 콘텐츠를 전자책처럼 읽습니다.',
url: '/play/apps?app=e-reader',
icons: [
{
src: '/pwa-192x192.svg',
sizes: '192x192',
type: 'image/svg+xml',
},
],
},
],
},
devOptions: {
enabled: true,
enabled: false,
},
}),
].filter(Boolean),