chore: sync backend and deployment changes
This commit is contained in:
@@ -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
10
.githooks/pre-commit
Executable 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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -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 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
|
||||
|
||||
|
||||
@@ -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`만 재기동합니다.
|
||||
- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다.
|
||||
|
||||
@@ -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"
|
||||
|
||||
34
docs/worklogs/2026-05-15.md
Normal file
34
docs/worklogs/2026-05-15.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 2026-05-15 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 화면 캡처 추가 예정
|
||||
|
||||
## 스크린샷
|
||||
|
||||

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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `path/to/file.tsx`
|
||||
|
||||
- 변경 목적과 핵심 수정 내용을 한 줄로 정리
|
||||
|
||||
```diff
|
||||
# 이 파일의 핵심 diff
|
||||
- before
|
||||
+ after
|
||||
```
|
||||
|
||||
### 파일 2: `path/to/another-file.ts`
|
||||
|
||||
- 필요 없으면 이 섹션은 삭제
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
-
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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을 다시 적어 줍니다.
|
||||
|
||||
|
||||
3692
etc/servers/work-server/data/e-reader-library.json
Normal file
3692
etc/servers/work-server/data/e-reader-library.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
|
||||
@@ -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
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
File diff suppressed because it is too large
Load Diff
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
57
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
357
etc/servers/work-server/src/routes/test-app.ts
Normal file
357
etc/servers/work-server/src/routes/test-app.ts
Normal 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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 기본 문맥으로 섞지 않습니다.';
|
||||
|
||||
@@ -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 체크리스트 실행';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>);
|
||||
}
|
||||
@@ -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
42
package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
25
public/e-reader.webmanifest
Normal file
25
public/e-reader.webmanifest
Normal 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
95
scripts/guard-staged-assets.mjs
Executable 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();
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user