Compare commits
28 Commits
hotfix/gua
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6e73dbd5 | |||
| 737ab0a34a | |||
| ffbdbf46b6 | |||
| 5b3e70910c | |||
| 262ce4b627 | |||
| b242d91ecb | |||
| 1e7212b862 | |||
| 753fd423db | |||
| c7f29bdc33 | |||
| a97d933cff | |||
| b1bec9cb6f | |||
| bb275c0534 | |||
| 82c46f4be4 | |||
| 983887dc05 | |||
| e195ac8088 | |||
| 10805d242e | |||
| e8a628ac34 | |||
| 58c5a7cfee | |||
| 26220577fc | |||
| 4984d74d39 | |||
| 215648bd8d | |||
| 4a88d3f430 | |||
| 7e9c3bd097 | |||
| 4c4b3c8d2c | |||
| c1d0f4c1db | |||
| 51e0099bea | |||
| f59522ffc4 | |||
| fb5ec649cd |
@@ -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
|
||||
|
||||
1
.tmp-chatshare-full.json
Normal file
1
.tmp-chatshare-full.json
Normal file
File diff suppressed because one or more lines are too long
1
.tmp-chatshare-initial.json
Normal file
1
.tmp-chatshare-initial.json
Normal file
File diff suppressed because one or more lines are too long
26
AGENTS.md
26
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,15 @@
|
||||
* `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 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`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다
|
||||
* `work-server` 재기동이나 배포 절차는 **기존 연결을 끊는 단일 컨테이너 재시작 방식이 아니라, blue/green 슬롯 전환 기반 무중단 절차를 기본 규칙으로 사용**한다
|
||||
* `work-server` 관련 문서, 스크립트, 운영 안내를 수정할 때는 **비활성 슬롯 기동 → health 확인 → 프록시 전환 → 이전 슬롯 정리** 순서를 유지하고, 연결이 끊기는 재시작을 기본 절차처럼 적지 않는다
|
||||
|
||||
### 요청 해석 규칙
|
||||
|
||||
@@ -40,6 +46,7 @@
|
||||
* 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다
|
||||
* `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다
|
||||
* 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다
|
||||
* 임시 스크린샷, 테스트 캡처, 대용량 리소스 파일은 기본적으로 Git 커밋을 차단하고, 의도적 자산 커밋일 때만 명시적으로 예외 처리한다
|
||||
* 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다
|
||||
|
||||
---
|
||||
@@ -47,7 +54,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 +65,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 +94,7 @@
|
||||
## 한 줄 요약
|
||||
|
||||
👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다
|
||||
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다
|
||||
👉 외부 확인과 검증 기본 도메인은 `https://preview.sm-home.cloud/`다
|
||||
👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는다
|
||||
👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -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 ...`으로 예외 처리합니다.
|
||||
|
||||
## 시작하기
|
||||
|
||||
@@ -28,8 +30,8 @@ docker compose -f docker-compose.preview.yml up -d --build
|
||||
|
||||
- 로컬 preview 컨테이너 접속 주소: `http://127.0.0.1:4173`
|
||||
- 외부 검증 도메인: `https://preview.sm-home.cloud/`
|
||||
- preview 컨테이너는 현재 프로젝트 루트를 바인드 마운트하고 `vite build --watch`로 정적 산출물을 자동 재빌드합니다.
|
||||
- 따라서 `https://preview.sm-home.cloud/`에서는 Vite HMR처럼 즉시 DOM이 바뀌지는 않지만, 소스 저장 후 재빌드가 끝나면 브라우저 새로고침만으로 최신 화면을 확인할 수 있습니다.
|
||||
- preview 컨테이너는 현재 프로젝트 루트를 바인드 마운트하고 `vite dev` 서버로 실행됩니다.
|
||||
- `https://preview.sm-home.cloud/`는 preview 컨테이너의 Vite dev server를 기준으로 사용하며, HMR이 연결되면 저장 후 새로고침 없이 변경이 반영됩니다.
|
||||
- API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다.
|
||||
- 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다.
|
||||
|
||||
@@ -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`만 재기동합니다.
|
||||
- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다.
|
||||
|
||||
@@ -2,12 +2,14 @@ services:
|
||||
preview-app:
|
||||
container_name: ai-code-app-preview
|
||||
image: node:${NODE_VERSION:-22.22.2}-bookworm
|
||||
user: "0:0"
|
||||
user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "${PREVIEW_APP_PORT:-4173}:5173"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- preview-app-hidden-dotdocker:/app/.docker
|
||||
- preview-app-hidden-etc-servers:/app/etc/servers
|
||||
- ./.docker/preview-app/node_modules:/app/node_modules
|
||||
- ./.docker/preview-app/home:/home/how2ice
|
||||
networks:
|
||||
@@ -19,10 +21,18 @@ services:
|
||||
PORT: 5173
|
||||
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100}
|
||||
VITE_DISABLE_APP_UPDATE: "true"
|
||||
VITE_PUBLIC_HMR_HOST: preview.sm-home.cloud
|
||||
VITE_PUBLIC_HMR_PROTOCOL: wss
|
||||
VITE_PUBLIC_HMR_CLIENT_PORT: 443
|
||||
VITE_DISABLE_PWA: "true"
|
||||
command: >
|
||||
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173 --strictPort"
|
||||
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
preview-app-hidden-dotdocker:
|
||||
preview-app-hidden-etc-servers:
|
||||
|
||||
networks:
|
||||
work-backend:
|
||||
external: true
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
-
|
||||
51
etc/commands/server-command/deploy-test.sh
Normal file
51
etc/commands/server-command/deploy-test.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||
REPO_ROOT="${REPO_ROOT:-$MAIN_PROJECT_ROOT}"
|
||||
TEST_DEPLOY_GIT_REMOTE="${TEST_DEPLOY_GIT_REMOTE:-origin}"
|
||||
TEST_DEPLOY_GIT_BRANCH="${TEST_DEPLOY_GIT_BRANCH:-main}"
|
||||
TEST_BUILD_COMMAND="${TEST_BUILD_COMMAND:-npm run build:test-app}"
|
||||
TEST_SERVER_RESTART_SCRIPT="${TEST_SERVER_RESTART_SCRIPT:-$SCRIPT_DIR/restart-test.sh}"
|
||||
TEST_DEPLOY_COMMIT_MESSAGE="${TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot}"
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "git CLI not found" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "npm CLI not found" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
||||
|
||||
if [ "$CURRENT_BRANCH" != "$TEST_DEPLOY_GIT_BRANCH" ]; then
|
||||
echo "expected branch ${TEST_DEPLOY_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::step::commit-main-worktree"
|
||||
git add -A -- . ':(exclude).server-command-test-app-built-at' ':(exclude,glob)tmp-*' ':(exclude,glob)tmp-verification/**'
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "no commit needed; main worktree already committed"
|
||||
else
|
||||
echo "staged files for TEST deploy commit:"
|
||||
git diff --cached --name-status
|
||||
git commit -m "$TEST_DEPLOY_COMMIT_MESSAGE"
|
||||
fi
|
||||
|
||||
echo "::step::push-origin-main"
|
||||
git push "$TEST_DEPLOY_GIT_REMOTE" "$TEST_DEPLOY_GIT_BRANCH"
|
||||
|
||||
echo "::step::build-test-app"
|
||||
sh -lc "$TEST_BUILD_COMMAND"
|
||||
|
||||
echo "::step::deploy-test-server"
|
||||
REPO_ROOT="$REPO_ROOT" sh "$TEST_SERVER_RESTART_SCRIPT"
|
||||
@@ -9,21 +9,28 @@ SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-
|
||||
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
SERVER_COMMAND_TEST_GIT_REMOTE="${SERVER_COMMAND_TEST_GIT_REMOTE:-origin}"
|
||||
SERVER_COMMAND_TEST_GIT_BRANCH="${SERVER_COMMAND_TEST_GIT_BRANCH:-main}"
|
||||
SERVER_COMMAND_TEST_GIT_SYNC="${SERVER_COMMAND_TEST_GIT_SYNC:-false}"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
if [ "$SERVER_COMMAND_TEST_GIT_SYNC" = "true" ]; then
|
||||
git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
|
||||
if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then
|
||||
git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \
|
||||
|| git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then
|
||||
git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \
|
||||
|| git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
fi
|
||||
|
||||
git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
fi
|
||||
|
||||
git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
TEST_BUILD_STAMP_FILE="${TEST_BUILD_STAMP_FILE:-$MAIN_PROJECT_ROOT/.server-command-test-app-built-at}"
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVER_COMMAND_SERVICE"
|
||||
docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --build --no-deps --force-recreate "$SERVER_COMMAND_SERVICE"
|
||||
date -Iseconds > "$TEST_BUILD_STAMP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||
|
||||
479
etc/commands/server-command/restart-work-server.sh
Normal file → Executable file
479
etc/commands/server-command/restart-work-server.sh
Normal file → Executable file
@@ -5,7 +5,484 @@ set -eu
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
|
||||
PROXY_SERVICE="${WORK_SERVER_PROXY_SERVICE:-work-server}"
|
||||
PROXY_CONTAINER="${WORK_SERVER_PROXY_CONTAINER:-work-server}"
|
||||
BLUE_SERVICE="${WORK_SERVER_BLUE_SERVICE:-work-server-blue}"
|
||||
GREEN_SERVICE="${WORK_SERVER_GREEN_SERVICE:-work-server-green}"
|
||||
BLUE_CONTAINER="${WORK_SERVER_BLUE_CONTAINER:-work-server-blue}"
|
||||
GREEN_CONTAINER="${WORK_SERVER_GREEN_CONTAINER:-work-server-green}"
|
||||
ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/active-slot}"
|
||||
PROXY_CONFIG_FILE="${WORK_SERVER_PROXY_CONFIG_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/proxy/default.conf}"
|
||||
HEALTH_ENDPOINT="${WORK_SERVER_HEALTH_ENDPOINT:-http://127.0.0.1:3100/health}"
|
||||
RUNTIME_ENDPOINT="${WORK_SERVER_RUNTIME_ENDPOINT:-http://127.0.0.1:3100/api/runtime}"
|
||||
RECOVERY_ENDPOINT="${WORK_SERVER_RECOVERY_ENDPOINT:-http://127.0.0.1:3100/api/runtime/recover-interrupted-chat}"
|
||||
PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS="${WORK_SERVER_PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS:-900}"
|
||||
LOCK_FILE="${WORK_SERVER_RESTART_LOCK_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/restart-in-progress.json}"
|
||||
DEPLOY_STATE_FILE="${WORK_SERVER_DEPLOY_STATE_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/deployment-state.json}"
|
||||
DEPLOY_FINISHED="false"
|
||||
LAST_DEPLOY_ERROR=""
|
||||
LAST_DEPLOY_LOG=""
|
||||
PREVIOUS_ACTIVE_COUNT=""
|
||||
PREVIOUS_QUEUED_COUNT=""
|
||||
RECOVERED_SESSION_COUNT=""
|
||||
RECOVERED_RESTARTED_COUNT=""
|
||||
RECOVERED_REQUEUED_COUNT=""
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server
|
||||
mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")" "$(dirname "$LOCK_FILE")" "$(dirname "$DEPLOY_STATE_FILE")"
|
||||
write_deploy_state() {
|
||||
DEPLOY_STATUS="$1"
|
||||
DEPLOY_PHASE="$2"
|
||||
DEPLOY_SUMMARY="$3"
|
||||
DEPLOY_STEP_KEY="${4:-}"
|
||||
DEPLOY_STEP_STATUS="${5:-}"
|
||||
DEPLOY_STEP_DETAIL="${6:-}"
|
||||
DEPLOY_LAST_ERROR="${7:-}"
|
||||
DEPLOY_LOG_EXCERPT="${8:-}"
|
||||
DEPLOY_ACTIVE_SLOT_VALUE="${ACTIVE_SLOT:-}"
|
||||
DEPLOY_TARGET_SLOT_VALUE="${TARGET_SLOT:-}"
|
||||
DEPLOY_PREVIOUS_SLOT_VALUE="${PREVIOUS_SLOT:-}"
|
||||
DEPLOY_TARGET_CONTAINER_VALUE="${TARGET_CONTAINER:-}"
|
||||
DEPLOY_PREVIOUS_CONTAINER_VALUE="${PREVIOUS_CONTAINER:-}"
|
||||
DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE="${PREVIOUS_ACTIVE_COUNT:-}"
|
||||
DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE="${PREVIOUS_QUEUED_COUNT:-}"
|
||||
DEPLOY_RECOVERED_SESSION_COUNT_VALUE="${RECOVERED_SESSION_COUNT:-}"
|
||||
DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE="${RECOVERED_RESTARTED_COUNT:-}"
|
||||
DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE="${RECOVERED_REQUEUED_COUNT:-}"
|
||||
export \
|
||||
DEPLOY_STATUS \
|
||||
DEPLOY_PHASE \
|
||||
DEPLOY_SUMMARY \
|
||||
DEPLOY_STEP_KEY \
|
||||
DEPLOY_STEP_STATUS \
|
||||
DEPLOY_STEP_DETAIL \
|
||||
DEPLOY_LAST_ERROR \
|
||||
DEPLOY_LOG_EXCERPT \
|
||||
DEPLOY_ACTIVE_SLOT_VALUE \
|
||||
DEPLOY_TARGET_SLOT_VALUE \
|
||||
DEPLOY_PREVIOUS_SLOT_VALUE \
|
||||
DEPLOY_TARGET_CONTAINER_VALUE \
|
||||
DEPLOY_PREVIOUS_CONTAINER_VALUE \
|
||||
DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE \
|
||||
DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE \
|
||||
DEPLOY_RECOVERED_SESSION_COUNT_VALUE \
|
||||
DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE \
|
||||
DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE
|
||||
node - "$DEPLOY_STATE_FILE" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const filePath = process.argv[2];
|
||||
const env = process.env;
|
||||
const stepKeys = [
|
||||
'build-target-slot',
|
||||
'verify-target-health',
|
||||
'switch-proxy',
|
||||
'drain-previous-slot',
|
||||
'rebuild-previous-slot',
|
||||
'recover-interrupted-chat',
|
||||
];
|
||||
const readJson = () => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const parseIso = (value) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
};
|
||||
const parseSlot = (value) => (value === 'blue' || value === 'green' ? value : null);
|
||||
const parseCount = (value) => {
|
||||
if (value == null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
const current = readJson() || {};
|
||||
const shouldResetForNewRun =
|
||||
env.DEPLOY_STATUS === 'running'
|
||||
&& env.DEPLOY_PHASE === 'build-target-slot'
|
||||
&& !env.DEPLOY_STEP_KEY;
|
||||
const now = new Date().toISOString();
|
||||
const stepsByKey = new Map();
|
||||
for (const stepKey of stepKeys) {
|
||||
const existing = !shouldResetForNewRun && Array.isArray(current.steps)
|
||||
? current.steps.find((item) => item && item.key === stepKey)
|
||||
: null;
|
||||
stepsByKey.set(stepKey, {
|
||||
key: stepKey,
|
||||
status:
|
||||
existing?.status === 'running' || existing?.status === 'completed' || existing?.status === 'failed'
|
||||
? existing.status
|
||||
: 'pending',
|
||||
detail: typeof existing?.detail === 'string' ? existing.detail : null,
|
||||
updatedAt: parseIso(existing?.updatedAt) || null,
|
||||
});
|
||||
}
|
||||
if (env.DEPLOY_STEP_KEY && stepsByKey.has(env.DEPLOY_STEP_KEY)) {
|
||||
const target = stepsByKey.get(env.DEPLOY_STEP_KEY);
|
||||
target.status =
|
||||
env.DEPLOY_STEP_STATUS === 'running'
|
||||
|| env.DEPLOY_STEP_STATUS === 'completed'
|
||||
|| env.DEPLOY_STEP_STATUS === 'failed'
|
||||
? env.DEPLOY_STEP_STATUS
|
||||
: 'pending';
|
||||
target.detail = env.DEPLOY_STEP_DETAIL || target.detail || null;
|
||||
target.updatedAt = now;
|
||||
}
|
||||
const payload = {
|
||||
status:
|
||||
env.DEPLOY_STATUS === 'running' || env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed'
|
||||
? env.DEPLOY_STATUS
|
||||
: 'idle',
|
||||
phase:
|
||||
env.DEPLOY_PHASE === 'build-target-slot'
|
||||
|| env.DEPLOY_PHASE === 'verify-target-health'
|
||||
|| env.DEPLOY_PHASE === 'switch-proxy'
|
||||
|| env.DEPLOY_PHASE === 'drain-previous-slot'
|
||||
|| env.DEPLOY_PHASE === 'rebuild-previous-slot'
|
||||
|| env.DEPLOY_PHASE === 'recover-interrupted-chat'
|
||||
|| env.DEPLOY_PHASE === 'completed'
|
||||
|| env.DEPLOY_PHASE === 'failed'
|
||||
? env.DEPLOY_PHASE
|
||||
: 'idle',
|
||||
summary: env.DEPLOY_SUMMARY || (!shouldResetForNewRun ? current.summary : null) || null,
|
||||
startedAt: shouldResetForNewRun ? now : parseIso(current.startedAt) || now,
|
||||
updatedAt: now,
|
||||
completedAt:
|
||||
shouldResetForNewRun
|
||||
? null
|
||||
: env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed'
|
||||
? now
|
||||
: parseIso(current.completedAt),
|
||||
activeSlot: parseSlot(env.DEPLOY_ACTIVE_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.activeSlot) : null),
|
||||
targetSlot: parseSlot(env.DEPLOY_TARGET_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.targetSlot) : null),
|
||||
previousSlot: parseSlot(env.DEPLOY_PREVIOUS_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.previousSlot) : null),
|
||||
targetContainer: env.DEPLOY_TARGET_CONTAINER_VALUE || (!shouldResetForNewRun ? current.targetContainer : null) || null,
|
||||
previousContainer: env.DEPLOY_PREVIOUS_CONTAINER_VALUE || (!shouldResetForNewRun ? current.previousContainer : null) || null,
|
||||
previousSlotActiveChatRequestCount:
|
||||
parseCount(env.DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.previousSlotActiveChatRequestCount) : null),
|
||||
previousSlotQueuedChatRequestCount:
|
||||
parseCount(env.DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.previousSlotQueuedChatRequestCount) : null),
|
||||
recoveredSessionCount:
|
||||
parseCount(env.DEPLOY_RECOVERED_SESSION_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.recoveredSessionCount) : null),
|
||||
recoveredRestartedCount:
|
||||
parseCount(env.DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.recoveredRestartedCount) : null),
|
||||
recoveredRequeuedCount:
|
||||
parseCount(env.DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.recoveredRequeuedCount) : null),
|
||||
lastError:
|
||||
env.DEPLOY_STATUS === 'completed'
|
||||
? null
|
||||
: env.DEPLOY_LAST_ERROR || (!shouldResetForNewRun ? current.lastError : null) || null,
|
||||
logExcerpt:
|
||||
env.DEPLOY_STATUS === 'completed'
|
||||
? null
|
||||
: env.DEPLOY_LOG_EXCERPT || (!shouldResetForNewRun ? current.logExcerpt : null) || null,
|
||||
steps: stepKeys.map((stepKey) => stepsByKey.get(stepKey)),
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload) + '\n', 'utf8');
|
||||
NODE
|
||||
}
|
||||
cleanup_restart_lock() {
|
||||
EXIT_CODE="$1"
|
||||
if [ "$DEPLOY_FINISHED" != "true" ]; then
|
||||
if [ "$EXIT_CODE" -ne 0 ]; then
|
||||
SUMMARY="WORK-SERVER 배포가 중단되었습니다."
|
||||
DETAIL="${LAST_DEPLOY_LOG:-알 수 없는 오류로 배포가 중단되었습니다.}"
|
||||
ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}"
|
||||
else
|
||||
SUMMARY="WORK-SERVER 배포 완료 표기 전에 스크립트가 종료되었습니다."
|
||||
DETAIL="${LAST_DEPLOY_LOG:-completed 상태를 기록하기 전에 스크립트가 종료되었습니다.}"
|
||||
ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}"
|
||||
fi
|
||||
write_deploy_state failed failed "$SUMMARY" "" "" "" "$ERROR_TEXT" "$DETAIL"
|
||||
fi
|
||||
rm -f "$LOCK_FILE"
|
||||
}
|
||||
trap 'cleanup_restart_lock "$?"' EXIT INT TERM
|
||||
|
||||
read_active_slot() {
|
||||
if [ -f "$ACTIVE_SLOT_FILE" ]; then
|
||||
SLOT=$(tr -d '[:space:]' <"$ACTIVE_SLOT_FILE")
|
||||
if [ "$SLOT" = "blue" ] || [ "$SLOT" = "green" ]; then
|
||||
printf '%s' "$SLOT"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'blue'
|
||||
}
|
||||
|
||||
container_is_running() {
|
||||
CONTAINER_NAME="$1"
|
||||
STATUS=$(docker inspect -f '{{.State.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)
|
||||
[ "$STATUS" = "running" ]
|
||||
}
|
||||
|
||||
resolve_active_slot() {
|
||||
SLOT=$(read_active_slot)
|
||||
|
||||
if [ "$SLOT" = "blue" ] && ! container_is_running "$BLUE_CONTAINER" && container_is_running "$GREEN_CONTAINER"; then
|
||||
printf 'green'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$SLOT" = "green" ] && ! container_is_running "$GREEN_CONTAINER" && container_is_running "$BLUE_CONTAINER"; then
|
||||
printf 'blue'
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "$SLOT"
|
||||
}
|
||||
|
||||
write_proxy_config() {
|
||||
SLOT="$1"
|
||||
TARGET_CONTAINER="$BLUE_CONTAINER"
|
||||
|
||||
if [ "$SLOT" = "green" ]; then
|
||||
TARGET_CONTAINER="$GREEN_CONTAINER"
|
||||
fi
|
||||
|
||||
cat >"$PROXY_CONFIG_FILE" <<EOF2
|
||||
server {
|
||||
listen 3100;
|
||||
server_name _;
|
||||
|
||||
location /ws/chat {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_set_header X-Forwarded-Host \$host;
|
||||
proxy_set_header X-Forwarded-Port \$server_port;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://$TARGET_CONTAINER:3100;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_set_header X-Forwarded-Host \$host;
|
||||
proxy_set_header X-Forwarded-Port \$server_port;
|
||||
proxy_set_header Connection "";
|
||||
proxy_pass http://$TARGET_CONTAINER:3100;
|
||||
}
|
||||
}
|
||||
EOF2
|
||||
}
|
||||
|
||||
wait_for_container_runtime_ready() {
|
||||
TARGET_CONTAINER="$1"
|
||||
TARGET_SLOT="$2"
|
||||
ATTEMPT=0
|
||||
STABLE_SUCCESS_COUNT=0
|
||||
|
||||
while [ "$ATTEMPT" -lt 90 ]; do
|
||||
if docker exec "$TARGET_CONTAINER" node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then
|
||||
STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1))
|
||||
if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
STABLE_SUCCESS_COUNT=0
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "runtime readiness check failed for $TARGET_CONTAINER slot $TARGET_SLOT" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_proxy_slot_health() {
|
||||
TARGET_SLOT="$1"
|
||||
ATTEMPT=0
|
||||
STABLE_SUCCESS_COUNT=0
|
||||
|
||||
while [ "$ATTEMPT" -lt 90 ]; do
|
||||
if node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then
|
||||
STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1))
|
||||
if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
STABLE_SUCCESS_COUNT=0
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "proxy runtime readiness check failed for slot $TARGET_SLOT via $HEALTH_ENDPOINT" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
read_runtime_value() {
|
||||
TARGET_CONTAINER="$1"
|
||||
FIELD_NAME="$2"
|
||||
|
||||
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then((response) => response.json()).then((payload) => { const value = payload?.[process.argv[2]]; if (typeof value === 'boolean') { process.stdout.write(value ? 'true' : 'false'); return; } if (value == null) { process.stdout.write(''); return; } process.stdout.write(String(value)); }).catch(() => process.exit(1));" "$RUNTIME_ENDPOINT" "$FIELD_NAME"
|
||||
}
|
||||
|
||||
set_container_draining() {
|
||||
TARGET_CONTAINER="$1"
|
||||
DRAINING_VALUE="$2"
|
||||
|
||||
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ draining: process.argv[2] === 'true' }) }).then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));" "${RUNTIME_ENDPOINT}/drain" "$DRAINING_VALUE"
|
||||
}
|
||||
|
||||
recover_interrupted_chat_requests() {
|
||||
TARGET_CONTAINER="$1"
|
||||
|
||||
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST' }).then(async (response) => { if (!response.ok) { process.stderr.write(await response.text()); process.exit(1); } process.stdout.write(await response.text()); }).catch((error) => { process.stderr.write(String(error)); process.exit(1); });" "$RECOVERY_ENDPOINT"
|
||||
}
|
||||
|
||||
wait_for_previous_slot_drain() {
|
||||
TARGET_CONTAINER="$1"
|
||||
ELAPSED=0
|
||||
|
||||
while [ "$ELAPSED" -lt "$PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS" ]; do
|
||||
ACTIVE_COUNT=$(read_runtime_value "$TARGET_CONTAINER" activeChatRequestCount 2>/dev/null || printf '0')
|
||||
QUEUED_COUNT=$(read_runtime_value "$TARGET_CONTAINER" queuedChatRequestCount 2>/dev/null || printf '0')
|
||||
PREVIOUS_ACTIVE_COUNT="${ACTIVE_COUNT:-0}"
|
||||
PREVIOUS_QUEUED_COUNT="${QUEUED_COUNT:-0}"
|
||||
write_deploy_state running drain-previous-slot "이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다." "drain-previous-slot" running "active ${PREVIOUS_ACTIVE_COUNT} · queued ${PREVIOUS_QUEUED_COUNT}"
|
||||
|
||||
if [ "${ACTIVE_COUNT:-0}" = "0" ] && [ "${QUEUED_COUNT:-0}" = "0" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
ELAPSED=$((ELAPSED + 2))
|
||||
done
|
||||
|
||||
echo "drain timeout reached for $TARGET_CONTAINER" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_proxy_running() {
|
||||
docker compose -f "$COMPOSE_FILE" up -d --no-deps "$PROXY_SERVICE" >/dev/null
|
||||
docker exec "$PROXY_CONTAINER" nginx -s reload >/dev/null
|
||||
}
|
||||
|
||||
ACTIVE_SLOT=$(resolve_active_slot)
|
||||
TARGET_SLOT="green"
|
||||
TARGET_SERVICE="$GREEN_SERVICE"
|
||||
TARGET_CONTAINER="$GREEN_CONTAINER"
|
||||
PREVIOUS_SERVICE="$BLUE_SERVICE"
|
||||
PREVIOUS_CONTAINER="$BLUE_CONTAINER"
|
||||
PREVIOUS_SLOT="blue"
|
||||
|
||||
if [ "$ACTIVE_SLOT" = "green" ]; then
|
||||
TARGET_SLOT="blue"
|
||||
TARGET_SERVICE="$BLUE_SERVICE"
|
||||
TARGET_CONTAINER="$BLUE_CONTAINER"
|
||||
PREVIOUS_SERVICE="$GREEN_SERVICE"
|
||||
PREVIOUS_CONTAINER="$GREEN_CONTAINER"
|
||||
PREVIOUS_SLOT="green"
|
||||
fi
|
||||
|
||||
write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 시작했습니다."
|
||||
|
||||
if BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE" 2>&1); then
|
||||
[ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT"
|
||||
write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 완료했습니다." "build-target-slot" completed "대상 슬롯 ${TARGET_SLOT} 준비 완료"
|
||||
else
|
||||
BUILD_STATUS=$?
|
||||
LAST_DEPLOY_ERROR="대기 슬롯 빌드에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="${BUILD_OUTPUT:-docker compose build failed}"
|
||||
[ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT" >&2
|
||||
write_deploy_state failed failed "대기 슬롯 빌드에 실패했습니다." "build-target-slot" failed "대상 슬롯 ${TARGET_SLOT} 빌드 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit "$BUILD_STATUS"
|
||||
fi
|
||||
|
||||
write_deploy_state running verify-target-health "새 슬롯 API 준비 상태를 확인합니다." "verify-target-health" running "대상 컨테이너 ${TARGET_CONTAINER}"
|
||||
if wait_for_container_runtime_ready "$TARGET_CONTAINER" "$TARGET_SLOT"; then
|
||||
write_deploy_state running verify-target-health "새 슬롯 API 준비 상태 확인이 완료되었습니다." "verify-target-health" completed "대상 슬롯 ${TARGET_SLOT} health/runtime 정상 응답"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="새 슬롯 API 준비 상태 확인에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="runtime readiness check failed for ${TARGET_CONTAINER}"
|
||||
write_deploy_state failed failed "새 슬롯 API 준비 상태 확인에 실패했습니다." "verify-target-health" failed "대상 슬롯 ${TARGET_SLOT} health/runtime 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_deploy_state running switch-proxy "프록시를 새 슬롯으로 전환합니다." "switch-proxy" running "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}"
|
||||
write_proxy_config "$TARGET_SLOT"
|
||||
if ensure_proxy_running && wait_for_proxy_slot_health "$TARGET_SLOT"; then
|
||||
printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE"
|
||||
ACTIVE_SLOT="$TARGET_SLOT"
|
||||
write_deploy_state running switch-proxy "프록시 전환을 완료했습니다." "switch-proxy" completed "프록시 3100 -> 대상 슬롯 ${TARGET_SLOT} 안정 응답 확인"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="프록시 전환에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="nginx reload or proxy health verification failed"
|
||||
write_deploy_state failed failed "프록시 전환에 실패했습니다." "switch-proxy" failed "대상 슬롯 ${TARGET_SLOT} 프록시 응답 확인 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then
|
||||
set_container_draining "$PREVIOUS_CONTAINER" true
|
||||
if wait_for_previous_slot_drain "$PREVIOUS_CONTAINER"; then
|
||||
write_deploy_state running drain-previous-slot "이전 슬롯 요청 이관이 완료되었습니다." "drain-previous-slot" completed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="이전 슬롯 드레인 대기 시간이 초과되었습니다."
|
||||
LAST_DEPLOY_LOG="drain timeout reached for ${PREVIOUS_CONTAINER}"
|
||||
write_deploy_state failed failed "이전 슬롯 요청 이관이 시간 안에 끝나지 않았습니다." "drain-previous-slot" failed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구합니다." "rebuild-previous-slot" running "대상 컨테이너 ${PREVIOUS_CONTAINER}"
|
||||
if PREVIOUS_BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$PREVIOUS_SERVICE" 2>&1); then
|
||||
[ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT"
|
||||
else
|
||||
PREVIOUS_BUILD_STATUS=$?
|
||||
LAST_DEPLOY_ERROR="이전 슬롯 대기 복구 빌드에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="${PREVIOUS_BUILD_OUTPUT:-docker compose rebuild failed}"
|
||||
[ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT" >&2
|
||||
write_deploy_state failed failed "이전 슬롯 대기 복구 빌드에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} 복구 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit "$PREVIOUS_BUILD_STATUS"
|
||||
fi
|
||||
|
||||
if wait_for_container_runtime_ready "$PREVIOUS_CONTAINER" "$PREVIOUS_SLOT"; then
|
||||
write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구했습니다." "rebuild-previous-slot" completed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 정상 응답"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="이전 슬롯 복구 API 준비 상태 확인에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="runtime readiness check failed for ${PREVIOUS_CONTAINER}"
|
||||
write_deploy_state failed failed "이전 슬롯 복구 API 준비 상태 확인에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구를 확인합니다." "recover-interrupted-chat" running "대상 슬롯 ${TARGET_SLOT}"
|
||||
if RECOVERY_JSON=$(recover_interrupted_chat_requests "$TARGET_CONTAINER" 2>&1); then
|
||||
[ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON"
|
||||
RECOVERY_COUNTS=$(printf '%s' "$RECOVERY_JSON" | node -e "let raw=''; process.stdin.on('data', (chunk) => raw += chunk); process.stdin.on('end', () => { try { const parsed = JSON.parse(raw); const recovered = parsed?.recovered ?? {}; process.stdout.write([recovered.sessionCount ?? '', recovered.restartedCount ?? '', recovered.requeuedCount ?? ''].join('\t')); } catch { process.stdout.write('\t\t'); } });")
|
||||
RECOVERED_SESSION_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $1}')
|
||||
RECOVERED_RESTARTED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $2}')
|
||||
RECOVERED_REQUEUED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $3}')
|
||||
write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구 확인이 완료되었습니다." "recover-interrupted-chat" completed "session ${RECOVERED_SESSION_COUNT:-0} · restarted ${RECOVERED_RESTARTED_COUNT:-0} · requeued ${RECOVERED_REQUEUED_COUNT:-0}"
|
||||
else
|
||||
RECOVERY_STATUS=$?
|
||||
LAST_DEPLOY_ERROR="중단된 채팅 요청 복구 확인에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="${RECOVERY_JSON:-recover interrupted chat failed}"
|
||||
[ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON" >&2
|
||||
write_deploy_state failed failed "중단된 채팅 요청 복구 확인에 실패했습니다." "recover-interrupted-chat" failed "대상 슬롯 ${TARGET_SLOT}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit "$RECOVERY_STATUS"
|
||||
fi
|
||||
|
||||
DEPLOY_FINISHED="true"
|
||||
write_deploy_state completed completed "WORK-SERVER 무중단 배포를 완료했습니다."
|
||||
printf 'work-server zero-downtime switch completed: %s -> %s\n' "$PREVIOUS_SLOT" "$TARGET_SLOT"
|
||||
|
||||
@@ -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=
|
||||
|
||||
1
etc/servers/work-server/.gitignore
vendored
1
etc/servers/work-server/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.dist-verify-actual
|
||||
.env
|
||||
|
||||
@@ -17,7 +17,19 @@ docker compose up -d
|
||||
docker compose logs -f work-server
|
||||
```
|
||||
|
||||
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
|
||||
`work-server`는 `3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다.
|
||||
|
||||
운영 기본 규칙:
|
||||
|
||||
- `work-server` 재기동은 기존 활성 슬롯을 바로 내리는 단일 컨테이너 재시작으로 처리하지 않습니다.
|
||||
- 항상 `비활성 슬롯 기동 -> /health 확인 -> nginx upstream 전환 -> 이전 슬롯 정리` 순서를 유지합니다.
|
||||
- 문서, 스크립트, 운영 가이드에 재기동 예시를 추가할 때도 무중단 전환 절차를 기본값으로 적고, 연결이 끊기는 재시작은 장애 대응이나 예외 상황으로만 취급합니다.
|
||||
|
||||
슬롯 로그까지 같이 보려면 아래처럼 확인합니다.
|
||||
|
||||
```bash
|
||||
docker compose logs -f work-server work-server-blue work-server-green
|
||||
```
|
||||
|
||||
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.
|
||||
|
||||
@@ -46,6 +58,7 @@ npm run server-command:runner
|
||||
- `SERVER_COMMAND_DOCKER_SOCKET`: 서버 재기동 명령이 사용할 Docker Unix socket 경로. rootless Docker면 예: `/run/user/1000/docker.sock`
|
||||
- `SERVER_COMMAND_API_BASE_URL`: `work-server`가 서버 재기동 요청을 위임할 host runner 주소
|
||||
- `SERVER_COMMAND_API_ACCESS_TOKEN`: host runner 호출 토큰
|
||||
- `SERVER_COMMAND_TEST_CHECK_URL`, `SERVER_COMMAND_REL_CHECK_URL`, `SERVER_COMMAND_PROD_CHECK_URL`: 외부 공개 URL과 별개로 재기동 성공 판정에 사용할 내부 확인 URL. 비워 두면 각 `SERVER_COMMAND_*_URL` 값을 그대로 사용합니다.
|
||||
|
||||
서버 재기동 기능을 쓰려면 `work-server` 컨테이너가 Docker에 접근할 수 있어야 합니다. 기본값은 `/var/run/docker.sock`이며, rootless Docker 환경이면 `.env`에 `SERVER_COMMAND_DOCKER_SOCKET` 또는 `DOCKER_HOST=unix:///run/user/<uid>/docker.sock`를 맞춰 준 뒤 `work-server`를 다시 올려야 합니다.
|
||||
|
||||
@@ -59,9 +72,9 @@ npm run server-command:runner
|
||||
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
|
||||
|
||||
`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 현재 화면 및 최근 대화 문맥은 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다.
|
||||
`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 채팅방 공통 문맥과 전용 메모는 충돌하지 않는 범위의 보조 문맥으로만 사용합니다. 현재 화면 및 최근 대화 문맥도 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다.
|
||||
|
||||
브라우저 기준 운영 접속 확인은 **`https://test.sm-home.cloud/`**, 소스 변경 검증과 최종 화면 테스트는 **`https://preview.sm-home.cloud/`** 기준으로 진행합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
브라우저 기준 화면 테스트, 소스 변경 검증, 최종 화면 확인은 **`https://preview.sm-home.cloud/`** 기준으로 진행합니다. `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
|
||||
채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat/<chat-session-id>/resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다.
|
||||
|
||||
@@ -116,8 +129,9 @@ npm run server-command:runner
|
||||
|
||||
## 웹푸쉬 호출 메모
|
||||
|
||||
- `POST /api/notifications/send`는 `title`, `body`, `data`, `threadId` 외에 `targetClientIds`도 받을 수 있습니다.
|
||||
- `targetClientIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 클라이언트에게만 알림을 보냅니다.
|
||||
- `POST /api/notifications/send`는 `title`, `body`, `data`, `threadId` 외에 `targetDeviceIds`도 받을 수 있습니다.
|
||||
- `targetDeviceIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 기기에만 알림을 보냅니다.
|
||||
- 기존 `targetClientIds`도 호환 입력으로는 허용되지만, 새 호출은 `targetDeviceIds` 사용을 기준으로 합니다.
|
||||
- 웹푸시 구독과 PWA iOS 토큰 등록 시 서버는 `appOrigin`, `appDomain`도 함께 저장합니다.
|
||||
- `POST /api/notifications/send`에 `targetAppOrigins`, `targetAppDomains`를 넣으면 해당 앱 도메인/오리진으로 등록된 구독에만 발송할 수 있습니다.
|
||||
- `GET /api/notifications/subscriptions/web`로 현재 저장된 웹푸시 구독의 `deviceId`, `appOrigin`, `appDomain`, `enabled` 상태를 확인할 수 있습니다.
|
||||
|
||||
5940
etc/servers/work-server/data/e-reader-library.json
Normal file
5940
etc/servers/work-server/data/e-reader-library.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,9 +1,25 @@
|
||||
services:
|
||||
work-server:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: work-server
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
mem_limit: 256m
|
||||
ports:
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ./.docker/proxy/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
networks:
|
||||
- work-backend
|
||||
|
||||
work-server-blue:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: work-server
|
||||
container_name: work-server-blue
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -19,8 +35,6 @@ services:
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
ports:
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ./:/app
|
||||
- work-server-node-modules:/app/node_modules
|
||||
@@ -42,7 +56,58 @@ services:
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
WORK_SERVER_DIST_DIR: /tmp/work-server-dist
|
||||
WORK_SERVER_DIST_DIR: /app/dist
|
||||
WORK_SERVER_SLOT: blue
|
||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||
networks:
|
||||
- work-backend
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
work-server-green:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: work-server-green
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
user: "0:0"
|
||||
group_add:
|
||||
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||
mem_limit: 2048m
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- path: ./.env.example
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
volumes:
|
||||
- ./:/app
|
||||
- work-server-node-modules:/app/node_modules
|
||||
- ../../../:/workspace/main-project
|
||||
- ../../../.auto_codex:/workspace/auto_codex
|
||||
- ../../../scripts:/workspace/repo-scripts:ro
|
||||
- ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
- ./.docker/home:/home/how2ice
|
||||
- ./.docker/codex-home:/codex-home
|
||||
- ./.docker/codex-home-template:/codex-home-template
|
||||
environment:
|
||||
TZ: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
HOME: /home/how2ice
|
||||
CODEX_HOME: /codex-home
|
||||
PLAN_CODEX_TEMPLATE_HOME: /codex-home-template
|
||||
PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex}
|
||||
PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false}
|
||||
PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false}
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
WORK_SERVER_DIST_DIR: /app/dist
|
||||
WORK_SERVER_SLOT: green
|
||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||
networks:
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -7,40 +7,97 @@ import { registerDdlRoutes } from './routes/ddl.js';
|
||||
import { registerErrorLogRoutes } from './routes/error-log.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerAppConfigRoutes } from './routes/app-config.js';
|
||||
import { registerBaseballTicketBayRoutes } from './routes/baseball-ticket-bay.js';
|
||||
import { registerChatRoutes } from './routes/chat.js';
|
||||
import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
|
||||
import { registerReaderRoutes } from './routes/reader.js';
|
||||
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
||||
import { registerPlayAppRoutes } from './routes/play-app.js';
|
||||
import { registerRuntimeRoutes } from './routes/runtime.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerSharedResourceTokenRoutes } from './routes/shared-resource-token.js';
|
||||
import { registerStockAlertRoutes } from './routes/stock-alert.js';
|
||||
import { registerTestAppRoutes } from './routes/test-app.js';
|
||||
import { registerTextMemoRoutes } from './routes/text-memo.js';
|
||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
import { createErrorLog } from './services/error-log-service.js';
|
||||
import {
|
||||
isRuntimeDraining,
|
||||
trackHttpRequestFinished,
|
||||
trackHttpRequestStarted,
|
||||
} from './services/runtime-drain-service.js';
|
||||
|
||||
function isDrainAllowedPath(method: string, url: string) {
|
||||
return method === 'OPTIONS'
|
||||
|| url === '/'
|
||||
|| url === '/api'
|
||||
|| url === '/health'
|
||||
|| url.startsWith('/api/runtime')
|
||||
|| url.startsWith('/api/server-commands');
|
||||
}
|
||||
|
||||
export function createApp() {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
routerOptions: {
|
||||
maxParamLength: 20000,
|
||||
},
|
||||
});
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
});
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
trackHttpRequestStarted();
|
||||
let finished = false;
|
||||
const finalize = () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
trackHttpRequestFinished();
|
||||
reply.raw.off('finish', finalize);
|
||||
reply.raw.off('close', finalize);
|
||||
};
|
||||
|
||||
reply.raw.on('finish', finalize);
|
||||
reply.raw.on('close', finalize);
|
||||
|
||||
if (isRuntimeDraining() && !isDrainAllowedPath(request.method, request.url)) {
|
||||
reply.code(503).send({
|
||||
ok: false,
|
||||
message: '이 서버는 배포 전환 중이라 새 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
|
||||
});
|
||||
return reply;
|
||||
}
|
||||
});
|
||||
|
||||
registerJsonBodyParser(app);
|
||||
app.register(registerBoardRoutes);
|
||||
app.register(registerHealthRoutes);
|
||||
app.register(registerAppConfigRoutes);
|
||||
app.register(registerBaseballTicketBayRoutes);
|
||||
app.register(registerChatRoutes);
|
||||
app.register(registerSchemaRoutes);
|
||||
app.register(registerDdlRoutes);
|
||||
app.register(registerCrudRoutes);
|
||||
app.register(registerStockAlertRoutes);
|
||||
app.register(registerTestAppRoutes);
|
||||
app.register(registerErrorLogRoutes);
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
app.register(registerPhotoPrismRoutes);
|
||||
app.register(registerPlayAppRoutes);
|
||||
app.register(registerReaderRoutes);
|
||||
app.register(registerResourceManagerRoutes);
|
||||
app.register(registerRuntimeRoutes);
|
||||
app.register(registerSharedResourceTokenRoutes);
|
||||
app.register(registerServerCommandRoutes);
|
||||
app.register(registerTextMemoRoutes);
|
||||
app.register(registerVisitorHistoryRoutes);
|
||||
|
||||
@@ -54,6 +54,8 @@ const envSchema = z.object({
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(),
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(),
|
||||
WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'),
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
OPENAI_ORGANIZATION_ID: z.string().optional(),
|
||||
APNS_KEY_ID: z.string().optional(),
|
||||
APNS_TEAM_ID: z.string().optional(),
|
||||
APNS_BUNDLE_ID: z.string().optional(),
|
||||
@@ -70,12 +72,16 @@ const envSchema = z.object({
|
||||
SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')),
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
|
||||
SERVER_COMMAND_TEST_URL: z.string().default('https://preview.sm-home.cloud/'),
|
||||
SERVER_COMMAND_TEST_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
||||
SERVER_COMMAND_REL_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
|
||||
SERVER_COMMAND_PROD_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
||||
SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE: z.string().optional(),
|
||||
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
||||
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
||||
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
|
||||
import {
|
||||
getChatContextSettingsConfig,
|
||||
getAppConfigSnapshot,
|
||||
@@ -14,6 +16,126 @@ import {
|
||||
upsertAutomationContextsConfig,
|
||||
} from '../services/automation-context-config-service.js';
|
||||
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
|
||||
import {
|
||||
getTokenSettingsConfig,
|
||||
getTokenSettingById,
|
||||
upsertTokenSettingsConfig,
|
||||
type TokenSettingRecord,
|
||||
} from '../services/token-setting-config-service.js';
|
||||
import { listTokenSettingActivities } from '../services/token-setting-activity-service.js';
|
||||
import { extractRequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
|
||||
|
||||
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
type TokenSettingsAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared'; tokenSetting: TokenSettingRecord };
|
||||
|
||||
type AppConfigAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared' };
|
||||
|
||||
async function resolveTokenSettingsAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies TokenSettingsAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (managedResource.token.resourceType === 'chat-share') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
const hasManagePermission = managedResource.token.permissions.includes('manage');
|
||||
const canOpenTokenSetting = normalizedAllowedAppIds.has('token-setting');
|
||||
const tokenSettingId = managedResource.token.tokenSettingId?.trim() ?? '';
|
||||
|
||||
if (!hasManagePermission || !canOpenTokenSetting || !tokenSettingId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenSetting = await getTokenSettingById(tokenSettingId);
|
||||
if (!tokenSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { scope: 'shared', tokenSetting } satisfies TokenSettingsAccessContext;
|
||||
}
|
||||
|
||||
async function resolveAppConfigAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies AppConfigAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (managedResource.token.resourceType === 'chat-share') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
const hasManagePermission = managedResource.token.permissions.includes('manage');
|
||||
const canOpenAppSettings = normalizedAllowedAppIds.has('app-settings');
|
||||
|
||||
if (!hasManagePermission || !canOpenAppSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { scope: 'shared' } satisfies AppConfigAccessContext;
|
||||
}
|
||||
|
||||
function sendTokenSettingsAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 토큰관리 관리 권한이 있는 공유 링크에서만 토큰 설정을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function sendAppConfigAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 앱 설정 관리 권한이 있는 공유 링크에서만 앱 설정을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestAppOrigin(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const rawAppOrigin = request.headers['x-app-origin'];
|
||||
@@ -50,7 +172,15 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
|
||||
}
|
||||
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async (request) => {
|
||||
app.get('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
const hasShareToken = Boolean(getRequestChatShareToken(request));
|
||||
|
||||
if (hasShareToken && !accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const config = await getAppConfigSnapshot(appOrigin);
|
||||
|
||||
@@ -96,6 +226,22 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/token-settings', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenSettings =
|
||||
accessContext.scope === 'full' ? await getTokenSettingsConfig() : [accessContext.tokenSetting];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tokenSettings,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/chat-types', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
@@ -219,7 +365,93 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/token-settings', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = z.object({
|
||||
tokenSettings: z.array(z.unknown()),
|
||||
}).parse(payload ?? {});
|
||||
|
||||
const nextTokenSettingsInput = parsed.tokenSettings as Partial<TokenSettingRecord>[];
|
||||
const savedTokenSettings =
|
||||
accessContext.scope === 'full'
|
||||
? await upsertTokenSettingsConfig(nextTokenSettingsInput, {
|
||||
actorLabel: 'manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
})
|
||||
: await (async () => {
|
||||
const authorizedSettingId = accessContext.tokenSetting.id;
|
||||
const requestedSetting = nextTokenSettingsInput.find(
|
||||
(item) => typeof item?.id === 'string' && item.id.trim().toLowerCase() === authorizedSettingId,
|
||||
);
|
||||
|
||||
if (!requestedSetting) {
|
||||
throw new Error('공유 링크에서는 현재 연결된 토큰 설정만 저장할 수 있습니다.');
|
||||
}
|
||||
|
||||
const currentTokenSettings = await getTokenSettingsConfig();
|
||||
const nextTokenSettings = currentTokenSettings.map((item) =>
|
||||
item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item,
|
||||
);
|
||||
return upsertTokenSettingsConfig(nextTokenSettings, {
|
||||
actorLabel: 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
})();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tokenSettings: accessContext.scope === 'full' ? savedTokenSettings : savedTokenSettings.filter((item) => item.id === accessContext.tokenSetting.id),
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/token-settings/:settingId/activities', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const settingId = z.string().trim().min(1).parse((request.params as { settingId: string }).settingId);
|
||||
if (accessContext.scope === 'shared' && accessContext.tokenSetting.id !== settingId) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 토큰 설정 이력만 볼 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
activities: await listTokenSettingActivities(settingId),
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
|
||||
300
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
300
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import { getSharedResourceTokenDetailByShareToken } from '../services/shared-resource-token-service.js';
|
||||
import {
|
||||
createBaseballTicketBayAlert,
|
||||
createBaseballTicketBayLog,
|
||||
deleteBaseballTicketBayLog,
|
||||
deleteBaseballTicketBayAlert,
|
||||
listBaseballTicketBayAlerts,
|
||||
listBaseballTicketBayLogs,
|
||||
runBaseballTicketBayAlert,
|
||||
searchBaseballTicketBayListings,
|
||||
updateBaseballTicketBayAlert,
|
||||
} from '../services/baseball-ticket-bay-service.js';
|
||||
|
||||
const timeWindowSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
start: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||
end: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||
});
|
||||
|
||||
const alertPayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(255),
|
||||
eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
team: z.string().trim().min(1).max(50),
|
||||
zone: z.string().trim().min(1).max(100),
|
||||
aisleSide: z.string().trim().min(1).max(100),
|
||||
seatDirections: z.array(z.string().trim().min(1).max(50)).max(10),
|
||||
maxPrice: z.number().finite().positive().nullable(),
|
||||
seatCount: z.number().int().positive().max(10),
|
||||
batchIntervalMinutes: z.number().int().min(1).max(120),
|
||||
sameProductAlertEnabled: z.boolean(),
|
||||
sameProductNotifyOnce: z.boolean(),
|
||||
active: z.boolean().default(true),
|
||||
timeWindows: z.array(timeWindowSchema).min(1).max(24),
|
||||
});
|
||||
|
||||
function readHeader(request: { headers: Record<string, string | string[] | undefined> }, key: string) {
|
||||
const raw = request.headers[key];
|
||||
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||
}
|
||||
|
||||
function hasBaseballTicketBayGlobalAccess(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
return hasErrorLogViewAccessToken(request.headers['x-access-token']);
|
||||
}
|
||||
|
||||
type BaseballTicketBayRouteAccessContext =
|
||||
| { scope: 'all' }
|
||||
| { scope: 'client'; clientId: string }
|
||||
| { scope: 'shared-token'; clientId: string; tokenId: string };
|
||||
|
||||
function toOwnerScope(accessContext: Exclude<BaseballTicketBayRouteAccessContext, { scope: 'all' }> | { scope: 'all' }) {
|
||||
if (accessContext.scope === 'all') {
|
||||
return { kind: 'all' } as const;
|
||||
}
|
||||
|
||||
if (accessContext.scope === 'shared-token') {
|
||||
return { kind: 'owner', ownerType: 'shared-token', ownerId: accessContext.tokenId } as const;
|
||||
}
|
||||
|
||||
return { kind: 'owner', ownerType: 'client', ownerId: accessContext.clientId } as const;
|
||||
}
|
||||
|
||||
async function resolveBaseballTicketBayAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) : Promise<BaseballTicketBayRouteAccessContext | null> {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (hasBaseballTicketBayGlobalAccess(request)) {
|
||||
return { scope: 'all' };
|
||||
}
|
||||
|
||||
const accessToken = readHeader(request, 'x-access-token');
|
||||
|
||||
if (accessToken) {
|
||||
const sharedTokenDetail = await getSharedResourceTokenDetailByShareToken(accessToken);
|
||||
|
||||
if (
|
||||
sharedTokenDetail
|
||||
&& sharedTokenDetail.token.enabled !== false
|
||||
&& !sharedTokenDetail.token.revokedAt
|
||||
&& sharedTokenDetail.token.allowedAppIds.some((item) => item.trim().toLowerCase() === 'baseball-ticket-bay')
|
||||
) {
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared-token',
|
||||
clientId,
|
||||
tokenId: sharedTokenDetail.token.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'client',
|
||||
clientId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {}));
|
||||
|
||||
app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 알림 목록을 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
includeAllClients: accessContext.scope === 'all',
|
||||
accessScope: accessContext.scope,
|
||||
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
|
||||
items: await listBaseballTicketBayAlerts(toOwnerScope(accessContext)),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/baseball-ticket-bay/logs', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 로그를 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
includeAllClients: accessContext.scope === 'all',
|
||||
accessScope: accessContext.scope,
|
||||
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
|
||||
items: await listBaseballTicketBayLogs(toOwnerScope(accessContext), query.alertId),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayLog(params.id, toOwnerScope(accessContext));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 저장할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const payload = alertPayloadSchema.parse(request.body ?? {});
|
||||
const item = await createBaseballTicketBayAlert(payload, {
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
});
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'create',
|
||||
status: 'info',
|
||||
message: '알림 조건을 저장했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
|
||||
const item = await updateBaseballTicketBayAlert(
|
||||
params.id,
|
||||
payload,
|
||||
accessContext.scope === 'all'
|
||||
? {
|
||||
scope: { kind: 'all' },
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
}
|
||||
: {
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
},
|
||||
);
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
|
||||
status: 'info',
|
||||
message:
|
||||
payload.active === false
|
||||
? '알림을 중지했습니다.'
|
||||
: payload.active === true
|
||||
? '알림을 다시 실행 상태로 전환했습니다.'
|
||||
: '알림 조건을 수정 저장했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayAlert(params.id, toOwnerScope(accessContext));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
|
||||
}
|
||||
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'delete',
|
||||
status: 'info',
|
||||
message: '알림 항목을 삭제했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 즉시 실행할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
|
||||
const result = await runBaseballTicketBayAlert(params.id, {
|
||||
ignoreTimeWindow: true,
|
||||
scope: toOwnerScope(accessContext),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
alert: result.alert,
|
||||
matches: result.matches,
|
||||
notifiedMatches: result.notifiedMatches,
|
||||
log: result.log,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType } from './chat.js';
|
||||
import Fastify from 'fastify';
|
||||
import { registerChatRoutes, resolvePromptFollowupMode, resolveStaticContentType } from './chat.js';
|
||||
|
||||
const repoRoot = path.resolve(process.cwd(), '../../..');
|
||||
|
||||
async function removeSessionUploads(sessionId: string) {
|
||||
await fs.rm(path.join(repoRoot, 'public', '.codex_chat', sessionId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
@@ -11,3 +23,73 @@ test('resolveStaticContentType keeps plain text content type for code resources'
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
test('resolvePromptFollowupMode defaults to queue and preserves direct mode', () => {
|
||||
assert.equal(resolvePromptFollowupMode(undefined), 'queue');
|
||||
assert.equal(resolvePromptFollowupMode(null), 'queue');
|
||||
assert.equal(resolvePromptFollowupMode('queue'), 'queue');
|
||||
assert.equal(resolvePromptFollowupMode('direct'), 'direct');
|
||||
});
|
||||
|
||||
test('chat attachments accept binary octet-stream uploads without base64 expansion', async () => {
|
||||
const app = Fastify();
|
||||
await registerChatRoutes(app);
|
||||
const sessionId = `binary-upload-${Date.now()}`;
|
||||
const payload = Buffer.alloc(829_627, 1);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/chat/attachments',
|
||||
headers: {
|
||||
'content-type': 'application/octet-stream',
|
||||
'x-chat-attachment-session-id': sessionId,
|
||||
'x-chat-attachment-file-name': encodeURIComponent('image.png'),
|
||||
'x-chat-attachment-mime-type': encodeURIComponent('image/png'),
|
||||
},
|
||||
payload,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
const body = response.json() as { ok: boolean; item: { path: string; size: number; mimeType: string } };
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.item.size, payload.byteLength);
|
||||
assert.equal(body.item.mimeType, 'image/png');
|
||||
assert.match(body.item.path, new RegExp(`^public/\\.codex_chat/${sessionId}/resource/uploads/.+image\\.png$`));
|
||||
} finally {
|
||||
await removeSessionUploads(sessionId);
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('chat attachments keep legacy JSON base64 uploads working', async () => {
|
||||
const app = Fastify();
|
||||
await registerChatRoutes(app);
|
||||
const sessionId = `json-upload-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/chat/attachments',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: JSON.stringify({
|
||||
sessionId,
|
||||
fileName: 'note.txt',
|
||||
mimeType: 'text/plain',
|
||||
contentBase64: Buffer.from('hello', 'utf8').toString('base64'),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
const body = response.json() as { ok: boolean; item: { size: number; mimeType: string; name: string } };
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.item.size, 5);
|
||||
assert.equal(body.item.mimeType, 'text/plain');
|
||||
assert.equal(body.item.name, 'note.txt');
|
||||
} finally {
|
||||
await removeSessionUploads(sessionId);
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getActiveChatService } from '../services/chat-service.js';
|
||||
import { getRuntimeDrainSnapshot } from '../services/runtime-drain-service.js';
|
||||
import { getRuntimeWorkServerBuildInfo } from '../services/work-server-build-service.js';
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance) {
|
||||
const respondHealth = async () => ({
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
const respondHealth = async () => {
|
||||
const buildInfo = getRuntimeWorkServerBuildInfo();
|
||||
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
buildId: buildInfo?.buildId ?? null,
|
||||
builtAt: buildInfo?.builtAt ?? null,
|
||||
...getRuntimeDrainSnapshot(),
|
||||
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/', respondHealth);
|
||||
app.get('/api', respondHealth);
|
||||
|
||||
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
590
etc/servers/work-server/src/routes/play-app.ts
Normal file
590
etc/servers/work-server/src/routes/play-app.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
type PlayAppEnvironment = 'preview' | 'test' | 'prod';
|
||||
|
||||
type PlayAppSeedEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
accentClassName: string;
|
||||
statusLabel: string;
|
||||
isReady: boolean;
|
||||
iconName: string;
|
||||
usagePriority?: number;
|
||||
supportedEnvironments: PlayAppEnvironment[];
|
||||
searchKeywords?: string[];
|
||||
searchDescription?: string;
|
||||
};
|
||||
|
||||
const PLAY_APP_TABLE = 'play_apps';
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function ensurePlayAppWriteAuthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰이 필요합니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const DEFAULT_ENTRIES: PlayAppSeedEntry[] = [
|
||||
{
|
||||
id: 'baseball-ticket-bay',
|
||||
name: '야구-티켓베이',
|
||||
accentClassName: 'apps-library__card--baseball-ticket-bay',
|
||||
statusLabel: '알림',
|
||||
isReady: true,
|
||||
iconName: 'BellOutlined',
|
||||
usagePriority: 100,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'],
|
||||
searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.',
|
||||
},
|
||||
{
|
||||
id: 'e-reader',
|
||||
name: 'E-Reader',
|
||||
accentClassName: 'apps-library__card--reader',
|
||||
statusLabel: '읽기',
|
||||
isReady: true,
|
||||
iconName: 'BookOutlined',
|
||||
usagePriority: 80,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
|
||||
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'photoprism',
|
||||
name: 'PhotoPrism',
|
||||
accentClassName: 'apps-library__card--photoprism',
|
||||
statusLabel: '연결',
|
||||
isReady: true,
|
||||
iconName: 'FileImageOutlined',
|
||||
usagePriority: 70,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
|
||||
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
|
||||
},
|
||||
{
|
||||
id: 'photo-puzzle',
|
||||
name: '사진 퍼즐',
|
||||
accentClassName: 'apps-library__card--puzzle',
|
||||
statusLabel: '실행',
|
||||
isReady: true,
|
||||
iconName: 'PictureOutlined',
|
||||
usagePriority: 60,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
|
||||
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
|
||||
},
|
||||
{
|
||||
id: 'the-quest',
|
||||
name: 'The Quest',
|
||||
accentClassName: 'apps-library__card--the-quest',
|
||||
statusLabel: '신규',
|
||||
isReady: true,
|
||||
iconName: 'ThunderboltOutlined',
|
||||
usagePriority: 50,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
||||
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
||||
},
|
||||
{
|
||||
id: 'template1',
|
||||
name: 'Template1',
|
||||
accentClassName: 'apps-library__card--template1',
|
||||
statusLabel: '템플릿',
|
||||
isReady: true,
|
||||
iconName: 'AppstoreAddOutlined',
|
||||
usagePriority: 45,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['template1', 'template', '앱 템플릿', '레이아웃', '기본 UI', 'layout'],
|
||||
searchDescription: '다른 앱 개발 시 공통 레이아웃을 빠르게 적용하기 위한 템플릿 화면입니다.',
|
||||
},
|
||||
{
|
||||
id: 'tetris',
|
||||
name: 'Tetris',
|
||||
accentClassName: 'apps-library__card--tetris',
|
||||
statusLabel: '실행',
|
||||
isReady: true,
|
||||
iconName: 'FundProjectionScreenOutlined',
|
||||
usagePriority: 40,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
|
||||
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',
|
||||
},
|
||||
{
|
||||
id: 'beat-lab',
|
||||
name: 'Beat Lab',
|
||||
accentClassName: 'apps-library__card--beat',
|
||||
statusLabel: '준비',
|
||||
isReady: false,
|
||||
iconName: 'SoundOutlined',
|
||||
usagePriority: 35,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Beat Lab 앱',
|
||||
},
|
||||
{
|
||||
id: 'sticker-booth',
|
||||
name: 'Sticker Booth',
|
||||
accentClassName: 'apps-library__card--sticker',
|
||||
statusLabel: '준비',
|
||||
isReady: false,
|
||||
iconName: 'StarOutlined',
|
||||
usagePriority: 30,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Sticker Booth 앱',
|
||||
},
|
||||
{
|
||||
id: 'launch-note',
|
||||
name: 'Launch Note',
|
||||
accentClassName: 'apps-library__card--launch',
|
||||
statusLabel: '예정',
|
||||
isReady: false,
|
||||
iconName: 'RocketOutlined',
|
||||
usagePriority: 20,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Launch Note 앱',
|
||||
},
|
||||
{
|
||||
id: 'arcade-pack',
|
||||
name: 'Arcade Pack',
|
||||
accentClassName: 'apps-library__card--arcade',
|
||||
statusLabel: '예정',
|
||||
isReady: false,
|
||||
iconName: 'FireOutlined',
|
||||
usagePriority: 10,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Arcade Pack 앱',
|
||||
},
|
||||
{
|
||||
id: 'app-vault',
|
||||
name: 'App Vault',
|
||||
accentClassName: 'apps-library__card--vault',
|
||||
statusLabel: '테마',
|
||||
isReady: false,
|
||||
iconName: 'AppstoreOutlined',
|
||||
usagePriority: 0,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 App Vault 앱',
|
||||
},
|
||||
] ;
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
environment: z.enum(['preview', 'test', 'prod']).optional(),
|
||||
});
|
||||
|
||||
const playAppIdSchema = z.string().trim().min(1).max(100);
|
||||
|
||||
const supportedEnvironmentSchema = z.enum(['preview', 'test', 'prod']);
|
||||
|
||||
const iconNameSchema = z.enum([
|
||||
'AppstoreOutlined',
|
||||
'AppstoreAddOutlined',
|
||||
'BellOutlined',
|
||||
'BookOutlined',
|
||||
'FireOutlined',
|
||||
'FundProjectionScreenOutlined',
|
||||
'FileImageOutlined',
|
||||
'PictureOutlined',
|
||||
'RocketOutlined',
|
||||
'SoundOutlined',
|
||||
'StarOutlined',
|
||||
'ThunderboltOutlined',
|
||||
]);
|
||||
|
||||
const playAppCreatePayloadSchema = z.object({
|
||||
id: playAppIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
accentClassName: z.string().trim().min(1).max(80),
|
||||
statusLabel: z.string().trim().min(1).max(80),
|
||||
isReady: z.boolean().default(false),
|
||||
iconName: iconNameSchema,
|
||||
usagePriority: z.number().int().min(0).max(1_000_000).optional(),
|
||||
supportedEnvironments: z.array(supportedEnvironmentSchema).min(1).default(['preview']),
|
||||
searchKeywords: z
|
||||
.array(z.string().trim().min(1).max(80))
|
||||
.default([])
|
||||
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0)))),
|
||||
searchDescription: z.string().trim().max(4000).default(''),
|
||||
});
|
||||
|
||||
const playAppUpdatePayloadSchema = z
|
||||
.object({
|
||||
id: playAppIdSchema.optional(),
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
accentClassName: z.string().trim().min(1).max(80).optional(),
|
||||
statusLabel: z.string().trim().min(1).max(80).optional(),
|
||||
isReady: z.boolean().optional(),
|
||||
iconName: iconNameSchema.optional(),
|
||||
usagePriority: z.number().int().min(0).max(1_000_000).nullable().optional(),
|
||||
supportedEnvironments: z.array(supportedEnvironmentSchema).optional(),
|
||||
searchKeywords: z
|
||||
.array(z.string().trim().min(1).max(80))
|
||||
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0))))
|
||||
.optional(),
|
||||
searchDescription: z.string().trim().max(4000).optional(),
|
||||
})
|
||||
.refine((payload) => Object.keys(payload).length > 0, {
|
||||
message: '수정할 항목이 없습니다.',
|
||||
});
|
||||
|
||||
const playAppIdParamsSchema = z.object({
|
||||
id: playAppIdSchema,
|
||||
});
|
||||
|
||||
type SupportedEnvironment = Array<PlayAppEnvironment>;
|
||||
type PlayAppRegistryRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
accent_class_name: string;
|
||||
status_label: string;
|
||||
is_ready: boolean;
|
||||
icon_name: string;
|
||||
usage_priority: number | null;
|
||||
supported_environments: string | null;
|
||||
search_keywords: string | null;
|
||||
search_description: string | null;
|
||||
};
|
||||
|
||||
type PlayAppRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
accentClassName: string;
|
||||
statusLabel: string;
|
||||
isReady: boolean;
|
||||
iconName: string;
|
||||
usagePriority?: number;
|
||||
supportedEnvironments: SupportedEnvironment;
|
||||
searchKeywords: string[];
|
||||
searchDescription: string;
|
||||
};
|
||||
|
||||
type PlayAppCreateInput = z.infer<typeof playAppCreatePayloadSchema>;
|
||||
type PlayAppUpdateInput = z.infer<typeof playAppUpdatePayloadSchema>;
|
||||
|
||||
function normalizeSupportedEnvironments(value: string | null): SupportedEnvironment {
|
||||
if (!value) {
|
||||
return ['preview'];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return ['preview'];
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => String(item).trim())
|
||||
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
|
||||
}
|
||||
} catch {
|
||||
// fall through to comma parser below.
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
|
||||
}
|
||||
|
||||
function parseJsonArrayList(value: string | null): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// fallthrough to legacy parser below.
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function toResponseItem(row: PlayAppRegistryRecord): PlayAppRow {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accentClassName: row.accent_class_name,
|
||||
statusLabel: row.status_label,
|
||||
isReady: !!row.is_ready,
|
||||
iconName: row.icon_name,
|
||||
usagePriority: row.usage_priority ?? undefined,
|
||||
supportedEnvironments: normalizeSupportedEnvironments(row.supported_environments),
|
||||
searchKeywords: parseJsonArrayList(row.search_keywords),
|
||||
searchDescription: row.search_description ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function encodeJsonList(values: readonly string[] | null | undefined) {
|
||||
if (!values || values.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
return JSON.stringify(Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))));
|
||||
}
|
||||
|
||||
function toDbRowPayload(input: PlayAppCreateInput | Omit<PlayAppUpdateInput, 'id'>) {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: 'id' in input ? input.id?.trim() : undefined,
|
||||
name: input.name?.trim(),
|
||||
accent_class_name: input.accentClassName?.trim(),
|
||||
status_label: input.statusLabel?.trim(),
|
||||
is_ready: input.isReady,
|
||||
icon_name: input.iconName,
|
||||
usage_priority: input.usagePriority ?? null,
|
||||
supported_environments: input.supportedEnvironments ? encodeJsonList(input.supportedEnvironments) : undefined,
|
||||
search_keywords: input.searchKeywords ? encodeJsonList(input.searchKeywords) : undefined,
|
||||
search_description: input.searchDescription ? input.searchDescription.trim() : undefined,
|
||||
};
|
||||
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
delete payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
if ('is_ready' in payload && payload.is_ready === undefined) {
|
||||
payload.is_ready = false;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function isDbUpdateResultEmpty(result: unknown) {
|
||||
if (Array.isArray(result)) {
|
||||
return result.length === 0;
|
||||
}
|
||||
|
||||
return typeof result === 'number' ? result === 0 : false;
|
||||
}
|
||||
|
||||
function parsePlayAppErrorWithCode(error: unknown, fallbackMessage: string) {
|
||||
if (error instanceof Error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'ER_DUP_ENTRY' || code === '23505') {
|
||||
const duplicateError = error as Error & { statusCode?: number; details?: string };
|
||||
duplicateError.statusCode = 409;
|
||||
duplicateError.message = '이미 등록된 앱 ID입니다.';
|
||||
return duplicateError;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const duplicateError = error as Error & { statusCode?: number; details?: string };
|
||||
duplicateError.message = fallbackMessage;
|
||||
return duplicateError;
|
||||
}
|
||||
|
||||
const next = new Error(fallbackMessage) as Error & { statusCode?: number };
|
||||
next.statusCode = 500;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function ensurePlayAppTable() {
|
||||
const exists = await db.schema.hasTable(PLAY_APP_TABLE);
|
||||
if (!exists) {
|
||||
await db.schema.createTable(PLAY_APP_TABLE, (table) => {
|
||||
table.string('id', 100).primary();
|
||||
table.string('name', 120).notNullable();
|
||||
table.string('accent_class_name', 80).notNullable();
|
||||
table.string('status_label', 80).notNullable();
|
||||
table.boolean('is_ready').notNullable().defaultTo(false);
|
||||
table.string('icon_name', 80).notNullable();
|
||||
table.integer('usage_priority').nullable();
|
||||
table.text('supported_environments').nullable();
|
||||
table.text('search_keywords').nullable();
|
||||
table.text('search_description').nullable();
|
||||
table.index('is_ready', 'play_apps_is_ready_idx');
|
||||
});
|
||||
}
|
||||
|
||||
const existingRows = await db(PLAY_APP_TABLE).select('id');
|
||||
const existingIds = new Set(existingRows.map((row) => row.id));
|
||||
|
||||
const rowsToInsert = DEFAULT_ENTRIES.filter((entry) => !existingIds.has(entry.id)).map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
accent_class_name: entry.accentClassName,
|
||||
status_label: entry.statusLabel,
|
||||
is_ready: entry.isReady,
|
||||
icon_name: entry.iconName,
|
||||
usage_priority: entry.usagePriority,
|
||||
supported_environments: encodeJsonList(entry.supportedEnvironments),
|
||||
search_keywords: encodeJsonList(entry.searchKeywords ?? []),
|
||||
search_description: entry.searchDescription ?? '',
|
||||
}));
|
||||
|
||||
if (rowsToInsert.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db(PLAY_APP_TABLE).insert(rowsToInsert);
|
||||
}
|
||||
|
||||
async function listPlayAppEntries(environment?: PlayAppEnvironment | null) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const rows = (await db(PLAY_APP_TABLE)
|
||||
.select<PlayAppRegistryRecord[]>('*')
|
||||
.orderBy('usage_priority', 'desc')
|
||||
.orderBy('id', 'asc')) as PlayAppRegistryRecord[];
|
||||
|
||||
const normalizedRows = rows.map(toResponseItem);
|
||||
const filteredRows = environment ? normalizedRows.filter((row) => row.supportedEnvironments.includes(environment)) : normalizedRows;
|
||||
|
||||
return filteredRows;
|
||||
}
|
||||
|
||||
async function createPlayAppEntry(body: unknown) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const payload = playAppCreatePayloadSchema.parse(body);
|
||||
const dbPayload = toDbRowPayload(payload);
|
||||
|
||||
try {
|
||||
await db(PLAY_APP_TABLE).insert(dbPayload);
|
||||
} catch (error) {
|
||||
throw parsePlayAppErrorWithCode(error, `앱 등록에 실패했습니다: ${payload.id}`);
|
||||
}
|
||||
|
||||
const insertedRow = await db(PLAY_APP_TABLE)
|
||||
.where({ id: payload.id })
|
||||
.first<PlayAppRegistryRecord>();
|
||||
|
||||
if (!insertedRow) {
|
||||
const notFoundError = new Error('등록된 앱을 조회하지 못했습니다.') as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 500;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: toResponseItem(insertedRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePlayAppEntry(params: unknown, body: unknown) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const { id } = playAppIdParamsSchema.parse(params);
|
||||
const payload = playAppUpdatePayloadSchema.parse(body);
|
||||
|
||||
if (payload.id && payload.id !== id) {
|
||||
const invalidError = new Error('요청 경로 ID와 본문 ID가 일치하지 않습니다.') as Error & { statusCode?: number };
|
||||
invalidError.statusCode = 409;
|
||||
throw invalidError;
|
||||
}
|
||||
|
||||
const dbPayload = toDbRowPayload(payload);
|
||||
const updated = await db(PLAY_APP_TABLE).where({ id }).update(dbPayload as Record<string, unknown>);
|
||||
|
||||
if (isDbUpdateResultEmpty(updated)) {
|
||||
const notFoundError = new Error(`수정할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 404;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
const updatedRow = await db(PLAY_APP_TABLE).where({ id }).first<PlayAppRegistryRecord>();
|
||||
if (!updatedRow) {
|
||||
const notFoundError = new Error(`수정한 앱을 조회할 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 404;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: toResponseItem(updatedRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function deletePlayAppEntry(params: unknown) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const { id } = playAppIdParamsSchema.parse(params);
|
||||
const deleted = await db(PLAY_APP_TABLE).where({ id }).delete('*');
|
||||
if (isDbUpdateResultEmpty(deleted)) {
|
||||
const notFoundError = new Error(`삭제할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 404;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
if (Array.isArray(deleted) && deleted[0]) {
|
||||
return {
|
||||
ok: true,
|
||||
deletedId: id,
|
||||
item: toResponseItem(deleted[0] as PlayAppRegistryRecord),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deletedId: id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerPlayAppRoutes(app: FastifyInstance) {
|
||||
app.get('/api/play-apps', async (request) => {
|
||||
const query = listQuerySchema.parse(request.query);
|
||||
|
||||
const items = await listPlayAppEntries(query.environment);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/play-apps', async (request, reply) => {
|
||||
if (!ensurePlayAppWriteAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return createPlayAppEntry(request.body);
|
||||
});
|
||||
|
||||
app.put('/api/play-apps/:id', async (request, reply) => {
|
||||
if (!ensurePlayAppWriteAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return updatePlayAppEntry(request.params, request.body);
|
||||
});
|
||||
|
||||
app.delete('/api/play-apps/:id', async (request, reply) => {
|
||||
if (!ensurePlayAppWriteAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return deletePlayAppEntry(request.params);
|
||||
});
|
||||
}
|
||||
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
116
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
116
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import { env } from '../config/env.js';
|
||||
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
|
||||
|
||||
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
|
||||
const legacyPublicResourceRoot = path.resolve(process.cwd(), '../../../public/resource');
|
||||
|
||||
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
|
||||
isValid: true,
|
||||
start: 5,
|
||||
end: 19,
|
||||
});
|
||||
assert.deepEqual(resolveSingleRange('bytes=-4', 20), {
|
||||
isValid: true,
|
||||
start: 16,
|
||||
end: 19,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSingleRange rejects malformed or out-of-bounds values', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=25-30', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=4-3', 20), { isValid: false });
|
||||
});
|
||||
|
||||
test('resource manager preview serves 206 partial content for byte ranges', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = `range-test-${Date.now()}.wav`;
|
||||
const absolutePath = path.join(fallbackResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, Buffer.from('0123456789', 'utf8'));
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${encodeURIComponent(relativePath)}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
headers: {
|
||||
range: 'bytes=2-5',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 206);
|
||||
assert.equal(response.headers['accept-ranges'], 'bytes');
|
||||
assert.equal(response.headers['content-range'], 'bytes 2-5/10');
|
||||
assert.equal(response.headers['content-length'], '4');
|
||||
assert.equal(response.body, '2345');
|
||||
} finally {
|
||||
await fs.rm(absolutePath, { force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resource manager preview falls back to public/resource legacy artifacts', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = path.join('legacy-preview-test', `sample-${Date.now()}.html`);
|
||||
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, '<!doctype html><html><body>legacy preview</body></html>', 'utf8');
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
|
||||
assert.match(response.body, /legacy preview/);
|
||||
} finally {
|
||||
await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resource manager preview restores encoded hash fragments in the file name', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`);
|
||||
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, '<!doctype html><html><body>encoded hash preview</body></html>', 'utf8');
|
||||
|
||||
try {
|
||||
for (const encodedSuffix of ['%23option-a', '%2523option-a']) {
|
||||
const encodedPath = relativePath
|
||||
.split(path.sep)
|
||||
.map((segment, index, list) =>
|
||||
index === list.length - 1
|
||||
? `${encodeURIComponent(segment)}${encodedSuffix}`
|
||||
: encodeURIComponent(segment),
|
||||
)
|
||||
.join('/');
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
|
||||
assert.match(response.body, /encoded hash preview/);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
55
etc/servers/work-server/src/routes/runtime.ts
Normal file
55
etc/servers/work-server/src/routes/runtime.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getActiveChatService } from '../services/chat-service.js';
|
||||
import {
|
||||
beginRuntimeDrain,
|
||||
endRuntimeDrain,
|
||||
getRuntimeDrainSnapshot,
|
||||
} from '../services/runtime-drain-service.js';
|
||||
|
||||
const runtimeDrainBodySchema = z.object({
|
||||
draining: z.boolean(),
|
||||
});
|
||||
|
||||
function buildRuntimeResponse() {
|
||||
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...getRuntimeDrainSnapshot(),
|
||||
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerRuntimeRoutes(app: FastifyInstance) {
|
||||
app.get('/api/runtime', async () => buildRuntimeResponse());
|
||||
|
||||
app.post('/api/runtime/recover-interrupted-chat', async () => {
|
||||
const recovered = await getActiveChatService()?.recoverInterruptedSessions();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
recovered: recovered ?? {
|
||||
sessionCount: 0,
|
||||
restartedCount: 0,
|
||||
requeuedCount: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/runtime/drain', async (request) => {
|
||||
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});
|
||||
|
||||
if (draining) {
|
||||
beginRuntimeDrain();
|
||||
} else {
|
||||
endRuntimeDrain();
|
||||
}
|
||||
|
||||
return buildRuntimeResponse();
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
|
||||
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
|
||||
import {
|
||||
deployTestServerCommand,
|
||||
deployWorkServerCommand,
|
||||
listServerCommands,
|
||||
readWorkServerDeploymentState,
|
||||
restartServerCommand,
|
||||
serverCommandKeys,
|
||||
} from '../services/server-command-service.js';
|
||||
import { readTestServerDeploymentState } from '../services/test-server-deployment-service.js';
|
||||
import {
|
||||
cancelServerRestartReservation,
|
||||
confirmServerRestartReservation,
|
||||
@@ -16,8 +25,10 @@ const serverCommandParamSchema = z.object({
|
||||
});
|
||||
|
||||
const restartReservationBodySchema = z.object({
|
||||
target: z.enum(['all', 'test', 'work-server']).optional(),
|
||||
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
|
||||
});
|
||||
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
|
||||
|
||||
function getImmediateRestartBlockInfo(
|
||||
key: z.infer<typeof serverCommandParamSchema>['key'],
|
||||
@@ -39,11 +50,8 @@ function getImmediateRestartBlockInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key === 'work-server' && automationPendingCount > 0) {
|
||||
return {
|
||||
pendingCount: automationPendingCount,
|
||||
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
if (key === 'work-server') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -54,6 +62,39 @@ function getRequestAccessToken(request: FastifyRequest) {
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
async function resolveSharedServerCommandAccessContext(request: FastifyRequest) {
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('server-command')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared' as const,
|
||||
allowedKeys: new Set<string>(['work-server', 'test']),
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestClientId(request: FastifyRequest) {
|
||||
const clientIdHeader = request.headers['x-client-id'];
|
||||
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
|
||||
@@ -72,36 +113,48 @@ function getRequestAppOrigin(request: FastifyRequest) {
|
||||
return origin?.trim() ?? '';
|
||||
}
|
||||
|
||||
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
async function resolveServerCommandAccessContext(request: FastifyRequest) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
return { scope: 'full' as const };
|
||||
}
|
||||
|
||||
return resolveSharedServerCommandAccessContext(request);
|
||||
}
|
||||
|
||||
function sendAccessDenied(reply: FastifyReply) {
|
||||
reply.status(403);
|
||||
void reply.send({
|
||||
message: '권한 토큰이 필요합니다.',
|
||||
message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
app.get('/api/server-commands', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await listServerCommands();
|
||||
return {
|
||||
ok: true,
|
||||
items: await listServerCommands(),
|
||||
items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = serverCommandParamSchema.parse(request.params);
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has(key)) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 이 서버를 재기동할 수 없습니다.' };
|
||||
}
|
||||
|
||||
if (key === 'test' || key === 'work-server') {
|
||||
const workloadSummary = await getRestartReservationWorkloadSummary();
|
||||
@@ -128,6 +181,12 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
|
||||
|
||||
if (statusCode === 409) {
|
||||
reply.status(409);
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
if (key !== 'test' && key !== 'work-server') {
|
||||
throw error;
|
||||
@@ -153,8 +212,103 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/work-server/deployment', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버 배포 상태를 확인할 수 없습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: (await readWorkServerDeploymentState()) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/work-server/actions/deploy', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버를 배포할 수 없습니다.' };
|
||||
}
|
||||
|
||||
const result = await deployWorkServerCommand();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
deployment: result.deployment ?? result.server.deployment ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/test/deployment', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버 배포 상태를 확인할 수 없습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: (await readTestServerDeploymentState()) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/test/actions/deploy', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버를 배포할 수 없습니다.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deployTestServerCommand();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
testDeployment: result.testDeployment ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
|
||||
|
||||
if (statusCode === 409) {
|
||||
reply.status(409);
|
||||
return { ok: false, message: error instanceof Error ? error.message : 'TEST 서버 배포가 이미 진행 중입니다.' };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,7 +319,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,9 +337,14 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
|
||||
const parsed = restartReservationBodySchema.parse(payload ?? {});
|
||||
|
||||
if (accessContext.scope !== 'full' && parsed.target !== 'work-server') {
|
||||
return reply.status(403).send({ message: '현재 공유채팅 링크로는 WORK 서버 재기동 예약만 사용할 수 있습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await scheduleServerRestartReservation({
|
||||
target: parsed.target,
|
||||
clientId: getRequestClientId(request),
|
||||
appOrigin: getRequestAppOrigin(request),
|
||||
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
|
||||
@@ -192,7 +353,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,7 +366,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
359
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
359
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
deleteSharedResourceTokens,
|
||||
deleteSharedResourceToken,
|
||||
getSharedResourceTokenDetail,
|
||||
getSharedResourceTokenDetailBySharePath,
|
||||
listSharedResourceTokens,
|
||||
recordSharedResourceTokenUsage,
|
||||
restoreSharedResourceToken,
|
||||
revokeSharedResourceToken,
|
||||
revokeSharedResourceTokens,
|
||||
sharedResourceTokenSchema,
|
||||
upsertSharedResourceToken,
|
||||
} from '../services/shared-resource-token-service.js';
|
||||
import { extractRequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `/chat/share/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
type SharedResourceTokenAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared'; tokenId: string };
|
||||
|
||||
async function resolveSharedResourceTokenAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies SharedResourceTokenAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('shared-resource')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared',
|
||||
tokenId: managedResource.token.id,
|
||||
} satisfies SharedResourceTokenAccessContext;
|
||||
}
|
||||
|
||||
function sendAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 shared-resource 관리 권한이 있는 공유 링크에서만 공유 리소스 관리를 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function isAllowedSharedTokenTarget(accessContext: SharedResourceTokenAccessContext, tokenId: string) {
|
||||
return accessContext.scope === 'full' || accessContext.tokenId === tokenId;
|
||||
}
|
||||
|
||||
export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
app.get('/api/shared-resource-tokens', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const sharedTokenDetail =
|
||||
accessContext.scope === 'shared' ? await getSharedResourceTokenDetail(accessContext.tokenId) : null;
|
||||
const items =
|
||||
accessContext.scope === 'full'
|
||||
? await listSharedResourceTokens()
|
||||
: sharedTokenDetail?.token
|
||||
? [sharedTokenDetail.token]
|
||||
: [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크로는 이 공유 토큰 상세를 열 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const item = await getSharedResourceTokenDetail(tokenId);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...item,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/shared-resource-tokens', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = sharedResourceTokenSchema.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.id !== accessContext.tokenId) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰 상세만 수정할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await upsertSharedResourceToken(payload, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/bulk-revoke', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/revoke', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await revokeSharedResourceToken(tokenId, payload.reason, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '회수할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/restore', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 복원할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await restoreSharedResourceToken(tokenId, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '복원할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/usage', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 기록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
actorLabel: z.string().trim().max(120).optional().nullable(),
|
||||
summary: z.string().trim().max(400).optional().nullable(),
|
||||
detail: z.string().trim().max(2000).optional().nullable(),
|
||||
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await recordSharedResourceTokenUsage(tokenId, {
|
||||
...payload,
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '사용량을 기록할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/bulk-delete', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deleteSharedResourceTokens(payload.tokenIds, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteSharedResourceToken(tokenId, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: true,
|
||||
tokenId,
|
||||
};
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import { ChatService } from './services/chat-service.js';
|
||||
import { ensureChatConversationTables } from './services/chat-room-service.js';
|
||||
import { shutdownNotificationProvider } from './services/notification-service.js';
|
||||
import { ServerRestartReservationWorker } from './services/server-restart-reservation-service.js';
|
||||
import { BaseballTicketBayWorker } from './workers/baseball-ticket-bay-worker.js';
|
||||
import { PlanWorker } from './workers/plan-worker.js';
|
||||
|
||||
const app = createApp();
|
||||
const planWorker = new PlanWorker(app.log);
|
||||
const baseballTicketBayWorker = new BaseballTicketBayWorker(app.log);
|
||||
const serverRestartReservationWorker = new ServerRestartReservationWorker(app.log);
|
||||
const chatService = new ChatService(app.log);
|
||||
const startedAt = Date.now();
|
||||
@@ -24,6 +26,7 @@ async function start() {
|
||||
port: env.PORT,
|
||||
});
|
||||
planWorker.start();
|
||||
baseballTicketBayWorker.start();
|
||||
serverRestartReservationWorker.start();
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
@@ -46,6 +49,7 @@ async function shutdown(signal: string) {
|
||||
|
||||
try {
|
||||
await planWorker.stop();
|
||||
await baseballTicketBayWorker.stop();
|
||||
await serverRestartReservationWorker.stop();
|
||||
chatService.close();
|
||||
await app.close();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
1653
etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
Normal file
1653
etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractChatMessageParts, parseChatMessageParts } from './chat-message-parts.js';
|
||||
|
||||
test('extractChatMessageParts normalizes absolute legacy dot-codex prompt preview urls to api chat resource urls', () => {
|
||||
const input = [
|
||||
'문서 미리보기',
|
||||
'[[prompt:{"title":"확인","options":[{"label":"legacy","value":"legacy","preview":{"type":"resource","url":"https://preview.sm-home.cloud/public/.codex_chat/chat-room/resource/source/chat-room-reference.md"}}]}]]',
|
||||
].join('\n');
|
||||
|
||||
const parsed = extractChatMessageParts(input);
|
||||
const prompt = parsed.parts.find((part): part is Extract<(typeof parsed.parts)[number], { type: 'prompt' }> => part.type === 'prompt');
|
||||
|
||||
assert.ok(prompt);
|
||||
assert.equal(
|
||||
prompt.options[0]?.preview?.url,
|
||||
'/api/chat/resources/.codex_chat/chat-room/resource/source/chat-room-reference.md',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseChatMessageParts normalizes absolute legacy link card urls to api chat resource urls', () => {
|
||||
const parsed = parseChatMessageParts([
|
||||
{
|
||||
type: 'link_card',
|
||||
title: 'legacy resource',
|
||||
url: 'https://preview.sm-home.cloud/.codex_chat/chat-room/resource/uploads/spec.png',
|
||||
actionLabel: '열기',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(parsed, [
|
||||
{
|
||||
type: 'link_card',
|
||||
title: 'legacy resource',
|
||||
url: '/api/chat/resources/.codex_chat/chat-room/resource/uploads/spec.png',
|
||||
actionLabel: '열기',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1,3 +1,12 @@
|
||||
export type ChatComposerAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
publicUrl: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type ChatMessagePart =
|
||||
| {
|
||||
type: 'link_card';
|
||||
@@ -48,6 +57,7 @@ export type ChatMessagePart =
|
||||
resolvedBy?: 'user' | 'timeout' | 'system' | null;
|
||||
resolvedAt?: string | null;
|
||||
resultText?: string | null;
|
||||
attachments?: ChatComposerAttachment[];
|
||||
options: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -71,7 +81,7 @@ type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
|
||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\((.+)\)\s*$/;
|
||||
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
||||
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
@@ -80,13 +90,132 @@ const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
|
||||
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
|
||||
|
||||
function buildCanonicalChatApiResourcePath(relativePath: string) {
|
||||
const normalizedRelativePath = normalizeText(relativePath).replace(/^\/+/, '');
|
||||
|
||||
if (!normalizedRelativePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedRelativePath.startsWith('.codex_chat/')) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalizedRelativePath}`;
|
||||
}
|
||||
|
||||
return `${CHAT_API_RESOURCE_MARKER}.codex_chat/${normalizedRelativePath}`;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function unwrapMarkdownLinkTarget(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const matched = normalized.match(/^<([\s\S]+)>$/);
|
||||
return matched?.[1]?.trim() ?? normalized;
|
||||
}
|
||||
|
||||
function normalizeResourceManagerPathSegment(segment: string) {
|
||||
const normalized = normalizeText(segment);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return encodeURIComponent(decodeURIComponent(normalized));
|
||||
} catch {
|
||||
return encodeURIComponent(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrlFragmentValue(value: string) {
|
||||
const normalized = normalizeText(value).replace(/^#+/, '');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewPathHash(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized);
|
||||
const isRootRelative = normalized.startsWith('/');
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const segments = parsed.pathname.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0 || parsed.hash) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
const fragment = normalizeUrlFragmentValue(decodedLastSegment.slice(hashIndex + 1));
|
||||
|
||||
if (!fileName || !fragment || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = encodeURIComponent(fileName);
|
||||
parsed.pathname = segments.join('/');
|
||||
parsed.hash = fragment;
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const pathWithQueryAndHash = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
return isRootRelative ? pathWithQueryAndHash : pathWithQueryAndHash.replace(/^\/+/, '');
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewUrl(value: string) {
|
||||
const normalized = normalizeText(value).replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const normalized = normalizePreviewPathHash(normalizeText(value).replace(/\\/g, '/'));
|
||||
const hashIndex = normalized.indexOf('#');
|
||||
const hash = hashIndex >= 0 ? normalizeUrlFragmentValue(normalized.slice(hashIndex + 1)) : '';
|
||||
const normalizedPath = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||
const matchedResourcePath = normalizedPath.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
|
||||
|
||||
if (!resourcePath) {
|
||||
@@ -102,19 +231,38 @@ function buildResourceManagerPreviewUrl(value: string) {
|
||||
const encodedPath = relativePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.map((segment) => normalizeResourceManagerPathSegment(segment))
|
||||
.join('/');
|
||||
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}${hash ? `#${hash}` : ''}` : '';
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
const normalized = unwrapMarkdownLinkTarget(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'https://local.invalid');
|
||||
const pathname = `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}`;
|
||||
|
||||
if (pathname.startsWith(CHAT_API_RESOURCE_MARKER) || pathname.startsWith(RESOURCE_MANAGER_PREVIEW_MARKER)) {
|
||||
return normalizePreviewPathHash(pathname);
|
||||
}
|
||||
|
||||
if (pathname.startsWith(CHAT_PUBLIC_DOT_CODEX_MARKER)) {
|
||||
return buildCanonicalChatApiResourcePath(pathname.slice(CHAT_PUBLIC_DOT_CODEX_MARKER.length));
|
||||
}
|
||||
|
||||
if (pathname.startsWith(CHAT_DOT_CODEX_MARKER)) {
|
||||
return buildCanonicalChatApiResourcePath(pathname.slice(CHAT_DOT_CODEX_MARKER.length));
|
||||
}
|
||||
} catch {
|
||||
// Fall through to handle relative and embedded resource paths below.
|
||||
}
|
||||
|
||||
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
|
||||
if (malformedResourceMatch?.[1]) {
|
||||
return `/${malformedResourceMatch[1]}`;
|
||||
@@ -125,18 +273,18 @@ function normalizeUrl(value: string) {
|
||||
const apiPath = normalized.slice(apiMarkerIndex);
|
||||
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
|
||||
return dotCodexIndex >= 0
|
||||
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
|
||||
? buildCanonicalChatApiResourcePath(apiPath.slice(dotCodexIndex + 1))
|
||||
: apiPath;
|
||||
}
|
||||
|
||||
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
|
||||
if (publicDotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
|
||||
return buildCanonicalChatApiResourcePath(normalized.slice(publicDotCodexIndex + 8));
|
||||
}
|
||||
|
||||
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
|
||||
if (dotCodexIndex >= 0) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
return buildCanonicalChatApiResourcePath(normalized.slice(dotCodexIndex + 1));
|
||||
}
|
||||
|
||||
if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) {
|
||||
@@ -220,6 +368,53 @@ function normalizePromptSelectedValues(value: unknown) {
|
||||
.filter((item, index, array) => array.indexOf(item) === index);
|
||||
}
|
||||
|
||||
function normalizePromptAttachment(value: unknown): ChatComposerAttachment | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = normalizeText(record.id);
|
||||
const name = normalizeText(record.name);
|
||||
const path = normalizeText(record.path);
|
||||
const publicUrl = normalizeText(record.publicUrl);
|
||||
const size = Number(record.size);
|
||||
const mimeType = normalizeText(record.mimeType);
|
||||
|
||||
if (!id || !name || !path || !publicUrl || !Number.isFinite(size) || size < 0 || !mimeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
path,
|
||||
publicUrl,
|
||||
size,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePromptAttachments(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [] as ChatComposerAttachment[];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
return value
|
||||
.map((item) => normalizePromptAttachment(item))
|
||||
.filter((item): item is ChatComposerAttachment => Boolean(item))
|
||||
.filter((item) => {
|
||||
if (seen.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePromptSteps(value: unknown): PromptStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -420,11 +615,12 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||
readOnly: record.readOnly === true || resolvedBy != null,
|
||||
selectedValues,
|
||||
resolvedBy,
|
||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||
resultText: normalizeText(record.resultText) || null,
|
||||
attachments: normalizePromptAttachments(record.attachments),
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,14 +2,25 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||
CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES,
|
||||
applyChatPromptSelectionPatch,
|
||||
buildChatConversationRequestUsageBySharedResourceTokenIdsQuery,
|
||||
buildChatConversationContextUpdateFields,
|
||||
buildChatPromptTargetSignature,
|
||||
buildChatConversationRequestPatchFromMessage,
|
||||
collectPromptSelectionCandidateRequestIds,
|
||||
collectRegisteredNotificationClientIds,
|
||||
hasMeaningfulChatSourceArtifacts,
|
||||
inferSourceChangeScreenTitle,
|
||||
isManagedChatShareSessionId,
|
||||
isVisibleConversationMessage,
|
||||
mergeChatConversationRequestStatus,
|
||||
normalizeStaleRequestItem,
|
||||
selectStaleOfflineNotificationClientIds,
|
||||
resolveNextConversationContextValue,
|
||||
resolveNextConversationChatTypeId,
|
||||
hasPendingAttentionVerificationRequest,
|
||||
isConversationAttentionPending,
|
||||
shouldClearConversationJobState,
|
||||
selectChatConversationResponseCandidate,
|
||||
} from './chat-room-service.js';
|
||||
@@ -39,6 +50,118 @@ test('resolveNextConversationContextValue prefers the requested chat type contex
|
||||
assert.equal(resolveNextConversationContextValue('old context', undefined, false), 'old context');
|
||||
});
|
||||
|
||||
test('isManagedChatShareSessionId detects managed shared chat rooms only', () => {
|
||||
assert.equal(isManagedChatShareSessionId('chat-share-room-mb2p1-1234abcd'), true);
|
||||
assert.equal(isManagedChatShareSessionId('chat-room-mb2p1-1234abcd'), false);
|
||||
assert.equal(isManagedChatShareSessionId(''), false);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields leaves title untouched when unrelated metadata changes', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '사용자 저장 제목',
|
||||
chat_type_id: 'codex-live',
|
||||
last_chat_type_id: 'codex-live',
|
||||
client_id: 'client-1',
|
||||
notify_offline: true,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-1',
|
||||
codexModel: 'gpt-5.4',
|
||||
},
|
||||
}),
|
||||
{
|
||||
codex_model: 'gpt-5.4',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields ignores undefined payload keys so title is not reset', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '브라우저 실검증 제목',
|
||||
request_badge_label: '기존 배지',
|
||||
chat_type_id: 'codex-live',
|
||||
last_chat_type_id: 'codex-live',
|
||||
client_id: 'client-1',
|
||||
notify_offline: true,
|
||||
},
|
||||
payload: {
|
||||
title: undefined,
|
||||
requestBadgeLabel: '새 배지',
|
||||
},
|
||||
}),
|
||||
{
|
||||
request_badge_label: '새 배지',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields updates shared room chat type metadata together', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '공유채팅',
|
||||
chat_type_id: 'general-request',
|
||||
last_chat_type_id: 'general-request',
|
||||
context_label: '일반 요청',
|
||||
context_description: 'old',
|
||||
notify_offline: true,
|
||||
},
|
||||
payload: {
|
||||
chatTypeId: 'codex-live-default',
|
||||
lastChatTypeId: 'codex-live-default',
|
||||
contextLabel: 'Codex Live 기본',
|
||||
contextDescription: null,
|
||||
},
|
||||
}),
|
||||
{
|
||||
chat_type_id: 'codex-live-default',
|
||||
last_chat_type_id: 'codex-live-default',
|
||||
context_label: 'Codex Live 기본',
|
||||
context_description: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields writes global notify flag only when no client is bound', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '공유채팅',
|
||||
chat_type_id: 'general-request',
|
||||
last_chat_type_id: 'general-request',
|
||||
notify_offline: false,
|
||||
},
|
||||
payload: {
|
||||
notifyOffline: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
notify_offline: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '공유채팅',
|
||||
chat_type_id: 'general-request',
|
||||
last_chat_type_id: 'general-request',
|
||||
client_id: 'client-1',
|
||||
notify_offline: false,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-1',
|
||||
notifyOffline: true,
|
||||
},
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => {
|
||||
assert.deepEqual(
|
||||
selectStaleOfflineNotificationClientIds(
|
||||
@@ -72,6 +195,166 @@ test('selectStaleOfflineNotificationClientIds keeps current or registered client
|
||||
);
|
||||
});
|
||||
|
||||
test('collectRegisteredNotificationClientIds keeps both web push client ids and device ids', () => {
|
||||
assert.deepEqual(
|
||||
Array.from(
|
||||
collectRegisteredNotificationClientIds([
|
||||
{
|
||||
device_id: 'web-device-1',
|
||||
client_id: 'client-preview-1',
|
||||
},
|
||||
{
|
||||
device_id: 'ios-device-1',
|
||||
},
|
||||
]),
|
||||
).sort(),
|
||||
['client-preview-1', 'ios-device-1', 'web-device-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hasPendingAttentionVerificationRequest keeps 일반 답변 in pending attention until manual completion', () => {
|
||||
assert.equal(
|
||||
hasPendingAttentionVerificationRequest(
|
||||
{
|
||||
status: 'completed',
|
||||
responseMessageId: 101,
|
||||
responseText: '일반 답변입니다.',
|
||||
requestOrigin: 'composer',
|
||||
},
|
||||
[],
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasPendingAttentionVerificationRequest(
|
||||
{
|
||||
status: 'completed',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
requestOrigin: 'composer',
|
||||
},
|
||||
[
|
||||
{
|
||||
author: 'codex',
|
||||
text: '```diff\n+ hello\n```',
|
||||
},
|
||||
],
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasPendingAttentionVerificationRequest(
|
||||
{
|
||||
status: 'completed',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
requestOrigin: 'composer',
|
||||
},
|
||||
[
|
||||
{
|
||||
author: 'codex',
|
||||
text: '짧은 진행 로그',
|
||||
},
|
||||
],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isConversationAttentionPending clears verification attention when 답변하기 child request exists', () => {
|
||||
assert.equal(
|
||||
isConversationAttentionPending({
|
||||
request: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'parent-request',
|
||||
requesterClientId: null,
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '기본처리',
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'completed',
|
||||
statusMessage: null,
|
||||
retryCount: 0,
|
||||
userMessageId: 1,
|
||||
userText: '원본 질문',
|
||||
responseMessageId: 2,
|
||||
responseText: '원본 답변',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: true,
|
||||
canDelete: false,
|
||||
manualPromptCompletedAt: null,
|
||||
manualVerificationCompletedAt: null,
|
||||
createdAt: '2026-05-26T00:00:00.000Z',
|
||||
updatedAt: '2026-05-26T00:01:00.000Z',
|
||||
answeredAt: '2026-05-26T00:01:00.000Z',
|
||||
terminalAt: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
relatedMessages: [
|
||||
{
|
||||
id: 2,
|
||||
author: 'codex',
|
||||
text: '원본 답변',
|
||||
timestamp: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
],
|
||||
childRequestCountByParentId: new Map([['parent-request', 1]]),
|
||||
promptFollowupCountByParentId: new Map(),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isConversationAttentionPending keeps completed 일반 답변 visible when no child request exists', () => {
|
||||
assert.equal(
|
||||
isConversationAttentionPending({
|
||||
request: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'standalone-request',
|
||||
requesterClientId: null,
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '기본처리',
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'completed',
|
||||
statusMessage: null,
|
||||
retryCount: 0,
|
||||
userMessageId: 1,
|
||||
userText: '독립 질문',
|
||||
responseMessageId: 2,
|
||||
responseText: '독립 답변',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: true,
|
||||
canDelete: false,
|
||||
manualPromptCompletedAt: null,
|
||||
manualVerificationCompletedAt: null,
|
||||
createdAt: '2026-05-26T00:00:00.000Z',
|
||||
updatedAt: '2026-05-26T00:01:00.000Z',
|
||||
answeredAt: '2026-05-26T00:01:00.000Z',
|
||||
terminalAt: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
relatedMessages: [
|
||||
{
|
||||
id: 2,
|
||||
author: 'codex',
|
||||
text: '독립 답변',
|
||||
timestamp: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
],
|
||||
childRequestCountByParentId: new Map(),
|
||||
promptFollowupCountByParentId: new Map(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
||||
assert.equal(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
@@ -84,10 +367,161 @@ test('buildChatConversationRequestPatchFromMessage ignores system progress messa
|
||||
);
|
||||
});
|
||||
|
||||
test('applyChatPromptSelectionPatch resolves the matched prompt with persisted selections', () => {
|
||||
const promptPart = {
|
||||
type: 'prompt' as const,
|
||||
title: '다음 단계 선택',
|
||||
description: '원하는 작업을 고르세요.',
|
||||
submitLabel: '선택 전달',
|
||||
mode: 'queue' as const,
|
||||
selectedValues: [],
|
||||
options: [],
|
||||
steps: [
|
||||
{
|
||||
key: 'scope',
|
||||
title: '범위',
|
||||
selectedValues: [],
|
||||
options: [
|
||||
{
|
||||
value: 'ui',
|
||||
label: 'UI',
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: 'API',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const patched = applyChatPromptSelectionPatch(
|
||||
[promptPart],
|
||||
{
|
||||
promptIndex: 0,
|
||||
promptTitle: promptPart.title,
|
||||
promptSignature: buildChatPromptTargetSignature(promptPart),
|
||||
selectedValues: ['ui'],
|
||||
stepSelections: [
|
||||
{
|
||||
stepKey: 'scope',
|
||||
selectedValues: ['ui'],
|
||||
freeText: '',
|
||||
},
|
||||
],
|
||||
summaryText: '범위: UI',
|
||||
attachments: [
|
||||
{
|
||||
id: 'attachment-1',
|
||||
name: 'spec.png',
|
||||
path: 'public/.codex_chat/test/resource/uploads/spec.png',
|
||||
publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
|
||||
size: 128,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
'2026-05-18T08:20:00.000Z',
|
||||
);
|
||||
|
||||
assert.ok(patched);
|
||||
assert.equal(patched?.[0]?.type, 'prompt');
|
||||
assert.deepEqual(patched?.[0]?.selectedValues, ['ui']);
|
||||
assert.equal(patched?.[0]?.readOnly, true);
|
||||
assert.equal(patched?.[0]?.resolvedBy, 'user');
|
||||
assert.equal(patched?.[0]?.resolvedAt, '2026-05-18T08:20:00.000Z');
|
||||
assert.equal(patched?.[0]?.resultText, '범위: UI');
|
||||
assert.equal(patched?.[0]?.attachments?.[0]?.name, 'spec.png');
|
||||
assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
|
||||
});
|
||||
|
||||
test('applyChatPromptSelectionPatch keeps followup text for free-text-only prompt submissions', () => {
|
||||
const promptPart = {
|
||||
type: 'prompt' as const,
|
||||
title: '다음 단계 선택',
|
||||
description: '원하는 작업을 고르세요.',
|
||||
submitLabel: '선택 전달',
|
||||
mode: 'queue' as const,
|
||||
selectedValues: [],
|
||||
options: [],
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const patched = applyChatPromptSelectionPatch(
|
||||
[promptPart],
|
||||
{
|
||||
promptIndex: 0,
|
||||
promptTitle: promptPart.title,
|
||||
promptSignature: buildChatPromptTargetSignature(promptPart),
|
||||
selectedValues: [],
|
||||
freeText: '',
|
||||
followupText: '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.',
|
||||
summaryText: '',
|
||||
},
|
||||
'2026-05-18T08:25:00.000Z',
|
||||
);
|
||||
|
||||
assert.ok(patched);
|
||||
assert.equal(patched?.[0]?.type, 'prompt');
|
||||
assert.equal(patched?.[0]?.resolvedBy, 'user');
|
||||
assert.equal(patched?.[0]?.resultText, '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.');
|
||||
});
|
||||
|
||||
test('collectPromptSelectionCandidateRequestIds includes descendant requests and prefers recent responses first', () => {
|
||||
assert.deepEqual(
|
||||
collectPromptSelectionCandidateRequestIds(
|
||||
[
|
||||
{
|
||||
request_id: 'root-request',
|
||||
parent_request_id: null,
|
||||
created_at: '2026-05-18T02:00:00.000Z',
|
||||
},
|
||||
{
|
||||
request_id: 'prompt-child',
|
||||
parent_request_id: 'root-request',
|
||||
created_at: '2026-05-18T02:01:00.000Z',
|
||||
},
|
||||
{
|
||||
request_id: 'composer-grandchild',
|
||||
parent_request_id: 'prompt-child',
|
||||
created_at: '2026-05-18T02:02:00.000Z',
|
||||
},
|
||||
{
|
||||
request_id: 'other-root',
|
||||
parent_request_id: null,
|
||||
created_at: '2026-05-18T02:03:00.000Z',
|
||||
},
|
||||
],
|
||||
'root-request',
|
||||
),
|
||||
['composer-grandchild', 'prompt-child', 'root-request'],
|
||||
);
|
||||
});
|
||||
|
||||
test('CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH allows long chat type guidance text', () => {
|
||||
assert.ok(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH >= 2691);
|
||||
});
|
||||
|
||||
test('chat request schema requires chat type columns for codex live persistence', () => {
|
||||
assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_id'), true);
|
||||
assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_label'), true);
|
||||
});
|
||||
|
||||
test('shared resource token usage summary query keeps aggregate aliases outside function bodies', () => {
|
||||
const query = buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(['token-1', 'token-2']);
|
||||
|
||||
assert.ok(query);
|
||||
|
||||
const sql = query?.toSQL().sql ?? '';
|
||||
|
||||
assert.match(sql, /count\(\*\) as request_count/i);
|
||||
assert.match(sql, /sum\(COALESCE\(total_tokens, 0\)\) as total_tokens/i);
|
||||
assert.match(sql, /sum\(CASE WHEN status = 'completed' THEN 1 ELSE 0 END\) as completed_request_count/i);
|
||||
assert.match(sql, /max\(COALESCE\(answered_at, terminal_at, updated_at, created_at\)\) as last_used_at/i);
|
||||
assert.doesNotMatch(sql, /END as completed_request_count\)/i);
|
||||
assert.doesNotMatch(sql, /created_at\) as last_used_at\)/i);
|
||||
});
|
||||
|
||||
test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => {
|
||||
assert.equal(
|
||||
isVisibleConversationMessage({
|
||||
@@ -130,6 +564,50 @@ test('hasMeaningfulChatSourceArtifacts requires real file or diff artifacts', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('inferSourceChangeScreenTitle prefers changed source menu over generic Codex Live title', () => {
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['src/app/main/ResourceManagementPage.tsx'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'리소스 관리 / 리소스 관리',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['src/app/main/PreviewAppOverlay.tsx'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'Preview App / 모바일 앱 열기',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['etc/servers/work-server/src/routes/resource-manager.ts'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'리소스 관리 / 리소스 관리',
|
||||
);
|
||||
});
|
||||
|
||||
test('inferSourceChangeScreenTitle falls back to docs and stored title when no menu rule matches', () => {
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['docs/project/overview.md'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'Docs / 프로젝트 구조',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['scripts/dev/check.sh'],
|
||||
'직접 지정 제목',
|
||||
),
|
||||
'직접 지정 제목',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
@@ -314,6 +792,28 @@ test('shouldClearConversationJobState keeps placeholder-only started responses w
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears in-progress state immediately after process restart when runtime is gone', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-9',
|
||||
currentJobStatus: 'started',
|
||||
currentStatusUpdatedAt: '2026-05-27T00:57:53.000Z',
|
||||
runtimeActive: false,
|
||||
nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
|
||||
processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
|
||||
request: {
|
||||
requestId: 'chat-req-9',
|
||||
status: 'started',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
terminalAt: null,
|
||||
updatedAt: '2026-05-27T00:58:10.000Z',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStaleRequestItem keeps queued requests when another request is currently active', () => {
|
||||
assert.deepEqual(
|
||||
normalizeStaleRequestItem(
|
||||
@@ -321,12 +821,19 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-queued',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
@@ -344,18 +851,88 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-queued',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
parentRequestId: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:30.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStaleRequestItem marks detached queued requests as failed after process restart when runtime is gone', () => {
|
||||
assert.deepEqual(
|
||||
normalizeStaleRequestItem(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-detached',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 12,
|
||||
userText: '끊긴 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-27T00:57:53.000Z',
|
||||
updatedAt: '2026-05-27T00:58:10.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
},
|
||||
{
|
||||
current_request_id: 'chat-req-running',
|
||||
current_job_status: 'started',
|
||||
current_status_updated_at: '2026-05-27T01:03:05.000Z',
|
||||
},
|
||||
{
|
||||
runtimeActive: false,
|
||||
nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
|
||||
processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
|
||||
},
|
||||
),
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-detached',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'failed',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 12,
|
||||
userText: '끊긴 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:30.000Z',
|
||||
canDelete: true,
|
||||
createdAt: '2026-05-27T00:57:53.000Z',
|
||||
updatedAt: '2026-05-27T00:58:10.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
terminalAt: '2026-05-27T00:58:10.000Z',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
ChatService,
|
||||
collectOfflineNotificationClientIds,
|
||||
createActivityLogMessage,
|
||||
buildAgenticCodexPrompt,
|
||||
@@ -17,9 +18,14 @@ import {
|
||||
fitActivityLogLines,
|
||||
isChatClientActivelyViewing,
|
||||
isAutomationRegistrationCountRequest,
|
||||
buildParticipantRequestInput,
|
||||
resolveCodexExecutionStages,
|
||||
resolveCodexParticipantsForExecution,
|
||||
resolveResponseTimestamp,
|
||||
resolveChatContextAppOrigin,
|
||||
resolveChatContextAppDomain,
|
||||
rewriteCodexOutputWithChatResources,
|
||||
sanitizeChatContextOverride,
|
||||
summarizeActivityProgressLine,
|
||||
shouldSendOfflineChatNotification,
|
||||
shouldUseAgenticCodexReply,
|
||||
@@ -28,9 +34,76 @@ import {
|
||||
} from './chat-service.js';
|
||||
import { extractChatMessageParts } from './chat-message-parts.js';
|
||||
|
||||
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
|
||||
test('ChatService rebinds a websocket to the payload session before handling a send request', () => {
|
||||
const logger = {
|
||||
error() {},
|
||||
warn() {},
|
||||
info() {},
|
||||
} as any;
|
||||
const service = new ChatService(logger);
|
||||
let supersededSocketClosed = false;
|
||||
|
||||
try {
|
||||
const currentSocket = {
|
||||
readyState: 1,
|
||||
close() {},
|
||||
} as any;
|
||||
const supersededTargetSocket = {
|
||||
readyState: 1,
|
||||
close() {
|
||||
supersededSocketClosed = true;
|
||||
},
|
||||
} as any;
|
||||
const currentSession = {
|
||||
sessionId: 'session-a',
|
||||
clientId: 'client-1',
|
||||
socket: currentSocket,
|
||||
lastSeenAt: 0,
|
||||
isDeleted: false,
|
||||
context: null,
|
||||
queue: [],
|
||||
activeRequestCount: 0,
|
||||
pendingQueueReleaseEventId: null,
|
||||
nextEventId: 1,
|
||||
eventHistory: [],
|
||||
messagePersistenceTail: Promise.resolve(),
|
||||
watchedRuntimeRequestId: null,
|
||||
};
|
||||
const targetSession = {
|
||||
sessionId: 'session-b',
|
||||
clientId: 'client-1',
|
||||
socket: supersededTargetSocket,
|
||||
lastSeenAt: 0,
|
||||
isDeleted: false,
|
||||
context: null,
|
||||
queue: [],
|
||||
activeRequestCount: 0,
|
||||
pendingQueueReleaseEventId: null,
|
||||
nextEventId: 1,
|
||||
eventHistory: [],
|
||||
messagePersistenceTail: Promise.resolve(),
|
||||
watchedRuntimeRequestId: null,
|
||||
};
|
||||
|
||||
(service as any).sessions.set(currentSession.sessionId, currentSession);
|
||||
(service as any).sessions.set(targetSession.sessionId, targetSession);
|
||||
(service as any).clientStates.set(currentSocket, currentSession);
|
||||
|
||||
const rebound = (service as any).rebindSocketToSession(currentSocket, 'session-b');
|
||||
|
||||
assert.equal(rebound, targetSession);
|
||||
assert.equal(currentSession.socket, null);
|
||||
assert.equal(targetSession.socket, currentSocket);
|
||||
assert.equal((service as any).clientStates.get(currentSocket), targetSession);
|
||||
assert.equal(supersededSocketClosed, true);
|
||||
} finally {
|
||||
service.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('collectOfflineNotificationClientIds keeps only explicit notification targets without duplicates', () => {
|
||||
assert.deepEqual(
|
||||
collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']),
|
||||
collectOfflineNotificationClientIds(['client-b', ' client-a ', '', 'client-c', 'client-b']),
|
||||
['client-b', 'client-a', 'client-c'],
|
||||
);
|
||||
});
|
||||
@@ -55,6 +128,29 @@ test('filterInactiveOfflineNotificationClientIds excludes only actively viewing
|
||||
);
|
||||
});
|
||||
|
||||
test('sanitizeChatContextOverride drops undefined codexModel without touching explicit null', () => {
|
||||
assert.deepEqual(
|
||||
sanitizeChatContextOverride({
|
||||
codexModel: undefined,
|
||||
chatTypeId: 'general-request',
|
||||
} as any),
|
||||
{
|
||||
chatTypeId: 'general-request',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
sanitizeChatContextOverride({
|
||||
codexModel: null,
|
||||
chatTypeId: 'general-request',
|
||||
}),
|
||||
{
|
||||
codexModel: null,
|
||||
chatTypeId: 'general-request',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldSendOfflineChatNotification blocks chat push when app setting disables room notifications', () => {
|
||||
assert.equal(
|
||||
shouldSendOfflineChatNotification({
|
||||
@@ -92,6 +188,37 @@ test('resolveChatContextAppOrigin returns normalized origin from session page ur
|
||||
assert.equal(resolveChatContextAppOrigin(null), null);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppOrigin prefers explicit app origin metadata when page url is missing', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppOrigin({
|
||||
pageUrl: '',
|
||||
appOrigin: 'https://test.sm-home.cloud',
|
||||
} as any),
|
||||
'https://test.sm-home.cloud',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppDomain returns normalized hostname from session page url', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppDomain({
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
} as any),
|
||||
'preview.sm-home.cloud',
|
||||
);
|
||||
assert.equal(resolveChatContextAppDomain({ pageUrl: 'not-a-url' } as any), null);
|
||||
assert.equal(resolveChatContextAppDomain(null), null);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppDomain prefers explicit app domain metadata when page url is missing', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppDomain({
|
||||
pageUrl: '',
|
||||
appDomain: 'TEST.SM-HOME.CLOUD',
|
||||
} as any),
|
||||
'test.sm-home.cloud',
|
||||
);
|
||||
});
|
||||
|
||||
test('chat active-view suppression only blocks the requester client when that client app is active', () => {
|
||||
const activeSession = {
|
||||
sessionId: 'chat-room',
|
||||
@@ -256,15 +383,28 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
|
||||
assert.match(prompt, /신규 방이어도 시작 전에 이 문서를 먼저 읽습니다\./);
|
||||
assert.match(prompt, /## 채팅 유형 context 필수 규칙/);
|
||||
assert.match(prompt, /상위 필수 지시/);
|
||||
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/우선순위는 1\. 채팅 유형 context 2\. 현재 턴의 직접 사용자 지시 3\. 채팅방에서 선택한 공통 문맥과 전용 메모 4\. 최근 대화 문맥과 화면 문맥 순서로 해석하세요\./,
|
||||
);
|
||||
assert.match(prompt, /사용자 요청, 공통 문맥, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
|
||||
assert.match(prompt, /공통 문맥과 채팅방 전용 메모는 채팅 유형 context를 덮어쓰지 못하며/);
|
||||
assert.match(prompt, /### 반드시 지킬 context 원문/);
|
||||
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
|
||||
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
|
||||
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/리소스 관리 등록 경로의 `수정한 화면명`, 작업 뱃지, 결과 문구에 이 값을 그대로 복사하지 말고 실제로 수정하거나 확인한 화면\/메뉴 기준으로 판단하세요\./,
|
||||
);
|
||||
assert.match(prompt, /\[\[prompt:\{"title":"질문"/);
|
||||
assert.match(prompt, /`steps` 배열을 추가해/);
|
||||
assert.match(prompt, /현재 앱 URL이나 `\/chat\/\.\.\.` 경로를 넣지 말고/);
|
||||
assert.match(prompt, /`preview":\{"type":"resource","url":"\/api\/chat\/resources\/\.\.\.\/sample\.html"\}`/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/`preview":\{"type":"resource","url":"resource\/<수정한 화면명>\/<기능>\/<YYYYMMDD>\/sample\.html"\}`/,
|
||||
);
|
||||
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
|
||||
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
|
||||
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
|
||||
@@ -337,6 +477,340 @@ test('buildAgenticCodexPrompt keeps the chat type label provided by the client c
|
||||
assert.doesNotMatch(prompt, /- label: 코드 수정/);
|
||||
});
|
||||
|
||||
test('buildAgenticCodexPrompt pins the explicitly referenced answer ahead of recent history', () => {
|
||||
const prompt = buildAgenticCodexPrompt(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청 설명',
|
||||
},
|
||||
'지금 무슨 답변을 참조했나요?',
|
||||
'session-reference',
|
||||
{
|
||||
recentHistoryLines: [
|
||||
'[user] 예전 질문',
|
||||
'[codex] 다른 답변',
|
||||
],
|
||||
referencedRequest: {
|
||||
sessionId: 'session-reference',
|
||||
requestId: 'request-123',
|
||||
requesterClientId: null,
|
||||
chatTypeId: 'general-request',
|
||||
chatTypeLabel: '일반 요청',
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'completed',
|
||||
statusMessage: '요청 처리 완료',
|
||||
retryCount: 0,
|
||||
userMessageId: 1,
|
||||
userText: 'preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼?',
|
||||
responseMessageId: 2,
|
||||
responseText: '배포 누락이 아니라 iframe 차단입니다.',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: true,
|
||||
canDelete: false,
|
||||
manualPromptCompletedAt: null,
|
||||
manualVerificationCompletedAt: null,
|
||||
createdAt: '2026-05-27T14:50:23.000Z',
|
||||
updatedAt: '2026-05-27T14:51:00.000Z',
|
||||
answeredAt: '2026-05-27T14:51:00.000Z',
|
||||
terminalAt: '2026-05-27T14:51:00.000Z',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(prompt, /## 답변 참조/);
|
||||
assert.match(prompt, /참조 requestId: request-123/);
|
||||
assert.match(prompt, /참조 사용자 요청: preview서버에서 test서버 채팅이 안열리는데 test서버 배포 해야돼\?/);
|
||||
assert.match(prompt, /참조 답변 본문: 배포 누락이 아니라 iframe 차단입니다\./);
|
||||
assert.match(prompt, /다른 최근 답변을 임의로 섞지 마세요\./);
|
||||
assert.ok(prompt.indexOf('## 답변 참조') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
|
||||
});
|
||||
|
||||
test('resolveCodexParticipantsForExecution expands moderator into opening and closing turns', () => {
|
||||
const participants = resolveCodexParticipantsForExecution({
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
codexParticipants: [
|
||||
{
|
||||
id: 'codex-1',
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
role: 'moderator',
|
||||
},
|
||||
{
|
||||
id: 'codex-2',
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
role: 'conversation',
|
||||
},
|
||||
{
|
||||
id: 'codex-3',
|
||||
name: 'Codex 3',
|
||||
model: 'gpt-5.4',
|
||||
role: 'conversation',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
assert.deepEqual(
|
||||
participants.map((participant) => `${participant.name}:${participant.turn}`),
|
||||
['Codex 1:opening', 'Codex 2:discussion', 'Codex 3:discussion', 'Codex 1:closing'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCodexParticipantsForExecution applies dispatcher policy with reviewer slot', () => {
|
||||
const participants = resolveCodexParticipantsForExecution({
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeExecutionPolicy: {
|
||||
mode: 'dispatcher-workers',
|
||||
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
|
||||
reviewPolicy: 'reviewer',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: true,
|
||||
},
|
||||
codexParticipants: [
|
||||
{
|
||||
id: 'codex-1',
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
role: 'default',
|
||||
},
|
||||
{
|
||||
id: 'codex-2',
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
role: 'default',
|
||||
},
|
||||
{
|
||||
id: 'codex-3',
|
||||
name: 'Codex 3',
|
||||
model: 'gpt-5.4',
|
||||
role: 'default',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
assert.deepEqual(
|
||||
participants.map((participant) => `${participant.name}:${participant.turn}:${participant.role}`),
|
||||
[
|
||||
'Codex 1:opening:moderator',
|
||||
'Codex 2:discussion:conversation',
|
||||
'Codex 3:review:reviewer',
|
||||
'Codex 1:closing:moderator',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutionStages runs direct multi-Codex default requests in parallel without closing summary', () => {
|
||||
const stages = resolveCodexExecutionStages(
|
||||
{
|
||||
codexModel: 'gpt-5.4',
|
||||
codexParticipants: [
|
||||
{ name: 'Codex 1', model: 'gpt-5.4', role: 'moderator' },
|
||||
{ name: 'Codex 2', model: 'gpt-5.4-mini', role: 'conversation' },
|
||||
{ name: 'Codex 3', model: 'gpt-5.4', role: 'default' },
|
||||
],
|
||||
chatTypeExecutionPolicy: {
|
||||
mode: 'default',
|
||||
participantBinding: 'manual',
|
||||
reviewPolicy: 'self',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: false,
|
||||
},
|
||||
} as any,
|
||||
'direct',
|
||||
);
|
||||
|
||||
assert.equal(stages.length, 1);
|
||||
assert.equal(stages[0]?.parallel, true);
|
||||
assert.deepEqual(
|
||||
stages[0]?.participants.map((participant) => `${participant.name}:${participant.turn}`),
|
||||
['Codex 1:standard', 'Codex 2:discussion', 'Codex 3:standard'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutionStages keeps moderator summary flow while parallelizing discussion stage', () => {
|
||||
const stages = resolveCodexExecutionStages(
|
||||
{
|
||||
codexModel: 'gpt-5.4',
|
||||
codexParticipants: [
|
||||
{ name: '회의기록자', model: 'gpt-5.4', role: 'moderator' },
|
||||
{ name: '구현자 A', model: 'gpt-5.4-mini', role: 'conversation' },
|
||||
{ name: '구현자 B', model: 'gpt-5.4', role: 'conversation' },
|
||||
],
|
||||
chatTypeExecutionPolicy: {
|
||||
mode: 'summary-free-talking',
|
||||
participantBinding: 'manual',
|
||||
reviewPolicy: 'self',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: false,
|
||||
finalSummaryRequired: true,
|
||||
},
|
||||
} as any,
|
||||
'direct',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
stages.map((stage) => ({
|
||||
parallel: stage.parallel,
|
||||
participants: stage.participants.map((participant) => `${participant.name}:${participant.turn}`),
|
||||
})),
|
||||
[
|
||||
{ parallel: false, participants: ['회의기록자:opening'] },
|
||||
{ parallel: true, participants: ['구현자 A:discussion', '구현자 B:discussion'] },
|
||||
{ parallel: false, participants: ['회의기록자:closing'] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildParticipantRequestInput gives moderator and discussion participants distinct instructions', () => {
|
||||
const participants = [
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'opening' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'conversation' as const,
|
||||
turn: 'discussion' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'closing' as const,
|
||||
},
|
||||
];
|
||||
const openingInput = buildParticipantRequestInput('요청 본문', participants[0], participants, []);
|
||||
const closingInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
text: '쟁점을 정리합니다.',
|
||||
},
|
||||
{
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
text: '구현 관점 보완입니다.',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(openingInput, /회의 기록자 겸 중재자/);
|
||||
assert.match(openingInput, /실행 정책: default/);
|
||||
assert.match(openingInput, /Codex 1\(gpt-5\.4, 중재 시작\) -> Codex 2\(gpt-5\.4, 프리토킹\) -> Codex 1\(gpt-5\.4, 최종 정리\)/);
|
||||
assert.match(closingInput, /최종 결론과 남은 쟁점을 정리/);
|
||||
assert.match(closingInput, /이전 Codex 발언/);
|
||||
});
|
||||
|
||||
test('buildParticipantRequestInput uses dispatcher and reviewer instructions from execution policy', () => {
|
||||
const participants = [
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'opening' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'conversation' as const,
|
||||
turn: 'discussion' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 3',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'reviewer' as const,
|
||||
turn: 'review' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const dispatcherInput = buildParticipantRequestInput('요청 본문', participants[0], participants, [], null, {
|
||||
mode: 'dispatcher-workers',
|
||||
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
|
||||
reviewPolicy: 'reviewer',
|
||||
resourceReportPolicy: 'always',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: true,
|
||||
});
|
||||
const reviewerInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [], null, {
|
||||
mode: 'dispatcher-workers',
|
||||
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
|
||||
reviewPolicy: 'reviewer',
|
||||
resourceReportPolicy: 'always',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: true,
|
||||
});
|
||||
|
||||
assert.match(dispatcherInput, /중계 지시자/);
|
||||
assert.match(dispatcherInput, /결과물 보고 정책: always/);
|
||||
assert.match(reviewerInput, /최종 검토자/);
|
||||
assert.match(reviewerInput, /최종 종합 강제: 예/);
|
||||
});
|
||||
|
||||
test('buildParticipantRequestInput keeps prompt parent question context in a separate server block', () => {
|
||||
const participant = {
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'opening' as const,
|
||||
};
|
||||
|
||||
const input = buildParticipantRequestInput(
|
||||
'실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요.\n\n추가 요청:\n테스트',
|
||||
participant,
|
||||
[participant],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
{
|
||||
key: 'prompt_parent_question',
|
||||
promptTitle: '다음 확인 선택',
|
||||
promptDescription: '이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다.',
|
||||
parentQuestionText: 'prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요(질의 응답 부모 명확하게 전달)',
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(input, /^실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요\./);
|
||||
assert.match(input, /prompt 문맥 참조:/);
|
||||
assert.match(input, /상위 사용자 질의: prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요/);
|
||||
assert.match(input, /대상 질의: 다음 확인 선택/);
|
||||
assert.match(input, /질의 설명: 이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다\./);
|
||||
});
|
||||
|
||||
test('buildAgenticCodexPrompt includes room-selected default contexts as structured sections', () => {
|
||||
const prompt = buildAgenticCodexPrompt(
|
||||
{
|
||||
@@ -370,11 +844,13 @@ test('buildAgenticCodexPrompt includes room-selected default contexts as structu
|
||||
assert.match(prompt, /## 채팅 유형 context 원문/);
|
||||
assert.match(prompt, /채팅 유형 원문 규칙/);
|
||||
assert.match(prompt, /## 채팅방에서 선택한 공통 문맥/);
|
||||
assert.match(prompt, /채팅 유형 context와 충돌하지 않는 범위에서만 보조로 적용하세요\./);
|
||||
assert.match(prompt, /### 권한 관리 공통 문맥/);
|
||||
assert.match(prompt, /채팅방에서 선택된 공통 문맥도 항상 반영합니다\./);
|
||||
assert.match(prompt, /### 방 전용 공통 문맥/);
|
||||
assert.match(prompt, /신규 방에서도 같은 규칙으로 동작해야 합니다\./);
|
||||
assert.match(prompt, /## 채팅방 전용 Context · 운영 메모/);
|
||||
assert.match(prompt, /채팅 유형 context를 바꾸지 않으며, 충돌하지 않는 범위에서만 보조로 해석하세요\./);
|
||||
assert.match(prompt, /preview 기준으로 검증합니다\./);
|
||||
});
|
||||
|
||||
@@ -590,6 +1066,7 @@ test('ensureChatSessionReferenceResource summarizes default contexts without cop
|
||||
const content = await readFile(absolutePath, 'utf8');
|
||||
|
||||
assert.match(content, /## 현재 채팅 유형 context 요약/);
|
||||
assert.match(content, /채팅 유형 context가 최상위이며, 공통 문맥과 채팅방 전용 메모는 그 아래 보조 문맥으로만 사용합니다\./);
|
||||
assert.match(content, /### 적용 중인 공통 문맥/);
|
||||
assert.match(content, /- 개발 리소스 관리/);
|
||||
assert.match(content, /- 리소스 출력/);
|
||||
@@ -831,6 +1308,54 @@ test('extractChatMessageParts keeps readonly auto-selected prompt state', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps prompt writable when only selectedValues exist', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
[
|
||||
'이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
|
||||
'[[prompt:{"title":"후속 범위 선택","description":"미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.","selectedValues":["mobile-cleanup"],"options":[{"label":"모바일 정리","value":"mobile-cleanup","description":"모바일 여백 정리"},{"label":"데스크톱 정리","value":"desktop-cleanup","description":"데스크톱 여백 정리"}]}]]',
|
||||
].join('\n'),
|
||||
),
|
||||
{
|
||||
strippedText: '이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '후속 범위 선택',
|
||||
description: '미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.',
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: ['mobile-cleanup'],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: '모바일 정리',
|
||||
value: 'mobile-cleanup',
|
||||
description: '모바일 여백 정리',
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: '데스크톱 정리',
|
||||
value: 'desktop-cleanup',
|
||||
description: '데스크톱 여백 정리',
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps prompt preview payloads for image markdown html and resource', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
@@ -915,6 +1440,96 @@ test('extractChatMessageParts keeps prompt preview payloads for image markdown h
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts preserves resource preview hash fragments when converting resource paths', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html#option-a"}}]}]]',
|
||||
),
|
||||
{
|
||||
strippedText: '',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '시안 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'option-a',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts restores encoded resource preview hash fragments', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
'[[prompt:{"title":"시안 선택","options":[{"label":"A안","value":"option-a","preview":{"type":"resource","url":"resource/Codex Live/공유채팅/채팅방 헤더 재배치 제안/20260527/docs/chat-room-header-notification-preview.html%23option-a"}}]}]]',
|
||||
),
|
||||
{
|
||||
strippedText: '',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '시안 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: 'A안',
|
||||
value: 'option-a',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/resource-manager/preview/Codex%20Live/%EA%B3%B5%EC%9C%A0%EC%B1%84%ED%8C%85/%EC%B1%84%ED%8C%85%EB%B0%A9%20%ED%97%A4%EB%8D%94%20%EC%9E%AC%EB%B0%B0%EC%B9%98%20%EC%A0%9C%EC%95%88/20260527/docs/chat-room-header-notification-preview.html#option-a',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts supports stepper prompt steps', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
@@ -1154,6 +1769,39 @@ test('extractChatMessageParts promotes standalone markdown links into structured
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps angle-bracket internal resource markdown links as plain text', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
|
||||
'\n',
|
||||
),
|
||||
),
|
||||
{
|
||||
strippedText: ['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
|
||||
'\n',
|
||||
),
|
||||
parts: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts promotes standalone markdown links with angle-bracket external targets into structured link cards', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts('- [판매글 열기](<https://www.daangn.com/kr/buy-sell/mac studio>)'),
|
||||
{
|
||||
strippedText: '',
|
||||
parts: [
|
||||
{
|
||||
type: 'link_card',
|
||||
title: '판매글 열기',
|
||||
url: 'https://www.daangn.com/kr/buy-sell/mac studio',
|
||||
actionLabel: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts promotes standalone urls with the previous line as the card title', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
@@ -1257,6 +1905,48 @@ test('parseStructuredCodexStdoutLine strips nested command execution JSON from r
|
||||
activityLog: '# 결과: 완료(0)\n# 출력: model = "gpt-5.4"',
|
||||
completedText: '',
|
||||
deltaText: '',
|
||||
usageSnapshot: null,
|
||||
shouldKeepRaw: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('parseStructuredCodexStdoutLine keeps usage snapshots from response.completed JSON', () => {
|
||||
assert.deepEqual(
|
||||
parseStructuredCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
content: [{ type: 'output_text', text: '최종 응답입니다.' }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 120,
|
||||
output_tokens: 45,
|
||||
cached_input_tokens: 30,
|
||||
reasoning_output_tokens: 10,
|
||||
total_tokens: 165,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
activityLog: '',
|
||||
completedText: '최종 응답입니다.',
|
||||
deltaText: '',
|
||||
usageSnapshot: {
|
||||
tokenTotals: {
|
||||
total: 165,
|
||||
input: 120,
|
||||
output: 45,
|
||||
cached: 30,
|
||||
reasoning: 10,
|
||||
},
|
||||
totalTokens: 165,
|
||||
},
|
||||
shouldKeepRaw: false,
|
||||
},
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
CHAT_CONVERSATION_TABLE,
|
||||
ensureChatConversationTables,
|
||||
} from './chat-room-service.js';
|
||||
|
||||
const CHAT_SHARE_TOKEN_ROOM_MAP_TABLE = 'chat_share_token_room_maps';
|
||||
|
||||
export type ChatShareTokenRoomMapItem = {
|
||||
tokenId: string;
|
||||
sessionId: string;
|
||||
rootRequestId: string;
|
||||
isDefault: boolean;
|
||||
sortOrder: number;
|
||||
createdByClientId: string | null;
|
||||
title: string;
|
||||
requestBadgeLabel: string | null;
|
||||
chatTypeId: string | null;
|
||||
lastChatTypeId: string | null;
|
||||
contextLabel: string | null;
|
||||
contextDescription: string | null;
|
||||
notifyOffline: boolean;
|
||||
linkContext: ChatShareRoomLinkContext | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
conversationUpdatedAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatShareRoomLinkContext = {
|
||||
kind: 'linked-session';
|
||||
sourceSessionId: string;
|
||||
sourceRequestId: string;
|
||||
sourceTitle: string | null;
|
||||
sourceRequestPreview: string | null;
|
||||
sourceChatTypeLabel: string | null;
|
||||
linkedAt: string | null;
|
||||
};
|
||||
|
||||
function normalizeOptionalText(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeRequiredText(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown) {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function normalizeInteger(value: unknown, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeDateTime(value: unknown) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseChatShareRoomLinkContext(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized) as Record<string, unknown>;
|
||||
|
||||
if (parsed.kind !== 'linked-session') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceSessionId = normalizeRequiredText(parsed.sourceSessionId);
|
||||
const sourceRequestId = normalizeRequiredText(parsed.sourceRequestId);
|
||||
|
||||
if (!sourceSessionId || !sourceRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'linked-session',
|
||||
sourceSessionId,
|
||||
sourceRequestId,
|
||||
sourceTitle: normalizeOptionalText(parsed.sourceTitle),
|
||||
sourceRequestPreview: normalizeOptionalText(parsed.sourceRequestPreview),
|
||||
sourceChatTypeLabel: normalizeOptionalText(parsed.sourceChatTypeLabel),
|
||||
linkedAt: normalizeDateTime(parsed.linkedAt),
|
||||
} satisfies ChatShareRoomLinkContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyChatShareRoomLinkContext(value: ChatShareRoomLinkContext | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.kind !== 'linked-session') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceSessionId = normalizeRequiredText(value.sourceSessionId);
|
||||
const sourceRequestId = normalizeRequiredText(value.sourceRequestId);
|
||||
|
||||
if (!sourceSessionId || !sourceRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
kind: 'linked-session',
|
||||
sourceSessionId,
|
||||
sourceRequestId,
|
||||
sourceTitle: normalizeOptionalText(value.sourceTitle),
|
||||
sourceRequestPreview: normalizeOptionalText(value.sourceRequestPreview),
|
||||
sourceChatTypeLabel: normalizeOptionalText(value.sourceChatTypeLabel),
|
||||
linkedAt: normalizeDateTime(value.linkedAt),
|
||||
});
|
||||
}
|
||||
|
||||
function mapChatShareTokenRoomRow(row: Record<string, unknown>): ChatShareTokenRoomMapItem {
|
||||
return {
|
||||
tokenId: normalizeRequiredText(row.shared_resource_token_id),
|
||||
sessionId: normalizeRequiredText(row.session_id),
|
||||
rootRequestId: normalizeRequiredText(row.root_request_id),
|
||||
isDefault: normalizeBoolean(row.is_default),
|
||||
sortOrder: normalizeInteger(row.sort_order),
|
||||
createdByClientId: normalizeOptionalText(row.created_by_client_id),
|
||||
title: normalizeRequiredText(row.title) || '공유 채팅방',
|
||||
requestBadgeLabel: normalizeOptionalText(row.request_badge_label),
|
||||
chatTypeId: normalizeOptionalText(row.chat_type_id),
|
||||
lastChatTypeId: normalizeOptionalText(row.last_chat_type_id),
|
||||
contextLabel: normalizeOptionalText(row.context_label),
|
||||
contextDescription: normalizeOptionalText(row.context_description),
|
||||
notifyOffline: normalizeBoolean(row.notify_offline),
|
||||
linkContext: parseChatShareRoomLinkContext(row.link_context_json),
|
||||
createdAt: normalizeDateTime(row.created_at),
|
||||
updatedAt: normalizeDateTime(row.updated_at),
|
||||
conversationUpdatedAt: normalizeDateTime(row.conversation_updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureChatShareTokenRoomMapTable() {
|
||||
await ensureChatConversationTables();
|
||||
|
||||
const hasTable = await db.schema.hasTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('shared_resource_token_id', 120).notNullable().index();
|
||||
table.string('session_id', 120).notNullable().index();
|
||||
table.string('root_request_id', 120).notNullable();
|
||||
table.boolean('is_default').notNullable().defaultTo(false);
|
||||
table.integer('sort_order').notNullable().defaultTo(0);
|
||||
table.string('created_by_client_id', 120).nullable();
|
||||
table.timestamp('archived_at', { useTz: true }).nullable().index();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['shared_resource_token_id', 'session_id']);
|
||||
});
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).notNullable().index()],
|
||||
['session_id', (table) => table.string('session_id', 120).notNullable().index()],
|
||||
['root_request_id', (table) => table.string('root_request_id', 120).notNullable().defaultTo('')],
|
||||
['is_default', (table) => table.boolean('is_default').notNullable().defaultTo(false)],
|
||||
['sort_order', (table) => table.integer('sort_order').notNullable().defaultTo(0)],
|
||||
['created_by_client_id', (table) => table.string('created_by_client_id', 120).nullable()],
|
||||
['link_context_json', (table) => table.text('link_context_json').nullable()],
|
||||
['archived_at', (table) => table.timestamp('archived_at', { useTz: true }).nullable().index()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listChatShareTokenRoomMaps(tokenId: string) {
|
||||
const normalizedTokenId = tokenId.trim();
|
||||
|
||||
if (!normalizedTokenId) {
|
||||
return [] as ChatShareTokenRoomMapItem[];
|
||||
}
|
||||
|
||||
await ensureChatShareTokenRoomMapTable();
|
||||
|
||||
const rows = await db(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`)
|
||||
.leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id')
|
||||
.select(
|
||||
'room_map.shared_resource_token_id',
|
||||
'room_map.session_id',
|
||||
'room_map.root_request_id',
|
||||
'room_map.is_default',
|
||||
'room_map.sort_order',
|
||||
'room_map.created_by_client_id',
|
||||
'room_map.created_at',
|
||||
'room_map.updated_at',
|
||||
'conversation.title',
|
||||
'conversation.request_badge_label',
|
||||
'conversation.chat_type_id',
|
||||
'conversation.last_chat_type_id',
|
||||
'conversation.context_label',
|
||||
'conversation.context_description',
|
||||
'conversation.notify_offline',
|
||||
'room_map.link_context_json',
|
||||
'conversation.updated_at as conversation_updated_at',
|
||||
)
|
||||
.where({ 'room_map.shared_resource_token_id': normalizedTokenId })
|
||||
.whereNull('room_map.archived_at')
|
||||
.orderBy('room_map.is_default', 'desc')
|
||||
.orderBy('room_map.sort_order', 'asc')
|
||||
.orderBy('room_map.created_at', 'asc');
|
||||
|
||||
return rows.map((row) => mapChatShareTokenRoomRow(row));
|
||||
}
|
||||
|
||||
export async function getChatShareTokenRoomMap(tokenId: string, sessionId: string) {
|
||||
const normalizedTokenId = tokenId.trim();
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedTokenId || !normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rooms = await listChatShareTokenRoomMaps(normalizedTokenId);
|
||||
return rooms.find((item) => item.sessionId === normalizedSessionId) ?? null;
|
||||
}
|
||||
|
||||
export async function upsertChatShareTokenRoomMap(args: {
|
||||
tokenId: string;
|
||||
sessionId: string;
|
||||
rootRequestId: string;
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number | null;
|
||||
createdByClientId?: string | null;
|
||||
linkContext?: ChatShareRoomLinkContext | null;
|
||||
}) {
|
||||
const normalizedTokenId = args.tokenId.trim();
|
||||
const normalizedSessionId = args.sessionId.trim();
|
||||
const normalizedRootRequestId = args.rootRequestId.trim();
|
||||
|
||||
if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureChatShareTokenRoomMapTable();
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const current = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
|
||||
.where({
|
||||
shared_resource_token_id: normalizedTokenId,
|
||||
session_id: normalizedSessionId,
|
||||
})
|
||||
.whereNull('archived_at')
|
||||
.first();
|
||||
|
||||
const maxSortOrderRow = await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
|
||||
.where({ shared_resource_token_id: normalizedTokenId })
|
||||
.whereNull('archived_at')
|
||||
.max<{ max_sort_order?: number | string | null }>('sort_order as max_sort_order')
|
||||
.first();
|
||||
const nextSortOrder = args.sortOrder != null
|
||||
? Math.max(0, Math.trunc(Number(args.sortOrder) || 0))
|
||||
: Math.max(0, normalizeInteger(maxSortOrderRow?.max_sort_order) + (current ? 0 : 1));
|
||||
|
||||
if (args.isDefault === true) {
|
||||
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
|
||||
.where({ shared_resource_token_id: normalizedTokenId })
|
||||
.whereNull('archived_at')
|
||||
.update({
|
||||
is_default: false,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
shared_resource_token_id: normalizedTokenId,
|
||||
session_id: normalizedSessionId,
|
||||
root_request_id: normalizedRootRequestId,
|
||||
is_default: args.isDefault === true,
|
||||
sort_order: nextSortOrder,
|
||||
created_by_client_id: normalizeOptionalText(args.createdByClientId),
|
||||
link_context_json:
|
||||
args.linkContext === undefined
|
||||
? (current?.link_context_json ?? null)
|
||||
: stringifyChatShareRoomLinkContext(args.linkContext),
|
||||
archived_at: null,
|
||||
updated_at: db.fn.now(),
|
||||
};
|
||||
|
||||
if (current) {
|
||||
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
|
||||
.where({
|
||||
shared_resource_token_id: normalizedTokenId,
|
||||
session_id: normalizedSessionId,
|
||||
})
|
||||
.update(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE).insert({
|
||||
...payload,
|
||||
created_at: db.fn.now(),
|
||||
});
|
||||
});
|
||||
|
||||
return getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId);
|
||||
}
|
||||
|
||||
export async function ensureDefaultChatShareTokenRoomMap(args: {
|
||||
tokenId: string;
|
||||
sessionId: string;
|
||||
rootRequestId: string;
|
||||
createdByClientId?: string | null;
|
||||
}) {
|
||||
const normalizedTokenId = args.tokenId.trim();
|
||||
const normalizedSessionId = args.sessionId.trim();
|
||||
const normalizedRootRequestId = args.rootRequestId.trim();
|
||||
|
||||
if (!normalizedTokenId || !normalizedSessionId || !normalizedRootRequestId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existing = await getChatShareTokenRoomMap(normalizedTokenId, normalizedSessionId);
|
||||
|
||||
if (!existing) {
|
||||
await upsertChatShareTokenRoomMap({
|
||||
tokenId: normalizedTokenId,
|
||||
sessionId: normalizedSessionId,
|
||||
rootRequestId: normalizedRootRequestId,
|
||||
isDefault: true,
|
||||
createdByClientId: args.createdByClientId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const rooms = await listChatShareTokenRoomMaps(normalizedTokenId);
|
||||
|
||||
if (rooms.some((item) => item.isDefault)) {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
await upsertChatShareTokenRoomMap({
|
||||
tokenId: normalizedTokenId,
|
||||
sessionId: normalizedSessionId,
|
||||
rootRequestId: normalizedRootRequestId,
|
||||
isDefault: true,
|
||||
createdByClientId: args.createdByClientId ?? null,
|
||||
});
|
||||
|
||||
return listChatShareTokenRoomMaps(normalizedTokenId);
|
||||
}
|
||||
|
||||
export async function resolveChatShareTokenRoomSessionIds(tokenId: string) {
|
||||
const rooms = await listChatShareTokenRoomMaps(tokenId);
|
||||
return rooms.map((item) => item.sessionId).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function archiveChatShareTokenRoomMap(tokenId: string, sessionId: string) {
|
||||
const normalizedTokenId = tokenId.trim();
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedTokenId || !normalizedSessionId) {
|
||||
return {
|
||||
archived: false,
|
||||
archivedRoom: null,
|
||||
nextDefaultRoom: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
await ensureChatShareTokenRoomMapTable();
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const current = await trx(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`)
|
||||
.leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id')
|
||||
.select(
|
||||
'room_map.shared_resource_token_id',
|
||||
'room_map.session_id',
|
||||
'room_map.root_request_id',
|
||||
'room_map.is_default',
|
||||
'room_map.sort_order',
|
||||
'room_map.created_by_client_id',
|
||||
'room_map.created_at',
|
||||
'room_map.updated_at',
|
||||
'conversation.title',
|
||||
'conversation.request_badge_label',
|
||||
'conversation.chat_type_id',
|
||||
'conversation.last_chat_type_id',
|
||||
'conversation.context_label',
|
||||
'conversation.context_description',
|
||||
'conversation.notify_offline',
|
||||
'conversation.updated_at as conversation_updated_at',
|
||||
)
|
||||
.where({
|
||||
'room_map.shared_resource_token_id': normalizedTokenId,
|
||||
'room_map.session_id': normalizedSessionId,
|
||||
})
|
||||
.whereNull('room_map.archived_at')
|
||||
.first();
|
||||
|
||||
if (!current) {
|
||||
return {
|
||||
archived: false,
|
||||
archivedRoom: null,
|
||||
nextDefaultRoom: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
|
||||
.where({
|
||||
shared_resource_token_id: normalizedTokenId,
|
||||
session_id: normalizedSessionId,
|
||||
})
|
||||
.whereNull('archived_at')
|
||||
.update({
|
||||
archived_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
let nextDefaultRoom: ChatShareTokenRoomMapItem | null = null;
|
||||
|
||||
if (current.is_default) {
|
||||
const nextDefaultRow = await trx(`${CHAT_SHARE_TOKEN_ROOM_MAP_TABLE} as room_map`)
|
||||
.leftJoin(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'room_map.session_id')
|
||||
.select(
|
||||
'room_map.shared_resource_token_id',
|
||||
'room_map.session_id',
|
||||
'room_map.root_request_id',
|
||||
'room_map.is_default',
|
||||
'room_map.sort_order',
|
||||
'room_map.created_by_client_id',
|
||||
'room_map.created_at',
|
||||
'room_map.updated_at',
|
||||
'conversation.title',
|
||||
'conversation.request_badge_label',
|
||||
'conversation.chat_type_id',
|
||||
'conversation.last_chat_type_id',
|
||||
'conversation.context_label',
|
||||
'conversation.context_description',
|
||||
'conversation.notify_offline',
|
||||
'conversation.updated_at as conversation_updated_at',
|
||||
)
|
||||
.where({ 'room_map.shared_resource_token_id': normalizedTokenId })
|
||||
.whereNull('room_map.archived_at')
|
||||
.orderBy('room_map.sort_order', 'asc')
|
||||
.orderBy('room_map.created_at', 'asc')
|
||||
.first();
|
||||
|
||||
if (nextDefaultRow) {
|
||||
await trx(CHAT_SHARE_TOKEN_ROOM_MAP_TABLE)
|
||||
.where({
|
||||
shared_resource_token_id: normalizedTokenId,
|
||||
session_id: nextDefaultRow.session_id,
|
||||
})
|
||||
.whereNull('archived_at')
|
||||
.update({
|
||||
is_default: true,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
nextDefaultRoom = mapChatShareTokenRoomRow({
|
||||
...nextDefaultRow,
|
||||
is_default: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
archived: true,
|
||||
archivedRoom: mapChatShareTokenRoomRow(current),
|
||||
nextDefaultRoom,
|
||||
} as const;
|
||||
});
|
||||
}
|
||||
@@ -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 체크리스트 실행';
|
||||
|
||||
@@ -186,7 +186,7 @@ test('syncMainProjectBranchForReservedRestart commits local changes and pushes t
|
||||
}
|
||||
});
|
||||
|
||||
test('syncMainProjectBranchForReservedRestart keeps reserved restart local when local main mode is enabled', async () => {
|
||||
test('syncMainProjectBranchForReservedRestart skips git sync when local main mode is enabled', async () => {
|
||||
const { repoPath } = await createRepo();
|
||||
const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE;
|
||||
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
|
||||
@@ -195,25 +195,26 @@ test('syncMainProjectBranchForReservedRestart keeps reserved restart local when
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await writeFile(path.join(repoPath, 'note.txt'), 'hello local reserved restart\n', 'utf8');
|
||||
|
||||
const headBefore = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
const remoteHeadBefore = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
||||
const statusBefore = await runGit(repoPath, ['status', '--porcelain']);
|
||||
const result = await syncMainProjectBranchForReservedRestart(
|
||||
repoPath,
|
||||
'main',
|
||||
'chore: sync main before reserved restart',
|
||||
);
|
||||
const head = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
const headAfter = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
const remoteHeadAfter = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
||||
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
|
||||
const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']);
|
||||
const statusAfter = await runGit(repoPath, ['status', '--porcelain']);
|
||||
|
||||
assert.equal(result.committed, true);
|
||||
assert.equal(result.commitMessage, 'chore: sync main before reserved restart');
|
||||
assert.equal(result.head, head);
|
||||
assert.equal(result.committed, false);
|
||||
assert.equal(result.commitMessage, null);
|
||||
assert.equal(result.head, null);
|
||||
assert.equal(result.syncMode, 'local');
|
||||
assert.equal(remoteHeadAfter, remoteHeadBefore);
|
||||
assert.notEqual(remoteHeadAfter, head);
|
||||
assert.equal(mainMessage, 'chore: sync main before reserved restart');
|
||||
assert.equal(noteContent, 'hello local reserved restart');
|
||||
assert.equal(headAfter, headBefore);
|
||||
assert.equal(statusBefore, '?? note.txt');
|
||||
assert.equal(statusAfter, '?? note.txt');
|
||||
} finally {
|
||||
if (previousLocalMainMode === undefined) {
|
||||
delete process.env.PLAN_LOCAL_MAIN_MODE;
|
||||
|
||||
@@ -174,22 +174,25 @@ export async function syncMainProjectBranchForReservedRestart(
|
||||
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
|
||||
|
||||
if (useLocalMainMode) {
|
||||
await assertBranchExists(repoPath, branchName);
|
||||
await runGit(repoPath, ['switch', branchName]);
|
||||
} else {
|
||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||
await ensureLocalBranchFromRemote(repoPath, branchName);
|
||||
return {
|
||||
branchName,
|
||||
commitMessage: null,
|
||||
committed: false,
|
||||
head: null,
|
||||
syncMode: 'local' as const,
|
||||
};
|
||||
}
|
||||
|
||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||
await ensureLocalBranchFromRemote(repoPath, branchName);
|
||||
|
||||
const hadChanges = await hasWorkingTreeChanges(repoPath);
|
||||
if (hadChanges) {
|
||||
await commitAllChanges(repoPath, commitMessage);
|
||||
}
|
||||
|
||||
if (!useLocalMainMode) {
|
||||
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
|
||||
await pushBranch(repoPath, branchName);
|
||||
}
|
||||
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
|
||||
await pushBranch(repoPath, branchName);
|
||||
|
||||
const { stdout: head } = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
@@ -198,7 +201,7 @@ export async function syncMainProjectBranchForReservedRestart(
|
||||
commitMessage: hadChanges ? commitMessage : null,
|
||||
committed: hadChanges,
|
||||
head,
|
||||
syncMode: useLocalMainMode ? 'local' as const : 'remote' as const,
|
||||
syncMode: 'remote' as const,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -66,6 +67,7 @@ export const sendIosNotificationSchema = z.object({
|
||||
body: z.string().trim().min(1),
|
||||
data: z.record(z.string(), z.string()).default({}),
|
||||
threadId: z.string().trim().min(1).optional(),
|
||||
targetDeviceIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
|
||||
targetClientIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
|
||||
targetAppOrigins: z.array(z.string().trim().url().max(500)).max(50).optional(),
|
||||
targetAppDomains: z.array(z.string().trim().min(1).max(255)).max(50).optional(),
|
||||
@@ -80,8 +82,44 @@ type NotificationPreferenceTarget = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
|
||||
return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
function normalizeTargetDeviceIds(payload: {
|
||||
targetDeviceIds?: string[];
|
||||
}) {
|
||||
return [...new Set((payload.targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeTargetClientIds(payload: {
|
||||
targetClientIds?: string[];
|
||||
}) {
|
||||
return [...new Set((payload.targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
|
||||
return [...new Set(values.map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
async function removeLegacyWebPushSubscriptionsForRegistration(args: {
|
||||
endpoint: string;
|
||||
deviceId?: string;
|
||||
clientId?: string;
|
||||
userAgent?: string;
|
||||
appOrigin?: string;
|
||||
}) {
|
||||
const appOrigin = normalizeAppOrigin(args.appOrigin);
|
||||
const userAgent = String(args.userAgent ?? '').trim();
|
||||
const deviceId = String(args.deviceId ?? '').trim();
|
||||
const clientId = String(args.clientId ?? '').trim();
|
||||
|
||||
if (!appOrigin || !userAgent || !deviceId || !clientId || deviceId === clientId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.whereNot({ endpoint: args.endpoint })
|
||||
.andWhere({ app_origin: appOrigin, user_agent: userAgent })
|
||||
.whereNull('client_id')
|
||||
.whereNotIn('device_id', [deviceId, clientId])
|
||||
.delete();
|
||||
}
|
||||
|
||||
function normalizeTargetAppOrigins(targetAppOrigins: string[] | undefined) {
|
||||
@@ -92,12 +130,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
|
||||
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
|
||||
}
|
||||
|
||||
function isAllowedTargetClientId(deviceId: string, targetClientIds: string[]) {
|
||||
if (targetClientIds.length === 0) {
|
||||
function isAllowedTargetRecipient(
|
||||
target: {
|
||||
deviceId?: string;
|
||||
clientId?: string;
|
||||
},
|
||||
targetDeviceIds: string[],
|
||||
targetClientIds: string[],
|
||||
) {
|
||||
if (targetDeviceIds.length === 0 && targetClientIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(deviceId) && targetClientIds.includes(deviceId);
|
||||
const deviceId = String(target.deviceId ?? '').trim();
|
||||
const clientId = String(target.clientId ?? '').trim();
|
||||
return (Boolean(deviceId) && targetDeviceIds.includes(deviceId)) || (Boolean(clientId) && targetClientIds.includes(clientId));
|
||||
}
|
||||
|
||||
function normalizeAppOrigin(value: unknown) {
|
||||
@@ -218,6 +265,31 @@ function normalizeNotificationDetailText(text?: string | null) {
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
export function withInferredNotificationOriginData(payload: IosNotificationPayload): IosNotificationPayload {
|
||||
const targetAppOrigin = normalizeTargetAppOrigins(payload.targetAppOrigins)[0] ?? '';
|
||||
const targetAppDomain =
|
||||
normalizeTargetAppDomains(payload.targetAppDomains)[0] ?? resolveAppDomainFromOrigin(targetAppOrigin);
|
||||
const currentData = payload.data ?? {};
|
||||
const currentAppOrigin = normalizeAppOrigin(currentData.appOrigin);
|
||||
const currentAppDomain = normalizeAppDomain(currentData.appDomain);
|
||||
|
||||
if (
|
||||
(!targetAppOrigin || currentAppOrigin === targetAppOrigin) &&
|
||||
(!targetAppDomain || currentAppDomain === targetAppDomain)
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
data: {
|
||||
...currentData,
|
||||
...(currentAppOrigin ? {} : targetAppOrigin ? { appOrigin: targetAppOrigin } : {}),
|
||||
...(currentAppDomain ? {} : targetAppDomain ? { appDomain: targetAppDomain } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isChatNotificationPayload(payload: IosNotificationPayload) {
|
||||
const category = String(payload.data?.category ?? '').trim().toLowerCase();
|
||||
const threadId = String(payload.threadId ?? '').trim().toLowerCase();
|
||||
@@ -375,6 +447,7 @@ async function ensureWebPushSubscriptionTable() {
|
||||
table.string('endpoint', 1000).notNullable().unique();
|
||||
table.jsonb('subscription_json').notNullable();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.string('client_id', 200).nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.string('app_origin', 500).nullable();
|
||||
table.string('app_domain', 255).nullable();
|
||||
@@ -391,6 +464,7 @@ async function ensureWebPushSubscriptionTable() {
|
||||
['endpoint', (table) => table.string('endpoint', 1000).notNullable()],
|
||||
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['client_id', (table) => table.string('client_id', 200).nullable()],
|
||||
['user_agent', (table) => table.text('user_agent').nullable()],
|
||||
['app_origin', (table) => table.string('app_origin', 500).nullable()],
|
||||
['app_domain', (table) => table.string('app_domain', 255).nullable()],
|
||||
@@ -500,6 +574,7 @@ export async function listWebPushSubscriptions() {
|
||||
id: row.id,
|
||||
endpoint: String(row.endpoint ?? ''),
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
clientId: row.client_id ? String(row.client_id) : '',
|
||||
userAgent: row.user_agent ? String(row.user_agent) : '',
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
@@ -641,6 +716,7 @@ export async function registerWebPushSubscription(
|
||||
await ensureWebPushSubscriptionTable();
|
||||
const appOrigin = normalizeAppOrigin(payload.appOrigin);
|
||||
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
|
||||
const cleanupTargetIds = normalizeRegistrationCleanupIds(payload.deviceId, payload.clientId);
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterWebPushSubscription(payload.subscription.endpoint);
|
||||
@@ -657,6 +733,7 @@ export async function registerWebPushSubscription(
|
||||
endpoint: payload.subscription.endpoint,
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
client_id: payload.clientId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
@@ -668,6 +745,7 @@ export async function registerWebPushSubscription(
|
||||
.merge({
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
client_id: payload.clientId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
@@ -676,13 +754,23 @@ export async function registerWebPushSubscription(
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (payload.deviceId?.trim()) {
|
||||
if (cleanupTargetIds.length > 0) {
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.where({ device_id: payload.deviceId.trim() })
|
||||
.whereNot({ endpoint: payload.subscription.endpoint })
|
||||
.andWhere((builder) => {
|
||||
builder.whereIn('device_id', cleanupTargetIds).orWhereIn('client_id', cleanupTargetIds);
|
||||
})
|
||||
.delete();
|
||||
}
|
||||
|
||||
await removeLegacyWebPushSubscriptionsForRegistration({
|
||||
endpoint: payload.subscription.endpoint,
|
||||
deviceId: payload.deviceId,
|
||||
clientId: payload.clientId,
|
||||
userAgent: payload.userAgent,
|
||||
appOrigin,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
endpoint: payload.subscription.endpoint,
|
||||
@@ -726,12 +814,13 @@ async function getEnabledWebPushSubscriptions() {
|
||||
.where({
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain');
|
||||
.select('endpoint', 'subscription_json', 'device_id', 'client_id', 'app_origin', 'app_domain');
|
||||
|
||||
return rows.map((row) => ({
|
||||
endpoint: String(row.endpoint),
|
||||
subscription: row.subscription_json as WebPushSubscriptionPayload,
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
clientId: row.client_id ? String(row.client_id) : '',
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
}));
|
||||
@@ -834,7 +923,8 @@ async function isNotificationRecipientAllowed(
|
||||
export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const provider = await getProvider();
|
||||
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
|
||||
const targetDeviceIds = normalizeTargetDeviceIds(payload);
|
||||
const targetClientIds = normalizeTargetClientIds(payload);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
|
||||
@@ -870,7 +960,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
.filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
|
||||
isAllowedTargetRecipient({ deviceId: row.deviceId }, targetDeviceIds, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
)
|
||||
.map((row) => row.token);
|
||||
@@ -919,7 +1009,8 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
|
||||
async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
|
||||
const targetDeviceIds = normalizeTargetDeviceIds(payload);
|
||||
const targetClientIds = normalizeTargetClientIds(payload);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
if (!ensureWebPushConfigured(env)) {
|
||||
@@ -940,17 +1031,25 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
[
|
||||
{ kind: 'web-endpoint', id: row.endpoint },
|
||||
{ kind: 'client', id: row.deviceId },
|
||||
{ kind: 'client', id: row.clientId },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
})),
|
||||
)
|
||||
).filter(
|
||||
(row) =>
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
|
||||
isAllowedTargetRecipient({ deviceId: row.deviceId, clientId: row.clientId }, targetDeviceIds, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
);
|
||||
const matchedSubscriptions = subscriptions.map((row) => ({
|
||||
endpoint: row.endpoint,
|
||||
deviceId: row.deviceId,
|
||||
clientId: row.clientId,
|
||||
appOrigin: row.appOrigin,
|
||||
appDomain: row.appDomain,
|
||||
}));
|
||||
|
||||
if (!subscriptions.length) {
|
||||
return {
|
||||
@@ -959,6 +1058,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
reason: '등록된 Web Push 구독이 없습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
matchedCount: 0,
|
||||
matchedSubscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1026,6 +1127,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
sentCount,
|
||||
failedCount,
|
||||
invalidEndpoints,
|
||||
matchedCount: matchedSubscriptions.length,
|
||||
matchedSubscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1036,6 +1139,7 @@ export async function sendNotifications(
|
||||
disableWebPush?: boolean;
|
||||
},
|
||||
) {
|
||||
const resolvedPayload = withInferredNotificationOriginData(payload);
|
||||
const [ios, web] = await Promise.all([
|
||||
options?.disableIos
|
||||
? Promise.resolve({
|
||||
@@ -1046,7 +1150,7 @@ export async function sendNotifications(
|
||||
failedCount: 0,
|
||||
invalidTokens: [],
|
||||
})
|
||||
: sendIosNotifications(payload),
|
||||
: sendIosNotifications(resolvedPayload),
|
||||
options?.disableWebPush
|
||||
? Promise.resolve({
|
||||
ok: true,
|
||||
@@ -1056,7 +1160,7 @@ export async function sendNotifications(
|
||||
failedCount: 0,
|
||||
invalidEndpoints: [],
|
||||
})
|
||||
: sendWebPushNotifications(payload),
|
||||
: sendWebPushNotifications(resolvedPayload),
|
||||
]);
|
||||
|
||||
const aggregate = resolveNotificationAggregateResult(
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { getKstNowParts } from './worklog-automation-utils.js';
|
||||
|
||||
export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks';
|
||||
const PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE = 741_205_262;
|
||||
const scheduleModes = ['interval', 'daily'] as const;
|
||||
const repeatIntervalUnits = ['second', 'minute', 'hour', 'day', 'week', 'month'] as const;
|
||||
const scheduleExecutionModes = ['codex', 'managed-service'] as const;
|
||||
@@ -515,6 +516,39 @@ function normalizeBoolean(value: unknown, fallback: boolean) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readBooleanLikeValue(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === 't' || normalized === '1';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function tryAcquirePlanScheduleRegistrationLock(scheduleId: number) {
|
||||
const result = (await db.raw('select pg_try_advisory_lock(?, ?) as locked', [
|
||||
PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE,
|
||||
scheduleId,
|
||||
])) as { rows?: Array<{ locked?: unknown }> };
|
||||
|
||||
return readBooleanLikeValue(result.rows?.[0]?.locked);
|
||||
}
|
||||
|
||||
async function releasePlanScheduleRegistrationLock(scheduleId: number) {
|
||||
await db.raw('select pg_advisory_unlock(?, ?)', [
|
||||
PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE,
|
||||
scheduleId,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildManagedServiceFailureSummary(result: {
|
||||
title?: string;
|
||||
skipped?: boolean;
|
||||
@@ -1510,162 +1544,179 @@ export async function registerDuePlanScheduledTasks(now = new Date()) {
|
||||
}
|
||||
|
||||
async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: Date) {
|
||||
const executionMode = normalizeScheduleExecutionMode(row.execution_mode);
|
||||
const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json);
|
||||
const shouldRefreshSnapshot =
|
||||
!row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false);
|
||||
const scheduleSnapshot = shouldRefreshSnapshot
|
||||
? await ensureSchedulePromptSnapshot({
|
||||
scheduleId: Number(row.id),
|
||||
workId: buildScheduledPlanWorkIdBase(row),
|
||||
note: String(row.note ?? ''),
|
||||
forceRefresh: true,
|
||||
})
|
||||
: {
|
||||
directory: `.auto_codex/schedule/${row.id}`,
|
||||
requestPath: `.auto_codex/schedule/${row.id}/request.md`,
|
||||
contextPath: `.auto_codex/schedule/${row.id}/context.md`,
|
||||
manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`,
|
||||
};
|
||||
const managedServiceReady = await ensureManagedServiceExecutionReady({
|
||||
row,
|
||||
scheduleSnapshot,
|
||||
automationContextIds,
|
||||
});
|
||||
const effectiveRow = managedServiceReady.row;
|
||||
const scheduleNote = [
|
||||
String(effectiveRow.note ?? '').trim(),
|
||||
'',
|
||||
'## 스케줄 전용 참조 문서',
|
||||
`- ${scheduleSnapshot.requestPath}`,
|
||||
`- ${scheduleSnapshot.contextPath}`,
|
||||
'',
|
||||
'위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.',
|
||||
executionMode === 'managed-service'
|
||||
? [
|
||||
'',
|
||||
'## 스케줄 관리 서비스',
|
||||
`- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`,
|
||||
`- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`)}`,
|
||||
managedServiceReady.ready
|
||||
? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.'
|
||||
: `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`,
|
||||
managedServiceReady.reason
|
||||
? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}`
|
||||
: null,
|
||||
'- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.',
|
||||
].join('\n')
|
||||
: null,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n')
|
||||
.trim();
|
||||
const scheduleId = Number(row.id);
|
||||
|
||||
if (executionMode === 'managed-service') {
|
||||
if (!managedServiceReady.ready) {
|
||||
return {
|
||||
createdPlan: managedServiceReady.createdPlan,
|
||||
createdBoardPosts: managedServiceReady.createdBoardPosts,
|
||||
};
|
||||
}
|
||||
|
||||
const managedServiceDirectory = String(
|
||||
effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`,
|
||||
);
|
||||
const managedServiceResult = await runManagedScheduleService(managedServiceDirectory);
|
||||
if (!managedServiceResult.ok) {
|
||||
throw new Error(
|
||||
`스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const createdPlan = await createCompletedPlanExecutionLogItem({
|
||||
workId: buildScheduledPlanWorkIdBase(effectiveRow),
|
||||
note: scheduleNote,
|
||||
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
|
||||
automationContextIds,
|
||||
releaseTarget: String(effectiveRow.release_target ?? 'release'),
|
||||
jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true),
|
||||
suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false),
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: 60,
|
||||
});
|
||||
const managedServiceChangedFiles = [
|
||||
`${managedServiceDirectory}/README.md`,
|
||||
`${managedServiceDirectory}/service.ts`,
|
||||
`${managedServiceDirectory}/service.mjs`,
|
||||
`${managedServiceDirectory}/service-manifest.json`,
|
||||
];
|
||||
|
||||
await createPlanSourceWorkHistory(Number(createdPlan.id), {
|
||||
summary: [
|
||||
`스케줄 서비스 실행: schedule #${effectiveRow.id}`,
|
||||
`서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`,
|
||||
`결과: ${
|
||||
managedServiceResult.skipped
|
||||
? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})`
|
||||
: `${managedServiceResult.itemCount}건 전송 시도`
|
||||
}`,
|
||||
].join('\n'),
|
||||
branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'),
|
||||
commitHash: null,
|
||||
changedFiles: managedServiceChangedFiles,
|
||||
commandLog: [
|
||||
`schedule-managed-service run scheduleId=${String(effectiveRow.id)}`,
|
||||
`servicePath=${managedServiceDirectory}/service.mjs`,
|
||||
`itemCount=${managedServiceResult.itemCount}`,
|
||||
`webSent=${managedServiceResult.web.sentCount}`,
|
||||
`webFailed=${managedServiceResult.web.failedCount}`,
|
||||
`skipped=${managedServiceResult.skipped ? 'true' : 'false'}`,
|
||||
`reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`,
|
||||
].join('\n'),
|
||||
diffText: null,
|
||||
sourceFiles: [],
|
||||
});
|
||||
await createPlanActionHistory(
|
||||
Number(createdPlan.id),
|
||||
'스케줄서비스실행',
|
||||
`Plan 스케줄 #${effectiveRow.id} 전용 서비스 파일을 직접 실행했습니다.`,
|
||||
);
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id: effectiveRow.id })
|
||||
.update({
|
||||
last_registered_at: now,
|
||||
context_snapshot_generated_at: now,
|
||||
context_snapshot_refresh_requested: false,
|
||||
managed_service_generated_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
if (!Number.isInteger(scheduleId) || scheduleId <= 0) {
|
||||
throw new Error('유효하지 않은 스케줄 ID입니다.');
|
||||
}
|
||||
|
||||
if (!(await tryAcquirePlanScheduleRegistrationLock(scheduleId))) {
|
||||
return {
|
||||
createdPlan,
|
||||
createdPlan: null,
|
||||
createdBoardPosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const boardPost = await createBoardPost({
|
||||
title: buildScheduledBoardPostTitle(effectiveRow),
|
||||
content: scheduleNote,
|
||||
attachments: [],
|
||||
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
|
||||
automationContextIds,
|
||||
requestExecutionMode: 'all_at_once',
|
||||
requestItems: [],
|
||||
});
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id: effectiveRow.id })
|
||||
.update({
|
||||
last_registered_at: now,
|
||||
context_snapshot_generated_at: now,
|
||||
context_snapshot_refresh_requested: false,
|
||||
updated_at: db.fn.now(),
|
||||
try {
|
||||
const executionMode = normalizeScheduleExecutionMode(row.execution_mode);
|
||||
const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json);
|
||||
const shouldRefreshSnapshot =
|
||||
!row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false);
|
||||
const scheduleSnapshot = shouldRefreshSnapshot
|
||||
? await ensureSchedulePromptSnapshot({
|
||||
scheduleId: Number(row.id),
|
||||
workId: buildScheduledPlanWorkIdBase(row),
|
||||
note: String(row.note ?? ''),
|
||||
forceRefresh: true,
|
||||
})
|
||||
: {
|
||||
directory: `.auto_codex/schedule/${row.id}`,
|
||||
requestPath: `.auto_codex/schedule/${row.id}/request.md`,
|
||||
contextPath: `.auto_codex/schedule/${row.id}/context.md`,
|
||||
manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`,
|
||||
};
|
||||
const managedServiceReady = await ensureManagedServiceExecutionReady({
|
||||
row,
|
||||
scheduleSnapshot,
|
||||
automationContextIds,
|
||||
});
|
||||
return {
|
||||
createdPlan: null,
|
||||
createdBoardPosts: [boardPost],
|
||||
};
|
||||
const effectiveRow = managedServiceReady.row;
|
||||
const scheduleNote = [
|
||||
String(effectiveRow.note ?? '').trim(),
|
||||
'',
|
||||
'## 스케줄 전용 참조 문서',
|
||||
`- ${scheduleSnapshot.requestPath}`,
|
||||
`- ${scheduleSnapshot.contextPath}`,
|
||||
'',
|
||||
'위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.',
|
||||
executionMode === 'managed-service'
|
||||
? [
|
||||
'',
|
||||
'## 스케줄 관리 서비스',
|
||||
`- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${scheduleId}-service`)}`,
|
||||
`- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${scheduleId}`)}`,
|
||||
managedServiceReady.ready
|
||||
? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.'
|
||||
: `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`,
|
||||
managedServiceReady.reason
|
||||
? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}`
|
||||
: null,
|
||||
'- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.',
|
||||
].join('\n')
|
||||
: null,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (executionMode === 'managed-service') {
|
||||
if (!managedServiceReady.ready) {
|
||||
return {
|
||||
createdPlan: managedServiceReady.createdPlan,
|
||||
createdBoardPosts: managedServiceReady.createdBoardPosts,
|
||||
};
|
||||
}
|
||||
|
||||
const managedServiceDirectory = String(
|
||||
effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${scheduleId}`,
|
||||
);
|
||||
const managedServiceResult = await runManagedScheduleService(managedServiceDirectory);
|
||||
if (!managedServiceResult.ok) {
|
||||
throw new Error(
|
||||
`스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const createdPlan = await createCompletedPlanExecutionLogItem({
|
||||
workId: buildScheduledPlanWorkIdBase(effectiveRow),
|
||||
note: scheduleNote,
|
||||
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
|
||||
automationContextIds,
|
||||
releaseTarget: String(effectiveRow.release_target ?? 'release'),
|
||||
jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true),
|
||||
suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false),
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: 60,
|
||||
});
|
||||
const managedServiceChangedFiles = [
|
||||
`${managedServiceDirectory}/README.md`,
|
||||
`${managedServiceDirectory}/service.ts`,
|
||||
`${managedServiceDirectory}/service.mjs`,
|
||||
`${managedServiceDirectory}/service-manifest.json`,
|
||||
];
|
||||
|
||||
await createPlanSourceWorkHistory(Number(createdPlan.id), {
|
||||
summary: [
|
||||
`스케줄 서비스 실행: schedule #${scheduleId}`,
|
||||
`서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${scheduleId}-service`)}`,
|
||||
`결과: ${
|
||||
managedServiceResult.skipped
|
||||
? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})`
|
||||
: `${managedServiceResult.itemCount}건 전송 시도`
|
||||
}`,
|
||||
].join('\n'),
|
||||
branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'),
|
||||
commitHash: null,
|
||||
changedFiles: managedServiceChangedFiles,
|
||||
commandLog: [
|
||||
`schedule-managed-service run scheduleId=${String(effectiveRow.id)}`,
|
||||
`servicePath=${managedServiceDirectory}/service.mjs`,
|
||||
`itemCount=${managedServiceResult.itemCount}`,
|
||||
`webSent=${managedServiceResult.web.sentCount}`,
|
||||
`webFailed=${managedServiceResult.web.failedCount}`,
|
||||
`skipped=${managedServiceResult.skipped ? 'true' : 'false'}`,
|
||||
`reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`,
|
||||
].join('\n'),
|
||||
diffText: null,
|
||||
sourceFiles: [],
|
||||
});
|
||||
await createPlanActionHistory(
|
||||
Number(createdPlan.id),
|
||||
'스케줄서비스실행',
|
||||
`Plan 스케줄 #${scheduleId} 전용 서비스 파일을 직접 실행했습니다.`,
|
||||
);
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id: scheduleId })
|
||||
.update({
|
||||
last_registered_at: now,
|
||||
context_snapshot_generated_at: now,
|
||||
context_snapshot_refresh_requested: false,
|
||||
managed_service_generated_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
createdPlan,
|
||||
createdBoardPosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
const boardPost = await createBoardPost({
|
||||
title: buildScheduledBoardPostTitle(effectiveRow),
|
||||
content: scheduleNote,
|
||||
attachments: [],
|
||||
automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'),
|
||||
automationContextIds,
|
||||
requestExecutionMode: 'all_at_once',
|
||||
requestItems: [],
|
||||
});
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id: scheduleId })
|
||||
.update({
|
||||
last_registered_at: now,
|
||||
context_snapshot_generated_at: now,
|
||||
context_snapshot_refresh_requested: false,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
return {
|
||||
createdPlan: null,
|
||||
createdBoardPosts: [boardPost],
|
||||
};
|
||||
} finally {
|
||||
await releasePlanScheduleRegistrationLock(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPlanScheduledTaskNow(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -57,6 +63,7 @@ class ResourceManagerError extends Error {
|
||||
|
||||
const RESOURCE_MANAGER_ROOT_DIR = 'resource';
|
||||
const RESOURCE_MANAGER_ROOT_LABEL = 'resource';
|
||||
const LEGACY_PUBLIC_RESOURCE_ROOT_DIR = path.join('public', 'resource');
|
||||
|
||||
const TEXT_FILE_EXTENSIONS = new Set([
|
||||
'.txt',
|
||||
@@ -133,6 +140,14 @@ export function resolveStaticContentType(filePath: string) {
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.wav':
|
||||
return 'audio/wav';
|
||||
case '.mp3':
|
||||
return 'audio/mpeg';
|
||||
case '.ogg':
|
||||
return 'audio/ogg';
|
||||
case '.m4a':
|
||||
return 'audio/mp4';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.webm':
|
||||
@@ -185,6 +200,52 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) {
|
||||
return normalized.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function decodeRepeatedly(value: string, maxIterations = 3) {
|
||||
let current = value;
|
||||
|
||||
for (let index = 0; index < maxIterations; index += 1) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(current);
|
||||
|
||||
if (!decoded || decoded === current) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = decoded;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizePreviewTargetPath(targetPath: string) {
|
||||
const normalized = normalizeRelativeTarget(targetPath);
|
||||
|
||||
if (!normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const segments = normalized.split('/');
|
||||
const lastSegment = segments.at(-1) ?? '';
|
||||
const decodedLastSegment = decodeRepeatedly(lastSegment);
|
||||
const hashIndex = decodedLastSegment.lastIndexOf('#');
|
||||
|
||||
if (hashIndex <= 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const fileName = decodedLastSegment.slice(0, hashIndex).trim();
|
||||
|
||||
if (!fileName || !/\.[a-z0-9]{1,16}$/i.test(fileName)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = fileName;
|
||||
return normalizeRelativeTarget(segments.join('/'));
|
||||
}
|
||||
|
||||
function resolveRepoRoot(candidateRootPath: string) {
|
||||
const candidates = [
|
||||
candidateRootPath,
|
||||
@@ -207,6 +268,30 @@ export function resolveResourceManagerRoot(repoRootPath: string) {
|
||||
return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR);
|
||||
}
|
||||
|
||||
function resolveLegacyPublicResourceRoot(repoRootPath: string) {
|
||||
return path.join(resolveRepoRoot(repoRootPath), LEGACY_PUBLIC_RESOURCE_ROOT_DIR);
|
||||
}
|
||||
|
||||
function resolveLegacyPublicResourcePreviewPath(repoRootPath: string, targetPath: string) {
|
||||
const normalizedRelativePath = normalizeRelativeTarget(targetPath);
|
||||
|
||||
if (!normalizedRelativePath) {
|
||||
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
const rootPath = resolveLegacyPublicResourceRoot(repoRootPath);
|
||||
const absolutePath = path.resolve(rootPath, normalizedRelativePath);
|
||||
|
||||
if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) {
|
||||
throw new ResourceManagerError('허용되지 않은 경로입니다.', 403);
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: normalizedRelativePath,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) {
|
||||
const rootPath = resolveResourceManagerRoot(repoRootPath);
|
||||
const normalizedRelativePath = normalizeRelativeTarget(relativePath);
|
||||
@@ -301,10 +386,6 @@ async function resolveDirectoryLatestModifiedAt(absolutePath: string, stats?: Aw
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
||||
const entryStats = await fs.stat(entryAbsolutePath);
|
||||
const entryModifiedAt = entry.isDirectory()
|
||||
@@ -336,7 +417,6 @@ async function buildTreeNode(absolutePath: string, relativePath: string): Promis
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
const children = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.name.startsWith('.'))
|
||||
.sort((left, right) => {
|
||||
if (left.isDirectory() && !right.isDirectory()) {
|
||||
return -1;
|
||||
@@ -395,7 +475,6 @@ export async function listResourceManagerDirectory(repoRootPath: string, directo
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
const items: ResourceManagerDirectoryEntry[] = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.name.startsWith('.'))
|
||||
.sort((left, right) => {
|
||||
if (left.isDirectory() && !right.isDirectory()) {
|
||||
return -1;
|
||||
@@ -596,7 +675,12 @@ export async function deleteResourceManagerItem(repoRootPath: string, targetPath
|
||||
|
||||
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
|
||||
return withResourceManagerError(async () => {
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
|
||||
const normalizedTargetPath = normalizePreviewTargetPath(targetPath);
|
||||
const primaryTarget = resolveResourceManagerTargetPath(repoRootPath, normalizedTargetPath);
|
||||
const legacyTarget = resolveLegacyPublicResourcePreviewPath(repoRootPath, normalizedTargetPath);
|
||||
const absolutePath = existsSync(primaryTarget.absolutePath)
|
||||
? primaryTarget.absolutePath
|
||||
: legacyTarget.absolutePath;
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
|
||||
@@ -609,8 +693,9 @@ export async function openResourceManagerPreviewStream(repoRootPath: string, tar
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(absolutePath),
|
||||
contentType: resolveStaticContentType(absolutePath),
|
||||
};
|
||||
size: stats.size,
|
||||
createStream: (range?: { start?: number; end?: number }) => createReadStream(absolutePath, range),
|
||||
} satisfies ResourceManagerPreviewStream;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
beginRuntimeDrain,
|
||||
endRuntimeDrain,
|
||||
getRuntimeDrainSnapshot,
|
||||
isRuntimeDraining,
|
||||
trackHttpRequestFinished,
|
||||
trackHttpRequestStarted,
|
||||
trackWebSocketConnectionClosed,
|
||||
trackWebSocketConnectionOpened,
|
||||
} from './runtime-drain-service.js';
|
||||
|
||||
test('runtime drain service tracks drain and connection counters without going negative', () => {
|
||||
endRuntimeDrain();
|
||||
trackHttpRequestFinished();
|
||||
trackWebSocketConnectionClosed();
|
||||
|
||||
assert.equal(isRuntimeDraining(), false);
|
||||
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 0);
|
||||
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 0);
|
||||
|
||||
beginRuntimeDrain();
|
||||
trackHttpRequestStarted();
|
||||
trackHttpRequestStarted();
|
||||
trackWebSocketConnectionOpened();
|
||||
|
||||
assert.equal(isRuntimeDraining(), true);
|
||||
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 2);
|
||||
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 1);
|
||||
|
||||
trackHttpRequestFinished();
|
||||
trackHttpRequestFinished();
|
||||
trackHttpRequestFinished();
|
||||
trackWebSocketConnectionClosed();
|
||||
trackWebSocketConnectionClosed();
|
||||
endRuntimeDrain();
|
||||
|
||||
const snapshot = getRuntimeDrainSnapshot();
|
||||
assert.equal(snapshot.draining, false);
|
||||
assert.equal(snapshot.drainStartedAt, null);
|
||||
assert.equal(snapshot.activeHttpRequestCount, 0);
|
||||
assert.equal(snapshot.activeWebSocketConnectionCount, 0);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
type RuntimeDrainState = {
|
||||
draining: boolean;
|
||||
drainStartedAt: string | null;
|
||||
activeHttpRequestCount: number;
|
||||
activeWebSocketConnectionCount: number;
|
||||
};
|
||||
|
||||
const state: RuntimeDrainState = {
|
||||
draining: false,
|
||||
drainStartedAt: null,
|
||||
activeHttpRequestCount: 0,
|
||||
activeWebSocketConnectionCount: 0,
|
||||
};
|
||||
|
||||
function clampCount(value: number) {
|
||||
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
|
||||
}
|
||||
|
||||
export function beginRuntimeDrain() {
|
||||
state.draining = true;
|
||||
state.drainStartedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export function endRuntimeDrain() {
|
||||
state.draining = false;
|
||||
state.drainStartedAt = null;
|
||||
}
|
||||
|
||||
export function isRuntimeDraining() {
|
||||
return state.draining;
|
||||
}
|
||||
|
||||
export function trackHttpRequestStarted() {
|
||||
state.activeHttpRequestCount += 1;
|
||||
}
|
||||
|
||||
export function trackHttpRequestFinished() {
|
||||
state.activeHttpRequestCount = clampCount(state.activeHttpRequestCount - 1);
|
||||
}
|
||||
|
||||
export function trackWebSocketConnectionOpened() {
|
||||
state.activeWebSocketConnectionCount += 1;
|
||||
}
|
||||
|
||||
export function trackWebSocketConnectionClosed() {
|
||||
state.activeWebSocketConnectionCount = clampCount(state.activeWebSocketConnectionCount - 1);
|
||||
}
|
||||
|
||||
export function getRuntimeDrainSnapshot() {
|
||||
return {
|
||||
draining: state.draining,
|
||||
drainStartedAt: state.drainStartedAt,
|
||||
activeHttpRequestCount: state.activeHttpRequestCount,
|
||||
activeWebSocketConnectionCount: state.activeWebSocketConnectionCount,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
listServerCommands,
|
||||
resolveDockerSocketPath,
|
||||
restartServerCommand,
|
||||
readWorkServerDeploymentState,
|
||||
} from './server-command-service.js';
|
||||
|
||||
test('buildRestartFailureMessage includes exit info and stderr output', () => {
|
||||
@@ -69,8 +70,12 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
||||
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/);
|
||||
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(
|
||||
testScript,
|
||||
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --no-deps --force-recreate "\$SERVER_COMMAND_SERVICE"/,
|
||||
);
|
||||
assert.match(testScript, /TEST_BUILD_STAMP_FILE="\$\{TEST_BUILD_STAMP_FILE:-\$MAIN_PROJECT_ROOT\/\.server-command-test-app-built-at\}"/);
|
||||
assert.match(testScript, /date -Iseconds > "\$TEST_BUILD_STAMP_FILE"/);
|
||||
assert.match(testScript, /restart-via-docker-socket\.mjs/);
|
||||
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
|
||||
assert.match(relScript, /command -v docker >/);
|
||||
@@ -92,9 +97,32 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
||||
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
|
||||
/ACTIVE_SLOT_FILE="\$\{WORK_SERVER_ACTIVE_SLOT_FILE:-\$REPO_ROOT\/etc\/servers\/work-server\/\.docker\/runtime\/active-slot\}"/,
|
||||
);
|
||||
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$TARGET_SERVICE"/,
|
||||
);
|
||||
assert.match(workServerScript, /RUNTIME_ENDPOINT="\$\{WORK_SERVER_RUNTIME_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\}"/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/RECOVERY_ENDPOINT="\$\{WORK_SERVER_RECOVERY_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\/recover-interrupted-chat\}"/,
|
||||
);
|
||||
assert.match(workServerScript, /set_container_draining "\$PREVIOUS_CONTAINER" true/);
|
||||
assert.match(workServerScript, /wait_for_previous_slot_drain "\$PREVIOUS_CONTAINER"/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$PREVIOUS_SERVICE"/,
|
||||
);
|
||||
assert.match(workServerScript, /recover_interrupted_chat_requests "\$TARGET_CONTAINER"/);
|
||||
assert.match(workServerScript, /docker exec "\$PROXY_CONTAINER" nginx -s reload/);
|
||||
assert.match(workServerScript, /wait_for_container_runtime_ready "\$TARGET_CONTAINER" "\$TARGET_SLOT"/);
|
||||
assert.match(workServerScript, /wait_for_proxy_slot_health "\$TARGET_SLOT"/);
|
||||
assert.match(workServerScript, /Promise.all\(\[fetch\(process.argv\[1\]\), fetch\(process.argv\[2\]\)\]\)/);
|
||||
assert.match(workServerScript, /payload\?\.slot !== expectedSlot/);
|
||||
assert.match(workServerScript, /runtime readiness check failed/);
|
||||
assert.match(workServerScript, /STABLE_SUCCESS_COUNT=\$\(\(STABLE_SUCCESS_COUNT \+ 1\)\)/);
|
||||
assert.match(workServerScript, /work-server zero-downtime switch completed/);
|
||||
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
||||
});
|
||||
|
||||
@@ -116,6 +144,22 @@ test('test restart script pulls the configured remote main branch before restart
|
||||
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
});
|
||||
|
||||
test('test deploy script commits the main worktree before pushing and restarting the preview server', () => {
|
||||
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
||||
const deployScript = fs.readFileSync(new URL('deploy-test.sh', commandsRoot), 'utf8');
|
||||
|
||||
assert.match(deployScript, /TEST_BUILD_COMMAND="\$\{TEST_BUILD_COMMAND:-npm run build:test-app\}"/);
|
||||
assert.match(deployScript, /TEST_DEPLOY_COMMIT_MESSAGE="\$\{TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot\}"/);
|
||||
assert.match(deployScript, /echo "::step::commit-main-worktree"/);
|
||||
assert.match(deployScript, /git add -A -- \./);
|
||||
assert.match(deployScript, /git commit -m "\$TEST_DEPLOY_COMMIT_MESSAGE"/);
|
||||
assert.match(deployScript, /echo "::step::push-origin-main"/);
|
||||
assert.match(deployScript, /git push "\$TEST_DEPLOY_GIT_REMOTE" "\$TEST_DEPLOY_GIT_BRANCH"/);
|
||||
assert.match(deployScript, /TEST_SERVER_RESTART_SCRIPT="\$\{TEST_SERVER_RESTART_SCRIPT:-\$SCRIPT_DIR\/restart-test\.sh\}"/);
|
||||
assert.doesNotMatch(deployScript, /restart-work-server\.sh/);
|
||||
assert.match(deployScript, /REPO_ROOT="\$REPO_ROOT" sh "\$TEST_SERVER_RESTART_SCRIPT"/);
|
||||
});
|
||||
|
||||
test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
|
||||
const packageJsonPath = new URL('../../package.json', import.meta.url);
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
||||
@@ -301,6 +345,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
await mkdir(path.join(tempRoot, 'src'), { recursive: true });
|
||||
await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true });
|
||||
await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'src', 'main.test.ts'), 'export const testOnly = true;\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'index.html'), '<!doctype html>\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8');
|
||||
@@ -309,6 +354,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8');
|
||||
await Promise.all([
|
||||
fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'src', 'main.test.ts'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate),
|
||||
@@ -323,6 +369,8 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
|
||||
const resourceDate = new Date('2026-04-28T00:00:00.000Z');
|
||||
await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate);
|
||||
const excludedTestDate = new Date('2026-05-01T00:00:00.000Z');
|
||||
await fs.promises.utimes(path.join(tempRoot, 'src', 'main.test.ts'), excludedTestDate, excludedTestDate);
|
||||
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
@@ -333,6 +381,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
assert.ok(testCommand);
|
||||
assert.equal(testCommand.buildRequired, false);
|
||||
assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
|
||||
assert.notEqual(testCommand.latestSourceChangePath, 'src/main.test.ts');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
@@ -349,3 +398,193 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('listServerCommands ignores work-server test-only source changes when computing buildRequired', async () => {
|
||||
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
|
||||
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-source-scan-'));
|
||||
|
||||
try {
|
||||
const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server');
|
||||
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8');
|
||||
await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true });
|
||||
await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true });
|
||||
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), 'export const testOnly = true;\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8');
|
||||
await mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workServerRoot, 'dist', 'build-info.json'),
|
||||
JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-25T07:58:59.046Z', builtAt: '2026-05-25T07:58:59.046Z' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const staleDate = new Date('2026-05-20T00:00:00.000Z');
|
||||
const excludedTestDate = new Date('2026-06-01T00:00:00.000Z');
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), staleDate, staleDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'package.json'), staleDate, staleDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), staleDate, staleDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), excludedTestDate, excludedTestDate);
|
||||
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
|
||||
const commands = await listServerCommands();
|
||||
const workServerCommand = commands.find((item) => item.key === 'work-server');
|
||||
|
||||
assert.ok(workServerCommand);
|
||||
assert.equal(workServerCommand.buildRequired, false);
|
||||
assert.notEqual(workServerCommand.latestSourceChangePath, 'src/services/service.test.ts');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('listServerCommands keeps work-server updateAvailable false when only a standby rebuild is newer', async () => {
|
||||
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
|
||||
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-standby-build-'));
|
||||
|
||||
try {
|
||||
const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server');
|
||||
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8');
|
||||
await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true });
|
||||
await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
|
||||
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8');
|
||||
await writeFile(
|
||||
path.join(workServerRoot, 'dist', 'build-info.json'),
|
||||
JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-26T16:10:05.960Z', builtAt: '2026-05-26T16:10:05.960Z' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const sourceDate = new Date('2026-05-26T16:06:46.162Z');
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), sourceDate, sourceDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'package.json'), sourceDate, sourceDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), sourceDate, sourceDate);
|
||||
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
|
||||
const commands = await listServerCommands();
|
||||
const workServerCommand = commands.find((item) => item.key === 'work-server');
|
||||
|
||||
assert.ok(workServerCommand);
|
||||
assert.equal(workServerCommand.buildRequired, false);
|
||||
assert.equal(workServerCommand.updateAvailable, false);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('readWorkServerDeploymentState transitions stale running deployment to failed when the lock is gone', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-'));
|
||||
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
|
||||
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
|
||||
|
||||
try {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8');
|
||||
|
||||
const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(runtimeDir, 'deployment-state.json'),
|
||||
JSON.stringify({
|
||||
status: 'running',
|
||||
phase: 'drain-previous-slot',
|
||||
summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.',
|
||||
startedAt: '2026-05-28T01:45:37.000Z',
|
||||
updatedAt: '2026-05-28T01:54:20.000Z',
|
||||
activeSlot: 'blue',
|
||||
targetSlot: 'blue',
|
||||
previousSlot: 'green',
|
||||
steps: [
|
||||
{ key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' },
|
||||
{ key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' },
|
||||
{ key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' },
|
||||
{ key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' },
|
||||
{ key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null },
|
||||
{ key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null },
|
||||
],
|
||||
}) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const snapshot = await readWorkServerDeploymentState();
|
||||
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.phase, 'failed');
|
||||
assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'failed');
|
||||
assert.match(String(snapshot.lastError ?? ''), /lock 파일이 없어서/);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('readWorkServerDeploymentState keeps running deployment when the lock is active', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-lock-'));
|
||||
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
|
||||
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
|
||||
|
||||
try {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8');
|
||||
|
||||
const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(runtimeDir, 'deployment-state.json'),
|
||||
JSON.stringify({
|
||||
status: 'running',
|
||||
phase: 'drain-previous-slot',
|
||||
summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.',
|
||||
startedAt: '2026-05-28T01:45:37.000Z',
|
||||
updatedAt: '2026-05-28T01:54:20.000Z',
|
||||
steps: [
|
||||
{ key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' },
|
||||
{ key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' },
|
||||
{ key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' },
|
||||
{ key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' },
|
||||
{ key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null },
|
||||
{ key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null },
|
||||
],
|
||||
}) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(runtimeDir, 'restart-in-progress.json'),
|
||||
JSON.stringify({ startedAt: new Date().toISOString(), key: 'work-server', pid: 1234 }) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const snapshot = await readWorkServerDeploymentState();
|
||||
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.status, 'running');
|
||||
assert.equal(snapshot.phase, 'drain-previous-slot');
|
||||
assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'running');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import { readFile, rm, stat } from 'node:fs/promises';
|
||||
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { env } from '../config/env.js';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
import {
|
||||
readTestServerDeploymentState,
|
||||
startTestServerDeployment,
|
||||
type TestServerDeploymentSnapshot,
|
||||
} from './test-server-deployment-service.js';
|
||||
import {
|
||||
getRuntimeWorkServerBuildInfo,
|
||||
readLatestWorkServerBuildInfo,
|
||||
@@ -32,6 +37,7 @@ type ServerDefinition = {
|
||||
commandWorkingDirectory: string;
|
||||
commandEnvironment: Record<string, string>;
|
||||
restartStrategy: 'wait' | 'deferred';
|
||||
deferredResponseMode?: 'wait-for-result' | 'accept-immediately';
|
||||
};
|
||||
|
||||
export type ServerCommandSnapshot = {
|
||||
@@ -65,12 +71,21 @@ export type ServerCommandSnapshot = {
|
||||
commandScript: string;
|
||||
commandWorkingDirectory: string;
|
||||
errorMessage: string | null;
|
||||
deployment: WorkServerDeploymentSnapshot | null;
|
||||
};
|
||||
|
||||
export type ServerCommandRestartResult = {
|
||||
server: ServerCommandSnapshot;
|
||||
commandOutput: string | null;
|
||||
restartState: 'completed' | 'accepted';
|
||||
deployment?: WorkServerDeploymentSnapshot | null;
|
||||
testDeployment?: TestServerDeploymentSnapshot | null;
|
||||
};
|
||||
|
||||
type ServerCommandScriptExecutionOptions = {
|
||||
commandScript?: string;
|
||||
environment?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type ExecFileFailure = Error & {
|
||||
@@ -116,6 +131,58 @@ type BuildInspectionResult = {
|
||||
updateSummary: string | null;
|
||||
};
|
||||
|
||||
type WorkServerSlot = 'blue' | 'green';
|
||||
|
||||
export type WorkServerDeploymentStepKey =
|
||||
| 'build-target-slot'
|
||||
| 'verify-target-health'
|
||||
| 'switch-proxy'
|
||||
| 'drain-previous-slot'
|
||||
| 'rebuild-previous-slot'
|
||||
| 'recover-interrupted-chat';
|
||||
|
||||
export type WorkServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type WorkServerDeploymentStepSnapshot = {
|
||||
key: WorkServerDeploymentStepKey;
|
||||
status: WorkServerDeploymentStepStatus;
|
||||
detail: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type WorkServerDeploymentPhase =
|
||||
| 'idle'
|
||||
| 'build-target-slot'
|
||||
| 'verify-target-health'
|
||||
| 'switch-proxy'
|
||||
| 'drain-previous-slot'
|
||||
| 'rebuild-previous-slot'
|
||||
| 'recover-interrupted-chat'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type WorkServerDeploymentSnapshot = {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
phase: WorkServerDeploymentPhase;
|
||||
summary: string | null;
|
||||
startedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
completedAt: string | null;
|
||||
activeSlot: WorkServerSlot | null;
|
||||
targetSlot: WorkServerSlot | null;
|
||||
previousSlot: WorkServerSlot | null;
|
||||
targetContainer: string | null;
|
||||
previousContainer: string | null;
|
||||
previousSlotActiveChatRequestCount: number | null;
|
||||
previousSlotQueuedChatRequestCount: number | null;
|
||||
recoveredSessionCount: number | null;
|
||||
recoveredRestartedCount: number | null;
|
||||
recoveredRequeuedCount: number | null;
|
||||
lastError: string | null;
|
||||
logExcerpt: string | null;
|
||||
steps: WorkServerDeploymentStepSnapshot[];
|
||||
};
|
||||
|
||||
const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
|
||||
const DEFERRED_RESTART_DELAY_MS = 2_000;
|
||||
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
|
||||
@@ -135,11 +202,55 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
|
||||
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
|
||||
'/tmp/ai-code-test-app-dist/assets',
|
||||
] as const;
|
||||
const APP_BUILD_STAMP_RELATIVE_PATH = '.server-command-test-app-built-at';
|
||||
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
|
||||
const APP_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const;
|
||||
const WORK_SERVER_RESTART_LOCK_STALE_MS = 20 * 60 * 1000;
|
||||
|
||||
type WorkServerRestartLockPayload = {
|
||||
startedAt: string;
|
||||
key: ServerCommandKey;
|
||||
pid: number;
|
||||
};
|
||||
|
||||
type WorkServerDeploymentStateFilePayload = {
|
||||
status?: unknown;
|
||||
phase?: unknown;
|
||||
summary?: unknown;
|
||||
startedAt?: unknown;
|
||||
updatedAt?: unknown;
|
||||
completedAt?: unknown;
|
||||
activeSlot?: unknown;
|
||||
targetSlot?: unknown;
|
||||
previousSlot?: unknown;
|
||||
targetContainer?: unknown;
|
||||
previousContainer?: unknown;
|
||||
previousSlotActiveChatRequestCount?: unknown;
|
||||
previousSlotQueuedChatRequestCount?: unknown;
|
||||
recoveredSessionCount?: unknown;
|
||||
recoveredRestartedCount?: unknown;
|
||||
recoveredRequeuedCount?: unknown;
|
||||
lastError?: unknown;
|
||||
logExcerpt?: unknown;
|
||||
steps?: unknown;
|
||||
};
|
||||
|
||||
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
|
||||
const allowLocal = options?.allowLocal ?? false;
|
||||
let latestBuiltAt: string | null = null;
|
||||
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||
const buildStampCandidates = [
|
||||
path.join(mainProjectRoot, APP_BUILD_STAMP_RELATIVE_PATH),
|
||||
path.join(normalizePath(env.SERVER_COMMAND_PROJECT_ROOT), APP_BUILD_STAMP_RELATIVE_PATH),
|
||||
].filter((value, index, array) => array.indexOf(value) === index);
|
||||
|
||||
for (const targetPath of buildStampCandidates) {
|
||||
const candidate = allowLocal ? await readLocalBuildTimestamp(targetPath) : null;
|
||||
|
||||
if (candidate && (!latestBuiltAt || candidate > latestBuiltAt)) {
|
||||
latestBuiltAt = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
|
||||
const candidates = [
|
||||
@@ -195,7 +306,13 @@ type SourceChangeInfo = {
|
||||
|
||||
function isExcludedAppSourcePath(rootPath: string, targetPath: string) {
|
||||
const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/');
|
||||
return APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix));
|
||||
|
||||
if (APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseName = path.basename(relativePath);
|
||||
return APP_SOURCE_EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(baseName));
|
||||
}
|
||||
|
||||
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
|
||||
@@ -462,6 +579,41 @@ export function resolveDockerSocketPath(source: NodeJS.ProcessEnv | Record<strin
|
||||
return '/var/run/docker.sock';
|
||||
}
|
||||
|
||||
function getWorkServerActiveSlotFileCandidates() {
|
||||
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
|
||||
return [
|
||||
env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim() || null,
|
||||
path.join(mainProjectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||
path.join(projectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||
path.join(projectRoot, '.docker', 'runtime', 'active-slot'),
|
||||
].filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index);
|
||||
}
|
||||
|
||||
async function readWorkServerActiveSlot(): Promise<WorkServerSlot> {
|
||||
for (const candidate of getWorkServerActiveSlotFileCandidates()) {
|
||||
try {
|
||||
const value = (await readFile(candidate, 'utf8')).trim();
|
||||
if (value === 'blue' || value === 'green') {
|
||||
return value;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
function resolveWorkServerContainerName(slot: WorkServerSlot) {
|
||||
return slot === 'green' ? 'work-server-green' : 'work-server-blue';
|
||||
}
|
||||
|
||||
function appendComposeDetails(detailParts: Array<string | null | undefined>) {
|
||||
return trimPreview(detailParts.filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function shouldRetryWithDockerSocket(error: unknown) {
|
||||
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
|
||||
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
|
||||
@@ -575,21 +727,23 @@ async function restartViaDockerSocket(definition: ServerDefinition) {
|
||||
}
|
||||
|
||||
function getServerDefinitions(): ServerDefinition[] {
|
||||
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
|
||||
const projectRoot = normalizePath(useLocalMainMode ? mainProjectRoot : env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
const scriptRootCandidates = [mainProjectRoot, projectRoot, '/workspace/main-project'];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'test',
|
||||
label: 'TEST',
|
||||
summary: '메인 프로젝트의 테스트 앱 컨테이너',
|
||||
label: 'PREVIEW',
|
||||
summary: 'preview.sm-home.cloud 테스트 앱 컨테이너',
|
||||
environment: 'test',
|
||||
publicUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_CHECK_URL || env.SERVER_COMMAND_TEST_URL),
|
||||
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_TEST_SERVICE,
|
||||
containerName: 'ai-code-app-app-1',
|
||||
commandScript: resolveCommandScriptPath('restart-test.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandScript: resolveCommandScriptPath('restart-test.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
@@ -607,11 +761,11 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
summary: 'release 브랜치를 서비스하는 릴리즈 앱 컨테이너',
|
||||
environment: 'release',
|
||||
publicUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_CHECK_URL || env.SERVER_COMMAND_REL_URL),
|
||||
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_REL_SERVICE,
|
||||
containerName: 'ai-code-app-release',
|
||||
commandScript: resolveCommandScriptPath('restart-rel.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandScript: resolveCommandScriptPath('restart-rel.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
@@ -626,11 +780,11 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
summary: '프로덕션 앱 컨테이너',
|
||||
environment: 'production',
|
||||
publicUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_CHECK_URL || env.SERVER_COMMAND_PROD_URL),
|
||||
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_PROD_SERVICE,
|
||||
containerName: 'ai-code-app-prod',
|
||||
commandScript: resolveCommandScriptPath('restart-prod.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandScript: resolveCommandScriptPath('restart-prod.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
@@ -639,6 +793,7 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-prod',
|
||||
},
|
||||
restartStrategy: 'deferred',
|
||||
deferredResponseMode: 'wait-for-result',
|
||||
},
|
||||
{
|
||||
key: 'work-server',
|
||||
@@ -650,12 +805,13 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
composeFile: path.join(projectRoot, 'etc', 'servers', 'work-server', 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_WORK_SERVER_SERVICE,
|
||||
containerName: 'work-server',
|
||||
commandScript: resolveCommandScriptPath('restart-work-server.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandWorkingDirectory: projectRoot,
|
||||
commandScript: resolveCommandScriptPath('restart-work-server.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
REPO_ROOT: projectRoot,
|
||||
REPO_ROOT: mainProjectRoot,
|
||||
},
|
||||
restartStrategy: 'deferred',
|
||||
deferredResponseMode: 'accept-immediately',
|
||||
},
|
||||
{
|
||||
key: 'command-runner',
|
||||
@@ -667,12 +823,13 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
composeFile: path.join(projectRoot, 'scripts', 'run-server-command-runner.mjs'),
|
||||
serviceName: 'server-command-runner',
|
||||
containerName: 'server-command-runner',
|
||||
commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandWorkingDirectory: projectRoot,
|
||||
commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
PROJECT_ROOT: projectRoot,
|
||||
PROJECT_ROOT: mainProjectRoot,
|
||||
},
|
||||
restartStrategy: 'deferred',
|
||||
deferredResponseMode: 'wait-for-result',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -687,6 +844,25 @@ function getServerDefinition(key: ServerCommandKey) {
|
||||
return definition;
|
||||
}
|
||||
|
||||
async function executeServerCommandScript(
|
||||
definition: ServerDefinition,
|
||||
options: ServerCommandScriptExecutionOptions = {},
|
||||
) {
|
||||
const commandScript = options.commandScript ?? definition.commandScript;
|
||||
const timeoutMs = options.timeoutMs ?? 30000;
|
||||
|
||||
return execFileAsync('sh', [commandScript], {
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
...definition.commandEnvironment,
|
||||
...options.environment,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function trimPreview(value: string | null | undefined, maxLength = 220) {
|
||||
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
@@ -708,6 +884,296 @@ function normalizeDateTimeValue(value: string | null | undefined) {
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
function getWorkServerRestartLockPath() {
|
||||
return path.join(resolveMainProjectRoot(), "etc", "servers", "work-server", ".docker", "runtime", "restart-in-progress.json");
|
||||
}
|
||||
|
||||
function getWorkServerDeploymentStatePath() {
|
||||
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'deployment-state.json');
|
||||
}
|
||||
|
||||
const WORK_SERVER_DEPLOYMENT_STEP_KEYS: WorkServerDeploymentStepKey[] = [
|
||||
'build-target-slot',
|
||||
'verify-target-health',
|
||||
'switch-proxy',
|
||||
'drain-previous-slot',
|
||||
'rebuild-previous-slot',
|
||||
'recover-interrupted-chat',
|
||||
];
|
||||
|
||||
function normalizeWorkServerDeploymentStepKey(value: unknown): WorkServerDeploymentStepKey | null {
|
||||
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as WorkServerDeploymentStepKey)
|
||||
? (value as WorkServerDeploymentStepKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeWorkServerSlotValue(value: unknown): WorkServerSlot | null {
|
||||
return value === 'blue' || value === 'green' ? value : null;
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentPhase(value: unknown): WorkServerDeploymentPhase {
|
||||
return value === 'build-target-slot'
|
||||
|| value === 'verify-target-health'
|
||||
|| value === 'switch-proxy'
|
||||
|| value === 'drain-previous-slot'
|
||||
|| value === 'rebuild-previous-slot'
|
||||
|| value === 'recover-interrupted-chat'
|
||||
|| value === 'completed'
|
||||
|| value === 'failed'
|
||||
? value
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentStatus(value: unknown): WorkServerDeploymentSnapshot['status'] {
|
||||
return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle';
|
||||
}
|
||||
|
||||
function normalizeNumberOrNull(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function buildEmptyWorkServerDeploymentSnapshot(): WorkServerDeploymentSnapshot {
|
||||
return {
|
||||
status: 'idle',
|
||||
phase: 'idle',
|
||||
summary: null,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
completedAt: null,
|
||||
activeSlot: null,
|
||||
targetSlot: null,
|
||||
previousSlot: null,
|
||||
targetContainer: null,
|
||||
previousContainer: null,
|
||||
previousSlotActiveChatRequestCount: null,
|
||||
previousSlotQueuedChatRequestCount: null,
|
||||
recoveredSessionCount: null,
|
||||
recoveredRestartedCount: null,
|
||||
recoveredRequeuedCount: null,
|
||||
lastError: null,
|
||||
logExcerpt: null,
|
||||
steps: WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentSteps(value: unknown) {
|
||||
const fallback = buildEmptyWorkServerDeploymentSnapshot().steps;
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalizedByKey = new Map<WorkServerDeploymentStepKey, WorkServerDeploymentStepSnapshot>();
|
||||
|
||||
value.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const key = normalizeWorkServerDeploymentStepKey(candidate.key);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status =
|
||||
candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
|| candidate.status === 'pending'
|
||||
? candidate.status
|
||||
: 'pending';
|
||||
|
||||
normalizedByKey.set(key, {
|
||||
key,
|
||||
status,
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
});
|
||||
});
|
||||
|
||||
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!);
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentSnapshot(value: unknown): WorkServerDeploymentSnapshot {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return buildEmptyWorkServerDeploymentSnapshot();
|
||||
}
|
||||
|
||||
const candidate = value as WorkServerDeploymentStateFilePayload;
|
||||
|
||||
return {
|
||||
status: normalizeWorkServerDeploymentStatus(candidate.status),
|
||||
phase: normalizeWorkServerDeploymentPhase(candidate.phase),
|
||||
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
||||
startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null),
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null),
|
||||
activeSlot: normalizeWorkServerSlotValue(candidate.activeSlot),
|
||||
targetSlot: normalizeWorkServerSlotValue(candidate.targetSlot),
|
||||
previousSlot: normalizeWorkServerSlotValue(candidate.previousSlot),
|
||||
targetContainer: typeof candidate.targetContainer === 'string' ? candidate.targetContainer : null,
|
||||
previousContainer: typeof candidate.previousContainer === 'string' ? candidate.previousContainer : null,
|
||||
previousSlotActiveChatRequestCount: normalizeNumberOrNull(candidate.previousSlotActiveChatRequestCount),
|
||||
previousSlotQueuedChatRequestCount: normalizeNumberOrNull(candidate.previousSlotQueuedChatRequestCount),
|
||||
recoveredSessionCount: normalizeNumberOrNull(candidate.recoveredSessionCount),
|
||||
recoveredRestartedCount: normalizeNumberOrNull(candidate.recoveredRestartedCount),
|
||||
recoveredRequeuedCount: normalizeNumberOrNull(candidate.recoveredRequeuedCount),
|
||||
lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null,
|
||||
logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null,
|
||||
steps: normalizeWorkServerDeploymentSteps(candidate.steps),
|
||||
};
|
||||
}
|
||||
|
||||
function isWorkServerDeploymentCompleted(snapshot: WorkServerDeploymentSnapshot) {
|
||||
return snapshot.steps.every((step) => step.status === 'completed');
|
||||
}
|
||||
|
||||
function reconcileStaleWorkServerDeploymentState(snapshot: WorkServerDeploymentSnapshot): WorkServerDeploymentSnapshot {
|
||||
if (snapshot.status !== 'running') {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (isWorkServerDeploymentCompleted(snapshot)) {
|
||||
return {
|
||||
...snapshot,
|
||||
status: 'completed',
|
||||
phase: 'completed',
|
||||
summary: snapshot.summary || 'WORK-SERVER 무중단 배포를 완료했습니다.',
|
||||
updatedAt: now,
|
||||
completedAt: snapshot.completedAt ?? now,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
status: 'failed',
|
||||
phase: 'failed',
|
||||
summary: 'WORK-SERVER 배포 상태가 중간 단계에서 종료되었습니다.',
|
||||
updatedAt: now,
|
||||
completedAt: snapshot.completedAt ?? now,
|
||||
lastError:
|
||||
snapshot.lastError
|
||||
|| '배포 lock 파일이 없어서 진행 중 상태를 종료된 상태로 보정했습니다.',
|
||||
steps: snapshot.steps.map((step) => (
|
||||
step.status === 'running'
|
||||
? {
|
||||
...step,
|
||||
status: 'failed',
|
||||
updatedAt: now,
|
||||
}
|
||||
: step
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
async function hasActiveWorkServerRestartLock() {
|
||||
const lockPath = getWorkServerRestartLockPath();
|
||||
|
||||
try {
|
||||
const [raw, lockStat] = await Promise.all([
|
||||
readFile(lockPath, 'utf8').catch(() => ''),
|
||||
stat(lockPath),
|
||||
]);
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<WorkServerRestartLockPayload>) : null;
|
||||
const freshnessSource =
|
||||
normalizeDateTimeValue(typeof parsed?.startedAt === 'string' ? parsed.startedAt : null)
|
||||
?? normalizeDateTimeValue(lockStat.mtime.toISOString());
|
||||
|
||||
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) {
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistWorkServerDeploymentState(snapshot: WorkServerDeploymentSnapshot) {
|
||||
const targetPath = getWorkServerDeploymentStatePath();
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, JSON.stringify(snapshot) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
export async function readWorkServerDeploymentState(): Promise<WorkServerDeploymentSnapshot | null> {
|
||||
try {
|
||||
const raw = await readFile(getWorkServerDeploymentStatePath(), 'utf8');
|
||||
const snapshot = normalizeWorkServerDeploymentSnapshot(JSON.parse(raw));
|
||||
|
||||
if (snapshot.status !== 'running' || await hasActiveWorkServerRestartLock()) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const reconciled = reconcileStaleWorkServerDeploymentState(snapshot);
|
||||
|
||||
if (JSON.stringify(reconciled) !== JSON.stringify(snapshot)) {
|
||||
await persistWorkServerDeploymentState(reconciled).catch(() => undefined);
|
||||
}
|
||||
|
||||
return reconciled;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireWorkServerRestartLock() {
|
||||
const lockPath = getWorkServerRestartLockPath();
|
||||
await mkdir(path.dirname(lockPath), { recursive: true });
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const handle = await open(lockPath, "wx");
|
||||
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ startedAt, key: "work-server", pid: process.pid }) + "\n", "utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let existingStartedAt: string | null = null;
|
||||
|
||||
try {
|
||||
const raw = await readFile(lockPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<WorkServerRestartLockPayload>;
|
||||
existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === "string" ? parsed.startedAt : null);
|
||||
const lockStat = await stat(lockPath).catch(() => null);
|
||||
const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null);
|
||||
|
||||
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) {
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
return acquireWorkServerRestartLock();
|
||||
}
|
||||
} catch {
|
||||
// ignore read failures and keep conflict response below
|
||||
}
|
||||
|
||||
const conflictError = new Error(
|
||||
existingStartedAt
|
||||
? "WORK-SERVER 무중단 재기동이 이미 진행 중입니다. 시작 시각 " + existingStartedAt
|
||||
: "WORK-SERVER 무중단 재기동이 이미 진행 중입니다.",
|
||||
);
|
||||
(conflictError as Error & { statusCode?: number }).statusCode = 409;
|
||||
throw conflictError;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRestartCommandPreview(definition: ServerDefinition) {
|
||||
return `sh ${definition.commandScript}`;
|
||||
}
|
||||
@@ -753,6 +1219,7 @@ function buildAcceptedRestartSnapshot(definition: ServerDefinition): ServerComma
|
||||
commandScript: definition.commandScript,
|
||||
commandWorkingDirectory: definition.commandWorkingDirectory,
|
||||
errorMessage: null,
|
||||
deployment: definition.key === 'work-server' ? buildEmptyWorkServerDeploymentSnapshot() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -905,6 +1372,7 @@ async function waitForDeferredRestartResult(
|
||||
|
||||
async function restartServerCommandDeferred(definition: ServerDefinition): Promise<ServerCommandRestartResult> {
|
||||
const { logPath, statusPath } = buildDeferredRestartProbePaths(definition);
|
||||
const workServerLockPath = definition.key === "work-server" ? await acquireWorkServerRestartLock() : null;
|
||||
const shellCommand = [
|
||||
`sleep ${Math.ceil(DEFERRED_RESTART_DELAY_MS / 1000)}`,
|
||||
`sh ${JSON.stringify(definition.commandScript)} >${JSON.stringify(logPath)} 2>&1`,
|
||||
@@ -912,7 +1380,8 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
|
||||
`printf '%s' \"$status\" >${JSON.stringify(statusPath)}`,
|
||||
].join('; ');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('sh', ['-c', shellCommand], {
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
detached: true,
|
||||
@@ -920,15 +1389,32 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
|
||||
env: {
|
||||
...process.env,
|
||||
...definition.commandEnvironment,
|
||||
...(workServerLockPath ? { WORK_SERVER_RESTART_LOCK_FILE: workServerLockPath } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
resolve();
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (workServerLockPath) {
|
||||
await rm(workServerLockPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (definition.deferredResponseMode === 'accept-immediately') {
|
||||
return {
|
||||
server: buildAcceptedRestartSnapshot(definition),
|
||||
commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
|
||||
restartState: 'accepted',
|
||||
deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null,
|
||||
};
|
||||
}
|
||||
|
||||
const commandOutput = await waitForDeferredRestartResult(definition, statusPath, logPath);
|
||||
|
||||
@@ -936,6 +1422,7 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
|
||||
server: buildAcceptedRestartSnapshot(definition),
|
||||
commandOutput: commandOutput ?? `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
|
||||
restartState: 'accepted',
|
||||
deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1102,11 +1589,16 @@ async function inspectComposeStatus(definition: ServerDefinition) {
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectContainerRuntime(definition: ServerDefinition): Promise<RuntimeInspectionResult> {
|
||||
async function inspectContainerRuntime(
|
||||
definition: ServerDefinition,
|
||||
containerNameOverride?: string,
|
||||
): Promise<RuntimeInspectionResult> {
|
||||
const containerName = containerNameOverride ?? definition.containerName;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'docker',
|
||||
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', definition.containerName],
|
||||
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', containerName],
|
||||
{
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
timeout: 8000,
|
||||
@@ -1123,7 +1615,7 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
|
||||
} catch (error) {
|
||||
if (shouldRetryWithDockerSocket(error)) {
|
||||
try {
|
||||
const inspected = await inspectContainerViaSocket(definition.containerName);
|
||||
const inspected = await inspectContainerViaSocket(containerName);
|
||||
return {
|
||||
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
|
||||
composeStatus: inspected.State?.Status?.trim() || null,
|
||||
@@ -1263,10 +1755,27 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
|
||||
}
|
||||
|
||||
if (definition.key === 'work-server') {
|
||||
const primarySlot = await readWorkServerActiveSlot();
|
||||
const candidateSlots: WorkServerSlot[] = primarySlot === 'green' ? ['green', 'blue'] : ['blue', 'green'];
|
||||
|
||||
for (const slot of candidateSlots) {
|
||||
const runtimeInfo = await inspectContainerRuntime(definition, resolveWorkServerContainerName(slot));
|
||||
|
||||
if (runtimeInfo.startedAt) {
|
||||
return {
|
||||
...runtimeInfo,
|
||||
composeDetails: appendComposeDetails([`slot:${slot}`, runtimeInfo.composeDetails]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeInfo = await inspectContainerRuntime(definition);
|
||||
|
||||
if (runtimeInfo.startedAt) {
|
||||
return runtimeInfo;
|
||||
return {
|
||||
...runtimeInfo,
|
||||
composeDetails: appendComposeDetails(['slot:proxy', runtimeInfo.composeDetails]),
|
||||
};
|
||||
}
|
||||
|
||||
return inspectCurrentProcessRuntime();
|
||||
@@ -1369,7 +1878,12 @@ async function inspectBuild(definition: ServerDefinition): Promise<BuildInspecti
|
||||
? !latestBuild?.builtAt || latestSourceChangedAt > latestBuild.builtAt
|
||||
: false;
|
||||
const updateAvailable =
|
||||
Boolean(runningBuild?.buildId) && Boolean(latestBuild?.buildId) && runningBuild?.buildId !== latestBuild?.buildId;
|
||||
!buildRequired &&
|
||||
Boolean(runningBuild?.builtAt) &&
|
||||
Boolean(latestBuild?.builtAt) &&
|
||||
Boolean(latestSourceChangedAt) &&
|
||||
runningBuild!.builtAt < latestBuild!.builtAt &&
|
||||
runningBuild!.builtAt < latestSourceChangedAt!;
|
||||
|
||||
return {
|
||||
runningVersion: runningBuild?.buildId ?? null,
|
||||
@@ -1425,6 +1939,7 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
|
||||
|
||||
const runtimeInfo = await inspectRuntime(definition);
|
||||
const buildInfo = await inspectBuild(definition);
|
||||
const deployment = definition.key === 'work-server' ? await readWorkServerDeploymentState() : null;
|
||||
const fallbackAttempt = selectedAttempt.url !== definition.checkUrl ? `fallback health check succeeded via ${selectedAttempt.url}` : null;
|
||||
const collectedErrors = attempts
|
||||
.filter((attempt) => attempt.errorMessage)
|
||||
@@ -1463,12 +1978,26 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
|
||||
updateAvailable: buildInfo.updateAvailable,
|
||||
updateSummary: buildInfo.updateSummary,
|
||||
responseTimeMs: Date.now() - startedAt,
|
||||
composeStatus: runtimeInfo.composeStatus,
|
||||
composeDetails: runtimeInfo.composeDetails,
|
||||
composeStatus:
|
||||
definition.key === 'work-server' && deployment?.status === 'running'
|
||||
? 'deploying'
|
||||
: runtimeInfo.composeStatus,
|
||||
composeDetails:
|
||||
definition.key === 'work-server' && deployment
|
||||
? appendComposeDetails([
|
||||
runtimeInfo.composeDetails,
|
||||
deployment.status !== 'idle'
|
||||
? `deploy:${deployment.status}${deployment.targetSlot ? `:${deployment.targetSlot}` : ''}`
|
||||
: null,
|
||||
])
|
||||
: runtimeInfo.composeDetails,
|
||||
lastCommand: buildRestartCommandPreview(definition),
|
||||
commandScript: definition.commandScript,
|
||||
commandWorkingDirectory: definition.commandWorkingDirectory,
|
||||
errorMessage,
|
||||
errorMessage: deployment?.status === 'failed' && deployment.lastError
|
||||
? trimPreview([deployment.lastError, errorMessage].filter(Boolean).join(' | '), 400)
|
||||
: errorMessage,
|
||||
deployment,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1497,15 +2026,7 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
|
||||
}
|
||||
|
||||
try {
|
||||
const commandResult = await execFileAsync('sh', [definition.commandScript], {
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
timeout: 30000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
...definition.commandEnvironment,
|
||||
},
|
||||
});
|
||||
const commandResult = await executeServerCommandScript(definition);
|
||||
stdout = commandResult.stdout;
|
||||
stderr = commandResult.stderr;
|
||||
} catch (error) {
|
||||
@@ -1532,5 +2053,23 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
|
||||
server,
|
||||
commandOutput: trimPreview([stdout, stderr].filter(Boolean).join('\n'), 400),
|
||||
restartState: 'completed',
|
||||
deployment: server.deployment,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deployWorkServerCommand(): Promise<ServerCommandRestartResult> {
|
||||
return restartServerCommand('work-server');
|
||||
}
|
||||
|
||||
export async function deployTestServerCommand(): Promise<ServerCommandRestartResult> {
|
||||
const testDefinition = getServerDefinition('test');
|
||||
const testDeployment = await startTestServerDeployment();
|
||||
const server = await checkServer(testDefinition);
|
||||
|
||||
return {
|
||||
server,
|
||||
commandOutput: 'TEST 배포를 시작했습니다. origin/main 푸시, 테스트 빌드, 테스트 배포 과정을 확인합니다.',
|
||||
restartState: 'accepted',
|
||||
testDeployment: testDeployment ?? (await readTestServerDeploymentState()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './server-command-service.js';
|
||||
import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js';
|
||||
import { syncMainProjectBranchForReservedRestart } from './git-service.js';
|
||||
import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js';
|
||||
|
||||
const SERVER_RESTART_RESERVATION_TABLE = 'server_restart_reservations';
|
||||
const SERVER_RESTART_RESERVATION_ROW_ID = 1;
|
||||
@@ -128,6 +129,34 @@ function normalizeExecutionPhase(value: unknown): RestartReservationExecutionPha
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeReservationTarget(value: unknown): RestartReservationTarget {
|
||||
return value === 'test' || value === 'work-server' ? value : 'all';
|
||||
}
|
||||
|
||||
function getReservationTargetKeys(target: RestartReservationTarget): Array<'test' | 'work-server'> {
|
||||
if (target === 'test') {
|
||||
return ['test'];
|
||||
}
|
||||
|
||||
if (target === 'work-server') {
|
||||
return ['work-server'];
|
||||
}
|
||||
|
||||
return ['test', 'work-server'];
|
||||
}
|
||||
|
||||
function getReservationTargetLabel(target: RestartReservationTarget) {
|
||||
if (target === 'test') {
|
||||
return 'TEST 서버';
|
||||
}
|
||||
|
||||
if (target === 'work-server') {
|
||||
return 'WORK 서버';
|
||||
}
|
||||
|
||||
return 'TEST / WORK 서버';
|
||||
}
|
||||
|
||||
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
||||
return {
|
||||
codexRunningCount: 0,
|
||||
@@ -331,7 +360,7 @@ function mapReservationRow(
|
||||
|
||||
return {
|
||||
enabled: Boolean(row?.enabled),
|
||||
target: row?.target === 'all' ? 'all' : 'all',
|
||||
target: normalizeReservationTarget(row?.target),
|
||||
status: row?.status ?? 'idle',
|
||||
requestedAt: row?.requested_at ?? null,
|
||||
requestedByClientId: row?.requested_by_client_id ?? null,
|
||||
@@ -549,7 +578,11 @@ async function requestCommandRunner(requestPath: string, init?: RequestInit) {
|
||||
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
|
||||
}
|
||||
|
||||
function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
|
||||
function buildWaitingReason(target: RestartReservationTarget, summary: RestartReservationWorkloadSummary) {
|
||||
if (target === 'work-server') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
|
||||
const codexPending = summary.codexRunningCount + summary.codexQueuedCount;
|
||||
@@ -868,6 +901,32 @@ function hasRestartStartedAfterReservation(
|
||||
return serverStartedTime + RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime;
|
||||
}
|
||||
|
||||
function hasRuntimeMarkerAfterReservation(
|
||||
runtimeMarkerAt: string | null | undefined,
|
||||
reservationStartedAt: string | null | undefined,
|
||||
) {
|
||||
const runtimeMarkerTime = Date.parse(runtimeMarkerAt ?? '');
|
||||
const reservationStartedTime = Date.parse(reservationStartedAt ?? '');
|
||||
|
||||
if (!Number.isFinite(runtimeMarkerTime) || !Number.isFinite(reservationStartedTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return runtimeMarkerTime + RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime;
|
||||
}
|
||||
|
||||
function getWorkServerRuntimeMarkerAt(
|
||||
server: Pick<ServerCommandSnapshot, 'runningBuiltAt' | 'runningVersion'>,
|
||||
) {
|
||||
if (server.runningBuiltAt?.trim()) {
|
||||
return server.runningBuiltAt;
|
||||
}
|
||||
|
||||
const versionText = server.runningVersion?.trim() ?? '';
|
||||
const versionMarker = versionText.includes('@') ? versionText.split('@').at(-1)?.trim() ?? '' : '';
|
||||
return versionMarker || null;
|
||||
}
|
||||
|
||||
export function hasReservedRestartVerification(
|
||||
key: 'test' | 'work-server',
|
||||
server: Pick<
|
||||
@@ -881,23 +940,29 @@ export function hasReservedRestartVerification(
|
||||
}
|
||||
|
||||
if (key === 'test') {
|
||||
return Boolean(server.runningBuiltAt) && !server.buildRequired;
|
||||
return hasRuntimeMarkerAfterReservation(server.runningBuiltAt, reservationStartedAt);
|
||||
}
|
||||
|
||||
return Boolean(server.runningVersion ?? server.runningBuiltAt) && !server.buildRequired && !server.updateAvailable;
|
||||
return hasRuntimeMarkerAfterReservation(getWorkServerRuntimeMarkerAt(server), reservationStartedAt);
|
||||
}
|
||||
|
||||
async function finalizeReservedRestart(row: RestartReservationRow) {
|
||||
const statuses = await listServerCommands();
|
||||
const testServer = statuses.find((item) => item.key === 'test') ?? null;
|
||||
const workServer = statuses.find((item) => item.key === 'work-server') ?? null;
|
||||
const testVerified = hasReservedRestartVerification('test', testServer, row.started_at);
|
||||
const workVerified = hasReservedRestartVerification('work-server', workServer, row.started_at);
|
||||
const target = normalizeReservationTarget(row.target);
|
||||
const targetKeys = getReservationTargetKeys(target);
|
||||
const verificationResults = {
|
||||
test: targetKeys.includes('test') ? hasReservedRestartVerification('test', testServer, row.started_at) : true,
|
||||
'work-server': targetKeys.includes('work-server')
|
||||
? hasReservedRestartVerification('work-server', workServer, row.started_at)
|
||||
: true,
|
||||
};
|
||||
|
||||
if (!testVerified || !workVerified) {
|
||||
if (!verificationResults.test || !verificationResults['work-server']) {
|
||||
const waitingTargets = [
|
||||
!testVerified ? 'TEST 서버' : null,
|
||||
!workVerified ? 'WORK 서버' : null,
|
||||
!verificationResults.test ? 'TEST 서버' : null,
|
||||
!verificationResults['work-server'] ? 'WORK 서버' : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
await updateReservationRow({
|
||||
@@ -1055,6 +1120,9 @@ export async function requestImmediateRestartRecovery(
|
||||
}
|
||||
|
||||
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
|
||||
const target = normalizeReservationTarget(row.target);
|
||||
const targetLabel = getReservationTargetLabel(target);
|
||||
const targetKeys = getReservationTargetKeys(target);
|
||||
const activeClients = await listActiveClients();
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
@@ -1062,7 +1130,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
||||
started_at: row.started_at ?? db.fn.now(),
|
||||
last_checked_at: db.fn.now(),
|
||||
execution_phase: 'commit-main-worktree',
|
||||
waiting_reason: 'main 작업트리 커밋 단계를 확인한 뒤 예약된 재기동을 이어갑니다.',
|
||||
waiting_reason: `${targetLabel} 무중단 재기동을 위해 main 작업트리 상태를 확인합니다.`,
|
||||
active_client_count: activeClients.length,
|
||||
last_error: null,
|
||||
});
|
||||
@@ -1070,12 +1138,12 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
||||
if (activeClients.length > 0) {
|
||||
await createNotificationMessage({
|
||||
title: '예약된 재기동 시작',
|
||||
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 TEST / WORK 서버 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
|
||||
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 ${targetLabel} 무중단 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
|
||||
category: 'system',
|
||||
source: SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE,
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
previewText: `예약된 재기동 시작 · 활성 클라이언트 ${activeClients.length}건`,
|
||||
previewText: `${targetLabel} 재기동 시작 · 활성 클라이언트 ${activeClients.length}건`,
|
||||
linkUrl: '/?topMenu=plans',
|
||||
linkLabel: '작업 화면 열기',
|
||||
},
|
||||
@@ -1102,31 +1170,40 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'restart-test',
|
||||
execution_phase: targetKeys.includes('test') ? 'restart-test' : 'restart-work-server',
|
||||
waiting_reason: syncResult.committed
|
||||
? 'main 변경을 정리한 뒤 TEST 서버 재기동을 시작합니다.'
|
||||
: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
|
||||
? `main 변경을 정리한 뒤 ${targetLabel} 재기동을 시작합니다.`
|
||||
: `main 작업트리 상태를 확인한 뒤 ${targetLabel} 재기동을 시작합니다.`,
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
|
||||
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
||||
if (targetKeys.includes('test')) {
|
||||
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
|
||||
}
|
||||
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'restart-work-server',
|
||||
waiting_reason: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
if (targetKeys.includes('test') && targetKeys.includes('work-server')) {
|
||||
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
||||
}
|
||||
|
||||
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
|
||||
if (targetKeys.includes('work-server')) {
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'restart-work-server',
|
||||
waiting_reason: target === 'work-server'
|
||||
? 'WORK 서버 무중단 재기동 후 정상 기동을 확인하는 중입니다.'
|
||||
: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
|
||||
}
|
||||
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'verify-runtime',
|
||||
waiting_reason: 'TEST / WORK 서버 새 런타임과 정상 기동을 확인하는 중입니다.',
|
||||
waiting_reason: `${targetLabel} 새 런타임과 정상 기동을 확인하는 중입니다.`,
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
@@ -1146,19 +1223,23 @@ export async function getServerRestartReservation() {
|
||||
}
|
||||
|
||||
export async function scheduleServerRestartReservation(options?: {
|
||||
target?: RestartReservationTarget | null;
|
||||
clientId?: string | null;
|
||||
appOrigin?: string | null;
|
||||
autoExecuteDelaySeconds?: number | null;
|
||||
}) {
|
||||
const autoExecuteDelaySeconds = resolveAutoExecuteDelaySeconds(options?.autoExecuteDelaySeconds);
|
||||
const target = normalizeReservationTarget(options?.target);
|
||||
const row = await updateReservationRow({
|
||||
enabled: true,
|
||||
target: 'all',
|
||||
target,
|
||||
status: 'waiting',
|
||||
requested_at: db.fn.now(),
|
||||
requested_by_client_id: options?.clientId?.trim() || null,
|
||||
last_checked_at: null,
|
||||
waiting_reason: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.',
|
||||
waiting_reason: target === 'work-server'
|
||||
? 'WORK 서버 무중단 재기동 가능 여부를 확인하는 중입니다.'
|
||||
: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.',
|
||||
workload_summary_json: getDefaultWorkloadSummary(),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
@@ -1215,7 +1296,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
|
||||
status: 'executing',
|
||||
started_at: db.fn.now(),
|
||||
last_checked_at: db.fn.now(),
|
||||
waiting_reason: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
|
||||
waiting_reason: `${getReservationTargetLabel(normalizeReservationTarget(row.target))} 재기동 준비를 시작합니다.`,
|
||||
last_error: null,
|
||||
auto_execute_at: null,
|
||||
execution_phase: 'commit-main-worktree',
|
||||
@@ -1273,6 +1354,10 @@ export class ServerRestartReservationWorker {
|
||||
this.running = true;
|
||||
|
||||
try {
|
||||
if (!(await isCurrentWorkServerSlotActive())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = await readReservationRow();
|
||||
|
||||
if (!row?.enabled) {
|
||||
@@ -1293,7 +1378,7 @@ export class ServerRestartReservationWorker {
|
||||
}
|
||||
|
||||
const workloadSummary = await getRestartReservationWorkloadSummary();
|
||||
const waitingReason = buildWaitingReason(workloadSummary);
|
||||
const waitingReason = buildWaitingReason(normalizeReservationTarget(row.target), workloadSummary);
|
||||
|
||||
if (!waitingReason && row.status === 'ready' && isReservationAutoExecuteDue(row)) {
|
||||
await confirmServerRestartReservation(this.logger);
|
||||
@@ -1301,6 +1386,7 @@ export class ServerRestartReservationWorker {
|
||||
}
|
||||
|
||||
const autoExecuteDelaySeconds = await resolveReservationAutoExecuteDelaySeconds(row);
|
||||
const targetLabel = getReservationTargetLabel(normalizeReservationTarget(row.target));
|
||||
const autoExecuteAt = buildAutoExecuteAt(
|
||||
row.status === 'ready' && row.auto_execute_at ? row.auto_execute_at : new Date().toISOString(),
|
||||
autoExecuteDelaySeconds,
|
||||
@@ -1310,7 +1396,7 @@ export class ServerRestartReservationWorker {
|
||||
status: waitingReason ? 'waiting' : 'ready',
|
||||
last_checked_at: db.fn.now(),
|
||||
waiting_reason: waitingReason
|
||||
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 TEST/WORK 서버 재기동을 자동 시작합니다.`,
|
||||
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 ${targetLabel} 무중단 재기동을 자동 시작합니다.`,
|
||||
workload_summary_json: workloadSummary,
|
||||
auto_execute_at: waitingReason
|
||||
? null
|
||||
|
||||
@@ -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,573 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
|
||||
const TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS = 20 * 60 * 1000;
|
||||
const TEST_SERVER_DEPLOYMENT_LOG_LIMIT = 4000;
|
||||
const TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS = 15_000;
|
||||
|
||||
export type TestServerDeploymentStepKey = 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server';
|
||||
export type TestServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type TestServerDeploymentStepSnapshot = {
|
||||
key: TestServerDeploymentStepKey;
|
||||
status: TestServerDeploymentStepStatus;
|
||||
detail: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type TestServerDeploymentPhase =
|
||||
| 'idle'
|
||||
| 'commit-main-worktree'
|
||||
| 'push-origin-main'
|
||||
| 'build-test-app'
|
||||
| 'deploy-test-server'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type TestServerDeploymentSnapshot = {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
phase: TestServerDeploymentPhase;
|
||||
summary: string | null;
|
||||
startedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
completedAt: string | null;
|
||||
lastError: string | null;
|
||||
logExcerpt: string | null;
|
||||
steps: TestServerDeploymentStepSnapshot[];
|
||||
};
|
||||
|
||||
type TestServerDeploymentStateFilePayload = {
|
||||
status?: unknown;
|
||||
phase?: unknown;
|
||||
summary?: unknown;
|
||||
startedAt?: unknown;
|
||||
updatedAt?: unknown;
|
||||
completedAt?: unknown;
|
||||
lastError?: unknown;
|
||||
logExcerpt?: unknown;
|
||||
steps?: unknown;
|
||||
};
|
||||
|
||||
type RestartLockPayload = {
|
||||
startedAt: string;
|
||||
key: 'test';
|
||||
pid: number;
|
||||
};
|
||||
|
||||
const TEST_SERVER_DEPLOYMENT_STEP_KEYS: TestServerDeploymentStepKey[] = [
|
||||
'commit-main-worktree',
|
||||
'push-origin-main',
|
||||
'build-test-app',
|
||||
'deploy-test-server',
|
||||
];
|
||||
|
||||
function normalizeDateTimeValue(value: string | null | undefined) {
|
||||
const normalized = value?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
function trimPreview(value: string | null | undefined, maxLength = 220) {
|
||||
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
|
||||
}
|
||||
|
||||
function getTestServerDeploymentStatePath() {
|
||||
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-state.json');
|
||||
}
|
||||
|
||||
function getTestServerDeploymentLockPath() {
|
||||
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-in-progress.json');
|
||||
}
|
||||
|
||||
function buildEmptyTestServerDeploymentSnapshot(): TestServerDeploymentSnapshot {
|
||||
return {
|
||||
status: 'idle',
|
||||
phase: 'idle',
|
||||
summary: null,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
completedAt: null,
|
||||
lastError: null,
|
||||
logExcerpt: null,
|
||||
steps: TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentStepKey(value: unknown): TestServerDeploymentStepKey | null {
|
||||
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as TestServerDeploymentStepKey)
|
||||
? (value as TestServerDeploymentStepKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentPhase(value: unknown): TestServerDeploymentPhase {
|
||||
return value === 'commit-main-worktree'
|
||||
|| value === 'push-origin-main'
|
||||
|| value === 'build-test-app'
|
||||
|| value === 'deploy-test-server'
|
||||
|| value === 'completed'
|
||||
|| value === 'failed'
|
||||
? value
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentStatus(value: unknown): TestServerDeploymentSnapshot['status'] {
|
||||
return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle';
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentSteps(value: unknown) {
|
||||
const fallback = buildEmptyTestServerDeploymentSnapshot().steps;
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalizedByKey = new Map<TestServerDeploymentStepKey, TestServerDeploymentStepSnapshot>();
|
||||
|
||||
value.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const key = normalizeTestServerDeploymentStepKey(candidate.key);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedByKey.set(key, {
|
||||
key,
|
||||
status:
|
||||
candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
|| candidate.status === 'pending'
|
||||
? candidate.status
|
||||
: 'pending',
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
});
|
||||
});
|
||||
|
||||
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!);
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentSnapshot(value: unknown): TestServerDeploymentSnapshot {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return buildEmptyTestServerDeploymentSnapshot();
|
||||
}
|
||||
|
||||
const candidate = value as TestServerDeploymentStateFilePayload;
|
||||
|
||||
return {
|
||||
status: normalizeTestServerDeploymentStatus(candidate.status),
|
||||
phase: normalizeTestServerDeploymentPhase(candidate.phase),
|
||||
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
||||
startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null),
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null),
|
||||
lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null,
|
||||
logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null,
|
||||
steps: normalizeTestServerDeploymentSteps(candidate.steps),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readTestServerDeploymentState(): Promise<TestServerDeploymentSnapshot | null> {
|
||||
try {
|
||||
const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8');
|
||||
const snapshot = normalizeTestServerDeploymentSnapshot(JSON.parse(raw));
|
||||
return await resolveStaleRunningTestDeployment(snapshot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTestServerDeploymentState(snapshot: TestServerDeploymentSnapshot) {
|
||||
const statePath = getTestServerDeploymentStatePath();
|
||||
await mkdir(path.dirname(statePath), { recursive: true });
|
||||
await writeFile(statePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function clearTestServerDeploymentState() {
|
||||
await rm(getTestServerDeploymentStatePath(), { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
function buildStaleTestServerDeploymentFailure(snapshot: TestServerDeploymentSnapshot) {
|
||||
const stalledAt = snapshot.updatedAt ?? snapshot.startedAt;
|
||||
const stalledLabel = stalledAt ? `마지막 상태 갱신 ${stalledAt}` : '상태 갱신 시각 확인 불가';
|
||||
return trimPreview(`TEST 배포 상태가 오래 갱신되지 않았고 잠금 파일도 없어 중단된 배포로 처리했습니다. ${stalledLabel}`, 500)
|
||||
?? 'TEST 배포 상태가 오래 갱신되지 않아 중단된 배포로 처리했습니다.';
|
||||
}
|
||||
|
||||
async function finalizeStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) {
|
||||
const failureMessage = buildStaleTestServerDeploymentFailure(snapshot);
|
||||
const now = new Date().toISOString();
|
||||
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key;
|
||||
|
||||
snapshot.status = 'failed';
|
||||
snapshot.phase = 'failed';
|
||||
snapshot.summary = buildTestServerDeploymentSummary('failed');
|
||||
snapshot.completedAt = now;
|
||||
snapshot.updatedAt = now;
|
||||
snapshot.lastError = failureMessage;
|
||||
|
||||
if (activeStep) {
|
||||
updateTestServerDeploymentStep(snapshot, activeStep, 'failed', failureMessage);
|
||||
}
|
||||
|
||||
await writeTestServerDeploymentState(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function resolveStaleRunningTestDeployment(snapshot: TestServerDeploymentSnapshot) {
|
||||
if (snapshot.status !== 'running') {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const freshnessSource = snapshot.updatedAt ?? snapshot.startedAt;
|
||||
if (!freshnessSource) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const staleForMs = Date.now() - Date.parse(freshnessSource);
|
||||
if (!Number.isFinite(staleForMs) || staleForMs < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const lockPath = getTestServerDeploymentLockPath();
|
||||
const lockStat = await stat(lockPath).catch(() => null);
|
||||
|
||||
if (lockStat?.isFile()) {
|
||||
const lockFreshnessSource = normalizeDateTimeValue(lockStat.mtime.toISOString() ?? null);
|
||||
if (lockFreshnessSource && Date.now() - Date.parse(lockFreshnessSource) < TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
return finalizeStaleRunningTestDeployment(snapshot);
|
||||
}
|
||||
|
||||
async function acquireTestServerDeploymentLock() {
|
||||
const lockPath = getTestServerDeploymentLockPath();
|
||||
await mkdir(path.dirname(lockPath), { recursive: true });
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx');
|
||||
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ startedAt, key: 'test', pid: process.pid } satisfies RestartLockPayload) + '\n', 'utf8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let existingStartedAt: string | null = null;
|
||||
|
||||
try {
|
||||
const raw = await readFile(lockPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<RestartLockPayload>;
|
||||
existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === 'string' ? parsed.startedAt : null);
|
||||
const lockStat = await stat(lockPath).catch(() => null);
|
||||
const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null);
|
||||
|
||||
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
return acquireTestServerDeploymentLock();
|
||||
}
|
||||
} catch {
|
||||
// ignore read failures and keep conflict response below
|
||||
}
|
||||
|
||||
const conflictError = new Error(
|
||||
existingStartedAt
|
||||
? `TEST 배포가 이미 진행 중입니다. 시작 시각 ${existingStartedAt}`
|
||||
: 'TEST 배포가 이미 진행 중입니다.',
|
||||
);
|
||||
(conflictError as Error & { statusCode?: number }).statusCode = 409;
|
||||
throw conflictError;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestServerDeploymentSummary(phase: TestServerDeploymentPhase) {
|
||||
switch (phase) {
|
||||
case 'commit-main-worktree':
|
||||
return 'main 작업트리 커밋 진행 중';
|
||||
case 'push-origin-main':
|
||||
return 'origin/main 푸시 진행 중';
|
||||
case 'build-test-app':
|
||||
return '테스트 앱 빌드 진행 중';
|
||||
case 'deploy-test-server':
|
||||
return '테스트 서버 배포 진행 중';
|
||||
case 'completed':
|
||||
return 'origin/main 푸시, 테스트 빌드, 테스트 배포가 완료되었습니다.';
|
||||
case 'failed':
|
||||
return 'TEST 배포에 실패했습니다.';
|
||||
default:
|
||||
return '테스트 배포 준비 중';
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestDeploymentFailureMessage(
|
||||
snapshot: Pick<TestServerDeploymentSnapshot, 'logExcerpt'>,
|
||||
error: unknown,
|
||||
) {
|
||||
const failure = error instanceof Error ? (error as Error & { code?: number | string; signal?: string | null }) : null;
|
||||
const exitInfo = [
|
||||
failure?.code != null ? `exit:${String(failure.code)}` : null,
|
||||
failure?.signal ? `signal:${String(failure.signal)}` : null,
|
||||
].filter(Boolean).join(' ');
|
||||
const logLines = (snapshot.logExcerpt ?? '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const lastMeaningfulLog = logLines.length > 0 ? logLines[logLines.length - 1] : null;
|
||||
|
||||
return trimPreview([
|
||||
lastMeaningfulLog && lastMeaningfulLog !== failure?.message ? lastMeaningfulLog : null,
|
||||
failure?.message || null,
|
||||
exitInfo || null,
|
||||
].filter(Boolean).join(' | '), 500) ?? 'TEST 배포에 실패했습니다.';
|
||||
}
|
||||
|
||||
function appendTestServerDeploymentLog(previous: string | null, chunk: string) {
|
||||
const normalizedChunk = chunk.trim();
|
||||
|
||||
if (!normalizedChunk) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const combined = [previous, normalizedChunk].filter(Boolean).join('\n');
|
||||
return combined.length > TEST_SERVER_DEPLOYMENT_LOG_LIMIT
|
||||
? combined.slice(combined.length - TEST_SERVER_DEPLOYMENT_LOG_LIMIT)
|
||||
: combined;
|
||||
}
|
||||
|
||||
function updateTestServerDeploymentStep(
|
||||
snapshot: TestServerDeploymentSnapshot,
|
||||
key: TestServerDeploymentStepKey,
|
||||
status: TestServerDeploymentStepStatus,
|
||||
detail?: string | null,
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
snapshot.steps = snapshot.steps.map((step) => {
|
||||
if (step.key !== key) {
|
||||
return step;
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
status,
|
||||
detail: detail === undefined ? step.detail : detail,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
snapshot.updatedAt = now;
|
||||
}
|
||||
|
||||
function markPreviousRunningStepCompleted(snapshot: TestServerDeploymentSnapshot, nextKey: TestServerDeploymentStepKey) {
|
||||
const previousRunning = snapshot.steps.find((step) => step.status === 'running' && step.key !== nextKey);
|
||||
if (previousRunning) {
|
||||
updateTestServerDeploymentStep(snapshot, previousRunning.key, 'completed');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTestServerDeploymentCleanup(completedAt: string) {
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
const snapshot = await readTestServerDeploymentState();
|
||||
if (snapshot?.status === 'completed' && snapshot.completedAt === completedAt) {
|
||||
await clearTestServerDeploymentState();
|
||||
}
|
||||
})();
|
||||
}, TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS);
|
||||
|
||||
if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTestServerDeployment(
|
||||
lockPath: string,
|
||||
snapshot: TestServerDeploymentSnapshot,
|
||||
persist: () => Promise<void>,
|
||||
) {
|
||||
const mainProjectRoot = resolveMainProjectRoot();
|
||||
const deployScript = path.join(mainProjectRoot, 'etc', 'commands', 'server-command', 'deploy-test.sh');
|
||||
|
||||
const moveToStep = (key: TestServerDeploymentStepKey) => {
|
||||
markPreviousRunningStepCompleted(snapshot, key);
|
||||
snapshot.phase = key;
|
||||
snapshot.summary = buildTestServerDeploymentSummary(key);
|
||||
updateTestServerDeploymentStep(snapshot, key, 'running');
|
||||
void persist();
|
||||
};
|
||||
|
||||
const appendOutput = (line: string) => {
|
||||
snapshot.logExcerpt = appendTestServerDeploymentLog(snapshot.logExcerpt, line);
|
||||
snapshot.updatedAt = new Date().toISOString();
|
||||
void persist();
|
||||
};
|
||||
|
||||
const fail = async (message: string) => {
|
||||
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key ?? 'commit-main-worktree';
|
||||
snapshot.status = 'failed';
|
||||
snapshot.phase = 'failed';
|
||||
snapshot.summary = buildTestServerDeploymentSummary('failed');
|
||||
snapshot.lastError = message;
|
||||
snapshot.updatedAt = new Date().toISOString();
|
||||
updateTestServerDeploymentStep(snapshot, activeStep, 'failed', message);
|
||||
await persist();
|
||||
};
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('sh', [deployScript], {
|
||||
cwd: mainProjectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
REPO_ROOT: mainProjectRoot,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
|
||||
const processLine = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
const marker = trimmed.match(/^::step::([a-z-]+)$/);
|
||||
|
||||
if (marker) {
|
||||
const nextStep = normalizeTestServerDeploymentStepKey(marker[1]);
|
||||
if (nextStep) {
|
||||
moveToStep(nextStep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appendOutput(line);
|
||||
};
|
||||
|
||||
const flushBufferedLines = (buffer: string) => {
|
||||
const normalized = buffer.replace(/\r$/, '').trim();
|
||||
if (normalized) {
|
||||
processLine(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const attachReader = (stream: NodeJS.ReadableStream | null, target: 'stdout' | 'stderr') => {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (chunk: string) => {
|
||||
if (target === 'stdout') {
|
||||
stdoutBuffer += chunk;
|
||||
while (stdoutBuffer.includes('\n')) {
|
||||
const index = stdoutBuffer.indexOf('\n');
|
||||
const line = stdoutBuffer.slice(0, index).replace(/\r$/, '');
|
||||
stdoutBuffer = stdoutBuffer.slice(index + 1);
|
||||
processLine(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stderrBuffer += chunk;
|
||||
while (stderrBuffer.includes('\n')) {
|
||||
const index = stderrBuffer.indexOf('\n');
|
||||
const line = stderrBuffer.slice(0, index).replace(/\r$/, '');
|
||||
stderrBuffer = stderrBuffer.slice(index + 1);
|
||||
processLine(line);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
attachReader(child.stdout, 'stdout');
|
||||
attachReader(child.stderr, 'stderr');
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => {
|
||||
flushBufferedLines(stdoutBuffer);
|
||||
flushBufferedLines(stderrBuffer);
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(Object.assign(new Error(`deploy-test exited with ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`), {
|
||||
code,
|
||||
signal,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key;
|
||||
if (activeStep) {
|
||||
updateTestServerDeploymentStep(snapshot, activeStep, 'completed');
|
||||
}
|
||||
const completedAt = new Date().toISOString();
|
||||
snapshot.status = 'completed';
|
||||
snapshot.phase = 'completed';
|
||||
snapshot.summary = buildTestServerDeploymentSummary('completed');
|
||||
snapshot.completedAt = completedAt;
|
||||
snapshot.updatedAt = completedAt;
|
||||
snapshot.lastError = null;
|
||||
await persist();
|
||||
scheduleTestServerDeploymentCleanup(completedAt);
|
||||
} catch (error) {
|
||||
const message = buildTestDeploymentFailureMessage(snapshot, error);
|
||||
await fail(message);
|
||||
} finally {
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startTestServerDeployment() {
|
||||
const lockPath = await acquireTestServerDeploymentLock();
|
||||
const startedAt = new Date().toISOString();
|
||||
const snapshot = buildEmptyTestServerDeploymentSnapshot();
|
||||
snapshot.status = 'running';
|
||||
snapshot.phase = 'commit-main-worktree';
|
||||
snapshot.summary = buildTestServerDeploymentSummary('commit-main-worktree');
|
||||
snapshot.startedAt = startedAt;
|
||||
snapshot.updatedAt = startedAt;
|
||||
updateTestServerDeploymentStep(snapshot, 'commit-main-worktree', 'running', 'main 작업트리 변경을 커밋합니다.');
|
||||
await writeTestServerDeploymentState(snapshot);
|
||||
|
||||
let persistQueue = Promise.resolve();
|
||||
const persist = async () => {
|
||||
persistQueue = persistQueue.then(() => writeTestServerDeploymentState(snapshot)).catch(() => undefined);
|
||||
await persistQueue;
|
||||
};
|
||||
|
||||
void runTestServerDeployment(lockPath, snapshot, persist);
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { db } from '../db/client.js';
|
||||
import type { RequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
const TOKEN_SETTING_ACTIVITIES_TABLE = 'token_setting_activities';
|
||||
|
||||
export type TokenSettingActivityRecord = {
|
||||
id: number;
|
||||
settingId: string;
|
||||
activityType: 'created' | 'updated' | 'deleted';
|
||||
actorLabel: string | null;
|
||||
summary: string;
|
||||
detail: string | null;
|
||||
clientIp: string | null;
|
||||
externalIp: string | null;
|
||||
forwardedFor: string | null;
|
||||
realIp: string | null;
|
||||
host: string | null;
|
||||
origin: string | null;
|
||||
referer: string | null;
|
||||
userAgent: string | null;
|
||||
clientId: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type TokenSettingActivityInput = {
|
||||
settingId: string;
|
||||
activityType: TokenSettingActivityRecord['activityType'];
|
||||
actorLabel?: string | null;
|
||||
summary: string;
|
||||
detail?: string | null;
|
||||
audit?: RequestAuditContext | null;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: unknown) {
|
||||
const normalized = normalizeText(value);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeDateTime(value: unknown) {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.parse(normalized);
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
export async function ensureTokenSettingActivityTable() {
|
||||
const hasTable = await db.schema.hasTable(TOKEN_SETTING_ACTIVITIES_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('setting_id', 120).notNullable().index();
|
||||
table.string('activity_type', 40).notNullable();
|
||||
table.string('actor_label', 120).nullable();
|
||||
table.string('summary', 400).notNullable();
|
||||
table.text('detail').nullable();
|
||||
table.string('client_ip', 120).nullable();
|
||||
table.string('external_ip', 120).nullable();
|
||||
table.text('forwarded_for').nullable();
|
||||
table.string('real_ip', 120).nullable();
|
||||
table.string('host', 255).nullable();
|
||||
table.text('origin').nullable();
|
||||
table.text('referer').nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.string('client_id', 255).nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['setting_id', (table) => table.string('setting_id', 120).notNullable().defaultTo('').index()],
|
||||
['activity_type', (table) => table.string('activity_type', 40).notNullable().defaultTo('updated')],
|
||||
['actor_label', (table) => table.string('actor_label', 120).nullable()],
|
||||
['summary', (table) => table.string('summary', 400).notNullable().defaultTo('')],
|
||||
['detail', (table) => table.text('detail').nullable()],
|
||||
['client_ip', (table) => table.string('client_ip', 120).nullable()],
|
||||
['external_ip', (table) => table.string('external_ip', 120).nullable()],
|
||||
['forwarded_for', (table) => table.text('forwarded_for').nullable()],
|
||||
['real_ip', (table) => table.string('real_ip', 120).nullable()],
|
||||
['host', (table) => table.string('host', 255).nullable()],
|
||||
['origin', (table) => table.text('origin').nullable()],
|
||||
['referer', (table) => table.text('referer').nullable()],
|
||||
['user_agent', (table) => table.text('user_agent').nullable()],
|
||||
['client_id', (table) => table.string('client_id', 255).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(TOKEN_SETTING_ACTIVITIES_TABLE, columnName);
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendTokenSettingActivity(input: TokenSettingActivityInput) {
|
||||
await ensureTokenSettingActivityTable();
|
||||
|
||||
await db(TOKEN_SETTING_ACTIVITIES_TABLE).insert({
|
||||
setting_id: normalizeText(input.settingId),
|
||||
activity_type: input.activityType,
|
||||
actor_label: normalizeOptionalText(input.actorLabel),
|
||||
summary: normalizeText(input.summary),
|
||||
detail: normalizeOptionalText(input.detail),
|
||||
client_ip: normalizeOptionalText(input.audit?.clientIp),
|
||||
external_ip: normalizeOptionalText(input.audit?.externalIp),
|
||||
forwarded_for: normalizeOptionalText(input.audit?.forwardedFor),
|
||||
real_ip: normalizeOptionalText(input.audit?.realIp),
|
||||
host: normalizeOptionalText(input.audit?.host),
|
||||
origin: normalizeOptionalText(input.audit?.origin),
|
||||
referer: normalizeOptionalText(input.audit?.referer),
|
||||
user_agent: normalizeOptionalText(input.audit?.userAgent),
|
||||
client_id: normalizeOptionalText(input.audit?.clientId),
|
||||
created_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTokenSettingActivities(settingId: string) {
|
||||
await ensureTokenSettingActivityTable();
|
||||
|
||||
const rows = await db(TOKEN_SETTING_ACTIVITIES_TABLE)
|
||||
.select('*')
|
||||
.where({ setting_id: normalizeText(settingId) })
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(200);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
settingId: normalizeText(row.setting_id),
|
||||
activityType: (normalizeText(row.activity_type) as TokenSettingActivityRecord['activityType']) || 'updated',
|
||||
actorLabel: normalizeOptionalText(row.actor_label),
|
||||
summary: normalizeText(row.summary),
|
||||
detail: normalizeOptionalText(row.detail),
|
||||
clientIp: normalizeOptionalText(row.client_ip),
|
||||
externalIp: normalizeOptionalText(row.external_ip),
|
||||
forwardedFor: normalizeOptionalText(row.forwarded_for),
|
||||
realIp: normalizeOptionalText(row.real_ip),
|
||||
host: normalizeOptionalText(row.host),
|
||||
origin: normalizeOptionalText(row.origin),
|
||||
referer: normalizeOptionalText(row.referer),
|
||||
userAgent: normalizeOptionalText(row.user_agent),
|
||||
clientId: normalizeOptionalText(row.client_id),
|
||||
createdAt: normalizeDateTime(row.created_at) ?? new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { db } from '../db/client.js';
|
||||
import { appendTokenSettingActivity, ensureTokenSettingActivityTable } from './token-setting-activity-service.js';
|
||||
import type { RequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
const TOKEN_SETTINGS_TABLE = 'token_settings';
|
||||
const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type TokenSettingRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
defaultExpiresInMinutes: number;
|
||||
maxExpiresInMinutes: number;
|
||||
maxTokensPer30Days: number;
|
||||
maxTokensPer7Days: number;
|
||||
maxTokensPer5Hours: number;
|
||||
oneTimeTokenLimit: number;
|
||||
allowedAppIds: string[];
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeEnabled(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
|
||||
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
function normalizeSettingId(value: unknown) {
|
||||
return normalizeText(value)
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]/g, '');
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const resolved = typeof value === 'number' ? value : Number(value);
|
||||
|
||||
if (!Number.isFinite(resolved)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(max, Math.max(min, Math.round(resolved)));
|
||||
}
|
||||
|
||||
function normalizeAllowedAppIds(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort((left, right) => left.localeCompare(right, 'en'));
|
||||
}
|
||||
|
||||
function normalizeTokenSetting(record: Partial<TokenSettingRecord>): TokenSettingRecord | null {
|
||||
const id = normalizeSettingId(record.id);
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultExpiresInMinutes = normalizePositiveInteger(record.defaultExpiresInMinutes, 60, 0, UNBOUNDED_NUMERIC_LIMIT);
|
||||
const resolvedMaxExpiresInMinutes = normalizePositiveInteger(
|
||||
record.maxExpiresInMinutes,
|
||||
defaultExpiresInMinutes <= 0 ? 0 : 10_080,
|
||||
0,
|
||||
UNBOUNDED_NUMERIC_LIMIT,
|
||||
);
|
||||
const maxExpiresInMinutes =
|
||||
defaultExpiresInMinutes <= 0 || resolvedMaxExpiresInMinutes <= 0
|
||||
? 0
|
||||
: Math.max(defaultExpiresInMinutes, resolvedMaxExpiresInMinutes);
|
||||
|
||||
const legacyMaxTotalTokens =
|
||||
'maxTotalTokens' in record
|
||||
? normalizePositiveInteger((record as { maxTotalTokens?: number }).maxTotalTokens, 100_000, 0, UNBOUNDED_NUMERIC_LIMIT)
|
||||
: 100_000;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
defaultExpiresInMinutes,
|
||||
maxExpiresInMinutes,
|
||||
maxTokensPer30Days: normalizePositiveInteger(record.maxTokensPer30Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
maxTokensPer7Days: normalizePositiveInteger(record.maxTokensPer7Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
maxTokensPer5Hours: normalizePositiveInteger(record.maxTokensPer5Hours, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
oneTimeTokenLimit: normalizePositiveInteger(record.oneTimeTokenLimit, 0, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
allowedAppIds: normalizeAllowedAppIds(record.allowedAppIds),
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: TokenSettingRecord, right: TokenSettingRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sanitizeTokenSettings(items: Partial<TokenSettingRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, TokenSettingRecord>();
|
||||
|
||||
for (const item of items ?? []) {
|
||||
const normalized = normalizeTokenSetting(item);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = byId.get(normalized.id);
|
||||
if (!current || compareUpdatedAt(current, normalized) <= 0) {
|
||||
byId.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byId.values()).sort((left, right) => {
|
||||
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id, 'en');
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTokenSettingsTable() {
|
||||
const hasTable = await db.schema.hasTable(TOKEN_SETTINGS_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(TOKEN_SETTINGS_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').notNullable().defaultTo('');
|
||||
table.integer('default_expires_in_minutes').notNullable().defaultTo(60);
|
||||
table.integer('max_expires_in_minutes').notNullable().defaultTo(10_080);
|
||||
table.bigInteger('max_total_tokens').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('max_tokens_per_30_days').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('max_tokens_per_7_days').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('max_tokens_per_5_hours').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('one_time_token_limit').notNullable().defaultTo(0);
|
||||
table.text('allowed_app_ids_json').notNullable().defaultTo('[]');
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
await ensureTokenSettingActivityTable();
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['name', (table) => table.string('name').notNullable().defaultTo('')],
|
||||
['description', (table) => table.text('description').notNullable().defaultTo('')],
|
||||
['default_expires_in_minutes', (table) => table.integer('default_expires_in_minutes').notNullable().defaultTo(60)],
|
||||
['max_expires_in_minutes', (table) => table.integer('max_expires_in_minutes').notNullable().defaultTo(10_080)],
|
||||
['max_total_tokens', (table) => table.bigInteger('max_total_tokens').notNullable().defaultTo(100_000)],
|
||||
['max_tokens_per_30_days', (table) => table.bigInteger('max_tokens_per_30_days').notNullable().defaultTo(100_000)],
|
||||
['max_tokens_per_7_days', (table) => table.bigInteger('max_tokens_per_7_days').notNullable().defaultTo(100_000)],
|
||||
['max_tokens_per_5_hours', (table) => table.bigInteger('max_tokens_per_5_hours').notNullable().defaultTo(100_000)],
|
||||
['one_time_token_limit', (table) => table.bigInteger('one_time_token_limit').notNullable().defaultTo(0)],
|
||||
['allowed_app_ids_json', (table) => table.text('allowed_app_ids_json').notNullable().defaultTo('[]')],
|
||||
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(TOKEN_SETTINGS_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(TOKEN_SETTINGS_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ensureTokenSettingActivityTable();
|
||||
}
|
||||
|
||||
function parseAllowedAppIds(row: Record<string, unknown>) {
|
||||
const rawValue = row.allowed_app_ids_json;
|
||||
|
||||
if (typeof rawValue !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toTokenSettingRecord(row: Record<string, unknown>) {
|
||||
return normalizeTokenSetting({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
name: typeof row.name === 'string' ? row.name : undefined,
|
||||
description: typeof row.description === 'string' ? row.description : undefined,
|
||||
defaultExpiresInMinutes:
|
||||
typeof row.default_expires_in_minutes === 'number' || typeof row.default_expires_in_minutes === 'string'
|
||||
? Number(row.default_expires_in_minutes)
|
||||
: undefined,
|
||||
maxExpiresInMinutes:
|
||||
typeof row.max_expires_in_minutes === 'number' || typeof row.max_expires_in_minutes === 'string'
|
||||
? Number(row.max_expires_in_minutes)
|
||||
: undefined,
|
||||
maxTokensPer30Days:
|
||||
typeof row.max_tokens_per_30_days === 'number' || typeof row.max_tokens_per_30_days === 'string'
|
||||
? Number(row.max_tokens_per_30_days)
|
||||
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
|
||||
? Number(row.max_total_tokens)
|
||||
: undefined,
|
||||
maxTokensPer7Days:
|
||||
typeof row.max_tokens_per_7_days === 'number' || typeof row.max_tokens_per_7_days === 'string'
|
||||
? Number(row.max_tokens_per_7_days)
|
||||
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
|
||||
? Number(row.max_total_tokens)
|
||||
: undefined,
|
||||
maxTokensPer5Hours:
|
||||
typeof row.max_tokens_per_5_hours === 'number' || typeof row.max_tokens_per_5_hours === 'string'
|
||||
? Number(row.max_tokens_per_5_hours)
|
||||
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
|
||||
? Number(row.max_total_tokens)
|
||||
: undefined,
|
||||
oneTimeTokenLimit:
|
||||
typeof row.one_time_token_limit === 'number' || typeof row.one_time_token_limit === 'string'
|
||||
? Number(row.one_time_token_limit)
|
||||
: undefined,
|
||||
allowedAppIds: parseAllowedAppIds(row),
|
||||
enabled:
|
||||
typeof row.enabled === 'boolean' || typeof row.enabled === 'number' || typeof row.enabled === 'string'
|
||||
? normalizeEnabled(row.enabled)
|
||||
: undefined,
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function readTokenSettingsFromTable() {
|
||||
await ensureTokenSettingsTable();
|
||||
|
||||
const rows = await db(TOKEN_SETTINGS_TABLE)
|
||||
.select(
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'default_expires_in_minutes',
|
||||
'max_expires_in_minutes',
|
||||
'max_tokens_per_30_days',
|
||||
'max_tokens_per_7_days',
|
||||
'max_tokens_per_5_hours',
|
||||
'one_time_token_limit',
|
||||
'max_total_tokens',
|
||||
'allowed_app_ids_json',
|
||||
'enabled',
|
||||
'updated_at',
|
||||
)
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
return sanitizeTokenSettings(
|
||||
rows
|
||||
.map((row) => toTokenSettingRecord(row as Record<string, unknown>))
|
||||
.filter((item): item is TokenSettingRecord => Boolean(item)),
|
||||
);
|
||||
}
|
||||
|
||||
function buildActivityDetail(previous: TokenSettingRecord | null, next: TokenSettingRecord | null) {
|
||||
if (!previous && next) {
|
||||
return `앱 ${next.allowedAppIds.join(', ') || '-'} / 기본 ${next.defaultExpiresInMinutes}분`;
|
||||
}
|
||||
|
||||
if (previous && !next) {
|
||||
return `삭제 전 앱 ${previous.allowedAppIds.join(', ') || '-'} / 기본 ${previous.defaultExpiresInMinutes}분`;
|
||||
}
|
||||
|
||||
if (!previous || !next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const changedFields: string[] = [];
|
||||
if (previous.name !== next.name) changedFields.push(`이름 ${previous.name} -> ${next.name}`);
|
||||
if (previous.description !== next.description) changedFields.push('설명');
|
||||
if (previous.defaultExpiresInMinutes !== next.defaultExpiresInMinutes) changedFields.push(`기본만료 ${previous.defaultExpiresInMinutes} -> ${next.defaultExpiresInMinutes}`);
|
||||
if (previous.maxTokensPer30Days !== next.maxTokensPer30Days) changedFields.push(`30일 ${previous.maxTokensPer30Days} -> ${next.maxTokensPer30Days}`);
|
||||
if (previous.maxTokensPer7Days !== next.maxTokensPer7Days) changedFields.push(`7일 ${previous.maxTokensPer7Days} -> ${next.maxTokensPer7Days}`);
|
||||
if (previous.maxTokensPer5Hours !== next.maxTokensPer5Hours) changedFields.push(`5시간 ${previous.maxTokensPer5Hours} -> ${next.maxTokensPer5Hours}`);
|
||||
if (previous.oneTimeTokenLimit !== next.oneTimeTokenLimit) changedFields.push(`1회 ${previous.oneTimeTokenLimit} -> ${next.oneTimeTokenLimit}`);
|
||||
if (previous.enabled !== next.enabled) changedFields.push(`사용 ${previous.enabled} -> ${next.enabled}`);
|
||||
if (JSON.stringify(previous.allowedAppIds) !== JSON.stringify(next.allowedAppIds)) {
|
||||
changedFields.push(`앱 ${previous.allowedAppIds.join(', ') || '-'} -> ${next.allowedAppIds.join(', ') || '-'}`);
|
||||
}
|
||||
|
||||
return changedFields.join(' / ') || null;
|
||||
}
|
||||
|
||||
async function replaceTokenSettingsInTable(
|
||||
items: TokenSettingRecord[],
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
await ensureTokenSettingsTable();
|
||||
|
||||
const previousItems = await readTokenSettingsFromTable();
|
||||
const nextItems = sanitizeTokenSettings(items);
|
||||
const previousById = new Map(previousItems.map((item) => [item.id, item] as const));
|
||||
const nextById = new Map(nextItems.map((item) => [item.id, item] as const));
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(TOKEN_SETTINGS_TABLE).del();
|
||||
|
||||
if (nextItems.length > 0) {
|
||||
await trx(TOKEN_SETTINGS_TABLE).insert(
|
||||
nextItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
default_expires_in_minutes: item.defaultExpiresInMinutes,
|
||||
max_expires_in_minutes: item.maxExpiresInMinutes,
|
||||
max_total_tokens: item.maxTokensPer30Days,
|
||||
max_tokens_per_30_days: item.maxTokensPer30Days,
|
||||
max_tokens_per_7_days: item.maxTokensPer7Days,
|
||||
max_tokens_per_5_hours: item.maxTokensPer5Hours,
|
||||
one_time_token_limit: item.oneTimeTokenLimit,
|
||||
allowed_app_ids_json: JSON.stringify(item.allowedAppIds),
|
||||
enabled: item.enabled,
|
||||
updated_at: item.updatedAt,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const affectedIds = Array.from(new Set([...previousById.keys(), ...nextById.keys()]));
|
||||
|
||||
for (const settingId of affectedIds) {
|
||||
const previous = previousById.get(settingId) ?? null;
|
||||
const next = nextById.get(settingId) ?? null;
|
||||
|
||||
if (!previous && next) {
|
||||
await appendTokenSettingActivity({
|
||||
settingId,
|
||||
activityType: 'created',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '토큰 설정을 생성했습니다.',
|
||||
detail: buildActivityDetail(previous, next),
|
||||
audit: options?.audit,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous && !next) {
|
||||
await appendTokenSettingActivity({
|
||||
settingId,
|
||||
activityType: 'deleted',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '토큰 설정을 삭제했습니다.',
|
||||
detail: buildActivityDetail(previous, next),
|
||||
audit: options?.audit,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous && next && JSON.stringify(previous) !== JSON.stringify(next)) {
|
||||
await appendTokenSettingActivity({
|
||||
settingId,
|
||||
activityType: 'updated',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '토큰 설정을 수정했습니다.',
|
||||
detail: buildActivityDetail(previous, next),
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
export async function getTokenSettingsConfig() {
|
||||
return readTokenSettingsFromTable();
|
||||
}
|
||||
|
||||
export async function upsertTokenSettingsConfig(
|
||||
items: Partial<TokenSettingRecord>[] | null | undefined,
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
return replaceTokenSettingsInTable(sanitizeTokenSettings(items), options);
|
||||
}
|
||||
|
||||
export async function getTokenSettingById(id: string) {
|
||||
const normalizedId = normalizeSettingId(id);
|
||||
if (!normalizedId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureTokenSettingsTable();
|
||||
|
||||
const row = await db(TOKEN_SETTINGS_TABLE)
|
||||
.where({ id: normalizedId })
|
||||
.first(
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'default_expires_in_minutes',
|
||||
'max_expires_in_minutes',
|
||||
'max_tokens_per_30_days',
|
||||
'max_tokens_per_7_days',
|
||||
'max_tokens_per_5_hours',
|
||||
'one_time_token_limit',
|
||||
'max_total_tokens',
|
||||
'allowed_app_ids_json',
|
||||
'enabled',
|
||||
'updated_at',
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toTokenSettingRecord(row as Record<string, unknown>);
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
type WorkServerSlot = 'blue' | 'green';
|
||||
|
||||
function normalizeSlot(value: string | null | undefined): WorkServerSlot | null {
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
return normalized === 'blue' || normalized === 'green' ? normalized : null;
|
||||
}
|
||||
|
||||
function buildActiveSlotFileCandidates() {
|
||||
const candidates = [
|
||||
env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim(),
|
||||
path.join(env.SERVER_COMMAND_MAIN_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||
path.join(env.SERVER_COMMAND_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||
path.join(env.SERVER_COMMAND_PROJECT_ROOT, '.docker', 'runtime', 'active-slot'),
|
||||
]
|
||||
.map((value) => String(value ?? '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
async function readActiveSlotFromFile() {
|
||||
for (const candidate of buildActiveSlotFileCandidates()) {
|
||||
try {
|
||||
const value = await readFile(candidate, 'utf8');
|
||||
const slot = normalizeSlot(value);
|
||||
|
||||
if (slot) {
|
||||
return slot;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or unreadable candidates and continue.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isCurrentWorkServerSlotActive() {
|
||||
const currentSlot = normalizeSlot(process.env.WORK_SERVER_SLOT);
|
||||
|
||||
if (!currentSlot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeSlot = (await readActiveSlotFromFile()) ?? 'blue';
|
||||
return currentSlot === activeSlot;
|
||||
}
|
||||
105
etc/servers/work-server/src/utils/request-audit.ts
Normal file
105
etc/servers/work-server/src/utils/request-audit.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export type RequestAuditContext = {
|
||||
clientIp: string | null;
|
||||
externalIp: string | null;
|
||||
forwardedFor: string | null;
|
||||
realIp: string | null;
|
||||
host: string | null;
|
||||
origin: string | null;
|
||||
referer: string | null;
|
||||
userAgent: string | null;
|
||||
clientId: string | null;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeHeaderValue(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeText(value[0]);
|
||||
}
|
||||
|
||||
return normalizeText(value);
|
||||
}
|
||||
|
||||
function splitForwardedFor(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function stripIpDecorations(value: string) {
|
||||
const normalized = value.replace(/^for=/iu, '').replace(/^"|"$/g, '').trim();
|
||||
if (normalized.startsWith('[') && normalized.includes(']')) {
|
||||
return normalized.slice(1, normalized.indexOf(']')).trim();
|
||||
}
|
||||
const ipv4PortMatch = normalized.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/u);
|
||||
if (ipv4PortMatch) {
|
||||
return ipv4PortMatch[1] ?? normalized;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPrivateOrLocalIp(value: string) {
|
||||
const normalized = stripIpDecorations(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === '::1' || normalized === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('127.') || normalized.startsWith('10.') || normalized.startsWith('192.168.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('::ffff:127.') || normalized.startsWith('::ffff:10.') || normalized.startsWith('::ffff:192.168.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^::ffff:172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveExternalIp(candidates: string[]) {
|
||||
const cleaned = candidates.map((item) => stripIpDecorations(item)).filter(Boolean);
|
||||
return cleaned.find((item) => !isPrivateOrLocalIp(item)) ?? cleaned[0] ?? null;
|
||||
}
|
||||
|
||||
export function extractRequestAuditContext(request: FastifyRequest): RequestAuditContext {
|
||||
const forwardedFor = normalizeHeaderValue(request.headers['x-forwarded-for']);
|
||||
const realIp = normalizeHeaderValue(request.headers['x-real-ip']) || normalizeHeaderValue(request.headers['cf-connecting-ip']);
|
||||
const clientIp = normalizeText(request.ip) || normalizeText(request.raw.socket.remoteAddress) || null;
|
||||
|
||||
return {
|
||||
clientIp: clientIp ? stripIpDecorations(clientIp) : null,
|
||||
externalIp: resolveExternalIp([
|
||||
...splitForwardedFor(forwardedFor),
|
||||
realIp,
|
||||
normalizeText(request.headers['x-client-ip']),
|
||||
clientIp ?? '',
|
||||
]),
|
||||
forwardedFor: forwardedFor || null,
|
||||
realIp: realIp ? stripIpDecorations(realIp) : null,
|
||||
host: normalizeHeaderValue(request.headers.host) || null,
|
||||
origin: normalizeHeaderValue(request.headers.origin) || null,
|
||||
referer: normalizeHeaderValue(request.headers.referer) || null,
|
||||
userAgent: normalizeHeaderValue(request.headers['user-agent']) || null,
|
||||
clientId: normalizeHeaderValue(request.headers['x-client-id']) || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { processDueBaseballTicketBayAlerts } from '../services/baseball-ticket-bay-service.js';
|
||||
import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
|
||||
export class BaseballTicketBayWorker {
|
||||
private readonly logger: FastifyBaseLogger;
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private running = false;
|
||||
|
||||
constructor(logger: FastifyBaseLogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, DEFAULT_INTERVAL_MS);
|
||||
this.timer.unref?.();
|
||||
this.logger.info({ intervalMs: DEFAULT_INTERVAL_MS }, 'Baseball Ticket Bay worker started');
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isCurrentWorkServerSlotActive())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
try {
|
||||
const results = await processDueBaseballTicketBayAlerts(new Date());
|
||||
const executed = results.length;
|
||||
const failed = results.filter((item) => !item.ok).length;
|
||||
|
||||
if (executed > 0) {
|
||||
this.logger.info({ executed, failed }, 'Baseball Ticket Bay batch processed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, 'Baseball Ticket Bay worker tick failed');
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from '../services/git-service.js';
|
||||
import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js';
|
||||
import { registerDuePlanScheduledTasks } from '../services/plan-schedule-service.js';
|
||||
import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js';
|
||||
|
||||
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
||||
const FIRST_PROGRESS_NOTIFICATION_MS = 60_000;
|
||||
@@ -857,6 +858,11 @@ export class PlanWorker {
|
||||
this.running = true;
|
||||
|
||||
try {
|
||||
if (!(await isCurrentWorkServerSlotActive())) {
|
||||
this.logger.info({ workerId: this.workerId }, 'Plan worker skipped on inactive work-server slot');
|
||||
return;
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
const appConfig = await getAppConfigSnapshot();
|
||||
const autoRefreshEnabled = appConfig.automation?.autoRefreshEnabled ?? true;
|
||||
|
||||
113
index.html
113
index.html
@@ -12,9 +12,120 @@
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
|
||||
<title>AI Code App</title>
|
||||
<script>
|
||||
(() => {
|
||||
const { pathname, search, hash, origin } = window.location;
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const playAppInstallMetadata = {
|
||||
'baseball-ticket-bay': { title: 'Baseball Ticket Bay', themeColor: '#1b3f91' },
|
||||
photoprism: { title: 'PhotoPrism', themeColor: '#0f766e' },
|
||||
'photo-puzzle': { title: 'Photo Puzzle', themeColor: '#d97706' },
|
||||
'the-quest': { title: 'The Quest', themeColor: '#7c3aed' },
|
||||
tetris: { title: 'Tetris', themeColor: '#0f172a' },
|
||||
'e-reader': { title: 'E-Reader', themeColor: '#165dff' },
|
||||
};
|
||||
|
||||
let installMetadata = null;
|
||||
|
||||
if (pathname === '/play/apps') {
|
||||
const appId = searchParams.get('app')?.trim() ?? '';
|
||||
const appMetadata = playAppInstallMetadata[appId];
|
||||
|
||||
if (appMetadata) {
|
||||
installMetadata = {
|
||||
title: appMetadata.title,
|
||||
shortName: appMetadata.title,
|
||||
description: `${appMetadata.title} 앱을 홈 화면에서 바로 엽니다.`,
|
||||
themeColor: appMetadata.themeColor,
|
||||
scope: pathname,
|
||||
};
|
||||
}
|
||||
} else if (pathname === '/plans/shared-resource') {
|
||||
installMetadata = {
|
||||
title: '공유 리소스 관리',
|
||||
shortName: '공유 리소스',
|
||||
description: '공유 리소스 관리 화면을 홈 화면 앱으로 바로 엽니다.',
|
||||
themeColor: '#0f766e',
|
||||
scope: pathname,
|
||||
};
|
||||
} else if (pathname.startsWith('/chat/share/') || pathname.startsWith('/shares/')) {
|
||||
installMetadata = {
|
||||
title: '리소스 공유 채팅방',
|
||||
shortName: '공유채팅',
|
||||
description: '리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.',
|
||||
themeColor: '#165dff',
|
||||
scope: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
if (!installMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startUrl = new URL(`${pathname}${search}${hash}`, origin).toString();
|
||||
const scopeUrl = new URL(installMetadata.scope, origin).toString();
|
||||
const manifest = {
|
||||
id: startUrl,
|
||||
name: installMetadata.title,
|
||||
short_name: installMetadata.shortName,
|
||||
description: installMetadata.description,
|
||||
theme_color: installMetadata.themeColor,
|
||||
background_color: '#eff5ff',
|
||||
display: 'standalone',
|
||||
lang: 'ko',
|
||||
scope: scopeUrl,
|
||||
start_url: startUrl,
|
||||
icons: [
|
||||
{
|
||||
src: new URL('/pwa-192x192.svg', origin).toString(),
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
{
|
||||
src: new URL('/pwa-512x512.svg', origin).toString(),
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const manifestHref = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(manifest, null, 2)], {
|
||||
type: 'application/manifest+json',
|
||||
}),
|
||||
);
|
||||
let manifestLink = document.querySelector('link[rel="manifest"]');
|
||||
|
||||
if (!manifestLink) {
|
||||
manifestLink = document.createElement('link');
|
||||
manifestLink.rel = 'manifest';
|
||||
document.head.appendChild(manifestLink);
|
||||
}
|
||||
|
||||
manifestLink.href = manifestHref;
|
||||
document.title = installMetadata.title;
|
||||
|
||||
let appleTitleMeta = document.querySelector('meta[name="apple-mobile-web-app-title"]');
|
||||
if (!appleTitleMeta) {
|
||||
appleTitleMeta = document.createElement('meta');
|
||||
appleTitleMeta.name = 'apple-mobile-web-app-title';
|
||||
document.head.appendChild(appleTitleMeta);
|
||||
}
|
||||
appleTitleMeta.content = installMetadata.title;
|
||||
|
||||
let themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!themeColorMeta) {
|
||||
themeColorMeta = document.createElement('meta');
|
||||
themeColorMeta.name = 'theme-color';
|
||||
document.head.appendChild(themeColorMeta);
|
||||
}
|
||||
themeColorMeta.content = installMetadata.themeColor;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
42
package-lock.json
generated
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"plan:codex:once": "node scripts/run-plan-codex-once.mjs",
|
||||
"server-command:runner": "node scripts/run-server-command-runner.mjs",
|
||||
"build:app": "node scripts/prepare-app-dist.mjs && tsc -b && VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true vite build --outDir app-dist",
|
||||
"build:preview-app": "node scripts/prepare-app-dist.mjs && VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir app-dist",
|
||||
"build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist",
|
||||
"build:lib": "tsc -p tsconfig.lib.json",
|
||||
"build": "npm run build:lib && npm run build:app",
|
||||
@@ -54,10 +55,12 @@
|
||||
"ag-grid-community": "^35.2.1",
|
||||
"ag-grid-react": "^35.2.1",
|
||||
"antd": "^5.27.0",
|
||||
"phaser": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1"
|
||||
"recharts": "^3.8.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
|
||||
BIN
public/..codex?
Normal file
BIN
public/..codex?
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
25
public/chat-share.webmanifest
Normal file
25
public/chat-share.webmanifest
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "/chat/share/",
|
||||
"name": "리소스 공유 채팅방",
|
||||
"short_name": "공유채팅",
|
||||
"description": "리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.",
|
||||
"theme_color": "#165dff",
|
||||
"background_color": "#f4f7fb",
|
||||
"display": "standalone",
|
||||
"lang": "ko",
|
||||
"scope": "/chat/share/",
|
||||
"start_url": "/chat/share/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-192x192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-512x512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
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": "/play/apps",
|
||||
"start_url": "/play/apps?app=e-reader",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-192x192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-512x512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# 공유 리소스 관리 채팅 열기 Drawer
|
||||
|
||||
## 기능 설명
|
||||
- 공유 리소스 관리 목록의 `열기` 버튼을 새 창 실행 대신 우측 `Drawer`로 열도록 변경했다.
|
||||
- Drawer 폭은 `100vw`로 고정해 데스크톱과 모바일 모두 화면을 가득 채우는 슬라이드 오픈 형태로 맞췄다.
|
||||
- Drawer 내부에는 공유 채팅 URL을 `iframe`으로 로드하고, 상단 액션에서 `새로고침`과 `새 창`을 추가했다.
|
||||
- 후속 조정으로 Drawer 상단 헤더 패딩과 타이틀 높이를 조금 줄여 본문 영역을 더 넓게 확보했다.
|
||||
|
||||
## 변경 범위
|
||||
- `src/app/main/SharedResourceManagementPage.tsx`
|
||||
- `src/app/main/SharedResourceManagementPage.css`
|
||||
|
||||
## 데이터/API 영향
|
||||
- 신규 API 호출이나 데이터 스키마 변경은 없다.
|
||||
- 기존 공유 채팅 URL 계산 로직을 그대로 사용하고, 표시 방식만 인앱 Drawer로 변경했다.
|
||||
|
||||
## 확인 포인트
|
||||
- 목록 `열기` 버튼 클릭 시 Drawer가 오른쪽에서 전체 폭으로 열린다.
|
||||
- 상세 패널의 `채팅창 열기`도 동일 Drawer를 연다.
|
||||
- Drawer 내부 `새 창` 버튼은 기존 외부 창 열기 동작을 유지한다.
|
||||
- Drawer 상단 헤더가 이전보다 낮아지고 iframe 본문 높이가 함께 맞춰진다.
|
||||
@@ -0,0 +1,17 @@
|
||||
# 검증 요약
|
||||
|
||||
## 실행 결과
|
||||
- `npm run build:test-app` 성공
|
||||
- `npx tsc -b --pretty false` 실패
|
||||
|
||||
## 실패 사유
|
||||
- 전체 타입체크 실패는 이번 수정과 무관한 저장소 기존 오류들 때문에 발생했다.
|
||||
- 대표적으로 `src/app/main/MainChatPanel.tsx`, `src/app/main/pages/ChatSharePage.tsx`, `src/features/layout/draw/LayoutDrawPage.tsx` 등 다수 파일에서 기존 타입 오류가 남아 있다.
|
||||
|
||||
## 이번 변경 확인 범위
|
||||
- `SharedResourceManagementPage`가 프로덕션 번들 기준으로 정상 빌드되는지 확인했다.
|
||||
- 전체 폭 Drawer와 iframe 렌더링은 정적 빌드 성공으로 문법/번들 기준 이상 없음을 확인했다.
|
||||
- Drawer 헤더 축소 후 iframe 최소 높이 계산도 함께 조정해 레이아웃 공백이 생기지 않도록 확인했다.
|
||||
|
||||
## 미실행 항목
|
||||
- 브라우저 실화면 캡처와 모바일 스크린샷은 이번 턴에서 생성하지 못했다.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 공유채팅방 개선
|
||||
|
||||
## 변경 목적
|
||||
- stepper prompt에서 HTML preview가 객체 재생성마다 다시 fetch/reset 되며 멈춘 것처럼 보이던 흐름을 줄입니다.
|
||||
- 공유채팅방 이동 시 이미 본 방은 즉시 복원하고, 최신화는 뒤에서 다시 받아 체감 로딩을 줄입니다.
|
||||
- 재접속 시 마지막으로 사용한 공유채팅방을 다시 열 때, 마지막 방 ID뿐 아니라 해당 방 스냅샷도 세션 기준으로 복원합니다.
|
||||
|
||||
## 변경 범위
|
||||
- `src/app/main/mainChatPanel/ChatPromptCard.tsx`
|
||||
- preview fetch effect 의존성을 안정화했습니다.
|
||||
- preview 본문/`content-type`을 메모리 캐시에 저장해 같은 HTML/markdown/resource preview 재진입 시 재요청을 줄였습니다.
|
||||
- preview viewed / selection change effect에서 객체 참조 의존성을 줄여 stepper 렌더 루프 가능성을 낮췄습니다.
|
||||
- `src/app/main/pages/ChatSharePage.tsx`
|
||||
- 공유채팅방 스냅샷을 `sessionStorage`에도 저장하도록 추가했습니다.
|
||||
- 토큰별 마지막 방 복원 시 세션 캐시 스냅샷을 먼저 적용하도록 보강했습니다.
|
||||
- 방 전환 시 메모리 캐시가 없더라도 세션 캐시가 있으면 즉시 그 스냅샷으로 전환하도록 보강했습니다.
|
||||
|
||||
## 데이터 / API 영향
|
||||
- 새 저장소 키
|
||||
- `sessionStorage`: `codex-live-share-room-snapshot:<token>:<sessionId>`
|
||||
- 기존 API 계약 변경 없음
|
||||
- `/api/chat/shares/:token`
|
||||
- `/api/chat/shares/:token?sessionId=...`
|
||||
|
||||
## 확인 포인트
|
||||
- 같은 stepper prompt preview를 다시 펼쳐도 로딩 스피너가 계속 반복되지 않는지
|
||||
- 이미 열어본 공유채팅방을 다시 눌렀을 때 화면이 캐시로 먼저 복원되는지
|
||||
- 새로고침 후 마지막 사용 방 URL / 선택 상태가 유지되는지
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
@@ -0,0 +1,24 @@
|
||||
# 공유채팅방 개선 검증
|
||||
|
||||
## 실행 결과
|
||||
- `npm exec tsc --noEmit`
|
||||
- 성공
|
||||
- `npm run build:test-app`
|
||||
- 성공
|
||||
- `curl http://127.0.0.1:5173/api/chat/shares/5e578dd5e91a4fa8b32cfe3c?sharePin=1459`
|
||||
- 성공
|
||||
- 응답 기준: `ok: true`, `title: 관리자`, `sessionId: chat-share-room-mpihlq67-ae86e941`, `requestCount: 5`
|
||||
|
||||
## 브라우저 확인
|
||||
- 로컬 test-app 빌드로 `/chat/share/:token` 진입 시 공유채팅 셸 자체는 열렸습니다.
|
||||
- 다만 이 격리 빌드 환경에서는 화면이 로딩 스피너 상태에 머무는 케이스가 있어, 이번 턴에서는 신뢰 가능한 기능 완료 화면 캡처까지는 확보하지 못했습니다.
|
||||
- 따라서 이번 검증 결론은 다음 범위로 한정합니다.
|
||||
- 타입 오류 없음
|
||||
- 프로덕션 테스트 빌드 성공
|
||||
- 공유채팅 스냅샷 API 정상 응답
|
||||
- 로컬 브라우저 진입 자체는 가능
|
||||
|
||||
## 판정
|
||||
- 코드 변경은 정상 반영됨
|
||||
- stepper HTML preview 안정화와 공유채팅방 캐시/복원 로직은 정적 검증 + API 검증까지 완료
|
||||
- 실서버 UI 최종 체감 확인은 공유채팅 실환경에서 한 번 더 보는 것이 안전함
|
||||
@@ -0,0 +1,27 @@
|
||||
# 공유채팅방 멈춤 완화
|
||||
|
||||
## 변경 배경
|
||||
- 1차 수정으로 `sessionStorage`에 공유방 스냅샷을 직렬화하던 경로는 제거했지만, 큰 관리형 공유채팅방에서는 여전히 서버가 최근 1000건 요청/메시지를 한 번에 내려주고 있었습니다.
|
||||
- 공유채팅 페이지도 검색 모달이 닫힌 상태에서 질문·응답·리소스·활동로그 통합검색 인덱스를 매 렌더마다 전수 계산하고 있어, 큰 방에서는 첫 진입과 갱신 시 추가 부담이 남아 있었습니다.
|
||||
|
||||
## 변경 내용
|
||||
- 기존 `sessionStorage` 제거 상태는 유지합니다.
|
||||
- `etc/servers/work-server/src/routes/chat.ts`
|
||||
- 관리형 공유채팅방(`MANAGED_CHAT_SHARE_SESSION_PREFIX`) 스냅샷은 최근 80건 요청 기준의 detail page만 내려주도록 바꿨습니다.
|
||||
- 공유채팅 초기 진입과 실시간 갱신이 더 이상 최근 1000건 전체 요청/메시지를 항상 읽지 않게 했습니다.
|
||||
- `src/app/main/pages/ChatSharePage.tsx`
|
||||
- 통합검색 결과 계산은 검색 모달이 실제로 열렸을 때만 수행하도록 바꿨습니다.
|
||||
- 방 진입 직후에는 닫혀 있는 검색 패널 때문에 질문/응답/리소스/활동로그 전체를 훑지 않습니다.
|
||||
|
||||
## 기대 효과
|
||||
- 큰 공유채팅방에서도 초기 진입과 자동 새로고침이 최근 이력 중심으로 동작해 멈춤 체감이 줄어듭니다.
|
||||
- 재접속 시 마지막 방 복원 기능은 유지하면서, 서버/프런트 양쪽의 불필요한 대량 계산을 줄입니다.
|
||||
|
||||
## 영향 범위
|
||||
- 공유채팅 페이지의 검색 계산 시점과 공유 스냅샷 응답 범위를 조정했습니다.
|
||||
- DB 스키마와 공유채팅 권한 로직은 변경하지 않았습니다.
|
||||
|
||||
## 확인 포인트
|
||||
- 관리형 공유채팅방 첫 진입 시 최근 이력 기준으로 빠르게 열리는지 확인
|
||||
- 메시지/활동로그가 많은 방에서도 방 이동·새로고침·실시간 갱신 시 멈춤 체감이 줄었는지 확인
|
||||
- 검색 모달을 열지 않았을 때는 통합검색 전수 계산이 돌지 않는지 확인
|
||||
@@ -0,0 +1,15 @@
|
||||
# 검증 요약
|
||||
|
||||
## 실행한 검증
|
||||
- `npm exec tsc --noEmit`
|
||||
- `npx tsc -p etc/servers/work-server/tsconfig.json --noEmit`
|
||||
- `npm run build:test-app`
|
||||
|
||||
## 결과
|
||||
- 프런트 타입 검사와 `work-server` 타입 검사는 모두 통과했습니다.
|
||||
- 테스트 번들은 재빌드까지 통과했습니다.
|
||||
- 이번 수정으로 관리형 공유채팅방 스냅샷은 최근 80건 detail page 기준으로 줄였고, 검색 모달이 닫혀 있을 때는 통합검색 계산을 건너뜁니다.
|
||||
|
||||
## 비고
|
||||
- 이번 수정은 UI 레이아웃 변경이 아니라 응답량·계산량 축소라서, 최종 화면 캡처 대신 타입/빌드 검증과 코드 경로 문서화를 남겼습니다.
|
||||
- `preview.sm-home.cloud` 실접속/잠금 해제 재현은 이번 턴에서 다시 수행하지 못했습니다.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 공유채팅 채팅방 설정 정리
|
||||
|
||||
## 변경 목표
|
||||
- 공유채팅방의 채팅방 설정 입력 항목이 많아도 부모 레이아웃이 흔들리지 않도록 전체폭 우측 Drawer 구조로 정리한다.
|
||||
- 공유 링크 권한만으로도 채팅유형과 채팅 알림 수신 여부가 정상 저장/재조회되도록 맞춘다.
|
||||
- 공통 문맥과 방 전용 문맥이 "상속"과 "방 전용 override"를 구분해 저장되도록 정리한다.
|
||||
|
||||
## 변경 범위
|
||||
- `src/app/main/pages/ChatSharePage.tsx`
|
||||
- 채팅방 설정 UI를 `Modal`에서 `Drawer` + `Tabs` 구조로 변경
|
||||
- 채팅유형, 공통 문맥, 방 전용 문맥, 채팅 알림, 보안 탭 분리
|
||||
- 공유 스냅샷의 `conversation.chatTypeId`, `lastChatTypeId`, `notifyOffline` 우선 사용
|
||||
- 공통 문맥 기본값 계산 시 빈 배열을 "없음"이 아니라 "채팅유형 기본값 상속"으로 처리
|
||||
- `src/app/main/pages/ChatSharePage.css`
|
||||
- 전체폭 Drawer 및 탭/카드형 설정 레이아웃 스타일 추가
|
||||
- `src/app/main/mainChatPanel/chatUtils.ts`
|
||||
- 공유 채팅방 설정 저장 API helper를 채팅유형/알림/비밀번호 통합 저장 형태로 확장
|
||||
- 공유 스냅샷 `conversation` 필드에 채팅유형/알림 메타데이터 파싱 추가
|
||||
- `etc/servers/work-server/src/routes/chat.ts`
|
||||
- `/api/chat/shares/:token/room-settings`가 채팅유형/알림 수신까지 저장하도록 확장
|
||||
- `/api/chat/shares/:token` 응답에 채팅유형/알림 상태 포함
|
||||
- `etc/servers/work-server/src/services/chat-room-service.test.ts`
|
||||
- 채팅방 컨텍스트 update field 계산 테스트 보강
|
||||
|
||||
## 저장/적용 기준
|
||||
- 채팅유형
|
||||
- 공유 스냅샷의 현재 `conversation.chatTypeId` 또는 `lastChatTypeId`를 우선 기준으로 사용한다.
|
||||
- 저장 시 `chatTypeId`, `lastChatTypeId`, `contextLabel`을 함께 반영한다.
|
||||
- 공통 문맥
|
||||
- 선택값이 비어 있고 방 전용 override가 없으면 채팅유형 기본 공통 문맥을 상속한다.
|
||||
- 선택값이 채팅유형 기본값과 동일하면 room override를 별도로 저장하지 않는다.
|
||||
- 방 전용 문맥
|
||||
- 제목/본문 중 하나라도 있으면 room context로 저장한다.
|
||||
- 둘 다 비면 room context에서 제거한다.
|
||||
- 채팅 알림
|
||||
- 공유 링크 현재 클라이언트 기준 `notifyOffline`을 저장한다.
|
||||
- 실제 푸시는 브라우저 권한과 전체 앱 알림 사용 상태가 모두 허용된 경우에만 수신된다.
|
||||
|
||||
## 확인 포인트
|
||||
- 전체폭 Drawer가 열려도 부모 화면 레이아웃이 흔들리지 않는지
|
||||
- 채팅유형을 바꾼 뒤 다시 설정을 열었을 때 방금 저장한 유형이 재표시되는지
|
||||
- 공통 문맥을 비워 두면 채팅유형 기본 문맥 상속으로 동작하는지
|
||||
- 방 전용 문맥 제목/본문 저장 후 다시 열었을 때 유지되는지
|
||||
- 채팅 알림 토글 상태가 저장 후 공유 스냅샷 응답에 반영되는지
|
||||
@@ -0,0 +1,40 @@
|
||||
# 공유채팅 채팅방 설정 정리 검증
|
||||
|
||||
## 실행 검증
|
||||
- `npm run build:test-app`
|
||||
- 결과: 성공
|
||||
- 목적: 프런트 번들 및 타입 레벨 오류 확인
|
||||
- `npm run build`
|
||||
- 위치: `etc/servers/work-server`
|
||||
- 결과: 성공
|
||||
- 목적: 공유 채팅방 설정 API 변경 후 서버 타입/빌드 확인
|
||||
|
||||
## 분기 검증
|
||||
- 채팅유형 저장
|
||||
- `conversation.chatTypeId` 우선 사용
|
||||
- `conversation.lastChatTypeId` fallback 사용
|
||||
- 요청 이력(`targetRequest.chatTypeId`, `requests[].chatTypeId`) fallback 유지
|
||||
- 공통 문맥 계산
|
||||
- room override 있음: override 사용
|
||||
- room override 비어 있음: 채팅유형 기본 공통 문맥 상속
|
||||
- 저장값이 채팅유형 기본값과 동일: room override 제거
|
||||
- 방 전용 문맥
|
||||
- 제목만 입력: 저장 대상
|
||||
- 본문만 입력: 저장 대상
|
||||
- 제목/본문 모두 비움: room context 제거
|
||||
- 채팅 알림
|
||||
- 공유 링크 클라이언트 기준 `notifyOffline=true`: 알림 수신 대상
|
||||
- `notifyOffline=false`: 현재 클라이언트 제외
|
||||
- clientId 없음: 글로벌 `notify_offline` 필드 업데이트 분기 유지
|
||||
- 비밀번호
|
||||
- 새로 켜기 + 입력 없음: 경고
|
||||
- 숫자 4자리 아님: 경고
|
||||
- 유지시간 변경만 있는 경우: 저장
|
||||
- 사용 안 함 전환: 기존 잠금 해제
|
||||
|
||||
## 테스트 메모
|
||||
- `node --import tsx --test src/services/chat-room-service.test.ts` 전체 파일은 저장소 기존 실패 케이스가 이미 포함되어 있어 전체 green 상태는 아님
|
||||
- 이번 변경과 직접 관련된 `buildChatConversationContextUpdateFields` 보강 케이스는 통과 확인
|
||||
|
||||
## 미실행 항목
|
||||
- 실제 `preview.sm-home.cloud` 브라우저 캡처와 모바일 스크린샷은 이번 턴에서 수행하지 못함
|
||||
@@ -0,0 +1,20 @@
|
||||
# 공유채팅 채팅방 이동 소도 개선
|
||||
|
||||
## 변경 요약
|
||||
- 공유채팅방 마지막 선택 방 저장을 `localStorage`에서 `sessionStorage`로 변경했습니다.
|
||||
- 같은 탭 안에서는 마지막으로 보던 방을 복원하지만, 브라우저를 완전히 닫으면 기억을 남기지 않습니다.
|
||||
- 채팅방 선택 시 `roomSessionId`를 URL에 반영할 때 사용자 선택은 `pushState`, 자동 보정은 `replaceState`로 나눴습니다.
|
||||
- 브라우저 뒤로가기/앞으로가기 시 현재 URL의 `roomSessionId`를 다시 읽어 선택 방과 동기화합니다.
|
||||
|
||||
## 변경 범위
|
||||
- 공유채팅 화면의 방 선택/복원/URL 동기화 로직
|
||||
- 영구 저장 제거에 따른 탭 세션 단위 이동 상태 복원
|
||||
|
||||
## 데이터 및 API 영향
|
||||
- 서버 API 스펙 변경은 없습니다.
|
||||
- 클라이언트 저장소 사용 범위만 `localStorage` -> `sessionStorage`로 바뀝니다.
|
||||
|
||||
## 확인 포인트
|
||||
- 공유채팅에서 방을 바꾼 뒤 새로고침하면 같은 탭에서는 마지막 방이 유지되는지
|
||||
- 브라우저 뒤로가기/앞으로가기 때 이전/다음 방으로 이동되는지
|
||||
- 브라우저를 완전히 닫았다가 다시 열면 이전 방이 영구 복원되지 않는지
|
||||
@@ -0,0 +1,11 @@
|
||||
# 검증 요약
|
||||
|
||||
## 수행 내용
|
||||
- `npm exec tsc --noEmit`
|
||||
|
||||
## 결과
|
||||
- 타입체크 통과
|
||||
|
||||
## 미수행 항목
|
||||
- `https://preview.sm-home.cloud/` 브라우저 실접속 검증은 이번 턴에서 수행하지 못했습니다.
|
||||
- 시각 레이아웃 변경이 아니라서 별도 UI 스크린샷은 생성하지 않았습니다.
|
||||
@@ -0,0 +1,709 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>공유채팅 헤더 재배치 제안</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-top: #edf3fb;
|
||||
--bg-bottom: #e4edf8;
|
||||
--surface: rgba(255, 255, 255, 0.84);
|
||||
--surface-strong: rgba(255, 255, 255, 0.94);
|
||||
--surface-soft: rgba(248, 250, 252, 0.92);
|
||||
--line: rgba(148, 163, 184, 0.24);
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--blue: #2563eb;
|
||||
--blue-soft: rgba(219, 234, 254, 0.96);
|
||||
--green: #166534;
|
||||
--green-soft: rgba(220, 252, 231, 0.98);
|
||||
--amber: #92400e;
|
||||
--amber-soft: rgba(254, 243, 199, 0.98);
|
||||
--red: #b91c1c;
|
||||
--red-soft: rgba(254, 226, 226, 0.98);
|
||||
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.14);
|
||||
--shadow-md: 0 12px 28px rgba(148, 163, 184, 0.16);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 22px;
|
||||
--radius-md: 18px;
|
||||
--radius-sm: 14px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", "Inter", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(1480px, calc(100vw - 40px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid rgba(196, 210, 226, 0.88);
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 250, 252, 0.86));
|
||||
box-shadow: var(--shadow-lg);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.chip,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
width: fit-content;
|
||||
color: var(--blue);
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
max-width: 920px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dna {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
color: #334155;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(196, 210, 226, 0.92);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.proposal {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid rgba(196, 210, 226, 0.96);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(244, 248, 253, 0.94));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.proposal h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.proposal p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 6px 10px;
|
||||
color: #334155;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid rgba(196, 210, 226, 0.86);
|
||||
}
|
||||
|
||||
.phone {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 740px;
|
||||
padding: 14px;
|
||||
border-radius: 30px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(237, 243, 251, 0.98), rgba(228, 237, 248, 0.94)),
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.1), transparent 32%);
|
||||
border: 1px solid rgba(196, 210, 226, 0.98);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.4),
|
||||
0 18px 38px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px 0;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.78));
|
||||
border: 1px solid rgba(196, 210, 226, 0.9);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.header-top,
|
||||
.header-bottom,
|
||||
.header-split {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.room-trigger,
|
||||
.action-pill,
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.42),
|
||||
0 8px 18px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.room-trigger {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.room-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.94), rgba(59, 130, 246, 0.72));
|
||||
color: white;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.room-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.room-copy strong {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.room-copy span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(219, 234, 254, 0.94);
|
||||
color: var(--blue);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 44px;
|
||||
height: 5px;
|
||||
margin: 2px auto 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.52);
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(196, 210, 226, 0.92);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric.blue {
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.98), rgba(219, 234, 254, 0.94));
|
||||
}
|
||||
|
||||
.metric.green {
|
||||
background: linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.94));
|
||||
}
|
||||
|
||||
.metric.amber {
|
||||
background: linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(254, 243, 199, 0.94));
|
||||
}
|
||||
|
||||
.metric.red {
|
||||
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.94));
|
||||
}
|
||||
|
||||
.sheet {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.92));
|
||||
border: 1px solid rgba(196, 210, 226, 0.9);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.32),
|
||||
0 14px 28px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sheet-title strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list,
|
||||
.feed {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-item,
|
||||
.feed-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 13px;
|
||||
border-radius: 18px;
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid rgba(196, 210, 226, 0.86);
|
||||
}
|
||||
|
||||
.list-item-top,
|
||||
.feed-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-item strong,
|
||||
.feed-item strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-item span,
|
||||
.feed-item span,
|
||||
.feed-item p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge.blue {
|
||||
color: #1d4ed8;
|
||||
background: rgba(219, 234, 254, 0.96);
|
||||
}
|
||||
|
||||
.badge.green {
|
||||
color: var(--green);
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.badge.amber {
|
||||
color: var(--amber);
|
||||
background: var(--amber-soft);
|
||||
}
|
||||
|
||||
.badge.red {
|
||||
color: var(--red);
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
padding: 8px 2px 2px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
line-height: 1.55;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.bubble.self {
|
||||
justify-self: end;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
}
|
||||
|
||||
.bubble.other {
|
||||
justify-self: start;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(196, 210, 226, 0.82);
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px dashed rgba(148, 163, 184, 0.4);
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.phone {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<span class="eyebrow">공유채팅 실제 테마 기반 제안</span>
|
||||
<h1>채팅방 헤더를 방 목록 + 알림센터 허브로 재구성</h1>
|
||||
<p>
|
||||
현재 공유채팅이 쓰는 옅은 블루 그라데이션, 반투명 화이트 surface, 파란 pill 액션 톤을 유지하면서
|
||||
헤더의 역할을 명확히 분리했습니다. 공통 방향은 “제목/아이콘 클릭으로 방 목록”, “중앙 손잡이 드래그로
|
||||
iOS 알림센터 스타일”, “현재 방과 다른 방 알림을 한 시트에서 함께 확인”입니다.
|
||||
</p>
|
||||
<div class="dna">
|
||||
<span class="chip">현재 테마: #edf3fb → #e4edf8</span>
|
||||
<span class="chip">액션 톤: white pill + #2563eb</span>
|
||||
<span class="chip">재질감: blur + soft shadow</span>
|
||||
<span class="chip">상태칩: blue / green / amber / red</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="proposal" id="option-a">
|
||||
<div>
|
||||
<h2>A안. Capsule Rail</h2>
|
||||
<p>가장 자연스럽게 익숙한 안입니다. 제목 캡슐이 방 목록 진입점이 되고, 중앙 손잡이를 내려 전체 알림센터를 펼칩니다.</p>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">추천: 모바일 우선</span>
|
||||
<span class="tag">방 목록 발견성 높음</span>
|
||||
<span class="tag">알림센터 구분 명확</span>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="status-bar"><span>9:41</span><span>Live 5G 92%</span></div>
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<div class="room-trigger">
|
||||
<div class="room-avatar">CC</div>
|
||||
<div class="room-copy">
|
||||
<strong>공유채팅 운영룸</strong>
|
||||
<span>제목/아이콘 탭: 방 목록 + 필터 열기</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-pill">설정</div>
|
||||
<div class="icon-circle">-</div>
|
||||
<div class="icon-circle">×</div>
|
||||
</div>
|
||||
<div class="header-bottom">
|
||||
<div class="filter-pill">진행중 6 · 다른 방 새답변 4</div>
|
||||
<div class="filter-pill">apps 2건</div>
|
||||
</div>
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">
|
||||
<strong>알림센터</strong>
|
||||
<span class="badge blue">전체 채팅 + apps</span>
|
||||
</div>
|
||||
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-grid">
|
||||
<div class="metric blue"><strong>12</strong><span>처리중 요청</span></div>
|
||||
<div class="metric green"><strong>4</strong><span>다른 방 새 답변</span></div>
|
||||
<div class="metric amber"><strong>2</strong><span>apps 경고</span></div>
|
||||
<div class="metric red"><strong>1</strong><span>확인 필요 실패</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">방 목록 + 필터</div>
|
||||
<div class="list">
|
||||
<div class="list-item">
|
||||
<div class="list-item-top"><strong>전체 방</strong><span class="badge blue">18</span></div>
|
||||
<span>최근답변, 처리중, 안읽음, apps 연결방 필터를 같은 레이어에서 전환</span>
|
||||
</div>
|
||||
<div class="list-item">
|
||||
<div class="list-item-top"><strong>개발 운영방</strong><span class="badge green">새 답변</span></div>
|
||||
<span>3분 전 Codex 응답 도착 · 앱 연결 2개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">알림 피드</div>
|
||||
<div class="feed">
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>자동화 공유방</strong><span class="badge blue">진행중</span></div>
|
||||
<p>배포 확인 캡처 업로드 완료. 검증 스크린샷 2장 확인 필요.</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>Apps 알림</strong><span class="badge amber">권한</span></div>
|
||||
<p>캘린더 동기화 1건 지연. 알림센터에서 바로 상세 열기 버튼 제공.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<div class="bubble other">헤더 제목을 누르면 바로 방 목록과 필터가 한 번에 보여야 합니다.</div>
|
||||
<div class="bubble self">A안은 그 요구를 가장 직접적으로 충족합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="proposal" id="option-b">
|
||||
<div>
|
||||
<h2>B안. Split Status Bar</h2>
|
||||
<p>좌측은 현재 방, 우측은 전체 알림과 apps 상태를 쌓아 두는 데스크톱 친화형입니다. 헤더 하단은 세그먼트 필터로 남깁니다.</p>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">추천: 데스크톱 확장</span>
|
||||
<span class="tag">필터 접근 가장 빠름</span>
|
||||
<span class="tag">정보량 많음</span>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="status-bar"><span>9:41</span><span>Workspace online</span></div>
|
||||
<div class="header">
|
||||
<div class="header-split">
|
||||
<div class="room-trigger">
|
||||
<div class="room-avatar">PM</div>
|
||||
<div class="room-copy">
|
||||
<strong>프로젝트 메인룸</strong>
|
||||
<span>아이콘/제목 클릭: 방 전환</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-pill">다른 방 4</div>
|
||||
<div class="action-pill">apps 2</div>
|
||||
</div>
|
||||
<div class="header-bottom">
|
||||
<div class="filter-pill">전체</div>
|
||||
<div class="filter-pill">진행중</div>
|
||||
<div class="filter-pill">안읽음</div>
|
||||
<div class="filter-pill">apps</div>
|
||||
</div>
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">
|
||||
<strong>Notification Center Dashboard</strong>
|
||||
<span class="badge green">실시간 집계</span>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="metric blue"><strong>08</strong><span>현재 방 처리 흐름</span></div>
|
||||
<div class="metric green"><strong>03</strong><span>다른 방 확인대기</span></div>
|
||||
<div class="metric amber"><strong>05</strong><span>apps 작업 알림</span></div>
|
||||
<div class="metric red"><strong>02</strong><span>실패/재시도</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">요약 콘텐츠</div>
|
||||
<div class="feed">
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>현재 방</strong><span class="badge blue">3건</span></div>
|
||||
<p>작업중인 요청, 마지막 응답 시간, 첨부 리소스 생성 수를 카드형으로 고정 배치</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>다른 채팅방</strong><span class="badge green">새 답변</span></div>
|
||||
<p>현재 방이 아니어도 읽지 않은 응답과 mention 성격 요청을 한 섹션에 모아 보여줌</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>Apps</strong><span class="badge amber">확인 필요</span></div>
|
||||
<p>캘린더, 알림, 연결 앱의 상태 메시지를 채팅 알림과 동일한 카드 리듬으로 정렬</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<div class="bubble other">필터를 자주 바꾸는 운영자라면 헤더 안에서 바로 전환하고 싶습니다.</div>
|
||||
<div class="bubble self">B안은 필터 중심 운영에 가장 유리합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="proposal" id="option-c">
|
||||
<div>
|
||||
<h2>C안. Focus Stack</h2>
|
||||
<p>현재 작업중인 방을 더 크게 인지시키는 집중형입니다. 알림센터는 “현재 방 집중 + 다른 방 보조” 우선순위가 드러납니다.</p>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">추천: 집중 작업</span>
|
||||
<span class="tag">브랜드감 강함</span>
|
||||
<span class="tag">운영감시형 대시보드</span>
|
||||
</div>
|
||||
<div class="phone">
|
||||
<div class="status-bar"><span>9:41</span><span>Preview theme sync</span></div>
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<div class="room-trigger" style="min-height: 52px;">
|
||||
<div class="room-avatar" style="width: 34px; height: 34px; border-radius: 14px;">UX</div>
|
||||
<div class="room-copy">
|
||||
<strong>UX 검토 채팅방</strong>
|
||||
<span>Hero chip 탭: 방 목록 / 필터 / 최근방</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon-circle">≡</div>
|
||||
</div>
|
||||
<div class="header-bottom">
|
||||
<div class="filter-pill">현재 방 진행중 4</div>
|
||||
<div class="filter-pill">전체 알림 9</div>
|
||||
</div>
|
||||
<div class="handle"></div>
|
||||
</div>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="sheet-title">
|
||||
<strong>집중 대시보드</strong>
|
||||
<span class="badge blue">현재 방 우선</span>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="metric blue"><strong>4</strong><span>현재 방 처리중</span></div>
|
||||
<div class="metric green"><strong>2</strong><span>완료 직전</span></div>
|
||||
<div class="metric amber"><strong>3</strong><span>다른 방 확인</span></div>
|
||||
<div class="metric red"><strong>1</strong><span>apps 경고</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-label">우선순위 피드</div>
|
||||
<div class="feed">
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>현재 방 요청</strong><span class="badge blue">우선</span></div>
|
||||
<p>마지막 응답 이후 7분 경과. 첨부 preview 3개 생성됨. 바로 이어보기 버튼 노출.</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>다른 방 답변</strong><span class="badge green">보조</span></div>
|
||||
<p>메인 운영방에 새 응답 2건. 눌러서 방 전환 없이 quick peek 가능.</p>
|
||||
</div>
|
||||
<div class="feed-item">
|
||||
<div class="feed-item-top"><strong>Apps 이벤트</strong><span class="badge amber">연결</span></div>
|
||||
<p>배포 완료, 캘린더 일정, 리소스 등록 완료 이벤트를 낮은 대비 카드로 정렬.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message">
|
||||
<div class="bubble other">작업 중인 방을 잃지 않으면서도 다른 방 알림은 놓치고 싶지 않습니다.</div>
|
||||
<div class="bubble self">C안은 집중감은 가장 좋지만, 운영형 전체 목록성은 A안보다 약합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="footer-note">
|
||||
추천 순서는 A안 → B안 → C안입니다. A안은 현재 공유채팅의 둥근 pill 액션과 blur 헤더 감성을 가장 자연스럽게 이어가면서,
|
||||
“제목/아이콘으로 방 목록”, “손잡이로 알림센터”라는 두 행동을 가장 덜 헷갈리게 분리합니다.
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
# 공유채팅 채팅방 헤더 재배치 제안
|
||||
|
||||
## 목적
|
||||
- 실제 공유채팅에서 사용하는 라이트 블루 테마를 유지한 채, 헤더를 방 선택과 알림센터 진입의 허브로 재구성한다.
|
||||
- 채팅방 제목 또는 아이콘 선택 시 필터와 채팅방 목록을 함께 노출하고, 헤더 손잡이를 아래로 내리면 iOS 알림센터 같은 요약 대시보드와 알림 피드를 보여준다.
|
||||
- 기존 기능은 유지하되, 설정/최소화/닫기 같은 툴 액션은 헤더 집중도를 해치지 않도록 보조 영역으로 정리한다.
|
||||
|
||||
## 현재 테마 확인 기준
|
||||
- 공유채팅 카드 바디는 `#edf3fb → #e4edf8` 계열 세로 그라데이션과 얕은 inset border, soft shadow를 사용한다.
|
||||
- 헤더 액션은 흰색 pill 버튼 위에 `#2563eb` 아이콘 포인트를 두고 hover 시 더 밝은 블루 계열로 반응한다.
|
||||
- 헤더 배경은 반투명 흰색보다 한 단계 채도 낮은 블루톤이며 blur가 들어간다.
|
||||
- 방 상태/요청 상태는 회색, 파랑, 초록, 빨강 계열 pill로 구분한다.
|
||||
|
||||
## 제안 방향 공통 원칙
|
||||
- 헤더 1행은 현재 방 인지와 이동, 헤더 2행은 상태/필터/알림센터 진입으로 역할을 명확히 분리한다.
|
||||
- 방 목록은 제목 또는 아이콘을 누르는 행위 하나로 열리도록 통합하고, 목록 안에서 필터 칩과 최근 대화 프리뷰를 동시에 보여준다.
|
||||
- 알림센터는 현재 방 전용이 아니라 전체 채팅방과 apps 알림을 함께 집계한다.
|
||||
- 손잡이 드래그는 모달보다 시스템 오버레이처럼 느껴지게 하고, 상단에는 다시보드형 요약 카드를 고정한다.
|
||||
|
||||
## 제안안 구성
|
||||
### A안 Capsule Rail
|
||||
- 제목 캡슐 자체가 방 목록 트리거다.
|
||||
- 헤더 중앙 손잡이를 내려 알림센터를 여는 패턴으로 가장 직관적이다.
|
||||
- 알림센터 상단은 처리중, 새 답변, apps 경고, 캘린더성 일정 같은 4분할 다시보드다.
|
||||
|
||||
### B안 Split Status Bar
|
||||
- 좌측은 방 정보, 우측은 앱 알림과 빠른 전환 상태를 모은 split bar 구조다.
|
||||
- 필터를 헤더 하단의 segment row로 남겨 자주 쓰는 상태 전환을 더 빠르게 한다.
|
||||
- 데스크톱 확장성은 좋지만 모바일에서는 약간 더 촘촘해질 수 있다.
|
||||
|
||||
### C안 Focus Stack
|
||||
- 방 아이콘과 제목을 한 덩어리 hero chip으로 키워 현재 방 인지를 강화한다.
|
||||
- 알림센터 대시보드 카드를 더 크게 두고, 현재 처리중 요청 중심의 집중도를 높인다.
|
||||
- 정보량이 많을 때보다 “현재 작업 집중” 상황에 적합하다.
|
||||
|
||||
## 유지되어야 할 기존 기능
|
||||
- 방 이동
|
||||
- 필터 전환
|
||||
- 설정
|
||||
- 최소화
|
||||
- 닫기
|
||||
- 현재 연결 상태 표시
|
||||
- 현재 방 상태 요약
|
||||
|
||||
## 알림센터 추천 콘텐츠
|
||||
- 상단 고정 다시보드: 처리중 요청 수, 읽지 않은 다른 채팅방, apps 경고/완료, 오늘 일정 또는 예약 작업
|
||||
- 중단: 현재 방 진행 카드와 다른 방 새 답변 카드 혼합 피드
|
||||
- 하단: 앱별 알림 묶음, 빠른 액션, 전체 읽음/필터 토글
|
||||
|
||||
## 검토 포인트
|
||||
- 방 목록과 알림센터를 둘 다 헤더에 얹되 탭 충돌 없이 한 손 조작이 가능한지
|
||||
- 모바일에서 드래그 손잡이와 브라우저 스크롤 제스처가 충돌하지 않는지
|
||||
- 현재 존재하는 설정/최소화/닫기 액션을 보조 영역으로 빼도 발견 가능성이 유지되는지
|
||||
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();
|
||||
@@ -58,6 +58,53 @@ const CODEX_LIVE_FINISHED_RETENTION_MS = Math.max(
|
||||
);
|
||||
const activeCodexExecutions = new Map();
|
||||
const recentCodexExecutions = new Map();
|
||||
let runnerShutdownSignal = null;
|
||||
|
||||
function logRunner(message) {
|
||||
process.stdout.write("[server-command-runner] " + new Date().toISOString() + " " + message + "\n");
|
||||
}
|
||||
|
||||
function summarizeActiveExecutionIds(limit = 8) {
|
||||
const requestIds = Array.from(activeCodexExecutions.keys()).slice(0, limit);
|
||||
const suffix = activeCodexExecutions.size > requestIds.length ? " +" + (activeCodexExecutions.size - requestIds.length) + " more" : "";
|
||||
return requestIds.length > 0 ? requestIds.join(", ") + suffix : "none";
|
||||
}
|
||||
|
||||
function resolveSignalExitCode(signal) {
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGHUP":
|
||||
return 129;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function shutdownRunnerFromSignal(signal) {
|
||||
if (runnerShutdownSignal) {
|
||||
return;
|
||||
}
|
||||
|
||||
runnerShutdownSignal = signal;
|
||||
logRunner("received " + signal + "; activeExecutions=" + activeCodexExecutions.size + "; requestIds=" + summarizeActiveExecutionIds());
|
||||
process.exit(resolveSignalExitCode(signal));
|
||||
}
|
||||
|
||||
process.once("SIGTERM", () => shutdownRunnerFromSignal("SIGTERM"));
|
||||
process.once("SIGINT", () => shutdownRunnerFromSignal("SIGINT"));
|
||||
process.once("SIGHUP", () => shutdownRunnerFromSignal("SIGHUP"));
|
||||
process.on("exit", (code) => {
|
||||
const shutdownLabel = runnerShutdownSignal === null ? "none" : runnerShutdownSignal;
|
||||
logRunner("exiting with code " + code + "; shutdownSignal=" + shutdownLabel + "; activeExecutions=" + activeCodexExecutions.size);
|
||||
});
|
||||
|
||||
function resolveCodexLiveModel(value) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return /^[A-Za-z0-9._:-]+$/u.test(normalized) ? normalized : CODEX_LIVE_MODEL;
|
||||
}
|
||||
|
||||
function createCodexExecutionRecord({ requestId, child, tempDir }) {
|
||||
return {
|
||||
@@ -134,6 +181,99 @@ function scheduleCodexExecutionCleanup(record) {
|
||||
record.cleanupTimer.unref?.();
|
||||
}
|
||||
|
||||
function normalizeCodexUsageMetricValue(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const normalized = Number(value);
|
||||
if (Number.isFinite(normalized)) {
|
||||
return Math.max(0, Math.round(normalized));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCodexUsageSnapshot(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usage =
|
||||
parsed.usage && typeof parsed.usage === 'object'
|
||||
? parsed.usage
|
||||
: parsed.response &&
|
||||
typeof parsed.response === 'object' &&
|
||||
parsed.response !== null &&
|
||||
parsed.response.usage &&
|
||||
typeof parsed.response.usage === 'object'
|
||||
? parsed.response.usage
|
||||
: null;
|
||||
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = normalizeCodexUsageMetricValue(usage.input_tokens);
|
||||
const output = normalizeCodexUsageMetricValue(usage.output_tokens);
|
||||
const cached = normalizeCodexUsageMetricValue(usage.cached_input_tokens);
|
||||
const reasoning = normalizeCodexUsageMetricValue(usage.reasoning_output_tokens ?? usage.reasoning_tokens);
|
||||
const total =
|
||||
normalizeCodexUsageMetricValue(usage.total_tokens) ??
|
||||
normalizeCodexUsageMetricValue(usage.totalTokens) ??
|
||||
[input ?? 0, output ?? 0].reduce((sum, value) => sum + value, 0);
|
||||
|
||||
if (input === null && output === null && cached === null && reasoning === null && total === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tokenTotals: {
|
||||
total: total ?? 0,
|
||||
input: input ?? 0,
|
||||
output: output ?? 0,
|
||||
cached: cached ?? 0,
|
||||
reasoning: reasoning ?? 0,
|
||||
},
|
||||
totalTokens: total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCodexUsageSnapshotFromText(output) {
|
||||
const text = String(output ?? '');
|
||||
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index] ?? '';
|
||||
|
||||
if (!line.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const usageSnapshot = extractCodexUsageSnapshot(JSON.parse(line));
|
||||
|
||||
if (usageSnapshot) {
|
||||
return usageSnapshot;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed JSON lines during fallback scan
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function finalizeCodexExecution(record) {
|
||||
if (record.completed) {
|
||||
return;
|
||||
@@ -642,6 +782,7 @@ async function runCodexLiveExecution(payload, response) {
|
||||
const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource');
|
||||
const uploadDir = path.join(resourceDir, 'uploads');
|
||||
const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex';
|
||||
const codexModel = resolveCodexLiveModel(payload?.model);
|
||||
const configuredIdleTimeoutMs = resolveCodexLiveIdleTimeoutMs(payload?.idleTimeoutSeconds);
|
||||
const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds, configuredIdleTimeoutMs);
|
||||
|
||||
@@ -673,13 +814,14 @@ async function runCodexLiveExecution(payload, response) {
|
||||
let stderrTail = '';
|
||||
let jsonLineBuffer = '';
|
||||
let completedText = '';
|
||||
let streamedUsageSnapshot = null;
|
||||
let idleTimer = null;
|
||||
let executionTimer = null;
|
||||
let terminationRequested = false;
|
||||
|
||||
const child = spawn(
|
||||
codexBin,
|
||||
['exec', '--model', CODEX_LIVE_MODEL, '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'],
|
||||
['exec', '--model', codexModel, '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'],
|
||||
{
|
||||
cwd: repoPath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -698,11 +840,13 @@ async function runCodexLiveExecution(payload, response) {
|
||||
child,
|
||||
tempDir,
|
||||
});
|
||||
logRunner("spawned Codex child pid=" + (child.pid ?? "unknown") + " requestId=" + requestId + " sessionId=" + sessionId + " model=" + codexModel + " idleTimeoutMs=" + configuredIdleTimeoutMs + " maxExecutionMs=" + configuredMaxExecutionMs);
|
||||
activeCodexExecutions.set(requestId, executionRecord);
|
||||
attachCodexExecutionSubscriber(executionRecord, response);
|
||||
broadcastCodexExecutionEvent(executionRecord, {
|
||||
type: 'started',
|
||||
pid: child.pid ?? null,
|
||||
model: codexModel,
|
||||
configuredIdleTimeoutSeconds: Math.round(configuredIdleTimeoutMs / 1000),
|
||||
configuredMaxExecutionSeconds: Math.round(configuredMaxExecutionMs / 1000),
|
||||
});
|
||||
@@ -719,7 +863,7 @@ async function runCodexLiveExecution(payload, response) {
|
||||
}
|
||||
};
|
||||
|
||||
const requestTermination = (message) => {
|
||||
const requestTermination = (message, reason = 'runner-termination') => {
|
||||
if (terminationRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -732,6 +876,7 @@ async function runCodexLiveExecution(payload, response) {
|
||||
message,
|
||||
});
|
||||
|
||||
logRunner("terminating Codex child pid=" + (child.pid ?? "unknown") + " requestId=" + requestId + " reason=" + reason + " message=" + message);
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
@@ -752,6 +897,7 @@ async function runCodexLiveExecution(payload, response) {
|
||||
idleTimer = setTimeout(() => {
|
||||
requestTermination(
|
||||
`Codex Live 실행이 ${Math.round(configuredIdleTimeoutMs / 1000)}초 동안 출력이 없어 중단되었습니다.`,
|
||||
'idle-timeout',
|
||||
);
|
||||
}, configuredIdleTimeoutMs);
|
||||
idleTimer.unref?.();
|
||||
@@ -760,6 +906,7 @@ async function runCodexLiveExecution(payload, response) {
|
||||
executionTimer = setTimeout(() => {
|
||||
requestTermination(
|
||||
`Codex Live 실행이 ${Math.round(configuredMaxExecutionMs / 1000)}초를 넘어 중단되었습니다.`,
|
||||
'max-execution-timeout',
|
||||
);
|
||||
}, configuredMaxExecutionMs);
|
||||
executionTimer.unref?.();
|
||||
@@ -785,16 +932,7 @@ async function runCodexLiveExecution(payload, response) {
|
||||
}
|
||||
|
||||
const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed);
|
||||
|
||||
if (nextCompletedText) {
|
||||
refreshIdleTimer();
|
||||
completedText = nextCompletedText;
|
||||
broadcastCodexExecutionEvent(executionRecord, {
|
||||
type: 'completed',
|
||||
text: nextCompletedText,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const usageSnapshot = extractCodexUsageSnapshot(parsed);
|
||||
|
||||
if (deltaText) {
|
||||
refreshIdleTimer();
|
||||
@@ -802,10 +940,28 @@ async function runCodexLiveExecution(payload, response) {
|
||||
type: 'delta',
|
||||
text: deltaText,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
if (usageSnapshot) {
|
||||
streamedUsageSnapshot = usageSnapshot;
|
||||
refreshIdleTimer();
|
||||
broadcastCodexExecutionEvent(executionRecord, {
|
||||
type: 'usage',
|
||||
usageSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
if (nextCompletedText) {
|
||||
refreshIdleTimer();
|
||||
completedText = nextCompletedText;
|
||||
broadcastCodexExecutionEvent(executionRecord, {
|
||||
type: 'completed',
|
||||
text: nextCompletedText,
|
||||
usageSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
return Boolean(deltaText || usageSnapshot || nextCompletedText || activityLog);
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
@@ -858,6 +1014,7 @@ async function runCodexLiveExecution(payload, response) {
|
||||
|
||||
child.on('error', async (error) => {
|
||||
clearExecutionTimers();
|
||||
logRunner("Codex child process error requestId=" + requestId + " pid=" + (child.pid ?? "unknown") + " message=" + (error instanceof Error ? error.message : String(error)));
|
||||
broadcastCodexExecutionEvent(executionRecord, {
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
@@ -869,13 +1026,26 @@ async function runCodexLiveExecution(payload, response) {
|
||||
finalizeCodexExecution(executionRecord);
|
||||
});
|
||||
|
||||
child.on('close', async (code) => {
|
||||
child.on('close', async (code, signal) => {
|
||||
clearExecutionTimers();
|
||||
logRunner("Codex child closed requestId=" + requestId + " pid=" + (child.pid ?? "unknown") + " exitCode=" + (code ?? "null") + " signal=" + (signal ?? "none") + " terminationRequested=" + terminationRequested);
|
||||
const trailingLine = jsonLineBuffer.trim();
|
||||
if (trailingLine) {
|
||||
handleCodexJsonLine(trailingLine);
|
||||
}
|
||||
|
||||
if (!streamedUsageSnapshot) {
|
||||
const fallbackUsageSnapshot = extractCodexUsageSnapshotFromText([stdoutTail, trailingLine].filter(Boolean).join('\n'));
|
||||
|
||||
if (fallbackUsageSnapshot) {
|
||||
streamedUsageSnapshot = fallbackUsageSnapshot;
|
||||
broadcastCodexExecutionEvent(executionRecord, {
|
||||
type: 'usage',
|
||||
usageSnapshot: fallbackUsageSnapshot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
broadcastCodexExecutionEvent(executionRecord, {
|
||||
type: 'error',
|
||||
@@ -1082,6 +1252,7 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
|
||||
try {
|
||||
logRunner("received cancel request for requestId=" + requestId + "; forwarding SIGTERM to child pid=" + (activeExecution.child.pid ?? "unknown"));
|
||||
activeExecution.child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
const current = activeCodexExecutions.get(requestId);
|
||||
@@ -1117,5 +1288,5 @@ server.listen(port, host, () => {
|
||||
});
|
||||
}, 10_000);
|
||||
heartbeatTimer.unref();
|
||||
process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`);
|
||||
logRunner("listening on http://" + host + ":" + port + "; pid=" + process.pid + "; ppid=" + process.ppid + "; startedAt=" + startedAt + "; logFile=" + runnerLogFile);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const port = Number(process.env.PORT ?? 5173);
|
||||
const distDirName = process.env.APP_DIST_DIR ?? 'app-dist';
|
||||
const rootDir = normalize(isAbsolute(distDirName) ? distDirName : join(process.cwd(), distDirName));
|
||||
const workServerUrl = new URL(process.env.WORK_SERVER_URL ?? 'http://127.0.0.1:3100');
|
||||
const proxyPrefixes = ['/api', '/.codex_chat', '/ws/chat'];
|
||||
const proxyPrefixes = ['/api', '/.codex_chat', '/public/.codex_chat', '/ws/chat'];
|
||||
|
||||
const mimeTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
@@ -25,6 +25,53 @@ const mimeTypes = {
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
function canListenOnPort(candidatePort, host = '0.0.0.0') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probeServer = createServer();
|
||||
|
||||
probeServer.once('error', (error) => {
|
||||
probeServer.close(() => {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
probeServer.once('listening', () => {
|
||||
probeServer.close((closeError) => {
|
||||
if (closeError) {
|
||||
reject(closeError);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
|
||||
probeServer.listen(candidatePort, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(initialPort, host = '0.0.0.0', maxAttempts = 20) {
|
||||
for (let offset = 0; offset < maxAttempts; offset += 1) {
|
||||
const candidatePort = initialPort + offset;
|
||||
const available = await canListenOnPort(candidatePort, host);
|
||||
|
||||
if (available) {
|
||||
return candidatePort;
|
||||
}
|
||||
|
||||
if (offset === 0) {
|
||||
console.warn(`Port ${initialPort} is in use, trying another one...`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No available port found from ${initialPort} to ${initialPort + maxAttempts - 1}.`);
|
||||
}
|
||||
|
||||
function resolveCacheControl(resolvedPath, extension) {
|
||||
const normalizedPath = resolvedPath.replace(/\\/g, '/');
|
||||
|
||||
@@ -218,6 +265,9 @@ server.on('upgrade', (request, socket, head) => {
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
console.log(`${distDirName} server listening on http://0.0.0.0:${port}`);
|
||||
const host = '0.0.0.0';
|
||||
const resolvedPort = await findAvailablePort(port, host);
|
||||
|
||||
server.listen(resolvedPort, host, () => {
|
||||
console.log(`${distDirName} server listening on http://${host}:${resolvedPort}`);
|
||||
});
|
||||
|
||||
0
scripts/server-command-runner-supervisor.sh
Normal file → Executable file
0
scripts/server-command-runner-supervisor.sh
Normal file → Executable file
75
src/App.tsx
75
src/App.tsx
@@ -1,15 +1,21 @@
|
||||
import { App as AntdApp } from 'antd';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { getOrCreateClientId } from './app/main/clientIdentity';
|
||||
import { reportClientError } from './app/main/errorLogApi';
|
||||
import { AppShell } from './app/main';
|
||||
import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay';
|
||||
import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal';
|
||||
import { buildChatPath } from './app/main/routes';
|
||||
import { isPreviewRuntime } from './app/main/previewRuntime';
|
||||
import { bindViewportCssVars } from './app/main/viewportCssVars';
|
||||
import { reportVisitorPageView } from './features/history/api';
|
||||
import { useAppStore } from './store';
|
||||
|
||||
const CHUNK_LOAD_RETRY_SESSION_KEY = 'ai-code-app.chunk-load-retried';
|
||||
const CACHE_RECOVERY_SESSION_KEY = 'ai-code-app.cache-recovery-completed';
|
||||
const INITIAL_LOADING_MIN_VISIBLE_MS = 450;
|
||||
const CACHE_RECOVERY_NOTICE = '캐시된 화면 정보가 맞지 않아 홈으로 이동합니다. 다시 열어 주세요.';
|
||||
const CACHE_RECOVERY_DELAY_MS = 900;
|
||||
|
||||
function shouldRetryChunkLoad(errorMessage: string) {
|
||||
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError/i.test(
|
||||
@@ -17,6 +23,12 @@ function shouldRetryChunkLoad(errorMessage: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRecoverFromCacheError(errorMessage: string) {
|
||||
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError|Loading chunk|Failed to load module script|does not provide an export named|Cannot find module/i.test(
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
function retryChunkLoadOnce(errorMessage: string) {
|
||||
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
||||
return false;
|
||||
@@ -26,16 +38,58 @@ function retryChunkLoadOnce(errorMessage: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') {
|
||||
try {
|
||||
if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
|
||||
window.location.reload();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getHomeRecoveryUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return buildChatPath('live');
|
||||
}
|
||||
|
||||
return new URL(buildChatPath('live'), window.location.origin).toString();
|
||||
}
|
||||
|
||||
function tryRecoverToHomeFromCacheError(errorMessage: string, notify: (text: string) => void) {
|
||||
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
|
||||
window.location.reload();
|
||||
return true;
|
||||
if (isPreviewRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!shouldRecoverFromCacheError(errorMessage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (sessionStorage.getItem(CACHE_RECOVERY_SESSION_KEY) === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(CACHE_RECOVERY_SESSION_KEY, '1');
|
||||
notify(CACHE_RECOVERY_NOTICE);
|
||||
window.setTimeout(() => {
|
||||
window.location.replace(getHomeRecoveryUrl());
|
||||
}, CACHE_RECOVERY_DELAY_MS);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { message } = AntdApp.useApp();
|
||||
const { currentPage } = useAppStore();
|
||||
const lastTrackedPageIdRef = useRef<string | null>(null);
|
||||
const [showInitialLoading, setShowInitialLoading] = useState(true);
|
||||
@@ -47,6 +101,13 @@ function App() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const notifyCacheRecovery = (text: string) => {
|
||||
message.warning({
|
||||
content: text,
|
||||
duration: 1.5,
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
const reportedError = event.error instanceof Error ? event.error : null;
|
||||
const errorMessage = event.message || reportedError?.message || '클라이언트 오류가 발생했습니다.';
|
||||
@@ -67,6 +128,8 @@ function App() {
|
||||
column: event.colno || null,
|
||||
},
|
||||
});
|
||||
|
||||
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
@@ -89,6 +152,8 @@ function App() {
|
||||
reasonType: typeof reason,
|
||||
},
|
||||
});
|
||||
|
||||
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
|
||||
};
|
||||
|
||||
window.addEventListener('error', handleError);
|
||||
@@ -98,7 +163,7 @@ function App() {
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
}, []);
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
getOrCreateClientId();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user