Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e6e73dbd5 | |||
| 737ab0a34a | |||
| ffbdbf46b6 | |||
| 5b3e70910c | |||
| 262ce4b627 | |||
| b242d91ecb | |||
| 1e7212b862 | |||
| 753fd423db | |||
| c7f29bdc33 | |||
| a97d933cff | |||
| b1bec9cb6f | |||
| bb275c0534 | |||
| 82c46f4be4 | |||
| 983887dc05 | |||
| e195ac8088 | |||
| 10805d242e | |||
| e8a628ac34 | |||
| 58c5a7cfee | |||
| 26220577fc | |||
| 4984d74d39 | |||
| 215648bd8d | |||
| 4a88d3f430 | |||
| 7e9c3bd097 | |||
| 4c4b3c8d2c | |||
| c1d0f4c1db | |||
| 51e0099bea | |||
| f59522ffc4 | |||
| fb5ec649cd | |||
| d38d022872 | |||
| 442879313f | |||
| 82c0d8a197 | |||
| 2df0ba30cb | |||
| 42ae640470 | |||
| 20a6333ed2 | |||
| 63e5d263a7 | |||
| d53532508b | |||
| c07b0b12af | |||
| b016951cd4 | |||
| 91230304e0 | |||
| f2d6310efa |
@@ -1,6 +1,7 @@
|
||||
.git
|
||||
.auto_codex
|
||||
.docker
|
||||
etc/servers/work-server/.docker
|
||||
.idea
|
||||
.vscode
|
||||
node_modules
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
NODE_VERSION=22.22.2
|
||||
CAPTURE_BASE_URL=https://test.sm-home.cloud/
|
||||
CAPTURE_BASE_URL=https://preview.sm-home.cloud/
|
||||
CAPTURE_REGISTERED_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
|
||||
VITE_ALLOWED_REGISTRATION_TOKEN=usr_7f3a9c2d8e1b4a6f
|
||||
PHOTOPRISM_PORT=2342
|
||||
|
||||
10
.githooks/pre-commit
Executable file
10
.githooks/pre-commit
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
. "$HOME/.nvm/nvm.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
node scripts/guard-staged-assets.mjs
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -16,12 +16,21 @@ playwright-report/
|
||||
test-results/
|
||||
.cache/
|
||||
tmp/
|
||||
.tmp-*/
|
||||
node_modules.root-owned-backup/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
.tmp
|
||||
!.env.example
|
||||
tmp-*.png
|
||||
tmp-*.jpg
|
||||
tmp-*.jpeg
|
||||
tmp-*.webp
|
||||
tmp-*.gif
|
||||
tmp-*.mp4
|
||||
tmp-*.mov
|
||||
tmp-*.webm
|
||||
|
||||
# etc workspace
|
||||
etc/**/.env
|
||||
@@ -42,3 +51,5 @@ vite.config.d.ts
|
||||
public/.codex_chat
|
||||
.server-command-runner-heartbeat.json
|
||||
docs/assets/worklogs/
|
||||
resource/Codex Live/
|
||||
resource/To-Do List/
|
||||
|
||||
1
.tmp-chatshare-full.json
Normal file
1
.tmp-chatshare-full.json
Normal file
File diff suppressed because one or more lines are too long
1
.tmp-chatshare-initial.json
Normal file
1
.tmp-chatshare-initial.json
Normal file
File diff suppressed because one or more lines are too long
49
AGENTS.md
49
AGENTS.md
@@ -9,21 +9,24 @@
|
||||
### Codex / AI 기본 규칙
|
||||
|
||||
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
|
||||
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다
|
||||
* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env`의 `CAPTURE_BASE_URL=https://test.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다
|
||||
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://preview.sm-home.cloud/` 기준으로 사용**한다
|
||||
* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env`의 `CAPTURE_BASE_URL=https://preview.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다
|
||||
* 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다
|
||||
* `test.sm-home.cloud` nginx 프록시는 **화면 `/`만 `5174` 앱 테스트 서버로 보내고, `/api/`와 `/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다
|
||||
* `test.sm-home.cloud`의 `/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다
|
||||
* 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다
|
||||
* `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
|
||||
* `자동화 작업메모`, `자동화 메모`, 자동화 접수된 작업메모는 **항상 신규 `feature/*` 브랜치를 생성해 작업**하고, 이후 `release` 반영과 `main` 일괄반영까지 진행한다
|
||||
* 자동화 메모 작업을 실제 진행할 때 **현재 작업중인 자동화가 없다면**, 자동화 작업공간의 `release` 브랜치를 먼저 `main` 기준 최신 상태로 맞춘 뒤 다음 자동화 작업을 시작한다
|
||||
* 자동화 작업메모의 `main` 일괄반영이 끝나면 **프로젝트 루트에서 최신 `main`을 `pull --ff-only`로 동기화**한다
|
||||
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
|
||||
* 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다
|
||||
* Git 작업을 수행하더라도 `public/assets/` 아래 대용량 리소스, `tmp-*` 캡처 파일, 임시 산출물은 기본적으로 커밋 대상에 포함하지 않는다
|
||||
* 이미지/동영상/PDF 같은 바이너리 자산이 정말 필요할 때만 예외적으로 커밋하고, 그 외에는 코드 변경과 분리해 별도 확인 후 처리한다
|
||||
* 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다
|
||||
* 현재는 브랜치 전략보다 **로컬 실행 가능 상태 유지, 코드 수정, 문서 갱신, 메모 반영 속도**를 우선한다
|
||||
* `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다
|
||||
* 사용자가 **명시적으로 요청한 경우를 제외하면** 구현 편의나 상태 갱신을 이유로 `polling`, `setInterval`, 주기적 재시도 루프 같은 반복 조회 구조를 추가하거나 유지하지 않는다
|
||||
* 기존 기능에 `polling`, `setInterval`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다
|
||||
* `work-server` 재기동이나 배포 절차는 **기존 연결을 끊는 단일 컨테이너 재시작 방식이 아니라, blue/green 슬롯 전환 기반 무중단 절차를 기본 규칙으로 사용**한다
|
||||
* `work-server` 관련 문서, 스크립트, 운영 안내를 수정할 때는 **비활성 슬롯 기동 → health 확인 → 프록시 전환 → 이전 슬롯 정리** 순서를 유지하고, 연결이 끊기는 재시작을 기본 절차처럼 적지 않는다
|
||||
|
||||
### 요청 해석 규칙
|
||||
|
||||
@@ -31,7 +34,7 @@
|
||||
* 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다
|
||||
* 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다
|
||||
* 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다
|
||||
* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석하고, 신규 `feature/*` 브랜치 생성부터 `release` 반영, `main` 일괄반영, 프로젝트 루트 `pull`까지 포함한 흐름을 적용한다
|
||||
* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석한다
|
||||
* 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다
|
||||
* 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다
|
||||
* 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다
|
||||
@@ -43,6 +46,7 @@
|
||||
* 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다
|
||||
* `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다
|
||||
* 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다
|
||||
* 임시 스크린샷, 테스트 캡처, 대용량 리소스 파일은 기본적으로 Git 커밋을 차단하고, 의도적 자산 커밋일 때만 명시적으로 예외 처리한다
|
||||
* 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다
|
||||
|
||||
---
|
||||
@@ -50,35 +54,48 @@
|
||||
## Codex Live / 채팅 / 작업 메모 규칙
|
||||
|
||||
* `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
|
||||
* 자동화 작업메모 반영 요청은 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull`** 순서를 기본으로 처리한다
|
||||
* 자동화 작업메모를 시작하기 직전에 **진행 중인 자동화가 없으면**, 자동화 작업공간의 `release`를 `main`과 먼저 맞춘 뒤 `feature/*` 작업을 시작한다
|
||||
* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다
|
||||
* 외부 도메인 기준 동작 확인과 최종 검증은 기본적으로 `https://preview.sm-home.cloud/`를 우선 기준으로 본다
|
||||
* `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인한다
|
||||
* `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다
|
||||
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
|
||||
* 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다
|
||||
* 자동화 작업메모는 Git flow를 기본으로 적용하며, 일반 채팅/수동 작업은 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다
|
||||
* 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다
|
||||
* 채팅 첨부 파일은 `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`에 주입한 상태로 캡처한다
|
||||
* 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다
|
||||
* `/play/apps` 아래에서 실행되는 앱 화면은 기본적으로 부모 앱 헤더를 다시 노출하지 말고, 개별 앱 콘텐츠가 화면을 가득 채우는 레이아웃을 우선 적용한다
|
||||
|
||||
---
|
||||
|
||||
## Plan / 자동화 메모
|
||||
|
||||
* 일반 수동 작업에는 여전히 로컬 `main` 직접 수정 원칙을 유지한다
|
||||
* 다만 자동화 작업메모와 Plan 자동화에는 기존 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 기본 규칙으로 다시 적용한다
|
||||
* 추가로 자동화 큐가 비어 있는 시점에 새 자동화 메모 작업을 시작하면, 자동화 작업공간의 `release`를 `main`과 동기화한 뒤 같은 흐름을 이어간다
|
||||
* `hotfix/*` 흐름은 기존 예외 규칙을 유지하고, 자동화 대상이 아닌 일반 요청에는 자동 적용하지 않는다
|
||||
* 자동화 작업메모와 Plan 자동화도 별도 Git 흐름을 기본 전제로 문서화하지 않는다
|
||||
* 자동화 처리 세부 절차가 필요하면 해당 기능 문서나 서버 설정이 아니라, 실제 요청 문맥과 현재 운영 설정을 기준으로 다시 확인한다
|
||||
|
||||
---
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다
|
||||
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다
|
||||
👉 자동화가 비어 있으면 다음 자동화 시작 전 작업공간 `release`를 `main`과 먼저 맞춘다
|
||||
👉 자동화 작업메모는 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 탄다
|
||||
👉 외부 확인과 검증 기본 도메인은 `https://preview.sm-home.cloud/`다
|
||||
👉 자동화 브랜치 흐름은 문서에 고정 규칙으로 적지 않는다
|
||||
👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
|
||||
|
||||
---
|
||||
|
||||
26
README.md
Executable file → Normal file
26
README.md
Executable file → Normal file
@@ -8,8 +8,10 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입
|
||||
- Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다.
|
||||
- Codex나 자동화 도구는 기본적으로 Git 작업 없이 **현재 프로젝트 루트의 `main` 작업본을 바로 수정**합니다.
|
||||
- `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
|
||||
- 단, 자동화 접수된 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름을 사용합니다.
|
||||
- 자동화 접수된 작업메모도 별도 브랜치 흐름을 이 문서에 고정하지 않고, 실제 운영 설정과 요청 문맥을 기준으로 처리합니다.
|
||||
- 채팅 리소스와 첨부 파일은 `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 컨테이너
|
||||
|
||||
실제 반영 화면을 확인할 때는 바인드 마운트 없이 별도 이미지로 빌드하는 `docker-compose.preview.yml`을 사용합니다.
|
||||
실제 반영 화면을 확인할 때는 별도 preview 컨테이너를 사용합니다.
|
||||
|
||||
```bash
|
||||
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 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다.
|
||||
|
||||
@@ -35,7 +39,8 @@ docker compose -f docker-compose.preview.yml up -d --build
|
||||
|
||||
- 테스트용 컨테이너는 현재 `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`만 재기동합니다.
|
||||
- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다.
|
||||
@@ -93,6 +98,7 @@ src/
|
||||
│ ├─ status-badge/ # 상태 표현 UI
|
||||
│ └─ window/ # 드래그/리사이즈 가능한 윈도우 UI
|
||||
├─ features/
|
||||
│ ├─ board/ # 작업 요청 게시판과 자동화 접수 화면
|
||||
│ ├─ dashboard/ # 프로젝트 전용 대시보드 샘플
|
||||
│ ├─ layout/ # 프로젝트 전용 레이아웃 문서
|
||||
│ ├─ markdownPreview/ # 기능 레벨 Markdown 카드
|
||||
@@ -113,6 +119,7 @@ docs/
|
||||
- `APIs / Components`: 공통 컴포넌트 샘플 탐색
|
||||
- `APIs / Widgets`: 위젯 샘플 탐색
|
||||
- `Docs`: `docs/**/*.md`와 일부 `src/features/**/*.md` 문서 탐색
|
||||
- `Plans / 작업 요청`: 게시글 1건 안에 여러 하위 요청을 묶고 자동화 접수를 추적
|
||||
- `Plans`: 작업 항목, 조치 이력, 이슈 이력을 관리하는 Plan 게시판
|
||||
|
||||
## 문서 위치
|
||||
@@ -120,7 +127,16 @@ docs/
|
||||
- 전체 문서 가이드: `docs/README.md`
|
||||
- 작업일지: `docs/worklogs`
|
||||
- 기능 문서: `docs/features`
|
||||
- 작업 요청 기능 문서: `docs/features/work-request-board.md`
|
||||
- 컴포넌트 문서: `docs/components`
|
||||
- 공통 컴포넌트 패키지 가이드: `src/components/README.md`
|
||||
- 공통 위젯 패키지 가이드: `src/widgets/README.md`
|
||||
|
||||
## 공통 패키지 문서 규칙
|
||||
|
||||
- `src/components`, `src/widgets`처럼 여러 화면에서 공통 재사용되는 패키지에는 해당 패키지 하위 `README.md`를 둡니다.
|
||||
- 패키지 하위 문서에는 최소한 목적, 하위 구조, export 또는 registry 기준점, 샘플 및 문서 연결 규약을 적습니다.
|
||||
- 컴포넌트 또는 위젯 구조를 바꾸면 구현 파일만 수정하지 말고 해당 패키지 `README.md`와 관련 `docs/components/*.md`도 함께 점검합니다.
|
||||
|
||||
## 운영 메모
|
||||
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
services:
|
||||
preview-app:
|
||||
container_name: ai-code-app-preview
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.preview
|
||||
image: node:${NODE_VERSION:-22.22.2}-bookworm
|
||||
user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "${PREVIEW_APP_PORT:-4173}:5173"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- preview-app-hidden-dotdocker:/app/.docker
|
||||
- preview-app-hidden-etc-servers:/app/etc/servers
|
||||
- ./.docker/preview-app/node_modules:/app/node_modules
|
||||
- ./.docker/preview-app/home:/home/how2ice
|
||||
networks:
|
||||
- default
|
||||
- work-backend
|
||||
environment:
|
||||
HOME: /home/how2ice
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
PORT: 5173
|
||||
APP_DIST_DIR: /tmp/ai-code-test-app-dist
|
||||
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://host.docker.internal:3100}
|
||||
WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100}
|
||||
VITE_DISABLE_APP_UPDATE: "true"
|
||||
VITE_PUBLIC_HMR_HOST: preview.sm-home.cloud
|
||||
VITE_PUBLIC_HMR_PROTOCOL: wss
|
||||
VITE_PUBLIC_HMR_CLIENT_PORT: 443
|
||||
VITE_DISABLE_PWA: "true"
|
||||
command: >
|
||||
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
preview-app-hidden-dotdocker:
|
||||
preview-app-hidden-etc-servers:
|
||||
|
||||
networks:
|
||||
work-backend:
|
||||
external: true
|
||||
|
||||
70
docker-compose.yml
Executable file → Normal file
70
docker-compose.yml
Executable file → Normal file
@@ -1,63 +1,4 @@
|
||||
services:
|
||||
photoprism:
|
||||
image: photoprism/photoprism:latest
|
||||
container_name: photoprism
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
depends_on:
|
||||
- photoprism-db
|
||||
ports:
|
||||
- '127.0.0.1:${PHOTOPRISM_PORT:-2342}:2342'
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PHOTOPRISM_ORIGINALS_SOURCE:-/mnt/usb/photos}
|
||||
target: /photoprism/originals
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- photoprism-storage:/photoprism/storage
|
||||
environment:
|
||||
PHOTOPRISM_ADMIN_USER: ${PHOTOPRISM_ADMIN_USER:-admin}
|
||||
PHOTOPRISM_ADMIN_PASSWORD: ${PHOTOPRISM_ADMIN_PASSWORD:-ChangeMe1234}
|
||||
PHOTOPRISM_SITE_URL: ${PHOTOPRISM_SITE_URL:-https://photo.sm-home.cloud/}
|
||||
PHOTOPRISM_ORIGINALS_PATH: /photoprism/originals
|
||||
PHOTOPRISM_STORAGE_PATH: /photoprism/storage
|
||||
PHOTOPRISM_READONLY: ${PHOTOPRISM_READONLY:-true}
|
||||
PHOTOPRISM_DATABASE_DRIVER: mysql
|
||||
PHOTOPRISM_DATABASE_SERVER: photoprism-db:3306
|
||||
PHOTOPRISM_DATABASE_NAME: ${PHOTOPRISM_DATABASE_NAME:-photoprism}
|
||||
PHOTOPRISM_DATABASE_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism}
|
||||
PHOTOPRISM_DATABASE_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
- work-backend
|
||||
|
||||
photoprism-db:
|
||||
image: mariadb:11
|
||||
container_name: photoprism-db
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
command: --innodb-buffer-pool-size=512M --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
environment:
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
MARIADB_DATABASE: ${PHOTOPRISM_DATABASE_NAME:-photoprism}
|
||||
MARIADB_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism}
|
||||
MARIADB_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism}
|
||||
MARIADB_ROOT_PASSWORD: ${PHOTOPRISM_DATABASE_ROOT_PASSWORD:-photoprism-root}
|
||||
volumes:
|
||||
- photoprism-db:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
- work-backend
|
||||
|
||||
prod-app:
|
||||
image: node:${NODE_VERSION:-22.22.2}-bookworm
|
||||
container_name: ai-code-app-prod
|
||||
@@ -125,12 +66,12 @@ services:
|
||||
user: "0:0"
|
||||
cpus: 1.0
|
||||
mem_limit: 1536m
|
||||
working_dir: /release-app
|
||||
working_dir: /workspace/auto_codex/repo
|
||||
ports:
|
||||
- '127.0.0.1:5175:5173'
|
||||
volumes:
|
||||
- ${RELEASE_APP_SOURCE:-.}:/release-app
|
||||
- ./.docker/release-app/node_modules:/release-app/node_modules
|
||||
- ./.auto_codex/repo:/workspace/auto_codex/repo
|
||||
- ./.docker/release-app/node_modules:/workspace/auto_codex/repo/node_modules
|
||||
- ./.docker/release-app/home:/home/how2ice
|
||||
networks:
|
||||
- default
|
||||
@@ -138,6 +79,9 @@ services:
|
||||
environment:
|
||||
HOME: /home/how2ice
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
VITE_PUBLIC_HMR_HOST: rel.sm-home.cloud
|
||||
VITE_PUBLIC_HMR_PROTOCOL: wss
|
||||
VITE_PUBLIC_HMR_CLIENT_PORT: 443
|
||||
command: >
|
||||
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
|
||||
@@ -148,5 +92,3 @@ networks:
|
||||
volumes:
|
||||
app-node-modules:
|
||||
app-home:
|
||||
photoprism-storage:
|
||||
photoprism-db:
|
||||
|
||||
183
docs/README.md
Executable file → Normal file
183
docs/README.md
Executable file → Normal file
@@ -1,156 +1,53 @@
|
||||
# Docs Guide
|
||||
# 프로젝트 구조
|
||||
|
||||
프로젝트 문서는 작업일지, 기능 문서, 컴포넌트 문서를 기본 축으로 운영합니다. 현재 메인 앱 `Docs` 화면은 `docs/**/*.md`를 동적으로 수집해 폴더별로 노출합니다.
|
||||
이 문서는 현재 저장소의 큰 구조만 빠르게 확인하기 위한 기준 문서입니다. `Docs` 화면도 이 문서만 기본으로 읽으며, 채팅/자동화용 세부 context는 각 관리 화면에서 개별 항목으로 관리합니다.
|
||||
|
||||
## 0. 임시 로컬 모드
|
||||
|
||||
- 현재 저장소는 당분간 로컬 전용으로 운영합니다.
|
||||
- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다.
|
||||
- `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
|
||||
- 자동화 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름으로 처리합니다.
|
||||
- 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
|
||||
src/components
|
||||
├─ markdownPreview
|
||||
├─ navigation
|
||||
├─ previewer
|
||||
├─ search
|
||||
├─ status-badge
|
||||
└─ window
|
||||
src/
|
||||
docs/
|
||||
etc/
|
||||
public/
|
||||
scripts/
|
||||
```
|
||||
|
||||
공통 플러그인 타입과 유틸은 `src/types/component-plugin.ts` 에서 관리합니다.
|
||||
- `src`: 메인 프런트엔드 소스
|
||||
- `docs`: 작업 템플릿과 작업일지 같은 보조 문서
|
||||
- `etc`: work-server, DB, 운영 보조 리소스
|
||||
- `public`: 정적 파일과 채팅 세션 리소스
|
||||
- `scripts`: 개발/운영 스크립트
|
||||
|
||||
샘플 운영 규칙:
|
||||
## 프런트엔드 구조
|
||||
|
||||
- `samples/Sample.tsx`는 해당 컴포넌트의 가장 기본형만 표현
|
||||
- plugin/feature 예시는 `samples/*.tsx`로 분리
|
||||
- 샘플 목록에서는 같은 컴포넌트 기준으로 묶고 `base -> plugin -> feature` 순서로 정렬
|
||||
```text
|
||||
src
|
||||
├─ 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 게시판
|
||||
## 문서 노출 기준
|
||||
|
||||
프로젝트 종속 기능 규칙:
|
||||
|
||||
- 현재 프로젝트에서만 의미 있는 화면/기능은 `src/features` 아래에 둠
|
||||
- 예: `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` 참고
|
||||
- 앱 `Docs` 메뉴는 구조 확인용 문서만 노출합니다.
|
||||
- 작업일지, 템플릿, 과거 설계 메모는 저장소에 남길 수 있어도 기본 문서 목록에서는 제외합니다.
|
||||
- 채팅 유형 context와 자동화 유형 context는 공용 문서가 아니라 각 관리 데이터에서 직접 관리합니다.
|
||||
|
||||
7
docs/components/check-combo.md
Executable file → Normal file
7
docs/components/check-combo.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
`code/value` 데이터를 받아 여러 항목을 체크박스 형태로 선택하고, 실제 값은 `code[]`로 유지하는 다중 선택 combo 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
7
docs/components/codex-diff-previewer.md
Executable file → Normal file
7
docs/components/codex-diff-previewer.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
변경 파일의 전체 소스와 raw diff를 codex 스타일 아코디언으로 함께 보여주는 공통 preview 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -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
7
docs/components/evidence-attachment-strip-ui.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
Plan/Board 계열 화면에서 반복되는 산출물 카드, 링크, 미리보기 진입 UI를 공통 스트립으로 정리하는 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 지원 타입
|
||||
|
||||
- `image`
|
||||
|
||||
7
docs/components/input.md
Executable file → Normal file
7
docs/components/input.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
Ant Design `Input`을 기반으로 하되, 타이핑 중에는 내부 상태만 변경하고 `Enter` 또는 `blur` 시점에만 외부 `onChange`를 호출하는 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
7
docs/components/popup.md
Executable file → Normal file
7
docs/components/popup.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
`[input][button][readonly input]` 형태로 검색어 입력, 버튼 액션, 선택 결과 표시를 한 줄에서 처리하는 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
7
docs/components/previewer-ui.md
Executable file → Normal file
7
docs/components/previewer-ui.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
다양한 데이터를 공통 카드 형태로 미리보기할 수 있는 previewer 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 지원 타입
|
||||
|
||||
- `text`
|
||||
|
||||
7
docs/components/process-flow-ui.md
Executable file → Normal file
7
docs/components/process-flow-ui.md
Executable file → Normal file
@@ -5,6 +5,13 @@
|
||||
Plan, Board, History 화면에서 공통으로 사용할 수 있는 단계형 진행 표시 컴포넌트입니다.
|
||||
현재 단계, 완료 단계, 실패 단계, 다음 대기 단계를 한 번에 보여주며 가로/세로 배치와 compact 모드를 지원합니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
7
docs/components/search-command.md
Executable file → Normal file
7
docs/components/search-command.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
문서, API, 컴포넌트, 위젯을 키워드로 빠르게 찾고 바로 이동할 수 있는 통합 검색 모달입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트와 위젯에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트와 위젯은 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트와 위젯은 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- `AutoComplete` 기반 추천 드롭다운
|
||||
|
||||
7
docs/components/select.md
Executable file → Normal file
7
docs/components/select.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
`code/value` 데이터를 받아 실제 값은 `code`로 유지하고, 드롭다운 표시와 검색은 `value` 기준으로 처리하는 select combo 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
7
docs/components/status-badge.md
Executable file → Normal file
7
docs/components/status-badge.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
상태 값을 간단한 UI 배지로 표현하는 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
7
docs/components/stepper.md
Executable file → Normal file
7
docs/components/stepper.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
여러 단계를 순서대로 표시하고 현재 진행 위치를 강조하는 stepper 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 구현 위치
|
||||
|
||||
```text
|
||||
|
||||
7
docs/components/window-ui.md
Executable file → Normal file
7
docs/components/window-ui.md
Executable file → Normal file
@@ -4,6 +4,13 @@
|
||||
|
||||
부모 영역 안에서 이동 가능한 모달 스타일 윈도우를 제공합니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 헤더 작업줄 드래그 이동
|
||||
|
||||
12
docs/features/plan-automation.md
Executable file → Normal file
12
docs/features/plan-automation.md
Executable file → Normal file
@@ -4,7 +4,7 @@
|
||||
|
||||
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
|
||||
|
||||
현재 운영 규칙에서 자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치에서 시작하며, `release` 반영과 `main` 일괄반영, 프로젝트 루트 `pull --ff-only`까지 이어집니다. 또한 새 자동화 메모 작업을 시작할 때 현재 작업중인 자동화가 없다면, 자동화 작업공간의 `release`를 먼저 `main` 기준 최신 상태로 맞춘 뒤 다음 작업을 진행합니다. 반면 `Codex Live`나 일반 수동 요청은 여전히 로컬 `main` 직접 수정 기준을 유지합니다.
|
||||
자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비하고, 작업 결과를 `release` 반영 후 `main`까지 반영하는 흐름입니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다.
|
||||
|
||||
## 구현 위치
|
||||
|
||||
@@ -38,6 +38,12 @@ Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연
|
||||
|
||||
기존 저장값인 `plan_registration`, `general_development`는 서버에서 각각 `plan`, `auto_worker`로 정규화합니다.
|
||||
|
||||
자동화 context 해석 규칙:
|
||||
|
||||
- 자동화 실행 시 기본 문맥은 선택된 자동화 유형 description/context만 사용
|
||||
- `Codex Live` 채팅 문맥, 일반 채팅 문맥은 자동화 기본 context로 섞지 않음
|
||||
- 추가 지시가 필요하면 요청 본문에 명시적으로 적어 전달
|
||||
|
||||
## API 연동 방식
|
||||
|
||||
기본 API 베이스 URL 규칙:
|
||||
@@ -116,9 +122,7 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수
|
||||
|
||||
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
|
||||
|
||||
자동화 메모 작업을 시작하는 시점에 진행 중인 다른 자동화가 없다면, worker는 먼저 자동화 작업공간의 `release`를 `main`과 맞춰 기준 브랜치를 정리한 뒤 새 `feature/*` 작업을 시작해야 합니다. 이 단계는 유휴 상태에서만 수행하고, 이미 다른 자동화가 돌고 있는 동안에는 중간 기준 브랜치를 임의로 재정렬하지 않습니다.
|
||||
|
||||
자동화 작업메모가 `main` 반영 단계까지 끝나면 worker는 `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`를 수행해 실제 작업본을 최신 `main`으로 맞춥니다.
|
||||
자동화 작업의 세부 Git 절차와 예외 처리 순서는 실제 worker 구현과 현재 설정값을 함께 확인합니다.
|
||||
|
||||
## 차트 집계 방식
|
||||
|
||||
|
||||
4
docs/features/plan-board-review.md
Executable file → Normal file
4
docs/features/plan-board-review.md
Executable file → Normal file
@@ -49,7 +49,7 @@
|
||||
2. `작업시작` 또는 자동화에 따라 브랜치 준비와 작업이 진행됩니다.
|
||||
3. 작업이 완료되면 release 반영 상태와 main 반영 대기 여부가 추적됩니다.
|
||||
4. release 검수 화면에서 샘플/위젯/변경 파일을 확인하고 검수 상태를 갱신합니다.
|
||||
5. main 반영이 끝나면 프로젝트 루트 `pull --ff-only`까지 수행한 뒤 최종 완료 흐름으로 정리됩니다.
|
||||
5. 최종 완료 처리는 현재 worker 상태와 운영 설정을 기준으로 정리됩니다.
|
||||
|
||||
## 목록 기능
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
- `작업취소`
|
||||
- `main 일괄 반영 요청`
|
||||
|
||||
자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치로 시작하며, `release` 반영과 `main` 일괄 반영 뒤 프로젝트 루트 동기화까지 포함합니다.
|
||||
자동화 작업메모(`auto_worker`)의 Git 절차는 이 문서에서 고정 규칙으로 설명하지 않습니다. 세부 흐름은 실제 worker 구현과 현재 운영 설정을 확인합니다.
|
||||
|
||||
세부 액션은 `/plan/items/:id/actions/:action` API로 실행됩니다.
|
||||
|
||||
|
||||
14
docs/features/plan-schedule.md
Executable file → Normal file
14
docs/features/plan-schedule.md
Executable file → Normal file
@@ -22,16 +22,22 @@
|
||||
## 입력 항목
|
||||
|
||||
- `workId`: 반복 등록할 작업 ID
|
||||
- 스케줄이 실제 자동화 접수로 Plan을 만들 때 이 값을 베이스 ID로 사용합니다.
|
||||
- 생성되는 Plan 작업 ID는 `workId-1`부터 `workId-999` 범위의 suffix를 붙여 유니크하게 관리합니다.
|
||||
- `note`: 매번 생성될 요청 메모
|
||||
- `automationType`: 자동화 유형
|
||||
- `plan`: Markdown 스타일 Plan 문서 등록/접수
|
||||
- `auto_worker`: 실제 자동 작업 실행
|
||||
- `command_execution`, `non_source_work`: 기존 분류 유지
|
||||
- 자동화 유형 관리를 통해 등록된 항목을 그대로 선택합니다.
|
||||
- 스케줄 실행 시 선택한 자동화 유형 ID를 유지한 채 자동화 작업메모를 등록하고 즉시 접수합니다.
|
||||
- `releaseTarget`: 반영 대상 브랜치
|
||||
- `jangsingProcessingRequired`: 기능동작확인 필요 여부
|
||||
- `autoDeployToMain`: main 자동 반영 대상 여부
|
||||
- `enabled`: 스케줄 사용 여부
|
||||
- `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`로 등록
|
||||
- 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인
|
||||
- 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작
|
||||
- 기존 스케줄 참조 문서를 다시 만들고 싶으면 `refreshContextSnapshotOnNextRun`을 켠 뒤 저장하면 다음 실행 1회 후 자동 해제
|
||||
- 외부 프로그램으로 확장할 예정인 작업은 `managed-service`로 분리해 두면 스케줄 PK 기준 서비스 키와 패키지 경로를 고정할 수 있음
|
||||
- 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤
|
||||
|
||||
## API 경로 메모
|
||||
|
||||
0
docs/features/plan-usage.md
Executable file → Normal file
0
docs/features/plan-usage.md
Executable file → Normal file
0
docs/features/search-layer.md
Executable file → Normal file
0
docs/features/search-layer.md
Executable file → Normal file
71
docs/features/work-request-board.md
Normal file
71
docs/features/work-request-board.md
Normal 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
0
docs/templates/feature-template.md
vendored
Executable file → Normal file
0
docs/templates/worklog-template.md
vendored
Executable file → Normal file
0
docs/templates/worklog-template.md
vendored
Executable file → Normal file
@@ -1 +0,0 @@
|
||||
테스트MD자동 생성 입니다.
|
||||
0
docs/worklogs/2026-03-30.md
Executable file → Normal file
0
docs/worklogs/2026-03-30.md
Executable file → Normal file
0
docs/worklogs/2026-03-31.md
Executable file → Normal file
0
docs/worklogs/2026-03-31.md
Executable file → Normal file
0
docs/worklogs/2026-04-01.md
Executable file → Normal file
0
docs/worklogs/2026-04-01.md
Executable file → Normal file
0
docs/worklogs/2026-04-02.md
Executable file → Normal file
0
docs/worklogs/2026-04-02.md
Executable file → Normal file
0
docs/worklogs/2026-04-03.md
Executable file → Normal file
0
docs/worklogs/2026-04-03.md
Executable file → Normal file
0
docs/worklogs/2026-04-04.md
Executable file → Normal file
0
docs/worklogs/2026-04-04.md
Executable file → Normal file
0
docs/worklogs/2026-04-05.md
Executable file → Normal file
0
docs/worklogs/2026-04-05.md
Executable file → Normal file
0
docs/worklogs/2026-04-06.md
Executable file → Normal file
0
docs/worklogs/2026-04-06.md
Executable file → Normal file
0
docs/worklogs/2026-04-07.md
Executable file → Normal file
0
docs/worklogs/2026-04-07.md
Executable file → Normal file
0
docs/worklogs/2026-04-08.md
Executable file → Normal file
0
docs/worklogs/2026-04-08.md
Executable file → Normal file
0
docs/worklogs/2026-04-09.md
Executable file → Normal file
0
docs/worklogs/2026-04-09.md
Executable file → Normal file
0
docs/worklogs/2026-04-10.md
Executable file → Normal file
0
docs/worklogs/2026-04-10.md
Executable file → Normal file
0
docs/worklogs/2026-04-11.md
Executable file → Normal file
0
docs/worklogs/2026-04-11.md
Executable file → Normal file
34
docs/worklogs/2026-05-13.md
Normal file
34
docs/worklogs/2026-05-13.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 2026-05-13 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 화면 캡처 추가 예정
|
||||
|
||||
## 스크린샷
|
||||
|
||||

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

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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `path/to/file.tsx`
|
||||
|
||||
- 변경 목적과 핵심 수정 내용을 한 줄로 정리
|
||||
|
||||
```diff
|
||||
# 이 파일의 핵심 diff
|
||||
- before
|
||||
+ after
|
||||
```
|
||||
|
||||
### 파일 2: `path/to/another-file.ts`
|
||||
|
||||
- 필요 없으면 이 섹션은 삭제
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
-
|
||||
51
etc/commands/server-command/deploy-test.sh
Normal file
51
etc/commands/server-command/deploy-test.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||
REPO_ROOT="${REPO_ROOT:-$MAIN_PROJECT_ROOT}"
|
||||
TEST_DEPLOY_GIT_REMOTE="${TEST_DEPLOY_GIT_REMOTE:-origin}"
|
||||
TEST_DEPLOY_GIT_BRANCH="${TEST_DEPLOY_GIT_BRANCH:-main}"
|
||||
TEST_BUILD_COMMAND="${TEST_BUILD_COMMAND:-npm run build:test-app}"
|
||||
TEST_SERVER_RESTART_SCRIPT="${TEST_SERVER_RESTART_SCRIPT:-$SCRIPT_DIR/restart-test.sh}"
|
||||
TEST_DEPLOY_COMMIT_MESSAGE="${TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot}"
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "git CLI not found" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "npm CLI not found" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
||||
|
||||
if [ "$CURRENT_BRANCH" != "$TEST_DEPLOY_GIT_BRANCH" ]; then
|
||||
echo "expected branch ${TEST_DEPLOY_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::step::commit-main-worktree"
|
||||
git add -A -- . ':(exclude).server-command-test-app-built-at' ':(exclude,glob)tmp-*' ':(exclude,glob)tmp-verification/**'
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "no commit needed; main worktree already committed"
|
||||
else
|
||||
echo "staged files for TEST deploy commit:"
|
||||
git diff --cached --name-status
|
||||
git commit -m "$TEST_DEPLOY_COMMIT_MESSAGE"
|
||||
fi
|
||||
|
||||
echo "::step::push-origin-main"
|
||||
git push "$TEST_DEPLOY_GIT_REMOTE" "$TEST_DEPLOY_GIT_BRANCH"
|
||||
|
||||
echo "::step::build-test-app"
|
||||
sh -lc "$TEST_BUILD_COMMAND"
|
||||
|
||||
echo "::step::deploy-test-server"
|
||||
REPO_ROOT="$REPO_ROOT" sh "$TEST_SERVER_RESTART_SCRIPT"
|
||||
0
etc/commands/server-command/restart-rel.sh
Executable file → Normal file
0
etc/commands/server-command/restart-rel.sh
Executable file → Normal file
29
etc/commands/server-command/restart-server-command-runner.sh
Executable file → Normal file
29
etc/commands/server-command/restart-server-command-runner.sh
Executable file → Normal file
@@ -6,12 +6,14 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && 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_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_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
|
||||
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
|
||||
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_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")
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
|
||||
if [ -n "$RUNNER_PIDS" ]; then
|
||||
kill $RUNNER_PIDS || true
|
||||
if [ ! -x "$SUPERVISOR_SCRIPT" ]; then
|
||||
chmod +x "$SUPERVISOR_SCRIPT"
|
||||
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
|
||||
fi
|
||||
|
||||
setsid env \
|
||||
PROJECT_ROOT="$PROJECT_ROOT" \
|
||||
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
|
||||
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
|
||||
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"
|
||||
|
||||
22
etc/commands/server-command/restart-test.sh
Executable file → Normal file
22
etc/commands/server-command/restart-test.sh
Executable file → Normal 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_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_TEST_GIT_REMOTE="${SERVER_COMMAND_TEST_GIT_REMOTE:-origin}"
|
||||
SERVER_COMMAND_TEST_GIT_BRANCH="${SERVER_COMMAND_TEST_GIT_BRANCH:-main}"
|
||||
SERVER_COMMAND_TEST_GIT_SYNC="${SERVER_COMMAND_TEST_GIT_SYNC:-false}"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then
|
||||
exit 0
|
||||
if [ "$SERVER_COMMAND_TEST_GIT_SYNC" = "true" ]; then
|
||||
git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
|
||||
if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then
|
||||
git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \
|
||||
|| git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
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
|
||||
|
||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||
|
||||
0
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file → Normal file
0
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file → Normal file
@@ -4,6 +4,485 @@ set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
|
||||
PROXY_SERVICE="${WORK_SERVER_PROXY_SERVICE:-work-server}"
|
||||
PROXY_CONTAINER="${WORK_SERVER_PROXY_CONTAINER:-work-server}"
|
||||
BLUE_SERVICE="${WORK_SERVER_BLUE_SERVICE:-work-server-blue}"
|
||||
GREEN_SERVICE="${WORK_SERVER_GREEN_SERVICE:-work-server-green}"
|
||||
BLUE_CONTAINER="${WORK_SERVER_BLUE_CONTAINER:-work-server-blue}"
|
||||
GREEN_CONTAINER="${WORK_SERVER_GREEN_CONTAINER:-work-server-green}"
|
||||
ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/active-slot}"
|
||||
PROXY_CONFIG_FILE="${WORK_SERVER_PROXY_CONFIG_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/proxy/default.conf}"
|
||||
HEALTH_ENDPOINT="${WORK_SERVER_HEALTH_ENDPOINT:-http://127.0.0.1:3100/health}"
|
||||
RUNTIME_ENDPOINT="${WORK_SERVER_RUNTIME_ENDPOINT:-http://127.0.0.1:3100/api/runtime}"
|
||||
RECOVERY_ENDPOINT="${WORK_SERVER_RECOVERY_ENDPOINT:-http://127.0.0.1:3100/api/runtime/recover-interrupted-chat}"
|
||||
PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS="${WORK_SERVER_PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS:-900}"
|
||||
LOCK_FILE="${WORK_SERVER_RESTART_LOCK_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/restart-in-progress.json}"
|
||||
DEPLOY_STATE_FILE="${WORK_SERVER_DEPLOY_STATE_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/deployment-state.json}"
|
||||
DEPLOY_FINISHED="false"
|
||||
LAST_DEPLOY_ERROR=""
|
||||
LAST_DEPLOY_LOG=""
|
||||
PREVIOUS_ACTIVE_COUNT=""
|
||||
PREVIOUS_QUEUED_COUNT=""
|
||||
RECOVERED_SESSION_COUNT=""
|
||||
RECOVERED_RESTARTED_COUNT=""
|
||||
RECOVERED_REQUEUED_COUNT=""
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
exec docker compose -f 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
0
etc/db/work-db/README.md
Executable file → Normal file
0
etc/db/work-db/docker-compose.yml
Executable file → Normal file
0
etc/db/work-db/docker-compose.yml
Executable file → Normal file
@@ -2,6 +2,7 @@ create table if not exists board_posts (
|
||||
id serial primary key,
|
||||
title varchar(200) not null,
|
||||
content text not null,
|
||||
attachments_json text not null default '[]',
|
||||
automation_plan_item_id integer null,
|
||||
automation_received_at timestamptz null,
|
||||
created_at timestamptz not null default now(),
|
||||
|
||||
4
etc/servers/work-server/.dockerignore
Normal file
4
etc/servers/work-server/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.docker
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log*
|
||||
@@ -37,12 +37,15 @@ SERVER_COMMAND_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
|
||||
SERVER_COMMAND_API_BASE_URL=http://host.docker.internal:3211/api
|
||||
SERVER_COMMAND_API_ACCESS_TOKEN=local-server-command-runner
|
||||
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE=/api/server-commands/{key}/actions/restart
|
||||
SERVER_COMMAND_PROJECT_ROOT=/workspace/auto_codex/repo
|
||||
SERVER_COMMAND_PROJECT_ROOT=/workspace/main-project
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT=/workspace/main-project
|
||||
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
|
||||
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/
|
||||
SERVER_COMMAND_TEST_CHECK_URL=http://ai-code-app-app-1:5173/
|
||||
SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/
|
||||
SERVER_COMMAND_REL_CHECK_URL=http://ai-code-app-release:5173/
|
||||
SERVER_COMMAND_PROD_URL=https://sm-home.cloud/
|
||||
SERVER_COMMAND_PROD_CHECK_URL=http://ai-code-app-prod:5173/
|
||||
SERVER_COMMAND_PROD_GIT_REMOTE=origin
|
||||
SERVER_COMMAND_PROD_GIT_BRANCH=main
|
||||
SERVER_COMMAND_PROD_GIT_USERNAME=
|
||||
|
||||
1
etc/servers/work-server/.gitignore
vendored
1
etc/servers/work-server/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.dist-verify-actual
|
||||
.env
|
||||
|
||||
@@ -11,4 +11,7 @@ COPY src ./src
|
||||
|
||||
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"]
|
||||
|
||||
@@ -17,7 +17,19 @@ docker compose up -d
|
||||
docker compose logs -f work-server
|
||||
```
|
||||
|
||||
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
|
||||
`work-server`는 `3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다.
|
||||
|
||||
운영 기본 규칙:
|
||||
|
||||
- `work-server` 재기동은 기존 활성 슬롯을 바로 내리는 단일 컨테이너 재시작으로 처리하지 않습니다.
|
||||
- 항상 `비활성 슬롯 기동 -> /health 확인 -> nginx upstream 전환 -> 이전 슬롯 정리` 순서를 유지합니다.
|
||||
- 문서, 스크립트, 운영 가이드에 재기동 예시를 추가할 때도 무중단 전환 절차를 기본값으로 적고, 연결이 끊기는 재시작은 장애 대응이나 예외 상황으로만 취급합니다.
|
||||
|
||||
슬롯 로그까지 같이 보려면 아래처럼 확인합니다.
|
||||
|
||||
```bash
|
||||
docker compose logs -f work-server work-server-blue work-server-green
|
||||
```
|
||||
|
||||
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.
|
||||
|
||||
@@ -46,6 +58,7 @@ npm run server-command:runner
|
||||
- `SERVER_COMMAND_DOCKER_SOCKET`: 서버 재기동 명령이 사용할 Docker Unix socket 경로. rootless Docker면 예: `/run/user/1000/docker.sock`
|
||||
- `SERVER_COMMAND_API_BASE_URL`: `work-server`가 서버 재기동 요청을 위임할 host runner 주소
|
||||
- `SERVER_COMMAND_API_ACCESS_TOKEN`: host runner 호출 토큰
|
||||
- `SERVER_COMMAND_TEST_CHECK_URL`, `SERVER_COMMAND_REL_CHECK_URL`, `SERVER_COMMAND_PROD_CHECK_URL`: 외부 공개 URL과 별개로 재기동 성공 판정에 사용할 내부 확인 URL. 비워 두면 각 `SERVER_COMMAND_*_URL` 값을 그대로 사용합니다.
|
||||
|
||||
서버 재기동 기능을 쓰려면 `work-server` 컨테이너가 Docker에 접근할 수 있어야 합니다. 기본값은 `/var/run/docker.sock`이며, rootless Docker 환경이면 `.env`에 `SERVER_COMMAND_DOCKER_SOCKET` 또는 `DOCKER_HOST=unix:///run/user/<uid>/docker.sock`를 맞춰 준 뒤 `work-server`를 다시 올려야 합니다.
|
||||
|
||||
@@ -57,17 +70,11 @@ npm run server-command:runner
|
||||
|
||||
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다.
|
||||
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다.
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
|
||||
|
||||
단, 자동화 작업메모(`auto_worker`)는 예외적으로 아래 Git 흐름을 기본 동작으로 사용합니다.
|
||||
`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 채팅방 공통 문맥과 전용 메모는 충돌하지 않는 범위의 보조 문맥으로만 사용합니다. 현재 화면 및 최근 대화 문맥도 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다.
|
||||
|
||||
- 신규 `feature/*` 브랜치 생성
|
||||
- 자동 작업 수행
|
||||
- `release` 브랜치 반영
|
||||
- `main` 일괄반영
|
||||
- `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`
|
||||
|
||||
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`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을 다시 적어 줍니다.
|
||||
|
||||
@@ -77,15 +84,9 @@ npm run server-command:runner
|
||||
|
||||
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
|
||||
|
||||
현재 운영 기준에서 자동화 작업메모는 아래 브랜치 흐름을 기본 동작으로 사용합니다.
|
||||
현재 운영 기준에서 자동화 작업메모의 세부 Git 절차는 이 문서에 고정하지 않습니다. 상태 전이와 실제 처리 흐름은 worker 구현, 환경 변수, 현재 운영 정책을 함께 확인해야 합니다.
|
||||
|
||||
- `등록` 상태: worker가 읽어서 신규 `feature/plan-{id}-{workId}` 브랜치 생성
|
||||
- 성공 시: `작업중`, `브랜치준비`
|
||||
- 실패 시: `이슈`, 최근 오류 기록
|
||||
- `개발완료` 상태: worker가 `release` 브랜치 병합 시도
|
||||
- 병합 성공 시: `완료`
|
||||
- 병합 실패 시: `이슈`
|
||||
- `main` 반영 성공 시: `PLAN_MAIN_PROJECT_REPO_PATH`에서 `main` 브랜치 `pull --ff-only`까지 수행
|
||||
자동화 실행기는 선택된 자동화 유형의 description/context만 우선 참조합니다. `Codex Live`나 일반 채팅 문맥은 자동화 기본 context로 사용하지 않습니다.
|
||||
|
||||
안전 조건:
|
||||
|
||||
@@ -115,6 +116,7 @@ npm run server-command:runner
|
||||
- `DELETE /api/plan/items/:id`
|
||||
- `POST /api/notifications/setup`
|
||||
- `GET /api/notifications/tokens`
|
||||
- `GET /api/notifications/subscriptions/web`
|
||||
- `PUT /api/notifications/tokens/ios`
|
||||
- `DELETE /api/notifications/tokens/ios`
|
||||
- `POST /api/notifications/send-test`
|
||||
@@ -124,3 +126,13 @@ npm run server-command:runner
|
||||
- 프론트에서 알림 `On` 시 `PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다.
|
||||
- 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다.
|
||||
- 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`로 사용해 이전 알림을 대체합니다.
|
||||
|
||||
5940
etc/servers/work-server/data/e-reader-library.json
Normal file
5940
etc/servers/work-server/data/e-reader-library.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,9 +1,25 @@
|
||||
services:
|
||||
work-server:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: work-server
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
mem_limit: 256m
|
||||
ports:
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ./.docker/proxy/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
networks:
|
||||
- work-backend
|
||||
|
||||
work-server-blue:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: work-server
|
||||
container_name: work-server-blue
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -12,7 +28,6 @@ services:
|
||||
user: "0:0"
|
||||
group_add:
|
||||
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||
cpus: 1.5
|
||||
mem_limit: 2048m
|
||||
working_dir: /app
|
||||
env_file:
|
||||
@@ -20,9 +35,9 @@ services:
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
ports:
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ./:/app
|
||||
- work-server-node-modules:/app/node_modules
|
||||
- ../../../:/workspace/main-project
|
||||
- ../../../.auto_codex:/workspace/auto_codex
|
||||
- ../../../scripts:/workspace/repo-scripts:ro
|
||||
@@ -41,6 +56,58 @@ services:
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
WORK_SERVER_DIST_DIR: /app/dist
|
||||
WORK_SERVER_SLOT: blue
|
||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||
networks:
|
||||
- work-backend
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
work-server-green:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: work-server-green
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
user: "0:0"
|
||||
group_add:
|
||||
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||
mem_limit: 2048m
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- path: ./.env.example
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
volumes:
|
||||
- ./:/app
|
||||
- work-server-node-modules:/app/node_modules
|
||||
- ../../../:/workspace/main-project
|
||||
- ../../../.auto_codex:/workspace/auto_codex
|
||||
- ../../../scripts:/workspace/repo-scripts:ro
|
||||
- ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
- ./.docker/home:/home/how2ice
|
||||
- ./.docker/codex-home:/codex-home
|
||||
- ./.docker/codex-home-template:/codex-home-template
|
||||
environment:
|
||||
TZ: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
HOME: /home/how2ice
|
||||
CODEX_HOME: /codex-home
|
||||
PLAN_CODEX_TEMPLATE_HOME: /codex-home-template
|
||||
PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex}
|
||||
PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false}
|
||||
PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false}
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
WORK_SERVER_DIST_DIR: /app/dist
|
||||
WORK_SERVER_SLOT: green
|
||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||
networks:
|
||||
@@ -51,3 +118,6 @@ services:
|
||||
networks:
|
||||
work-backend:
|
||||
name: work-backend
|
||||
|
||||
volumes:
|
||||
work-server-node-modules:
|
||||
|
||||
0
etc/servers/work-server/package-lock.json
generated
Executable file → Normal file
0
etc/servers/work-server/package-lock.json
generated
Executable file → Normal file
@@ -5,8 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && npm run start",
|
||||
"build": "tsc -p tsconfig.json && node ./scripts/write-build-info.mjs",
|
||||
"start": "node dist/server.js",
|
||||
"build": "sh -c 'tsc -p tsconfig.json --outDir \"${WORK_SERVER_DIST_DIR:-dist}\" && node ./scripts/write-build-info.mjs'",
|
||||
"start": "sh -c 'node \"${WORK_SERVER_DIST_DIR:-dist}/server.js\"'",
|
||||
"backfill:codex-live-resource-paths": "node --import tsx ./scripts/backfill-codex-live-resource-paths.ts",
|
||||
"backfill:chat-request-links": "node --import tsx ./scripts/backfill-chat-request-links.ts",
|
||||
"backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts",
|
||||
"test": "node --import tsx --test src/**/*.test.ts"
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { inferSourceChangeScreenTitle } from '../src/services/chat-room-service.js';
|
||||
|
||||
const APPLY_FLAG = '--apply';
|
||||
const repoRootPath = path.resolve(process.cwd(), '../../..');
|
||||
const codexLiveRootPath = path.join(repoRootPath, 'resource', 'Codex Live');
|
||||
const genericScreenRootPath = path.join(codexLiveRootPath, 'Codex Live');
|
||||
|
||||
type FeaturePlan = {
|
||||
featureName: string;
|
||||
sourcePath: string;
|
||||
targetLabel: string;
|
||||
targetPath: string;
|
||||
filePaths: string[];
|
||||
};
|
||||
|
||||
function normalizeWhitespace(value: string) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getScreenLabelFromTitle(title: string) {
|
||||
const segments = String(title ?? '')
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return segments.at(-1) ?? '';
|
||||
}
|
||||
|
||||
function extractSourcePathsFromSpec(text: string) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
Array.from(text.matchAll(/`((?:src|etc|docs|public|scripts)\/[^`]+)`/g), (match) => normalizeWhitespace(match[1])),
|
||||
),
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
function containsPattern(values: string[], pattern: RegExp) {
|
||||
return values.some((value) => pattern.test(normalizeWhitespace(value)));
|
||||
}
|
||||
|
||||
function inferScreenLabelFromFeatureMetadata(args: {
|
||||
featureName: string;
|
||||
filePaths: string[];
|
||||
specTexts: string[];
|
||||
}) {
|
||||
const featureName = normalizeWhitespace(args.featureName);
|
||||
const filePaths = args.filePaths.map((value) => normalizeWhitespace(value));
|
||||
const specTexts = args.specTexts.map((value) => normalizeWhitespace(value));
|
||||
|
||||
if (
|
||||
containsPattern(filePaths, /(?:resourceManagerApi|resource-manager-service|resource-manager|ResourceManagementPage)/iu) ||
|
||||
containsPattern([featureName], /(?:resource manager|리소스 관리|리소스 경로|리소스 가이드|이미지 생성 CLI)/iu)
|
||||
) {
|
||||
return '리소스 관리';
|
||||
}
|
||||
|
||||
if (
|
||||
containsPattern(filePaths, /(?:ChatSourceChangesPage|chat-room-service)/iu) ||
|
||||
containsPattern([featureName], /(?:변경 이력|source change|source-changes)/iu)
|
||||
) {
|
||||
return '변경 이력';
|
||||
}
|
||||
|
||||
if (
|
||||
containsPattern(filePaths, /(?:PreviewAppOverlay|PreviewAppWindow|previewRuntime|appUpdate)/iu) ||
|
||||
containsPattern([featureName], /(?:모바일 앱 열기|Preview App)/iu)
|
||||
) {
|
||||
return '모바일 앱 열기';
|
||||
}
|
||||
|
||||
if (
|
||||
containsPattern(filePaths, /(?:MainHeader|HeaderMessageCenter|MainLayout\.css)/iu) ||
|
||||
containsPattern([featureName], /(?:헤더)/iu)
|
||||
) {
|
||||
return '헤더 표시';
|
||||
}
|
||||
|
||||
if (
|
||||
containsPattern(
|
||||
filePaths,
|
||||
/(?:MainChatPanel|ChatConversationView|mainChatPanel|ChatActivityChecklist|chatUtils)/iu,
|
||||
) ||
|
||||
containsPattern(
|
||||
[featureName],
|
||||
/(?:채팅 말풍선|시스템 카드|말풍선|prompt|즉시전송|즉시 접수|답변 이동|활동 로그|첨부 파일|채팅방|MainChatPanel|ChatConversationView|mainChatPanel)/iu,
|
||||
)
|
||||
) {
|
||||
return '채팅 말풍선';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferScreenLabelFromSpec(args: {
|
||||
featureName: string;
|
||||
filePaths: string[];
|
||||
specTexts: string[];
|
||||
}) {
|
||||
const inferredTitle = inferSourceChangeScreenTitle(args.filePaths, 'Codex Live / Codex Live');
|
||||
const inferredLabel = getScreenLabelFromTitle(inferredTitle);
|
||||
|
||||
if (inferredLabel && inferredLabel !== 'Codex Live' && inferredLabel !== '새 대화') {
|
||||
return inferredLabel;
|
||||
}
|
||||
|
||||
const metadataLabel = inferScreenLabelFromFeatureMetadata(args);
|
||||
|
||||
if (metadataLabel) {
|
||||
return metadataLabel;
|
||||
}
|
||||
|
||||
const normalizedFeatureName = normalizeWhitespace(args.featureName);
|
||||
const hasHeaderSpecificFile = args.filePaths.some((filePath) =>
|
||||
/^(?:src\/app\/main\/MainHeader\.(?:ts|tsx)|src\/app\/main\/HeaderMessageCenter\.(?:ts|tsx|css))$/u.test(filePath),
|
||||
);
|
||||
const hasOnlyHeaderLayoutFiles =
|
||||
args.filePaths.length > 0 &&
|
||||
args.filePaths.every((filePath) =>
|
||||
/^(?:src\/app\/main\/MainLayout\.css|src\/app\/main\/HeaderMessageCenter\.css)$/u.test(filePath),
|
||||
);
|
||||
const hasPreviewSpecificFile = args.filePaths.some((filePath) =>
|
||||
/^(?:src\/app\/main\/PreviewAppOverlay\.(?:ts|tsx)|src\/app\/main\/PreviewAppWindow\.(?:ts|tsx)|src\/app\/main\/previewRuntime\.(?:ts|tsx|js)|src\/app\/main\/appUpdate\.(?:ts|tsx|js))$/u.test(
|
||||
filePath,
|
||||
),
|
||||
);
|
||||
|
||||
if (/^(?:preview\b|동영상 preview\b)/iu.test(normalizedFeatureName) || hasPreviewSpecificFile) {
|
||||
return '모바일 앱 열기';
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:헤더|테마|앱 설정|알림 뱃지|헤더 표시)/u.test(normalizedFeatureName) &&
|
||||
(hasHeaderSpecificFile || hasOnlyHeaderLayoutFiles || args.filePaths.length === 0)
|
||||
) {
|
||||
return '헤더 표시';
|
||||
}
|
||||
|
||||
return 'Codex Live';
|
||||
}
|
||||
|
||||
async function exists(targetPath: string) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFeaturePlans() {
|
||||
if (!(await exists(genericScreenRootPath))) {
|
||||
return [] as FeaturePlan[];
|
||||
}
|
||||
|
||||
const featureEntries = await fs.readdir(genericScreenRootPath, { withFileTypes: true });
|
||||
const plans: FeaturePlan[] = [];
|
||||
|
||||
for (const featureEntry of featureEntries) {
|
||||
if (!featureEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const featureName = featureEntry.name;
|
||||
const featurePath = path.join(genericScreenRootPath, featureName);
|
||||
const datedEntries = (await fs.readdir(featurePath, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
const specTexts: string[] = [];
|
||||
const filePaths = new Set<string>();
|
||||
|
||||
for (const datedEntry of datedEntries) {
|
||||
const specPath = path.join(featurePath, datedEntry, 'docs', 'feature-spec.md');
|
||||
|
||||
if (!(await exists(specPath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const specText = await fs.readFile(specPath, 'utf8');
|
||||
specTexts.push(specText);
|
||||
|
||||
extractSourcePathsFromSpec(specText).forEach((filePath) => {
|
||||
filePaths.add(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
const targetLabel = inferScreenLabelFromSpec({
|
||||
featureName,
|
||||
filePaths: Array.from(filePaths),
|
||||
specTexts,
|
||||
});
|
||||
|
||||
plans.push({
|
||||
featureName,
|
||||
sourcePath: featurePath,
|
||||
targetLabel,
|
||||
targetPath: path.join(codexLiveRootPath, targetLabel, featureName),
|
||||
filePaths: Array.from(filePaths),
|
||||
});
|
||||
}
|
||||
|
||||
return plans.sort((left, right) => left.featureName.localeCompare(right.featureName, 'ko'));
|
||||
}
|
||||
|
||||
async function moveDirectoryContents(sourcePath: string, targetPath: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
if (!(await exists(targetPath))) {
|
||||
await fs.rename(sourcePath, targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceEntries = await fs.readdir(sourcePath, { withFileTypes: true });
|
||||
|
||||
for (const sourceEntry of sourceEntries) {
|
||||
const nextSourcePath = path.join(sourcePath, sourceEntry.name);
|
||||
const nextTargetPath = path.join(targetPath, sourceEntry.name);
|
||||
|
||||
if (sourceEntry.isDirectory()) {
|
||||
await moveDirectoryContents(nextSourcePath, nextTargetPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await exists(nextTargetPath)) {
|
||||
throw new Error(`대상 파일이 이미 존재합니다: ${path.relative(repoRootPath, nextTargetPath)}`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(nextTargetPath), { recursive: true });
|
||||
await fs.rename(nextSourcePath, nextTargetPath);
|
||||
}
|
||||
|
||||
await fs.rm(sourcePath, { recursive: false });
|
||||
}
|
||||
|
||||
async function applyMoves(plans: FeaturePlan[]) {
|
||||
const applied: Array<{ featureName: string; from: string; to: string }> = [];
|
||||
|
||||
for (const plan of plans) {
|
||||
if (plan.targetLabel === 'Codex Live') {
|
||||
continue;
|
||||
}
|
||||
|
||||
await moveDirectoryContents(plan.sourcePath, plan.targetPath);
|
||||
applied.push({
|
||||
featureName: plan.featureName,
|
||||
from: path.relative(repoRootPath, plan.sourcePath),
|
||||
to: path.relative(repoRootPath, plan.targetPath),
|
||||
});
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = await readFeaturePlans();
|
||||
const movablePlans = plans.filter((plan) => plan.targetLabel !== 'Codex Live');
|
||||
|
||||
const summary = {
|
||||
mode: process.argv.includes(APPLY_FLAG) ? 'apply' : 'dry-run',
|
||||
totalFeatureCount: plans.length,
|
||||
movableFeatureCount: movablePlans.length,
|
||||
groupedTargets: movablePlans.reduce<Record<string, number>>((accumulator, plan) => {
|
||||
accumulator[plan.targetLabel] = (accumulator[plan.targetLabel] ?? 0) + 1;
|
||||
return accumulator;
|
||||
}, {}),
|
||||
moves: movablePlans.map((plan) => ({
|
||||
featureName: plan.featureName,
|
||||
from: path.relative(repoRootPath, plan.sourcePath),
|
||||
to: path.relative(repoRootPath, plan.targetPath),
|
||||
filePaths: plan.filePaths,
|
||||
})),
|
||||
};
|
||||
|
||||
if (!process.argv.includes(APPLY_FLAG)) {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const applied = await applyMoves(plans);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
...summary,
|
||||
appliedCount: applied.length,
|
||||
applied,
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
111
etc/servers/work-server/scripts/container-supervisor.sh
Normal file
111
etc/servers/work-server/scripts/container-supervisor.sh
Normal 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
|
||||
@@ -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
24
etc/servers/work-server/scripts/write-build-info.mjs
Executable file → Normal file
@@ -3,8 +3,11 @@ import path from 'node:path';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
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 distNodeModulesPath = path.join(distDirectoryPath, 'node_modules');
|
||||
const projectNodeModulesPath = path.join(projectRoot, 'node_modules');
|
||||
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
const builtAt = new Date().toISOString();
|
||||
@@ -18,4 +21,23 @@ const buildInfo = {
|
||||
await fs.mkdir(distDirectoryPath, { recursive: true });
|
||||
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}`);
|
||||
|
||||
63
etc/servers/work-server/src/app.ts
Executable file → Normal file
63
etc/servers/work-server/src/app.ts
Executable file → Normal file
@@ -7,36 +7,99 @@ import { registerDdlRoutes } from './routes/ddl.js';
|
||||
import { registerErrorLogRoutes } from './routes/error-log.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerAppConfigRoutes } from './routes/app-config.js';
|
||||
import { registerBaseballTicketBayRoutes } from './routes/baseball-ticket-bay.js';
|
||||
import { registerChatRoutes } from './routes/chat.js';
|
||||
import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
|
||||
import { registerReaderRoutes } from './routes/reader.js';
|
||||
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
||||
import { registerPlayAppRoutes } from './routes/play-app.js';
|
||||
import { registerRuntimeRoutes } from './routes/runtime.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerSharedResourceTokenRoutes } from './routes/shared-resource-token.js';
|
||||
import { registerStockAlertRoutes } from './routes/stock-alert.js';
|
||||
import { registerTestAppRoutes } from './routes/test-app.js';
|
||||
import { registerTextMemoRoutes } from './routes/text-memo.js';
|
||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
import { createErrorLog } from './services/error-log-service.js';
|
||||
import {
|
||||
isRuntimeDraining,
|
||||
trackHttpRequestFinished,
|
||||
trackHttpRequestStarted,
|
||||
} from './services/runtime-drain-service.js';
|
||||
|
||||
function isDrainAllowedPath(method: string, url: string) {
|
||||
return method === 'OPTIONS'
|
||||
|| url === '/'
|
||||
|| url === '/api'
|
||||
|| url === '/health'
|
||||
|| url.startsWith('/api/runtime')
|
||||
|| url.startsWith('/api/server-commands');
|
||||
}
|
||||
|
||||
export function createApp() {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
routerOptions: {
|
||||
maxParamLength: 20000,
|
||||
},
|
||||
});
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
});
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
trackHttpRequestStarted();
|
||||
let finished = false;
|
||||
const finalize = () => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
trackHttpRequestFinished();
|
||||
reply.raw.off('finish', finalize);
|
||||
reply.raw.off('close', finalize);
|
||||
};
|
||||
|
||||
reply.raw.on('finish', finalize);
|
||||
reply.raw.on('close', finalize);
|
||||
|
||||
if (isRuntimeDraining() && !isDrainAllowedPath(request.method, request.url)) {
|
||||
reply.code(503).send({
|
||||
ok: false,
|
||||
message: '이 서버는 배포 전환 중이라 새 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
|
||||
});
|
||||
return reply;
|
||||
}
|
||||
});
|
||||
|
||||
registerJsonBodyParser(app);
|
||||
app.register(registerBoardRoutes);
|
||||
app.register(registerHealthRoutes);
|
||||
app.register(registerAppConfigRoutes);
|
||||
app.register(registerBaseballTicketBayRoutes);
|
||||
app.register(registerChatRoutes);
|
||||
app.register(registerSchemaRoutes);
|
||||
app.register(registerDdlRoutes);
|
||||
app.register(registerCrudRoutes);
|
||||
app.register(registerStockAlertRoutes);
|
||||
app.register(registerTestAppRoutes);
|
||||
app.register(registerErrorLogRoutes);
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
app.register(registerPhotoPrismRoutes);
|
||||
app.register(registerPlayAppRoutes);
|
||||
app.register(registerReaderRoutes);
|
||||
app.register(registerResourceManagerRoutes);
|
||||
app.register(registerRuntimeRoutes);
|
||||
app.register(registerSharedResourceTokenRoutes);
|
||||
app.register(registerServerCommandRoutes);
|
||||
app.register(registerTextMemoRoutes);
|
||||
app.register(registerVisitorHistoryRoutes);
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
|
||||
112
etc/servers/work-server/src/config/env.js
Normal file
112
etc/servers/work-server/src/config/env.js
Normal 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();
|
||||
@@ -54,6 +54,8 @@ const envSchema = z.object({
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(),
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(),
|
||||
WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'),
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
OPENAI_ORGANIZATION_ID: z.string().optional(),
|
||||
APNS_KEY_ID: z.string().optional(),
|
||||
APNS_TEAM_ID: z.string().optional(),
|
||||
APNS_BUNDLE_ID: z.string().optional(),
|
||||
@@ -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_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')),
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
|
||||
SERVER_COMMAND_TEST_URL: z.string().default('https://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_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
|
||||
SERVER_COMMAND_PROD_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
||||
SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE: z.string().optional(),
|
||||
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
||||
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
||||
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
|
||||
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
|
||||
WORK_SERVER_DIST_DIR: z.string().default('dist'),
|
||||
});
|
||||
|
||||
function parseEnv() {
|
||||
|
||||
37
etc/servers/work-server/src/db/client.js
Normal file
37
etc/servers/work-server/src/db/client.js
Normal 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
0
etc/servers/work-server/src/db/client.ts
Executable file → Normal file
0
etc/servers/work-server/src/json-body.ts
Executable file → Normal file
0
etc/servers/work-server/src/json-body.ts
Executable file → Normal file
0
etc/servers/work-server/src/lib/identifier.ts
Executable file → Normal file
0
etc/servers/work-server/src/lib/identifier.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.ts
Executable file → Normal file
431
etc/servers/work-server/src/routes/app-config.ts
Executable file → Normal file
431
etc/servers/work-server/src/routes/app-config.ts
Executable file → Normal file
@@ -1,23 +1,244 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
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 {
|
||||
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) {
|
||||
app.get('/api/app-config', async () => {
|
||||
const config = await getAppConfig();
|
||||
app.get('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
const hasShareToken = Boolean(getRequestChatShareToken(request));
|
||||
|
||||
if (hasShareToken && !accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const config = await getAppConfigSnapshot(appOrigin);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: config ?? {},
|
||||
config,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat-types', async () => {
|
||||
const chatTypes = await getChatTypesConfig();
|
||||
app.get('/api/chat-types', async (request) => {
|
||||
const chatTypeConfig = await getChatTypesConfig(getRequestAppOrigin(request));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
chatTypes,
|
||||
...chatTypeConfig,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat-context-settings', async (request) => {
|
||||
const settings = await getChatContextSettingsConfig(getRequestAppOrigin(request));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
settings,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/automation-types', async () => {
|
||||
const automationTypes = await getAutomationTypesConfig();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
automationTypes,
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -33,15 +254,20 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = z.object({
|
||||
chatTypes: z.array(z.unknown()),
|
||||
}).parse(payload ?? {});
|
||||
const parsed = z
|
||||
.object({
|
||||
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 {
|
||||
ok: true,
|
||||
chatTypes: savedChatTypes,
|
||||
...savedChatTypeConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
@@ -50,7 +276,182 @@ 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) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = z.object({
|
||||
automationTypes: z.array(z.unknown()),
|
||||
}).parse(payload ?? {});
|
||||
|
||||
const savedAutomationTypes = await upsertAutomationTypesConfig(parsed.automationTypes);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
automationTypes: savedAutomationTypes,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '자동화 처리 유형 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
@@ -72,11 +473,13 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
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 {
|
||||
ok: true,
|
||||
config: savedConfig,
|
||||
config: normalizeAppConfigSnapshot(savedConfig),
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
|
||||
300
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
300
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import { getSharedResourceTokenDetailByShareToken } from '../services/shared-resource-token-service.js';
|
||||
import {
|
||||
createBaseballTicketBayAlert,
|
||||
createBaseballTicketBayLog,
|
||||
deleteBaseballTicketBayLog,
|
||||
deleteBaseballTicketBayAlert,
|
||||
listBaseballTicketBayAlerts,
|
||||
listBaseballTicketBayLogs,
|
||||
runBaseballTicketBayAlert,
|
||||
searchBaseballTicketBayListings,
|
||||
updateBaseballTicketBayAlert,
|
||||
} from '../services/baseball-ticket-bay-service.js';
|
||||
|
||||
const timeWindowSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
start: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||
end: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||
});
|
||||
|
||||
const alertPayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(255),
|
||||
eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
team: z.string().trim().min(1).max(50),
|
||||
zone: z.string().trim().min(1).max(100),
|
||||
aisleSide: z.string().trim().min(1).max(100),
|
||||
seatDirections: z.array(z.string().trim().min(1).max(50)).max(10),
|
||||
maxPrice: z.number().finite().positive().nullable(),
|
||||
seatCount: z.number().int().positive().max(10),
|
||||
batchIntervalMinutes: z.number().int().min(1).max(120),
|
||||
sameProductAlertEnabled: z.boolean(),
|
||||
sameProductNotifyOnce: z.boolean(),
|
||||
active: z.boolean().default(true),
|
||||
timeWindows: z.array(timeWindowSchema).min(1).max(24),
|
||||
});
|
||||
|
||||
function readHeader(request: { headers: Record<string, string | string[] | undefined> }, key: string) {
|
||||
const raw = request.headers[key];
|
||||
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||
}
|
||||
|
||||
function hasBaseballTicketBayGlobalAccess(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
return hasErrorLogViewAccessToken(request.headers['x-access-token']);
|
||||
}
|
||||
|
||||
type BaseballTicketBayRouteAccessContext =
|
||||
| { scope: 'all' }
|
||||
| { scope: 'client'; clientId: string }
|
||||
| { scope: 'shared-token'; clientId: string; tokenId: string };
|
||||
|
||||
function toOwnerScope(accessContext: Exclude<BaseballTicketBayRouteAccessContext, { scope: 'all' }> | { scope: 'all' }) {
|
||||
if (accessContext.scope === 'all') {
|
||||
return { kind: 'all' } as const;
|
||||
}
|
||||
|
||||
if (accessContext.scope === 'shared-token') {
|
||||
return { kind: 'owner', ownerType: 'shared-token', ownerId: accessContext.tokenId } as const;
|
||||
}
|
||||
|
||||
return { kind: 'owner', ownerType: 'client', ownerId: accessContext.clientId } as const;
|
||||
}
|
||||
|
||||
async function resolveBaseballTicketBayAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) : Promise<BaseballTicketBayRouteAccessContext | null> {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (hasBaseballTicketBayGlobalAccess(request)) {
|
||||
return { scope: 'all' };
|
||||
}
|
||||
|
||||
const accessToken = readHeader(request, 'x-access-token');
|
||||
|
||||
if (accessToken) {
|
||||
const sharedTokenDetail = await getSharedResourceTokenDetailByShareToken(accessToken);
|
||||
|
||||
if (
|
||||
sharedTokenDetail
|
||||
&& sharedTokenDetail.token.enabled !== false
|
||||
&& !sharedTokenDetail.token.revokedAt
|
||||
&& sharedTokenDetail.token.allowedAppIds.some((item) => item.trim().toLowerCase() === 'baseball-ticket-bay')
|
||||
) {
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared-token',
|
||||
clientId,
|
||||
tokenId: sharedTokenDetail.token.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'client',
|
||||
clientId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {}));
|
||||
|
||||
app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 알림 목록을 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
includeAllClients: accessContext.scope === 'all',
|
||||
accessScope: accessContext.scope,
|
||||
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
|
||||
items: await listBaseballTicketBayAlerts(toOwnerScope(accessContext)),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/baseball-ticket-bay/logs', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 로그를 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
includeAllClients: accessContext.scope === 'all',
|
||||
accessScope: accessContext.scope,
|
||||
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
|
||||
items: await listBaseballTicketBayLogs(toOwnerScope(accessContext), query.alertId),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayLog(params.id, toOwnerScope(accessContext));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 저장할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const payload = alertPayloadSchema.parse(request.body ?? {});
|
||||
const item = await createBaseballTicketBayAlert(payload, {
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
});
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'create',
|
||||
status: 'info',
|
||||
message: '알림 조건을 저장했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
|
||||
const item = await updateBaseballTicketBayAlert(
|
||||
params.id,
|
||||
payload,
|
||||
accessContext.scope === 'all'
|
||||
? {
|
||||
scope: { kind: 'all' },
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
}
|
||||
: {
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
},
|
||||
);
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
|
||||
status: 'info',
|
||||
message:
|
||||
payload.active === false
|
||||
? '알림을 중지했습니다.'
|
||||
: payload.active === true
|
||||
? '알림을 다시 실행 상태로 전환했습니다.'
|
||||
: '알림 조건을 수정 저장했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayAlert(params.id, toOwnerScope(accessContext));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
|
||||
}
|
||||
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: item.clientId,
|
||||
ownerType: item.ownerType,
|
||||
ownerId: item.ownerId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'delete',
|
||||
status: 'info',
|
||||
message: '알림 항목을 삭제했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 즉시 실행할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
|
||||
const result = await runBaseballTicketBayAlert(params.id, {
|
||||
ignoreTimeWindow: true,
|
||||
scope: toOwnerScope(accessContext),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
alert: result.alert,
|
||||
matches: result.matches,
|
||||
notifiedMatches: result.notifiedMatches,
|
||||
log: result.log,
|
||||
};
|
||||
});
|
||||
}
|
||||
2
etc/servers/work-server/src/routes/board.ts
Executable file → Normal file
2
etc/servers/work-server/src/routes/board.ts
Executable file → Normal file
@@ -109,7 +109,7 @@ export async function registerBoardRoutes(app: FastifyInstance) {
|
||||
return {
|
||||
ok: true,
|
||||
item: result.item,
|
||||
planItemId: result.planItemId,
|
||||
planItemIds: result.planItemIds,
|
||||
alreadyReceived: result.alreadyReceived,
|
||||
};
|
||||
});
|
||||
|
||||
95
etc/servers/work-server/src/routes/chat.test.ts
Normal file
95
etc/servers/work-server/src/routes/chat.test.ts
Normal 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
3907
etc/servers/work-server/src/routes/chat.ts
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
0
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/crud.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/crud.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/ddl.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/ddl.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/error-log.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/error-log.ts
Executable file → Normal file
27
etc/servers/work-server/src/routes/health.ts
Executable file → Normal file
27
etc/servers/work-server/src/routes/health.ts
Executable file → Normal file
@@ -1,11 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getActiveChatService } from '../services/chat-service.js';
|
||||
import { getRuntimeDrainSnapshot } from '../services/runtime-drain-service.js';
|
||||
import { getRuntimeWorkServerBuildInfo } from '../services/work-server-build-service.js';
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance) {
|
||||
const respondHealth = async () => ({
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
const respondHealth = async () => {
|
||||
const buildInfo = getRuntimeWorkServerBuildInfo();
|
||||
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
buildId: buildInfo?.buildId ?? null,
|
||||
builtAt: buildInfo?.builtAt ?? null,
|
||||
...getRuntimeDrainSnapshot(),
|
||||
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/', respondHealth);
|
||||
app.get('/api', respondHealth);
|
||||
|
||||
57
etc/servers/work-server/src/routes/notification.ts
Executable file → Normal file
57
etc/servers/work-server/src/routes/notification.ts
Executable file → Normal file
@@ -1,20 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
listIosNotificationTokens,
|
||||
listWebPushSubscriptions,
|
||||
getAutomationNotificationPreference,
|
||||
getWebPushConfig,
|
||||
registerIosNotificationToken,
|
||||
registerAutomationNotificationPreferenceSchema,
|
||||
registerIosTokenSchema,
|
||||
registerWebPushSubscription,
|
||||
registerWebPushSubscriptionSchema,
|
||||
sendNotifications,
|
||||
sendIosNotificationSchema,
|
||||
setupNotificationTables,
|
||||
upsertAutomationNotificationPreference,
|
||||
unregisterIosNotificationToken,
|
||||
unregisterIosTokenSchema,
|
||||
unregisterWebPushSubscription,
|
||||
unregisterWebPushSubscriptionSchema,
|
||||
} from '../services/notification-service.js';
|
||||
@@ -30,14 +26,10 @@ import {
|
||||
} from '../services/notification-message-service.js';
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
type AutomationNotificationPreferenceTargetKind = NonNullable<
|
||||
z.infer<typeof automationNotificationPreferenceQuerySchema>['targetKind']
|
||||
>;
|
||||
|
||||
function getClientIdHeader(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const rawClientId = request.headers['x-client-id'];
|
||||
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) {
|
||||
app.post('/api/notifications/setup', async () => setupNotificationTables());
|
||||
|
||||
app.get('/api/notifications/tokens', async () => ({
|
||||
items: await listIosNotificationTokens(),
|
||||
app.get('/api/notifications/subscriptions/web', async () => ({
|
||||
items: await listWebPushSubscriptions(),
|
||||
}));
|
||||
|
||||
app.get('/api/notifications/webpush/config', async () => getWebPushConfig());
|
||||
|
||||
app.get('/api/notifications/messages', async (request) => {
|
||||
const query = notificationMessageListQuerySchema.parse(request.query ?? {});
|
||||
const clientId = getClientIdHeader(request);
|
||||
return {
|
||||
ok: true,
|
||||
...(await listNotificationMessages(query)),
|
||||
...(await listNotificationMessages(query, clientId)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -125,7 +118,7 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
|
||||
return {
|
||||
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) => {
|
||||
const payload = registerWebPushSubscriptionSchema.parse(request.body ?? {});
|
||||
return registerWebPushSubscription(payload);
|
||||
@@ -179,29 +162,3 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
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;
|
||||
}
|
||||
|
||||
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
File diff suppressed because it is too large
Load Diff
1256
etc/servers/work-server/src/routes/plan.js
Normal file
1256
etc/servers/work-server/src/routes/plan.js
Normal file
File diff suppressed because it is too large
Load Diff
74
etc/servers/work-server/src/routes/plan.ts
Executable file → Normal file
74
etc/servers/work-server/src/routes/plan.ts
Executable file → Normal file
@@ -64,10 +64,12 @@ import {
|
||||
listPlanScheduledTasks,
|
||||
mapPlanScheduledTaskRow,
|
||||
registerPlanScheduledTaskNow,
|
||||
syncManagedServiceGenerationCompletion,
|
||||
updatePlanScheduledTask,
|
||||
updatePlanScheduledTaskSchema,
|
||||
} from '../services/plan-schedule-service.js';
|
||||
import { getVisitorClientByClientId } from '../services/visitor-history-service.js';
|
||||
import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js';
|
||||
|
||||
const completeActionSchema = z.object({
|
||||
note: z.string().trim().min(1).optional(),
|
||||
@@ -164,11 +166,25 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
|
||||
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
|
||||
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 {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
item: mapPlanScheduledTaskRow(latestRow ?? row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
@@ -210,16 +226,44 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const shouldTriggerImmediateRegistration =
|
||||
row &&
|
||||
Boolean(row.enabled ?? true) &&
|
||||
Boolean(row.immediate_run_enabled ?? true) &&
|
||||
payload.enabled !== false;
|
||||
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null;
|
||||
const shouldTriggerImmediateRegistration = (
|
||||
row
|
||||
&& String(row.execution_mode ?? '') === 'managed-service'
|
||||
&& payload.recreateManagedServiceOnNextSave === true
|
||||
) || (
|
||||
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 {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
item: mapPlanScheduledTaskRow(latestRow ?? row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
@@ -547,6 +591,8 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
await syncManagedServiceGenerationCompletion(id);
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
@@ -554,6 +600,7 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
'수동 작업완료로 release 반영 대기 상태가 되었습니다.',
|
||||
'development-completed',
|
||||
);
|
||||
await progressBoardPostAutomationByPlanResult(id, 'completed');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -576,6 +623,8 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
await syncManagedServiceGenerationCompletion(id);
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
@@ -583,6 +632,7 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
payload.note ?? '작업이 완료 처리되었습니다.',
|
||||
'plan-completed',
|
||||
);
|
||||
await progressBoardPostAutomationByPlanResult(id, 'completed');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -719,8 +769,12 @@ export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const env = getEnv();
|
||||
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(
|
||||
{
|
||||
repoPath: env.PLAN_GIT_REPO_PATH,
|
||||
|
||||
590
etc/servers/work-server/src/routes/play-app.ts
Normal file
590
etc/servers/work-server/src/routes/play-app.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
type PlayAppEnvironment = 'preview' | 'test' | 'prod';
|
||||
|
||||
type PlayAppSeedEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
accentClassName: string;
|
||||
statusLabel: string;
|
||||
isReady: boolean;
|
||||
iconName: string;
|
||||
usagePriority?: number;
|
||||
supportedEnvironments: PlayAppEnvironment[];
|
||||
searchKeywords?: string[];
|
||||
searchDescription?: string;
|
||||
};
|
||||
|
||||
const PLAY_APP_TABLE = 'play_apps';
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function ensurePlayAppWriteAuthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰이 필요합니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const DEFAULT_ENTRIES: PlayAppSeedEntry[] = [
|
||||
{
|
||||
id: 'baseball-ticket-bay',
|
||||
name: '야구-티켓베이',
|
||||
accentClassName: 'apps-library__card--baseball-ticket-bay',
|
||||
statusLabel: '알림',
|
||||
isReady: true,
|
||||
iconName: 'BellOutlined',
|
||||
usagePriority: 100,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'],
|
||||
searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.',
|
||||
},
|
||||
{
|
||||
id: 'e-reader',
|
||||
name: 'E-Reader',
|
||||
accentClassName: 'apps-library__card--reader',
|
||||
statusLabel: '읽기',
|
||||
isReady: true,
|
||||
iconName: 'BookOutlined',
|
||||
usagePriority: 80,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
|
||||
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'photoprism',
|
||||
name: 'PhotoPrism',
|
||||
accentClassName: 'apps-library__card--photoprism',
|
||||
statusLabel: '연결',
|
||||
isReady: true,
|
||||
iconName: 'FileImageOutlined',
|
||||
usagePriority: 70,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
|
||||
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
|
||||
},
|
||||
{
|
||||
id: 'photo-puzzle',
|
||||
name: '사진 퍼즐',
|
||||
accentClassName: 'apps-library__card--puzzle',
|
||||
statusLabel: '실행',
|
||||
isReady: true,
|
||||
iconName: 'PictureOutlined',
|
||||
usagePriority: 60,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
|
||||
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
|
||||
},
|
||||
{
|
||||
id: 'the-quest',
|
||||
name: 'The Quest',
|
||||
accentClassName: 'apps-library__card--the-quest',
|
||||
statusLabel: '신규',
|
||||
isReady: true,
|
||||
iconName: 'ThunderboltOutlined',
|
||||
usagePriority: 50,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
|
||||
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
|
||||
},
|
||||
{
|
||||
id: 'template1',
|
||||
name: 'Template1',
|
||||
accentClassName: 'apps-library__card--template1',
|
||||
statusLabel: '템플릿',
|
||||
isReady: true,
|
||||
iconName: 'AppstoreAddOutlined',
|
||||
usagePriority: 45,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['template1', 'template', '앱 템플릿', '레이아웃', '기본 UI', 'layout'],
|
||||
searchDescription: '다른 앱 개발 시 공통 레이아웃을 빠르게 적용하기 위한 템플릿 화면입니다.',
|
||||
},
|
||||
{
|
||||
id: 'tetris',
|
||||
name: 'Tetris',
|
||||
accentClassName: 'apps-library__card--tetris',
|
||||
statusLabel: '실행',
|
||||
isReady: true,
|
||||
iconName: 'FundProjectionScreenOutlined',
|
||||
usagePriority: 40,
|
||||
supportedEnvironments: ['preview', 'test'],
|
||||
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
|
||||
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',
|
||||
},
|
||||
{
|
||||
id: 'beat-lab',
|
||||
name: 'Beat Lab',
|
||||
accentClassName: 'apps-library__card--beat',
|
||||
statusLabel: '준비',
|
||||
isReady: false,
|
||||
iconName: 'SoundOutlined',
|
||||
usagePriority: 35,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Beat Lab 앱',
|
||||
},
|
||||
{
|
||||
id: 'sticker-booth',
|
||||
name: 'Sticker Booth',
|
||||
accentClassName: 'apps-library__card--sticker',
|
||||
statusLabel: '준비',
|
||||
isReady: false,
|
||||
iconName: 'StarOutlined',
|
||||
usagePriority: 30,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Sticker Booth 앱',
|
||||
},
|
||||
{
|
||||
id: 'launch-note',
|
||||
name: 'Launch Note',
|
||||
accentClassName: 'apps-library__card--launch',
|
||||
statusLabel: '예정',
|
||||
isReady: false,
|
||||
iconName: 'RocketOutlined',
|
||||
usagePriority: 20,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Launch Note 앱',
|
||||
},
|
||||
{
|
||||
id: 'arcade-pack',
|
||||
name: 'Arcade Pack',
|
||||
accentClassName: 'apps-library__card--arcade',
|
||||
statusLabel: '예정',
|
||||
isReady: false,
|
||||
iconName: 'FireOutlined',
|
||||
usagePriority: 10,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 Arcade Pack 앱',
|
||||
},
|
||||
{
|
||||
id: 'app-vault',
|
||||
name: 'App Vault',
|
||||
accentClassName: 'apps-library__card--vault',
|
||||
statusLabel: '테마',
|
||||
isReady: false,
|
||||
iconName: 'AppstoreOutlined',
|
||||
usagePriority: 0,
|
||||
supportedEnvironments: ['preview'],
|
||||
searchDescription: '향후 추가 예정인 App Vault 앱',
|
||||
},
|
||||
] ;
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
environment: z.enum(['preview', 'test', 'prod']).optional(),
|
||||
});
|
||||
|
||||
const playAppIdSchema = z.string().trim().min(1).max(100);
|
||||
|
||||
const supportedEnvironmentSchema = z.enum(['preview', 'test', 'prod']);
|
||||
|
||||
const iconNameSchema = z.enum([
|
||||
'AppstoreOutlined',
|
||||
'AppstoreAddOutlined',
|
||||
'BellOutlined',
|
||||
'BookOutlined',
|
||||
'FireOutlined',
|
||||
'FundProjectionScreenOutlined',
|
||||
'FileImageOutlined',
|
||||
'PictureOutlined',
|
||||
'RocketOutlined',
|
||||
'SoundOutlined',
|
||||
'StarOutlined',
|
||||
'ThunderboltOutlined',
|
||||
]);
|
||||
|
||||
const playAppCreatePayloadSchema = z.object({
|
||||
id: playAppIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
accentClassName: z.string().trim().min(1).max(80),
|
||||
statusLabel: z.string().trim().min(1).max(80),
|
||||
isReady: z.boolean().default(false),
|
||||
iconName: iconNameSchema,
|
||||
usagePriority: z.number().int().min(0).max(1_000_000).optional(),
|
||||
supportedEnvironments: z.array(supportedEnvironmentSchema).min(1).default(['preview']),
|
||||
searchKeywords: z
|
||||
.array(z.string().trim().min(1).max(80))
|
||||
.default([])
|
||||
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0)))),
|
||||
searchDescription: z.string().trim().max(4000).default(''),
|
||||
});
|
||||
|
||||
const playAppUpdatePayloadSchema = z
|
||||
.object({
|
||||
id: playAppIdSchema.optional(),
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
accentClassName: z.string().trim().min(1).max(80).optional(),
|
||||
statusLabel: z.string().trim().min(1).max(80).optional(),
|
||||
isReady: z.boolean().optional(),
|
||||
iconName: iconNameSchema.optional(),
|
||||
usagePriority: z.number().int().min(0).max(1_000_000).nullable().optional(),
|
||||
supportedEnvironments: z.array(supportedEnvironmentSchema).optional(),
|
||||
searchKeywords: z
|
||||
.array(z.string().trim().min(1).max(80))
|
||||
.transform((keywords) => Array.from(new Set(keywords.map((keyword) => keyword.trim()).filter((keyword) => keyword.length > 0))))
|
||||
.optional(),
|
||||
searchDescription: z.string().trim().max(4000).optional(),
|
||||
})
|
||||
.refine((payload) => Object.keys(payload).length > 0, {
|
||||
message: '수정할 항목이 없습니다.',
|
||||
});
|
||||
|
||||
const playAppIdParamsSchema = z.object({
|
||||
id: playAppIdSchema,
|
||||
});
|
||||
|
||||
type SupportedEnvironment = Array<PlayAppEnvironment>;
|
||||
type PlayAppRegistryRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
accent_class_name: string;
|
||||
status_label: string;
|
||||
is_ready: boolean;
|
||||
icon_name: string;
|
||||
usage_priority: number | null;
|
||||
supported_environments: string | null;
|
||||
search_keywords: string | null;
|
||||
search_description: string | null;
|
||||
};
|
||||
|
||||
type PlayAppRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
accentClassName: string;
|
||||
statusLabel: string;
|
||||
isReady: boolean;
|
||||
iconName: string;
|
||||
usagePriority?: number;
|
||||
supportedEnvironments: SupportedEnvironment;
|
||||
searchKeywords: string[];
|
||||
searchDescription: string;
|
||||
};
|
||||
|
||||
type PlayAppCreateInput = z.infer<typeof playAppCreatePayloadSchema>;
|
||||
type PlayAppUpdateInput = z.infer<typeof playAppUpdatePayloadSchema>;
|
||||
|
||||
function normalizeSupportedEnvironments(value: string | null): SupportedEnvironment {
|
||||
if (!value) {
|
||||
return ['preview'];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return ['preview'];
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => String(item).trim())
|
||||
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
|
||||
}
|
||||
} catch {
|
||||
// fall through to comma parser below.
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item): item is PlayAppEnvironment => item === 'preview' || item === 'test' || item === 'prod');
|
||||
}
|
||||
|
||||
function parseJsonArrayList(value: string | null): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// fallthrough to legacy parser below.
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function toResponseItem(row: PlayAppRegistryRecord): PlayAppRow {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accentClassName: row.accent_class_name,
|
||||
statusLabel: row.status_label,
|
||||
isReady: !!row.is_ready,
|
||||
iconName: row.icon_name,
|
||||
usagePriority: row.usage_priority ?? undefined,
|
||||
supportedEnvironments: normalizeSupportedEnvironments(row.supported_environments),
|
||||
searchKeywords: parseJsonArrayList(row.search_keywords),
|
||||
searchDescription: row.search_description ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function encodeJsonList(values: readonly string[] | null | undefined) {
|
||||
if (!values || values.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
return JSON.stringify(Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))));
|
||||
}
|
||||
|
||||
function toDbRowPayload(input: PlayAppCreateInput | Omit<PlayAppUpdateInput, 'id'>) {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: 'id' in input ? input.id?.trim() : undefined,
|
||||
name: input.name?.trim(),
|
||||
accent_class_name: input.accentClassName?.trim(),
|
||||
status_label: input.statusLabel?.trim(),
|
||||
is_ready: input.isReady,
|
||||
icon_name: input.iconName,
|
||||
usage_priority: input.usagePriority ?? null,
|
||||
supported_environments: input.supportedEnvironments ? encodeJsonList(input.supportedEnvironments) : undefined,
|
||||
search_keywords: input.searchKeywords ? encodeJsonList(input.searchKeywords) : undefined,
|
||||
search_description: input.searchDescription ? input.searchDescription.trim() : undefined,
|
||||
};
|
||||
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
delete payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
if ('is_ready' in payload && payload.is_ready === undefined) {
|
||||
payload.is_ready = false;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function isDbUpdateResultEmpty(result: unknown) {
|
||||
if (Array.isArray(result)) {
|
||||
return result.length === 0;
|
||||
}
|
||||
|
||||
return typeof result === 'number' ? result === 0 : false;
|
||||
}
|
||||
|
||||
function parsePlayAppErrorWithCode(error: unknown, fallbackMessage: string) {
|
||||
if (error instanceof Error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'ER_DUP_ENTRY' || code === '23505') {
|
||||
const duplicateError = error as Error & { statusCode?: number; details?: string };
|
||||
duplicateError.statusCode = 409;
|
||||
duplicateError.message = '이미 등록된 앱 ID입니다.';
|
||||
return duplicateError;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const duplicateError = error as Error & { statusCode?: number; details?: string };
|
||||
duplicateError.message = fallbackMessage;
|
||||
return duplicateError;
|
||||
}
|
||||
|
||||
const next = new Error(fallbackMessage) as Error & { statusCode?: number };
|
||||
next.statusCode = 500;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function ensurePlayAppTable() {
|
||||
const exists = await db.schema.hasTable(PLAY_APP_TABLE);
|
||||
if (!exists) {
|
||||
await db.schema.createTable(PLAY_APP_TABLE, (table) => {
|
||||
table.string('id', 100).primary();
|
||||
table.string('name', 120).notNullable();
|
||||
table.string('accent_class_name', 80).notNullable();
|
||||
table.string('status_label', 80).notNullable();
|
||||
table.boolean('is_ready').notNullable().defaultTo(false);
|
||||
table.string('icon_name', 80).notNullable();
|
||||
table.integer('usage_priority').nullable();
|
||||
table.text('supported_environments').nullable();
|
||||
table.text('search_keywords').nullable();
|
||||
table.text('search_description').nullable();
|
||||
table.index('is_ready', 'play_apps_is_ready_idx');
|
||||
});
|
||||
}
|
||||
|
||||
const existingRows = await db(PLAY_APP_TABLE).select('id');
|
||||
const existingIds = new Set(existingRows.map((row) => row.id));
|
||||
|
||||
const rowsToInsert = DEFAULT_ENTRIES.filter((entry) => !existingIds.has(entry.id)).map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
accent_class_name: entry.accentClassName,
|
||||
status_label: entry.statusLabel,
|
||||
is_ready: entry.isReady,
|
||||
icon_name: entry.iconName,
|
||||
usage_priority: entry.usagePriority,
|
||||
supported_environments: encodeJsonList(entry.supportedEnvironments),
|
||||
search_keywords: encodeJsonList(entry.searchKeywords ?? []),
|
||||
search_description: entry.searchDescription ?? '',
|
||||
}));
|
||||
|
||||
if (rowsToInsert.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db(PLAY_APP_TABLE).insert(rowsToInsert);
|
||||
}
|
||||
|
||||
async function listPlayAppEntries(environment?: PlayAppEnvironment | null) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const rows = (await db(PLAY_APP_TABLE)
|
||||
.select<PlayAppRegistryRecord[]>('*')
|
||||
.orderBy('usage_priority', 'desc')
|
||||
.orderBy('id', 'asc')) as PlayAppRegistryRecord[];
|
||||
|
||||
const normalizedRows = rows.map(toResponseItem);
|
||||
const filteredRows = environment ? normalizedRows.filter((row) => row.supportedEnvironments.includes(environment)) : normalizedRows;
|
||||
|
||||
return filteredRows;
|
||||
}
|
||||
|
||||
async function createPlayAppEntry(body: unknown) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const payload = playAppCreatePayloadSchema.parse(body);
|
||||
const dbPayload = toDbRowPayload(payload);
|
||||
|
||||
try {
|
||||
await db(PLAY_APP_TABLE).insert(dbPayload);
|
||||
} catch (error) {
|
||||
throw parsePlayAppErrorWithCode(error, `앱 등록에 실패했습니다: ${payload.id}`);
|
||||
}
|
||||
|
||||
const insertedRow = await db(PLAY_APP_TABLE)
|
||||
.where({ id: payload.id })
|
||||
.first<PlayAppRegistryRecord>();
|
||||
|
||||
if (!insertedRow) {
|
||||
const notFoundError = new Error('등록된 앱을 조회하지 못했습니다.') as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 500;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: toResponseItem(insertedRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function updatePlayAppEntry(params: unknown, body: unknown) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const { id } = playAppIdParamsSchema.parse(params);
|
||||
const payload = playAppUpdatePayloadSchema.parse(body);
|
||||
|
||||
if (payload.id && payload.id !== id) {
|
||||
const invalidError = new Error('요청 경로 ID와 본문 ID가 일치하지 않습니다.') as Error & { statusCode?: number };
|
||||
invalidError.statusCode = 409;
|
||||
throw invalidError;
|
||||
}
|
||||
|
||||
const dbPayload = toDbRowPayload(payload);
|
||||
const updated = await db(PLAY_APP_TABLE).where({ id }).update(dbPayload as Record<string, unknown>);
|
||||
|
||||
if (isDbUpdateResultEmpty(updated)) {
|
||||
const notFoundError = new Error(`수정할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 404;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
const updatedRow = await db(PLAY_APP_TABLE).where({ id }).first<PlayAppRegistryRecord>();
|
||||
if (!updatedRow) {
|
||||
const notFoundError = new Error(`수정한 앱을 조회할 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 404;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: toResponseItem(updatedRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function deletePlayAppEntry(params: unknown) {
|
||||
await ensurePlayAppTable();
|
||||
|
||||
const { id } = playAppIdParamsSchema.parse(params);
|
||||
const deleted = await db(PLAY_APP_TABLE).where({ id }).delete('*');
|
||||
if (isDbUpdateResultEmpty(deleted)) {
|
||||
const notFoundError = new Error(`삭제할 앱을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
notFoundError.statusCode = 404;
|
||||
throw notFoundError;
|
||||
}
|
||||
|
||||
if (Array.isArray(deleted) && deleted[0]) {
|
||||
return {
|
||||
ok: true,
|
||||
deletedId: id,
|
||||
item: toResponseItem(deleted[0] as PlayAppRegistryRecord),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deletedId: id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerPlayAppRoutes(app: FastifyInstance) {
|
||||
app.get('/api/play-apps', async (request) => {
|
||||
const query = listQuerySchema.parse(request.query);
|
||||
|
||||
const items = await listPlayAppEntries(query.environment);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/play-apps', async (request, reply) => {
|
||||
if (!ensurePlayAppWriteAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return createPlayAppEntry(request.body);
|
||||
});
|
||||
|
||||
app.put('/api/play-apps/:id', async (request, reply) => {
|
||||
if (!ensurePlayAppWriteAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return updatePlayAppEntry(request.params, request.body);
|
||||
});
|
||||
|
||||
app.delete('/api/play-apps/:id', async (request, reply) => {
|
||||
if (!ensurePlayAppWriteAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return deletePlayAppEntry(request.params);
|
||||
});
|
||||
}
|
||||
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
File diff suppressed because it is too large
Load Diff
116
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
116
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import { env } from '../config/env.js';
|
||||
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
|
||||
|
||||
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
|
||||
const legacyPublicResourceRoot = path.resolve(process.cwd(), '../../../public/resource');
|
||||
|
||||
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
|
||||
isValid: true,
|
||||
start: 5,
|
||||
end: 19,
|
||||
});
|
||||
assert.deepEqual(resolveSingleRange('bytes=-4', 20), {
|
||||
isValid: true,
|
||||
start: 16,
|
||||
end: 19,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSingleRange rejects malformed or out-of-bounds values', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=25-30', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=4-3', 20), { isValid: false });
|
||||
});
|
||||
|
||||
test('resource manager preview serves 206 partial content for byte ranges', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = `range-test-${Date.now()}.wav`;
|
||||
const absolutePath = path.join(fallbackResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, Buffer.from('0123456789', 'utf8'));
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${encodeURIComponent(relativePath)}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
headers: {
|
||||
range: 'bytes=2-5',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 206);
|
||||
assert.equal(response.headers['accept-ranges'], 'bytes');
|
||||
assert.equal(response.headers['content-range'], 'bytes 2-5/10');
|
||||
assert.equal(response.headers['content-length'], '4');
|
||||
assert.equal(response.body, '2345');
|
||||
} finally {
|
||||
await fs.rm(absolutePath, { force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resource manager preview falls back to public/resource legacy artifacts', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = path.join('legacy-preview-test', `sample-${Date.now()}.html`);
|
||||
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, '<!doctype html><html><body>legacy preview</body></html>', 'utf8');
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${relativePath.split(path.sep).map((segment) => encodeURIComponent(segment)).join('/')}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
|
||||
assert.match(response.body, /legacy preview/);
|
||||
} finally {
|
||||
await fs.rm(path.join(legacyPublicResourceRoot, 'legacy-preview-test'), { recursive: true, force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('resource manager preview restores encoded hash fragments in the file name', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = path.join('encoded-preview-test', `sample-${Date.now()}.html`);
|
||||
const absolutePath = path.join(legacyPublicResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, '<!doctype html><html><body>encoded hash preview</body></html>', 'utf8');
|
||||
|
||||
try {
|
||||
for (const encodedSuffix of ['%23option-a', '%2523option-a']) {
|
||||
const encodedPath = relativePath
|
||||
.split(path.sep)
|
||||
.map((segment, index, list) =>
|
||||
index === list.length - 1
|
||||
? `${encodeURIComponent(segment)}${encodedSuffix}`
|
||||
: encodeURIComponent(segment),
|
||||
)
|
||||
.join('/');
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${encodedPath}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(String(response.headers['content-type'] ?? ''), /text\/html/i);
|
||||
assert.match(response.body, /encoded hash preview/);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(path.join(legacyPublicResourceRoot, 'encoded-preview-test'), { recursive: true, force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
303
etc/servers/work-server/src/routes/resource-manager.ts
Normal file
303
etc/servers/work-server/src/routes/resource-manager.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
55
etc/servers/work-server/src/routes/runtime.ts
Normal file
55
etc/servers/work-server/src/routes/runtime.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getActiveChatService } from '../services/chat-service.js';
|
||||
import {
|
||||
beginRuntimeDrain,
|
||||
endRuntimeDrain,
|
||||
getRuntimeDrainSnapshot,
|
||||
} from '../services/runtime-drain-service.js';
|
||||
|
||||
const runtimeDrainBodySchema = z.object({
|
||||
draining: z.boolean(),
|
||||
});
|
||||
|
||||
function buildRuntimeResponse() {
|
||||
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...getRuntimeDrainSnapshot(),
|
||||
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerRuntimeRoutes(app: FastifyInstance) {
|
||||
app.get('/api/runtime', async () => buildRuntimeResponse());
|
||||
|
||||
app.post('/api/runtime/recover-interrupted-chat', async () => {
|
||||
const recovered = await getActiveChatService()?.recoverInterruptedSessions();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
recovered: recovered ?? {
|
||||
sessionCount: 0,
|
||||
restartedCount: 0,
|
||||
requeuedCount: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/runtime/drain', async (request) => {
|
||||
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});
|
||||
|
||||
if (draining) {
|
||||
beginRuntimeDrain();
|
||||
} else {
|
||||
endRuntimeDrain();
|
||||
}
|
||||
|
||||
return buildRuntimeResponse();
|
||||
});
|
||||
}
|
||||
0
etc/servers/work-server/src/routes/schema.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/schema.ts
Executable file → Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user