39 Commits

Author SHA1 Message Date
4e6e73dbd5 chore: test deploy snapshot 2026-05-29 18:51:12 +09:00
737ab0a34a chore: test deploy snapshot 2026-05-29 18:20:17 +09:00
ffbdbf46b6 chore: test deploy snapshot 2026-05-29 18:08:38 +09:00
5b3e70910c chore: test deploy snapshot 2026-05-29 17:42:33 +09:00
262ce4b627 chore: test deploy snapshot 2026-05-29 16:11:46 +09:00
b242d91ecb chore: test deploy snapshot 2026-05-29 07:57:56 +09:00
1e7212b862 chore: test deploy snapshot 2026-05-28 22:47:45 +09:00
753fd423db chore: test deploy snapshot 2026-05-28 19:44:56 +09:00
c7f29bdc33 chore: test deploy snapshot 2026-05-28 16:57:02 +09:00
a97d933cff chore: test deploy snapshot 2026-05-28 16:11:33 +09:00
b1bec9cb6f chore: test deploy snapshot 2026-05-28 15:47:24 +09:00
bb275c0534 chore: test deploy snapshot 2026-05-28 14:34:49 +09:00
82c46f4be4 chore: test deploy snapshot 2026-05-28 12:45:36 +09:00
983887dc05 chore: test deploy snapshot 2026-05-28 08:09:49 +09:00
e195ac8088 chore: test deploy snapshot 2026-05-27 19:32:28 +09:00
10805d242e chore: test deploy snapshot 2026-05-27 16:35:12 +09:00
e8a628ac34 chore: test deploy snapshot 2026-05-27 14:40:33 +09:00
58c5a7cfee chore: test deploy snapshot 2026-05-27 12:11:09 +09:00
26220577fc chore: test deploy snapshot 2026-05-27 12:04:45 +09:00
4984d74d39 chore: test deploy snapshot 2026-05-27 11:57:01 +09:00
215648bd8d chore: test deploy snapshot 2026-05-27 11:44:33 +09:00
4a88d3f430 chore: test deploy snapshot 2026-05-27 11:35:26 +09:00
7e9c3bd097 chore: test deploy snapshot 2026-05-27 11:19:49 +09:00
4c4b3c8d2c chore: test deploy snapshot 2026-05-27 10:43:01 +09:00
c1d0f4c1db feat: refresh shared chat and server workflows 2026-05-26 12:26:33 +09:00
51e0099bea feat: add play apps and layout tools 2026-05-25 17:29:21 +09:00
f59522ffc4 feat: update main chat and system chat UI 2026-05-25 17:26:37 +09:00
fb5ec649cd chore: sync backend and deployment changes 2026-05-25 17:25:52 +09:00
d38d022872 chore: exclude local resource artifacts from main sync 2026-05-15 10:16:45 +09:00
442879313f feat: refine codex live chat context flows 2026-05-08 21:15:51 +09:00
82c0d8a197 chore: sync local workspace changes 2026-05-07 11:03:47 +09:00
2df0ba30cb feat: expand live chat and work server tools 2026-04-30 11:40:02 +09:00
42ae640470 feat: persist text memo notes in work server 2026-04-26 17:36:46 +09:00
20a6333ed2 chore: update live chat and work server changes 2026-04-26 16:37:06 +09:00
63e5d263a7 feat: refine codex live chat flow 2026-04-24 21:02:01 +09:00
d53532508b Fix chat type persistence and board flow 2026-04-24 15:56:30 +09:00
c07b0b12af merge: sync origin main 2026-04-24 08:53:56 +09:00
b016951cd4 merge: release into main 2026-04-24 08:53:37 +09:00
e10e0ef0d8 fix: narrow worklog preview root imports 2026-04-24 08:53:31 +09:00
785 changed files with 220837 additions and 11773 deletions

View File

@@ -1,6 +1,7 @@
.git .git
.auto_codex .auto_codex
.docker .docker
etc/servers/work-server/.docker
.idea .idea
.vscode .vscode
node_modules node_modules

View File

@@ -1,5 +1,5 @@
NODE_VERSION=22.22.2 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 CAPTURE_REGISTERED_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
VITE_ALLOWED_REGISTRATION_TOKEN=usr_7f3a9c2d8e1b4a6f VITE_ALLOWED_REGISTRATION_TOKEN=usr_7f3a9c2d8e1b4a6f
PHOTOPRISM_PORT=2342 PHOTOPRISM_PORT=2342

10
.githooks/pre-commit Executable file
View File

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

11
.gitignore vendored
View File

@@ -16,12 +16,21 @@ playwright-report/
test-results/ test-results/
.cache/ .cache/
tmp/ tmp/
.tmp-*/
node_modules.root-owned-backup/ node_modules.root-owned-backup/
.env .env
.env.* .env.*
.tmp .tmp
!.env.example !.env.example
tmp-*.png
tmp-*.jpg
tmp-*.jpeg
tmp-*.webp
tmp-*.gif
tmp-*.mp4
tmp-*.mov
tmp-*.webm
# etc workspace # etc workspace
etc/**/.env etc/**/.env
@@ -42,3 +51,5 @@ vite.config.d.ts
public/.codex_chat public/.codex_chat
.server-command-runner-heartbeat.json .server-command-runner-heartbeat.json
docs/assets/worklogs/ docs/assets/worklogs/
resource/Codex Live/
resource/To-Do List/

1
.tmp-chatshare-full.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -9,8 +9,8 @@
### Codex / AI 기본 규칙 ### Codex / AI 기본 규칙
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선을 요청하면 **현재 로컬 `main`에서 바로 작업**한다 * 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다 * 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://preview.sm-home.cloud/` 기준으로 사용**한다
* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env``CAPTURE_BASE_URL=https://test.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다 * 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env``CAPTURE_BASE_URL=https://preview.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다
* 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다 * 별도로 운영 중인 `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` nginx 프록시는 **화면 `/``5174` 앱 테스트 서버로 보내고, `/api/``/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다
* `test.sm-home.cloud``/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다 * `test.sm-home.cloud``/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다
@@ -18,9 +18,15 @@
* `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다 * `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다 * `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
* 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다 * 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다
* Git 작업을 수행하더라도 `public/assets/` 아래 대용량 리소스, `tmp-*` 캡처 파일, 임시 산출물은 기본적으로 커밋 대상에 포함하지 않는다
* 이미지/동영상/PDF 같은 바이너리 자산이 정말 필요할 때만 예외적으로 커밋하고, 그 외에는 코드 변경과 분리해 별도 확인 후 처리한다
* 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다 * 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다
* 현재는 브랜치 전략보다 **로컬 실행 가능 상태 유지, 코드 수정, 문서 갱신, 메모 반영 속도**를 우선한다 * 현재는 브랜치 전략보다 **로컬 실행 가능 상태 유지, 코드 수정, 문서 갱신, 메모 반영 속도**를 우선한다
* `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다 * `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다
* 사용자가 **명시적으로 요청한 경우를 제외하면** 구현 편의나 상태 갱신을 이유로 `polling`, `setInterval`, 주기적 재시도 루프 같은 반복 조회 구조를 추가하거나 유지하지 않는다
* 기존 기능에 `polling`, `setInterval`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다
* `work-server` 재기동이나 배포 절차는 **기존 연결을 끊는 단일 컨테이너 재시작 방식이 아니라, blue/green 슬롯 전환 기반 무중단 절차를 기본 규칙으로 사용**한다
* `work-server` 관련 문서, 스크립트, 운영 안내를 수정할 때는 **비활성 슬롯 기동 → health 확인 → 프록시 전환 → 이전 슬롯 정리** 순서를 유지하고, 연결이 끊기는 재시작을 기본 절차처럼 적지 않는다
### 요청 해석 규칙 ### 요청 해석 규칙
@@ -40,6 +46,7 @@
* 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다 * 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다
* `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다 * `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다
* 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다 * 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다
* 임시 스크린샷, 테스트 캡처, 대용량 리소스 파일은 기본적으로 Git 커밋을 차단하고, 의도적 자산 커밋일 때만 명시적으로 예외 처리한다
* 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다 * 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다
--- ---
@@ -47,14 +54,32 @@
## Codex Live / 채팅 / 작업 메모 규칙 ## Codex Live / 채팅 / 작업 메모 규칙
* `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다 * `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`을 가리키는지** 확인한다 * `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다 * 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
* 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다 * 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다
* 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다 * 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다 * 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다 * 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
* 채팅 답변에서 링크 카드는 외부 공개 링크에만 사용하고 `[[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`에 주입한 상태로 캡처한다 * 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
* 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다
* `/play/apps` 아래에서 실행되는 앱 화면은 기본적으로 부모 앱 헤더를 다시 노출하지 말고, 개별 앱 콘텐츠가 화면을 가득 채우는 레이아웃을 우선 적용한다
--- ---
@@ -69,7 +94,7 @@
## 한 줄 요약 ## 한 줄 요약
👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다 👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나 👉 외부 확인과 검증 기본 도메인은 `https://preview.sm-home.cloud/`
👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는다 👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는다
👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다 👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다

24
README.md Executable file → Normal file
View File

@@ -10,6 +10,8 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입
- `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다. - `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
- 자동화 접수된 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다. - 자동화 접수된 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다.
- 채팅 리소스와 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래를 사용합니다. - 채팅 리소스와 첨부 파일은 `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 ...`으로 예외 처리합니다.
## 시작하기 ## 시작하기
@@ -20,14 +22,16 @@ npm run dev
## 확인용 Preview 컨테이너 ## 확인용 Preview 컨테이너
실제 반영 화면을 확인할 때는 바인드 마운트 없이 별도 이미지로 빌드하는 `docker-compose.preview.yml` 사용합니다. 실제 반영 화면을 확인할 때는 별도 preview 컨테이너를 사용합니다.
```bash ```bash
docker compose -f docker-compose.preview.yml up -d --build docker compose -f docker-compose.preview.yml up -d --build
``` ```
- 기본 접속 주소: `http://127.0.0.1:4173` - 로컬 preview 컨테이너 접속 주소: `http://127.0.0.1:4173`
- 소스 코드는 이미지 빌드 시점에 복사되므로, 로컬 파일 변경이 컨테이너에 바로 섞이지 않습니다. - 외부 검증 도메인: `https://preview.sm-home.cloud/`
- preview 컨테이너는 현재 프로젝트 루트를 바인드 마운트하고 `vite dev` 서버로 실행됩니다.
- `https://preview.sm-home.cloud/`는 preview 컨테이너의 Vite dev server를 기준으로 사용하며, HMR이 연결되면 저장 후 새로고침 없이 변경이 반영됩니다.
- API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다. - API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다.
- 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다. - 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다.
@@ -35,7 +39,8 @@ docker compose -f docker-compose.preview.yml up -d --build
- 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다. - 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다.
- 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다. - 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다.
- `https://test.sm-home.cloud/` 운영 기준은 `화면 / -> 5174 앱 테스트 서버`, `/api/``/ws/chat` -> `127.0.0.1:3100 work-server`니다. - 화면 테스트, 소스 변경 검증, 최종 화면 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.
- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.
- 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다. - 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다.
- 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다. - 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다.
- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다. - 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다.
@@ -93,6 +98,7 @@ src/
│ ├─ status-badge/ # 상태 표현 UI │ ├─ status-badge/ # 상태 표현 UI
│ └─ window/ # 드래그/리사이즈 가능한 윈도우 UI │ └─ window/ # 드래그/리사이즈 가능한 윈도우 UI
├─ features/ ├─ features/
│ ├─ board/ # 작업 요청 게시판과 자동화 접수 화면
│ ├─ dashboard/ # 프로젝트 전용 대시보드 샘플 │ ├─ dashboard/ # 프로젝트 전용 대시보드 샘플
│ ├─ layout/ # 프로젝트 전용 레이아웃 문서 │ ├─ layout/ # 프로젝트 전용 레이아웃 문서
│ ├─ markdownPreview/ # 기능 레벨 Markdown 카드 │ ├─ markdownPreview/ # 기능 레벨 Markdown 카드
@@ -113,6 +119,7 @@ docs/
- `APIs / Components`: 공통 컴포넌트 샘플 탐색 - `APIs / Components`: 공통 컴포넌트 샘플 탐색
- `APIs / Widgets`: 위젯 샘플 탐색 - `APIs / Widgets`: 위젯 샘플 탐색
- `Docs`: `docs/**/*.md`와 일부 `src/features/**/*.md` 문서 탐색 - `Docs`: `docs/**/*.md`와 일부 `src/features/**/*.md` 문서 탐색
- `Plans / 작업 요청`: 게시글 1건 안에 여러 하위 요청을 묶고 자동화 접수를 추적
- `Plans`: 작업 항목, 조치 이력, 이슈 이력을 관리하는 Plan 게시판 - `Plans`: 작업 항목, 조치 이력, 이슈 이력을 관리하는 Plan 게시판
## 문서 위치 ## 문서 위치
@@ -120,7 +127,16 @@ docs/
- 전체 문서 가이드: `docs/README.md` - 전체 문서 가이드: `docs/README.md`
- 작업일지: `docs/worklogs` - 작업일지: `docs/worklogs`
- 기능 문서: `docs/features` - 기능 문서: `docs/features`
- 작업 요청 기능 문서: `docs/features/work-request-board.md`
- 컴포넌트 문서: `docs/components` - 컴포넌트 문서: `docs/components`
- 공통 컴포넌트 패키지 가이드: `src/components/README.md`
- 공통 위젯 패키지 가이드: `src/widgets/README.md`
## 공통 패키지 문서 규칙
- `src/components`, `src/widgets`처럼 여러 화면에서 공통 재사용되는 패키지에는 해당 패키지 하위 `README.md`를 둡니다.
- 패키지 하위 문서에는 최소한 목적, 하위 구조, export 또는 registry 기준점, 샘플 및 문서 연결 규약을 적습니다.
- 컴포넌트 또는 위젯 구조를 바꾸면 구현 파일만 수정하지 말고 해당 패키지 `README.md`와 관련 `docs/components/*.md`도 함께 점검합니다.
## 운영 메모 ## 운영 메모

