Compare commits
13 Commits
release
...
hotfix/gua
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a1e622546 | |||
| 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
|
||||
|
||||
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/
|
||||
|
||||
47
AGENTS.md
47
AGENTS.md
@@ -9,21 +9,22 @@
|
||||
### 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`이 이미 있더라도 사용자 요청 없이 당연한 전제로 유지하지 말고, 이벤트 기반·명시적 새로고침·단발성 요청으로 대체 가능한지 먼저 검토한다
|
||||
|
||||
### 요청 해석 규칙
|
||||
|
||||
@@ -31,7 +32,7 @@
|
||||
* 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다
|
||||
* 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다
|
||||
* 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다
|
||||
* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석하고, 신규 `feature/*` 브랜치 생성부터 `release` 반영, `main` 일괄반영, 프로젝트 루트 `pull`까지 포함한 흐름을 적용한다
|
||||
* 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석한다
|
||||
* 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다
|
||||
* 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다
|
||||
* 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다
|
||||
@@ -43,6 +44,7 @@
|
||||
* 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다
|
||||
* `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다
|
||||
* 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다
|
||||
* 임시 스크린샷, 테스트 캡처, 대용량 리소스 파일은 기본적으로 Git 커밋을 차단하고, 의도적 자산 커밋일 때만 명시적으로 예외 처리한다
|
||||
* 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다
|
||||
|
||||
---
|
||||
@@ -50,35 +52,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 build --watch`로 정적 산출물을 자동 재빌드합니다.
|
||||
- 따라서 `https://preview.sm-home.cloud/`에서는 Vite HMR처럼 즉시 DOM이 바뀌지는 않지만, 소스 저장 후 재빌드가 끝나면 브라우저 새로고침만으로 최신 화면을 확인할 수 있습니다.
|
||||
- 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,28 @@
|
||||
services:
|
||||
preview-app:
|
||||
container_name: ai-code-app-preview
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.preview
|
||||
image: node:${NODE_VERSION:-22.22.2}-bookworm
|
||||
user: "0:0"
|
||||
working_dir: /app
|
||||
ports:
|
||||
- "${PREVIEW_APP_PORT:-4173}:5173"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ./.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"
|
||||
command: >
|
||||
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173 --strictPort"
|
||||
restart: unless-stopped
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
-
|
||||
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"
|
||||
|
||||
17
etc/commands/server-command/restart-test.sh
Executable file → Normal file
17
etc/commands/server-command/restart-test.sh
Executable file → Normal file
@@ -7,16 +7,23 @@ 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}"
|
||||
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
|
||||
fi
|
||||
git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
|
||||
if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then
|
||||
git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \
|
||||
|| git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
fi
|
||||
|
||||
git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVER_COMMAND_SERVICE"
|
||||
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
etc/commands/server-command/restart-work-server.sh
Executable file → Normal file
4
etc/commands/server-command/restart-work-server.sh
Executable file → Normal file
@@ -4,6 +4,8 @@ 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"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server
|
||||
|
||||
exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server
|
||||
|
||||
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*
|
||||
@@ -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"]
|
||||
|
||||
@@ -57,17 +57,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://test.sm-home.cloud/`**, 소스 변경 검증과 최종 화면 테스트는 **`https://preview.sm-home.cloud/`** 기준으로 진행합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
|
||||
채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat/<chat-session-id>/resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다.
|
||||
|
||||
@@ -77,15 +71,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 +103,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 +113,12 @@ 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` 외에 `targetClientIds`도 받을 수 있습니다.
|
||||
- `targetClientIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 클라이언트에게만 알림을 보냅니다.
|
||||
- 웹푸시 구독과 PWA iOS 토큰 등록 시 서버는 `appOrigin`, `appDomain`도 함께 저장합니다.
|
||||
- `POST /api/notifications/send`에 `targetAppOrigins`, `targetAppDomains`를 넣으면 해당 앱 도메인/오리진으로 등록된 구독에만 발송할 수 있습니다.
|
||||
- `GET /api/notifications/subscriptions/web`로 현재 저장된 웹푸시 구독의 `deviceId`, `appOrigin`, `appDomain`, `enabled` 상태를 확인할 수 있습니다.
|
||||
- 같은 알림을 교체하려면 DB 삭제 대신 `data.notificationKey` 또는 `threadId`를 고정값으로 보내세요. 서비스워커가 이 값을 브라우저 알림 `tag`로 사용해 이전 알림을 대체합니다.
|
||||
|
||||
@@ -12,7 +12,6 @@ services:
|
||||
user: "0:0"
|
||||
group_add:
|
||||
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||
cpus: 1.5
|
||||
mem_limit: 2048m
|
||||
working_dir: /app
|
||||
env_file:
|
||||
@@ -23,6 +22,8 @@ services:
|
||||
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 +42,7 @@ services:
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
WORK_SERVER_DIST_DIR: /tmp/work-server-dist
|
||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||
networks:
|
||||
@@ -51,3 +53,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,8 @@
|
||||
"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: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"
|
||||
|
||||
96
etc/servers/work-server/scripts/container-supervisor.sh
Normal file
96
etc/servers/work-server/scripts/container-supervisor.sh
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
APP_ROOT="${APP_ROOT:-/app}"
|
||||
STATE_DIR="${WORK_SERVER_STATE_DIR:-/tmp/work-server-runtime}"
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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}`);
|
||||
|
||||
6
etc/servers/work-server/src/app.ts
Executable file → Normal file
6
etc/servers/work-server/src/app.ts
Executable file → Normal file
@@ -10,8 +10,11 @@ import { registerAppConfigRoutes } from './routes/app-config.js';
|
||||
import { registerChatRoutes } from './routes/chat.js';
|
||||
import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerStockAlertRoutes } from './routes/stock-alert.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';
|
||||
@@ -33,10 +36,13 @@ export function createApp() {
|
||||
app.register(registerSchemaRoutes);
|
||||
app.register(registerDdlRoutes);
|
||||
app.register(registerCrudRoutes);
|
||||
app.register(registerStockAlertRoutes);
|
||||
app.register(registerErrorLogRoutes);
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
app.register(registerResourceManagerRoutes);
|
||||
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();
|
||||
@@ -69,7 +69,7 @@ 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_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
||||
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||
@@ -80,6 +80,7 @@ const envSchema = z.object({
|
||||
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
199
etc/servers/work-server/src/routes/app-config.ts
Executable file → Normal file
199
etc/servers/work-server/src/routes/app-config.ts
Executable file → Normal file
@@ -1,23 +1,98 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-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';
|
||||
|
||||
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) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -33,15 +108,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,6 +130,95 @@ 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/app-config', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
@@ -72,11 +241,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({
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
13
etc/servers/work-server/src/routes/chat.test.ts
Normal file
13
etc/servers/work-server/src/routes/chat.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType } from './chat.js';
|
||||
|
||||
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
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');
|
||||
});
|
||||
113
etc/servers/work-server/src/routes/chat.ts
Executable file → Normal file
113
etc/servers/work-server/src/routes/chat.ts
Executable file → Normal file
@@ -6,14 +6,17 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
|
||||
import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
|
||||
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
||||
import {
|
||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||
clearChatConversationData,
|
||||
createChatConversation,
|
||||
deleteUnansweredChatConversationRequest,
|
||||
deleteChatConversation,
|
||||
ensureChatConversationTables,
|
||||
getChatConversation,
|
||||
listChatSourceChangeSnapshots,
|
||||
listChatConversationDetailPage,
|
||||
listChatConversations,
|
||||
markChatConversationResponsesRead,
|
||||
@@ -21,13 +24,14 @@ import {
|
||||
updateChatConversationContext,
|
||||
} from '../services/chat-room-service.js';
|
||||
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
||||
import { resolveMainProjectRoot } from '../services/main-project-root-service.js';
|
||||
|
||||
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
||||
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
||||
|
||||
function resolveStaticContentType(filePath: string) {
|
||||
export function resolveStaticContentType(filePath: string) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
@@ -39,11 +43,15 @@ function resolveStaticContentType(filePath: string) {
|
||||
case '.cjs':
|
||||
case '.json':
|
||||
case '.css':
|
||||
case '.html':
|
||||
case '.md':
|
||||
case '.txt':
|
||||
case '.diff':
|
||||
return 'text/plain; charset=utf-8';
|
||||
case '.html':
|
||||
case '.htm':
|
||||
return 'text/html; charset=utf-8';
|
||||
case '.md':
|
||||
case '.markdown':
|
||||
return 'text/markdown; charset=utf-8';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.png':
|
||||
@@ -136,7 +144,7 @@ function sanitizeChatAttachmentFileName(fileName: string) {
|
||||
}
|
||||
|
||||
function resolveChatAttachmentRepoPath() {
|
||||
return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH);
|
||||
return resolveMainProjectRoot();
|
||||
}
|
||||
|
||||
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
||||
@@ -148,7 +156,22 @@ function canViewAllConversations(request: { headers: Record<string, unknown> })
|
||||
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
|
||||
}
|
||||
|
||||
function applyChatApiNoStoreHeaders(reply: FastifyReply) {
|
||||
reply.header('Cache-Control', 'no-store, no-cache, max-age=0, must-revalidate');
|
||||
reply.header('Pragma', 'no-cache');
|
||||
reply.header('Expires', '0');
|
||||
reply.header('Surrogate-Control', 'no-store');
|
||||
}
|
||||
|
||||
export async function registerChatRoutes(app: FastifyInstance) {
|
||||
app.addHook('onSend', async (request, reply, payload) => {
|
||||
if (request.method.toUpperCase() === 'GET' && request.url.startsWith('/api/chat')) {
|
||||
applyChatApiNoStoreHeaders(reply);
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
||||
@@ -175,7 +198,22 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
|
||||
const viewerClientId = getClientIdHeader(request);
|
||||
const clientId = canViewAllConversations(request) ? null : viewerClientId;
|
||||
const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null);
|
||||
const items = await listChatConversations(clientId, query.limit ?? 200, viewerClientId || null);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/source-changes', async (request) => {
|
||||
const query = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
const viewerClientId = getClientIdHeader(request);
|
||||
const clientId = canViewAllConversations(request) ? null : viewerClientId;
|
||||
const items = await listChatSourceChangeSnapshots(clientId, query.limit ?? 300);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -224,7 +262,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
);
|
||||
const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/'));
|
||||
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await ensureChatSessionResourceDirectories(resolveChatAttachmentRepoPath(), payload.sessionId);
|
||||
await writeFile(absolutePath, buffer);
|
||||
|
||||
return {
|
||||
@@ -358,10 +396,12 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
title: z.string().trim().max(200).optional(),
|
||||
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
|
||||
chatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||
generalSectionName: z.string().trim().max(120).optional().nullable(),
|
||||
contextLabel: z.string().trim().max(200).optional(),
|
||||
contextDescription: z.string().trim().max(2000).optional(),
|
||||
contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional(),
|
||||
notifyOffline: z.boolean().optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
@@ -370,8 +410,10 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
sessionId: payload.sessionId,
|
||||
clientId: clientId || null,
|
||||
title: payload.title ?? '새 대화',
|
||||
requestBadgeLabel: payload.requestBadgeLabel ?? null,
|
||||
chatTypeId: payload.chatTypeId ?? null,
|
||||
lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null,
|
||||
generalSectionName: payload.generalSectionName ?? null,
|
||||
contextLabel: payload.contextLabel ?? null,
|
||||
contextDescription: payload.contextDescription ?? null,
|
||||
notifyOffline: payload.notifyOffline ?? true,
|
||||
@@ -401,7 +443,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const messageLimit = query.limit ?? 6;
|
||||
const messageLimit = query.limit ?? 8;
|
||||
const detailPage = await listChatConversationDetailPage(params.sessionId, {
|
||||
limit: messageLimit,
|
||||
beforeMessageId: query.beforeMessageId ?? null,
|
||||
@@ -485,10 +527,12 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
|
||||
chatTypeId: z.string().trim().max(120).optional().nullable(),
|
||||
lastChatTypeId: z.string().trim().max(120).optional().nullable(),
|
||||
generalSectionName: z.string().trim().max(120).optional().nullable(),
|
||||
contextLabel: z.string().trim().max(200).optional().nullable(),
|
||||
contextDescription: z.string().trim().max(2000).optional().nullable(),
|
||||
contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional().nullable(),
|
||||
notifyOffline: z.boolean().optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
@@ -503,11 +547,22 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
|
||||
const item = await updateChatConversationContext(params.sessionId, {
|
||||
title: payload.title ?? current.title,
|
||||
requestBadgeLabel:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'requestBadgeLabel') ? payload.requestBadgeLabel ?? null : undefined,
|
||||
clientId: current.clientId,
|
||||
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
|
||||
lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId,
|
||||
contextLabel: payload.contextLabel ?? current.contextLabel,
|
||||
contextDescription: payload.contextDescription ?? current.contextDescription,
|
||||
chatTypeId: Object.prototype.hasOwnProperty.call(payload, 'chatTypeId') ? payload.chatTypeId ?? null : undefined,
|
||||
lastChatTypeId:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'lastChatTypeId') ? payload.lastChatTypeId ?? null : undefined,
|
||||
generalSectionName:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'generalSectionName')
|
||||
? payload.generalSectionName ?? null
|
||||
: undefined,
|
||||
contextLabel:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'contextLabel') ? payload.contextLabel ?? null : undefined,
|
||||
contextDescription:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'contextDescription')
|
||||
? payload.contextDescription ?? null
|
||||
: undefined,
|
||||
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
|
||||
});
|
||||
|
||||
@@ -540,4 +595,34 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
sessionId: params.sessionId,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/conversations/:sessionId/clear', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
|
||||
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||
const current = await getChatConversation(params.sessionId, clientId || null);
|
||||
|
||||
if (!current) {
|
||||
return reply.code(404).send({
|
||||
message: '초기화할 채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
getActiveChatService()?.resetSessionData(params.sessionId);
|
||||
chatRuntimeService.clearSession(params.sessionId);
|
||||
const item = await clearChatConversationData(params.sessionId, clientId || null);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅방 데이터 초기화 후 다시 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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
0
etc/servers/work-server/src/routes/health.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/health.ts
Executable file → Normal file
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;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
239
etc/servers/work-server/src/routes/resource-manager.ts
Normal file
239
etc/servers/work-server/src/routes/resource-manager.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
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(),
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.type(preview.contentType);
|
||||
return reply.send(preview.stream);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
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
169
etc/servers/work-server/src/routes/server-command.ts
Executable file → Normal file
169
etc/servers/work-server/src/routes/server-command.ts
Executable file → Normal file
@@ -2,16 +2,76 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
|
||||
import {
|
||||
cancelServerRestartReservation,
|
||||
confirmServerRestartReservation,
|
||||
getRestartReservationWorkloadSummary,
|
||||
requestImmediateRestartRecovery,
|
||||
getServerRestartReservation,
|
||||
scheduleServerRestartReservation,
|
||||
} from '../services/server-restart-reservation-service.js';
|
||||
|
||||
const serverCommandParamSchema = z.object({
|
||||
key: z.enum(serverCommandKeys),
|
||||
});
|
||||
|
||||
const restartReservationBodySchema = z.object({
|
||||
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
|
||||
});
|
||||
|
||||
function getImmediateRestartBlockInfo(
|
||||
key: z.infer<typeof serverCommandParamSchema>['key'],
|
||||
workloadSummary: Awaited<ReturnType<typeof getRestartReservationWorkloadSummary>>,
|
||||
) {
|
||||
const codexPendingCount = workloadSummary.codexRunningCount + workloadSummary.codexQueuedCount;
|
||||
const automationPendingCount = workloadSummary.automationRunningCount + workloadSummary.automationQueuedCount;
|
||||
|
||||
if (key === 'test') {
|
||||
const pendingCount = codexPendingCount + automationPendingCount;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
return {
|
||||
pendingCount,
|
||||
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key === 'work-server' && automationPendingCount > 0) {
|
||||
return {
|
||||
pendingCount: automationPendingCount,
|
||||
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestClientId(request: FastifyRequest) {
|
||||
const clientIdHeader = request.headers['x-client-id'];
|
||||
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestAppOrigin(request: FastifyRequest) {
|
||||
const appOriginHeader = request.headers['x-app-origin'];
|
||||
const appOrigin = Array.isArray(appOriginHeader) ? appOriginHeader[0] : appOriginHeader;
|
||||
|
||||
if (appOrigin?.trim()) {
|
||||
return appOrigin.trim();
|
||||
}
|
||||
|
||||
const originHeader = request.headers.origin;
|
||||
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
|
||||
return origin?.trim() ?? '';
|
||||
}
|
||||
|
||||
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
@@ -42,13 +102,114 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const { key } = serverCommandParamSchema.parse(request.params);
|
||||
const result = await restartServerCommand(key);
|
||||
|
||||
if (key === 'test' || key === 'work-server') {
|
||||
const workloadSummary = await getRestartReservationWorkloadSummary();
|
||||
const blockInfo = getImmediateRestartBlockInfo(key, workloadSummary);
|
||||
|
||||
if (blockInfo) {
|
||||
reply.status(409);
|
||||
return {
|
||||
ok: false,
|
||||
message: blockInfo.message,
|
||||
workloadSummary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand(key);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
|
||||
if (key !== 'test' && key !== 'work-server') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!/(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await requestImmediateRestartRecovery(app.log, key, message);
|
||||
const server = (await listServerCommands()).find((item) => item.key === key);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`${key} 서버 상태를 다시 읽지 못했습니다.`);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: server,
|
||||
commandOutput: `${message}\n\n빌드 실패를 감지해 Codex 자동 개선과 재기동 재시도를 시작했습니다.`,
|
||||
restartState: 'accepted' as const,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
item: await getServerRestartReservation(),
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = restartReservationBodySchema.parse(payload ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await scheduleServerRestartReservation({
|
||||
clientId: getRequestClientId(request),
|
||||
appOrigin: getRequestAppOrigin(request),
|
||||
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await confirmServerRestartReservation(app.log),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await cancelServerRestartReservation(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
146
etc/servers/work-server/src/routes/stock-alert.ts
Normal file
146
etc/servers/work-server/src/routes/stock-alert.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
STOCK_ALERT_TYPE_OPTIONS,
|
||||
createStockAlert,
|
||||
deleteStockAlert,
|
||||
listStockAlerts,
|
||||
sendCurrentPriceStockAlertWebPush,
|
||||
searchStockAlertCandidates,
|
||||
saveStockAlerts,
|
||||
updateStockAlert,
|
||||
updateStockAlertLayoutFeatureDescription,
|
||||
} from '../services/stock-alert-service.js';
|
||||
|
||||
const filterTypeSchema = z.enum(STOCK_ALERT_TYPE_OPTIONS.map((option) => option.value) as [string, ...string[]]).default('all');
|
||||
|
||||
const stockAlertMutationSchema = z
|
||||
.object({
|
||||
id: z.string().trim().optional(),
|
||||
stockCode: z.string().trim().optional(),
|
||||
stockName: z.string().trim().optional(),
|
||||
alertType: z.string().trim().optional(),
|
||||
alertTypes: z.array(z.string().trim()).optional(),
|
||||
})
|
||||
.transform((value) => ({
|
||||
id: value.id,
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
alertTypes: value.alertTypes?.length
|
||||
? value.alertTypes
|
||||
: value.alertType?.trim()
|
||||
? [value.alertType.trim()]
|
||||
: [],
|
||||
}));
|
||||
|
||||
const stockAlertMutationBodySchema = z
|
||||
.object({
|
||||
stockCode: z.string().trim().optional(),
|
||||
stockName: z.string().trim().optional(),
|
||||
alertType: z.string().trim().optional(),
|
||||
alertTypes: z.array(z.string().trim()).optional(),
|
||||
})
|
||||
.transform((value) => ({
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
alertTypes: value.alertTypes?.length
|
||||
? value.alertTypes
|
||||
: value.alertType?.trim()
|
||||
? [value.alertType.trim()]
|
||||
: [],
|
||||
}));
|
||||
|
||||
export async function registerStockAlertRoutes(app: FastifyInstance) {
|
||||
app.get('/api/stock-alerts/search', async (request) => {
|
||||
const query = z
|
||||
.object({
|
||||
query: z.string().trim().min(1),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
})
|
||||
.parse(request.query ?? {});
|
||||
|
||||
const items = await searchStockAlertCandidates(query.query, query.limit ?? 20);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/stock-alerts', async (request) => {
|
||||
const query = z
|
||||
.object({
|
||||
alertType: filterTypeSchema.optional(),
|
||||
})
|
||||
.parse(request.query ?? {});
|
||||
const alertType = (query.alertType ?? 'all') as 'all' | 'price' | 'top3';
|
||||
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
const items = await listStockAlerts(alertType);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/stock-alerts/notify-current-price', async () => {
|
||||
const result = await sendCurrentPriceStockAlertWebPush();
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
skipped: Boolean(result.web?.skipped),
|
||||
title: result.title,
|
||||
body: result.body,
|
||||
itemCount: result.itemCount,
|
||||
lines: result.lines,
|
||||
ios: result.ios,
|
||||
web: result.web,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/stock-alerts', async (request) => {
|
||||
const payload = stockAlertMutationSchema.parse(request.body ?? {});
|
||||
const item = await createStockAlert(payload);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/stock-alerts/:id', async (request) => {
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const payload = stockAlertMutationBodySchema.parse(request.body ?? {});
|
||||
const item = await updateStockAlert(params.id, payload);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/stock-alerts/batch', async (request) => {
|
||||
const payload = z.object({ items: z.array(stockAlertMutationSchema).default([]) }).parse(request.body ?? {});
|
||||
const items = await saveStockAlerts(payload.items);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/stock-alerts/:id', async (request) => {
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
await deleteStockAlert(params.id);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: params.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
86
etc/servers/work-server/src/routes/text-memo.ts
Normal file
86
etc/servers/work-server/src/routes/text-memo.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createTextMemoNote,
|
||||
deleteTextMemoNote,
|
||||
importTextMemoNotes,
|
||||
listTextMemoNotes,
|
||||
textMemoNoteCreateSchema,
|
||||
textMemoNoteImportSchema,
|
||||
textMemoNoteUpdateSchema,
|
||||
updateTextMemoLayoutFeatureDescription,
|
||||
updateTextMemoNote,
|
||||
} from '../services/text-memo-service.js';
|
||||
|
||||
function resolveClientId(headers: Record<string, unknown>) {
|
||||
return String(headers['x-client-id'] ?? '').trim();
|
||||
}
|
||||
|
||||
export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.get('/api/text-memo/notes', async (request) => {
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
const items = await listTextMemoNotes(resolveClientId(request.headers));
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/text-memo/notes', async (request) => {
|
||||
const payload = textMemoNoteCreateSchema.parse(request.body ?? {});
|
||||
const item = await createTextMemoNote(resolveClientId(request.headers), payload);
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/text-memo/notes/import', async (request) => {
|
||||
const payload = textMemoNoteImportSchema.parse(request.body ?? {});
|
||||
const items = await importTextMemoNotes(resolveClientId(request.headers), payload);
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/text-memo/notes/:noteId', async (request, reply) => {
|
||||
const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId);
|
||||
const payload = textMemoNoteUpdateSchema.parse(request.body ?? {});
|
||||
const item = await updateTextMemoNote(resolveClientId(request.headers), noteId, payload);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 메모를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/text-memo/notes/:noteId', async (request, reply) => {
|
||||
const noteId = z.string().trim().min(1).parse((request.params as { noteId: string }).noteId);
|
||||
const deleted = await deleteTextMemoNote(resolveClientId(request.headers), noteId);
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 메모를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
0
etc/servers/work-server/src/routes/visitor-history.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/visitor-history.ts
Executable file → Normal file
42
etc/servers/work-server/src/server.ts
Executable file → Normal file
42
etc/servers/work-server/src/server.ts
Executable file → Normal file
@@ -2,24 +2,29 @@ import { env } from './config/env.js';
|
||||
import { db } from './db/client.js';
|
||||
import { createApp } from './app.js';
|
||||
import { ChatService } from './services/chat-service.js';
|
||||
import { clearAllChatConversationJobStates, ensureChatConversationTables } from './services/chat-room-service.js';
|
||||
import { ensureChatConversationTables } from './services/chat-room-service.js';
|
||||
import { shutdownNotificationProvider } from './services/notification-service.js';
|
||||
import { ServerRestartReservationWorker } from './services/server-restart-reservation-service.js';
|
||||
import { PlanWorker } from './workers/plan-worker.js';
|
||||
|
||||
const app = createApp();
|
||||
const planWorker = new PlanWorker(app.log);
|
||||
const serverRestartReservationWorker = new ServerRestartReservationWorker(app.log);
|
||||
const chatService = new ChatService(app.log);
|
||||
const startedAt = Date.now();
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
app.server.on('upgrade', chatService.attachUpgradeHandler());
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await ensureChatConversationTables();
|
||||
await clearAllChatConversationJobStates();
|
||||
await chatService.recoverInterruptedSessions();
|
||||
await app.listen({
|
||||
host: '0.0.0.0',
|
||||
port: env.PORT,
|
||||
});
|
||||
planWorker.start();
|
||||
serverRestartReservationWorker.start();
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
@@ -27,14 +32,33 @@ async function start() {
|
||||
}
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
app.log.info(`Received ${signal}, closing server`);
|
||||
if (shutdownPromise) {
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
await planWorker.stop();
|
||||
chatService.close();
|
||||
await app.close();
|
||||
await shutdownNotificationProvider();
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
shutdownPromise = (async () => {
|
||||
app.log.warn({
|
||||
signal,
|
||||
pid: process.pid,
|
||||
uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
|
||||
rssBytes: process.memoryUsage().rss,
|
||||
}, 'Received shutdown signal');
|
||||
|
||||
try {
|
||||
await planWorker.stop();
|
||||
await serverRestartReservationWorker.stop();
|
||||
chatService.close();
|
||||
await app.close();
|
||||
await shutdownNotificationProvider();
|
||||
await db.destroy();
|
||||
process.exitCode = 0;
|
||||
} catch (error) {
|
||||
app.log.error({ error, signal }, 'Failed to shut down cleanly');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
})();
|
||||
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
|
||||
465
etc/servers/work-server/src/services/app-config-service.js
Normal file
465
etc/servers/work-server/src/services/app-config-service.js
Normal file
@@ -0,0 +1,465 @@
|
||||
"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);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.APP_CONFIG_TABLE = void 0;
|
||||
exports.resolveAppConfigByOrigin = resolveAppConfigByOrigin;
|
||||
exports.getAppConfig = getAppConfig;
|
||||
exports.sanitizePersistedChatTypes = sanitizePersistedChatTypes;
|
||||
exports.normalizeAppConfigSnapshot = normalizeAppConfigSnapshot;
|
||||
exports.getAppConfigSnapshot = getAppConfigSnapshot;
|
||||
exports.upsertAppConfig = upsertAppConfig;
|
||||
exports.getChatTypesConfig = getChatTypesConfig;
|
||||
exports.upsertChatTypesConfig = upsertChatTypesConfig;
|
||||
var client_js_1 = require("../db/client.js");
|
||||
exports.APP_CONFIG_TABLE = 'app_configs';
|
||||
var CHAT_TYPES_CONFIG_KEY = 'chatTypes';
|
||||
var CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings';
|
||||
var SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs';
|
||||
var DEFAULT_CHAT_APP_CONFIG = {
|
||||
maxContextMessages: 12,
|
||||
maxContextChars: 3200,
|
||||
codexLiveMaxExecutionSeconds: 600,
|
||||
codexLiveIdleTimeoutSeconds: 180,
|
||||
receiveRoomNotifications: true,
|
||||
restartReservationCompletionDelaySeconds: 10,
|
||||
};
|
||||
function ensureAppConfigTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.APP_CONFIG_TABLE)];
|
||||
case 1:
|
||||
hasTable = _b.sent();
|
||||
if (!!hasTable) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.createTable(exports.APP_CONFIG_TABLE, function (table) {
|
||||
table.increments('id').primary();
|
||||
table.jsonb('config_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now());
|
||||
})];
|
||||
case 2:
|
||||
_b.sent();
|
||||
return [2 /*return*/];
|
||||
case 3:
|
||||
requiredColumns = [
|
||||
['config_json', function (table) { return table.jsonb('config_json').notNullable().defaultTo('{}'); }],
|
||||
['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }],
|
||||
];
|
||||
_loop_1 = function (columnName, createColumn) {
|
||||
var hasColumn;
|
||||
return __generator(this, function (_c) {
|
||||
switch (_c.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.APP_CONFIG_TABLE, columnName)];
|
||||
case 1:
|
||||
hasColumn = _c.sent();
|
||||
if (!!hasColumn) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.APP_CONFIG_TABLE, function (table) {
|
||||
createColumn(table);
|
||||
})];
|
||||
case 2:
|
||||
_c.sent();
|
||||
_c.label = 3;
|
||||
case 3: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
};
|
||||
_i = 0, requiredColumns_1 = requiredColumns;
|
||||
_b.label = 4;
|
||||
case 4:
|
||||
if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7];
|
||||
_a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1];
|
||||
return [5 /*yield**/, _loop_1(columnName, createColumn)];
|
||||
case 5:
|
||||
_b.sent();
|
||||
_b.label = 6;
|
||||
case 6:
|
||||
_i++;
|
||||
return [3 /*break*/, 4];
|
||||
case 7: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function normalizeAppOrigin(appOrigin) {
|
||||
if (typeof appOrigin !== 'string') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
var url = new URL(appOrigin.trim());
|
||||
return url.origin;
|
||||
}
|
||||
catch (_a) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
function normalizeAppDomain(appDomain) {
|
||||
return typeof appDomain === 'string' ? appDomain.trim().toLowerCase() : '';
|
||||
}
|
||||
function deepMergeConfigRecords(base, override) {
|
||||
var merged = __assign({}, base);
|
||||
for (var _i = 0, _a = Object.entries(override); _i < _a.length; _i++) {
|
||||
var _b = _a[_i], key = _b[0], value = _b[1];
|
||||
var current = merged[key];
|
||||
var canMergeObject = value &&
|
||||
current &&
|
||||
typeof value === 'object' &&
|
||||
typeof current === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
!Array.isArray(current);
|
||||
merged[key] = canMergeObject
|
||||
? deepMergeConfigRecords(normalizeConfigRecord(current), normalizeConfigRecord(value))
|
||||
: value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
function getBaseAppConfigRecord(value) {
|
||||
var normalized = normalizeConfigRecord(value);
|
||||
var nextConfig = __assign({}, normalized);
|
||||
delete nextConfig[SCOPED_APP_CONFIGS_KEY];
|
||||
return nextConfig;
|
||||
}
|
||||
function getScopedAppConfigsRecord(value) {
|
||||
return normalizeConfigRecord(normalizeConfigRecord(value)[SCOPED_APP_CONFIGS_KEY]);
|
||||
}
|
||||
function resolveScopedAppConfig(value, appOrigin) {
|
||||
var normalizedAppOrigin = normalizeAppOrigin(appOrigin);
|
||||
if (!normalizedAppOrigin) {
|
||||
return null;
|
||||
}
|
||||
var scopedEntry = normalizeConfigRecord(getScopedAppConfigsRecord(value)[normalizedAppOrigin]);
|
||||
var scopedConfig = normalizeConfigRecord(scopedEntry.config);
|
||||
return Object.keys(scopedConfig).length > 0 ? scopedConfig : null;
|
||||
}
|
||||
function mergeScopedAppConfig(currentConfig, nextConfig, appOrigin, appDomain) {
|
||||
var _a, _b;
|
||||
var _c;
|
||||
var normalizedAppOrigin = normalizeAppOrigin(appOrigin);
|
||||
if (!normalizedAppOrigin) {
|
||||
return deepMergeConfigRecords(currentConfig, nextConfig);
|
||||
}
|
||||
var baseConfig = getBaseAppConfigRecord(currentConfig);
|
||||
var scopedConfigs = getScopedAppConfigsRecord(currentConfig);
|
||||
var currentResolvedScopedConfig = (_c = resolveScopedAppConfig(currentConfig, normalizedAppOrigin)) !== null && _c !== void 0 ? _c : baseConfig;
|
||||
var nextResolvedScopedConfig = deepMergeConfigRecords(currentResolvedScopedConfig, nextConfig);
|
||||
var normalizedAppDomain = normalizeAppDomain(appDomain);
|
||||
return __assign(__assign({}, baseConfig), (_a = {}, _a[SCOPED_APP_CONFIGS_KEY] = __assign(__assign({}, scopedConfigs), (_b = {}, _b[normalizedAppOrigin] = {
|
||||
config: nextResolvedScopedConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
appDomain: normalizedAppDomain || null,
|
||||
}, _b)), _a));
|
||||
}
|
||||
function resolveAppConfigByOrigin(value, appOrigin) {
|
||||
var baseConfig = getBaseAppConfigRecord(value);
|
||||
var scopedConfig = resolveScopedAppConfig(value, appOrigin);
|
||||
if (!scopedConfig) {
|
||||
return baseConfig;
|
||||
}
|
||||
return deepMergeConfigRecords(baseConfig, scopedConfig);
|
||||
}
|
||||
function getAppConfig(appOrigin) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var row;
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, ensureAppConfigTable()];
|
||||
case 1:
|
||||
_b.sent();
|
||||
return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE).first()];
|
||||
case 2:
|
||||
row = _b.sent();
|
||||
if (!row) {
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
if (typeof row.config_json === 'string') {
|
||||
try {
|
||||
return [2 /*return*/, resolveAppConfigByOrigin(JSON.parse(row.config_json), appOrigin)];
|
||||
}
|
||||
catch (_c) {
|
||||
return [2 /*return*/, {}];
|
||||
}
|
||||
}
|
||||
return [2 /*return*/, resolveAppConfigByOrigin((_a = row.config_json) !== null && _a !== void 0 ? _a : {}, appOrigin)];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function normalizeConfigRecord(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function normalizeIntegerInRange(value, fallback, min, max) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(max, Math.max(min, Math.round(value)));
|
||||
}
|
||||
function normalizeText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
function normalizePermissions(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return ['token-user'];
|
||||
}
|
||||
var permissions = Array.from(new Set(value.filter(function (item) { return item === 'guest' || item === 'token-user'; })));
|
||||
return permissions.length > 0 ? permissions : ['token-user'];
|
||||
}
|
||||
function normalizePositiveSortOrder(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return Number.NaN;
|
||||
}
|
||||
var nextValue = Math.trunc(value);
|
||||
return nextValue > 0 ? nextValue : Number.NaN;
|
||||
}
|
||||
function normalizeChatTypeRecord(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
var record = value;
|
||||
var name = normalizeText(record.name);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: normalizeText(record.id) || "chat-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)),
|
||||
name: name,
|
||||
sortOrder: normalizePositiveSortOrder(record.sortOrder),
|
||||
description: normalizeText(record.description),
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
function buildChatTypeSemanticKey(record) {
|
||||
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
function compareUpdatedAt(left, right) {
|
||||
var leftTime = Date.parse(left.updatedAt);
|
||||
var rightTime = Date.parse(right.updatedAt);
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function sanitizeChatTypes(items) {
|
||||
var normalized = items
|
||||
.map(function (item) { return normalizeChatTypeRecord(item); })
|
||||
.filter(function (item) { return Boolean(item); });
|
||||
var byId = new Map();
|
||||
var bySemanticKey = new Map();
|
||||
for (var _i = 0, normalized_1 = normalized; _i < normalized_1.length; _i++) {
|
||||
var item = normalized_1[_i];
|
||||
var current = byId.get(item.id);
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
}
|
||||
for (var _a = 0, _b = byId.values(); _a < _b.length; _a++) {
|
||||
var item = _b[_a];
|
||||
var semanticKey = buildChatTypeSemanticKey(item);
|
||||
var current = bySemanticKey.get(semanticKey);
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
return Array.from(bySemanticKey.values())
|
||||
.sort(function (left, right) {
|
||||
var leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null;
|
||||
var rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null;
|
||||
if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) {
|
||||
return leftSortOrder - rightSortOrder;
|
||||
}
|
||||
if (leftSortOrder !== null && rightSortOrder === null) {
|
||||
return -1;
|
||||
}
|
||||
if (leftSortOrder === null && rightSortOrder !== null) {
|
||||
return 1;
|
||||
}
|
||||
var nameCompare = left.name.localeCompare(right.name, 'ko-KR');
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
return compareUpdatedAt(left, right);
|
||||
})
|
||||
.map(function (item, index) { return (__assign(__assign({}, item), { sortOrder: index + 1 })); });
|
||||
}
|
||||
function sanitizePersistedChatTypes(items) {
|
||||
return sanitizeChatTypes(items);
|
||||
}
|
||||
function isSameChatTypeList(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every(function (item, index) {
|
||||
var target = right[index];
|
||||
return (target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.sortOrder === target.sortOrder &&
|
||||
item.description === target.description &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt &&
|
||||
item.permissions.length === target.permissions.length &&
|
||||
item.permissions.every(function (permission, permissionIndex) { return permission === target.permissions[permissionIndex]; }));
|
||||
});
|
||||
}
|
||||
function normalizeAppConfigSnapshot(value) {
|
||||
var normalized = normalizeConfigRecord(value);
|
||||
var chat = normalizeConfigRecord(normalized.chat);
|
||||
var worklogAutomation = normalizeConfigRecord(normalized.worklogAutomation);
|
||||
return __assign(__assign({}, normalized), { chat: {
|
||||
maxContextMessages: normalizeIntegerInRange(chat.maxContextMessages, DEFAULT_CHAT_APP_CONFIG.maxContextMessages, 1, 50),
|
||||
maxContextChars: normalizeIntegerInRange(chat.maxContextChars, DEFAULT_CHAT_APP_CONFIG.maxContextChars, 500, 20000),
|
||||
codexLiveMaxExecutionSeconds: normalizeIntegerInRange(chat.codexLiveMaxExecutionSeconds, DEFAULT_CHAT_APP_CONFIG.codexLiveMaxExecutionSeconds, 60, 7200),
|
||||
codexLiveIdleTimeoutSeconds: normalizeIntegerInRange(chat.codexLiveIdleTimeoutSeconds, DEFAULT_CHAT_APP_CONFIG.codexLiveIdleTimeoutSeconds, 30, 3600),
|
||||
receiveRoomNotifications: typeof chat.receiveRoomNotifications === 'boolean'
|
||||
? chat.receiveRoomNotifications
|
||||
: DEFAULT_CHAT_APP_CONFIG.receiveRoomNotifications,
|
||||
}, worklogAutomation: Object.keys(worklogAutomation).length > 0
|
||||
? __assign(__assign({}, normalized.worklogAutomation), { repeatRequestEnabled: false }) : undefined });
|
||||
}
|
||||
function getAppConfigSnapshot(appOrigin) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0:
|
||||
_a = normalizeAppConfigSnapshot;
|
||||
return [4 /*yield*/, getAppConfig(appOrigin)];
|
||||
case 1: return [2 /*return*/, _a.apply(void 0, [_b.sent()])];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function upsertAppConfig(config, appOrigin, appDomain) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var nextConfig, existing, initialConfig, rows_1, mergedConfig, rows;
|
||||
var _a, _b, _c, _d;
|
||||
return __generator(this, function (_e) {
|
||||
switch (_e.label) {
|
||||
case 0: return [4 /*yield*/, ensureAppConfigTable()];
|
||||
case 1:
|
||||
_e.sent();
|
||||
nextConfig = normalizeConfigRecord(config);
|
||||
return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE).first()];
|
||||
case 2:
|
||||
existing = _e.sent();
|
||||
if (!!existing) return [3 /*break*/, 4];
|
||||
initialConfig = mergeScopedAppConfig({}, nextConfig, appOrigin, appDomain);
|
||||
return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE)
|
||||
.insert({
|
||||
config_json: initialConfig,
|
||||
updated_at: client_js_1.db.fn.now(),
|
||||
})
|
||||
.returning('*')];
|
||||
case 3:
|
||||
rows_1 = _e.sent();
|
||||
return [2 /*return*/, resolveAppConfigByOrigin((_b = (_a = rows_1[0]) === null || _a === void 0 ? void 0 : _a.config_json) !== null && _b !== void 0 ? _b : initialConfig, appOrigin)];
|
||||
case 4:
|
||||
mergedConfig = mergeScopedAppConfig(normalizeConfigRecord(existing.config_json), nextConfig, appOrigin, appDomain);
|
||||
return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE)
|
||||
.update({
|
||||
config_json: mergedConfig,
|
||||
updated_at: client_js_1.db.fn.now(),
|
||||
})
|
||||
.returning('*')];
|
||||
case 5:
|
||||
rows = _e.sent();
|
||||
return [2 /*return*/, resolveAppConfigByOrigin((_d = (_c = rows[0]) === null || _c === void 0 ? void 0 : _c.config_json) !== null && _d !== void 0 ? _d : mergedConfig, appOrigin)];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function getChatTypesConfig(appOrigin) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var config, normalized, chatTypes, savedChatTypes, resolvedChatTypes;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, getAppConfig(appOrigin)];
|
||||
case 1:
|
||||
config = _b.sent();
|
||||
normalized = normalizeConfigRecord(config);
|
||||
chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
|
||||
if (chatTypes == null) {
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
|
||||
resolvedChatTypes = sanitizePersistedChatTypes(savedChatTypes);
|
||||
return [2 /*return*/, {
|
||||
chatTypes: resolvedChatTypes,
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function upsertChatTypesConfig(chatTypes, appOrigin, appDomain) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var current, _a, resolvedChatTypes, nextConfig;
|
||||
var _b;
|
||||
return __generator(this, function (_c) {
|
||||
switch (_c.label) {
|
||||
case 0:
|
||||
_a = normalizeConfigRecord;
|
||||
return [4 /*yield*/, getAppConfig(appOrigin)];
|
||||
case 1:
|
||||
current = _a.apply(void 0, [_c.sent()]);
|
||||
resolvedChatTypes = sanitizePersistedChatTypes(chatTypes);
|
||||
nextConfig = __assign(__assign({}, current), (_b = {}, _b[CHAT_TYPES_CONFIG_KEY] = resolvedChatTypes, _b));
|
||||
return [4 /*yield*/, upsertAppConfig(nextConfig, appOrigin, appDomain)];
|
||||
case 2:
|
||||
_c.sent();
|
||||
return [2 /*return*/, {
|
||||
chatTypes: resolvedChatTypes,
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
531
etc/servers/work-server/src/services/app-config-service.test.ts
Normal file
531
etc/servers/work-server/src/services/app-config-service.test.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
migrateLegacyChatTypeContexts,
|
||||
sanitizePersistedChatTypes,
|
||||
resolveAppConfigByOrigin,
|
||||
resolveCanonicalChatTypesFromConfig,
|
||||
resolveCanonicalChatContextSettingsFromConfig,
|
||||
stripChatContextSettingsFromScopedAppConfigs,
|
||||
stripSharedContextDataFromScopedAppConfigs,
|
||||
} from './app-config-service.js';
|
||||
|
||||
test('sanitizePersistedChatTypes keeps saved chat type edits as-is', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
sortOrder: 3,
|
||||
description: '사용자가 수정한 일반 요청 문맥',
|
||||
permissions: ['guest', 'token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-24T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const generalRequest = merged.find((item) => item.id === 'general-request');
|
||||
|
||||
assert.ok(generalRequest);
|
||||
assert.equal(generalRequest.description, '사용자가 수정한 일반 요청 문맥');
|
||||
assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']);
|
||||
assert.equal(generalRequest.sortOrder, 1);
|
||||
});
|
||||
|
||||
test('sanitizePersistedChatTypes keeps saved layout editor execution entries', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'layout-editor-execution',
|
||||
name: 'Layout editor 실행',
|
||||
sortOrder: 2,
|
||||
description: '호출 가능한 API 요청만 처리합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-27T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const layoutEditorExecution = merged.find((item) => item.id === 'layout-editor-execution');
|
||||
|
||||
assert.ok(layoutEditorExecution);
|
||||
assert.equal(layoutEditorExecution.description, '호출 가능한 API 요청만 처리합니다.');
|
||||
assert.equal(layoutEditorExecution.sortOrder, 1);
|
||||
});
|
||||
|
||||
test('sanitizePersistedChatTypes keeps saved guided layout editor entries', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'layout-editor-guided-execution',
|
||||
name: 'Layout editor 단계별 실행',
|
||||
sortOrder: 4,
|
||||
description: '사용자가 정리한 단계별 Layout 실행 문맥',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-01T09:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const guidedLayoutEditorExecution = merged.find((item) => item.id === 'layout-editor-guided-execution');
|
||||
|
||||
assert.ok(guidedLayoutEditorExecution);
|
||||
assert.equal(guidedLayoutEditorExecution.description, '사용자가 정리한 단계별 Layout 실행 문맥');
|
||||
assert.equal(guidedLayoutEditorExecution.sortOrder, 1);
|
||||
});
|
||||
|
||||
test('sanitizePersistedChatTypes returns empty list when nothing is saved', () => {
|
||||
const merged = sanitizePersistedChatTypes([]);
|
||||
|
||||
assert.deepEqual(merged, []);
|
||||
});
|
||||
|
||||
test('sanitizePersistedChatTypes keeps saved chat type list without backfilling removed entries', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
sortOrder: 2,
|
||||
description: '사용자가 수정한 일반 요청 문맥',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'custom-support-flow',
|
||||
name: '운영 문의 전용',
|
||||
sortOrder: 1,
|
||||
description: 'custom',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution'));
|
||||
assert.ok(!merged.some((item) => item.id === 'layout-editor-execution'));
|
||||
assert.ok(merged.some((item) => item.id === 'custom-support-flow'));
|
||||
});
|
||||
|
||||
test('sanitizePersistedChatTypes keeps all saved chat types without special filtering', () => {
|
||||
const stripped = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
sortOrder: 2,
|
||||
description: 'builtin',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
sortOrder: 3,
|
||||
description: 'custom-seeded',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'custom-support-flow',
|
||||
name: '운영 문의 전용',
|
||||
sortOrder: 1,
|
||||
description: 'custom',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'general-request', 'plan-checklist-execution']);
|
||||
});
|
||||
|
||||
test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => {
|
||||
const migrated = migrateLegacyChatTypeContexts(
|
||||
{
|
||||
defaultContexts: [],
|
||||
chatTypeDefaults: [
|
||||
{
|
||||
chatTypeId: 'plan-checklist-execution',
|
||||
defaultContextIds: ['legacy-linked-context'],
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
roomContexts: [],
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
sortOrder: 1,
|
||||
description: 'legacy plan context',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(migrated.defaultContexts.some((item) => item.id === 'chat-default-plan-checklist-execution'), true);
|
||||
assert.equal(
|
||||
migrated.defaultContexts.find((item) => item.id === 'chat-default-plan-checklist-execution')?.content,
|
||||
'legacy plan context',
|
||||
);
|
||||
assert.equal(migrated.chatTypeDefaults.some((item) => item.chatTypeId === 'plan-checklist-execution'), false);
|
||||
});
|
||||
|
||||
test('resolveAppConfigByOrigin prefers scoped app config over legacy global config', () => {
|
||||
const resolved = resolveAppConfigByOrigin(
|
||||
{
|
||||
chat: {
|
||||
maxContextMessages: 12,
|
||||
receiveRoomNotifications: true,
|
||||
},
|
||||
automation: {
|
||||
notifyOnAutomationStart: true,
|
||||
},
|
||||
scopedAppConfigs: {
|
||||
'https://rel.sm-home.cloud': {
|
||||
config: {
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://rel.sm-home.cloud',
|
||||
) as {
|
||||
chat?: { maxContextMessages?: number; receiveRoomNotifications?: boolean };
|
||||
automation?: { notifyOnAutomationStart?: boolean };
|
||||
};
|
||||
|
||||
assert.equal(resolved.chat?.maxContextMessages, 12);
|
||||
assert.equal(resolved.chat?.receiveRoomNotifications, false);
|
||||
assert.equal(resolved.automation?.notifyOnAutomationStart, true);
|
||||
});
|
||||
|
||||
test('resolveAppConfigByOrigin falls back to legacy global config when scoped config is missing', () => {
|
||||
const resolved = resolveAppConfigByOrigin(
|
||||
{
|
||||
chat: {
|
||||
receiveRoomNotifications: true,
|
||||
},
|
||||
},
|
||||
'https://preview.sm-home.cloud',
|
||||
) as {
|
||||
chat?: { receiveRoomNotifications?: boolean };
|
||||
};
|
||||
|
||||
assert.equal(resolved.chat?.receiveRoomNotifications, true);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context settings over stale scoped entries', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig(
|
||||
{
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'global-a',
|
||||
title: '전역 A',
|
||||
content: 'global',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'global-b',
|
||||
title: '전역 B',
|
||||
content: 'global',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
scopedAppConfigs: {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'scoped-a',
|
||||
title: '스코프 A',
|
||||
content: 'scoped',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => item.id),
|
||||
['global-a', 'global-b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig keeps saved default context sort order and renumbers gaps', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig({
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'context-b',
|
||||
title: 'B 문맥',
|
||||
sortOrder: 3,
|
||||
content: 'b',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'context-a',
|
||||
title: 'A 문맥',
|
||||
sortOrder: 1,
|
||||
content: 'a',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => ({ id: item.id, sortOrder: item.sortOrder })),
|
||||
[
|
||||
{ id: 'context-a', sortOrder: 1 },
|
||||
{ id: 'context-b', sortOrder: 2 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig appends unsorted default contexts after sorted entries', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig({
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'context-b',
|
||||
title: 'B 문맥',
|
||||
content: 'b',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'context-a',
|
||||
title: 'A 문맥',
|
||||
sortOrder: 1,
|
||||
content: 'a',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => ({ id: item.id, sortOrder: item.sortOrder })),
|
||||
[
|
||||
{ id: 'context-a', sortOrder: 1 },
|
||||
{ id: 'context-b', sortOrder: 2 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped settings only when global settings are empty', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig(
|
||||
{
|
||||
scopedAppConfigs: {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'scoped-a',
|
||||
title: '스코프 A',
|
||||
content: 'scoped',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => item.id),
|
||||
['scoped-a'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatTypesFromConfig merges global chat types with stale scoped entries', () => {
|
||||
const resolved = resolveCanonicalChatTypesFromConfig(
|
||||
{
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'verification-test-generation',
|
||||
name: '검증 밑 테스트 생성',
|
||||
description: 'global',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T08:15:18.440Z',
|
||||
},
|
||||
],
|
||||
scopedAppConfigs: {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: 'scoped',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.some((item) => item.id === 'verification-test-generation'), true);
|
||||
assert.equal(resolved.some((item) => item.id === 'general-request'), true);
|
||||
});
|
||||
|
||||
test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat context settings only', () => {
|
||||
const stripped = stripChatContextSettingsFromScopedAppConfigs({
|
||||
scopedAppConfigs: {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [{ id: 'legacy', title: 'legacy', content: 'legacy', enabled: true }],
|
||||
},
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(stripped.changed, true);
|
||||
assert.deepEqual(stripped.scopedConfigs, {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('stripSharedContextDataFromScopedAppConfigs removes scoped chat-type/context data and backs up non-shared origins', () => {
|
||||
const stripped = stripSharedContextDataFromScopedAppConfigs(
|
||||
{
|
||||
scopedAppConfigs: {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: 'preview-shared',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'preview-context',
|
||||
title: 'preview',
|
||||
content: 'shared',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'preview.sm-home.cloud',
|
||||
},
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'chat-type-test-temp',
|
||||
name: '임시 유형',
|
||||
description: 'test-only',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'test-context',
|
||||
title: 'test',
|
||||
content: 'legacy',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
automation: {
|
||||
notifyOnAutomationStart: true,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'test.sm-home.cloud',
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.equal(stripped.changed, true);
|
||||
assert.deepEqual(stripped.scopedConfigs, {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'preview.sm-home.cloud',
|
||||
},
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
automation: {
|
||||
notifyOnAutomationStart: true,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'test.sm-home.cloud',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
Array.isArray(stripped.backups['https://test.sm-home.cloud']?.chatTypes),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
stripped.backups['https://test.sm-home.cloud']?.chatContextSettings?.defaultContexts[0]?.id,
|
||||
'test-context',
|
||||
);
|
||||
assert.equal(stripped.backups['https://preview.sm-home.cloud'], undefined);
|
||||
});
|
||||
1020
etc/servers/work-server/src/services/app-config-service.ts
Executable file → Normal file
1020
etc/servers/work-server/src/services/app-config-service.ts
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
||||
"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);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
||||
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
||||
if (ar || !(i in from)) {
|
||||
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
||||
ar[i] = from[i];
|
||||
}
|
||||
}
|
||||
return to.concat(ar || Array.prototype.slice.call(from));
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_AUTOMATION_CONTEXTS = void 0;
|
||||
exports.sanitizeAutomationContexts = sanitizeAutomationContexts;
|
||||
exports.getAutomationContextsConfig = getAutomationContextsConfig;
|
||||
exports.upsertAutomationContextsConfig = upsertAutomationContextsConfig;
|
||||
exports.normalizeAutomationContextSelection = normalizeAutomationContextSelection;
|
||||
exports.resolveAutomationContexts = resolveAutomationContexts;
|
||||
var client_js_1 = require("../db/client.js");
|
||||
var AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
|
||||
exports.DEFAULT_AUTOMATION_CONTEXTS = [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content: '## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content: '## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
function normalizeText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
function normalizeEnabled(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
var normalizedValue = value.trim().toLowerCase();
|
||||
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return value !== false;
|
||||
}
|
||||
function buildContextTitleKey(value) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
function compareContextUpdatedAt(left, right) {
|
||||
var leftTime = Date.parse(left.updatedAt);
|
||||
var rightTime = Date.parse(right.updatedAt);
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function normalizeAutomationContext(record) {
|
||||
var title = normalizeText(record.title);
|
||||
var content = normalizeText(record.content);
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
var rawId = normalizeText(record.id);
|
||||
var normalizedId = rawId || "automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: title || 'Context',
|
||||
content: content,
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
defaultSelected: normalizeEnabled(record.defaultSelected),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
function sanitizeAutomationContexts(items) {
|
||||
var byId = new Map();
|
||||
var bySemanticKey = new Map();
|
||||
(items !== null && items !== void 0 ? items : [])
|
||||
.map(function (item) { return normalizeAutomationContext(item); })
|
||||
.filter(function (item) { return Boolean(item); })
|
||||
.forEach(function (item) {
|
||||
var currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
|
||||
var item = _a[_i];
|
||||
var semanticKey = buildContextTitleKey(item.title);
|
||||
var current = bySemanticKey.get(semanticKey);
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
var values = Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); });
|
||||
return values.length > 0 ? values : exports.DEFAULT_AUTOMATION_CONTEXTS;
|
||||
}
|
||||
function ensureAutomationContextsTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE)];
|
||||
case 1:
|
||||
hasTable = _b.sent();
|
||||
if (!!hasTable) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, function (table) {
|
||||
table.string('id').primary();
|
||||
table.string('title').notNullable();
|
||||
table.text('content').notNullable().defaultTo('');
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.boolean('default_selected').notNullable().defaultTo(false);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now());
|
||||
})];
|
||||
case 2:
|
||||
_b.sent();
|
||||
return [2 /*return*/];
|
||||
case 3:
|
||||
requiredColumns = [
|
||||
['title', function (table) { return table.string('title').notNullable().defaultTo(''); }],
|
||||
['content', function (table) { return table.text('content').notNullable().defaultTo(''); }],
|
||||
['enabled', function (table) { return table.boolean('enabled').notNullable().defaultTo(true); }],
|
||||
['default_selected', function (table) { return table.boolean('default_selected').notNullable().defaultTo(false); }],
|
||||
['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }],
|
||||
];
|
||||
_loop_1 = function (columnName, createColumn) {
|
||||
var hasColumn;
|
||||
return __generator(this, function (_c) {
|
||||
switch (_c.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName)];
|
||||
case 1:
|
||||
hasColumn = _c.sent();
|
||||
if (!!hasColumn) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, function (table) {
|
||||
createColumn(table);
|
||||
})];
|
||||
case 2:
|
||||
_c.sent();
|
||||
_c.label = 3;
|
||||
case 3: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
};
|
||||
_i = 0, requiredColumns_1 = requiredColumns;
|
||||
_b.label = 4;
|
||||
case 4:
|
||||
if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7];
|
||||
_a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1];
|
||||
return [5 /*yield**/, _loop_1(columnName, createColumn)];
|
||||
case 5:
|
||||
_b.sent();
|
||||
_b.label = 6;
|
||||
case 6:
|
||||
_i++;
|
||||
return [3 /*break*/, 4];
|
||||
case 7: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function parseContextsFromLegacyValue(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
catch (_a) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function toAutomationContextRecord(row) {
|
||||
return normalizeAutomationContext({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
title: typeof row.title === 'string' ? row.title : undefined,
|
||||
content: typeof row.content === 'string' ? row.content : undefined,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
defaultSelected: normalizeEnabled(row.default_selected),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
function replaceAutomationContextsInTable(items) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var nextItems;
|
||||
var _this = this;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ensureAutomationContextsTable()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
nextItems = sanitizeAutomationContexts(items);
|
||||
return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, trx(AUTOMATION_CONTEXTS_TABLE).del()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, trx(AUTOMATION_CONTEXTS_TABLE).insert(nextItems.map(function (item) { return ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
enabled: item.enabled,
|
||||
default_selected: item.defaultSelected,
|
||||
updated_at: item.updatedAt,
|
||||
}); }))];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); })];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [2 /*return*/, nextItems];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function seedAutomationContextsFromLegacySources() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var seededItems, hasAutomationTypesTable, rows, _i, rows_1, row;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
seededItems = __spreadArray([], exports.DEFAULT_AUTOMATION_CONTEXTS, true);
|
||||
return [4 /*yield*/, client_js_1.db.schema.hasTable('automation_types')];
|
||||
case 1:
|
||||
hasAutomationTypesTable = _a.sent();
|
||||
if (!hasAutomationTypesTable) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, (0, client_js_1.db)('automation_types').select('contexts_json')];
|
||||
case 2:
|
||||
rows = _a.sent();
|
||||
for (_i = 0, rows_1 = rows; _i < rows_1.length; _i++) {
|
||||
row = rows_1[_i];
|
||||
seededItems.push.apply(seededItems, parseContextsFromLegacyValue(row.contexts_json));
|
||||
}
|
||||
_a.label = 3;
|
||||
case 3: return [2 /*return*/, replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems))];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function isSameAutomationContextList(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every(function (item, index) {
|
||||
var target = right[index];
|
||||
return (target &&
|
||||
item.id === target.id &&
|
||||
item.title === target.title &&
|
||||
item.content === target.content &&
|
||||
item.enabled === target.enabled &&
|
||||
item.defaultSelected === target.defaultSelected &&
|
||||
item.updatedAt === target.updatedAt);
|
||||
});
|
||||
}
|
||||
function mergeDefaultAutomationContexts(items) {
|
||||
var byId = new Map(items.map(function (item) { return [item.id, item]; }));
|
||||
for (var _i = 0, DEFAULT_AUTOMATION_CONTEXTS_1 = exports.DEFAULT_AUTOMATION_CONTEXTS; _i < DEFAULT_AUTOMATION_CONTEXTS_1.length; _i++) {
|
||||
var defaultItem = DEFAULT_AUTOMATION_CONTEXTS_1[_i];
|
||||
var existingItem = byId.get(defaultItem.id);
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
byId.set(defaultItem.id, __assign(__assign({}, existingItem), { title: defaultItem.title, content: existingItem.content || defaultItem.content }));
|
||||
}
|
||||
return sanitizeAutomationContexts(Array.from(byId.values()));
|
||||
}
|
||||
function readAutomationContextsFromTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var rows, savedItems;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ensureAutomationContextsTable()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, (0, client_js_1.db)(AUTOMATION_CONTEXTS_TABLE)
|
||||
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
|
||||
.orderBy('title', 'asc')];
|
||||
case 2:
|
||||
rows = _a.sent();
|
||||
savedItems = rows
|
||||
.map(function (row) { return toAutomationContextRecord(row); })
|
||||
.filter(function (item) { return Boolean(item); });
|
||||
return [2 /*return*/, sanitizeAutomationContexts(savedItems)];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function getAutomationContextsConfig() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var savedContexts, mergedContexts;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, readAutomationContextsFromTable()];
|
||||
case 1:
|
||||
savedContexts = _a.sent();
|
||||
if (savedContexts.length === 0 || savedContexts === exports.DEFAULT_AUTOMATION_CONTEXTS) {
|
||||
return [2 /*return*/, seedAutomationContextsFromLegacySources()];
|
||||
}
|
||||
mergedContexts = mergeDefaultAutomationContexts(savedContexts);
|
||||
if (!!isSameAutomationContextList(savedContexts, mergedContexts)) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, replaceAutomationContextsInTable(mergedContexts)];
|
||||
case 2:
|
||||
_a.sent();
|
||||
_a.label = 3;
|
||||
case 3: return [2 /*return*/, mergedContexts];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function upsertAutomationContextsConfig(items) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var nextContexts;
|
||||
return __generator(this, function (_a) {
|
||||
nextContexts = mergeDefaultAutomationContexts(sanitizeAutomationContexts(Array.isArray(items) ? items : []));
|
||||
return [2 /*return*/, replaceAutomationContextsInTable(nextContexts)];
|
||||
});
|
||||
});
|
||||
}
|
||||
function normalizeAutomationContextSelection(value) {
|
||||
var rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map(function (item) { return item.trim(); })
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return __spreadArray([], new Set(rawValues.map(function (item) { return normalizeText(String(item)); }).filter(Boolean)), true);
|
||||
}
|
||||
function resolveAutomationContexts(contexts, selectedContextIds) {
|
||||
var normalizedContexts = sanitizeAutomationContexts(contexts);
|
||||
var requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
||||
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
||||
return [];
|
||||
}
|
||||
if (requestedIds.length === 0) {
|
||||
return normalizedContexts.filter(function (item) { return item.enabled && item.defaultSelected; });
|
||||
}
|
||||
var requestedIdSet = new Set(requestedIds);
|
||||
return normalizedContexts.filter(function (item) { return requestedIdSet.has(item.id); });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_AUTOMATION_CONTEXTS,
|
||||
resolveAutomationContexts,
|
||||
sanitizeAutomationContexts,
|
||||
} from './automation-context-config-service.js';
|
||||
|
||||
test('default automation contexts include base handling context', () => {
|
||||
assert.ok(DEFAULT_AUTOMATION_CONTEXTS.some((item) => item.id === 'none-default'));
|
||||
});
|
||||
|
||||
test('sanitizeAutomationContexts falls back to defaults', () => {
|
||||
const items = sanitizeAutomationContexts([]);
|
||||
assert.ok(items.some((item) => item.id === 'auto-worker-default'));
|
||||
});
|
||||
|
||||
test('resolveAutomationContexts returns only explicitly selected contexts', () => {
|
||||
const contexts = resolveAutomationContexts(
|
||||
[
|
||||
{
|
||||
id: 'ctx-1',
|
||||
title: 'A',
|
||||
content: 'A',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'ctx-2',
|
||||
title: 'B',
|
||||
content: 'B',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
['ctx-2'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
contexts.map((item) => item.id),
|
||||
['ctx-2'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
const AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
|
||||
|
||||
export type AutomationContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
defaultSelected: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content:
|
||||
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeEnabled(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
|
||||
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
function buildContextTitleKey(value: string) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
|
||||
const title = normalizeText(record.title);
|
||||
const content = normalizeText(record.content);
|
||||
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: title || 'Context',
|
||||
content,
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
defaultSelected: normalizeEnabled(record.defaultSelected),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, AutomationContextRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationContextRecord>();
|
||||
|
||||
(items ?? [])
|
||||
.map((item) => normalizeAutomationContext(item))
|
||||
.filter((item): item is AutomationContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
const currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = buildContextTitleKey(item.title);
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
||||
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
|
||||
}
|
||||
|
||||
async function ensureAutomationContextsTable() {
|
||||
const hasTable = await db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('title').notNullable();
|
||||
table.text('content').notNullable().defaultTo('');
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.boolean('default_selected').notNullable().defaultTo(false);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['title', (table) => table.string('title').notNullable().defaultTo('')],
|
||||
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
||||
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
||||
['default_selected', (table) => table.boolean('default_selected').notNullable().defaultTo(false)],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseContextsFromLegacyValue(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toAutomationContextRecord(row: Record<string, unknown>) {
|
||||
return normalizeAutomationContext({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
title: typeof row.title === 'string' ? row.title : undefined,
|
||||
content: typeof row.content === 'string' ? row.content : undefined,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
defaultSelected: normalizeEnabled(row.default_selected),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceAutomationContextsInTable(items: AutomationContextRecord[]) {
|
||||
await ensureAutomationContextsTable();
|
||||
|
||||
const nextItems = sanitizeAutomationContexts(items);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(AUTOMATION_CONTEXTS_TABLE).del();
|
||||
await trx(AUTOMATION_CONTEXTS_TABLE).insert(
|
||||
nextItems.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
enabled: item.enabled,
|
||||
default_selected: item.defaultSelected,
|
||||
updated_at: item.updatedAt,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
async function seedAutomationContextsFromLegacySources() {
|
||||
const seededItems: Partial<AutomationContextRecord>[] = [...DEFAULT_AUTOMATION_CONTEXTS];
|
||||
|
||||
const hasAutomationTypesTable = await db.schema.hasTable('automation_types');
|
||||
if (hasAutomationTypesTable) {
|
||||
const rows = await db('automation_types').select('contexts_json');
|
||||
for (const row of rows) {
|
||||
seededItems.push(...parseContextsFromLegacyValue((row as Record<string, unknown>).contexts_json));
|
||||
}
|
||||
}
|
||||
|
||||
return replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems));
|
||||
}
|
||||
|
||||
function isSameAutomationContextList(left: AutomationContextRecord[], right: AutomationContextRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const target = right[index];
|
||||
return (
|
||||
target &&
|
||||
item.id === target.id &&
|
||||
item.title === target.title &&
|
||||
item.content === target.content &&
|
||||
item.enabled === target.enabled &&
|
||||
item.defaultSelected === target.defaultSelected &&
|
||||
item.updatedAt === target.updatedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDefaultAutomationContexts(items: AutomationContextRecord[]) {
|
||||
const byId = new Map(items.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_AUTOMATION_CONTEXTS) {
|
||||
const existingItem = byId.get(defaultItem.id);
|
||||
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
title: defaultItem.title,
|
||||
content: existingItem.content || defaultItem.content,
|
||||
});
|
||||
}
|
||||
|
||||
return sanitizeAutomationContexts(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
async function readAutomationContextsFromTable() {
|
||||
await ensureAutomationContextsTable();
|
||||
|
||||
const rows = await db(AUTOMATION_CONTEXTS_TABLE)
|
||||
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
|
||||
.orderBy('title', 'asc');
|
||||
|
||||
const savedItems = rows
|
||||
.map((row) => toAutomationContextRecord(row as Record<string, unknown>))
|
||||
.filter((item): item is AutomationContextRecord => Boolean(item));
|
||||
|
||||
return sanitizeAutomationContexts(savedItems);
|
||||
}
|
||||
|
||||
export async function getAutomationContextsConfig() {
|
||||
const savedContexts = await readAutomationContextsFromTable();
|
||||
|
||||
if (savedContexts.length === 0 || savedContexts === DEFAULT_AUTOMATION_CONTEXTS) {
|
||||
return seedAutomationContextsFromLegacySources();
|
||||
}
|
||||
|
||||
const mergedContexts = mergeDefaultAutomationContexts(savedContexts);
|
||||
if (!isSameAutomationContextList(savedContexts, mergedContexts)) {
|
||||
await replaceAutomationContextsInTable(mergedContexts);
|
||||
}
|
||||
|
||||
return mergedContexts;
|
||||
}
|
||||
|
||||
export async function upsertAutomationContextsConfig(items: unknown[]) {
|
||||
const nextContexts = mergeDefaultAutomationContexts(
|
||||
sanitizeAutomationContexts(Array.isArray(items) ? (items as Partial<AutomationContextRecord>[]) : []),
|
||||
);
|
||||
|
||||
return replaceAutomationContextsInTable(nextContexts);
|
||||
}
|
||||
|
||||
export function normalizeAutomationContextSelection(value: unknown) {
|
||||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function resolveAutomationContexts(
|
||||
contexts: AutomationContextRecord[] | null | undefined,
|
||||
selectedContextIds?: unknown,
|
||||
) {
|
||||
const normalizedContexts = sanitizeAutomationContexts(contexts);
|
||||
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
||||
|
||||
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (requestedIds.length === 0) {
|
||||
return normalizedContexts.filter((item) => item.enabled && item.defaultSelected);
|
||||
}
|
||||
|
||||
const requestedIdSet = new Set(requestedIds);
|
||||
return normalizedContexts.filter((item) => requestedIdSet.has(item.id));
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
||||
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
||||
if (ar || !(i in from)) {
|
||||
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
||||
ar[i] = from[i];
|
||||
}
|
||||
}
|
||||
return to.concat(ar || Array.prototype.slice.call(from));
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.stringifyAutomationContextIds = stringifyAutomationContextIds;
|
||||
exports.parseAutomationContextIds = parseAutomationContextIds;
|
||||
exports.buildAutomationContextMarkdown = buildAutomationContextMarkdown;
|
||||
exports.buildAutomationNoteSections = buildAutomationNoteSections;
|
||||
exports.ensureSchedulePromptSnapshot = ensureSchedulePromptSnapshot;
|
||||
var node_path_1 = require("node:path");
|
||||
var promises_1 = require("node:fs/promises");
|
||||
var env_js_1 = require("../config/env.js");
|
||||
var automation_context_config_service_js_1 = require("./automation-context-config-service.js");
|
||||
function stringifyAutomationContextIds(value) {
|
||||
return JSON.stringify((0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(value));
|
||||
}
|
||||
function parseAutomationContextIds(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(value);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
var trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(trimmed);
|
||||
return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(parsed);
|
||||
}
|
||||
catch (_a) {
|
||||
return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(trimmed);
|
||||
}
|
||||
}
|
||||
function buildAutomationContextMarkdown(contexts, selectedContextIds) {
|
||||
var resolvedContexts = (0, automation_context_config_service_js_1.resolveAutomationContexts)(contexts, selectedContextIds);
|
||||
if (resolvedContexts.length === 0) {
|
||||
return '선택된 자동화 Context 없음';
|
||||
}
|
||||
return resolvedContexts
|
||||
.map(function (item) { return ["### ".concat(item.title), item.content.trim() || '(내용 없음)'].join('\n'); })
|
||||
.join('\n\n');
|
||||
}
|
||||
function buildAutomationNoteSections(options) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var availableContexts, _a, lines;
|
||||
var _b, _c, _d, _e, _f, _g;
|
||||
return __generator(this, function (_h) {
|
||||
switch (_h.label) {
|
||||
case 0:
|
||||
if (!((_b = options.availableContexts) !== null && _b !== void 0)) return [3 /*break*/, 1];
|
||||
_a = _b;
|
||||
return [3 /*break*/, 3];
|
||||
case 1: return [4 /*yield*/, (0, automation_context_config_service_js_1.getAutomationContextsConfig)()];
|
||||
case 2:
|
||||
_a = (_h.sent());
|
||||
_h.label = 3;
|
||||
case 3:
|
||||
availableContexts = _a;
|
||||
lines = __spreadArray(__spreadArray(__spreadArray([
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
((_c = options.title) === null || _c === void 0 ? void 0 : _c.trim()) ? "- \uAC8C\uC2DC\uD310 \uC81C\uBAA9: ".concat(options.title.trim()) : null,
|
||||
"- \uBA54\uBAA8 \uCD9C\uCC98: ".concat(options.sourceLabel),
|
||||
((_e = (_d = options.automationType) === null || _d === void 0 ? void 0 : _d.name) === null || _e === void 0 ? void 0 : _e.trim()) ? "- \uC120\uD0DD \uC790\uB3D9\uD654 \uC720\uD615: ".concat(options.automationType.name.trim()) : null,
|
||||
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.',
|
||||
'',
|
||||
'## 자동화 Context',
|
||||
buildAutomationContextMarkdown(availableContexts, options.selectedContextIds),
|
||||
''
|
||||
], ((_f = options.extraSections) !== null && _f !== void 0 ? _f : []), true), [
|
||||
'## 요청 본문',
|
||||
options.requestContent.trim()
|
||||
], false), (((_g = options.attachments) === null || _g === void 0 ? void 0 : _g.length) ? __spreadArray(['', '## 첨부 파일'], options.attachments, true) : []), true);
|
||||
return [2 /*return*/, lines.filter(function (line) { return line !== null && line !== undefined; }).join('\n')];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function extractRequestedPaths(note) {
|
||||
var _a;
|
||||
var matches = (_a = note.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g)) !== null && _a !== void 0 ? _a : [];
|
||||
return __spreadArray([], new Set(matches.map(function (item) { return item.replace(/\\/g, '/'); })), true);
|
||||
}
|
||||
function tryReadFile(filePath) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0:
|
||||
_b.trys.push([0, 2, , 3]);
|
||||
return [4 /*yield*/, (0, promises_1.readFile)(filePath, 'utf8')];
|
||||
case 1: return [2 /*return*/, _b.sent()];
|
||||
case 2:
|
||||
_a = _b.sent();
|
||||
return [2 /*return*/, null];
|
||||
case 3: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function limitText(value, maxChars) {
|
||||
if (maxChars === void 0) { maxChars = 12000; }
|
||||
var normalized = value.trim();
|
||||
return normalized.length <= maxChars ? normalized : "".concat(normalized.slice(0, maxChars).trimEnd(), "\n\n...");
|
||||
}
|
||||
function getScheduleRepoRoot() {
|
||||
var env = (0, env_js_1.getEnv)();
|
||||
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
|
||||
}
|
||||
function ensureSchedulePromptSnapshot(options) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var repoRoot, scheduleDir, requestPath, contextPath, manifestPath, requestedPaths, candidatePaths, uniqueRelativePaths, references, _i, uniqueRelativePaths_1, relativePath, absolutePath, content, requestMarkdown, contextMarkdown, relativeDir;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
repoRoot = getScheduleRepoRoot();
|
||||
scheduleDir = node_path_1.default.join(repoRoot, '.auto_codex', 'schedule', String(options.scheduleId));
|
||||
return [4 /*yield*/, (0, promises_1.mkdir)(scheduleDir, { recursive: true })];
|
||||
case 1:
|
||||
_a.sent();
|
||||
requestPath = node_path_1.default.join(scheduleDir, 'request.md');
|
||||
contextPath = node_path_1.default.join(scheduleDir, 'context.md');
|
||||
manifestPath = node_path_1.default.join(scheduleDir, 'manifest.json');
|
||||
requestedPaths = extractRequestedPaths(options.note);
|
||||
candidatePaths = __spreadArray([
|
||||
'AGENTS.md',
|
||||
'docs/README.md'
|
||||
], requestedPaths.filter(function (item) { return !item.startsWith('http://') && !item.startsWith('https://'); }), true);
|
||||
uniqueRelativePaths = __spreadArray([], new Set(candidatePaths), true);
|
||||
references = [];
|
||||
_i = 0, uniqueRelativePaths_1 = uniqueRelativePaths;
|
||||
_a.label = 2;
|
||||
case 2:
|
||||
if (!(_i < uniqueRelativePaths_1.length)) return [3 /*break*/, 5];
|
||||
relativePath = uniqueRelativePaths_1[_i];
|
||||
absolutePath = node_path_1.default.resolve(repoRoot, relativePath);
|
||||
return [4 /*yield*/, tryReadFile(absolutePath)];
|
||||
case 3:
|
||||
content = _a.sent();
|
||||
if (!content) {
|
||||
return [3 /*break*/, 4];
|
||||
}
|
||||
references.push("## ".concat(relativePath, "\n\n```\n").concat(limitText(content), "\n```"));
|
||||
_a.label = 4;
|
||||
case 4:
|
||||
_i++;
|
||||
return [3 /*break*/, 2];
|
||||
case 5:
|
||||
requestMarkdown = [
|
||||
'# 스케줄 요청 원문',
|
||||
'',
|
||||
"- \uC2A4\uCF00\uC904 ID: ".concat(options.scheduleId),
|
||||
"- \uC791\uC5C5 ID: ".concat(options.workId),
|
||||
'',
|
||||
'## 원본 메모',
|
||||
options.note.trim() || '(비어 있음)',
|
||||
].join('\n');
|
||||
contextMarkdown = __spreadArray([
|
||||
'# 스케줄 전용 참조',
|
||||
'',
|
||||
'- 최초 활성화 시점에 읽은 요청/문서/소스 일부를 이 디렉터리 아래로 정리했습니다.',
|
||||
'- 이후 자동화 실행은 우선 이 디렉터리의 Markdown 문서를 참조하고, 원본 소스 재탐색은 꼭 필요할 때만 제한적으로 수행합니다.',
|
||||
''
|
||||
], (references.length > 0 ? references : ['## 참조 문서', '별도로 추출된 문서가 없습니다. request.md를 우선 참조합니다.']), true).join('\n\n');
|
||||
return [4 /*yield*/, (0, promises_1.writeFile)(requestPath, "".concat(requestMarkdown, "\n"), 'utf8')];
|
||||
case 6:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, (0, promises_1.writeFile)(contextPath, "".concat(contextMarkdown, "\n"), 'utf8')];
|
||||
case 7:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, (0, promises_1.writeFile)(manifestPath, JSON.stringify({
|
||||
scheduleId: options.scheduleId,
|
||||
workId: options.workId,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
forceRefresh: Boolean(options.forceRefresh),
|
||||
sourcePaths: uniqueRelativePaths,
|
||||
}, null, 2), 'utf8')];
|
||||
case 8:
|
||||
_a.sent();
|
||||
relativeDir = node_path_1.default.relative(repoRoot, scheduleDir).replace(/\\/g, '/');
|
||||
return [2 /*return*/, {
|
||||
directory: relativeDir,
|
||||
requestPath: "".concat(relativeDir, "/request.md"),
|
||||
contextPath: "".concat(relativeDir, "/context.md"),
|
||||
manifestPath: "".concat(relativeDir, "/manifest.json"),
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import {
|
||||
type AutomationContextRecord,
|
||||
getAutomationContextsConfig,
|
||||
normalizeAutomationContextSelection,
|
||||
resolveAutomationContexts,
|
||||
} from './automation-context-config-service.js';
|
||||
import type { AutomationTypeRecord } from './automation-type-config-service.js';
|
||||
|
||||
export function stringifyAutomationContextIds(value: unknown) {
|
||||
return JSON.stringify(normalizeAutomationContextSelection(value));
|
||||
}
|
||||
|
||||
export function parseAutomationContextIds(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeAutomationContextSelection(value);
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return normalizeAutomationContextSelection(parsed);
|
||||
} catch {
|
||||
return normalizeAutomationContextSelection(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAutomationContextMarkdown(
|
||||
contexts: Awaited<ReturnType<typeof getAutomationContextsConfig>> | null | undefined,
|
||||
selectedContextIds?: unknown,
|
||||
) {
|
||||
const resolvedContexts = resolveAutomationContexts(contexts, selectedContextIds);
|
||||
|
||||
if (resolvedContexts.length === 0) {
|
||||
return '선택된 자동화 Context 없음';
|
||||
}
|
||||
|
||||
return resolvedContexts
|
||||
.map((item) => [`### ${item.title}`, item.content.trim() || '(내용 없음)'].join('\n'))
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export async function buildAutomationNoteSections(options: {
|
||||
title?: string;
|
||||
sourceLabel: string;
|
||||
requestContent: string;
|
||||
attachments?: string[];
|
||||
automationType?: Pick<AutomationTypeRecord, 'name'> | null;
|
||||
availableContexts?: AutomationContextRecord[];
|
||||
selectedContextIds?: unknown;
|
||||
extraSections?: string[];
|
||||
}) {
|
||||
const availableContexts = options.availableContexts ?? (await getAutomationContextsConfig());
|
||||
const lines = [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
options.title?.trim() ? `- 게시판 제목: ${options.title.trim()}` : null,
|
||||
`- 메모 출처: ${options.sourceLabel}`,
|
||||
options.automationType?.name?.trim() ? `- 선택 자동화 유형: ${options.automationType.name.trim()}` : null,
|
||||
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.',
|
||||
'',
|
||||
'## 자동화 Context',
|
||||
buildAutomationContextMarkdown(availableContexts, options.selectedContextIds),
|
||||
'',
|
||||
...(options.extraSections ?? []),
|
||||
'## 요청 본문',
|
||||
options.requestContent.trim(),
|
||||
...(options.attachments?.length ? ['', '## 첨부 파일', ...options.attachments] : []),
|
||||
];
|
||||
|
||||
return lines.filter((line): line is string => line !== null && line !== undefined).join('\n');
|
||||
}
|
||||
|
||||
function extractRequestedPaths(note: string) {
|
||||
const matches = note.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g) ?? [];
|
||||
return [...new Set(matches.map((item) => item.replace(/\\/g, '/')))];
|
||||
}
|
||||
|
||||
async function tryReadFile(filePath: string) {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function limitText(value: string, maxChars = 12000) {
|
||||
const normalized = value.trim();
|
||||
return normalized.length <= maxChars ? normalized : `${normalized.slice(0, maxChars).trimEnd()}\n\n...`;
|
||||
}
|
||||
|
||||
function getScheduleRepoRoot() {
|
||||
const env = getEnv();
|
||||
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
|
||||
}
|
||||
|
||||
export async function ensureSchedulePromptSnapshot(options: {
|
||||
scheduleId: number;
|
||||
workId: string;
|
||||
note: string;
|
||||
forceRefresh?: boolean;
|
||||
}) {
|
||||
const repoRoot = getScheduleRepoRoot();
|
||||
const scheduleDir = path.join(repoRoot, '.auto_codex', 'schedule', String(options.scheduleId));
|
||||
await mkdir(scheduleDir, { recursive: true });
|
||||
|
||||
const requestPath = path.join(scheduleDir, 'request.md');
|
||||
const contextPath = path.join(scheduleDir, 'context.md');
|
||||
const manifestPath = path.join(scheduleDir, 'manifest.json');
|
||||
|
||||
const requestedPaths = extractRequestedPaths(options.note);
|
||||
const candidatePaths = [
|
||||
'AGENTS.md',
|
||||
'docs/README.md',
|
||||
...requestedPaths.filter((item) => !item.startsWith('http://') && !item.startsWith('https://')),
|
||||
];
|
||||
const uniqueRelativePaths = [...new Set(candidatePaths)];
|
||||
|
||||
const references: string[] = [];
|
||||
|
||||
for (const relativePath of uniqueRelativePaths) {
|
||||
const absolutePath = path.resolve(repoRoot, relativePath);
|
||||
const content = await tryReadFile(absolutePath);
|
||||
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
references.push(`## ${relativePath}\n\n\`\`\`\n${limitText(content)}\n\`\`\``);
|
||||
}
|
||||
|
||||
const requestMarkdown = [
|
||||
'# 스케줄 요청 원문',
|
||||
'',
|
||||
`- 스케줄 ID: ${options.scheduleId}`,
|
||||
`- 작업 ID: ${options.workId}`,
|
||||
'',
|
||||
'## 원본 메모',
|
||||
options.note.trim() || '(비어 있음)',
|
||||
].join('\n');
|
||||
|
||||
const contextMarkdown = [
|
||||
'# 스케줄 전용 참조',
|
||||
'',
|
||||
'- 최초 활성화 시점에 읽은 요청/문서/소스 일부를 이 디렉터리 아래로 정리했습니다.',
|
||||
'- 이후 자동화 실행은 우선 이 디렉터리의 Markdown 문서를 참조하고, 원본 소스 재탐색은 꼭 필요할 때만 제한적으로 수행합니다.',
|
||||
'',
|
||||
...(references.length > 0 ? references : ['## 참조 문서', '별도로 추출된 문서가 없습니다. request.md를 우선 참조합니다.']),
|
||||
].join('\n\n');
|
||||
|
||||
await writeFile(requestPath, `${requestMarkdown}\n`, 'utf8');
|
||||
await writeFile(contextPath, `${contextMarkdown}\n`, 'utf8');
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
scheduleId: options.scheduleId,
|
||||
workId: options.workId,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
forceRefresh: Boolean(options.forceRefresh),
|
||||
sourcePaths: uniqueRelativePaths,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const relativeDir = path.relative(repoRoot, scheduleDir).replace(/\\/g, '/');
|
||||
return {
|
||||
directory: relativeDir,
|
||||
requestPath: `${relativeDir}/request.md`,
|
||||
contextPath: `${relativeDir}/context.md`,
|
||||
manifestPath: `${relativeDir}/manifest.json`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
"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);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
||||
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
||||
if (ar || !(i in from)) {
|
||||
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
||||
ar[i] = from[i];
|
||||
}
|
||||
}
|
||||
return to.concat(ar || Array.prototype.slice.call(from));
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_AUTOMATION_TYPES = exports.AUTOMATION_BEHAVIOR_TYPES = void 0;
|
||||
exports.normalizeLegacyAutomationBehaviorType = normalizeLegacyAutomationBehaviorType;
|
||||
exports.sanitizeAutomationContexts = sanitizeAutomationContexts;
|
||||
exports.sanitizeAutomationTypes = sanitizeAutomationTypes;
|
||||
exports.getAutomationTypesConfig = getAutomationTypesConfig;
|
||||
exports.upsertAutomationTypesConfig = upsertAutomationTypesConfig;
|
||||
exports.resolveAutomationType = resolveAutomationType;
|
||||
exports.resolveStoredAutomationTypeId = resolveStoredAutomationTypeId;
|
||||
exports.normalizeAutomationContextSelection = normalizeAutomationContextSelection;
|
||||
exports.resolveAutomationTypeContexts = resolveAutomationTypeContexts;
|
||||
var client_js_1 = require("../db/client.js");
|
||||
var app_config_service_js_1 = require("./app-config-service.js");
|
||||
var AUTOMATION_TYPES_TABLE = 'automation_types';
|
||||
var AUTOMATION_TYPES_CONFIG_KEY = 'automationTypes';
|
||||
exports.AUTOMATION_BEHAVIOR_TYPES = [
|
||||
'none',
|
||||
'plan',
|
||||
'command_execution',
|
||||
'non_source_work',
|
||||
'auto_worker',
|
||||
];
|
||||
exports.DEFAULT_AUTOMATION_TYPES = [
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description: '일반 문의/확인 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description: '기본 자동화 처리용 유형입니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content: '## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto_worker',
|
||||
name: 'autoWorker',
|
||||
description: '자동화 작업메모로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content: '## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'auto_worker',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
function normalizeText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
function normalizeEnabled(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
var normalizedValue = value.trim().toLowerCase();
|
||||
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return value !== false;
|
||||
}
|
||||
function normalizeLegacyAutomationTypeId(value) {
|
||||
var normalizedValue = normalizeLegacyAutomationBehaviorType(value);
|
||||
if (normalizedValue === 'stock-alert') {
|
||||
return 'general-inquiry';
|
||||
}
|
||||
return normalizedValue;
|
||||
}
|
||||
function normalizeBehaviorType(value) {
|
||||
var normalizedValue = normalizeLegacyAutomationBehaviorType(value);
|
||||
return exports.AUTOMATION_BEHAVIOR_TYPES.includes(normalizedValue)
|
||||
? normalizedValue
|
||||
: 'none';
|
||||
}
|
||||
function normalizeLegacyAutomationBehaviorType(value) {
|
||||
var normalizedValue = normalizeText(value);
|
||||
if (normalizedValue === 'plan_registration') {
|
||||
return 'plan';
|
||||
}
|
||||
if (normalizedValue === 'general_development') {
|
||||
return 'auto_worker';
|
||||
}
|
||||
return normalizedValue;
|
||||
}
|
||||
function buildNameKey(value) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
function buildContextTitleKey(value) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
function normalizeAutomationContext(record) {
|
||||
var title = normalizeText(record.title);
|
||||
var content = normalizeText(record.content);
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
var rawId = normalizeText(record.id);
|
||||
var normalizedId = rawId || "automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: title || 'Context',
|
||||
content: content,
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
defaultSelected: normalizeEnabled(record.defaultSelected),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
function compareContextUpdatedAt(left, right) {
|
||||
var leftTime = Date.parse(left.updatedAt);
|
||||
var rightTime = Date.parse(right.updatedAt);
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function sanitizeAutomationContexts(items) {
|
||||
var byId = new Map();
|
||||
var bySemanticKey = new Map();
|
||||
(items !== null && items !== void 0 ? items : [])
|
||||
.map(function (item) { return normalizeAutomationContext(item); })
|
||||
.filter(function (item) { return Boolean(item); })
|
||||
.forEach(function (item) {
|
||||
var currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
|
||||
var item = _a[_i];
|
||||
var semanticKey = buildContextTitleKey(item.title);
|
||||
var current = bySemanticKey.get(semanticKey);
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); });
|
||||
}
|
||||
function normalizeAutomationType(record) {
|
||||
var name = normalizeText(record.name);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
var rawId = normalizeText(record.id);
|
||||
var normalizedId = rawId || "automation-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
|
||||
return {
|
||||
id: normalizedId,
|
||||
name: name,
|
||||
description: normalizeText(record.description),
|
||||
contexts: sanitizeAutomationContexts(record.contexts),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
function compareUpdatedAt(left, right) {
|
||||
var leftTime = Date.parse(left.updatedAt);
|
||||
var rightTime = Date.parse(right.updatedAt);
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function dedupeAutomationTypes(items) {
|
||||
var byId = new Map();
|
||||
var bySemanticKey = new Map();
|
||||
for (var _i = 0, items_1 = items; _i < items_1.length; _i++) {
|
||||
var item = items_1[_i];
|
||||
var currentById = byId.get(item.id);
|
||||
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
}
|
||||
for (var _a = 0, _b = byId.values(); _a < _b.length; _a++) {
|
||||
var item = _b[_a];
|
||||
var semanticKey = "".concat(item.behaviorType, ":").concat(buildNameKey(item.name));
|
||||
var current = bySemanticKey.get(semanticKey);
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.name.localeCompare(right.name, 'ko-KR'); });
|
||||
}
|
||||
function sanitizeAutomationTypes(items) {
|
||||
var normalized = (items !== null && items !== void 0 ? items : [])
|
||||
.map(function (item) { return normalizeAutomationType(item); })
|
||||
.filter(function (item) { return Boolean(item); });
|
||||
if (normalized.length === 0) {
|
||||
return exports.DEFAULT_AUTOMATION_TYPES;
|
||||
}
|
||||
return dedupeAutomationTypes(normalized);
|
||||
}
|
||||
function mergeDefaultAutomationTypes(items) {
|
||||
var _a;
|
||||
var byId = new Map(items.map(function (item) { return [item.id, item]; }));
|
||||
for (var _i = 0, DEFAULT_AUTOMATION_TYPES_1 = exports.DEFAULT_AUTOMATION_TYPES; _i < DEFAULT_AUTOMATION_TYPES_1.length; _i++) {
|
||||
var defaultItem = DEFAULT_AUTOMATION_TYPES_1[_i];
|
||||
var existingItem = byId.get(defaultItem.id);
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
byId.set(defaultItem.id, __assign(__assign({}, existingItem), { name: defaultItem.name, description: existingItem.description || defaultItem.description, contexts: sanitizeAutomationContexts(((_a = existingItem.contexts) === null || _a === void 0 ? void 0 : _a.length) ? existingItem.contexts : defaultItem.contexts), behaviorType: defaultItem.behaviorType }));
|
||||
}
|
||||
return sanitizeAutomationTypes(Array.from(byId.values()));
|
||||
}
|
||||
function isSameAutomationTypeList(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every(function (item, index) {
|
||||
var target = right[index];
|
||||
return (target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
JSON.stringify(item.contexts) === JSON.stringify(target.contexts) &&
|
||||
item.behaviorType === target.behaviorType &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt);
|
||||
});
|
||||
}
|
||||
function ensureAutomationTypesTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(AUTOMATION_TYPES_TABLE)];
|
||||
case 1:
|
||||
hasTable = _b.sent();
|
||||
if (!!hasTable) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.createTable(AUTOMATION_TYPES_TABLE, function (table) {
|
||||
table.string('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').notNullable().defaultTo('');
|
||||
table.text('contexts_json').notNullable().defaultTo('[]');
|
||||
table.string('behavior_type').notNullable();
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now());
|
||||
})];
|
||||
case 2:
|
||||
_b.sent();
|
||||
return [2 /*return*/];
|
||||
case 3:
|
||||
requiredColumns = [
|
||||
['name', function (table) { return table.string('name').notNullable().defaultTo(''); }],
|
||||
['description', function (table) { return table.text('description').notNullable().defaultTo(''); }],
|
||||
['contexts_json', function (table) { return table.text('contexts_json').notNullable().defaultTo('[]'); }],
|
||||
['behavior_type', function (table) { return table.string('behavior_type').notNullable().defaultTo('none'); }],
|
||||
['enabled', function (table) { return table.boolean('enabled').notNullable().defaultTo(true); }],
|
||||
['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }],
|
||||
];
|
||||
_loop_1 = function (columnName, createColumn) {
|
||||
var hasColumn;
|
||||
return __generator(this, function (_c) {
|
||||
switch (_c.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName)];
|
||||
case 1:
|
||||
hasColumn = _c.sent();
|
||||
if (!!hasColumn) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.alterTable(AUTOMATION_TYPES_TABLE, function (table) {
|
||||
createColumn(table);
|
||||
})];
|
||||
case 2:
|
||||
_c.sent();
|
||||
_c.label = 3;
|
||||
case 3: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
};
|
||||
_i = 0, requiredColumns_1 = requiredColumns;
|
||||
_b.label = 4;
|
||||
case 4:
|
||||
if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7];
|
||||
_a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1];
|
||||
return [5 /*yield**/, _loop_1(columnName, createColumn)];
|
||||
case 5:
|
||||
_b.sent();
|
||||
_b.label = 6;
|
||||
case 6:
|
||||
_i++;
|
||||
return [3 /*break*/, 4];
|
||||
case 7: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function normalizeConfigRecord(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function parseContextsFromRow(row) {
|
||||
var rawValue = row.contexts_json;
|
||||
if (typeof rawValue !== 'string') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(rawValue);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
catch (_a) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function toAutomationTypeRecord(row) {
|
||||
return normalizeAutomationType({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
name: typeof row.name === 'string' ? row.name : undefined,
|
||||
description: typeof row.description === 'string' ? row.description : undefined,
|
||||
contexts: parseContextsFromRow(row),
|
||||
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type),
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
function seedAutomationTypesFromLegacyConfig() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var config, _a, raw, legacyItems;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0:
|
||||
_a = normalizeConfigRecord;
|
||||
return [4 /*yield*/, (0, app_config_service_js_1.getAppConfig)()];
|
||||
case 1:
|
||||
config = _a.apply(void 0, [_b.sent()]);
|
||||
raw = config[AUTOMATION_TYPES_CONFIG_KEY];
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return [2 /*return*/, mergeDefaultAutomationTypes(exports.DEFAULT_AUTOMATION_TYPES)];
|
||||
}
|
||||
legacyItems = mergeDefaultAutomationTypes(sanitizeAutomationTypes(raw));
|
||||
return [4 /*yield*/, replaceAutomationTypesInTable(legacyItems)];
|
||||
case 2:
|
||||
_b.sent();
|
||||
return [2 /*return*/, legacyItems];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function readAutomationTypesFromTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var rows, savedItems;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ensureAutomationTypesTable()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, (0, client_js_1.db)(AUTOMATION_TYPES_TABLE)
|
||||
.select('id', 'name', 'description', 'contexts_json', 'behavior_type', 'enabled', 'updated_at')
|
||||
.orderBy('name', 'asc')];
|
||||
case 2:
|
||||
rows = _a.sent();
|
||||
savedItems = rows
|
||||
.map(function (row) { return toAutomationTypeRecord(row); })
|
||||
.filter(function (item) { return Boolean(item); });
|
||||
return [2 /*return*/, sanitizeAutomationTypes(savedItems)];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function replaceAutomationTypesInTable(items) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var nextItems;
|
||||
var _this = this;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ensureAutomationTypesTable()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
nextItems = sanitizeAutomationTypes(items);
|
||||
return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, trx(AUTOMATION_TYPES_TABLE).del()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, trx(AUTOMATION_TYPES_TABLE).insert(nextItems.map(function (item) {
|
||||
var _a;
|
||||
return ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
contexts_json: JSON.stringify((_a = item.contexts) !== null && _a !== void 0 ? _a : []),
|
||||
behavior_type: item.behaviorType,
|
||||
enabled: item.enabled,
|
||||
updated_at: item.updatedAt,
|
||||
});
|
||||
}))];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); })];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [2 /*return*/, nextItems];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function getAutomationTypesConfig() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var savedAutomationTypes, automationTypes;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, readAutomationTypesFromTable()];
|
||||
case 1:
|
||||
savedAutomationTypes = _a.sent();
|
||||
automationTypes = mergeDefaultAutomationTypes(savedAutomationTypes);
|
||||
if (automationTypes.length === 0 || automationTypes === exports.DEFAULT_AUTOMATION_TYPES) {
|
||||
return [2 /*return*/, seedAutomationTypesFromLegacyConfig()];
|
||||
}
|
||||
if (!!isSameAutomationTypeList(savedAutomationTypes, automationTypes)) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, replaceAutomationTypesInTable(automationTypes)];
|
||||
case 2:
|
||||
_a.sent();
|
||||
_a.label = 3;
|
||||
case 3: return [2 /*return*/, automationTypes];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function upsertAutomationTypesConfig(items) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var nextAutomationTypes;
|
||||
return __generator(this, function (_a) {
|
||||
nextAutomationTypes = mergeDefaultAutomationTypes(sanitizeAutomationTypes(Array.isArray(items) ? items : []));
|
||||
return [2 /*return*/, replaceAutomationTypesInTable(nextAutomationTypes)];
|
||||
});
|
||||
});
|
||||
}
|
||||
function resolveAutomationType(input) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var requestedId, automationTypes, matched, matchedByBehavior;
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0:
|
||||
requestedId = normalizeLegacyAutomationTypeId(input);
|
||||
return [4 /*yield*/, getAutomationTypesConfig()];
|
||||
case 1:
|
||||
automationTypes = _b.sent();
|
||||
matched = automationTypes.find(function (item) { return item.id === requestedId; });
|
||||
if (matched) {
|
||||
return [2 /*return*/, matched];
|
||||
}
|
||||
matchedByBehavior = automationTypes.find(function (item) { return item.behaviorType === normalizeBehaviorType(requestedId); });
|
||||
if (matchedByBehavior) {
|
||||
return [2 /*return*/, matchedByBehavior];
|
||||
}
|
||||
return [2 /*return*/, ((_a = exports.DEFAULT_AUTOMATION_TYPES.find(function (item) { return item.id === normalizeBehaviorType(requestedId); })) !== null && _a !== void 0 ? _a : exports.DEFAULT_AUTOMATION_TYPES[0])];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function resolveStoredAutomationTypeId(row) {
|
||||
var automationTypeId = normalizeText(row.automation_type_id);
|
||||
if (automationTypeId) {
|
||||
return normalizeLegacyAutomationTypeId(automationTypeId);
|
||||
}
|
||||
return normalizeLegacyAutomationTypeId(row.automation_type) || 'none';
|
||||
}
|
||||
function normalizeAutomationContextSelection(value) {
|
||||
var rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map(function (item) { return item.trim(); })
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return __spreadArray([], new Set(rawValues.map(function (item) { return normalizeText(String(item)); }).filter(Boolean)), true);
|
||||
}
|
||||
function resolveAutomationTypeContexts(automationType, selectedContextIds) {
|
||||
var contexts = sanitizeAutomationContexts(automationType === null || automationType === void 0 ? void 0 : automationType.contexts);
|
||||
var requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
||||
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
||||
return [];
|
||||
}
|
||||
if (requestedIds.length === 0) {
|
||||
return contexts.filter(function (item) { return item.enabled && item.defaultSelected; });
|
||||
}
|
||||
var requestedIdSet = new Set(requestedIds);
|
||||
return contexts.filter(function (item) { return requestedIdSet.has(item.id); });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_AUTOMATION_TYPES,
|
||||
resolveStoredAutomationTypeId,
|
||||
sanitizeAutomationTypes,
|
||||
} from './automation-type-config-service.js';
|
||||
|
||||
test('default automation types include general inquiry', () => {
|
||||
const generalInquiry = DEFAULT_AUTOMATION_TYPES.find((item) => item.id === 'general-inquiry');
|
||||
|
||||
assert.ok(generalInquiry);
|
||||
assert.equal(generalInquiry.name, '일반 문의');
|
||||
assert.equal(generalInquiry.behaviorType, 'command_execution');
|
||||
});
|
||||
|
||||
test('sanitizeAutomationTypes falls back to general inquiry in defaults', () => {
|
||||
const items = sanitizeAutomationTypes([]);
|
||||
|
||||
assert.ok(items.some((item) => item.id === 'general-inquiry'));
|
||||
});
|
||||
|
||||
test('resolveStoredAutomationTypeId remaps legacy stock-alert id to general inquiry', () => {
|
||||
assert.equal(resolveStoredAutomationTypeId({ automation_type_id: 'stock-alert' }), 'general-inquiry');
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
import { db } from '../db/client.js';
|
||||
import { getAppConfig } from './app-config-service.js';
|
||||
|
||||
const AUTOMATION_TYPES_TABLE = 'automation_types';
|
||||
const AUTOMATION_TYPES_CONFIG_KEY = 'automationTypes';
|
||||
|
||||
export const AUTOMATION_BEHAVIOR_TYPES = [
|
||||
'none',
|
||||
'plan',
|
||||
'command_execution',
|
||||
'non_source_work',
|
||||
'auto_worker',
|
||||
] as const;
|
||||
|
||||
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
|
||||
|
||||
export type AutomationTypeContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
defaultSelected: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AutomationTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
contexts: AutomationTypeContextRecord[];
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description: '일반 문의/확인 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description: '기본 자동화 처리용 유형입니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto_worker',
|
||||
name: 'autoWorker',
|
||||
description: '자동화 작업메모로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content:
|
||||
'## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'auto_worker',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeEnabled(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
|
||||
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
function normalizeLegacyAutomationTypeId(value: unknown) {
|
||||
const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
|
||||
|
||||
if (normalizedValue === 'stock-alert') {
|
||||
return 'general-inquiry';
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
|
||||
const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
|
||||
return AUTOMATION_BEHAVIOR_TYPES.includes(normalizedValue as AutomationBehaviorType)
|
||||
? (normalizedValue as AutomationBehaviorType)
|
||||
: 'none';
|
||||
}
|
||||
|
||||
export function normalizeLegacyAutomationBehaviorType(value: unknown): string {
|
||||
const normalizedValue = normalizeText(value);
|
||||
|
||||
if (normalizedValue === 'plan_registration') {
|
||||
return 'plan';
|
||||
}
|
||||
|
||||
if (normalizedValue === 'general_development') {
|
||||
return 'auto_worker';
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function buildNameKey(value: string) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function buildContextTitleKey(value: string) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function normalizeAutomationContext(record: Partial<AutomationTypeContextRecord>): AutomationTypeContextRecord | null {
|
||||
const title = normalizeText(record.title);
|
||||
const content = normalizeText(record.content);
|
||||
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: title || 'Context',
|
||||
content,
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
defaultSelected: normalizeEnabled(record.defaultSelected),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareContextUpdatedAt(left: AutomationTypeContextRecord, right: AutomationTypeContextRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sanitizeAutomationContexts(items: Partial<AutomationTypeContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, AutomationTypeContextRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationTypeContextRecord>();
|
||||
|
||||
(items ?? [])
|
||||
.map((item) => normalizeAutomationContext(item))
|
||||
.filter((item): item is AutomationTypeContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
const currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = buildContextTitleKey(item.title);
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
||||
}
|
||||
|
||||
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId || `automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
contexts: sanitizeAutomationContexts(record.contexts),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function dedupeAutomationTypes(items: AutomationTypeRecord[]) {
|
||||
const byId = new Map<string, AutomationTypeRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationTypeRecord>();
|
||||
|
||||
for (const item of items) {
|
||||
const currentById = byId.get(item.id);
|
||||
|
||||
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = `${item.behaviorType}:${buildNameKey(item.name)}`;
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
}
|
||||
|
||||
export function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[] | null | undefined) {
|
||||
const normalized = (items ?? [])
|
||||
.map((item) => normalizeAutomationType(item))
|
||||
.filter((item): item is AutomationTypeRecord => Boolean(item));
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return DEFAULT_AUTOMATION_TYPES;
|
||||
}
|
||||
|
||||
return dedupeAutomationTypes(normalized);
|
||||
}
|
||||
|
||||
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
|
||||
const byId = new Map(items.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
|
||||
const existingItem = byId.get(defaultItem.id);
|
||||
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
name: defaultItem.name,
|
||||
description: existingItem.description || defaultItem.description,
|
||||
contexts: sanitizeAutomationContexts(existingItem.contexts?.length ? existingItem.contexts : defaultItem.contexts),
|
||||
behaviorType: defaultItem.behaviorType,
|
||||
});
|
||||
}
|
||||
|
||||
return sanitizeAutomationTypes(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const target = right[index];
|
||||
return (
|
||||
target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
JSON.stringify(item.contexts) === JSON.stringify(target.contexts) &&
|
||||
item.behaviorType === target.behaviorType &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureAutomationTypesTable() {
|
||||
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').notNullable().defaultTo('');
|
||||
table.text('contexts_json').notNullable().defaultTo('[]');
|
||||
table.string('behavior_type').notNullable();
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['name', (table) => table.string('name').notNullable().defaultTo('')],
|
||||
['description', (table) => table.text('description').notNullable().defaultTo('')],
|
||||
['contexts_json', (table) => table.text('contexts_json').notNullable().defaultTo('[]')],
|
||||
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
|
||||
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfigRecord(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseContextsFromRow(row: Record<string, unknown>) {
|
||||
const rawValue = row.contexts_json;
|
||||
|
||||
if (typeof rawValue !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toAutomationTypeRecord(row: Record<string, unknown>) {
|
||||
return normalizeAutomationType({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
name: typeof row.name === 'string' ? row.name : undefined,
|
||||
description: typeof row.description === 'string' ? row.description : undefined,
|
||||
contexts: parseContextsFromRow(row),
|
||||
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedAutomationTypesFromLegacyConfig() {
|
||||
const config = normalizeConfigRecord(await getAppConfig());
|
||||
const raw = config[AUTOMATION_TYPES_CONFIG_KEY];
|
||||
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return mergeDefaultAutomationTypes(DEFAULT_AUTOMATION_TYPES);
|
||||
}
|
||||
|
||||
const legacyItems = mergeDefaultAutomationTypes(
|
||||
sanitizeAutomationTypes(raw as Partial<AutomationTypeRecord>[]),
|
||||
);
|
||||
await replaceAutomationTypesInTable(legacyItems);
|
||||
return legacyItems;
|
||||
}
|
||||
|
||||
async function readAutomationTypesFromTable() {
|
||||
await ensureAutomationTypesTable();
|
||||
|
||||
const rows = await db(AUTOMATION_TYPES_TABLE)
|
||||
.select('id', 'name', 'description', 'contexts_json', 'behavior_type', 'enabled', 'updated_at')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const savedItems = rows
|
||||
.map((row) => toAutomationTypeRecord(row as Record<string, unknown>))
|
||||
.filter((item): item is AutomationTypeRecord => Boolean(item));
|
||||
|
||||
return sanitizeAutomationTypes(savedItems);
|
||||
}
|
||||
|
||||
async function replaceAutomationTypesInTable(items: AutomationTypeRecord[]) {
|
||||
await ensureAutomationTypesTable();
|
||||
|
||||
const nextItems = sanitizeAutomationTypes(items);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(AUTOMATION_TYPES_TABLE).del();
|
||||
|
||||
await trx(AUTOMATION_TYPES_TABLE).insert(
|
||||
nextItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
contexts_json: JSON.stringify(item.contexts ?? []),
|
||||
behavior_type: item.behaviorType,
|
||||
enabled: item.enabled,
|
||||
updated_at: item.updatedAt,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
export async function getAutomationTypesConfig() {
|
||||
const savedAutomationTypes = await readAutomationTypesFromTable();
|
||||
const automationTypes = mergeDefaultAutomationTypes(savedAutomationTypes);
|
||||
|
||||
if (automationTypes.length === 0 || automationTypes === DEFAULT_AUTOMATION_TYPES) {
|
||||
return seedAutomationTypesFromLegacyConfig();
|
||||
}
|
||||
|
||||
if (!isSameAutomationTypeList(savedAutomationTypes, automationTypes)) {
|
||||
await replaceAutomationTypesInTable(automationTypes);
|
||||
}
|
||||
|
||||
return automationTypes;
|
||||
}
|
||||
|
||||
export async function upsertAutomationTypesConfig(items: unknown[]) {
|
||||
const nextAutomationTypes = mergeDefaultAutomationTypes(
|
||||
sanitizeAutomationTypes(Array.isArray(items) ? (items as Partial<AutomationTypeRecord>[]) : []),
|
||||
);
|
||||
return replaceAutomationTypesInTable(nextAutomationTypes);
|
||||
}
|
||||
|
||||
export async function resolveAutomationType(input: unknown) {
|
||||
const requestedId = normalizeLegacyAutomationTypeId(input);
|
||||
const automationTypes = await getAutomationTypesConfig();
|
||||
const matched = automationTypes.find((item) => item.id === requestedId);
|
||||
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
const matchedByBehavior = automationTypes.find((item) => item.behaviorType === normalizeBehaviorType(requestedId));
|
||||
|
||||
if (matchedByBehavior) {
|
||||
return matchedByBehavior;
|
||||
}
|
||||
|
||||
return (
|
||||
DEFAULT_AUTOMATION_TYPES.find((item) => item.id === normalizeBehaviorType(requestedId)) ??
|
||||
DEFAULT_AUTOMATION_TYPES[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveStoredAutomationTypeId(row: Record<string, unknown>) {
|
||||
const automationTypeId = normalizeText(row.automation_type_id);
|
||||
|
||||
if (automationTypeId) {
|
||||
return normalizeLegacyAutomationTypeId(automationTypeId);
|
||||
}
|
||||
|
||||
return normalizeLegacyAutomationTypeId(row.automation_type) || 'none';
|
||||
}
|
||||
|
||||
export function normalizeAutomationContextSelection(value: unknown) {
|
||||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function resolveAutomationTypeContexts(
|
||||
automationType: Pick<AutomationTypeRecord, 'contexts'> | null | undefined,
|
||||
selectedContextIds?: unknown,
|
||||
) {
|
||||
const contexts = sanitizeAutomationContexts(automationType?.contexts);
|
||||
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
||||
|
||||
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (requestedIds.length === 0) {
|
||||
return contexts.filter((item) => item.enabled && item.defaultSelected);
|
||||
}
|
||||
|
||||
const requestedIdSet = new Set(requestedIds);
|
||||
return contexts.filter((item) => requestedIdSet.has(item.id));
|
||||
}
|
||||
1261
etc/servers/work-server/src/services/board-service.js
Normal file
1261
etc/servers/work-server/src/services/board-service.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user