View File

@@ -1,16 +1,38 @@
services: services:
preview-app: preview-app:
container_name: ai-code-app-preview container_name: ai-code-app-preview
build: image: node:${NODE_VERSION:-22.22.2}-bookworm
context: . user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
dockerfile: Dockerfile.preview working_dir: /app
ports: ports:
- "${PREVIEW_APP_PORT:-4173}:5173" - "${PREVIEW_APP_PORT:-4173}:5173"
extra_hosts: volumes:
- "host.docker.internal:host-gateway" - ./:/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:
- default
- work-backend
environment: environment:
HOME: /home/how2ice
NPM_CONFIG_CACHE: /home/how2ice/.npm
PORT: 5173 PORT: 5173
APP_DIST_DIR: /tmp/ai-code-test-app-dist WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100}
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://host.docker.internal:3100}
VITE_DISABLE_APP_UPDATE: "true" 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"
restart: unless-stopped restart: unless-stopped
volumes:
preview-app-hidden-dotdocker:
preview-app-hidden-etc-servers:
networks:
work-backend:
external: true

0
docker-compose.yml Executable file → Normal file
View File

184
docs/README.md Executable file → Normal file
View File

@@ -1,157 +1,53 @@
# Docs Guide # 프로젝트 구조
프로젝트 문서는 작업일지, 기능 문서, 컴포넌트 문서를 기본 축으로 운영합니다. 현재 메인 앱 `Docs` 화면은 `docs/**/*.md`를 동적으로 수집해 폴더별로 노출합니다. 문서는 현재 저장소의 큰 구조만 빠르게 확인하기 위한 기준 문서입니다. `Docs` 화면도 이 문서만 기본으로 읽으며, 채팅/자동화용 세부 context는 각 관리 화면에서 개별 항목으로 관리합니다.
## 0. 임시 로컬 모드 ## 최상위 구조
- 현재 저장소는 당분간 로컬 전용으로 운영합니다.
- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다.
- `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
- 자동화 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다.
- 자동화와 `Codex Live`는 별개로 취급하며, 자동화는 선택된 자동화 유형 context만 우선 참조합니다.
- Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
## 1. 작업일지
- 위치: `docs/worklogs`
- 규칙: 날짜별 1개 Markdown 파일 작성
- 파일명 예시: `2026-03-31.md`
- 템플릿: `docs/templates/worklog-template.md`
- 권장 기록 범위: 구현 내용, 구조 변경, 빌드/배포 이슈, Git 작업 내역
- 최근 작업일지는 날짜별로 계속 누적 기록
- 화면 캡처는 `docs/assets/worklogs/YYYY-MM-DD/` 아래에 저장하고 작업일지에서 상대 경로로 연결
- 캡처는 전체 화면보다 작업한 컴포넌트 영역 단위 이미지를 우선 사용
- 메뉴/기능 증적이 필요하면 `capture:menu`, `capture:feature` 스크립트로 화면 단위 캡처를 함께 남김
- 화면 캡처를 남기지 못한 날에도 `## 화면 캡처` 섹션은 유지하고, 미첨부 사유를 한 줄로 기록
- 문서 최신화 작업을 수행한 날에는 어떤 문서를 왜 수정했는지 함께 기록
권장 항목:
- 오늘 작업한 내용
- 이슈 및 해결 과정
- 결정 사항
- 상세 작업 내역
## 2. 기능 문서
- 위치: `docs/features`
- 규칙: 기능 단위로 Markdown 파일 작성
- 파일명 예시: `auth.md`, `dashboard.md`
- 템플릿: `docs/templates/feature-template.md`
- 권장 기록 범위: 기능 목적, 화면 흐름, API/상태, 테스트 포인트
- `docs/features/*.md`를 추가하거나 수정하면 앱 `Docs / 기능문서` 메뉴에 반영됨
- `src/features/**/*.md`는 프로젝트 내부 전용 설명 문서용이며 메인 `Docs` 메뉴의 기본 수집 대상은 아님
권장 항목:
- 기능 목적
- 주요 화면/흐름
- 데이터 구조 및 API
- 예외 처리
- 테스트 포인트
## 3. 컴포넌트 문서
- 위치: `docs/components`
- 규칙: 컴포넌트별 1개 Markdown 파일 작성
- 파일명 예시: `status-badge.md`, `user-card.md`
- 대표 샘플: 각 컴포넌트의 `samples/Sample.tsx`
- 확장 샘플: `samples/*.tsx`
권장 항목:
- 목적
- 폴더 구조
- UI props
- plugin input/output 규칙
- plugin 합성 규칙
- Sample 활용 예시
현재 기준 주요 컴포넌트 구조:
```text ```text
src/components src/
├─ markdownPreview docs/
├─ navigation etc/
├─ previewer public/
├─ search scripts/
├─ status-badge
└─ window
``` ```
공통 플러그인 타입과 유틸은 `src/types/component-plugin.ts` 에서 관리합니다. - `src`: 메인 프런트엔드 소스
- `docs`: 작업 템플릿과 작업일지 같은 보조 문서
- `etc`: work-server, DB, 운영 보조 리소스
- `public`: 정적 파일과 채팅 세션 리소스
- `scripts`: 개발/운영 스크립트
샘플 운영 규칙: ## 프런트엔드 구조
- `samples/Sample.tsx`는 해당 컴포넌트의 가장 기본형만 표현 ```text
- plugin/feature 예시는 `samples/*.tsx`로 분리 src
- 샘플 목록에서는 같은 컴포넌트 기준으로 묶고 `base -> plugin -> feature` 순서로 정렬 ├─ app
│ └─ main
├─ components
├─ widgets
├─ features
├─ views
├─ layer
└─ store
```
## 4. 샘플/위젯 레이아웃 - `src/app/main`: 메인 앱 셸, 라우팅, 상단/사이드바, 채팅/문서 진입점
- `src/components`: 공통 UI 조각
- `src/widgets`: 공통 카드형 블록
- `src/features`: 프로젝트 전용 기능
- `src/views`: 플레이/샘플 성격의 화면
- `src/layer`: 전역 레이어와 검색 같은 횡단 기능
- `src/store`: 앱 전역 상태
- 컴포넌트 샘플 레이아웃: 좌측 컴포넌트 목록 + 우측 상세 카드 ## 기능 배치 기준
- 상세 카드는 컴포넌트 하나당 1개
- 카드 내부는 `Base Sample` 아래에 `Plugin Samples`, `Feature Samples`를 순차적으로 배치
- 위젯 샘플은 `widgets/**/samples/*.tsx` 기준으로 별도 수집
- 실제 샘플 엔트리 로딩은 `src/app/manifests/samples.manifest.ts`, `src/samples/registry.ts`를 기준으로 동작
## 5. 프로젝트 종속 레이아웃 - 화면 전용 로직은 `src/features`에 둡니다.
- 여러 화면에서 재사용되는 UI는 `src/components` 또는 `src/widgets`에 둡니다.
- 문서 렌더링과 샘플 수집 같은 앱 메타 기능은 `src/app/main`과 매니페스트에서 관리합니다.
- 위치: `src/features/layout` ## 문서 노출 기준
- 대상: 현재 프로젝트 화면에서만 사용하는 레이아웃
- 예시: 컴포넌트 샘플 목록, 위젯 샘플 목록, Docs markdown preview, Plan 게시판
프로젝트 종속 기능 규칙: -`Docs` 메뉴는 구조 확인용 문서만 노출합니다.
- 작업일지, 템플릿, 과거 설계 메모는 저장소에 남길 수 있어도 기본 문서 목록에서는 제외합니다.
- 현재 프로젝트에서만 의미 있는 화면/기능은 `src/features` 아래에 둠 - 채팅 유형 context와 자동화 유형 context는 공용 문서가 아니라 각 관리 데이터에서 직접 관리합니다.
- 예: `Plan 게시판`, 대시보드 feature 샘플, 앱 전용 레이아웃
- 공통 컴포넌트/위젯으로 재사용 가능한 항목은 `src/components`, `src/widgets`에 유지
메인 화면 분리 규칙:
- 위치: `src/app/main`
- 구성: `MainView`, `MainHeader`, `MainSidebar`, `MainContent`
- 목적: 상단 메뉴, 사이드바, 본문, 검색/문서/Plan 흐름을 앱 레벨에서 분리
## 6. Markdown Preview
- 공통 markdown preview는 `src/components/markdownPreview` 아래에서 관리
- `basePath`를 받아 특정 폴더 아래 markdown 문서를 재사용 가능하게 렌더링
- `docs` 문서 영역은 좌측 폴더/문서 트리 + 우측 markdown 카드 목록 구조 사용
- 문서 수집 매니페스트는 `src/app/manifests/docs.manifest.ts`에서 관리
- `docs/features`, `docs/components`, `docs/worklogs`, `docs/templates`는 폴더 단위로 자동 분류됨
- `docs/worklogs`는 최신 날짜가 먼저 보이도록 역순 정렬
## 7. 대시보드 위젯/데이터
- 대시보드 카드 위젯은 `src/widgets/dashboard-report-card`
- 위젯 샘플과 프로젝트 종속 샘플은 분리
- 재사용 가능한 샘플 데이터는 `src/data` 아래에서 관리
- 프로젝트 전용 대시보드 샘플은 `src/features/dashboard`에 둠
## 8. 배포 메모
- Nexus publish 대상 registry는 `package.json``publishConfig.registry`
- alpha 버전 배포는 `npm publish --tag alpha`
- Nexus 인증은 `~/.npmrc``username / _password(base64) / email` 방식으로 확인
## 9. etc 운영 기준
- 부가 서버/DB/타언어 프로젝트는 `etc` 아래에서 분리 관리
- 서버 예시: `etc/servers/work-server`
- DB 예시: `etc/db/work-db`
- `etc` 내부 비밀값과 생성물은 커밋 제외
- `.env`
- `node_modules`
- `dist`
- `*.log`
## 10. Plan 기능 문서 메모
- `Plan` 기능은 `src/features/planBoard`에서 관리
- 현재 앱에는 목록/상세 보드, release 검수, 차트, 스케줄 화면이 함께 포함됨
- 기본 상태는 `등록`, `작업중`, `작업완료`, `릴리즈완료`, `완료`
- 자동화 진행은 `workerStatus`로 별도 관리하며 브랜치 준비, 자동 작업, release/main 반영, 프로젝트 루트 pull 완료 상태를 포함한 흐름을 표현
- `작업시작` 이후에는 원본 요청(`작업 ID`, 원본 메모`)을 수정하지 않고 조치 이력으로 누적 기록
- 권한 토큰이 없으면 조회 중심으로 동작하며 민감 메모와 소스 작업 일부는 제한 또는 마스킹
- 관련 기능 문서는 `docs/features/plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고

7
docs/components/check-combo.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
`code/value` 데이터를 받아 여러 항목을 체크박스 형태로 선택하고, 실제 값은 `code[]`로 유지하는 다중 선택 combo 입력 컴포넌트입니다. `code/value` 데이터를 받아 여러 항목을 체크박스 형태로 선택하고, 실제 값은 `code[]`로 유지하는 다중 선택 combo 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조 ## 폴더 구조
```text ```text

7
docs/components/codex-diff-previewer.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
변경 파일의 전체 소스와 raw diff를 codex 스타일 아코디언으로 함께 보여주는 공통 preview 컴포넌트입니다. 변경 파일의 전체 소스와 raw diff를 codex 스타일 아코디언으로 함께 보여주는 공통 preview 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조 ## 폴더 구조
```text ```text

View File

@@ -1,130 +0,0 @@
# 신규 컴포넌트 후보 2차 정리
## 신규 컴포넌트 후보 7차 제안
### 목적
현재 `release` 브랜치 기준으로 기존 컴포넌트와 겹치지 않는 신규 공통 컴포넌트 후보를 제안합니다.
이 글은 검토용 plan 게시판 작성만 수행하며, 자동화 접수는 하지 않고 미접수 상태로 유지합니다.
### release 기준 확인
- 이미 존재: Dashboard Report Card, Progress/MultiProgress, Search Command, Popup/Select/CheckCombo 입력, Markdown Preview, Previewer/Codex Diff, Status Badge, Window, DataListTable, EmbeddedMap, TextMemo/GPS/API 샘플 위젯
- 제안 방향: Plan/Board/History 화면에서 반복될 가능성이 높지만 아직 공통 컴포넌트로 분리되지 않은 조합형 UI
### 신규 후보
#### 1. Query Filter Builder UI
복수 조건 필터를 행 단위로 추가하고 저장할 수 있는 필터 빌더 컴포넌트입니다.
- 적용 위치: Plan Board 고급 필터, History 검색, Board 검색
- 주요 props: `fields`, `operators`, `value`, `onChange`, `presets`, `compact`
- 기대 효과: 화면마다 흩어질 수 있는 필터 조건 UI를 일관된 패턴으로 정리
#### 2. Timeline Activity Feed UI
작업 상태 변경, 접수, release/main 반영, 오류 이벤트를 시간순으로 보여주는 활동 피드 컴포넌트입니다.
- 적용 위치: Plan 상세, History 상세, 자동화 실행 이력
- 주요 props: `items`, `groupByDate`, `statusResolver`, `dense`, `renderMeta`
- 기대 효과: 로그성 텍스트를 추적 가능한 UI로 전환하고 최근 변경 맥락을 빠르게 파악
#### 3. Evidence Attachment Strip UI
스크린샷, diff, 로그, 링크 같은 증빙 자료를 한 줄 카드 목록으로 노출하는 첨부 스트립 컴포넌트입니다.
- 적용 위치: Plan 검증 증빙, Preview 결과, History 상세
- 주요 props: `attachments`, `onPreview`, `onDownload`, `maxVisible`, `variant`
- 기대 효과: 증빙 자료 표시와 미리보기 진입점을 공통화
### 우선순위 제안
1. Query Filter Builder UI
2. Timeline Activity Feed UI
3. Evidence Attachment Strip UI
우선 1번을 먼저 검토하는 것이 좋습니다. Plan Board와 History에서 필터 조건이 계속 늘어날 가능성이 높아 재사용 효과가 가장 큽니다.
## 목적
기존에 개발 완료된 `FormField`, `StateKit`, `DataListTable`과 이미 개발 접수된 `Action Toolbar UI`, `Detail Inspector Panel`, `Timeline / Activity Log UI`, `Confirm Dialog UI`, `Notification Toast / Action Feedback UI`, `Date Range Input`, `File Attachment List`, `Component Usage Doc Card`, `Split Pane Layout`은 이번 후보에서 제외합니다.
이번 문서는 현재 코드베이스와 기존 Board/Plan 접수 이력에 없는 신규 공통 컴포넌트만 다시 추려 이후 Plan 후속 작업 후보를 만드는 목적입니다.
## 제외 기준
- 이미 구현 완료된 공통 컴포넌트는 중복 후보로 다시 올리지 않음
- 이미 Board/Plan에서 개발 접수된 컴포넌트는 신규 후보에서 제외
- 앱 전용 화면 조합보다 여러 기능에서 재사용 가능한 공통 UI를 우선 선정
## 신규 후보
### 1. Drawer / Side Sheet UI
본문 흐름을 끊지 않고 우측 또는 하단에서 보조 편집 화면을 여는 컴포넌트입니다.
- 적용 위치: Plan 상세 보조 편집, 설정 화면, 모바일 상세 패널
- 주요 props: `open`, `placement`, `width`, `title`, `footer`, `onClose`
- 기대 효과: 전체 화면 전환 없이 보조 작업을 열고 닫는 패턴을 공통화
### 2. Description List / Key Value Summary UI
상세 정보 화면에서 라벨과 값을 읽기 전용으로 정리하는 컴포넌트입니다.
- 적용 위치: Plan 메타 정보, 방문 이력 상세, 앱 설정 요약
- 주요 props: `items`, `columns`, `size`, `labelWidth`, `copyable`
- 기대 효과: 상세 화면마다 반복되는 메타 정보 레이아웃을 줄임
### 3. Stepper / Process Flow UI
등록, 작업중, `release` 반영, `main` 반영 같은 단계를 순서형으로 보여주는 컴포넌트입니다.
- 적용 위치: Plan 상태 흐름, 배포 진행 표시, 자동화 단계 요약
- 주요 props: `steps`, `current`, `status`, `direction`, `compact`
- 기대 효과: 텍스트 상태 나열보다 현재 단계와 다음 단계를 직관적으로 전달
### 4. Tag Input UI
여러 키워드나 라벨을 직접 추가하고 삭제하는 입력 컴포넌트입니다.
- 적용 위치: Board 태그, 검색 조건 저장, 증적 분류, 빠른 필터 조합
- 주요 props: `value`, `suggestions`, `maxTags`, `allowCustom`, `onChange`
- 기대 효과: 다중 조건 입력을 `select`와 별도로 다뤄 반복 필터 구성이 쉬워짐
### 5. Breadcrumb / Context Path UI
현재 위치와 상위 경로를 짧게 보여주는 탐색 보조 컴포넌트입니다.
- 적용 위치: Docs 상세, Components 샘플 상세, History 상세 진입 경로
- 주요 props: `items`, `separator`, `compact`, `onNavigate`
- 기대 효과: 깊은 메뉴 구조에서 현재 위치 파악과 상위 이동 비용을 낮춤
### 6. Property Grid UI
설정값이나 옵션 목록을 2열 또는 다열로 배치해 빠르게 편집하는 설정형 컴포넌트입니다.
- 적용 위치: 앱 설정, 자동화 설정, 위젯 옵션 편집
- 주요 props: `sections`, `fields`, `columns`, `readonly`, `onChange`
- 기대 효과: 설정 폼을 긴 세로 나열 대신 밀도 있게 구성 가능
## 권장 진행 순서
1. `Description List / Key Value Summary UI`
2. `Stepper / Process Flow UI`
3. `Drawer / Side Sheet UI`
4. `Tag Input UI`
5. `Property Grid UI`
6. `Breadcrumb / Context Path UI`
## 검증 기준
- 모바일 폭에서 `drawer`, `stepper`, `property grid`가 가로 넘침 없이 동작하는지 확인
- Plan 상세와 설정 화면에 붙였을 때 기존 `antd` 기본 컴포넌트 조합보다 반복 코드가 줄어드는지 확인
- 읽기 전용 화면과 편집 화면에서 같은 컴포넌트를 무리 없이 재사용할 수 있는지 확인
## 메모
- 다음 후보 구현 시에는 `samples/BaseSample.tsx`, `samples/Sample.tsx`, 필요 시 `plugins/*.plugin.ts`를 같은 묶음으로 준비
- Docs 문서에는 목적, 주요 props, 적용 위치, 확장 포인트를 함께 기록

7
docs/components/evidence-attachment-strip-ui.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
Plan/Board 계열 화면에서 반복되는 산출물 카드, 링크, 미리보기 진입 UI를 공통 스트립으로 정리하는 컴포넌트입니다. Plan/Board 계열 화면에서 반복되는 산출물 카드, 링크, 미리보기 진입 UI를 공통 스트립으로 정리하는 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 지원 타입 ## 지원 타입
- `image` - `image`

7
docs/components/input.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
Ant Design `Input`을 기반으로 하되, 타이핑 중에는 내부 상태만 변경하고 `Enter` 또는 `blur` 시점에만 외부 `onChange`를 호출하는 입력 컴포넌트입니다. Ant Design `Input`을 기반으로 하되, 타이핑 중에는 내부 상태만 변경하고 `Enter` 또는 `blur` 시점에만 외부 `onChange`를 호출하는 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조 ## 폴더 구조
```text ```text

7
docs/components/popup.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
`[input][button][readonly input]` 형태로 검색어 입력, 버튼 액션, 선택 결과 표시를 한 줄에서 처리하는 입력 컴포넌트입니다. `[input][button][readonly input]` 형태로 검색어 입력, 버튼 액션, 선택 결과 표시를 한 줄에서 처리하는 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조 ## 폴더 구조
```text ```text

7
docs/components/previewer-ui.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
다양한 데이터를 공통 카드 형태로 미리보기할 수 있는 previewer 컴포넌트입니다. 다양한 데이터를 공통 카드 형태로 미리보기할 수 있는 previewer 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 지원 타입 ## 지원 타입
- `text` - `text`

7
docs/components/process-flow-ui.md Executable file → Normal file
View File

@@ -5,6 +5,13 @@
Plan, Board, History 화면에서 공통으로 사용할 수 있는 단계형 진행 표시 컴포넌트입니다. Plan, Board, History 화면에서 공통으로 사용할 수 있는 단계형 진행 표시 컴포넌트입니다.
현재 단계, 완료 단계, 실패 단계, 다음 대기 단계를 한 번에 보여주며 가로/세로 배치와 compact 모드를 지원합니다. 현재 단계, 완료 단계, 실패 단계, 다음 대기 단계를 한 번에 보여주며 가로/세로 배치와 compact 모드를 지원합니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조 ## 폴더 구조
```text ```text

7
docs/components/search-command.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
문서, API, 컴포넌트, 위젯을 키워드로 빠르게 찾고 바로 이동할 수 있는 통합 검색 모달입니다. 문서, API, 컴포넌트, 위젯을 키워드로 빠르게 찾고 바로 이동할 수 있는 통합 검색 모달입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트와 위젯에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트와 위젯은 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트와 위젯은 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 특징 ## 특징
- `AutoComplete` 기반 추천 드롭다운 - `AutoComplete` 기반 추천 드롭다운

7
docs/components/select.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
`code/value` 데이터를 받아 실제 값은 `code`로 유지하고, 드롭다운 표시와 검색은 `value` 기준으로 처리하는 select combo 입력 컴포넌트입니다. `code/value` 데이터를 받아 실제 값은 `code`로 유지하고, 드롭다운 표시와 검색은 `value` 기준으로 처리하는 select combo 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조 ## 폴더 구조
```text ```text

7
docs/components/status-badge.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
상태 값을 간단한 UI 배지로 표현하는 컴포넌트입니다. 상태 값을 간단한 UI 배지로 표현하는 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조 ## 폴더 구조
```text ```text

7
docs/components/stepper.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
여러 단계를 순서대로 표시하고 현재 진행 위치를 강조하는 stepper 컴포넌트입니다. 여러 단계를 순서대로 표시하고 현재 진행 위치를 강조하는 stepper 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 구현 위치 ## 구현 위치
```text ```text

7
docs/components/window-ui.md Executable file → Normal file
View File

@@ -4,6 +4,13 @@
부모 영역 안에서 이동 가능한 모달 스타일 윈도우를 제공합니다. 부모 영역 안에서 이동 가능한 모달 스타일 윈도우를 제공합니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 특징 ## 특징
- 헤더 작업줄 드래그 이동 - 헤더 작업줄 드래그 이동

4
docs/features/plan-automation.md Executable file → Normal file
View File

@@ -4,7 +4,7 @@
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다. Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
현재 문서는 자동화 브랜치 전략 자체를 고정 규칙으로 설명하지 않습니다. 자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다. 자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비하고, 작업 결과를 `release` 반영 후 `main`까지 반영하는 흐름입니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다.
## 구현 위치 ## 구현 위치
@@ -122,7 +122,7 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다. Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
자동화 작업의 Git 절차나 동기화 순서는 이 문서에 고정 규칙으로 적지 않습니다. 필요 시 실제 worker 구현과 현재 설정값을 함께 확인합니다. 자동화 작업의 세부 Git 절차와 예외 처리 순서는 실제 worker 구현과 현재 설정값을 함께 확인합니다.
## 차트 집계 방식 ## 차트 집계 방식

0
docs/features/plan-board-review.md Executable file → Normal file
View File

14
docs/features/plan-schedule.md Executable file → Normal file
View File

@@ -22,16 +22,22 @@
## 입력 항목 ## 입력 항목
- `workId`: 반복 등록할 작업 ID - `workId`: 반복 등록할 작업 ID
- 스케줄이 실제 자동화 접수로 Plan을 만들 때 이 값을 베이스 ID로 사용합니다.
- 생성되는 Plan 작업 ID는 `workId-1`부터 `workId-999` 범위의 suffix를 붙여 유니크하게 관리합니다.
- `note`: 매번 생성될 요청 메모 - `note`: 매번 생성될 요청 메모
- `automationType`: 자동화 유형 - `automationType`: 자동화 유형
- `plan`: Markdown 스타일 Plan 문서 등록/접수 - 자동화 유형 관리를 통해 등록된 항목을 그대로 선택합니다.
- `auto_worker`: 실제 자동 작업 실행 - 스케줄 실행 시 선택한 자동화 유형 ID를 유지한 채 자동화 작업메모를 등록하고 즉시 접수합니다.
- `command_execution`, `non_source_work`: 기존 분류 유지
- `releaseTarget`: 반영 대상 브랜치 - `releaseTarget`: 반영 대상 브랜치
- `jangsingProcessingRequired`: 기능동작확인 필요 여부 - `jangsingProcessingRequired`: 기능동작확인 필요 여부
- `autoDeployToMain`: main 자동 반영 대상 여부 - `autoDeployToMain`: main 자동 반영 대상 여부
- `enabled`: 스케줄 사용 여부 - `enabled`: 스케줄 사용 여부
- `immediateRunEnabled`: 생성 직후 바로 등록 허용 여부 - `immediateRunEnabled`: 생성 직후 바로 등록 허용 여부
- `refreshContextSnapshotOnNextRun`: 다음 자동 실행 1회에 한해 프로젝트 구조/관련 소스를 다시 읽고 `.auto_codex/schedule/<id>/` 아래 Markdown 문서를 재정리할지 여부
- `executionMode`: `codex` 또는 `managed-service`
- `managed-service`를 선택하면 스케줄 PK가 포함된 `.auto_codex/schedule/<id>/managed-service/...` 경로에 서비스 패키지 번들을 생성해 구분합니다.
- 스케줄 삭제 시 해당 디렉터리도 함께 삭제합니다.
- `recreateManagedServiceOnNextSave`: 관리형 서비스 패키지를 저장 시 다시 생성할지 여부
## 스케줄 모드 ## 스케줄 모드
@@ -75,6 +81,8 @@
- 자주 반복되는 운영 작업은 고정 `workId`로 등록 - 자주 반복되는 운영 작업은 고정 `workId`로 등록
- 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인 - 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인
- 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작 - 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작
- 기존 스케줄 참조 문서를 다시 만들고 싶으면 `refreshContextSnapshotOnNextRun`을 켠 뒤 저장하면 다음 실행 1회 후 자동 해제
- 외부 프로그램으로 확장할 예정인 작업은 `managed-service`로 분리해 두면 스케줄 PK 기준 서비스 키와 패키지 경로를 고정할 수 있음
- 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤 - 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤
## API 경로 메모 ## API 경로 메모

0
docs/features/plan-usage.md Executable file → Normal file
View File

0
docs/features/search-layer.md Executable file → Normal file
View File

View File

@@ -0,0 +1,71 @@
# 작업 요청 게시판
## 목적
작업 요청 게시판은 게시글 1건 안에 여러 하위 요청을 묶어 등록하고, 각 하위 요청이 자동화 작업으로 어떻게 접수되고 완료되는지 추적하는 화면입니다.
## 핵심 구조
- 게시글 1건은 공통 제목, 공통 메모, 공통 첨부 파일을 가집니다.
- 게시글 안에는 하위 요청을 1건 이상 둘 수 있습니다.
- 각 하위 요청은 별도 Plan 항목으로 등록될 수 있고, 상태도 개별로 추적됩니다.
- 공통 메모와 첨부 파일 경로는 하위 요청별 자동화 메모에 함께 포함됩니다.
## 데이터 모델
- 게시글 테이블: `board_posts`
- 하위 요청 테이블: `board_post_requests`
- 주요 서버 구현:
- 라우트: `etc/servers/work-server/src/routes/board.ts`
- 서비스: `etc/servers/work-server/src/services/board-service.ts`
- 주요 프런트 구현:
- 화면: `src/features/board/BoardPage.tsx`
- API 클라이언트: `src/features/board/api.ts`
- 타입: `src/features/board/types.ts`
## 실행 방식
하위 요청 등록 방식은 게시글 단위로 선택합니다.
- `all_at_once`: 접수 가능한 하위 요청을 한 번에 모두 Plan으로 등록합니다.
- `after_previous_finished`: 앞 요청이 성공/실패와 무관하게 종료되면 다음 요청을 등록합니다.
- `after_previous_success`: 앞 요청이 성공으로 완료된 경우에만 다음 요청을 등록합니다. 실패하면 뒤 요청은 `blocked` 상태로 남습니다.
## 상태 추적
각 하위 요청은 아래 정보를 기준으로 상태를 계산합니다.
- 게시판 워크플로 상태: `pending`, `waiting`, `registered`, `completed`, `failed`, `blocked`
- 연결된 Plan 상태: `status`, `workerStatus`, `lastError`
화면에서는 이를 바탕으로 다음처럼 보여줍니다.
- `미접수`: 아직 Plan 등록 전
- `선행 대기`: 순차 실행에서 앞 요청 완료를 기다리는 상태
- `대기열`: Plan 등록은 됐지만 아직 본격 처리 전
- `진행중`: worker가 작업 중
- `완료`: Plan 완료 반영
- `실패`: worker 실패 또는 오류 기록 존재
- `차단`: 성공 의존 순차 모드에서 앞 요청 실패로 후속 요청 중단
## 화면 동작
- 목록 화면에서 게시글별로 `완료 x/y`, 실패 수, 진행 수를 요약해 보여줍니다.
- 상세 화면에서 하위 요청을 추가, 삭제, 순서 변경할 수 있습니다.
- 자동화 접수 후에는 게시글과 하위 요청 편집이 잠기고 읽기 전용으로 전환됩니다.
- 하위 요청별로 연결된 Plan 링크를 바로 열 수 있습니다.
- 여러 게시글을 선택해 일괄 접수할 수 있지만, 실제 순차 흐름은 각 게시글의 실행 옵션을 따릅니다.
## 자동화 연동
- 접수 시 `receiveBoardPostAutomation()`이 하위 요청별 Plan을 생성합니다.
- Plan worker가 완료/실패를 기록하면 `progressBoardPostAutomationByPlanResult()`가 다음 하위 요청 등록 여부를 결정합니다.
- 레거시 호환용 `board_posts.automation_plan_item_id`, `automation_received_at`도 첫 접수 요청 기준으로 함께 동기화합니다.
## 검증 포인트
- 새 게시글 저장 시 하위 요청이 1건 이상 생성되는지 확인
- 실행 옵션별로 다음 요청 등록 시점이 의도대로 달라지는지 확인
- 앞 요청 실패 시 `after_previous_success` 모드에서 후속 요청이 `차단`으로 남는지 확인
- 자동화 접수 후 편집/삭제가 막히는지 확인
- 하위 요청별 Plan 링크가 올바른 항목으로 연결되는지 확인

0
docs/templates/feature-template.md vendored Executable file → Normal file
View File

0
docs/templates/worklog-template.md vendored Executable file → Normal file
View File

View File

@@ -1 +0,0 @@
테스트MD자동 생성 입니다.

0
docs/worklogs/2026-03-30.md Executable file → Normal file
View File

0
docs/worklogs/2026-03-31.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-01.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-02.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-03.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-04.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-05.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-06.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-07.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-08.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-09.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-10.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-11.md Executable file → Normal file
View File

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
REPO_ROOT="${REPO_ROOT:-$MAIN_PROJECT_ROOT}"
TEST_DEPLOY_GIT_REMOTE="${TEST_DEPLOY_GIT_REMOTE:-origin}"
TEST_DEPLOY_GIT_BRANCH="${TEST_DEPLOY_GIT_BRANCH:-main}"
TEST_BUILD_COMMAND="${TEST_BUILD_COMMAND:-npm run build:test-app}"
TEST_SERVER_RESTART_SCRIPT="${TEST_SERVER_RESTART_SCRIPT:-$SCRIPT_DIR/restart-test.sh}"
TEST_DEPLOY_COMMIT_MESSAGE="${TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot}"
cd "$MAIN_PROJECT_ROOT"
if ! command -v git >/dev/null 2>&1; then
echo "git CLI not found" >&2
exit 127
fi
if ! command -v npm >/dev/null 2>&1; then
echo "npm CLI not found" >&2
exit 127
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
if [ "$CURRENT_BRANCH" != "$TEST_DEPLOY_GIT_BRANCH" ]; then
echo "expected branch ${TEST_DEPLOY_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2
exit 1
fi
echo "::step::commit-main-worktree"
git add -A -- . ':(exclude).server-command-test-app-built-at' ':(exclude,glob)tmp-*' ':(exclude,glob)tmp-verification/**'
if git diff --cached --quiet; then
echo "no commit needed; main worktree already committed"
else
echo "staged files for TEST deploy commit:"
git diff --cached --name-status
git commit -m "$TEST_DEPLOY_COMMIT_MESSAGE"
fi
echo "::step::push-origin-main"
git push "$TEST_DEPLOY_GIT_REMOTE" "$TEST_DEPLOY_GIT_BRANCH"
echo "::step::build-test-app"
sh -lc "$TEST_BUILD_COMMAND"
echo "::step::deploy-test-server"
REPO_ROOT="$REPO_ROOT" sh "$TEST_SERVER_RESTART_SCRIPT"

0
etc/commands/server-command/restart-rel.sh Executable file → Normal file
View File

View File

@@ -6,12 +6,14 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}" PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}" RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}" RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
SUPERVISOR_SCRIPT="${SERVER_COMMAND_RUNNER_SUPERVISOR_SCRIPT:-$PROJECT_ROOT/scripts/server-command-runner-supervisor.sh}"
RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}" RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}" RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}" RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}" RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}" RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}" RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}"
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT") RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
@@ -27,17 +29,36 @@ if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then
exit 1 exit 1
fi fi
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true) if [ ! -x "$SUPERVISOR_SCRIPT" ]; then
if [ -n "$RUNNER_PIDS" ]; then chmod +x "$SUPERVISOR_SCRIPT"
kill $RUNNER_PIDS || true fi
if [ -f "$SUPERVISOR_PID_FILE" ]; then
SUPERVISOR_PID=$(cat "$SUPERVISOR_PID_FILE" 2>/dev/null || true)
if [ -n "${SUPERVISOR_PID:-}" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then
kill -HUP "$SUPERVISOR_PID"
echo "server-command-runner reload requested"
exit 0
fi
rm -f "$SUPERVISOR_PID_FILE"
fi
LEGACY_RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
if [ -n "$LEGACY_RUNNER_PIDS" ]; then
kill $LEGACY_RUNNER_PIDS || true
sleep 1 sleep 1
fi fi
setsid env \ setsid env \
PROJECT_ROOT="$PROJECT_ROOT" \
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \ SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \ SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \ SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \ SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null & SERVER_COMMAND_RUNNER_SCRIPT="$RUNNER_SCRIPT" \
SERVER_COMMAND_RUNNER_NODE_BIN="$RUNNER_NODE_BIN" \
SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE="$SUPERVISOR_PID_FILE" \
"$SUPERVISOR_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
echo "server-command-runner restart requested" echo "server-command-runner restart requested"

22
etc/commands/server-command/restart-test.sh Executable file → Normal file
View File

@@ -7,16 +7,30 @@ SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/d
SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-app}" SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-app}"
SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1}" SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1}"
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}" 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) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$MAIN_PROJECT_ROOT" cd "$MAIN_PROJECT_ROOT"
if command -v docker >/dev/null 2>&1; then if [ "$SERVER_COMMAND_TEST_GIT_SYNC" = "true" ]; then
if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
exit 0
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 fi
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE" git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
fi
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
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 fi
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then

View File

View File

@@ -4,6 +4,485 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && 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" cd "$REPO_ROOT"
exec docker compose -f etc/servers/work-server/docker-compose.yml 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"

0
etc/db/work-db/README.md Executable file → Normal file
View File

0
etc/db/work-db/docker-compose.yml Executable file → Normal file
View File

View File

@@ -2,6 +2,7 @@ create table if not exists board_posts (
id serial primary key, id serial primary key,
title varchar(200) not null, title varchar(200) not null,
content text not null, content text not null,
attachments_json text not null default '[]',
automation_plan_item_id integer null, automation_plan_item_id integer null,
automation_received_at timestamptz null, automation_received_at timestamptz null,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),

View File

@@ -0,0 +1,4 @@
.docker
node_modules
dist
npm-debug.log*

View File

@@ -37,12 +37,15 @@ SERVER_COMMAND_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
SERVER_COMMAND_API_BASE_URL=http://host.docker.internal:3211/api SERVER_COMMAND_API_BASE_URL=http://host.docker.internal:3211/api
SERVER_COMMAND_API_ACCESS_TOKEN=local-server-command-runner SERVER_COMMAND_API_ACCESS_TOKEN=local-server-command-runner
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE=/api/server-commands/{key}/actions/restart 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_MAIN_PROJECT_ROOT=/workspace/main-project
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/ 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_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_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_REMOTE=origin
SERVER_COMMAND_PROD_GIT_BRANCH=main SERVER_COMMAND_PROD_GIT_BRANCH=main
SERVER_COMMAND_PROD_GIT_USERNAME= SERVER_COMMAND_PROD_GIT_USERNAME=

View File

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

View File

@@ -11,4 +11,7 @@ COPY src ./src
RUN npm run build RUN npm run build
CMD ["npm", "run", "start"] COPY scripts/container-supervisor.sh /usr/local/bin/work-server-supervisor
RUN chmod +x /usr/local/bin/work-server-supervisor
CMD ["/usr/local/bin/work-server-supervisor"]

View File

@@ -17,7 +17,19 @@ docker compose up -d
docker compose logs -f work-server 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는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다. 호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 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_DOCKER_SOCKET`: 서버 재기동 명령이 사용할 Docker Unix socket 경로. rootless Docker면 예: `/run/user/1000/docker.sock`
- `SERVER_COMMAND_API_BASE_URL`: `work-server`가 서버 재기동 요청을 위임할 host runner 주소 - `SERVER_COMMAND_API_BASE_URL`: `work-server`가 서버 재기동 요청을 위임할 host runner 주소
- `SERVER_COMMAND_API_ACCESS_TOKEN`: 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`를 다시 올려야 합니다. 서버 재기동 기능을 쓰려면 `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`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
`Codex Live``Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형과 현재 화면 문맥을 기준으로 동작하고, 자동화 유형 context 기본 문맥으로 섞지 않습니다. `Codex Live``Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 채팅방 공통 문맥과 전용 메모는 충돌하지 않는 범위의 보조 문맥으로만 사용합니다. 현재 화면 및 최근 대화 문맥도 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context 기본 문맥으로 섞지 않습니다.
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.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을 다시 적어 줍니다. 채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat/<chat-session-id>/resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다.
@@ -103,6 +116,7 @@ npm run server-command:runner
- `DELETE /api/plan/items/:id` - `DELETE /api/plan/items/:id`
- `POST /api/notifications/setup` - `POST /api/notifications/setup`
- `GET /api/notifications/tokens` - `GET /api/notifications/tokens`
- `GET /api/notifications/subscriptions/web`
- `PUT /api/notifications/tokens/ios` - `PUT /api/notifications/tokens/ios`
- `DELETE /api/notifications/tokens/ios` - `DELETE /api/notifications/tokens/ios`
- `POST /api/notifications/send-test` - `POST /api/notifications/send-test`
@@ -112,3 +126,13 @@ npm run server-command:runner
- 프론트에서 알림 `On``PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다. - 프론트에서 알림 `On``PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다.
- 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다. - 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다.
- Plan worker가 브랜치 준비, 자동 작업 완료/실패, release 반영 완료/실패, main 반영 완료/실패 시 등록된 iOS 토큰으로 APNs 알림을 전송합니다. - Plan worker가 브랜치 준비, 자동 작업 완료/실패, release 반영 완료/실패, main 반영 완료/실패 시 등록된 iOS 토큰으로 APNs 알림을 전송합니다.
## 웹푸쉬 호출 메모
- `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` 상태를 확인할 수 있습니다.
- 같은 알림을 교체하려면 DB 삭제 대신 `data.notificationKey` 또는 `threadId`를 고정값으로 보내세요. 서비스워커가 이 값을 브라우저 알림 `tag`로 사용해 이전 알림을 대체합니다.

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,25 @@
services: services:
work-server: 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: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: work-server container_name: work-server-blue
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -19,9 +35,9 @@ services:
required: false required: false
- path: ./.env - path: ./.env
required: false required: false
ports:
- '127.0.0.1:3100:3100'
volumes: volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
- ../../../:/workspace/main-project - ../../../:/workspace/main-project
- ../../../.auto_codex:/workspace/auto_codex - ../../../.auto_codex:/workspace/auto_codex
- ../../../scripts:/workspace/repo-scripts:ro - ../../../scripts:/workspace/repo-scripts:ro
@@ -40,6 +56,58 @@ services:
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul} APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul} DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
NPM_CONFIG_CACHE: /home/how2ice/.npm NPM_CONFIG_CACHE: /home/how2ice/.npm
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} SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
DOCKER_HOST: ${DOCKER_HOST:-} DOCKER_HOST: ${DOCKER_HOST:-}
networks: networks:
@@ -50,3 +118,6 @@ services:
networks: networks:
work-backend: work-backend:
name: work-backend name: work-backend
volumes:
work-server-node-modules:

0
etc/servers/work-server/package-lock.json generated Executable file → Normal file
View File

View File

@@ -5,8 +5,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "npm run build && npm run start", "dev": "npm run build && npm run start",
"build": "tsc -p tsconfig.json && node ./scripts/write-build-info.mjs", "build": "sh -c 'tsc -p tsconfig.json --outDir \"${WORK_SERVER_DIST_DIR:-dist}\" && node ./scripts/write-build-info.mjs'",
"start": "node dist/server.js", "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-request-links": "node --import tsx ./scripts/backfill-chat-request-links.ts",
"backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts", "backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts",
"test": "node --import tsx --test src/**/*.test.ts" "test": "node --import tsx --test src/**/*.test.ts"

View File

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

View File

@@ -0,0 +1,111 @@
#!/bin/sh
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=""
STOP_REQUESTED="0"
RELOAD_REQUESTED="0"
mkdir -p "$STATE_DIR"
cd "$APP_ROOT"
log() {
printf '[work-server-supervisor] %s\n' "$*"
}
ensure_dependencies() {
if [ ! -f "$LOCK_FILE" ]; then
log "package-lock.json not found; skipping npm ci"
return 0
fi
CURRENT_HASH=$(sha256sum "$LOCK_FILE" | awk '{print $1}')
PREVIOUS_HASH=""
if [ -f "$LOCK_HASH_FILE" ]; then
PREVIOUS_HASH=$(cat "$LOCK_HASH_FILE")
fi
if [ ! -d "$APP_ROOT/node_modules" ] || [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ]; then
log "installing dependencies"
npm ci --legacy-peer-deps
printf '%s' "$CURRENT_HASH" >"$LOCK_HASH_FILE"
fi
}
prepare_runtime() {
ensure_dependencies
log "building latest source"
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 &
CHILD_PID=$!
}
request_reload() {
log "reload requested"
if prepare_runtime; then
RELOAD_REQUESTED="1"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
else
log "reload aborted because build failed; keeping current process"
fi
}
request_stop() {
STOP_REQUESTED="1"
log "shutdown requested"
if [ -n "$CHILD_PID" ]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
fi
}
trap 'request_reload' HUP
trap 'request_stop' INT TERM
prepare_runtime_or_fallback
while :; do
start_child
set +e
wait "$CHILD_PID"
EXIT_CODE=$?
set -e
CHILD_PID=""
if [ "$STOP_REQUESTED" = "1" ]; then
exit "$EXIT_CODE"
fi
if [ "$RELOAD_REQUESTED" = "1" ]; then
RELOAD_REQUESTED="0"
continue
fi
log "server exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds"
sleep 2
done

View File

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

24
etc/servers/work-server/scripts/write-build-info.mjs Executable file → Normal file
View File

@@ -3,8 +3,11 @@ import path from 'node:path';
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const packageJsonPath = path.join(projectRoot, 'package.json'); const packageJsonPath = path.join(projectRoot, 'package.json');
const distDirectoryPath = path.join(projectRoot, 'dist'); const configuredDistPath = process.env.WORK_SERVER_DIST_DIR?.trim() || 'dist';
const distDirectoryPath = path.resolve(projectRoot, configuredDistPath);
const buildInfoPath = path.join(distDirectoryPath, 'build-info.json'); const buildInfoPath = path.join(distDirectoryPath, 'build-info.json');
const distNodeModulesPath = path.join(distDirectoryPath, 'node_modules');
const projectNodeModulesPath = path.join(projectRoot, 'node_modules');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const builtAt = new Date().toISOString(); const builtAt = new Date().toISOString();
@@ -18,4 +21,23 @@ const buildInfo = {
await fs.mkdir(distDirectoryPath, { recursive: true }); await fs.mkdir(distDirectoryPath, { recursive: true });
await fs.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2)); await fs.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2));
try {
const existingNodeModulesLink = await fs.lstat(distNodeModulesPath);
if (existingNodeModulesLink.isSymbolicLink()) {
await fs.unlink(distNodeModulesPath);
}
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
try {
await fs.symlink(projectNodeModulesPath, distNodeModulesPath, 'dir');
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error;
}
}
console.log(`work-server build info written to ${buildInfoPath}`); console.log(`work-server build info written to ${buildInfoPath}`);

63
etc/servers/work-server/src/app.ts Executable file → Normal file
View File

@@ -7,36 +7,99 @@ import { registerDdlRoutes } from './routes/ddl.js';
import { registerErrorLogRoutes } from './routes/error-log.js'; import { registerErrorLogRoutes } from './routes/error-log.js';
import { registerHealthRoutes } from './routes/health.js'; import { registerHealthRoutes } from './routes/health.js';
import { registerAppConfigRoutes } from './routes/app-config.js'; import { registerAppConfigRoutes } from './routes/app-config.js';
import { registerBaseballTicketBayRoutes } from './routes/baseball-ticket-bay.js';
import { registerChatRoutes } from './routes/chat.js'; import { registerChatRoutes } from './routes/chat.js';
import { registerNotificationRoutes } from './routes/notification.js'; import { registerNotificationRoutes } from './routes/notification.js';
import { registerPlanRoutes } from './routes/plan.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 { registerServerCommandRoutes } from './routes/server-command.js';
import { registerSchemaRoutes } from './routes/schema.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 { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
import { shouldPersistNotFoundErrorLog } from './not-found.js'; import { shouldPersistNotFoundErrorLog } from './not-found.js';
import { createErrorLog } from './services/error-log-service.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() { export function createApp() {
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
routerOptions: {
maxParamLength: 20000,
},
}); });
app.register(cors, { app.register(cors, {
origin: true, 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); registerJsonBodyParser(app);
app.register(registerBoardRoutes); app.register(registerBoardRoutes);
app.register(registerHealthRoutes); app.register(registerHealthRoutes);
app.register(registerAppConfigRoutes); app.register(registerAppConfigRoutes);
app.register(registerBaseballTicketBayRoutes);
app.register(registerChatRoutes); app.register(registerChatRoutes);
app.register(registerSchemaRoutes); app.register(registerSchemaRoutes);
app.register(registerDdlRoutes); app.register(registerDdlRoutes);
app.register(registerCrudRoutes); app.register(registerCrudRoutes);
app.register(registerStockAlertRoutes);
app.register(registerTestAppRoutes);
app.register(registerErrorLogRoutes); app.register(registerErrorLogRoutes);
app.register(registerNotificationRoutes); app.register(registerNotificationRoutes);
app.register(registerPlanRoutes); 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(registerServerCommandRoutes);
app.register(registerTextMemoRoutes);
app.register(registerVisitorHistoryRoutes); app.register(registerVisitorHistoryRoutes);
app.setNotFoundHandler(async (request, reply) => { app.setNotFoundHandler(async (request, reply) => {

View File

@@ -0,0 +1,112 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.env = void 0;
exports.getEnv = getEnv;
var node_path_1 = require("node:path");
var dotenv_1 = require("dotenv");
var zod_1 = require("zod");
dotenv_1.default.config({ override: true, quiet: true });
var envSchema = zod_1.z.object({
PORT: zod_1.z.coerce.number().default(3100),
APP_TIME_ZONE: zod_1.z.string().default('Asia/Seoul'),
DB_TIME_ZONE: zod_1.z.string().default('Asia/Seoul'),
DB_CLIENT: zod_1.z.string().default('pg'),
DB_HOST: zod_1.z.string().default('localhost'),
DB_PORT: zod_1.z.coerce.number().default(5432),
DB_NAME: zod_1.z.string().default('work_db'),
DB_USER: zod_1.z.string().default('work_user'),
DB_PASSWORD: zod_1.z.string().default('change-me'),
DB_SSL: zod_1.z
.string()
.default('false')
.transform(function (value) { return value === 'true'; }),
PLAN_WORKER_ENABLED: zod_1.z
.string()
.default('true')
.transform(function (value) { return value === 'true'; }),
PLAN_WORKER_INTERVAL_MS: zod_1.z.coerce.number().default(10000),
PLAN_WORKER_ID: zod_1.z.string().optional(),
PLAN_GIT_REPO_PATH: zod_1.z.string().default('/workspace/repo'),
PLAN_MAIN_PROJECT_REPO_PATH: zod_1.z.string().optional(),
PLAN_RELEASE_BRANCH: zod_1.z.string().default('release'),
PLAN_MAIN_BRANCH: zod_1.z.string().default('main'),
PLAN_GIT_USER_NAME: zod_1.z.string().default('how2ice'),
PLAN_GIT_USER_EMAIL: zod_1.z.string().default('how2ice@naver.com'),
PLAN_CODEX_RUNNER_PATH: zod_1.z.string().default('/workspace/repo-scripts/run-plan-codex-once.mjs'),
PLAN_CODEX_ENABLED: zod_1.z
.string()
.default('true')
.transform(function (value) { return value === 'true'; }),
PLAN_LOCAL_MAIN_MODE: zod_1.z
.string()
.default('true')
.transform(function (value) { return value === 'true'; }),
PLAN_CODEX_BIN: zod_1.z.string().default('codex'),
PLAN_CODEX_TEMPLATE_HOME: zod_1.z.string().optional(),
PLAN_PREVIEW_BASE_URL: zod_1.z.string().optional(),
PLAN_PREVIEW_URL_TEMPLATE: zod_1.z.string().optional(),
IOS_NOTIFICATION_ENABLED: zod_1.z
.string()
.default('false')
.transform(function (value) { return value === 'true'; }),
WEB_PUSH_ENABLED: zod_1.z
.string()
.default('false')
.transform(function (value) { return value === 'true'; }),
WEB_PUSH_VAPID_PUBLIC_KEY: zod_1.z.string().optional(),
WEB_PUSH_VAPID_PRIVATE_KEY: zod_1.z.string().optional(),
WEB_PUSH_SUBJECT: zod_1.z.string().default('mailto:how2ice@naver.com'),
APNS_KEY_ID: zod_1.z.string().optional(),
APNS_TEAM_ID: zod_1.z.string().optional(),
APNS_BUNDLE_ID: zod_1.z.string().optional(),
APNS_PRIVATE_KEY: zod_1.z.string().optional(),
APNS_PRIVATE_KEY_PATH: zod_1.z.string().optional(),
APNS_PRODUCTION: zod_1.z
.string()
.default('false')
.transform(function (value) { return value === 'true'; }),
SERVER_COMMAND_ACCESS_TOKEN: zod_1.z.string().default('usr_7f3a9c2d8e1b4a6f'),
SERVER_COMMAND_API_BASE_URL: zod_1.z.string().optional(),
SERVER_COMMAND_API_ACCESS_TOKEN: zod_1.z.string().optional(),
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: zod_1.z.string().default('/api/server-commands/{key}/actions/restart'),
SERVER_COMMAND_PROJECT_ROOT: zod_1.z.string().default(node_path_1.default.resolve(process.cwd(), '../../..')),
SERVER_COMMAND_MAIN_PROJECT_ROOT: zod_1.z.string().default('/workspace/main-project'),
SERVER_COMMAND_TEST_URL: zod_1.z.string().default('https://preview.sm-home.cloud/'),
SERVER_COMMAND_REL_URL: zod_1.z.string().default('https://rel.sm-home.cloud/'),
SERVER_COMMAND_PROD_URL: zod_1.z.string().default('https://sm-home.cloud/'),
SERVER_COMMAND_WORK_SERVER_URL: zod_1.z.string().default('http://127.0.0.1:3100/health'),
SERVER_COMMAND_RUNNER_URL: zod_1.z.string().default('http://host.docker.internal:3211/health'),
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: zod_1.z.string().default('local-server-command-runner'),
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: zod_1.z.string().optional(),
SERVER_COMMAND_TEST_SERVICE: zod_1.z.string().default('app'),
SERVER_COMMAND_REL_SERVICE: zod_1.z.string().default('release-app'),
SERVER_COMMAND_PROD_SERVICE: zod_1.z.string().default('prod-app'),
SERVER_COMMAND_WORK_SERVER_SERVICE: zod_1.z.string().default('work-server'),
WORK_SERVER_DIST_DIR: zod_1.z.string().default('dist'),
});
function parseEnv() {
var _a;
dotenv_1.default.config({ override: true, quiet: true });
var parsedEnv = envSchema.parse(process.env);
return __assign(__assign({}, parsedEnv), { PLAN_MAIN_PROJECT_REPO_PATH: (_a = parsedEnv.PLAN_MAIN_PROJECT_REPO_PATH) !== null && _a !== void 0 ? _a : parsedEnv.PLAN_GIT_REPO_PATH });
}
function getEnv() {
var _a;
var parsedEnv = parseEnv();
if (!((_a = process.env.TZ) === null || _a === void 0 ? void 0 : _a.trim())) {
process.env.TZ = parsedEnv.APP_TIME_ZONE;
}
return parsedEnv;
}
exports.env = getEnv();

View File

@@ -54,6 +54,8 @@ const envSchema = z.object({
WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(), WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(),
WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(), WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(),
WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'), 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_KEY_ID: z.string().optional(),
APNS_TEAM_ID: z.string().optional(), APNS_TEAM_ID: z.string().optional(),
APNS_BUNDLE_ID: z.string().optional(), APNS_BUNDLE_ID: z.string().optional(),
@@ -69,17 +71,22 @@ const envSchema = z.object({
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: z.string().default('/api/server-commands/{key}/actions/restart'), SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: z.string().default('/api/server-commands/{key}/actions/restart'),
SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')), SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')),
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'), SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'), 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_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_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_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_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_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(), 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_TEST_SERVICE: z.string().default('app'),
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'), SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'), SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'), SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
WORK_SERVER_DIST_DIR: z.string().default('dist'),
}); });
function parseEnv() { function parseEnv() {

View File

@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.db = void 0;
var knex_1 = require("knex");
var env_js_1 = require("../config/env.js");
exports.db = (0, knex_1.default)({
client: env_js_1.env.DB_CLIENT,
connection: {
host: env_js_1.env.DB_HOST,
port: env_js_1.env.DB_PORT,
database: env_js_1.env.DB_NAME,
user: env_js_1.env.DB_USER,
password: env_js_1.env.DB_PASSWORD,
ssl: env_js_1.env.DB_SSL ? { rejectUnauthorized: false } : false,
},
pool: {
min: 0,
max: 10,
afterCreate: function (connection, done) {
var _a;
var clientName = String((_a = env_js_1.env.DB_CLIENT) !== null && _a !== void 0 ? _a : '').toLowerCase();
if (clientName === 'pg' || clientName === 'postgres' || clientName === 'postgresql') {
connection.query("SET TIME ZONE '".concat(env_js_1.env.DB_TIME_ZONE, "'"), function (error) {
done(error, connection);
});
return;
}
if (clientName === 'mysql' || clientName === 'mysql2') {
connection.query('SET time_zone = "+09:00"', function (error) {
done(error, connection);
});
return;
}
done(null, connection);
},
},
});

0
etc/servers/work-server/src/db/client.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/json-body.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/lib/identifier.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/not-found.test.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/not-found.ts Executable file → Normal file
View File

392
etc/servers/work-server/src/routes/app-config.ts Executable file → Normal file
View File

@@ -1,24 +1,210 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js'; import { env } from '../config/env.js';
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
import {
getChatContextSettingsConfig,
getAppConfigSnapshot,
getChatTypesConfig,
normalizeAppConfigSnapshot,
upsertAppConfig,
upsertChatContextSettingsConfig,
upsertChatTypesConfig,
} from '../services/app-config-service.js';
import {
getAutomationContextsConfig,
upsertAutomationContextsConfig,
} from '../services/automation-context-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-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'];
const appOrigin = Array.isArray(rawAppOrigin) ? rawAppOrigin[0] : rawAppOrigin;
if (appOrigin?.trim()) {
return appOrigin.trim();
}
const rawOrigin = request.headers.origin;
const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
return origin?.trim() ?? '';
}
function getRequestAppDomain(request: { headers: Record<string, string | string[] | undefined> }) {
const rawAppDomain = request.headers['x-app-domain'];
const appDomain = Array.isArray(rawAppDomain) ? rawAppDomain[0] : rawAppDomain;
if (appDomain?.trim()) {
return appDomain.trim();
}
const appOrigin = getRequestAppOrigin(request);
if (!appOrigin) {
return '';
}
try {
return new URL(appOrigin).hostname;
} catch {
return '';
}
}
export async function registerAppConfigRoutes(app: FastifyInstance) { export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => { app.get('/api/app-config', async (request, reply) => {
const config = await getAppConfig(); 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);
return { return {
ok: true, ok: true,
config: config ?? {}, config,
}; };
}); });
app.get('/api/chat-types', async () => { app.get('/api/chat-types', async (request) => {
const chatTypes = await getChatTypesConfig(); const chatTypeConfig = await getChatTypesConfig(getRequestAppOrigin(request));
return { return {
ok: true, ok: true,
chatTypes, ...chatTypeConfig,
};
});
app.get('/api/chat-context-settings', async (request) => {
const settings = await getChatContextSettingsConfig(getRequestAppOrigin(request));
return {
ok: true,
settings,
}; };
}); });
@@ -31,6 +217,31 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}; };
}); });
app.get('/api/automation-contexts', async () => {
const automationContexts = await getAutomationContextsConfig();
return {
ok: true,
automationContexts,
};
});
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) => { app.put('/api/chat-types', async (request, reply) => {
try { try {
let payload: unknown = request.body ?? {}; let payload: unknown = request.body ?? {};
@@ -43,15 +254,20 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
} }
} }
const parsed = z.object({ const parsed = z
chatTypes: z.array(z.unknown()), .object({
}).parse(payload ?? {}); chatTypes: z.array(z.unknown()).optional(),
})
.parse(payload ?? {});
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes); const appOrigin = getRequestAppOrigin(request);
const appDomain = getRequestAppDomain(request);
const targetChatTypes = parsed.chatTypes ?? [];
const savedChatTypeConfig = await upsertChatTypesConfig(targetChatTypes, appOrigin, appDomain);
return { return {
ok: true, ok: true,
chatTypes: savedChatTypes, ...savedChatTypeConfig,
}; };
} catch (error) { } catch (error) {
return reply.code(409).send({ return reply.code(409).send({
@@ -60,6 +276,37 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
} }
}); });
app.put('/api/chat-context-settings', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
settings: z.unknown(),
}).parse(payload ?? {});
const appOrigin = getRequestAppOrigin(request);
const appDomain = getRequestAppDomain(request);
const savedSettings = await upsertChatContextSettingsConfig(parsed.settings, appOrigin, appDomain);
return {
ok: true,
settings: savedSettings,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '채팅 Context 설정 저장에 실패했습니다.',
});
}
});
app.put('/api/automation-types', async (request, reply) => { app.put('/api/automation-types', async (request, reply) => {
try { try {
let payload: unknown = request.body ?? {}; let payload: unknown = request.body ?? {};
@@ -89,7 +336,122 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
} }
}); });
app.put('/api/automation-contexts', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
automationContexts: z.array(z.unknown()),
}).parse(payload ?? {});
const savedAutomationContexts = await upsertAutomationContextsConfig(parsed.automationContexts);
return {
ok: true,
automationContexts: savedAutomationContexts,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '자동화 Context 저장에 실패했습니다.',
});
}
});
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) => { app.put('/api/app-config', async (request, reply) => {
const accessContext = await resolveAppConfigAccessContext(request);
if (!accessContext) {
sendAppConfigAccessDenied(reply);
return;
}
try { try {
let payload: unknown = request.body ?? {}; let payload: unknown = request.body ?? {};
@@ -111,11 +473,13 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
throw new Error('설정 값 형식이 올바르지 않습니다.'); throw new Error('설정 값 형식이 올바르지 않습니다.');
} }
const savedConfig = await upsertAppConfig(config as Record<string, unknown>); const appOrigin = getRequestAppOrigin(request);
const appDomain = getRequestAppDomain(request);
const savedConfig = await upsertAppConfig(config as Record<string, unknown>, appOrigin, appDomain);
return { return {
ok: true, ok: true,
config: savedConfig, config: normalizeAppConfigSnapshot(savedConfig),
}; };
} catch (error) { } catch (error) {
return reply.code(409).send({ return reply.code(409).send({

View File

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

2
etc/servers/work-server/src/routes/board.ts Executable file → Normal file
View File

@@ -109,7 +109,7 @@ export async function registerBoardRoutes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
item: result.item, item: result.item,
planItemId: result.planItemId, planItemIds: result.planItemIds,
alreadyReceived: result.alreadyReceived, alreadyReceived: result.alreadyReceived,
}; };
}); });

View File

@@ -0,0 +1,95 @@
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 { 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');
assert.equal(resolveStaticContentType('/tmp/sample.htm'), 'text/html; charset=utf-8');
});
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();
}
});

3907
etc/servers/work-server/src/routes/chat.ts Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

0
etc/servers/work-server/src/routes/crud.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/routes/ddl.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/routes/error-log.ts Executable file → Normal file
View File

27
etc/servers/work-server/src/routes/health.ts Executable file → Normal file
View File

@@ -1,11 +1,28 @@
import type { FastifyInstance } from 'fastify'; 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) { export async function registerHealthRoutes(app: FastifyInstance) {
const respondHealth = async () => ({ const respondHealth = async () => {
ok: true, const buildInfo = getRuntimeWorkServerBuildInfo();
service: 'work-server', const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
timestamp: new Date().toISOString(),
}); 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('/', respondHealth);
app.get('/api', respondHealth); app.get('/api', respondHealth);

57
etc/servers/work-server/src/routes/notification.ts Executable file → Normal file
View File

@@ -1,20 +1,16 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { import {
listIosNotificationTokens, listWebPushSubscriptions,
getAutomationNotificationPreference, getAutomationNotificationPreference,
getWebPushConfig, getWebPushConfig,
registerIosNotificationToken,
registerAutomationNotificationPreferenceSchema, registerAutomationNotificationPreferenceSchema,
registerIosTokenSchema,
registerWebPushSubscription, registerWebPushSubscription,
registerWebPushSubscriptionSchema, registerWebPushSubscriptionSchema,
sendNotifications, sendNotifications,
sendIosNotificationSchema, sendIosNotificationSchema,
setupNotificationTables, setupNotificationTables,
upsertAutomationNotificationPreference, upsertAutomationNotificationPreference,
unregisterIosNotificationToken,
unregisterIosTokenSchema,
unregisterWebPushSubscription, unregisterWebPushSubscription,
unregisterWebPushSubscriptionSchema, unregisterWebPushSubscriptionSchema,
} from '../services/notification-service.js'; } from '../services/notification-service.js';
@@ -30,14 +26,10 @@ import {
} from '../services/notification-message-service.js'; } from '../services/notification-message-service.js';
const automationNotificationPreferenceQuerySchema = z.object({ const automationNotificationPreferenceQuerySchema = z.object({
targetKind: z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']).optional(), targetKind: z.enum(['client', 'web-endpoint']).optional(),
targetId: z.string().trim().min(1).max(1000).optional(), targetId: z.string().trim().min(1).max(1000).optional(),
}); });
type AutomationNotificationPreferenceTargetKind = NonNullable<
z.infer<typeof automationNotificationPreferenceQuerySchema>['targetKind']
>;
function getClientIdHeader(request: { headers: Record<string, string | string[] | undefined> }) { function getClientIdHeader(request: { headers: Record<string, string | string[] | undefined> }) {
const rawClientId = request.headers['x-client-id']; const rawClientId = request.headers['x-client-id'];
const clientId = Array.isArray(rawClientId) ? rawClientId[0] : rawClientId; const clientId = Array.isArray(rawClientId) ? rawClientId[0] : rawClientId;
@@ -47,17 +39,18 @@ function getClientIdHeader(request: { headers: Record<string, string | string[]
export async function registerNotificationRoutes(app: FastifyInstance) { export async function registerNotificationRoutes(app: FastifyInstance) {
app.post('/api/notifications/setup', async () => setupNotificationTables()); app.post('/api/notifications/setup', async () => setupNotificationTables());
app.get('/api/notifications/tokens', async () => ({ app.get('/api/notifications/subscriptions/web', async () => ({
items: await listIosNotificationTokens(), items: await listWebPushSubscriptions(),
})); }));
app.get('/api/notifications/webpush/config', async () => getWebPushConfig()); app.get('/api/notifications/webpush/config', async () => getWebPushConfig());
app.get('/api/notifications/messages', async (request) => { app.get('/api/notifications/messages', async (request) => {
const query = notificationMessageListQuerySchema.parse(request.query ?? {}); const query = notificationMessageListQuerySchema.parse(request.query ?? {});
const clientId = getClientIdHeader(request);
return { return {
ok: true, ok: true,
...(await listNotificationMessages(query)), ...(await listNotificationMessages(query, clientId)),
}; };
}); });
@@ -125,7 +118,7 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
return { return {
ok: true, ok: true,
automation: await getAutomationNotificationPreferenceWithFallback(targetId, targetKind), automation: await getAutomationNotificationPreference(targetId, targetKind),
}; };
}); });
@@ -149,16 +142,6 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
} }
}); });
app.put('/api/notifications/tokens/ios', async (request) => {
const payload = registerIosTokenSchema.parse(request.body ?? {});
return registerIosNotificationToken(payload);
});
app.delete('/api/notifications/tokens/ios', async (request) => {
const payload = unregisterIosTokenSchema.parse(request.body ?? {});
return unregisterIosNotificationToken(payload.token);
});
app.put('/api/notifications/subscriptions/web', async (request) => { app.put('/api/notifications/subscriptions/web', async (request) => {
const payload = registerWebPushSubscriptionSchema.parse(request.body ?? {}); const payload = registerWebPushSubscriptionSchema.parse(request.body ?? {});
return registerWebPushSubscription(payload); return registerWebPushSubscription(payload);
@@ -179,29 +162,3 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
return sendNotifications(payload); return sendNotifications(payload);
}); });
} }
async function getAutomationNotificationPreferenceWithFallback(
targetId: string,
targetKind: AutomationNotificationPreferenceTargetKind,
) {
const automation = await getAutomationNotificationPreference(targetId, targetKind);
if (automation || targetKind !== 'ios-token-client') {
return automation;
}
const [token, clientId] = targetId.split('::client::');
if (token?.trim()) {
const tokenAutomation = await getAutomationNotificationPreference(token.trim(), 'ios-token');
if (tokenAutomation) {
return tokenAutomation;
}
}
if (clientId?.trim()) {
return getAutomationNotificationPreference(clientId.trim(), 'client');
}
return null;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

74
etc/servers/work-server/src/routes/plan.ts Executable file → Normal file
View File

@@ -64,10 +64,12 @@ import {
listPlanScheduledTasks, listPlanScheduledTasks,
mapPlanScheduledTaskRow, mapPlanScheduledTaskRow,
registerPlanScheduledTaskNow, registerPlanScheduledTaskNow,
syncManagedServiceGenerationCompletion,
updatePlanScheduledTask, updatePlanScheduledTask,
updatePlanScheduledTaskSchema, updatePlanScheduledTaskSchema,
} from '../services/plan-schedule-service.js'; } from '../services/plan-schedule-service.js';
import { getVisitorClientByClientId } from '../services/visitor-history-service.js'; import { getVisitorClientByClientId } from '../services/visitor-history-service.js';
import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js';
const completeActionSchema = z.object({ const completeActionSchema = z.object({
note: z.string().trim().min(1).optional(), note: z.string().trim().min(1).optional(),
@@ -164,11 +166,25 @@ export async function registerPlanRoutes(app: FastifyInstance) {
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {}); const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
const row = await createPlanScheduledTask(payload); const row = await createPlanScheduledTask(payload);
const immediateRegistration = payload.enabled && payload.immediateRunEnabled ? await registerPlanScheduledTaskNow(Number(row.id)) : null; const ignoreScheduleDueForImmediateRegistration =
payload.repeatWindows.length === 0
&& payload.scheduleWeekdays.length === 0
&& payload.scheduleDateRanges.length === 0;
const immediateRegistration =
payload.executionMode === 'managed-service' && payload.recreateManagedServiceOnNextSave
? await registerPlanScheduledTaskNow(Number(row.id), new Date(), {
forceManagedServiceGeneration: true,
})
: payload.enabled && payload.immediateRunEnabled
? await registerPlanScheduledTaskNow(Number(row.id), new Date(), {
ignoreScheduleDue: ignoreScheduleDueForImmediateRegistration,
})
: null;
const latestRow = await getPlanScheduledTaskById(Number(row.id));
return { return {
ok: true, ok: true,
item: mapPlanScheduledTaskRow(row), item: mapPlanScheduledTaskRow(latestRow ?? row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null, registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [], registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
}; };
@@ -210,16 +226,44 @@ export async function registerPlanRoutes(app: FastifyInstance) {
}); });
} }
const shouldTriggerImmediateRegistration = const shouldTriggerImmediateRegistration = (
row && row
Boolean(row.enabled ?? true) && && String(row.execution_mode ?? '') === 'managed-service'
Boolean(row.immediate_run_enabled ?? true) && && payload.recreateManagedServiceOnNextSave === true
payload.enabled !== false; ) || (
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null; row
&& Boolean(row.enabled ?? true)
&& Boolean(row.immediate_run_enabled ?? true)
&& payload.enabled !== false
);
const effectiveRepeatWindows =
payload.repeatWindows !== undefined
? payload.repeatWindows
: (row ? mapPlanScheduledTaskRow(row).repeatWindows : []);
const effectiveScheduleDateRanges =
payload.scheduleDateRanges !== undefined
? payload.scheduleDateRanges
: (row ? mapPlanScheduledTaskRow(row).scheduleDateRanges : []);
const effectiveScheduleWeekdays =
payload.scheduleWeekdays !== undefined
? payload.scheduleWeekdays
: (row ? mapPlanScheduledTaskRow(row).scheduleWeekdays : []);
const immediateRegistration = shouldTriggerImmediateRegistration
? await registerPlanScheduledTaskNow(id, new Date(), {
ignoreScheduleDue:
payload.recreateManagedServiceOnNextSave === true
? false
: effectiveRepeatWindows.length === 0
&& effectiveScheduleWeekdays.length === 0
&& effectiveScheduleDateRanges.length === 0,
forceManagedServiceGeneration: payload.recreateManagedServiceOnNextSave === true,
})
: null;
const latestRow = await getPlanScheduledTaskById(Number(id));
return { return {
ok: true, ok: true,
item: mapPlanScheduledTaskRow(row), item: mapPlanScheduledTaskRow(latestRow ?? row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null, registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [], registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
}; };
@@ -547,6 +591,8 @@ export async function registerPlanRoutes(app: FastifyInstance) {
}); });
} }
await syncManagedServiceGenerationCompletion(id);
const planLabel = formatPlanNotificationLabel(String(row.work_id), id); const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent( await notifyPlanEvent(
id, id,
@@ -554,6 +600,7 @@ export async function registerPlanRoutes(app: FastifyInstance) {
'수동 작업완료로 release 반영 대기 상태가 되었습니다.', '수동 작업완료로 release 반영 대기 상태가 되었습니다.',
'development-completed', 'development-completed',
); );
await progressBoardPostAutomationByPlanResult(id, 'completed');
return { return {
ok: true, ok: true,
@@ -576,6 +623,8 @@ export async function registerPlanRoutes(app: FastifyInstance) {
}); });
} }
await syncManagedServiceGenerationCompletion(id);
const planLabel = formatPlanNotificationLabel(String(row.work_id), id); const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent( await notifyPlanEvent(
id, id,
@@ -583,6 +632,7 @@ export async function registerPlanRoutes(app: FastifyInstance) {
payload.note ?? '작업이 완료 처리되었습니다.', payload.note ?? '작업이 완료 처리되었습니다.',
'plan-completed', 'plan-completed',
); );
await progressBoardPostAutomationByPlanResult(id, 'completed');
return { return {
ok: true, ok: true,
@@ -719,8 +769,12 @@ export async function registerPlanRoutes(app: FastifyInstance) {
try { try {
const env = getEnv(); const env = getEnv();
const isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패'; const isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패';
const sourceWorkCount = Math.max(0, Number(item.usageSnapshot?.sourceWorkCount ?? 0) || 0);
const requiresRollbackBeforeCancel =
sourceWorkCount > 0 &&
(item.status === '릴리즈완료' || item.workerStatus === 'main반영실패');
if (!isReleaseMergeFailure) { if (!isReleaseMergeFailure && requiresRollbackBeforeCancel) {
await recreateReleaseBranchFromMain( await recreateReleaseBranchFromMain(
{ {
repoPath: env.PLAN_GIT_REPO_PATH, repoPath: env.PLAN_GIT_REPO_PATH,

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,303 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import {
copyResourceManagerItem,
createResourceManagerDirectory,
createResourceManagerFile,
deleteResourceManagerItem,
ensureResourceManagerRoot,
getResourceManagerTree,
listResourceManagerDirectory,
moveResourceManagerItem,
openResourceManagerPreviewStream,
readResourceManagerFile,
saveResourceManagerFile,
uploadResourceManagerFile,
} from '../services/resource-manager-service.js';
const queryPathSchema = z.object({
path: z.string().trim().optional().default(''),
});
const createDirectoryBodySchema = z.object({
parentPath: z.string().trim().optional().default(''),
name: z.string().trim().min(1).max(255),
});
const createFileBodySchema = z.object({
parentPath: z.string().trim().optional().default(''),
name: z.string().trim().min(1).max(255),
content: z.string().optional().default(''),
});
const saveFileBodySchema = z.object({
path: z.string().trim().min(1),
content: z.string(),
});
const uploadFileBodySchema = z.object({
parentPath: z.string().trim().optional().default(''),
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().trim().min(1),
});
const copyMoveBodySchema = z.object({
path: z.string().trim().min(1),
targetDirectoryPath: z.string().trim().optional().default(''),
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();
}
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply, tokenOverride?: string | null) {
if ((tokenOverride ?? getRequestAccessToken(request)) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
}
reply.status(403);
void reply.send({
message: '권한 토큰이 필요합니다.',
});
return false;
}
function resolveRepoRootPath() {
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
}
export async function registerResourceManagerRoutes(app: FastifyInstance) {
app.get('/api/resource-manager/tree', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const repoRootPath = resolveRepoRootPath();
await ensureResourceManagerRoot(repoRootPath);
return {
ok: true,
item: await getResourceManagerTree(repoRootPath),
};
});
app.get('/api/resource-manager/directory', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const query = queryPathSchema.parse(request.query ?? {});
return {
ok: true,
item: await listResourceManagerDirectory(resolveRepoRootPath(), query.path),
};
});
app.get('/api/resource-manager/file', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const query = queryPathSchema.extend({
path: z.string().trim().min(1),
}).parse(request.query ?? {});
return {
ok: true,
item: await readResourceManagerFile(resolveRepoRootPath(), query.path),
};
});
app.get('/api/resource-manager/preview/*', async (request, reply) => {
const query = z.object({
token: z.string().trim().optional(),
}).parse(request.query ?? {});
if (!ensureAuthorized(request, reply, query.token ?? null)) {
return;
}
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);
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) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = createDirectoryBodySchema.parse(request.body ?? {});
await createResourceManagerDirectory(resolveRepoRootPath(), payload.parentPath, payload.name);
return {
ok: true,
};
});
app.post('/api/resource-manager/files', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = createFileBodySchema.parse(request.body ?? {});
await createResourceManagerFile(resolveRepoRootPath(), payload.parentPath, payload.name, payload.content);
return {
ok: true,
};
});
app.put('/api/resource-manager/files/content', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = saveFileBodySchema.parse(request.body ?? {});
await saveResourceManagerFile(resolveRepoRootPath(), payload.path, payload.content);
return {
ok: true,
};
});
app.post('/api/resource-manager/files/upload', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = uploadFileBodySchema.parse(request.body ?? {});
return {
ok: true,
item: await uploadResourceManagerFile(
resolveRepoRootPath(),
payload.parentPath,
payload.fileName,
payload.contentBase64,
),
};
});
app.post('/api/resource-manager/items/copy', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = copyMoveBodySchema.parse(request.body ?? {});
return {
ok: true,
item: await copyResourceManagerItem(
resolveRepoRootPath(),
payload.path,
payload.targetDirectoryPath,
payload.nextName,
),
};
});
app.post('/api/resource-manager/items/move', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = copyMoveBodySchema.parse(request.body ?? {});
return {
ok: true,
item: await moveResourceManagerItem(
resolveRepoRootPath(),
payload.path,
payload.targetDirectoryPath,
payload.nextName,
),
};
});
app.delete('/api/resource-manager/items', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const query = queryPathSchema.extend({
path: z.string().trim().min(1),
}).parse(request.query ?? {});
await deleteResourceManagerItem(resolveRepoRootPath(), query.path);
return {
ok: true,
};
});
}

View File

@@ -0,0 +1,55 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getActiveChatService } from '../services/chat-service.js';
import {
beginRuntimeDrain,
endRuntimeDrain,
getRuntimeDrainSnapshot,
} from '../services/runtime-drain-service.js';
const runtimeDrainBodySchema = z.object({
draining: z.boolean(),
});
function buildRuntimeResponse() {
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
return {
ok: true,
...getRuntimeDrainSnapshot(),
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
};
}
export async function registerRuntimeRoutes(app: FastifyInstance) {
app.get('/api/runtime', async () => buildRuntimeResponse());
app.post('/api/runtime/recover-interrupted-chat', async () => {
const recovered = await getActiveChatService()?.recoverInterruptedSessions();
return {
ok: true,
recovered: recovered ?? {
sessionCount: 0,
restartedCount: 0,
requeuedCount: 0,
},
};
});
app.post('/api/runtime/drain', async (request) => {
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});
if (draining) {
beginRuntimeDrain();
} else {
endRuntimeDrain();
}
return buildRuntimeResponse();
});
}

0
etc/servers/work-server/src/routes/schema.ts Executable file → Normal file
View File

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