Initial import
This commit is contained in:
11
.env.example
Executable file
11
.env.example
Executable file
@@ -0,0 +1,11 @@
|
||||
NODE_VERSION=22.22.2
|
||||
PHOTOPRISM_PORT=2342
|
||||
PHOTOPRISM_ORIGINALS_SOURCE=/mnt/usb/photos
|
||||
PHOTOPRISM_SITE_URL=https://photo.sm-home.cloud/
|
||||
PHOTOPRISM_READONLY=true
|
||||
PHOTOPRISM_ADMIN_USER=admin
|
||||
PHOTOPRISM_ADMIN_PASSWORD=ChangeMe1234
|
||||
PHOTOPRISM_DATABASE_NAME=photoprism
|
||||
PHOTOPRISM_DATABASE_USER=photoprism
|
||||
PHOTOPRISM_DATABASE_PASSWORD=photoprism
|
||||
PHOTOPRISM_DATABASE_ROOT_PASSWORD=photoprism-root
|
||||
43
.gitignore
vendored
Executable file
43
.gitignore
vendored
Executable file
@@ -0,0 +1,43 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dev-dist/
|
||||
app-dist/
|
||||
test-app-dist/
|
||||
|
||||
.DS_Store
|
||||
.auto_codex/
|
||||
.worktrees/
|
||||
.idea/
|
||||
.vscode/
|
||||
.docker/
|
||||
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.cache/
|
||||
tmp/
|
||||
node_modules.root-owned-backup/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
.tmp
|
||||
!.env.example
|
||||
|
||||
# etc workspace
|
||||
etc/**/.env
|
||||
etc/**/node_modules/
|
||||
etc/**/dist/
|
||||
etc/**/.docker/
|
||||
etc/**/*.log
|
||||
etc/**/.DS_Store
|
||||
|
||||
*.tsbuildinfo
|
||||
*.swp
|
||||
*.root-owned-backup
|
||||
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
|
||||
public/.codex_chat
|
||||
.server-command-runner-heartbeat.json
|
||||
docs/assets/worklogs/
|
||||
71
AGENTS.md
Executable file
71
AGENTS.md
Executable file
@@ -0,0 +1,71 @@
|
||||
# 📌 AI 작업 운영 규칙 (Codex + 로컬 작업 기준)
|
||||
|
||||
## 🚨 현재 적용 모드 (최우선)
|
||||
|
||||
현재 이 저장소는 **로컬 전용 + main 직접 작업 모드**로 사용한다.
|
||||
|
||||
이 문서의 목적은 복잡한 브랜치 흐름보다 **현재 체크아웃된 로컬 `main`에서 바로 수정하고 실행하는 기준**을 우선 적용하는 것이다.
|
||||
|
||||
### Codex / AI 기본 규칙
|
||||
|
||||
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선, 작업 메모 반영을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
|
||||
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다
|
||||
* 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다
|
||||
* `채팅`, `작업 메모`, `작업메모`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
|
||||
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
|
||||
* 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다
|
||||
* 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다
|
||||
* 현재는 브랜치 전략보다 **로컬 실행 가능 상태 유지, 코드 수정, 문서 갱신, 메모 반영 속도**를 우선한다
|
||||
* `.auto_codex` 관련 Git 자동화, Plan 자동화, 브랜치 생성/병합 흐름은 사용자가 다시 요청하기 전까지 **비활성 상태**로 취급한다
|
||||
|
||||
### 요청 해석 규칙
|
||||
|
||||
* 사용자가 단순히 구현, 수정, 실행, 설정 변경을 요청하면 **Git 작업 없이 로컬 파일 수정과 실행 작업만 진행**한다
|
||||
* 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다
|
||||
* 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다
|
||||
* 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다
|
||||
* 사용자가 `자동화 메모`라고만 말하면 게시판 메모인지 자동화 접수 메모인지 문맥을 먼저 확인한다
|
||||
* 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다
|
||||
* 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다
|
||||
* 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다
|
||||
|
||||
---
|
||||
|
||||
## Git 관련 안전 규칙
|
||||
|
||||
* 로컬 작업 중에도 사용자가 요청하지 않은 Git 정리 작업은 하지 않는다
|
||||
* `reset`, `checkout`, `switch`, `clean`, 강제 덮어쓰기처럼 되돌리기 어려운 작업은 자동으로 수행하지 않는다
|
||||
* 사용자가 Git 작업을 요청해도 **정말 필요한 범위만** 실행한다
|
||||
* 현재 모드에서는 `main` 직접 수정이 허용되지만, **자동 commit / push는 여전히 금지**한다
|
||||
|
||||
---
|
||||
|
||||
## Codex Live / 채팅 / 작업 메모 규칙
|
||||
|
||||
* `Codex Live`, 일반 채팅, 작업 메모 반영 요청은 모두 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
|
||||
* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다
|
||||
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
|
||||
* 작업 메모는 기록 목적이든 실제 수정 지시든 우선 `main` 기준의 로컬 작업으로 연결한다
|
||||
* 채팅과 작업 메모는 Git flow를 강제하지 않고, 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다
|
||||
* 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다
|
||||
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
|
||||
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
|
||||
|
||||
---
|
||||
|
||||
## Plan / 자동화 메모
|
||||
|
||||
* 기존 문서에 남아 있는 `feature`, `hotfix`, `release` 흐름은 **현재 로컬 모드에서는 기본 규칙으로 사용하지 않는다**
|
||||
* Plan 게시판과 자동화 관련 기능 설명은 UI/상태 설명으로만 참고하고, 실제 Git 브랜치 운영 규칙으로 자동 적용하지 않는다
|
||||
* 사용자가 나중에 브랜치 전략 복구를 명시적으로 요청하면 그때 별도 문서 갱신 후 다시 적용한다
|
||||
|
||||
---
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
👉 지금은 로컬 `main`에서 바로 수정한다
|
||||
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다
|
||||
👉 채팅이든 작업 메모든 기본 해석은 `main` 직접 작업이다
|
||||
👉 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
|
||||
|
||||
---
|
||||
121
README.md
Executable file
121
README.md
Executable file
@@ -0,0 +1,121 @@
|
||||
# AI Code App
|
||||
|
||||
React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입니다. 현재 저장소는 공통 컴포넌트, 위젯 샘플, Markdown 문서 뷰어, Plan 게시판을 하나의 앱에서 탐색할 수 있도록 구성되어 있습니다.
|
||||
|
||||
## 임시 운영 메모
|
||||
|
||||
- 현재 저장소는 당분간 **로컬 전용 작업 모드**로 사용합니다.
|
||||
- Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다.
|
||||
- Codex나 자동화 도구는 기본적으로 Git 작업 없이 **현재 프로젝트 루트의 `main` 작업본을 바로 수정**합니다.
|
||||
- `채팅`, `Codex Live`, `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
|
||||
- 채팅 리소스와 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래를 사용합니다.
|
||||
|
||||
## 시작하기
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## PhotoPrism
|
||||
|
||||
루트 `docker-compose.yml`에는 PhotoPrism와 MariaDB 서비스가 포함되어 있습니다.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d photoprism photoprism-db
|
||||
```
|
||||
|
||||
- 기본 접속 포트: `127.0.0.1:2342`
|
||||
- 기본 사이트 URL: `https://photo.sm-home.cloud/`
|
||||
- 원본 사진 경로: `/mnt/usb/photos`
|
||||
- 원본 경로는 읽기 전용으로 마운트됩니다.
|
||||
- `/mnt/usb/photos`가 호스트에 없으면 bind mount를 자동 생성하지 않고 기동이 실패합니다.
|
||||
|
||||
## 주요 스크립트
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run build:lib
|
||||
npm run build:app
|
||||
npm run preview
|
||||
npm run docs:daily
|
||||
npm run capture:component
|
||||
npm run capture:menu
|
||||
npm run capture:feature
|
||||
npm run capture:fullscreen
|
||||
npm run capture:plan-mobile
|
||||
npm run plan:codex:once
|
||||
```
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```text
|
||||
src/
|
||||
├─ app/
|
||||
│ ├─ main/ # 메인 레이아웃과 상단/사이드 UI
|
||||
│ └─ manifests/ # 문서/샘플 로딩 매니페스트
|
||||
├─ components/
|
||||
│ ├─ markdownPreview/ # Markdown 문서 목록/카드 렌더링
|
||||
│ ├─ navigation/ # 섹션 메뉴와 폴더 트리
|
||||
│ ├─ previewer/ # text/json/code/image/markdown 미리보기
|
||||
│ ├─ search/ # 통합 검색 모달
|
||||
│ ├─ status-badge/ # 상태 표현 UI
|
||||
│ └─ window/ # 드래그/리사이즈 가능한 윈도우 UI
|
||||
├─ features/
|
||||
│ ├─ dashboard/ # 프로젝트 전용 대시보드 샘플
|
||||
│ ├─ layout/ # 프로젝트 전용 레이아웃 문서
|
||||
│ ├─ markdownPreview/ # 기능 레벨 Markdown 카드
|
||||
│ └─ planBoard/ # Plan 게시판 화면과 API 연동
|
||||
├─ layer/ # 제스처/검색 레이어
|
||||
├─ samples/ # 샘플 엔트리 레지스트리
|
||||
├─ store/ # 앱 전역 상태
|
||||
└─ widgets/ # 위젯 단위 샘플과 공통 셸
|
||||
docs/
|
||||
├─ components/ # 컴포넌트 문서
|
||||
├─ features/ # 기능 문서
|
||||
├─ templates/ # 문서 템플릿
|
||||
└─ worklogs/ # 날짜별 작업일지
|
||||
```
|
||||
|
||||
## 앱 구성
|
||||
|
||||
- `APIs / Components`: 공통 컴포넌트 샘플 탐색
|
||||
- `APIs / Widgets`: 위젯 샘플 탐색
|
||||
- `Docs`: `docs/**/*.md`와 일부 `src/features/**/*.md` 문서 탐색
|
||||
- `Plans`: 작업 항목, 조치 이력, 이슈 이력을 관리하는 Plan 게시판
|
||||
|
||||
## 문서 위치
|
||||
|
||||
- 전체 문서 가이드: `docs/README.md`
|
||||
- 작업일지: `docs/worklogs`
|
||||
- 기능 문서: `docs/features`
|
||||
- 컴포넌트 문서: `docs/components`
|
||||
|
||||
## 운영 메모
|
||||
|
||||
- 앱 문서는 Vite `import.meta.glob`으로 Markdown 파일을 수집합니다.
|
||||
- 작업일지는 날짜별 파일로 누적하며 캡처 이미지는 `docs/assets/worklogs/YYYY-MM-DD/` 기준으로 관리합니다.
|
||||
- Plan 자동화 스크립트는 `scripts/run-plan-codex-once.mjs`를 사용합니다.
|
||||
- 서버 재기동을 호스트 프로젝트 루트 기준으로 처리하려면 `npm run server-command:runner`를 실행합니다.
|
||||
- 문서/작업일지 일일 정리는 `npm run docs:daily`와 `.github/workflows/daily-docs-maintenance.yml` 기준으로 실행합니다.
|
||||
|
||||
## 프로젝트 현황
|
||||
|
||||
<!-- AUTO_DAILY_DOCS:README_START -->
|
||||
- 기준 일자: `2026-04-07` (Asia/Seoul)
|
||||
- 작업일지: 9개
|
||||
- 기능 문서: 2개
|
||||
- 컴포넌트 문서: 8개
|
||||
- 스크린샷 보관 폴더: 7개
|
||||
|
||||
최근 작업일지
|
||||
- `2026-04-07` 작업일지
|
||||
- `2026-04-06` 작업일지
|
||||
- `2026-04-05` 작업일지
|
||||
- `2026-04-04` 작업일지
|
||||
- `2026-04-03` 작업일지
|
||||
- `2026-04-02` 작업일지
|
||||
- `2026-04-01` 작업일지
|
||||
<!-- AUTO_DAILY_DOCS:README_END -->
|
||||
152
docker-compose.yml
Executable file
152
docker-compose.yml
Executable file
@@ -0,0 +1,152 @@
|
||||
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
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
user: "0:0"
|
||||
cpus: 1.0
|
||||
mem_limit: 1536m
|
||||
working_dir: /prod-app
|
||||
ports:
|
||||
- '127.0.0.1:5173:5173'
|
||||
volumes:
|
||||
- ${PROD_APP_SOURCE:-.}:/prod-app
|
||||
- ./.docker/prod-app/node_modules:/prod-app/node_modules
|
||||
- ./.docker/prod-app/home:/home/how2ice
|
||||
networks:
|
||||
- default
|
||||
- work-backend
|
||||
environment:
|
||||
HOME: /home/how2ice
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
WORK_SERVER_URL: http://work-server:3100
|
||||
VITE_DISABLE_APP_UPDATE: "true"
|
||||
command: >
|
||||
sh -c "npm ci --legacy-peer-deps && npm run build:test-app && npm run preview:test-app -- --host 0.0.0.0 --port 5173 --strictPort"
|
||||
|
||||
app:
|
||||
image: node:${NODE_VERSION:-22.22.2}-bookworm
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
user: "0:0"
|
||||
cpus: 1.0
|
||||
mem_limit: 1536m
|
||||
working_dir: /app
|
||||
ports:
|
||||
- '127.0.0.1:5174:5173'
|
||||
volumes:
|
||||
- ./:/app
|
||||
- app-node-modules:/app/node_modules
|
||||
- app-home:/home/how2ice
|
||||
networks:
|
||||
- default
|
||||
- work-backend
|
||||
environment:
|
||||
HOME: /home/how2ice
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
WORK_SERVER_URL: http://work-server:3100
|
||||
command: >
|
||||
sh -c "npm ci --legacy-peer-deps && npm run build:test-app && npm run preview:test-app -- --host 0.0.0.0 --port 5173 --strictPort"
|
||||
|
||||
release-app:
|
||||
image: node:${NODE_VERSION:-22.22.2}-bookworm
|
||||
container_name: ai-code-app-release
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
user: "0:0"
|
||||
cpus: 1.0
|
||||
mem_limit: 1536m
|
||||
working_dir: /release-app
|
||||
ports:
|
||||
- '127.0.0.1:5175:5173'
|
||||
volumes:
|
||||
- ${RELEASE_APP_SOURCE:-.}:/release-app
|
||||
- ./.docker/release-app/node_modules:/release-app/node_modules
|
||||
- ./.docker/release-app/home:/home/how2ice
|
||||
networks:
|
||||
- default
|
||||
- work-backend
|
||||
environment:
|
||||
HOME: /home/how2ice
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
command: >
|
||||
sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173"
|
||||
|
||||
networks:
|
||||
work-backend:
|
||||
name: work-backend
|
||||
|
||||
volumes:
|
||||
app-node-modules:
|
||||
app-home:
|
||||
photoprism-storage:
|
||||
photoprism-db:
|
||||
155
docs/README.md
Executable file
155
docs/README.md
Executable file
@@ -0,0 +1,155 @@
|
||||
# Docs Guide
|
||||
|
||||
프로젝트 문서는 작업일지, 기능 문서, 컴포넌트 문서를 기본 축으로 운영합니다. 현재 메인 앱 `Docs` 화면은 `docs/**/*.md`를 동적으로 수집해 폴더별로 노출합니다.
|
||||
|
||||
## 0. 임시 로컬 모드
|
||||
|
||||
- 현재 저장소는 당분간 로컬 전용으로 운영합니다.
|
||||
- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다.
|
||||
- `Codex Live`, 일반 채팅, 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
|
||||
- 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/types/component-plugin.ts` 에서 관리합니다.
|
||||
|
||||
샘플 운영 규칙:
|
||||
|
||||
- `samples/Sample.tsx`는 해당 컴포넌트의 가장 기본형만 표현
|
||||
- plugin/feature 예시는 `samples/*.tsx`로 분리
|
||||
- 샘플 목록에서는 같은 컴포넌트 기준으로 묶고 `base -> plugin -> feature` 순서로 정렬
|
||||
|
||||
## 4. 샘플/위젯 레이아웃
|
||||
|
||||
- 컴포넌트 샘플 레이아웃: 좌측 컴포넌트 목록 + 우측 상세 카드
|
||||
- 상세 카드는 컴포넌트 하나당 1개
|
||||
- 카드 내부는 `Base Sample` 아래에 `Plugin Samples`, `Feature Samples`를 순차적으로 배치
|
||||
- 위젯 샘플은 `widgets/**/samples/*.tsx` 기준으로 별도 수집
|
||||
- 실제 샘플 엔트리 로딩은 `src/app/manifests/samples.manifest.ts`, `src/samples/registry.ts`를 기준으로 동작
|
||||
|
||||
## 5. 프로젝트 종속 레이아웃
|
||||
|
||||
- 위치: `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 반영 상태를 표현
|
||||
- `작업시작` 이후에는 원본 요청(`작업 ID`, 원본 메모`)을 수정하지 않고 조치 이력으로 누적 기록
|
||||
- 권한 토큰이 없으면 조회 중심으로 동작하며 민감 메모와 소스 작업 일부는 제한 또는 마스킹
|
||||
- 관련 기능 문서는 `docs/features/plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고
|
||||
249
docs/chat-frontend-rewrite-plan.md
Normal file
249
docs/chat-frontend-rewrite-plan.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Chat Frontend Rewrite Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Rebuild the chat frontend around explicit feature boundaries instead of one large panel. The rewrite keeps the current server contract for now, but removes direct API coupling from UI components and preserves the current mobile visual structure.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
The first rewrite wave does not change:
|
||||
|
||||
1. Work server REST endpoint shapes
|
||||
2. Work server WebSocket event payload shapes
|
||||
3. Existing chat database schema
|
||||
4. Current mobile interaction model and visual hierarchy
|
||||
|
||||
## Current Pain Points
|
||||
|
||||
The existing frontend mixes too many concerns in the same render tree:
|
||||
|
||||
1. Session routing and panel selection
|
||||
2. Conversation list fetch and sort
|
||||
3. Conversation detail recovery
|
||||
4. Composer draft and upload lifecycle
|
||||
5. WebSocket connection and reconnect
|
||||
6. Runtime dashboard fetch and live updates
|
||||
7. Error log loading
|
||||
8. Notification unread sync
|
||||
9. Visibility, focus, reconnect, and page restore sync
|
||||
|
||||
This creates three classes of failures:
|
||||
|
||||
1. Render loops from unstable effect dependencies
|
||||
2. Request storms from duplicate fetch paths
|
||||
3. Partial outages where one failing concern blanks the whole chat workspace
|
||||
|
||||
## Rewrite Strategy
|
||||
|
||||
The rewrite is frontend-first, but not frontend-only in architecture. The new frontend must assume that API latency and WebSocket reconnects can fail, and each feature controller must degrade independently.
|
||||
|
||||
### Guiding Rules
|
||||
|
||||
1. UI components do not call REST helpers directly
|
||||
2. UI components do not build WebSocket URLs directly
|
||||
3. One feature controller owns one feature's network lifecycle
|
||||
4. Shell state never performs data fetches
|
||||
5. Mobile layout is preserved while data flow is replaced under it
|
||||
|
||||
## Feature Inventory
|
||||
|
||||
### 1. Workspace Shell
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Hold `chat | runtime | errors` selection
|
||||
2. Hold active session id
|
||||
3. Hold mobile split-pane visibility
|
||||
4. Compose feature panes
|
||||
|
||||
Rules:
|
||||
|
||||
1. No direct REST calls
|
||||
2. No direct socket usage
|
||||
3. No data caching
|
||||
|
||||
### 2. Conversation List
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Load room summaries
|
||||
2. Search and sort locally
|
||||
3. Create, rename, delete, and select rooms
|
||||
4. Expose unread and processing badges
|
||||
|
||||
Rules:
|
||||
|
||||
1. One fetch source
|
||||
2. One short-lived in-flight dedupe layer
|
||||
3. No room detail fetch inside the list controller
|
||||
|
||||
### 3. Conversation Room
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Load one room detail
|
||||
2. Merge server messages with optimistic local state
|
||||
3. Recover state after reconnect
|
||||
4. Mark replies as read
|
||||
|
||||
Rules:
|
||||
|
||||
1. Only one active detail request at a time
|
||||
2. Detail loading must never fan out into list reloads
|
||||
3. Loading and recovery state must be local to the active room
|
||||
|
||||
### 4. Composer
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Hold draft and attachments
|
||||
2. Submit queue or direct requests
|
||||
3. Retry, cancel, and delete pending items
|
||||
4. Manage optimistic user/request messages
|
||||
|
||||
Rules:
|
||||
|
||||
1. No list-wide refresh on send
|
||||
2. No runtime refresh coupled to draft input
|
||||
|
||||
### 5. Live Connection
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Open and maintain the shared chat socket
|
||||
2. Route message, job, runtime, and activity events
|
||||
3. Reconnect with bounded recovery
|
||||
4. Publish connection state through a shared adapter
|
||||
|
||||
Rules:
|
||||
|
||||
1. No duplicate context writes
|
||||
2. Background transitions are throttled
|
||||
3. Reconnect only restores the active room by default
|
||||
|
||||
### 6. Runtime
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Load runtime snapshot
|
||||
2. Show queue and active jobs
|
||||
3. Load per-job detail
|
||||
4. Support remove and cancel actions
|
||||
|
||||
Rules:
|
||||
|
||||
1. Runtime refresh is separate from room detail refresh
|
||||
2. Runtime failure must not blank the chat room UI
|
||||
|
||||
### 7. Error Viewer
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Load error log list
|
||||
2. Render error detail and resource previews
|
||||
|
||||
Rules:
|
||||
|
||||
1. Fully isolated from chat room state
|
||||
|
||||
### 8. Notification Integration
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Unread badges
|
||||
2. Notification center list/detail
|
||||
3. Offline room notifications
|
||||
|
||||
Rules:
|
||||
|
||||
1. No room detail polling from notification badge refresh
|
||||
2. No direct dependency from notification UI to chat room rendering
|
||||
|
||||
## New Frontend Layers
|
||||
|
||||
### A. UI Layer
|
||||
|
||||
Files under `components/` and pane files.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Render props only
|
||||
2. Emit user actions only
|
||||
|
||||
### B. Feature Controller Layer
|
||||
|
||||
Files under `hooks/`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Manage one feature's state machine
|
||||
2. Translate UI actions to gateway calls
|
||||
3. Own loading and error state
|
||||
|
||||
### C. Gateway Layer
|
||||
|
||||
Files under `data/`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Wrap all chat REST calls
|
||||
2. Wrap all chat socket entry points
|
||||
3. Normalize fallback behavior and timeouts in one place
|
||||
|
||||
This is the critical separation that the old frontend does not have.
|
||||
|
||||
## Target Folder Shape
|
||||
|
||||
```text
|
||||
src/app/main/chatV2/
|
||||
ChatWorkspaceV2.tsx
|
||||
types.ts
|
||||
data/
|
||||
chatGateway.ts
|
||||
chatConnectionGateway.ts
|
||||
hooks/
|
||||
useChatWorkspaceState.ts
|
||||
useConversationListController.ts
|
||||
useConversationRoomController.ts
|
||||
useComposerController.ts
|
||||
useRuntimeController.ts
|
||||
useNotificationController.ts
|
||||
components/
|
||||
ConversationListPane.tsx
|
||||
ConversationRoomPane.tsx
|
||||
Composer.tsx
|
||||
RuntimePane.tsx
|
||||
ErrorPane.tsx
|
||||
```
|
||||
|
||||
## Migration Waves
|
||||
|
||||
### Wave 1
|
||||
|
||||
1. Freeze mobile layout
|
||||
2. Introduce chatV2 gateway layer
|
||||
3. Move list/detail/runtime access behind the gateway
|
||||
|
||||
### Wave 2
|
||||
|
||||
1. Replace list controller
|
||||
2. Replace room controller
|
||||
3. Replace composer controller
|
||||
|
||||
### Wave 3
|
||||
|
||||
1. Reconnect runtime and notifications through new controllers
|
||||
2. Remove old `MainChatPanel` effect chains
|
||||
|
||||
### Wave 4
|
||||
|
||||
1. Make `MainChatPanel` a thin compatibility wrapper or replace it entirely
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Main load triggers one list fetch
|
||||
2. Opening one room triggers one detail fetch
|
||||
3. No direct browser fallback to external `:3100` ports on remote hosts
|
||||
4. WebSocket and REST routing live in one gateway boundary
|
||||
5. One pane can fail without blanking the others
|
||||
6. Mobile layout matches the pre-rewrite visual structure
|
||||
34
docs/components/check-combo.md
Executable file
34
docs/components/check-combo.md
Executable file
@@ -0,0 +1,34 @@
|
||||
# Check Combo Input
|
||||
|
||||
## 목적
|
||||
|
||||
`code/value` 데이터를 받아 여러 항목을 체크박스 형태로 선택하고, 실제 값은 `code[]`로 유지하는 다중 선택 combo 입력 컴포넌트입니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
src/components/inputs/checkCombo
|
||||
├─ CheckComboUI.tsx
|
||||
├─ index.ts
|
||||
├─ plugins/
|
||||
├─ samples/
|
||||
└─ types/
|
||||
```
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `data: { code, value }[]`
|
||||
- `value`, `defaultValue`
|
||||
- `onChange(codes, items)`
|
||||
- `showSearch`
|
||||
- `allowClear`
|
||||
- `placeholder`
|
||||
|
||||
## plugins
|
||||
|
||||
- `createCheckComboPlaceholderPlugin`
|
||||
- `createCheckComboSortPlugin`
|
||||
|
||||
## 샘플
|
||||
|
||||
- 대표 샘플: `src/components/inputs/checkCombo/samples/Sample.tsx`
|
||||
35
docs/components/codex-diff-previewer.md
Executable file
35
docs/components/codex-diff-previewer.md
Executable file
@@ -0,0 +1,35 @@
|
||||
# Codex Diff Previewer
|
||||
|
||||
## 목적
|
||||
|
||||
변경 파일의 전체 소스와 raw diff를 codex 스타일 아코디언으로 함께 보여주는 공통 preview 컴포넌트입니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
src/components/previewer
|
||||
├─ CodexDiffBlock.tsx
|
||||
├─ CodexDiffPreviewer.tsx
|
||||
├─ CodexDiffPreviewer.css
|
||||
├─ samples/
|
||||
│ └─ CodexDiffSample.tsx
|
||||
└─ index.ts
|
||||
```
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `files`
|
||||
- `diffText`
|
||||
- `title`
|
||||
- `description`
|
||||
- `height`
|
||||
|
||||
## 공통 사용처
|
||||
|
||||
- 작업일지 `## 소스` 탭
|
||||
- 일반 문서의 ````diff```` 코드 블록
|
||||
- previewer 샘플 갤러리
|
||||
|
||||
## 샘플
|
||||
|
||||
- 대표 샘플: `src/components/previewer/samples/CodexDiffSample.tsx`
|
||||
130
docs/components/component-addition-suggestions.md
Executable file
130
docs/components/component-addition-suggestions.md
Executable file
@@ -0,0 +1,130 @@
|
||||
# 신규 컴포넌트 후보 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, 적용 위치, 확장 포인트를 함께 기록
|
||||
46
docs/components/evidence-attachment-strip-ui.md
Executable file
46
docs/components/evidence-attachment-strip-ui.md
Executable file
@@ -0,0 +1,46 @@
|
||||
# Evidence Attachment Strip UI
|
||||
|
||||
## 목적
|
||||
|
||||
Plan/Board 계열 화면에서 반복되는 산출물 카드, 링크, 미리보기 진입 UI를 공통 스트립으로 정리하는 컴포넌트입니다.
|
||||
|
||||
## 지원 타입
|
||||
|
||||
- `image`
|
||||
- `markdown`
|
||||
- `code`
|
||||
- `text`
|
||||
- `json`
|
||||
- `preview`
|
||||
- `video`
|
||||
- `audio`
|
||||
- `pdf`
|
||||
- `empty`
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `attachments`
|
||||
- `onPreview`
|
||||
- `onCopy`
|
||||
- `maxVisible`
|
||||
- `compact`
|
||||
- `emptyText`
|
||||
- `title`
|
||||
- `description`
|
||||
|
||||
## 기본 액션
|
||||
|
||||
- 링크 열기
|
||||
- 복사
|
||||
- 미리보기 진입
|
||||
|
||||
## 적용 예시
|
||||
|
||||
- `PlanBoardPage`의 `WorklogEvidenceTab` 산출물 Preview 영역
|
||||
- 작업일지/스크린샷/로그/preview 링크 혼합 카드 목록
|
||||
- 모바일 보조 패널 또는 상세 모달의 compact 첨부 목록
|
||||
|
||||
## 확장 포인트
|
||||
|
||||
- `EvidenceAttachmentPreviewBody`를 별도 export 하므로 상세 모달 본문에서 같은 렌더러를 재사용할 수 있습니다.
|
||||
- `copyValue`, `language`, `format`을 항목별로 제어해 코드/텍스트/경로형 산출물 표현을 조정할 수 있습니다.
|
||||
66
docs/components/input.md
Executable file
66
docs/components/input.md
Executable file
@@ -0,0 +1,66 @@
|
||||
# Input
|
||||
|
||||
## 목적
|
||||
|
||||
Ant Design `Input`을 기반으로 하되, 타이핑 중에는 내부 상태만 변경하고 `Enter` 또는 `blur` 시점에만 외부 `onChange`를 호출하는 입력 컴포넌트입니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
src/components/inputs/primitives/input
|
||||
├─ plugins/
|
||||
│ ├─ index.ts
|
||||
│ └─ input.plugin.ts
|
||||
├─ samples/
|
||||
│ ├─ Sample.tsx
|
||||
│ └─ ValidInputSample.tsx
|
||||
├─ types/
|
||||
│ ├─ index.ts
|
||||
│ └─ input.ts
|
||||
├─ InputUI.tsx
|
||||
└─ index.ts
|
||||
```
|
||||
|
||||
## 동작 방식
|
||||
|
||||
- 입력 중에는 내부 `draftValue`만 변경
|
||||
- `Enter` 시 외부 `onChange` 호출
|
||||
- `blur` 시 외부 `onChange` 호출
|
||||
- 검증이나 추가 기능은 `commitPlugins`로 주입
|
||||
- 나머지 props는 Ant Design `InputProps`를 그대로 전달
|
||||
|
||||
## 샘플 규칙
|
||||
|
||||
- `samples/Sample.tsx`: 기본형 `InputUI`
|
||||
- `samples/ValidInputSample.tsx`: validation plugin을 적용한 확장 샘플
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```tsx
|
||||
<InputUI
|
||||
value={value}
|
||||
placeholder="입력 후 Enter"
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
```tsx
|
||||
<InputUI
|
||||
value={value}
|
||||
placeholder="3글자 이상만 허용"
|
||||
commitPlugins={[
|
||||
createValidInputPlugin(({ nextValue }) => nextValue.trim().length >= 3),
|
||||
]}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- 외부에서 제어하는 값은 확정 시점에만 변경됩니다.
|
||||
- `InputUI.tsx` 하나만 두고 기능은 plugin으로 확장하는 구조입니다.
|
||||
- API 게시판이나 문서 예시는 `samples/Sample.tsx`를 대표 샘플로 사용합니다.
|
||||
35
docs/components/popup.md
Executable file
35
docs/components/popup.md
Executable file
@@ -0,0 +1,35 @@
|
||||
# Popup Input
|
||||
|
||||
## 목적
|
||||
|
||||
`[input][button][readonly input]` 형태로 검색어 입력, 버튼 액션, 선택 결과 표시를 한 줄에서 처리하는 입력 컴포넌트입니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
src/components/inputs/popup
|
||||
├─ PopupUI.tsx
|
||||
├─ index.ts
|
||||
├─ plugins/
|
||||
├─ samples/
|
||||
└─ types/
|
||||
```
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `value`, `defaultValue`
|
||||
- `resultValue`
|
||||
- `onChange`
|
||||
- `onButtonClick`
|
||||
- `buttonText`
|
||||
- `inputPlaceholder`
|
||||
- `resultPlaceholder`
|
||||
|
||||
## plugins
|
||||
|
||||
- `createPopupButtonTextPlugin`
|
||||
- `createPopupResultPlaceholderPlugin`
|
||||
|
||||
## 샘플
|
||||
|
||||
- 대표 샘플: `src/components/inputs/popup/samples/Sample.tsx`
|
||||
30
docs/components/previewer-ui.md
Executable file
30
docs/components/previewer-ui.md
Executable file
@@ -0,0 +1,30 @@
|
||||
# Previewer UI
|
||||
|
||||
## 목적
|
||||
|
||||
다양한 데이터를 공통 카드 형태로 미리보기할 수 있는 previewer 컴포넌트입니다.
|
||||
|
||||
## 지원 타입
|
||||
|
||||
- `text`
|
||||
- `json`
|
||||
- `code`
|
||||
- `image`
|
||||
- `markdown`
|
||||
- `empty`
|
||||
|
||||
## 추가로 유용한 preview 타입
|
||||
|
||||
- `markdown`: 작업일지, 문서 미리보기
|
||||
- `empty`: 파일 미선택, 데이터 없음 상태
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `type`
|
||||
- `title`
|
||||
- `description`
|
||||
- `value`
|
||||
- `language`
|
||||
- `imageAlt`
|
||||
- `height`
|
||||
- `toolbar`
|
||||
108
docs/components/process-flow-ui.md
Executable file
108
docs/components/process-flow-ui.md
Executable file
@@ -0,0 +1,108 @@
|
||||
# Process Flow UI
|
||||
|
||||
## 목적
|
||||
|
||||
Plan, Board, History 화면에서 공통으로 사용할 수 있는 단계형 진행 표시 컴포넌트입니다.
|
||||
현재 단계, 완료 단계, 실패 단계, 다음 대기 단계를 한 번에 보여주며 가로/세로 배치와 compact 모드를 지원합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
src/components/processFlow
|
||||
├─ samples/
|
||||
│ └─ BaseSample.tsx
|
||||
├─ types/
|
||||
│ ├─ index.ts
|
||||
│ └─ process-flow.ts
|
||||
├─ ProcessFlowUI.css
|
||||
├─ ProcessFlowUI.tsx
|
||||
└─ index.ts
|
||||
```
|
||||
|
||||
## 기본 Props
|
||||
|
||||
```ts
|
||||
type ProcessFlowStepStatus = 'complete' | 'current' | 'failed' | 'pending';
|
||||
|
||||
type ProcessFlowStep = {
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: ProcessFlowStepStatus;
|
||||
};
|
||||
|
||||
type ProcessFlowUIProps = {
|
||||
steps: ProcessFlowStep[];
|
||||
currentStepKey?: string;
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
compact?: boolean;
|
||||
showConnector?: boolean;
|
||||
onStepClick?: (step: ProcessFlowStep, index: number) => void;
|
||||
statusIcons?: Partial<Record<ProcessFlowStepStatus, ReactNode>>;
|
||||
statusStyles?: Partial<Record<ProcessFlowStepStatus, Partial<ProcessFlowStatusAppearance>>>;
|
||||
statusLabels?: Partial<Record<ProcessFlowStepStatus, string>>;
|
||||
};
|
||||
```
|
||||
|
||||
## 동작 규칙
|
||||
|
||||
- `step.status` 가 있으면 해당 상태를 우선 사용합니다.
|
||||
- `step.status` 가 없으면 `currentStepKey` 기준으로 이전 단계는 `complete`, 현재 단계는 `current`, 이후 단계는 `pending` 으로 계산합니다.
|
||||
- `direction="horizontal"` 은 넓은 화면에서 단계 흐름을 한 줄로 보여주고, 모바일 폭에서는 자동으로 세로 스택으로 바뀝니다.
|
||||
- `direction="vertical"` 은 긴 설명이나 운영 절차처럼 텍스트가 많은 흐름에 적합합니다.
|
||||
- `compact` 는 카드 안쪽, 테이블 상세, 모바일 요약 영역처럼 밀도가 필요한 구간에 사용합니다.
|
||||
|
||||
## 기본 예시
|
||||
|
||||
```tsx
|
||||
import { ProcessFlowUI } from '@/components/processFlow';
|
||||
|
||||
const steps = [
|
||||
{ key: 'created', label: '등록' },
|
||||
{ key: 'working', label: '작업중' },
|
||||
{ key: 'done', label: '작업완료' },
|
||||
{ key: 'released', label: '릴리즈완료' },
|
||||
{ key: 'completed', label: '완료' },
|
||||
];
|
||||
|
||||
export function PlanStatusFlow() {
|
||||
return <ProcessFlowUI steps={steps} currentStepKey="working" />;
|
||||
}
|
||||
```
|
||||
|
||||
## 상태 확장 예시
|
||||
|
||||
```tsx
|
||||
import { CheckOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
<ProcessFlowUI
|
||||
steps={steps}
|
||||
currentStepKey="released"
|
||||
statusIcons={{
|
||||
complete: <CheckOutlined />,
|
||||
current: <SyncOutlined spin />,
|
||||
}}
|
||||
statusStyles={{
|
||||
current: {
|
||||
accent: '#7c3aed',
|
||||
accentSoft: 'rgba(124, 58, 237, 0.12)',
|
||||
border: 'rgba(124, 58, 237, 0.22)',
|
||||
background: 'linear-gradient(180deg, rgba(245, 243, 255, 0.98) 0%, rgba(237, 233, 254, 0.84) 100%)',
|
||||
text: '#4c1d95',
|
||||
connector: 'rgba(124, 58, 237, 0.28)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 적용 예시
|
||||
|
||||
- Plan 상세: `등록 -> 작업중 -> 작업완료 -> 릴리즈완료 -> 완료`
|
||||
- release/main 반영 대기 흐름 표시
|
||||
- Board 자동화 접수 후 현재 진행 단계 요약
|
||||
|
||||
## 재사용 가이드
|
||||
|
||||
- 공통 운영 상태를 이미 문자열로 보유하고 있다면 화면 레이어에서 `steps` 와 `currentStepKey` 로만 변환해서 바로 사용할 수 있습니다.
|
||||
- 상태 라벨이 운영 용어와 다르면 `statusLabels` 로 화면별 텍스트만 바꿉니다.
|
||||
- 프로젝트 고유 색이나 아이콘이 필요하면 `statusStyles`, `statusIcons` 만 덮어쓰고 기본 레이아웃은 유지합니다.
|
||||
23
docs/components/search-command.md
Executable file
23
docs/components/search-command.md
Executable file
@@ -0,0 +1,23 @@
|
||||
# Search Command
|
||||
|
||||
## 목적
|
||||
|
||||
문서, API, 컴포넌트, 위젯을 키워드로 빠르게 찾고 바로 이동할 수 있는 통합 검색 모달입니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- `AutoComplete` 기반 추천 드롭다운
|
||||
- 모달 오픈 시 입력창 자동 포커스
|
||||
- `Enter`, 항목 선택, 바깥 클릭, `Esc`로 닫기/이동
|
||||
- 모바일 상단 제스처와 연결 가능
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `open`
|
||||
- `options`
|
||||
- `onClose`
|
||||
|
||||
## 샘플/연결
|
||||
|
||||
- `src/components/search/SearchCommandModal.tsx`
|
||||
- `src/layer/search/context/SearchLayerContext.tsx`
|
||||
34
docs/components/select.md
Executable file
34
docs/components/select.md
Executable file
@@ -0,0 +1,34 @@
|
||||
# Select Input
|
||||
|
||||
## 목적
|
||||
|
||||
`code/value` 데이터를 받아 실제 값은 `code`로 유지하고, 드롭다운 표시와 검색은 `value` 기준으로 처리하는 select combo 입력 컴포넌트입니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
src/components/inputs/select
|
||||
├─ SelectUI.tsx
|
||||
├─ index.ts
|
||||
├─ plugins/
|
||||
├─ samples/
|
||||
└─ types/
|
||||
```
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `data: { code, value }[]`
|
||||
- `value`, `defaultValue`
|
||||
- `onChange(code, item)`
|
||||
- `showSearch`
|
||||
- `allowClear`
|
||||
- `placeholder`
|
||||
|
||||
## plugins
|
||||
|
||||
- `createSelectPlaceholderPlugin`
|
||||
- `createSelectSortPlugin`
|
||||
|
||||
## 샘플
|
||||
|
||||
- 대표 샘플: `src/components/inputs/select/samples/Sample.tsx`
|
||||
106
docs/components/status-badge.md
Executable file
106
docs/components/status-badge.md
Executable file
@@ -0,0 +1,106 @@
|
||||
# StatusBadge
|
||||
|
||||
## 목적
|
||||
|
||||
상태 값을 간단한 UI 배지로 표현하는 컴포넌트입니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
src/components/status-badge
|
||||
├─ plugins/
|
||||
│ ├─ index.ts
|
||||
│ └─ status-badge.plugin.ts
|
||||
├─ samples/
|
||||
│ └─ Sample.tsx
|
||||
├─ types/
|
||||
│ ├─ index.ts
|
||||
│ └─ status-badge.ts
|
||||
├─ StatusBadgeUI.tsx
|
||||
└─ index.ts
|
||||
```
|
||||
|
||||
## 구성 원칙
|
||||
|
||||
- `StatusBadgeUI.tsx`: 실제 UI 렌더링
|
||||
- `types/`: props, plugin input 타입 관리
|
||||
- `plugins/`: 외부 입력 변환, props 후처리, 커링 플러그인 관리
|
||||
- `samples/Sample.tsx`: 대표 샘플
|
||||
|
||||
공통 플러그인 제네릭은 `src/types/component-plugin.ts` 에서 관리합니다.
|
||||
|
||||
## 기본 Props
|
||||
|
||||
```ts
|
||||
type StatusBadgeProps = {
|
||||
label: string;
|
||||
tone?: 'success' | 'warning' | 'error' | 'processing' | 'default';
|
||||
};
|
||||
```
|
||||
|
||||
## Plugin Input 예시
|
||||
|
||||
```ts
|
||||
type StatusBadgePluginInput = {
|
||||
text: string;
|
||||
status?: 'ready' | 'working' | 'blocked' | 'done';
|
||||
};
|
||||
```
|
||||
|
||||
## 공통 Plugin 타입
|
||||
|
||||
```ts
|
||||
type PropsPlugin<TProps> = (props: TProps) => TProps;
|
||||
|
||||
type ComponentPlugin<TInput, TProps = TInput> = (input: TInput) => TProps;
|
||||
|
||||
type ComponentPluginFactory<TArgs extends unknown[] = [], TInput = unknown, TProps = TInput> =
|
||||
(...args: TArgs) => ComponentPlugin<TInput, TProps>;
|
||||
```
|
||||
|
||||
`PropsPlugin<TProps>` 는 질문하신 `plugin<T>(T props) => T` 형태를 직접 표현합니다.
|
||||
`ComponentPlugin` 과 `ComponentPluginFactory` 는 입력 타입과 UI props 타입이 다르거나, 커링이 필요한 경우까지 확장하기 위한 타입입니다.
|
||||
|
||||
## 여러 Plugin 합성
|
||||
|
||||
```ts
|
||||
function plugins<TProps>(
|
||||
props: TProps,
|
||||
pluginList: ReadonlyArray<PropsPlugin<TProps>>,
|
||||
): TProps
|
||||
```
|
||||
|
||||
`plugins<T>(props, Plugin[])` 형태로 여러 props 플러그인을 순서대로 적용합니다.
|
||||
|
||||
## Plugin 구현 예시
|
||||
|
||||
```ts
|
||||
const mapStatusBadgeInputToProps: ComponentPlugin<
|
||||
StatusBadgePluginInput,
|
||||
StatusBadgeProps
|
||||
> = (input) => ({
|
||||
label: input.text,
|
||||
tone: input.status ? statusToneMap[input.status] : 'default',
|
||||
});
|
||||
|
||||
const createStatusBadgeTonePlugin: ComponentPluginFactory<
|
||||
[options?: StatusBadgePluginOptions],
|
||||
StatusBadgeProps
|
||||
> = (options) => (props) => ({
|
||||
...props,
|
||||
tone: props.tone === 'default' ? options?.fallbackTone ?? 'default' : props.tone,
|
||||
});
|
||||
```
|
||||
|
||||
## Sample 활용 목적
|
||||
|
||||
- API 게시판에서 예제 UI 노출
|
||||
- 문서 페이지에서 동작 방식 설명
|
||||
- QA 시 컴포넌트 빠른 확인
|
||||
|
||||
## 확장 방향
|
||||
|
||||
- `map input -> props` 와 `props -> props` 플러그인 체인을 조합
|
||||
- `plugins(props, pluginList)` 로 여러 후처리 플러그인 적용
|
||||
- 기본형은 `PropsPlugin<TProps>` 로, 확장형은 `ComponentPluginFactory` 로 표준화
|
||||
- 샘플 자동 수집 페이지 구성
|
||||
43
docs/components/stepper.md
Executable file
43
docs/components/stepper.md
Executable file
@@ -0,0 +1,43 @@
|
||||
# Stepper
|
||||
|
||||
## 목적
|
||||
|
||||
여러 단계를 순서대로 표시하고 현재 진행 위치를 강조하는 stepper 컴포넌트입니다.
|
||||
|
||||
## 구현 위치
|
||||
|
||||
```text
|
||||
src/components/stepper
|
||||
├─ StepperUI.tsx
|
||||
├─ index.ts
|
||||
├─ types.ts
|
||||
└─ samples/
|
||||
└─ BaseSample.tsx
|
||||
```
|
||||
|
||||
## 특징
|
||||
|
||||
- 내부적으로 `ProcessFlowUI`를 재사용해 동일한 상태 표현과 스타일을 유지합니다.
|
||||
- `horizontal`, `vertical` 두 방향을 지원합니다.
|
||||
- `currentStepKey`만 넘겨도 현재 단계를 기준으로 완료/진행중/대기 상태를 계산합니다.
|
||||
- 각 단계에 `status`를 직접 지정하면 수동 상태 제어도 가능합니다.
|
||||
- `compact`, `showConnector`, `statusLabels`, `statusIcons`, `statusStyles`를 그대로 사용할 수 있습니다.
|
||||
|
||||
## 기본 예시
|
||||
|
||||
```tsx
|
||||
<Stepper
|
||||
steps={[
|
||||
{ key: 'draft', label: '초안' },
|
||||
{ key: 'review', label: '검토' },
|
||||
{ key: 'done', label: '완료' },
|
||||
]}
|
||||
currentStepKey="review"
|
||||
/>
|
||||
```
|
||||
|
||||
## 참고
|
||||
|
||||
- `Stepper`와 `StepperUI`를 함께 export하므로 둘 중 하나를 선택해 사용할 수 있습니다.
|
||||
- `ProcessFlowUI`의 별칭 성격 컴포넌트이므로 동작 규칙과 스타일 계열은 동일합니다.
|
||||
- 대표 샘플은 `src/components/stepper/samples/BaseSample.tsx`를 사용합니다.
|
||||
27
docs/components/window-ui.md
Executable file
27
docs/components/window-ui.md
Executable file
@@ -0,0 +1,27 @@
|
||||
# Window UI
|
||||
|
||||
## 목적
|
||||
|
||||
부모 영역 안에서 이동 가능한 모달 스타일 윈도우를 제공합니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 헤더 작업줄 드래그 이동
|
||||
- 부모 영역 내부로 이동 범위 제한
|
||||
- 모서리/변 리사이즈
|
||||
- 리사이즈 테두리 더 넓은 히트영역
|
||||
- 리사이즈 변/모서리 더블클릭 및 더블탭 시 해당 방향으로 즉시 확장
|
||||
- 최소화 / 최대화 / 복원
|
||||
- 헤더 더블클릭 최대화 토글
|
||||
|
||||
## 주요 props
|
||||
|
||||
- `title`
|
||||
- `subtitle`
|
||||
- `defaultFrame`
|
||||
- `minWidth`
|
||||
- `minHeight`
|
||||
|
||||
## 샘플
|
||||
|
||||
- `src/components/window/samples/Sample.tsx`
|
||||
144
docs/features/plan-automation.md
Executable file
144
docs/features/plan-automation.md
Executable file
@@ -0,0 +1,144 @@
|
||||
# Plan 자동화와 구현 방식
|
||||
|
||||
## 목적
|
||||
|
||||
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
|
||||
|
||||
## 구현 위치
|
||||
|
||||
- 화면 진입: `src/app/main/MainContent.tsx`
|
||||
- 메뉴/라우팅: `src/app/main/routes.tsx`
|
||||
- 메인 보드: `src/features/planBoard/PlanBoardPage.tsx`
|
||||
- release 검수: `src/features/planBoard/ReleaseReviewPage.tsx`
|
||||
- 스케줄: `src/features/planBoard/PlanSchedulePage.tsx`
|
||||
- 차트: `src/features/planBoard/charts.tsx`
|
||||
- API 클라이언트: `src/features/planBoard/api.ts`
|
||||
- 빠른 필터: `src/features/planBoard/quickFilters.ts`
|
||||
- 메모 마스킹: `src/features/planBoard/noteMasking.ts`
|
||||
|
||||
## 데이터 모델
|
||||
|
||||
핵심 타입은 `src/features/planBoard/types.ts`에 있습니다.
|
||||
|
||||
- `PlanItem`: 보드의 기본 작업 항목
|
||||
- `PlanDraft`: 생성/수정용 초안
|
||||
- `PlanIssueHistory`: 이슈 이력
|
||||
- `PlanActionHistory`: 조치 이력
|
||||
- `PlanSourceWorkHistory`: 브랜치, diff, preview, 파일 스냅샷 등 소스 작업 증적
|
||||
- `PlanReleaseReview`: release 검수 상태와 메타데이터
|
||||
- `PlanReleaseReviewBoardItem`: 검수 화면용 합성 모델
|
||||
|
||||
자동화 유형 값은 다음 기준으로 사용합니다.
|
||||
|
||||
- `plan`: Markdown 스타일 Plan 문서 등록 및 접수용
|
||||
- `auto_worker`: 실제 Codex 자동 작업 실행용
|
||||
- `command_execution`, `non_source_work`: 기존 실행 분류 유지
|
||||
|
||||
기존 저장값인 `plan_registration`, `general_development`는 서버에서 각각 `plan`, `auto_worker`로 정규화합니다.
|
||||
|
||||
## API 연동 방식
|
||||
|
||||
기본 API 베이스 URL 규칙:
|
||||
|
||||
- `VITE_WORK_SERVER_URL`이 있으면 그 값을 사용
|
||||
- 없으면 `/api` 사용
|
||||
- `/api`가 실패하면 브라우저 origin 기준 `3100/api`로 fallback 재시도
|
||||
|
||||
요청 공통 규칙:
|
||||
|
||||
- GET은 기본 `no-store`
|
||||
- 타임아웃 8초
|
||||
- 등록된 토큰이 있으면 `X-Access-Token` 헤더 추가
|
||||
- 클라이언트 식별 헤더는 `appendClientIdHeader`로 추가
|
||||
|
||||
## 주요 엔드포인트
|
||||
|
||||
- `POST /plan/setup`: 초기 테이블/구성 준비
|
||||
- `GET /plan/items`: Plan 목록 조회
|
||||
- `POST /plan/items`: Plan 등록
|
||||
- `PATCH /plan/items/:id`: Plan 수정
|
||||
- `DELETE /plan/items/:id`: Plan 삭제
|
||||
- `POST /plan/items/:id/actions/:action`: 상태 전이/재처리 액션
|
||||
- `GET /plan/items/:id/issues`: 이슈 이력
|
||||
- `POST /plan/items/:id/issues/action`: 이슈 조치
|
||||
- `GET /plan/items/:id/actions`: 조치 이력
|
||||
- `POST /plan/items/:id/actions/note`: 추가 조치 메모
|
||||
- `GET /plan/items/:id/source-works`: 소스 작업 이력
|
||||
- `GET /plan/items/:id/source-works/:sourceWorkId`: 특정 소스 작업 상세
|
||||
- `GET /plan/release-reviews`: release 검수 목록
|
||||
- `PATCH /plan/release-reviews/:planItemId`: release 검수 상태 저장
|
||||
|
||||
## 빠른 필터 규칙
|
||||
|
||||
`quickFilters.ts`에서 다음 조건을 별도 유틸로 관리합니다.
|
||||
|
||||
- `working`: 상태가 `작업중`
|
||||
- `release-pending-main`: `릴리즈완료`이거나 main 반영 대기/진행/실패 상태
|
||||
- `automation-failed`: 워커 실패 상태 집합에 포함
|
||||
|
||||
이 구조 덕분에 사이드바, 검색, deep link가 같은 기준을 공유합니다.
|
||||
|
||||
## release 검수 방식
|
||||
|
||||
release 검수 화면은 Plan 본문과 분리되어 있지만 같은 데이터 축을 사용합니다.
|
||||
|
||||
- 검수 필터: 전체, main 대기, 검수완료, 수정필요
|
||||
- 검수 메모 저장
|
||||
- 관련 컴포넌트/위젯 샘플 바로 열기
|
||||
- 체크된 페이지/샘플 ID 기반 진행률 계산
|
||||
- 전체 대상 확인 시 자동으로 `approved`
|
||||
- 일부 확인 또는 메모 작성 시 `reviewing`
|
||||
- 수정 요청 상태에서 체크가 덜 끝난 경우 `changes-requested` 유지
|
||||
|
||||
검수 메타데이터에는 다음 값이 들어갈 수 있습니다.
|
||||
|
||||
- `summary`
|
||||
- `pageSelectionIds`
|
||||
- `checkedPageSelectionIds`
|
||||
- `docIds`
|
||||
- `componentIds`
|
||||
- `widgetIds`
|
||||
|
||||
## 소스 작업 증적
|
||||
|
||||
Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수 있습니다.
|
||||
|
||||
- 작업란
|
||||
- 전체소스
|
||||
- diff
|
||||
- 커맨드 로그
|
||||
- 증적 Preview
|
||||
- 파일 목록
|
||||
|
||||
`PlanSourceWorkHistory`에는 브랜치명, 커밋 해시, preview URL, 변경 파일, diff 텍스트, 파일 스냅샷이 포함됩니다.
|
||||
|
||||
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
|
||||
|
||||
## 차트 집계 방식
|
||||
|
||||
`charts.tsx`는 Plan 전체 목록을 주기적으로 다시 불러와 최근 성과를 집계합니다.
|
||||
|
||||
- 일별 최근 7일
|
||||
- 주별 최근 8주
|
||||
- 등록 수
|
||||
- 기능확인완료 수
|
||||
- 작업완료 수
|
||||
- main 반영 수
|
||||
|
||||
현재 스냅샷은 전체, 등록, 작업중, 기능확인완료, 완료, main반영 개수를 함께 제공합니다.
|
||||
|
||||
## 검색/진입 연계
|
||||
|
||||
통합 검색 옵션은 `src/app/main/mainView/searchOptions.ts`에서 정의합니다.
|
||||
|
||||
- `자동화 / 자동화`
|
||||
- `자동화 / plan`
|
||||
- `자동화 / release 검수`
|
||||
|
||||
검색에서 선택하면 해당 메뉴로 이동하고 필요한 경우 문서/컴포넌트 위치로 스크롤합니다.
|
||||
|
||||
## 유지보수 메모
|
||||
|
||||
- 상태 문자열과 워커 상태 문자열은 문자열 상수 기반이라 백엔드와 값이 어긋나지 않게 같이 관리해야 합니다.
|
||||
- 스케줄 API는 여러 경로 후보를 순차 시도하므로 서버 경로 통합 시 `api.ts` 정리가 필요합니다.
|
||||
- release 검수 메타데이터 스키마를 늘릴 때는 `ReleaseReviewPage`의 진행률 계산 규칙도 같이 점검해야 합니다.
|
||||
114
docs/features/plan-board-review.md
Executable file
114
docs/features/plan-board-review.md
Executable file
@@ -0,0 +1,114 @@
|
||||
# Plan 자동화 보드
|
||||
|
||||
## 목적
|
||||
|
||||
`src/features/planBoard`는 Plan 등록부터 자동 작업, release 검수, main 동기화 전 단계까지 한 화면 흐름으로 관리하는 기능 묶음입니다.
|
||||
현재 문서는 기존 개선 제안 메모를 대신해 실제 구현 기준의 운영 문서로 사용합니다.
|
||||
|
||||
## 화면 구성
|
||||
|
||||
- `all`: 전체 자동화 목록
|
||||
- `in-progress`: 완료 전 항목 중심 목록
|
||||
- `error`: 열린 이슈가 있는 항목 중심 목록
|
||||
- `done`: release 완료 기준 목록
|
||||
- `board`: 상세 편집과 이력 조회 중심 보드
|
||||
- `release-review`: release 검수 전용 화면
|
||||
- `charts`: 최근 작업 추이 차트
|
||||
- `schedule`: 반복 등록 스케줄 관리
|
||||
- `history`: 향후 이력 확장 메뉴용 영역
|
||||
|
||||
라우팅과 메뉴 정의는 `src/app/main/routes.tsx`, `src/app/main/MainContent.tsx`에서 관리합니다.
|
||||
|
||||
## 문서 반영 방식
|
||||
|
||||
- 이 문서를 포함한 `docs/features/*.md`는 메인 앱 `Docs / 기능문서` 메뉴에서 자동 수집됩니다.
|
||||
- 문서 추가만으로 별도 라우트를 만들지는 않으며, `Docs` 화면의 markdown 목록에 합류하는 방식입니다.
|
||||
- 노출 순서와 폴더 라벨은 `src/app/main/layout/useMainLayoutData.ts`, `src/app/main/routes.tsx`에서 결정합니다.
|
||||
|
||||
## 상태 모델
|
||||
|
||||
기본 상태는 `src/features/planBoard/types.ts`에 정의되어 있습니다.
|
||||
|
||||
- `등록`
|
||||
- `작업중`
|
||||
- `작업완료`
|
||||
- `릴리즈완료`
|
||||
- `완료`
|
||||
|
||||
실제 자동화 진행은 별도 `workerStatus`로 관리합니다. 대표값은 다음과 같습니다.
|
||||
|
||||
- 진행: `브랜치생성중`, `자동작업중`, `release반영중`, `main반영중`
|
||||
- 대기: `브랜치준비`, `release반영대기`, `main반영대기`
|
||||
- 실패: `브랜치실패`, `자동작업실패`, `release반영실패`, `main반영실패`
|
||||
|
||||
즉, 사용자에게 보이는 업무 상태와 자동 워커 상태를 분리해서 표현합니다.
|
||||
|
||||
## 주요 사용 흐름
|
||||
|
||||
1. 작업을 등록하면 Plan 항목이 생성됩니다.
|
||||
2. `작업시작` 또는 자동화에 따라 브랜치 준비와 작업이 진행됩니다.
|
||||
3. 작업이 완료되면 release 반영 상태와 main 반영 대기 여부가 추적됩니다.
|
||||
4. release 검수 화면에서 샘플/위젯/변경 파일을 확인하고 검수 상태를 갱신합니다.
|
||||
5. main 반영이 끝나면 최종 완료 흐름으로 정리됩니다.
|
||||
|
||||
## 목록 기능
|
||||
|
||||
`PlanBoardPage.tsx` 기준으로 다음 기능이 구현되어 있습니다.
|
||||
|
||||
- 검색: `workId`, 메모, 상태, 브랜치, 워커 상태, 이슈 태그 검색
|
||||
- 보조 필터: 작업자 상태, release 상태, main 상태, 이슈 상태
|
||||
- 빠른 필터: 현재 작업중, 현재 release 상태, 현재 자동화 실패
|
||||
- 정렬: 진행 목록에서는 자동화 우선순위를 먼저 적용하고, 그 외에는 최근 갱신순 정렬
|
||||
- 페이지네이션: 목록 10개 단위
|
||||
|
||||
## 상세 기능
|
||||
|
||||
선택한 Plan 항목은 오버레이에서 상세 확인과 후속 조치를 처리합니다.
|
||||
|
||||
- 원본 요청 보기
|
||||
- 작업 체크리스트
|
||||
- 릴리즈 준비 요약
|
||||
- 소스 작업 내역
|
||||
- 조치 / 이슈 이력
|
||||
- 추가 조치 기록
|
||||
- 이슈 조치 기록
|
||||
- 작업 소스 / diff / command log / 증적 / 파일 뷰어
|
||||
|
||||
원본 요청은 `startedAt` 이후 잠기며, 이후 변경은 조치 이력으로 누적합니다.
|
||||
|
||||
## 실행 액션
|
||||
|
||||
상태와 `workerStatus`에 따라 다음 액션 버튼이 조건부로 노출됩니다.
|
||||
|
||||
- `작업시작`
|
||||
- `작업완료 처리`
|
||||
- `브랜치 재시도`
|
||||
- `작업 재처리`
|
||||
- `release 반영 재시도`
|
||||
- `작업취소`
|
||||
- `main 일괄 반영 요청`
|
||||
|
||||
세부 액션은 `/plan/items/:id/actions/:action` API로 실행됩니다.
|
||||
|
||||
## 자동 새로고침과 알림
|
||||
|
||||
- 자동화가 진행/대기 상태일 때만 자동 조회 타이머가 동작합니다.
|
||||
- 주기는 `appConfig.automation.autoRefreshIntervalSeconds`를 따릅니다.
|
||||
- 길게 눌러 자동 조회 On/Off를 전환할 수 있습니다.
|
||||
- 설정값에 따라 자동화 시작/실패 메시지를 toast로 알립니다.
|
||||
|
||||
## 권한 처리
|
||||
|
||||
권한 토큰이 없는 경우 조회 중심으로 동작합니다.
|
||||
|
||||
- 수정/삭제/조치/스케줄 저장 비활성화
|
||||
- 민감한 요청 메모는 마스킹
|
||||
- 소스 작업 상세/증적 확인 제한
|
||||
|
||||
토큰 처리와 헤더 부착은 `src/app/main/tokenAccess.ts`, `src/features/planBoard/api.ts`에서 담당합니다.
|
||||
|
||||
## 연관 문서
|
||||
|
||||
- 자동화/구현 방식: `docs/features/plan-automation.md`
|
||||
- 스케줄 사용법: `docs/features/plan-schedule.md`
|
||||
- 활용 가이드: `docs/features/plan-usage.md`
|
||||
98
docs/features/plan-schedule.md
Executable file
98
docs/features/plan-schedule.md
Executable file
@@ -0,0 +1,98 @@
|
||||
# Plan 스케줄 사용법
|
||||
|
||||
## 목적
|
||||
|
||||
반복적으로 등록되는 자동화 요청을 수동 입력 없이 생성하기 위한 스케줄 화면 사용법과 제약을 정리합니다.
|
||||
|
||||
## 구현 위치
|
||||
|
||||
- 화면: `src/features/planBoard/PlanSchedulePage.tsx`
|
||||
- API: `src/features/planBoard/api.ts`
|
||||
|
||||
## 제공 기능
|
||||
|
||||
- 스케줄 목록 조회
|
||||
- 신규 스케줄 등록
|
||||
- 기존 스케줄 수정/삭제
|
||||
- 즉시 실행 옵션 설정
|
||||
- 활성/비활성 전환
|
||||
- 반복 주기 또는 매일 실행 시간 설정
|
||||
- 다음 실행 예정 시각 계산 표시
|
||||
|
||||
## 입력 항목
|
||||
|
||||
- `workId`: 반복 등록할 작업 ID
|
||||
- `note`: 매번 생성될 요청 메모
|
||||
- `automationType`: 자동화 유형
|
||||
- `plan`: Markdown 스타일 Plan 문서 등록/접수
|
||||
- `auto_worker`: 실제 자동 작업 실행
|
||||
- `command_execution`, `non_source_work`: 기존 분류 유지
|
||||
- `releaseTarget`: 반영 대상 브랜치
|
||||
- `jangsingProcessingRequired`: 기능동작확인 필요 여부
|
||||
- `autoDeployToMain`: main 자동 반영 대상 여부
|
||||
- `enabled`: 스케줄 사용 여부
|
||||
- `immediateRunEnabled`: 생성 직후 바로 등록 허용 여부
|
||||
|
||||
## 스케줄 모드
|
||||
|
||||
### 1. 반복 주기
|
||||
|
||||
`interval` 모드입니다.
|
||||
|
||||
- 값 + 단위로 반복 주기 설정
|
||||
- 단위: `minute`, `hour`, `day`, `week`, `month`
|
||||
- 내부 저장은 `repeatIntervalMinutes` 기준
|
||||
|
||||
### 2. 매일 시간
|
||||
|
||||
`daily` 모드입니다.
|
||||
|
||||
- `HH:mm` 형식 시간 사용
|
||||
- 시간 계산은 `Asia/Seoul` 기준
|
||||
- 하루 1회 등록에 적합
|
||||
|
||||
## 유효성 규칙
|
||||
|
||||
현재 화면에서 다음 검사를 수행합니다.
|
||||
|
||||
- 작업 ID 필수
|
||||
- 메모 필수
|
||||
- 같은 작업 ID 중복 금지
|
||||
- `interval` 모드 최소 10분 이상
|
||||
- 비활성 스케줄은 자동 등록되지 않음을 경고
|
||||
|
||||
## 다음 실행 시각 계산
|
||||
|
||||
다음 실행 시각은 화면에서 즉석 계산해 보여줍니다.
|
||||
|
||||
- 비활성 상태면 `중지`
|
||||
- 직전 등록 이력이 없고 즉시 실행이 켜져 있으면 현재 시각 기준
|
||||
- `daily`는 오늘 KST 실행 여부를 보고 오늘 또는 다음 날 시각 결정
|
||||
- `interval`은 마지막 등록 시각 또는 생성 시각 기준으로 다음 주기를 계산
|
||||
|
||||
## 추천 운영 방식
|
||||
|
||||
- 자주 반복되는 운영 작업은 고정 `workId`로 등록
|
||||
- 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인
|
||||
- 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작
|
||||
- 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤
|
||||
|
||||
## API 경로 메모
|
||||
|
||||
스케줄 API는 서버 구현 차이를 흡수하기 위해 여러 경로를 순차 시도합니다.
|
||||
|
||||
- `/plan/scheduled-tasks`
|
||||
- `/plan/schedule/tasks`
|
||||
- `/plan/schedule`
|
||||
- `/plan/schedules`
|
||||
- `/plans/...` 변형 경로
|
||||
|
||||
서버 경로가 고정되면 후보 경로를 줄여도 됩니다.
|
||||
|
||||
## 테스트 포인트
|
||||
|
||||
- interval/daily 전환 시 값 보존이 자연스러운지 확인
|
||||
- 같은 `workId` 중복 저장이 막히는지 확인
|
||||
- KST 기준 다음 실행 시각이 기대와 일치하는지 확인
|
||||
- 비활성 스케줄이 자동 생성되지 않는지 확인
|
||||
- 토큰 없는 사용자에게 저장/삭제가 제한되는지 확인
|
||||
58
docs/features/plan-usage.md
Executable file
58
docs/features/plan-usage.md
Executable file
@@ -0,0 +1,58 @@
|
||||
# Plan 활용 가이드
|
||||
|
||||
## 목적
|
||||
|
||||
운영자, 검수자, 개발자가 현재 Plan 기능을 어떤 순서로 써야 하는지 정리한 실무 가이드입니다.
|
||||
|
||||
## 빠른 진입
|
||||
|
||||
- 전체 목록 확인: `자동화 / 자동화`
|
||||
- 상세 처리 중심: `자동화 / plan`
|
||||
- release 검수: `자동화 / release 검수`
|
||||
- 추이 확인: `자동화 / 차트`
|
||||
- 반복 등록: `자동화 / 스케줄`
|
||||
|
||||
통합 검색에서도 `plan`, `release 검수`, `스케줄`로 바로 이동할 수 있습니다.
|
||||
|
||||
## 운영자 기준 사용 순서
|
||||
|
||||
1. `자동화 / 자동화`에서 작업을 찾습니다.
|
||||
2. 필요하면 빠른 필터로 `현재 작업중`, `현재 release 상태`, `현재 자동화 실패`를 좁힙니다.
|
||||
3. 상세 오버레이에서 현재 상태, 이슈, 최근 소스 작업을 확인합니다.
|
||||
4. 상황에 맞는 액션을 실행합니다.
|
||||
5. 후속 설명은 조치 기록 또는 이슈 조치 기록으로 남깁니다.
|
||||
|
||||
## 검수자 기준 사용 순서
|
||||
|
||||
1. `release 검수`에서 `main 대기` 필터를 우선 봅니다.
|
||||
2. 검수 메모와 변경 파일, 관련 컴포넌트/위젯 샘플을 확인합니다.
|
||||
3. 확인한 항목은 체크 상태로 반영합니다.
|
||||
4. 이상 없으면 `검수완료`, 수정이 필요하면 `수정필요`로 저장합니다.
|
||||
|
||||
## 개발자 기준 확인 포인트
|
||||
|
||||
- 실패 상태면 `workerStatus`와 `lastError`를 먼저 확인
|
||||
- 브랜치 누락 계열 실패는 `브랜치 재시도` 또는 `작업 재처리` 판단
|
||||
- release/main 반영 관련 문제는 상세의 소스 작업 내역과 diff를 먼저 확인
|
||||
- 메모 원문이 잠겨 있으면 추가 조치 기록으로 정정 사유를 남김
|
||||
|
||||
## 권한 없는 사용자 동작
|
||||
|
||||
- 목록과 검수 현황은 조회 가능
|
||||
- 민감 메모는 마스킹
|
||||
- 상세 수정/삭제/조치 실행은 제한
|
||||
- 소스 작업 상세와 증적 확인도 제한될 수 있음
|
||||
|
||||
## 문서와 화면을 함께 운영하는 방법
|
||||
|
||||
- 기능 변경 시 `docs/features`를 먼저 갱신
|
||||
- 새 문서를 만들면 앱 `Docs / 기능문서`에 자동 노출되는지 함께 확인
|
||||
- 증적이 필요한 변경은 Plan 상세의 Preview, diff, 파일 목록으로 확인
|
||||
- 작업일지에는 어떤 기능 문서를 왜 수정했는지 함께 남김
|
||||
|
||||
## 추천 문서 읽기 순서
|
||||
|
||||
1. `docs/features/plan-board-review.md`
|
||||
2. `docs/features/plan-automation.md`
|
||||
3. `docs/features/plan-schedule.md`
|
||||
4. `docs/features/plan-usage.md`
|
||||
100
docs/features/project-setup.md
Executable file
100
docs/features/project-setup.md
Executable file
@@ -0,0 +1,100 @@
|
||||
# 프로젝트 구성 개요
|
||||
|
||||
## 목적
|
||||
|
||||
현재 저장소의 화면 구조와 문서 체계를 빠르게 파악하기 위한 최신 개요 문서입니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- React
|
||||
- Vite
|
||||
- TypeScript
|
||||
- Ant Design
|
||||
- Recharts
|
||||
- React Router
|
||||
|
||||
## 최상위 앱 구조
|
||||
|
||||
- `src/app/main`: 메인 앱 프레임, 상단 메뉴, 사이드바, 본문, 검색 연동
|
||||
- `src/features`: 프로젝트 전용 기능 화면
|
||||
- `src/components`: 재사용 가능한 UI 컴포넌트
|
||||
- `src/widgets`: 샘플/위젯 단위 UI
|
||||
- `docs`: 기능/컴포넌트/작업일지 문서
|
||||
- `etc/servers/work-server`: Plan API 연동 서버 자산
|
||||
|
||||
## 현재 주요 기능 축
|
||||
|
||||
### Docs
|
||||
|
||||
- `docs/**/*.md`를 수집해 문서 화면에 노출
|
||||
- 작업일지, 기능 문서, 컴포넌트 문서를 같은 흐름으로 탐색
|
||||
- `docs/features` 아래 문서는 `Docs / 기능문서` 메뉴에서 동적으로 확인 가능
|
||||
|
||||
### APIs
|
||||
|
||||
- 컴포넌트 샘플
|
||||
- 위젯 샘플
|
||||
|
||||
### Plans
|
||||
|
||||
- Plan 자동화 목록/상세
|
||||
- release 검수
|
||||
- 차트
|
||||
- 스케줄
|
||||
- 히스토리 확장 영역
|
||||
|
||||
### Chat
|
||||
|
||||
- Codex Live
|
||||
- 에러 로그
|
||||
|
||||
`Codex Live`는 현재 프로젝트 환경의 `main_project`를 기준 저장소로 사용합니다. 소스 수정이 필요하면 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다.
|
||||
|
||||
일반 채팅 요청과 작업메모 반영 요청도 같은 기준을 따르며, 별도 브랜치 생성 없이 현재 프로젝트 루트에서 바로 수정하는 것을 기본 동작으로 사용합니다.
|
||||
|
||||
채팅에서 제공되는 파일/문서/이미지/코드 리소스와 첨부 파일은 세션별로 `public/.codex_chat/<chat-session-id>/resource/...` 아래에 노출됩니다.
|
||||
|
||||
### Play
|
||||
|
||||
- Layout Editor
|
||||
- 저장된 레이아웃 기록
|
||||
|
||||
## Plan 기능 구조
|
||||
|
||||
Plan 관련 코드는 `src/features/planBoard`에 집중되어 있습니다.
|
||||
|
||||
- `PlanBoardPage.tsx`: 자동화 목록과 상세 편집
|
||||
- `ReleaseReviewPage.tsx`: release 검수
|
||||
- `PlanSchedulePage.tsx`: 반복 등록 스케줄
|
||||
- `charts.tsx`: 작업 추이 차트
|
||||
- `api.ts`: API 통신
|
||||
- `types.ts`: 상태/타입 정의
|
||||
|
||||
## 문서 구조
|
||||
|
||||
- `docs/worklogs`: 날짜별 작업 기록
|
||||
- `docs/features`: 기능 설명과 운영 가이드
|
||||
- `docs/components`: 공통 컴포넌트 설명
|
||||
- `docs/templates`: 기능/작업일지 템플릿
|
||||
|
||||
현재 `docs/features`의 핵심 문서는 다음과 같습니다.
|
||||
|
||||
- `project-setup.md`
|
||||
- `search-layer.md`
|
||||
- `plan-board-review.md`
|
||||
- `plan-automation.md`
|
||||
- `plan-schedule.md`
|
||||
- `plan-usage.md`
|
||||
|
||||
## 검색/문서 연계
|
||||
|
||||
- 통합 검색 옵션은 `src/app/main/mainView/searchOptions.ts`에서 구성
|
||||
- 문서, Plan 화면, 컴포넌트 샘플, 위젯 샘플을 하나의 검색 엔트리로 제공
|
||||
- 선택 시 해당 메뉴와 포커스 대상으로 바로 이동
|
||||
|
||||
## 운영 메모
|
||||
|
||||
- 기능 문서는 구현 파일명과 메뉴명을 그대로 써서 찾기 쉽게 유지
|
||||
- `docs/features` 변경분이 보이지 않으면 현재 선택한 Docs 폴더가 `기능문서`인지 먼저 확인
|
||||
- Plan 관련 변경은 문서와 라우팅/검색 옵션을 함께 확인
|
||||
- 스케줄, release 검수, 차트처럼 화면이 분리된 기능은 개별 문서를 유지
|
||||
22
docs/features/search-layer.md
Executable file
22
docs/features/search-layer.md
Executable file
@@ -0,0 +1,22 @@
|
||||
# Search Layer
|
||||
|
||||
## 목적
|
||||
|
||||
통합 검색 모달의 열기/닫기와 검색 옵션 목록을 전역 레이어에서 관리합니다.
|
||||
|
||||
## 구조
|
||||
|
||||
- `src/layer/search/context`
|
||||
- `src/layer/search/hooks`
|
||||
- `src/layer/search/types`
|
||||
|
||||
## 역할 분리
|
||||
|
||||
- `layer/search`: 검색 모달 렌더링, open/close, 옵션 등록
|
||||
- `layer/gesture`: 제스처 감지와 검색 열기 트리거
|
||||
- `store/appStore`: 현재 페이지와 포커스된 컴포넌트 상태 관리
|
||||
|
||||
## 메모
|
||||
|
||||
- 검색 UI는 페이지 내부에 직접 두지 않고 레이어에서 렌더링
|
||||
- 페이지는 검색 옵션만 계산해서 레이어에 전달
|
||||
25
docs/templates/feature-template.md
vendored
Executable file
25
docs/templates/feature-template.md
vendored
Executable file
@@ -0,0 +1,25 @@
|
||||
# 기능명
|
||||
|
||||
## 목적
|
||||
|
||||
-
|
||||
|
||||
## 사용자 시나리오
|
||||
|
||||
-
|
||||
|
||||
## 화면/동작 설명
|
||||
|
||||
-
|
||||
|
||||
## 데이터 및 API
|
||||
|
||||
-
|
||||
|
||||
## 예외 처리
|
||||
|
||||
-
|
||||
|
||||
## 테스트 포인트
|
||||
|
||||
-
|
||||
53
docs/templates/worklog-template.md
vendored
Executable file
53
docs/templates/worklog-template.md
vendored
Executable file
@@ -0,0 +1,53 @@
|
||||
# YYYY-MM-DD 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
-
|
||||
|
||||
## 이슈 및 해결
|
||||
|
||||
-
|
||||
|
||||
## 결정 사항
|
||||
|
||||
-
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
-
|
||||
- 이 섹션에는 파일 목록, 경로 나열, raw diff를 직접 풀어쓰지 말고 작업 흐름과 판단만 정리
|
||||
- 파일 목록은 `## 변경/신규 파일`, raw diff는 `## 소스`에서만 기록
|
||||
|
||||
## 스크린샷
|
||||
|
||||
- 전체 화면 스크린샷 1장은 필수
|
||||
- 위젯/컴포넌트 단위 부분 스크린샷은 필요한 만큼 추가
|
||||
- 저장소 기준 연결된 스크린샷이 없으면 작업 종료 전 반드시 채움
|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `path/to/file.tsx`
|
||||
|
||||
- 변경 또는 신규 추가 목적과 핵심 내용을 한 줄로 정리
|
||||
- `상세 작업 내역`에는 파일 목록이나 raw diff를 다시 쓰지 않음
|
||||
- `소스` 탭에서 Codex preview 스타일의 `전체소스 / raw diff` 전환을 제공하므로 여기에는 파일별 raw diff 위주로 남김
|
||||
|
||||
```diff
|
||||
# 이 파일의 raw diff
|
||||
- before
|
||||
+ after
|
||||
```
|
||||
|
||||
### 파일 2: `path/to/another-file.ts`
|
||||
|
||||
- 필요 없으면 이 섹션은 삭제
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
```
|
||||
|
||||
## 변경/신규 파일
|
||||
|
||||
- M path/to/file.tsx
|
||||
- A path/to/new-file.ts
|
||||
1
docs/test001.md
Executable file
1
docs/test001.md
Executable file
@@ -0,0 +1 @@
|
||||
테스트MD자동 생성 입니다.
|
||||
126
docs/worklogs/2026-03-30.md
Executable file
126
docs/worklogs/2026-03-30.md
Executable file
@@ -0,0 +1,126 @@
|
||||
# 2026-03-30 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- Ant Design 기반 프론트엔드 프로젝트 초기 구조 생성
|
||||
- `docs/worklogs`, `docs/features` 문서 폴더 구성
|
||||
- 기본 대시보드 스타일의 시작 화면 작성
|
||||
- React 19.2.4, Vite 8.0.3 최신 안정 버전으로 업데이트
|
||||
- 컴포넌트 샘플 구조를 `plugins/`, `samples/`, `types/`, `xxxUI.tsx` 형태로 정리
|
||||
- 공통 플러그인 제네릭과 `plugins<T>(props, pluginList)` 합성 유틸 추가
|
||||
- `status-badge` 컴포넌트 샘플과 컴포넌트 문서 작성
|
||||
- `InputUI.tsx` 단일 컴포넌트 구조와 validation plugin 기반 입력 샘플 추가
|
||||
- 컴포넌트 샘플과 위젯 샘플을 별도 registry로 분리
|
||||
- `WidgetShell`, `ApiSampleCardWidget`, 위젯 샘플 레이아웃 추가
|
||||
- 컴포넌트 샘플 레이아웃을 좌측 내비게이션 + 우측 상세 카드 구조로 개편
|
||||
- 입력 컴포넌트 구조를 `primitives / specialized / composite` 계층으로 재정리
|
||||
- Git 저장소 초기화, `main` 브랜치 생성, 원격 `origin` 연결, 초기 커밋 생성
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- 초기 스택은 `React + Vite + TypeScript + Ant Design`으로 결정
|
||||
- `antd` 포함 번들 특성상 빌드 시 청크 크기 경고가 발생함
|
||||
- 원격 저장소 `push`는 HTTPS 인증 정보 부재로 실패
|
||||
- 샘플 목록은 `samples/Sample.tsx` 기본형과 `samples/*.tsx` 확장형을 구분해 관리하기로 함
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 작업일지는 날짜별 Markdown 파일로 관리
|
||||
- 기능 문서는 기능 단위 Markdown 파일로 관리
|
||||
- 컴포넌트 문서는 `docs/components` 아래에서 관리
|
||||
- 컴포넌트 샘플은 `samples/Sample.tsx`를 대표 샘플로 사용
|
||||
- 입력 컴포넌트는 `InputUI.tsx` 하나만 두고 기능은 plugin으로 확장
|
||||
- 입력 패키지는 `primitives`, `specialized`, `composite` 역할 기준으로 분리
|
||||
- 플러그인은 기본적으로 공통 제네릭 타입을 사용하고, 필요 시 커링/팩토리 형태로 확장
|
||||
- 여러 props 후처리 플러그인은 `plugins<T>(props, pluginList)` 형태로 합성
|
||||
- 샘플 목록은 같은 컴포넌트 기준으로 묶고 `base -> plugin -> feature` 순서로 정렬
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- 프로젝트 부트스트랩 이후 문서 폴더와 샘플 레지스트리를 함께 정리해 이후 확장 기준을 먼저 마련
|
||||
- `status-badge`, `InputUI`를 기준 컴포넌트로 삼아 plugin 합성 구조와 샘플 표시 구조를 동시에 검증
|
||||
- 컴포넌트/위젯 샘플을 분리해 API 성격의 문서 화면으로 확장할 수 있는 기본 형태를 준비
|
||||
- 원격 저장소 연결까지 완료했지만 인증 이슈로 `push`는 보류 상태로 남음
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
- `src/components/status-badge/StatusBadgeUI.tsx`, `src/components/status-badge/plugins/status-badge.plugin.ts`: 상태 배지 기본 UI와 플러그인 합성 구조를 잡아 샘플 갤러리의 기준 컴포넌트로 삼았습니다.
|
||||
- `src/components/inputs/input/InputUI.tsx`: validation plugin 기반 입력 컴포넌트 초안을 잡아 이후 `primitives / specialized / composite` 확장의 출발점으로 사용했습니다.
|
||||
- `src/widgets/core/WidgetShell.tsx`: 위젯 본문을 단순 문단이 아니라 자유 레이아웃 컨테이너로 바꿔 카드형 샘플과 대시보드 위젯을 수용하게 했습니다.
|
||||
- `docs/templates/worklog-template.md`: 작업일지 템플릿에 상세 내역, 스크린샷, 소스, 실행 커맨드, 변경 파일 섹션을 추가해 증적 기록 형식을 고정했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/widgets/core/WidgetShell.tsx b/src/widgets/core/WidgetShell.tsx
|
||||
-const { Paragraph, Title } = Typography;
|
||||
+const { Title } = Typography;
|
||||
...
|
||||
- <Paragraph className="widget-shell__content">{children}</Paragraph>
|
||||
+ <div className="widget-shell__content">{children}</div>
|
||||
|
||||
diff --git a/docs/templates/worklog-template.md b/docs/templates/worklog-template.md
|
||||
+## 상세 작업 내역
|
||||
+## 스크린샷
|
||||
+## 소스
|
||||
+
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- A .gitignore
|
||||
- A README.md
|
||||
- A docs/README.md
|
||||
- A docs/components/status-badge.md
|
||||
- A docs/features/project-setup.md
|
||||
- A docs/templates/feature-template.md
|
||||
- A docs/templates/worklog-template.md
|
||||
- A docs/worklogs/2026-03-30.md
|
||||
- A index.html
|
||||
- A package-lock.json
|
||||
- A package.json
|
||||
- A src/App.tsx
|
||||
- A src/components/status-badge/StatusBadgeUI.tsx
|
||||
- A src/components/status-badge/index.ts
|
||||
- A src/components/status-badge/plugins/index.ts
|
||||
- A src/components/status-badge/plugins/status-badge.plugin.ts
|
||||
- A src/components/status-badge/samples/Sample.tsx
|
||||
- A src/components/status-badge/types/index.ts
|
||||
- A src/components/status-badge/types/status-badge.ts
|
||||
- A src/main.tsx
|
||||
- A src/styles.css
|
||||
- A src/types/component-plugin.ts
|
||||
- A tsconfig.app.json
|
||||
- A tsconfig.json
|
||||
- A tsconfig.node.json
|
||||
- A vite.config.ts
|
||||
|
||||
## 실행 커맨드
|
||||
+## 변경 파일
|
||||
```
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
git init
|
||||
git checkout -b main
|
||||
git add .
|
||||
git commit -m "feat: initialize antd app and component plugin structure"
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `README.md`
|
||||
- `docs/worklogs/2026-03-30.md`
|
||||
- `src/App.tsx`
|
||||
- `src/components/status-badge/StatusBadgeUI.tsx`
|
||||
- `src/components/status-badge/plugins/status-badge.plugin.ts`
|
||||
- `src/components/inputs/input/InputUI.tsx`
|
||||
- `src/widgets/core/WidgetShell.tsx`
|
||||
- `src/styles.css`
|
||||
- `package.json`
|
||||
- `vite.config.ts`
|
||||
173
docs/worklogs/2026-03-31.md
Executable file
173
docs/worklogs/2026-03-31.md
Executable file
@@ -0,0 +1,173 @@
|
||||
# 2026-03-31 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 입력 컴포넌트 구조를 `primitives / specialized / composite` 계층으로 재정리
|
||||
- 기본 입력을 `src/components/inputs/primitives/input` 아래로 이동
|
||||
- 이메일 입력을 `src/components/inputs/specialized/emailInput` 구조로 정리
|
||||
- 3분할 입력을 `src/components/inputs/composite/multiInput` 구조로 정리
|
||||
- 샘플 및 문서 경로를 새 입력 계층 구조에 맞게 갱신
|
||||
- 프로젝트 종속 레이아웃을 `src/features/layout` 아래로 이동
|
||||
- `basePath` 기반 공통 markdown preview 컴포넌트와 리스트 구성 추가
|
||||
- `docs/**/*.md`까지 읽을 수 있도록 markdown registry 범위 확장
|
||||
- `docs` 문서를 좌측 폴더/문서 트리 + 우측 markdown 카드 목록 구조로 구성
|
||||
- 좌측 문서 바로가기를 공통 `FolderTreeNav` 기반 접기/펼치기 트리로 정리
|
||||
- 구조 변경 내용을 커밋하고 원격 `main` 브랜치로 푸시
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- 입력 컴포넌트 이름과 패키지명 기준이 중간에 몇 차례 조정됨
|
||||
- 샘플 레이아웃과 문서 경로는 구조 변경 시 함께 관리해야 함
|
||||
- `src` 기준 glob만 사용하면 프로젝트 루트 `docs/`는 읽히지 않음
|
||||
- 빌드는 정상 통과했지만 `antd` 포함 번들로 인해 청크 크기 경고는 계속 발생
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 입력 컴포넌트는 역할 기준으로 `primitives`, `specialized`, `composite`로 분리
|
||||
- `InputUI`는 primitive로 관리
|
||||
- `EmailInputUI`는 specialized 입력으로 관리
|
||||
- `MultiInputUI`는 여러 입력을 조합한 composite 입력으로 관리
|
||||
- 프로젝트 종속적인 레이아웃은 `src/features/layout` 아래에서 관리
|
||||
- markdown preview는 공통 컴포넌트가 `basePath`를 받아 재사용 가능하게 구성
|
||||
- 작업일지는 날짜별 파일로 계속 누적 기록
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- 입력 계층 재정리와 동시에 샘플/문서 경로도 같이 옮겨 구조 변경 시 누락 포인트를 줄임
|
||||
- `docs` 트리와 markdown preview를 공통화하면서 프로젝트 루트 문서도 읽을 수 있게 범위를 확장
|
||||
- 좌측 폴더 트리와 우측 카드 목록 구성을 도입해 문서 탐색 화면의 기본 틀을 완성
|
||||
- 구조 개편 후 빌드와 원격 푸시까지 마쳐 문서 기반 화면을 기준선으로 고정
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
- `src/components/inputs/primitives/input/InputUI.tsx`: 기본 입력을 primitive 계층으로 고정하고 commit plugin 검증 흐름을 공통 엔트리로 정리했습니다.
|
||||
- `src/components/inputs/specialized/emailInput/EmailInputUI.tsx`: 이메일 전용 입력을 primitive 위에 얹어 specialized 계층 규칙을 검증했습니다.
|
||||
- `src/components/inputs/composite/multiInput/MultiInputUI.tsx`: 전화번호형 3분할 입력을 추가해 composite 계층과 세그먼트 이동 UX를 구현했습니다.
|
||||
- `src/components/markdownPreview/MarkdownPreviewCard.tsx`, `src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx`: 루트 `docs/` 문서를 카드와 폴더 트리로 탐색하는 Docs 미리보기 레이아웃을 추가했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/components/inputs/composite/multiInput/MultiInputUI.tsx b/src/components/inputs/composite/multiInput/MultiInputUI.tsx
|
||||
+function splitValue(value?: string): MultiInputParts {
|
||||
+ const digits = (value ?? '').replace(/\D/g, '').slice(0, 11);
|
||||
+ return [digits.slice(0, 3), digits.slice(3, 7), digits.slice(7, 11)];
|
||||
+}
|
||||
...
|
||||
+ if (event.target.value.length === 3) {
|
||||
+ secondRef.current?.focus();
|
||||
+ }
|
||||
|
||||
diff --git a/src/components/inputs/specialized/emailInput/EmailInputUI.tsx b/src/components/inputs/specialized/emailInput/EmailInputUI.tsx
|
||||
+ inputMode={inputMode}
|
||||
+ placeholder={placeholder}
|
||||
+ autoComplete={autoComplete}
|
||||
+ commitPlugins={[createEmailValidatorPlugin(), ...commitPlugins]}
|
||||
```
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- M README.md
|
||||
- M docs/README.md
|
||||
- M docs/features/project-setup.md
|
||||
- A docs/worklogs/2026-03-31.md
|
||||
- M src/App.tsx
|
||||
- A src/components/markdownPreview/MarkdownPreviewCard.tsx
|
||||
- A src/components/markdownPreview/MarkdownPreviewContent.tsx
|
||||
- A src/components/markdownPreview/MarkdownPreviewList.tsx
|
||||
- A src/components/markdownPreview/index.ts
|
||||
- A src/components/markdownPreview/markdown-document.ts
|
||||
- A src/components/markdownPreview/registry.ts
|
||||
- A src/components/navigation/folder-tree-nav.tsx
|
||||
- A src/components/navigation/index.ts
|
||||
- A src/features/layout/README.md
|
||||
- R src/layouts/component-sample-gallery/ComponentSamplesLayout.tsx -> src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx
|
||||
- R src/layouts/component-sample-gallery/index.ts -> src/features/layout/component-sample-gallery/index.ts
|
||||
- A src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx
|
||||
- A src/features/layout/docs-markdown-preview/index.ts
|
||||
- A src/features/layout/feature-markdown-preview/FeatureMarkdownPreviewListLayout.tsx
|
||||
- A src/features/layout/feature-markdown-preview/index.ts
|
||||
- R src/layouts/widget-registry-gallery/WidgetRegistryLayout.tsx -> src/features/layout/widget-registry-gallery/WidgetRegistryLayout.tsx
|
||||
- R src/layouts/widget-registry-gallery/index.ts -> src/features/layout/widget-registry-gallery/index.ts
|
||||
- R src/layouts/widget-sample-gallery/SampleWidgetsLayout.tsx -> src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx
|
||||
- R src/layouts/widget-sample-gallery/index.ts -> src/features/layout/widget-sample-gallery/index.ts
|
||||
- A src/features/markdownPreview/FeatureMarkdownPreviewCard.tsx
|
||||
- A src/features/markdownPreview/index.ts
|
||||
- A src/features/overview.md
|
||||
- M src/styles.css
|
||||
- M docs/components/input.md
|
||||
- M docs/worklogs/2026-03-30.md
|
||||
- A src/components/inputs/composite/multiInput/MultiInputUI.tsx
|
||||
- A src/components/inputs/composite/multiInput/index.ts
|
||||
- A src/components/inputs/composite/multiInput/plugins/index.ts
|
||||
- A src/components/inputs/composite/multiInput/plugins/multi-input.plugin.ts
|
||||
- A src/components/inputs/composite/multiInput/samples/Sample.tsx
|
||||
- A src/components/inputs/composite/multiInput/types/index.ts
|
||||
- A src/components/inputs/composite/multiInput/types/multi-input.ts
|
||||
- R src/components/inputs/input/InputUI.tsx -> src/components/inputs/primitives/input/InputUI.tsx
|
||||
- R src/components/inputs/input/index.ts -> src/components/inputs/primitives/input/index.ts
|
||||
- R src/components/inputs/input/plugins/index.ts -> src/components/inputs/primitives/input/plugins/index.ts
|
||||
- R src/components/inputs/input/plugins/input.plugin.ts -> src/components/inputs/primitives/input/plugins/input.plugin.ts
|
||||
- R src/components/inputs/input/samples/Sample.tsx -> src/components/inputs/primitives/input/samples/Sample.tsx
|
||||
- R src/components/inputs/input/samples/ValidInputSample.tsx -> src/components/inputs/primitives/input/samples/ValidInputSample.tsx
|
||||
- R src/components/inputs/input/types/index.ts -> src/components/inputs/primitives/input/types/index.ts
|
||||
- R src/components/inputs/input/types/input.ts -> src/components/inputs/primitives/input/types/input.ts
|
||||
- A src/components/inputs/specialized/emailInput/EmailInputUI.tsx
|
||||
- A src/components/inputs/specialized/emailInput/index.ts
|
||||
- A src/components/inputs/specialized/emailInput/plugins/email-input.plugin.ts
|
||||
- A src/components/inputs/specialized/emailInput/plugins/index.ts
|
||||
- A src/components/inputs/specialized/emailInput/samples/Sample.tsx
|
||||
- A src/components/inputs/specialized/emailInput/types/email-input.ts
|
||||
- A src/components/inputs/specialized/emailInput/types/index.ts
|
||||
- A docs/components/input.md
|
||||
- A src/components/inputs/input/InputUI.tsx
|
||||
- A src/components/inputs/input/index.ts
|
||||
- A src/components/inputs/input/plugins/index.ts
|
||||
- A src/components/inputs/input/plugins/input.plugin.ts
|
||||
- A src/components/inputs/input/samples/Sample.tsx
|
||||
- A src/components/inputs/input/samples/ValidInputSample.tsx
|
||||
- A src/components/inputs/input/types/index.ts
|
||||
- A src/components/inputs/input/types/input.ts
|
||||
- M src/components/status-badge/samples/Sample.tsx
|
||||
- A src/layouts/component-sample-gallery/ComponentSamplesLayout.tsx
|
||||
- A src/layouts/component-sample-gallery/index.ts
|
||||
- A src/layouts/widget-registry-gallery/WidgetRegistryLayout.tsx
|
||||
- A src/layouts/widget-registry-gallery/index.ts
|
||||
- A src/layouts/widget-sample-gallery/SampleWidgetsLayout.tsx
|
||||
- A src/layouts/widget-sample-gallery/index.ts
|
||||
- A src/samples/registry.ts
|
||||
- A src/vite-env.d.ts
|
||||
- A src/widgets/api-sample-card/ApiSampleCardWidget.tsx
|
||||
- A src/widgets/api-sample-card/index.ts
|
||||
- A src/widgets/api-sample-card/samples/Sample.tsx
|
||||
- A src/widgets/core/WidgetShell.tsx
|
||||
- A src/widgets/core/index.ts
|
||||
- A src/widgets/core/registry/widget-features.ts
|
||||
- A src/widgets/core/types/widget.ts
|
||||
- A src/widgets/registry.ts
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
git add .
|
||||
git commit -m "입력 컴포넌트 구조 정리"
|
||||
git commit -m "문서 미리보기와 레이아웃 정리"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `docs/worklogs/2026-03-31.md`
|
||||
- `src/components/inputs/primitives/input/InputUI.tsx`
|
||||
- `src/components/inputs/specialized/emailInput/EmailInputUI.tsx`
|
||||
- `src/components/inputs/composite/multiInput/MultiInputUI.tsx`
|
||||
- `src/components/markdownPreview/MarkdownPreviewCard.tsx`
|
||||
- `src/components/markdownPreview/MarkdownPreviewContent.tsx`
|
||||
- `src/components/markdownPreview/MarkdownPreviewList.tsx`
|
||||
- `src/components/navigation/folder-tree-nav.tsx`
|
||||
- `src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx`
|
||||
- `src/styles.css`
|
||||
254
docs/worklogs/2026-04-01.md
Executable file
254
docs/worklogs/2026-04-01.md
Executable file
@@ -0,0 +1,254 @@
|
||||
# 2026-04-01 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 대시보드 카드 위젯과 샘플 구성을 보강
|
||||
- WMS/TMS 대시보드 콘텐츠 배치와 차트 표현을 조정
|
||||
- 대시보드 공통 표현 컴포넌트 `progress`, `multiProgress` 구조를 정리
|
||||
- `src/data` 아래로 대시보드 프리셋 데이터를 분리
|
||||
- 메인 화면을 좌측 메뉴 기반 API 스타일 구조로 정리
|
||||
- `popup` 입력 컴포넌트를 추가하고 UI 스타일을 보강
|
||||
- `select` 입력 컴포넌트를 추가
|
||||
- `checkCombo` 입력 컴포넌트를 추가
|
||||
- `package.json`에 Nexus `publishConfig` 추가
|
||||
- 버전을 `0.1.0-alpha.0`로 조정하고 publish용 `private` 설정을 해제
|
||||
- 메인 화면을 `src/views/main` 아래로 임시 분리
|
||||
- Nexus 인증 방식을 `username / _password(base64)` 형식으로 재구성
|
||||
- Nexus에 `ai-code-app@0.1.0-alpha.0` 배포 성공
|
||||
- 메인 화면 구조를 `src/app/main` 기준으로 다시 정리하고 export 경로를 보강
|
||||
- 콘텐츠 영역 바깥 여백을 줄여 카드 중심 레이아웃으로 정리
|
||||
- 버튼 클릭 시에만 편집 가능한 `buttonEditableInput` 컴포넌트를 추가
|
||||
- `buttonEditableInput`의 readonly 음영과 확인 버튼 편집 완료 동작을 보정
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- `npm publish`는 alpha 버전 배포 시 `--tag alpha`가 필요
|
||||
- Nexus는 토큰 방식보다 `username / _password(base64)` 방식에서 인증이 정상 동작함
|
||||
- popup 입력은 스타일 보강 과정에서 입력/버튼/readonly 영역의 경계와 톤을 반복 조정
|
||||
- 대시보드 카드 높이와 콘텐츠 간격 조정은 과도한 고정값을 피하고 점진적으로 조정하는 편이 안정적
|
||||
- 버튼 기반 편집 입력은 blur, Enter, 버튼 클릭 확정 타이밍을 함께 맞춰야 UX가 안정적
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 재사용 가능한 샘플/프리셋 데이터는 `src/data`에서 관리
|
||||
- 대시보드 공통 표현은 `src/components/dashboard`, 카드 조합은 `src/widgets/dashboard-report-card`로 분리
|
||||
- 신규 입력 컴포넌트도 `UI / plugins / samples / types` 구조를 동일하게 적용
|
||||
- 메인 화면은 임시로 `views/main` 아래에 `header / sidebar / content` 단위로 분리
|
||||
- 배포는 `npm publish --tag alpha` 기준으로 진행
|
||||
- 메인 화면 엔트리는 `src/app/main` 기준으로 정리하고 패키지 루트 export를 함께 관리
|
||||
- 버튼 편집 입력은 기본 readonly 상태를 유지하고 검증 실패 시 이전 값으로 복원
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- 대시보드 위젯, 입력 컴포넌트, 메인 화면 구조 변경이 동시에 진행되며 앱 성격이 샘플 갤러리 중심으로 이동
|
||||
- Nexus 배포 설정과 인증 방식을 실사용 기준으로 정리해 alpha 배포 경로를 실제로 검증
|
||||
- `popup`, `select`, `checkCombo`, `buttonEditableInput`을 추가하며 입력 UI 패키지 확장 패턴을 구체화
|
||||
- 메인 화면을 `src/app/main`으로 재정리하면서 이후 헤더/사이드바/콘텐츠 분리 작업의 기반을 마련
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
- `src/components/dashboard/progress/ProgressUI.tsx`, `src/components/dashboard/multiProgress/MultiProgressUI.tsx`: 대시보드 진행률 표현을 컴포넌트화해 카드/샘플/위젯에서 공통으로 쓰게 정리했습니다.
|
||||
- `src/components/inputs/popup/PopupUI.tsx`, `src/components/inputs/select/SelectUI.tsx`, `src/components/inputs/checkCombo/CheckComboUI.tsx`: 신규 입력군을 같은 패키지 규칙으로 추가해 선택형 입력 범위를 넓혔습니다.
|
||||
- `src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx`: readonly 상태와 편집 확정 버튼을 가진 클릭 편집 입력을 도입했습니다.
|
||||
- `src/app/main/MainContent.tsx`: 메인 콘텐츠를 `Docs / APIs` 기준 카드 레이아웃으로 나눠 현재 앱 성격에 맞는 메인 화면으로 재정리했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx b/src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx
|
||||
+ const [isEditing, setIsEditing] = useState(false);
|
||||
...
|
||||
+ readOnly={!isEditing}
|
||||
+ commitPlugins={mergedCommitPlugins}
|
||||
...
|
||||
+ {isEditing ? confirmButtonLabel : editButtonLabel}
|
||||
|
||||
diff --git a/src/app/main/MainContent.tsx b/src/app/main/MainContent.tsx
|
||||
+ {activeTopMenu === 'docs' ? (
|
||||
+ <div className="app-main-panel">
|
||||
+ <Card title={`Docs / ${selectedDocsMenu}`} ...>
|
||||
+ {selectedDocs.map((document) => (
|
||||
+ <MarkdownPreviewCard document={document} />
|
||||
+ ))}
|
||||
```
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- A app-dist/assets/2026-03-30-C4SD1FRx.js
|
||||
- A app-dist/assets/2026-03-31-DwLJWvh2.js
|
||||
- A app-dist/assets/2026-04-01-D5gI7Q4h.js
|
||||
- A app-dist/assets/AntdIcon-Byo_R91X.js
|
||||
- A app-dist/assets/CloseOutlined-B6nrJF3-.js
|
||||
- A app-dist/assets/InputUI-DAmC5DJh.js
|
||||
- A app-dist/assets/MultiProgressUI--uB5kqTr.js
|
||||
- A app-dist/assets/ProgressUI-C91UL-oJ.js
|
||||
- A app-dist/assets/README-CI9EVrw_.js
|
||||
- A app-dist/assets/README-O9_O-4tf2.js
|
||||
- A app-dist/assets/Sample-6Ml90fMj.js
|
||||
- A app-dist/assets/Sample-BJxnglT1.js
|
||||
- A app-dist/assets/Sample-BPCdH5hH.js
|
||||
- A app-dist/assets/Sample-CLup9Uwo.js
|
||||
- A app-dist/assets/Sample-CeT4nPqx.js
|
||||
- A app-dist/assets/Sample-DKoCtyPX.js
|
||||
- A app-dist/assets/Sample-DMEGMJwT.js
|
||||
- A app-dist/assets/Sample-Dyso1eHr.js
|
||||
- A app-dist/assets/Sample-E6V4D3Du.js
|
||||
- A app-dist/assets/Sample-LB0lRdor.js
|
||||
- A app-dist/assets/Sample-xgRr-oUd.js
|
||||
- A app-dist/assets/SearchOutlined-Civ7xtmP.js
|
||||
- A app-dist/assets/TmsDeliveryFlowSample-BHeS93-n.js
|
||||
- A app-dist/assets/TmsDeliveryMetricsSample-BQV5az65.js
|
||||
- A app-dist/assets/ValidInputSample-C9pl9si5.js
|
||||
- A app-dist/assets/WidgetShell-DhXCYrC8.js
|
||||
- A app-dist/assets/WmsInboundOutboundSample-BZCM3_0V.js
|
||||
- A app-dist/assets/WmsInventoryTrendSample-DvxPBjgx.js
|
||||
- A app-dist/assets/card-BpKFEf6A.js
|
||||
- A app-dist/assets/check-combo-Bz7kGmN1.js
|
||||
- A app-dist/assets/clsx-CzIxj0DI.js
|
||||
- A app-dist/assets/component-plugin-BjxKibxS.js
|
||||
- A app-dist/assets/dashboard-report-presets-Bh8duNGL.js
|
||||
- A app-dist/assets/feature-template-D3D0o1kc.js
|
||||
- A app-dist/assets/index-BQsYfbAI.js
|
||||
- A app-dist/assets/index-CaXbpawn.css
|
||||
- A app-dist/assets/input-B6oA1SZJ.js
|
||||
- A app-dist/assets/input.plugin-ulF_zEvq.js
|
||||
- A app-dist/assets/jsx-runtime-CNArSbpp.js
|
||||
- A app-dist/assets/overview-DgYaz2rW.js
|
||||
- A app-dist/assets/popup-BGFdvx2z.js
|
||||
- A app-dist/assets/project-setup-jU8Nv-E8.js
|
||||
- A app-dist/assets/select-DYfkmyn8.js
|
||||
- A app-dist/assets/select-kIZVYgkF.js
|
||||
- A app-dist/assets/status-badge-1fx0opaz.js
|
||||
- A app-dist/assets/wave-DQjt-ubw.js
|
||||
- A app-dist/assets/worklog-template-DE_f72dx.js
|
||||
- A app-dist/index.html
|
||||
- A docker-compose.yml
|
||||
- M docs/worklogs/2026-04-01.md
|
||||
- M package-lock.json
|
||||
- M package.json
|
||||
- M src/App.tsx
|
||||
- A src/app/main/MainContent.tsx
|
||||
- A src/app/main/MainHeader.tsx
|
||||
- R src/views/main/MainSidebar.tsx -> src/app/main/MainSidebar.tsx
|
||||
- R src/views/main/MainView.tsx -> src/app/main/MainView.tsx
|
||||
- R src/views/main/index.ts -> src/app/main/index.ts
|
||||
- R src/views/main/types.ts -> src/app/main/types.ts
|
||||
- A src/app/manifests/docs.manifest.ts
|
||||
- A src/app/manifests/samples.manifest.ts
|
||||
- M src/components/inputs/primitives/input/index.ts
|
||||
- A src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.css
|
||||
- A src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx
|
||||
- A src/components/inputs/specialized/buttonEditableInput/index.ts
|
||||
- A src/components/inputs/specialized/buttonEditableInput/samples/Sample.tsx
|
||||
- M src/components/markdownPreview/MarkdownPreviewList.tsx
|
||||
- M src/components/markdownPreview/index.ts
|
||||
- M src/components/markdownPreview/registry.ts
|
||||
- M src/components/status-badge/index.ts
|
||||
- M src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx
|
||||
- M src/features/layout/dashboard-report-gallery/DashboardReportGalleryLayout.tsx
|
||||
- M src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx
|
||||
- M src/features/layout/feature-markdown-preview/FeatureMarkdownPreviewListLayout.tsx
|
||||
- M src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx
|
||||
- A src/index.ts
|
||||
- M src/samples/registry.ts
|
||||
- M src/styles.css
|
||||
- M src/views/main/MainContent.tsx
|
||||
- D src/views/main/MainHeader.tsx
|
||||
- A tsconfig.lib.json
|
||||
- M README.md
|
||||
- M docs/README.md
|
||||
- M docs/features/project-setup.md
|
||||
- A src/views/main/MainContent.tsx
|
||||
- A src/views/main/MainHeader.tsx
|
||||
- A src/views/main/MainSidebar.tsx
|
||||
- A src/views/main/MainView.tsx
|
||||
- A src/views/main/index.ts
|
||||
- A src/views/main/types.ts
|
||||
- A docs/components/check-combo.md
|
||||
- A docs/components/popup.md
|
||||
- A docs/components/select.md
|
||||
- A docs/worklogs/2026-04-01.md
|
||||
- A src/components/dashboard/multiProgress/MultiProgressUI.tsx
|
||||
- A src/components/dashboard/multiProgress/index.ts
|
||||
- A src/components/dashboard/multiProgress/plugins/index.ts
|
||||
- A src/components/dashboard/multiProgress/plugins/multi-progress.plugin.ts
|
||||
- A src/components/dashboard/multiProgress/samples/Sample.tsx
|
||||
- A src/components/dashboard/multiProgress/types/index.ts
|
||||
- A src/components/dashboard/multiProgress/types/multi-progress.ts
|
||||
- A src/components/dashboard/progress/ProgressUI.tsx
|
||||
- A src/components/dashboard/progress/index.ts
|
||||
- A src/components/dashboard/progress/plugins/index.ts
|
||||
- A src/components/dashboard/progress/plugins/progress.plugin.ts
|
||||
- A src/components/dashboard/progress/samples/Sample.tsx
|
||||
- A src/components/dashboard/progress/types/index.ts
|
||||
- A src/components/dashboard/progress/types/progress.ts
|
||||
- A src/components/inputs/checkCombo/CheckComboUI.tsx
|
||||
- A src/components/inputs/checkCombo/index.ts
|
||||
- A src/components/inputs/checkCombo/plugins/check-combo.plugin.ts
|
||||
- A src/components/inputs/checkCombo/plugins/index.ts
|
||||
- A src/components/inputs/checkCombo/samples/Sample.tsx
|
||||
- A src/components/inputs/checkCombo/types/check-combo.ts
|
||||
- A src/components/inputs/checkCombo/types/index.ts
|
||||
- A src/components/inputs/popup/PopupUI.tsx
|
||||
- A src/components/inputs/popup/index.ts
|
||||
- A src/components/inputs/popup/plugins/index.ts
|
||||
- A src/components/inputs/popup/plugins/popup.plugin.ts
|
||||
- A src/components/inputs/popup/samples/Sample.tsx
|
||||
- A src/components/inputs/popup/types/index.ts
|
||||
- A src/components/inputs/popup/types/popup.ts
|
||||
- A src/components/inputs/select/SelectUI.tsx
|
||||
- A src/components/inputs/select/index.ts
|
||||
- A src/components/inputs/select/plugins/index.ts
|
||||
- A src/components/inputs/select/plugins/select.plugin.ts
|
||||
- A src/components/inputs/select/samples/Sample.tsx
|
||||
- A src/components/inputs/select/types/index.ts
|
||||
- A src/components/inputs/select/types/select.ts
|
||||
- A src/components/navigation/SectionMenuLayout.tsx
|
||||
- M src/components/navigation/index.ts
|
||||
- A src/data/dashboard-report-presets.ts
|
||||
- A src/features/dashboard/TmsDashboardFeatureSamples.tsx
|
||||
- A src/features/dashboard/WmsDashboardFeatureSamples.tsx
|
||||
- A src/features/layout/dashboard-feature-gallery/DashboardFeatureGalleryLayout.tsx
|
||||
- A src/features/layout/dashboard-feature-gallery/index.ts
|
||||
- A src/features/layout/dashboard-report-gallery/DashboardReportGalleryLayout.tsx
|
||||
- A src/features/layout/dashboard-report-gallery/index.ts
|
||||
- M src/widgets/core/WidgetShell.tsx
|
||||
- A src/widgets/dashboard-report-card/DashboardReportCardWidget.tsx
|
||||
- A src/widgets/dashboard-report-card/index.ts
|
||||
- A src/widgets/dashboard-report-card/samples/TmsDeliveryFlowSample.tsx
|
||||
- A src/widgets/dashboard-report-card/samples/TmsDeliveryMetricsSample.tsx
|
||||
- A src/widgets/dashboard-report-card/samples/WmsInboundOutboundSample.tsx
|
||||
- A src/widgets/dashboard-report-card/samples/WmsInventoryTrendSample.tsx
|
||||
- M src/widgets/registry.ts
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm publish --tag alpha
|
||||
npm run capture:component -- --date 2026-04-01 --name check-combo-input
|
||||
npm run capture:component -- --date 2026-04-01 --name select-input
|
||||
npm run capture:component -- --date 2026-04-01 --name popup-input
|
||||
npm run capture:fullscreen -- --date 2026-04-01
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `docs/worklogs/2026-04-01.md`
|
||||
- `package.json`
|
||||
- `src/components/dashboard/progress/ProgressUI.tsx`
|
||||
- `src/components/dashboard/multiProgress/MultiProgressUI.tsx`
|
||||
- `src/components/inputs/popup/PopupUI.tsx`
|
||||
- `src/components/inputs/select/SelectUI.tsx`
|
||||
- `src/components/inputs/checkCombo/CheckComboUI.tsx`
|
||||
- `src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx`
|
||||
- `src/app/main/MainContent.tsx`
|
||||
- `src/styles.css`
|
||||
396
docs/worklogs/2026-04-02.md
Executable file
396
docs/worklogs/2026-04-02.md
Executable file
@@ -0,0 +1,396 @@
|
||||
# 2026-04-02 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 루트 `README.md`를 현재 앱 구조와 스크립트 기준으로 최신화
|
||||
- `docs/README.md`의 문서 운영 기준을 실제 디렉터리 구조와 Docs 수집 방식에 맞게 정리
|
||||
- 오늘 작업일지에 문서 최신화 내역을 추가
|
||||
- `window-ui` 공통 컴포넌트를 추가
|
||||
- 윈도우 헤더 드래그, 리사이즈, 최소화, 최대화, 복원 동작을 구성
|
||||
- 모바일 터치 기반 드래그와 버튼 터치 영역을 보정
|
||||
- 멀티 윈도우 추천 배치, 가로 분할, 세로 분할, 그리드 배치를 추가
|
||||
- 배치 이후 창 순서를 사용자가 조정할 수 있는 UI를 추가
|
||||
- 활성화된 윈도우가 항상 최상단으로 오도록 z-index 정렬을 추가
|
||||
- 전체화면 버튼을 빈 공간 우선 확장 후 부모 전체 확장 방식으로 보정
|
||||
- `previewer-ui` 공통 컴포넌트를 추가
|
||||
- `text`, `json`, `code`, `image`, `markdown`, `empty` preview 타입을 구성
|
||||
- JSON preview에 영역별 컬러 토큰 표시를 추가
|
||||
- Code preview에 언어 선택과 VSCode 스타일 표현을 추가
|
||||
- Previewer 샘플에 TypeScript, JSON, HTML, CSS, Bash, SQL, YAML 예제를 추가
|
||||
- 메인 앱을 PWA 오프라인 캐시 전략으로 보강
|
||||
- 모바일 헤더/사이드바 동작을 손봐서 메뉴 선택 흐름을 단순화
|
||||
- 헤더 액션 영역과 전체화면 토글 동작을 재정리
|
||||
- `src/layer/gesture`를 추가해 모바일 우측 상단 제스처를 레이어로 관리
|
||||
- `src/store/appStore`를 추가해 현재 페이지와 포커스 컴포넌트 상태를 관리
|
||||
- `src/layer/search`를 추가해 통합검색 모달과 검색 옵션을 레이어 컨텍스트로 관리
|
||||
- `SearchCommandModal` 통합 검색 컴포넌트를 추가
|
||||
- 문서, API, 컴포넌트, 위젯 키워드 자동 추천과 빠른 이동을 연결
|
||||
- 모바일 입력 확대 방지와 검색 모달 포커스 동작을 보정
|
||||
- 빌드 산출물 추적 정리를 위해 `.gitignore`를 보강하고 `app-dist`, `dev-dist` 무시 준비를 진행
|
||||
- `etc/db/work-db` 경로로 PostgreSQL 도커 구성을 분리하고 `.env` 기반 설정을 정리
|
||||
- `etc/servers/work-server`에 `Fastify + PostgreSQL` 기반 작업 API 서버를 추가
|
||||
- Plan 게시판용 등록, 수정, 삭제, 테이블 생성 API를 추가
|
||||
- Plan 게시판이 웹서버 프록시(`/api`)를 통해 `work-server`를 바라보도록 수정
|
||||
- Plan 항목 등록 시 작업 ID, 요청 메모, 대상 브랜치/릴리즈 흐름을 함께 관리하도록 화면과 서버 모델을 정리
|
||||
- Plan 항목을 주기적으로 읽어 `등록 -> 작업중(브랜치 준비)`와 `개발완료 -> 완료(release merge)` 흐름을 자동화하는 worker를 추가
|
||||
- `scripts/run-plan-codex-once.mjs`로 단건 Plan 항목을 읽어 Codex 실행 결과를 조치 이력에 반영하는 자동화 스크립트를 추가
|
||||
- Plan 자동화 실패 시 상태를 `이슈`로 바꾸지 않고 해시태그/오류 이력으로 관리하도록 변경
|
||||
- `etc` 내부에서 커밋되면 안 되는 `.env`, `node_modules`, `dist`, 로그 파일을 루트 `.gitignore`에서도 명시적으로 제외
|
||||
- `작업시작` 이후에는 원본 요청을 수정할 수 없도록 잠금 규칙을 추가
|
||||
- 작업 이후 변경 사항은 일반 조치 이력과 이슈 조치 이력으로 누적 기록되도록 확장
|
||||
- 컴포넌트/화면 스크린샷을 생성한 뒤 작업일지 `## 화면 캡처` 섹션에 자동 링크하는 스크립트를 정리
|
||||
- Plan 게시판 메모 상세의 모바일 높이를 절반 수준으로 줄이도록 조정
|
||||
- 작업일지 캡처 스크립트를 공통 유틸로 정리하고 누락 방지용 모바일 Plan 상세 캡처를 추가
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- `vite-plugin-pwa`는 `vite@8`과 peer 경고가 있어 도커 실행 시 `npm install --force`가 필요
|
||||
- 작업일지 이미지 표시를 위해 markdown preview가 전체 블록을 렌더링하도록 수정
|
||||
- 컴포넌트 캡처 자동화는 `Playwright` 기반으로 스크린샷을 생성하고 작업일지 링크를 함께 추가
|
||||
- 캡처 스크립트는 날짜별 폴더를 만들고 동일 날짜 작업일지에 이미지 Markdown 링크를 자동 삽입함
|
||||
- iOS에서는 제스처 후 키보드 오픈 보장이 제한적이라 강제 포커스 로직보다 레이어 분리와 단순 동작 유지에 우선순위를 둠
|
||||
- Plan 자동화는 현재 저장소 worktree가 깨끗해야 브랜치 생성이 가능함
|
||||
- 현재 저장소에는 `release` 브랜치가 아직 없어 실제 자동 merge를 쓰려면 브랜치 생성이 먼저 필요함
|
||||
- 컨테이너 내부 git은 `safe.directory` 이슈가 있어 자동 처리 로직을 추가함
|
||||
- Plan 이력 테이블 추가 이후 기존 실패 데이터에는 과거 이력이 없을 수 있어 재시도로 새 이력을 만드는 방식으로 정리함
|
||||
- `run-plan-codex-once`는 최근 조치 이력/이슈 이력을 프롬프트에 포함해 재작업이나 보완 요청을 우선 반영하도록 구성함
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 공통 미리보기 컴포넌트는 `src/components/previewer` 아래에서 관리
|
||||
- 공통 윈도우 컴포넌트는 `src/components/window` 아래에서 관리
|
||||
- 작업일지 캡처 이미지는 `docs/assets/worklogs/YYYY-MM-DD/`에 저장
|
||||
- 컴포넌트 캡처는 전체 화면이 아니라 샘플 컴포넌트 영역 중심으로 생성
|
||||
- 앱 상태 저장은 `src/store`
|
||||
- 화면 전역 UI는 `src/layer`
|
||||
- 통합검색은 store가 아니라 layer context로 관리
|
||||
- 프로젝트 내부 부가 서버/DB는 `etc/servers/<name>`, `etc/db/<name>` 구조로 관리
|
||||
- `etc` 내부의 비밀값/생성물은 각 하위 `.gitignore`와 루트 `.gitignore`에서 이중으로 차단
|
||||
- Plan 게시판은 상태 수기 변경보다 자동화 중심 흐름을 우선하고, 사용자는 상태별 허용 액션 버튼만 사용
|
||||
- 원본 요청은 `작업시작` 이후 읽기 전용으로 전환하고 추가 조치사항은 이력으로만 남김
|
||||
- 스크린샷 자산은 날짜별 작업일지와 1:1로 연결하고 캡처 직후 문서 링크까지 자동 반영
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- 초기 문서에 남아 있던 구형 `src/components/inputs`, `src/views/main` 기준 설명을 현재 `src/app/main`, `src/components/*`, `src/features/planBoard` 구조에 맞춰 교체
|
||||
- 루트 안내문에서 실행 스크립트, 앱 섹션, 문서 위치를 한 번에 확인할 수 있도록 정리
|
||||
- Docs 가이드에 실제 Markdown 수집 범위와 문서 최신화 기록 원칙을 반영
|
||||
- `window-ui`, `previewer-ui` 추가 이후 앱 전반 구조가 컴포넌트 문서/샘플 허브 형태로 더 선명해짐
|
||||
- PWA 오프라인 캐시 전략과 모바일 레이아웃 보정을 함께 진행해 실제 사용 환경 대응 범위를 넓힘
|
||||
- `gesture`, `search`, `store/appStore`를 나눠 전역 UI와 상태 책임을 분리하는 기준을 세움
|
||||
- 통합검색은 문서, API, 컴포넌트, 위젯을 한 흐름에서 찾도록 묶고 모달/제스처/포커스 추적을 연결
|
||||
- 빌드 산출물 추적을 정리하며 `.gitignore`와 Git 인덱스 운영 기준도 함께 정비
|
||||
- `work-server`는 Plan 게시판 API, DB 연결, worker 자동화 역할을 함께 가지도록 확장
|
||||
- Plan 게시판은 단순 상태 보드가 아니라 등록 정보, 조치 이력, 이슈 이력, 원본 요청 잠금 규칙까지 함께 관리하는 작업 큐 성격으로 확장
|
||||
- 등록 단계에서는 작업 ID와 요청 내용을 남기고, 자동화 단계에서는 브랜치 확보, Codex 실행, 커밋/푸시, release 반영 가능 여부를 순차적으로 판단하도록 흐름을 정리
|
||||
- 자동화 worker는 현재 저장소에 직접 브랜치를 만들고 `release` 브랜치 머지까지 시도하되, 작업 디렉터리가 dirty면 이슈 태그와 오류 이력을 남기고 안전하게 멈춤
|
||||
- Plan 화면에서는 브랜치명, 자동화 상태, 최근 오류, 이슈 태그, 조치 이력을 함께 확인할 수 있게 보강
|
||||
- `작업시작` 이후 서버는 원본 요청 수정 API를 `409`로 차단하고, 화면에서도 메모/작업 ID를 읽기 전용으로 전환
|
||||
- 스크린샷 처리 스크립트는 `docs/assets/worklogs/YYYY-MM-DD/` 저장, `docs/worklogs/YYYY-MM-DD.md` 링크 반영, 중복 링크 방지까지 한 번에 수행하도록 맞춤
|
||||
- 검색 모달, 프리뷰어, 윈도우 UI 캡처는 각각 샘플 셀렉터를 기준으로 잘라 저장해 작업일지 증적 자료로 재사용 가능하게 구성
|
||||
- Plan 게시판은 모바일에서 `새 메모` 상세를 바로 캡처하는 스크립트를 추가해 메모 높이 보정 결과를 작업일지에 바로 남길 수 있게 함
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
- `src/components/window/WindowUI.tsx`: 드래그, 리사이즈, 분할 배치를 지원하는 공통 윈도우 UI를 추가했습니다.
|
||||
- `src/components/previewer/PreviewerUI.tsx`: `text/json/code/image/markdown` 등 여러 증적 형식을 한 컴포넌트에서 미리보게 만들었습니다.
|
||||
- `src/components/search/SearchCommandModal.tsx`: 문서, API, 컴포넌트, 위젯을 한 번에 찾는 통합 검색 모달을 추가했습니다.
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`: Plan 게시판 UI와 상세 흐름을 본격 추가했습니다.
|
||||
- `etc/servers/work-server/src/workers/plan-worker.ts`, `scripts/run-plan-codex-once.mjs`: Plan 자동화 워커와 Codex 실행기를 도입해 브랜치 준비, 작업 실행, 반영 흐름을 자동화했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/etc/servers/work-server/src/workers/plan-worker.ts b/etc/servers/work-server/src/workers/plan-worker.ts
|
||||
+export class PlanWorker {
|
||||
+ private readonly workerId: string;
|
||||
+ private timer: NodeJS.Timeout | null = null;
|
||||
+ private running = false;
|
||||
...
|
||||
+ await this.processRegisteredPlans();
|
||||
+ await this.processExecutablePlans();
|
||||
+ await this.processReleaseReadyPlans();
|
||||
|
||||
diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs
|
||||
+const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null;
|
||||
+const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex';
|
||||
...
|
||||
+function requiresSourceChange(note) {
|
||||
+ return /(?:\/|\\).+\.[a-z0-9]+/i.test(text) || /(생성|만들어|추가|수정|변경|삭제|파일)/.test(text);
|
||||
+}
|
||||
```
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- M scripts/run-plan-codex-once.mjs
|
||||
- M .gitignore
|
||||
- M etc/servers/work-server/.env.example
|
||||
- M etc/servers/work-server/docker-compose.yml
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- M src/main.tsx
|
||||
- M etc/servers/work-server/src/config/env.ts
|
||||
- M etc/servers/work-server/src/routes/plan.ts
|
||||
- M etc/servers/work-server/src/services/git-service.ts
|
||||
- M src/features/planBoard/types.ts
|
||||
- M package.json
|
||||
- A scripts/run-plan-codex-once.mjs
|
||||
- M docs/README.md
|
||||
- M docs/worklogs/2026-04-02.md
|
||||
- M src/features/planBoard/api.ts
|
||||
- M docker-compose.yml
|
||||
- A etc/db/work-db/.env.example
|
||||
- A etc/db/work-db/.gitignore
|
||||
- A etc/db/work-db/README.md
|
||||
- A etc/db/work-db/docker-compose.yml
|
||||
- A etc/servers/work-server/.env.example
|
||||
- A etc/servers/work-server/.gitignore
|
||||
- A etc/servers/work-server/README.md
|
||||
- A etc/servers/work-server/docker-compose.yml
|
||||
- A etc/servers/work-server/package-lock.json
|
||||
- A etc/servers/work-server/package.json
|
||||
- A etc/servers/work-server/src/app.ts
|
||||
- A etc/servers/work-server/src/config/env.ts
|
||||
- A etc/servers/work-server/src/db/client.ts
|
||||
- A etc/servers/work-server/src/lib/identifier.ts
|
||||
- A etc/servers/work-server/src/routes/crud.ts
|
||||
- A etc/servers/work-server/src/routes/ddl.ts
|
||||
- A etc/servers/work-server/src/routes/health.ts
|
||||
- A etc/servers/work-server/src/routes/plan.ts
|
||||
- A etc/servers/work-server/src/routes/schema.ts
|
||||
- A etc/servers/work-server/src/server.ts
|
||||
- A etc/servers/work-server/src/services/git-service.ts
|
||||
- A etc/servers/work-server/src/services/plan-service.ts
|
||||
- A etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- A etc/servers/work-server/tsconfig.json
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M src/app/main/MainSidebar.tsx
|
||||
- M src/app/main/MainView.tsx
|
||||
- M src/app/main/types.ts
|
||||
- A src/features/planBoard/PlanBoardPage.tsx
|
||||
- A src/features/planBoard/api.ts
|
||||
- A src/features/planBoard/index.ts
|
||||
- A src/features/planBoard/types.ts
|
||||
- M src/store/appStore/types/index.ts
|
||||
- M src/styles.css
|
||||
- M vite.config.ts
|
||||
- A docs/assets/worklogs/2026-04-02/search-command.png
|
||||
- M docs/templates/worklog-template.md
|
||||
- M docs/worklogs/2026-03-30.md
|
||||
- M docs/worklogs/2026-03-31.md
|
||||
- M docs/worklogs/2026-04-01.md
|
||||
- A scripts/capture-search-command-screenshot.mjs
|
||||
- D app-dist/apple-touch-icon.svg
|
||||
- D app-dist/assets/2026-03-30-C9zIPzPv.js
|
||||
- D app-dist/assets/2026-03-31-DnTx-3Nm.js
|
||||
- D app-dist/assets/2026-04-01-HPg12E04.js
|
||||
- D app-dist/assets/AntdIcon-Byo_R91X.js
|
||||
- D app-dist/assets/CloseOutlined-B6nrJF3-.js
|
||||
- D app-dist/assets/InputUI-w7gS2eD_.js
|
||||
- D app-dist/assets/MultiProgressUI-BrtiP5fC.js
|
||||
- D app-dist/assets/ProgressUI-CcSfL1yk.js
|
||||
- D app-dist/assets/README-C7FUDFuk.js
|
||||
- D app-dist/assets/README-CCe9ioJ1.js
|
||||
- D app-dist/assets/Sample-3CFWfRoz.js
|
||||
- D app-dist/assets/Sample-AyuQ97sV.css
|
||||
- D app-dist/assets/Sample-BVdBpN2O.js
|
||||
- D app-dist/assets/Sample-BX522g-L.js
|
||||
- D app-dist/assets/Sample-BcLA6P4T.js
|
||||
- D app-dist/assets/Sample-BdE7za0g.js
|
||||
- D app-dist/assets/Sample-CJuh1XyL.js
|
||||
- D app-dist/assets/Sample-CXTqMdzC.js
|
||||
- D app-dist/assets/Sample-CYEm5q8n.js
|
||||
- D app-dist/assets/Sample-D8WPIGkO.js
|
||||
- D app-dist/assets/Sample-DCMU_ANA.js
|
||||
- D app-dist/assets/Sample-DucAjs70.css
|
||||
- D app-dist/assets/Sample-H43X1Va0.js
|
||||
- D app-dist/assets/Sample-H8g2LJhv.js
|
||||
- D app-dist/assets/Sample-IHgSYgwU.css
|
||||
- D app-dist/assets/Sample-iMOOVcMn.js
|
||||
- D app-dist/assets/SearchOutlined-Civ7xtmP.js
|
||||
- D app-dist/assets/TmsDeliveryFlowSample-BIsYLVLu.js
|
||||
- D app-dist/assets/TmsDeliveryMetricsSample-Cij5-xON.js
|
||||
- D app-dist/assets/ValidInputSample-DfYNB-Xp.js
|
||||
- D app-dist/assets/WidgetShell-BuID0-5g.js
|
||||
- D app-dist/assets/WmsInboundOutboundSample-D8v85F-k.js
|
||||
- D app-dist/assets/WmsInventoryTrendSample-BaOj-o94.js
|
||||
- D app-dist/assets/button-editable-input-TvwRLJmy.png
|
||||
- D app-dist/assets/card-C6TZ9YUa.js
|
||||
- D app-dist/assets/check-combo-Bz7kGmN1.js
|
||||
- D app-dist/assets/clsx-CzIxj0DI.js
|
||||
- D app-dist/assets/component-plugin-DrEfBYjG.js
|
||||
- D app-dist/assets/dashboard-report-presets-BmenDHNL.js
|
||||
- D app-dist/assets/feature-template-C3ggnfNS.js
|
||||
- D app-dist/assets/index-CR5AbEsh.js
|
||||
- D app-dist/assets/index-WZ9gt1kR.css
|
||||
- D app-dist/assets/input-B6oA1SZJ.js
|
||||
- D app-dist/assets/input.plugin-BigPQCa9.js
|
||||
- D app-dist/assets/jsx-runtime-CNArSbpp.js
|
||||
- D app-dist/assets/main-content-fullscreen-toggle-Cppu1H3t.png
|
||||
- D app-dist/assets/overview-BtWbOP4n.js
|
||||
- D app-dist/assets/popup-BGFdvx2z.js
|
||||
- D app-dist/assets/previewer-ui-BheoU6aq.js
|
||||
- D app-dist/assets/project-setup-B38Eco2m.js
|
||||
- D app-dist/assets/select-BVXn7KWj.js
|
||||
- D app-dist/assets/select-oi1Bjv-c.js
|
||||
- D app-dist/assets/status-badge-C7aul6sS.js
|
||||
- D app-dist/assets/wave-DQjt-ubw.js
|
||||
- D app-dist/assets/window-ui-CXDuYQu1.js
|
||||
- D app-dist/assets/workbox-window.prod.es5-B4qug_J_.js
|
||||
- D app-dist/assets/worklog-template-Donys780.js
|
||||
- D app-dist/favicon.svg
|
||||
- D app-dist/index.html
|
||||
- D app-dist/manifest.webmanifest
|
||||
- D app-dist/pwa-192x192.svg
|
||||
- D app-dist/pwa-512x512.svg
|
||||
- D app-dist/sw.js
|
||||
- D app-dist/workbox-8c29f6e4.js
|
||||
- D dev-dist/sw.js
|
||||
- D dev-dist/workbox-5a5d9309.js
|
||||
- A docs/components/search-command.md
|
||||
- A docs/features/search-layer.md
|
||||
- M index.html
|
||||
- A src/components/search/SearchCommandModal.tsx
|
||||
- A src/components/search/index.ts
|
||||
- A src/components/search/types.ts
|
||||
- M src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx
|
||||
- M src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx
|
||||
- M src/index.ts
|
||||
- A src/layer/gesture/context/GestureContext.tsx
|
||||
- A src/layer/gesture/hooks/useGestureLayer.ts
|
||||
- A src/layer/gesture/index.ts
|
||||
- A src/layer/gesture/types/index.ts
|
||||
- A src/layer/index.ts
|
||||
- A src/layer/search/context/SearchLayerContext.tsx
|
||||
- A src/layer/search/hooks/useSearchLayer.ts
|
||||
- A src/layer/search/index.ts
|
||||
- A src/layer/search/types/index.ts
|
||||
- A src/store/appStore/context/AppStoreContext.tsx
|
||||
- A src/store/appStore/hooks/useAppStore.ts
|
||||
- A src/store/appStore/index.ts
|
||||
- A src/store/appStore/types/index.ts
|
||||
- A src/store/index.ts
|
||||
- A app-dist/apple-touch-icon.svg
|
||||
- R app-dist/assets/2026-03-30-C4SD1FRx.js -> app-dist/assets/2026-03-30-C9zIPzPv.js
|
||||
- R app-dist/assets/2026-03-31-DwLJWvh2.js -> app-dist/assets/2026-03-31-DnTx-3Nm.js
|
||||
- D app-dist/assets/2026-04-01-D5gI7Q4h.js
|
||||
- A app-dist/assets/2026-04-01-HPg12E04.js
|
||||
- R app-dist/assets/InputUI-DAmC5DJh.js -> app-dist/assets/InputUI-w7gS2eD_.js
|
||||
- R app-dist/assets/MultiProgressUI--uB5kqTr.js -> app-dist/assets/MultiProgressUI-BrtiP5fC.js
|
||||
- R app-dist/assets/ProgressUI-C91UL-oJ.js -> app-dist/assets/ProgressUI-CcSfL1yk.js
|
||||
- R app-dist/assets/README-CI9EVrw_.js -> app-dist/assets/README-C7FUDFuk.js
|
||||
- R app-dist/assets/README-O9_O-4tf2.js -> app-dist/assets/README-CCe9ioJ1.js
|
||||
- R app-dist/assets/Sample-CeT4nPqx.js -> app-dist/assets/Sample-3CFWfRoz.js
|
||||
- A app-dist/assets/Sample-AyuQ97sV.css
|
||||
- R app-dist/assets/Sample-Dyso1eHr.js -> app-dist/assets/Sample-BVdBpN2O.js
|
||||
- A app-dist/assets/Sample-BX522g-L.js
|
||||
- R app-dist/assets/Sample-DKoCtyPX.js -> app-dist/assets/Sample-BcLA6P4T.js
|
||||
- R app-dist/assets/Sample-DMEGMJwT.js -> app-dist/assets/Sample-BdE7za0g.js
|
||||
- R app-dist/assets/Sample-BJxnglT1.js -> app-dist/assets/Sample-CJuh1XyL.js
|
||||
- R app-dist/assets/Sample-xgRr-oUd.js -> app-dist/assets/Sample-CXTqMdzC.js
|
||||
- R app-dist/assets/Sample-BPCdH5hH.js -> app-dist/assets/Sample-CYEm5q8n.js
|
||||
- R app-dist/assets/Sample-LB0lRdor.js -> app-dist/assets/Sample-D8WPIGkO.js
|
||||
- R app-dist/assets/Sample-6Ml90fMj.js -> app-dist/assets/Sample-DCMU_ANA.js
|
||||
- A app-dist/assets/Sample-DucAjs70.css
|
||||
- D app-dist/assets/Sample-E6V4D3Du.js
|
||||
- A app-dist/assets/Sample-H43X1Va0.js
|
||||
- R app-dist/assets/Sample-CLup9Uwo.js -> app-dist/assets/Sample-H8g2LJhv.js
|
||||
- A app-dist/assets/Sample-IHgSYgwU.css
|
||||
- A app-dist/assets/Sample-iMOOVcMn.js
|
||||
- R app-dist/assets/TmsDeliveryFlowSample-BHeS93-n.js -> app-dist/assets/TmsDeliveryFlowSample-BIsYLVLu.js
|
||||
- R app-dist/assets/TmsDeliveryMetricsSample-BQV5az65.js -> app-dist/assets/TmsDeliveryMetricsSample-Cij5-xON.js
|
||||
- R app-dist/assets/ValidInputSample-C9pl9si5.js -> app-dist/assets/ValidInputSample-DfYNB-Xp.js
|
||||
- R app-dist/assets/WidgetShell-DhXCYrC8.js -> app-dist/assets/WidgetShell-BuID0-5g.js
|
||||
- R app-dist/assets/WmsInboundOutboundSample-BZCM3_0V.js -> app-dist/assets/WmsInboundOutboundSample-D8v85F-k.js
|
||||
- R app-dist/assets/WmsInventoryTrendSample-DvxPBjgx.js -> app-dist/assets/WmsInventoryTrendSample-BaOj-o94.js
|
||||
- A app-dist/assets/button-editable-input-TvwRLJmy.png
|
||||
- R app-dist/assets/card-BpKFEf6A.js -> app-dist/assets/card-C6TZ9YUa.js
|
||||
- R app-dist/assets/component-plugin-BjxKibxS.js -> app-dist/assets/component-plugin-DrEfBYjG.js
|
||||
- R app-dist/assets/dashboard-report-presets-Bh8duNGL.js -> app-dist/assets/dashboard-report-presets-BmenDHNL.js
|
||||
- R app-dist/assets/feature-template-D3D0o1kc.js -> app-dist/assets/feature-template-C3ggnfNS.js
|
||||
- R app-dist/assets/index-BQsYfbAI.js -> app-dist/assets/index-CR5AbEsh.js
|
||||
- D app-dist/assets/index-CaXbpawn.css
|
||||
- A app-dist/assets/index-WZ9gt1kR.css
|
||||
- R app-dist/assets/input.plugin-ulF_zEvq.js -> app-dist/assets/input.plugin-BigPQCa9.js
|
||||
- A app-dist/assets/main-content-fullscreen-toggle-Cppu1H3t.png
|
||||
- R app-dist/assets/overview-DgYaz2rW.js -> app-dist/assets/overview-BtWbOP4n.js
|
||||
- A app-dist/assets/previewer-ui-BheoU6aq.js
|
||||
- R app-dist/assets/project-setup-jU8Nv-E8.js -> app-dist/assets/project-setup-B38Eco2m.js
|
||||
- R app-dist/assets/select-DYfkmyn8.js -> app-dist/assets/select-BVXn7KWj.js
|
||||
- R app-dist/assets/select-kIZVYgkF.js -> app-dist/assets/select-oi1Bjv-c.js
|
||||
- R app-dist/assets/status-badge-1fx0opaz.js -> app-dist/assets/status-badge-C7aul6sS.js
|
||||
- A app-dist/assets/window-ui-CXDuYQu1.js
|
||||
- A app-dist/assets/workbox-window.prod.es5-B4qug_J_.js
|
||||
- R app-dist/assets/worklog-template-DE_f72dx.js -> app-dist/assets/worklog-template-Donys780.js
|
||||
- A app-dist/favicon.svg
|
||||
- M app-dist/index.html
|
||||
- A app-dist/manifest.webmanifest
|
||||
- A app-dist/pwa-192x192.svg
|
||||
- A app-dist/pwa-512x512.svg
|
||||
- A app-dist/sw.js
|
||||
- A app-dist/workbox-8c29f6e4.js
|
||||
- A dev-dist/sw.js
|
||||
- A dev-dist/workbox-5a5d9309.js
|
||||
- A docs/assets/worklogs/2026-04-01/.gitkeep
|
||||
- A docs/assets/worklogs/2026-04-01/button-editable-input.png
|
||||
- A docs/assets/worklogs/2026-04-01/main-content-fullscreen-toggle.png
|
||||
- A docs/assets/worklogs/2026-04-02/.gitkeep
|
||||
- A docs/assets/worklogs/2026-04-02/previewer-ui.png
|
||||
- A docs/assets/worklogs/2026-04-02/window-ui.png
|
||||
- A docs/components/previewer-ui.md
|
||||
- A docs/components/window-ui.md
|
||||
- A docs/worklogs/2026-04-02.md
|
||||
- M package-lock.json
|
||||
- A public/apple-touch-icon.svg
|
||||
- A public/favicon.svg
|
||||
- A public/pwa-192x192.svg
|
||||
- A public/pwa-512x512.svg
|
||||
- A scripts/capture-component-screenshot.mjs
|
||||
- A scripts/capture-fullscreen-toggle-screenshot.mjs
|
||||
- M src/app/manifests/docs.manifest.ts
|
||||
- M src/components/markdownPreview/MarkdownPreviewCard.tsx
|
||||
- M src/components/markdownPreview/MarkdownPreviewContent.tsx
|
||||
- A src/components/previewer/PreviewerUI.css
|
||||
- A src/components/previewer/PreviewerUI.tsx
|
||||
- A src/components/previewer/index.ts
|
||||
- A src/components/previewer/samples/Sample.tsx
|
||||
- A src/components/previewer/types/index.ts
|
||||
- A src/components/previewer/types/previewer.ts
|
||||
- A src/components/window/WindowUI.css
|
||||
- A src/components/window/WindowUI.tsx
|
||||
- A src/components/window/index.ts
|
||||
- A src/components/window/samples/Sample.tsx
|
||||
- A src/components/window/types/index.ts
|
||||
- A src/components/window/types/window.ts
|
||||
- M src/vite-env.d.ts
|
||||
- M tsconfig.lib.json
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
docker compose up -d
|
||||
npm run plan:codex:once
|
||||
node scripts/capture-search-command-screenshot.mjs --date 2026-04-02
|
||||
npm run capture:plan-mobile -- --date 2026-04-02
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `docs/worklogs/2026-04-02.md`
|
||||
- `src/components/window/WindowUI.tsx`
|
||||
- `src/components/previewer/PreviewerUI.tsx`
|
||||
- `src/components/search/SearchCommandModal.tsx`
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`
|
||||
- `src/layer/search/context/SearchLayerContext.tsx`
|
||||
- `src/store/appStore/context/AppStoreContext.tsx`
|
||||
- `etc/servers/work-server/src/routes/plan.ts`
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`
|
||||
- `scripts/run-plan-codex-once.mjs`
|
||||
137
docs/worklogs/2026-04-03.md
Executable file
137
docs/worklogs/2026-04-03.md
Executable file
@@ -0,0 +1,137 @@
|
||||
# 2026-04-03 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- Plan 자동화 재처리 흐름과 main 반영 안정화
|
||||
- release/main 브랜치 전략을 정리하고 서버 재시작 오류를 수정
|
||||
- Plan 메모 검색 기능 추가
|
||||
- Plan 상세에서 소스 작업 이력/미리보기 확인 흐름 보강
|
||||
- 모바일 설정 아이콘 터치 불가 이슈를 제스처 오버레이 충돌 제거로 해결
|
||||
- 작업 상세의 불필요한 `내용 조회` 버튼 제거
|
||||
- Noti 설정의 서버 토큰 등록/삭제 연동 보강
|
||||
- 작업 ID 기본값(`작업ID`) 처리 및 중복 제약 완화
|
||||
- 완료/반영 단계에서도 소스 작업 이력이 누락되지 않도록 저장 로직 보강
|
||||
- Web Push 등록/호환 처리 보강(미지원 API 404 노출 완화)
|
||||
- 4월 3일 작업 증적용 스크린샷(앱 설정, 알림 설정) 보강
|
||||
- Plan 상세 증적 탭 하단에 스크린샷/작업일지/preview/source 전체화면 미리보기 추가
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- release -> main 자동 머지 이후 브랜치 상태 꼬임이 반복되어 브랜치 전략 재정비 필요
|
||||
- 모바일 터치 이슈는 기능 오류보다 레이어 겹침(오버레이) 영향이 컸음
|
||||
- 알림 기능은 로컬 UI 상태와 서버 토큰 상태가 분리되면 재현 어려운 장애로 이어짐
|
||||
- 작업/이슈 이력 누락은 재처리 판단 정확도에 직접 영향
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- Plan 자동화 브랜치 기준은 `main` 중심으로 정리
|
||||
- 실패 후 재실행은 명시적 재처리 요청 기반으로 진행
|
||||
- 알림 설정은 UI 토글만이 아니라 서버 토큰 등록/삭제와 동기화
|
||||
- 메모/소스 작업 이력은 완료 단계 이후까지 일관 저장
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- 자동화 워커의 재처리, 반영, 이력 저장 경로를 손봐 동일 이슈 반복 시 추적 가능성을 높임
|
||||
- release/main 전개 과정에서 발생하던 복구성 작업을 줄이기 위해 브랜치 운영 규칙을 단순화
|
||||
- Plan 보드 UX는 모바일 접근성(터치/조회/검색) 중심으로 우선 개선
|
||||
- 알림 관련 API 미지원 상황에서도 사용자에게 과도한 오류를 직접 노출하지 않도록 완충 처리 추가
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
- `etc/servers/work-server/src/services/git-service.ts`: 새 이슈 브랜치를 `release`가 아니라 `main` 기준으로 만들도록 바꿔 브랜치 꼬임을 줄였습니다.
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`: 기본 작업 ID 처리, 중복 제약 해제, 완료 시 소스 이력 저장, `main` 반영 요청 단건화 등 자동화 추적 로직을 보강했습니다.
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`: 소스 작업 이력과 증적 미리보기 흐름을 상세 화면에 반영했습니다.
|
||||
- `src/app/main/MainHeader.tsx`, `src/app/main/notificationApi.ts`: 알림 설정을 서버 토큰 상태와 동기화하고 미지원 환경의 404를 사용자 오류로 과하게 노출하지 않도록 조정했습니다.
|
||||
- `etc/servers/work-server/src/workers/plan-worker.ts`: 재처리와 반영 흐름을 `main` 중심으로 다시 맞췄습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/etc/servers/work-server/src/services/git-service.ts b/etc/servers/work-server/src/services/git-service.ts
|
||||
- const baseBranch = releaseTarget || config.releaseBranch;
|
||||
+ const baseBranch = config.mainBranch;
|
||||
|
||||
diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts
|
||||
- workId: z.string().trim().min(1),
|
||||
+ workId: z.string().trim().optional().default('작업ID'),
|
||||
...
|
||||
+ await createPlanLifecycleSourceWorkHistory(
|
||||
+ id,
|
||||
+ '작업완료 처리로 release 반영 대기 상태로 전환했습니다.',
|
||||
+ currentRow.assigned_branch ?? currentRow.release_target ?? 'release',
|
||||
+ );
|
||||
```
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M scripts/run-plan-codex-once.mjs
|
||||
- M etc/servers/work-server/.env.example
|
||||
- M etc/servers/work-server/README.md
|
||||
- M etc/servers/work-server/src/config/env.ts
|
||||
- M etc/servers/work-server/src/services/git-service.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M src/app/main/notificationApi.ts
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M etc/servers/work-server/package-lock.json
|
||||
- M etc/servers/work-server/package.json
|
||||
- M etc/servers/work-server/src/routes/notification.ts
|
||||
- M etc/servers/work-server/src/services/notification-service.ts
|
||||
- A etc/servers/work-server/src/types/web-push.d.ts
|
||||
- M src/main.tsx
|
||||
- A src/sw.ts
|
||||
- M vite.config.ts
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- M etc/servers/work-server/src/routes/plan.ts
|
||||
- M src/features/planBoard/api.ts
|
||||
- M src/features/planBoard/types.ts
|
||||
- A etc/servers/work-server/src/services/plan-notification-service.ts
|
||||
- A src/app/main/notificationApi.ts
|
||||
- M src/layer/gesture/context/GestureContext.tsx
|
||||
- M src/styles.css
|
||||
- M etc/servers/work-server/src/app.ts
|
||||
- A etc/servers/work-server/src/routes/notification.ts
|
||||
- M etc/servers/work-server/src/server.ts
|
||||
- A etc/servers/work-server/src/services/notification-service.ts
|
||||
- M README.md
|
||||
- A docs/assets/worklogs/2026-04-02/plan-board-mobile-memo-detail.png
|
||||
- M docs/worklogs/2026-04-02.md
|
||||
- M package.json
|
||||
- M scripts/capture-component-screenshot.mjs
|
||||
- M scripts/capture-fullscreen-toggle-screenshot.mjs
|
||||
- A scripts/capture-plan-board-mobile-screenshot.mjs
|
||||
- M scripts/capture-search-command-screenshot.mjs
|
||||
- A scripts/worklog-capture-utils.mjs
|
||||
- M src/app/main/MainSidebar.tsx
|
||||
- M docs/README.md
|
||||
- M index.html
|
||||
- M src/app/main/MainView.tsx
|
||||
- M src/app/main/types.ts
|
||||
- M docker-compose.yml
|
||||
- M src/features/planBoard/index.ts
|
||||
- A docs/test001.md
|
||||
- M etc/servers/work-server/docker-compose.yml
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npm run plan:codex:once
|
||||
npm run build
|
||||
npm run capture:settings -- --date 2026-04-03
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `docs/worklogs/2026-04-03.md`
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`
|
||||
- `etc/servers/work-server/src/workers/plan-worker.ts`
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`
|
||||
- `etc/servers/work-server/src/services/git-service.ts`
|
||||
- `src/app/main/MainHeader.tsx`
|
||||
- `src/app/main/notificationApi.ts`
|
||||
- `src/layer/gesture/context/GestureContext.tsx`
|
||||
- `scripts/run-plan-codex-once.mjs`
|
||||
- `src/styles.css`
|
||||
104
docs/worklogs/2026-04-04.md
Executable file
104
docs/worklogs/2026-04-04.md
Executable file
@@ -0,0 +1,104 @@
|
||||
# 2026-04-04 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- Plan 목록 조회 버튼을 자동조회 중심 UX로 개편(남은 시간 표시/토글)
|
||||
- 자동조회 버튼 레이아웃 깨짐/문구 중복/선택 드래그 문제 보정
|
||||
- 앱 업데이트 진행 UI에 단계 표시와 프로그레스 흐름 추가
|
||||
- 서비스워커 교체 흐름(`SKIP_WAITING`, 등록 확인, 타임아웃) 안정화
|
||||
- 업데이트 실패 시 상태 복구와 오류 가시성 개선
|
||||
- `vite-plugin-pwa` 업데이트 경로를 공식 흐름 우선으로 정리
|
||||
- 자동화 Codex 실행 타임아웃 도입 및 롤백 과정 반영
|
||||
- Plan 삭제 응답/정리 로직 개선
|
||||
- work-server 메인 프로젝트 경로/풀 동작 정리
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- 업데이트 이슈는 단일 원인보다 서비스워커 교체 타이밍/등록 상태/환경 설정이 복합적으로 영향
|
||||
- 자동조회는 텍스트/배지/오버레이가 겹치면 UX가 빠르게 악화됨
|
||||
- Codex 타임아웃은 안전장치가 되지만 과도하면 정상 작업도 중단시킬 수 있음
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 업데이트 상태는 단순 성공/실패가 아닌 단계 기반으로 노출
|
||||
- 서비스워커 등록/교체는 실패 복구 경로까지 포함해 설계
|
||||
- 자동조회 UI는 버튼 본문 최소화 + 보조 배지 방식 유지
|
||||
- 워커 경로 설정은 환경별 명확한 기준 경로로 통일
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- Plan 보드에서는 자동조회 중심 운영을 위해 상단 액션을 재설계하고 모바일/데스크톱 표시 균형을 맞춤
|
||||
- 앱 업데이트 영역은 사용자 체감 기준(지금 무엇을 하는지)으로 문구와 상태 전이를 재구성
|
||||
- 서비스워커 관련 반복 장애를 줄이기 위해 등록 확인, 교체 메시지 처리, 타임아웃/롤백 로직을 함께 점검
|
||||
- 서버 측 경로 및 삭제 처리 개선으로 운영 중 발생하던 잔여 데이터/응답 일관성 문제를 줄임
|
||||
|
||||
## 스크린샷
|
||||
|
||||
- 저장소 기준 연결된 스크린샷 없음
|
||||
|
||||
## 소스
|
||||
|
||||
- `src/app/main/appUpdate.ts`, `src/app/main/MainHeader.tsx`: 앱 업데이트를 단계형 상태와 프로그레스 UI로 노출하고 서비스워커 등록 대기/재시도 흐름을 추가했습니다.
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`: 자동조회 버튼 UX와 Plan 목록 상단 액션 구성을 손봤습니다.
|
||||
- `scripts/run-plan-codex-once.mjs`: Codex 자식 프로세스 종료/에러를 중복 처리하지 않도록 `settled` 가드를 추가했습니다.
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`: Plan 삭제 시 연관 소스작업/조치/이슈 이력까지 함께 정리하도록 트랜잭션 삭제로 바꿨습니다.
|
||||
- `etc/servers/work-server/src/workers/plan-worker.ts`: 타임아웃과 실패 처리 흐름을 보강했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts
|
||||
- await db(PLAN_TABLE).where({ id }).delete();
|
||||
+ await db.transaction(async (trx) => {
|
||||
+ await trx(PLAN_SOURCE_WORK_TABLE).where({ plan_item_id: id }).delete();
|
||||
+ await trx(PLAN_ACTION_TABLE).where({ plan_item_id: id }).delete();
|
||||
+ await trx(PLAN_ISSUE_TABLE).where({ plan_item_id: id }).delete();
|
||||
+ await trx(PLAN_TABLE).where({ id }).delete();
|
||||
+ });
|
||||
|
||||
diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx
|
||||
+import { applyAppUpdate, getAppUpdateSnapshot, subscribeAppUpdate, type AppUpdateStatus } from './appUpdate';
|
||||
...
|
||||
+ <Button
|
||||
+ block
|
||||
+ icon={appUpdateStatus === 'updating' ? <ReloadOutlined spin /> : <DownloadOutlined />}
|
||||
+ >
|
||||
```
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- M src/app/main/appUpdate.ts
|
||||
- M etc/servers/work-server/src/routes/plan.ts
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M src/features/planBoard/api.ts
|
||||
- M etc/servers/work-server/src/config/env.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M scripts/run-plan-codex-once.mjs
|
||||
- M src/sw.ts
|
||||
- M vite.config.ts
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M src/styles.css
|
||||
- M etc/servers/work-server/.env.example
|
||||
- M etc/servers/work-server/docker-compose.yml
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- M docker-compose.yml
|
||||
- A src/app/main/appUpdate.ts
|
||||
- M src/main.tsx
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run plan:codex:once
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `docs/worklogs/2026-04-04.md`
|
||||
- `src/app/main/appUpdate.ts`
|
||||
- `src/app/main/MainHeader.tsx`
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`
|
||||
- `src/sw.ts`
|
||||
- `vite.config.ts`
|
||||
- `etc/servers/work-server/src/config/env.ts`
|
||||
- `etc/servers/work-server/src/workers/plan-worker.ts`
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`
|
||||
- `scripts/run-plan-codex-once.mjs`
|
||||
129
docs/worklogs/2026-04-05.md
Executable file
129
docs/worklogs/2026-04-05.md
Executable file
@@ -0,0 +1,129 @@
|
||||
# 2026-04-05 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- Plan 작업 등록 시 `메인까지 자동등록` 옵션 추가 및 자동 main 반영 흐름 연결
|
||||
- 대기성 알림을 줄이고 실제 자동화 시작/핵심 단계 중심 알림으로 재정렬
|
||||
- 기본 진입 화면을 Plans로 조정하고 알림 진입 시 plan 상세 연결 개선
|
||||
- Plan 성과 차트를 보드 하단에서 별도 사이드 메뉴(차트) 화면으로 분리
|
||||
- 작업 ID 기본값일 때 알림 제목을 `#채번번호` 기준으로 표기
|
||||
- Codex Live 채팅 기능 추가 후 별도 Chat 메뉴/사이드바로 분리
|
||||
- 채팅을 폴링 기반에서 `work-server /ws/chat` WebSocket 기반으로 전환
|
||||
- Vite 개발 환경에 `/ws/chat` 프록시 추가 및 재연결 안정화
|
||||
- 채팅 UI를 하단 입력 고정형으로 개편하고 진단/오프라인 폴백 개선
|
||||
- 진행 알림(1분/3분) 메시지에 현재 처리 단계/요약 반영
|
||||
- 재처리/재시작 알림 조건 정교화(실제 재실행 예약 시만 노출)
|
||||
- Web Push 알림 태그 충돌(덮어쓰기) 방지 처리
|
||||
- Noti 토큰 관리 UI 및 전송 조건(토큰 등록 시) 정리
|
||||
- Docs 기본 선택을 `worklogs`로 조정
|
||||
- Plan 목록에서 `정상처리 구분(상/중/하)` 즉시 변경 기능 추가
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- 채팅 연결 이슈는 프론트 코드보다 프록시/업그레이드 헤더/URL 계산의 영향이 큼
|
||||
- 진행 알림은 내용 품질이 낮으면 오히려 운영 피로도를 높임
|
||||
- Web Push는 `tag` 충돌 시 중요한 이벤트가 사라질 수 있어 이벤트 키 설계가 중요
|
||||
- 재처리 알림은 "실행 예약"과 "단순 응답" 구분이 필요
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 채팅은 WebSocket 기본, 실패 시 진단 가능 메시지와 폴백 제공
|
||||
- 진행 알림은 `1분 후`, 이후 `3분마다` 유지하되 현재 단계 중심 문구 사용
|
||||
- 알림 제목은 사용자 입력값보다 채번 ID 우선 전략 유지
|
||||
- 재시작 알림은 실제 재실행이 예약된 경우에만 발송
|
||||
- Docs 진입 기본 탭은 작업일지 우선
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- Plan/Chat 네비게이션을 재구성해 상단 메뉴-사이드바-콘텐츠 관계를 명확히 분리
|
||||
- 워커 알림 체계를 시작/경과/완료/재시작으로 세분화하고 노이즈 이벤트를 제거
|
||||
- 채팅은 UI 레이아웃, 연결 안정성, 진단 문구, 오프라인 대체 응답까지 함께 다듬어 실사용 대응 범위를 확장
|
||||
- 푸시 알림은 iOS/Web 공통 이벤트 일관성을 높이고 덮어쓰기 문제를 줄이도록 이벤트 키 전략을 조정
|
||||
- Plan 보드 운영 기능(정상처리 구분, 상세 이동, 자동화 상태 반영)을 보강해 운영 화면으로서 활용도를 높임
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
- `etc/servers/work-server/src/services/chat-service.ts`, `src/app/main/MainChatPanel.tsx`: Codex Live 채팅을 WebSocket 기반으로 연결하고, 현재 화면/Plan 상태를 반영한 응답 흐름을 추가했습니다.
|
||||
- `src/features/planBoard/charts.tsx`, `src/features/planBoard/PlanBoardPage.tsx`: Plan 성과 차트를 별도 메뉴로 분리하고 보드 운영 기능을 확장했습니다.
|
||||
- `src/features/planBoard/api.ts`: Plan 데이터 조회를 `no-store` 중심으로 바꿔 현재 상태가 바로 반영되게 조정했습니다.
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`: `메인까지 자동등록`, 정상처리 구분, 진행 알림/재시작 조건을 서버 흐름에 연결했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts
|
||||
+type ChatInboundMessage =
|
||||
+ | { type: 'context:update'; payload: ChatContext }
|
||||
+ | { type: 'message:send'; payload: { text: string } };
|
||||
...
|
||||
+function isWorklogRequest(input: string) {
|
||||
+ return mentionsWorklog || (normalized.includes('log') && asksToWrite);
|
||||
+}
|
||||
...
|
||||
+function buildWorklogReply(context: ChatContext | null, snapshot: PlanSnapshot | null) {
|
||||
+ return [
|
||||
+ `# ${today} 작업일지`,
|
||||
+ '## 오늘 작업',
|
||||
+ ].join('\\n');
|
||||
```
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- M etc/servers/work-server/src/services/chat-service.ts
|
||||
- M etc/servers/work-server/src/routes/plan.ts
|
||||
- M src/app/main/MainChatPanel.tsx
|
||||
- M etc/servers/work-server/src/services/plan-notification-service.ts
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- A src/app/main/tokenAccess.ts
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- M etc/servers/work-server/src/config/env.ts
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M scripts/run-plan-codex-once.mjs
|
||||
- M src/features/planBoard/types.ts
|
||||
- M src/styles.css
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M vite.config.ts
|
||||
- M etc/servers/work-server/package-lock.json
|
||||
- M etc/servers/work-server/package.json
|
||||
- M etc/servers/work-server/src/server.ts
|
||||
- A etc/servers/work-server/src/services/chat-service.ts
|
||||
- M src/app/main/MainSidebar.tsx
|
||||
- M src/app/main/MainView.tsx
|
||||
- M src/app/main/types.ts
|
||||
- M src/store/appStore/types/index.ts
|
||||
- A src/app/main/MainChatPanel.tsx
|
||||
- A src/features/planBoard/charts.tsx
|
||||
- M src/features/planBoard/index.ts
|
||||
- M src/store/appStore/context/AppStoreContext.tsx
|
||||
- M src/sw.js
|
||||
- M src/features/planBoard/api.ts
|
||||
- M docker-compose.yml
|
||||
- M scripts/serve-app-dist.mjs
|
||||
- R src/sw.ts -> src/sw.js
|
||||
- A scripts/serve-app-dist.mjs
|
||||
- M src/app/main/appUpdate.ts
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run capture:feature -- --date 2026-04-05 --target chat-live
|
||||
npm run capture:feature -- --date 2026-04-05 --target plans-charts
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `docs/worklogs/2026-04-05.md`
|
||||
- `src/app/main/MainChatPanel.tsx`
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`
|
||||
- `src/features/planBoard/charts.tsx`
|
||||
- `src/features/planBoard/api.ts`
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`
|
||||
- `etc/servers/work-server/src/services/plan-notification-service.ts`
|
||||
- `etc/servers/work-server/src/services/plan-notification-policy.ts`
|
||||
- `etc/servers/work-server/src/services/chat-service.ts`
|
||||
- `etc/servers/work-server/src/workers/plan-worker.ts`
|
||||
171
docs/worklogs/2026-04-06.md
Executable file
171
docs/worklogs/2026-04-06.md
Executable file
@@ -0,0 +1,171 @@
|
||||
# 2026-04-06 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- Plan API의 `Load Failed`, 완료건 정상처리, 삭제 요청 실패 같은 기본 동작 오류를 먼저 정리
|
||||
- Plan 목록/상세에서 작업 ID 표기, 자동화 진행 상태, 로딩/오류 메시지, 복사 버튼 등 사용성 보강
|
||||
- Chat 화면이 선택된 Plan 문맥을 더 직접적으로 답하도록 서버 응답과 헤더 UX를 계속 보완
|
||||
- `Codex Live`와 `앱로그 > 에러 로그` 구조를 나누고, 에러 로그 상세 화면을 탭/미리보기 중심으로 확장
|
||||
- 앱 설정을 드롭다운 구조와 `app config` 저장 방식으로 재구성하고 DB 동기화, 스냅샷 캐시, 즉시 반영 흐름 정리
|
||||
- Docs 작업일지를 산출물 브라우저와 섹션 탭 기반 미리보기 형태로 키우고, `## 소스`/`## 실행 커맨드`/`## 변경 파일` 포맷을 통일
|
||||
- 작업일지 스크린샷 자동화, 중복 제거, 날짜별 연결 규칙을 정리하고 3월 30일~4월 6일 문서를 후속 보강
|
||||
- 토큰 미등록 사용자의 Plan/설정 접근 범위를 readonly와 403 기준으로 제한
|
||||
|
||||
## 이슈 및 메모
|
||||
|
||||
- Fastify는 빈 바디인데 `Content-Type: application/json`만 있는 `DELETE` 요청을 `400`으로 거절해 프런트 공통 요청 유틸 수정이 필요했음
|
||||
- 완료건 정상처리, 자동 재처리, 자동화 설정 반영 시점이 서로 어긋나면 사용자 입장에서는 저장 실패처럼 보여 서버/프런트 기준을 같이 맞춰야 했음
|
||||
- 작업일지 본문만 보여주던 방식으로는 스크린샷, 로그, 코드 diff, 변경 파일을 따라가기 어려워 산출물 브라우저가 필요했음
|
||||
- 에러 로그/채팅/작업일지 preview가 각각 다른 규칙으로 렌더링돼 fenced code, 커맨드 블록, 파일 목록 표시가 계속 들쭉날쭉했음
|
||||
- 토큰 미등록 사용자에게는 Plan 상세, 메모, 소스 작업, 설정 일부를 그대로 노출하면 안 돼 화면과 API를 동시에 잠가야 했음
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 공통 API 유틸은 HTTP 메서드가 아니라 실제 바디 유무 기준으로 `Content-Type`을 설정
|
||||
- 자동화 실패 후 메모 저장이나 이슈 조치만으로는 자동 재처리를 다시 켜지 않도록 `retry=false` 기본값을 유지
|
||||
- 작업일지 preview는 Markdown 본문 외 산출물도 같은 카드 안에서 섹션 단위 탭으로 노출
|
||||
- 작업일지 `## 소스`는 파일 나열보다 "요약 설명 + 대표 diff" 중심으로 고정
|
||||
- 앱 설정은 클라이언트 임시 상태가 아니라 DB 저장값과 비교 가능한 `app config` 구조로 관리
|
||||
- 토큰 미등록 사용자는 Plan 관련 쓰기 작업과 민감 상세 열람을 모두 제한
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- `src/features/planBoard/api.ts`에서 요청 타임아웃/재시도와 헤더 구성을 다듬어 `Load Failed`, 삭제 실패, 정상처리 갱신 문제를 함께 줄였음
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`에서 작업 ID 표기, 자동화 상태 설명, 오류 알림 복사, 진행중 우선 정렬, readonly 처리, 작업일지 산출물 브라우저를 순차 반영
|
||||
- `etc/servers/work-server/src/services/chat-service.ts`는 Chat 화면이어도 `planId`가 있으면 상태/이력/이슈를 같이 답하게 보강했고, 응답 제한 이유도 더 직접적으로 바꿨음
|
||||
- `src/app/main/MainChatPanel.tsx`와 서버 에러 로그 라우트/서비스를 연결해 `앱로그 > 에러 로그` 화면과 상세 탭/최대화/참조 preview를 정리했음
|
||||
- `src/app/main/MainHeader.tsx`, `src/app/main/appConfig.ts`, `etc/servers/work-server/src/routes/app-config.ts` 계층에서 앱 설정 드롭다운, DB 저장, 동기화, 캐시, 즉시 반영 흐름을 묶었음
|
||||
- `src/components/markdownPreview/MarkdownPreviewCard.tsx`, `src/components/markdownPreview/MarkdownPreviewContent.tsx`, `src/components/previewer/*`는 작업일지/산출물 preview를 공통 포맷 레이어로 렌더링하게 바꿨음
|
||||
- `scripts/capture-menu-screenshot.mjs`, `scripts/capture-feature-screenshot.mjs`, `scripts/worklog-capture-utils.mjs`로 메뉴/기능 캡처와 작업일지 연결을 자동화했고, 불필요한 날짜 참조는 다시 정리했음
|
||||
- `docs/worklogs/2026-03-30.md`부터 `docs/worklogs/2026-04-06.md`까지 문서 포맷과 스크린샷/소스/커맨드/변경 파일 섹션을 현재 규칙에 맞게 보강했음
|
||||
|
||||
## 스크린샷
|
||||
|
||||
- 저장소 기준 연결된 스크린샷 없음
|
||||
|
||||
## 소스
|
||||
|
||||
- Plan 기본 동작 안정화: 완료건 정상처리, 삭제, 재시도, 오류 표시에 걸친 프런트/서버 동작을 정리했습니다.
|
||||
- `src/features/planBoard/api.ts`, `src/features/planBoard/PlanBoardPage.tsx`, `etc/servers/work-server/src/services/plan-service.ts`: 요청 헤더, 타임아웃, 목록 정렬, readonly, 완료 처리와 자동 재처리 기준을 함께 맞췄습니다.
|
||||
- Chat/앱로그 확장: 선택된 Plan 문맥 응답과 에러 로그 상세 뷰를 계속 키웠습니다.
|
||||
- `etc/servers/work-server/src/services/chat-service.ts`, `src/app/main/MainChatPanel.tsx`, `src/app/main/errorLogApi.ts`: Chat 답변 문맥, 앱로그 메뉴 구조, 에러 로그 조회/상세/preview 흐름을 연결했습니다.
|
||||
- 작업일지/증적 preview 정리: 문서 본문, 코드, 커맨드, 변경 파일을 같은 시각 규칙으로 렌더링하게 통일했습니다.
|
||||
- `src/components/markdownPreview/MarkdownPreviewContent.tsx`, `src/components/previewer/renderers.tsx`, `docs/templates/worklog-template.md`: 작업일지 포맷과 preview 렌더러를 섹션 중심으로 정리했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/features/planBoard/api.ts b/src/features/planBoard/api.ts
|
||||
+ const hasBody = init?.body !== undefined && init.body !== null;
|
||||
...
|
||||
- headers.set('Content-Type', 'application/json');
|
||||
+ if (hasBody && !headers.has('Content-Type')) {
|
||||
+ headers.set('Content-Type', 'application/json');
|
||||
+ }
|
||||
```
|
||||
|
||||
```diff
|
||||
diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx
|
||||
- <button>앱 설정</button>
|
||||
+ <Dropdown menu={{ items: appConfigMenus }}>
|
||||
+ <button>앱 설정</button>
|
||||
+ </Dropdown>
|
||||
```
|
||||
|
||||
```diff
|
||||
diff --git a/src/components/previewer/renderers.tsx b/src/components/previewer/renderers.tsx
|
||||
+export function renderMarkdownSectionPreview(section, context) {
|
||||
+ if (section.type === 'code') return <CodeBlockPreview {...context} />;
|
||||
+ if (section.type === 'command') return <CommandLogPreview {...context} />;
|
||||
+ return <DefaultSectionPreview {...context} />;
|
||||
+}
|
||||
```
|
||||
|
||||
## 변경 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- M src/app/main/MainChatPanel.tsx
|
||||
- M src/styles.css
|
||||
- M etc/servers/work-server/src/services/app-config-service.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M src/components/window/WindowUI.tsx
|
||||
- M src/app/main/MainSidebar.tsx
|
||||
- M src/app/main/MainView.tsx
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- M src/features/planBoard/api.ts
|
||||
- M etc/servers/work-server/src/services/plan-policy.test.ts
|
||||
- M etc/servers/work-server/src/app.ts
|
||||
- M etc/servers/work-server/src/services/error-log-service.ts
|
||||
- M src/app/main/errorLogApi.ts
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M src/app/main/types.ts
|
||||
- A etc/servers/work-server/src/routes/error-log.ts
|
||||
- A etc/servers/work-server/src/services/error-log-service.ts
|
||||
- M src/App.tsx
|
||||
- A src/app/main/errorLogApi.ts
|
||||
- M etc/servers/work-server/src/routes/app-config.ts
|
||||
- M src/app/main/appConfig.ts
|
||||
- A etc/servers/work-server/src/routes/app-config.ts
|
||||
- A etc/servers/work-server/src/services/app-config-service.ts
|
||||
- M etc/servers/work-server/src/config/env.ts
|
||||
- M etc/servers/work-server/src/routes/plan.ts
|
||||
- M etc/servers/work-server/src/services/git-service.ts
|
||||
- M etc/servers/work-server/src/services/notification-service.ts
|
||||
- A src/app/main/appConfig.ts
|
||||
- M etc/servers/work-server/src/services/chat-service.ts
|
||||
- M src/features/planBoard/charts.tsx
|
||||
- M src/features/planBoard/types.ts
|
||||
- A docs/worklogs/2026-04-03.md
|
||||
- A docs/worklogs/2026-04-04.md
|
||||
- A docs/worklogs/2026-04-05.md
|
||||
- A docs/worklogs/2026-04-06.md
|
||||
- M etc/servers/work-server/src/services/plan-notification-service.ts
|
||||
- M etc/servers/work-server/package.json
|
||||
- A etc/servers/work-server/src/services/plan-notification-policy.ts
|
||||
- A etc/servers/work-server/src/services/plan-policy.test.ts
|
||||
- A etc/servers/work-server/src/services/plan-retry-policy.ts
|
||||
- M src/sw.js
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
npx tsc -b
|
||||
npm run build:app
|
||||
npm run capture:menu -- --date 2026-04-06
|
||||
npm run capture:feature -- --date 2026-04-06
|
||||
npm run plan:codex:once
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
- `docs/templates/worklog-template.md`
|
||||
- `docs/worklogs/2026-03-30.md`
|
||||
- `docs/worklogs/2026-03-31.md`
|
||||
- `docs/worklogs/2026-04-01.md`
|
||||
- `docs/worklogs/2026-04-02.md`
|
||||
- `docs/worklogs/2026-04-03.md`
|
||||
- `docs/worklogs/2026-04-04.md`
|
||||
- `docs/worklogs/2026-04-05.md`
|
||||
- `docs/worklogs/2026-04-06.md`
|
||||
- `etc/servers/work-server/src/routes/app-config.ts`
|
||||
- `etc/servers/work-server/src/routes/error-log.ts`
|
||||
- `etc/servers/work-server/src/routes/plan.ts`
|
||||
- `etc/servers/work-server/src/services/app-config-service.ts`
|
||||
- `etc/servers/work-server/src/services/chat-service.ts`
|
||||
- `etc/servers/work-server/src/services/error-log-service.ts`
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`
|
||||
- `etc/servers/work-server/src/workers/plan-worker.ts`
|
||||
- `scripts/capture-feature-screenshot.mjs`
|
||||
- `scripts/capture-menu-screenshot.mjs`
|
||||
- `scripts/worklog-capture-utils.mjs`
|
||||
- `src/app/main/MainChatPanel.tsx`
|
||||
- `src/app/main/MainHeader.tsx`
|
||||
- `src/app/main/appConfig.ts`
|
||||
- `src/app/main/errorLogApi.ts`
|
||||
- `src/components/markdownPreview/MarkdownPreviewCard.tsx`
|
||||
- `src/components/markdownPreview/MarkdownPreviewContent.tsx`
|
||||
- `src/components/previewer/PreviewerUI.css`
|
||||
- `src/components/previewer/PreviewerUI.tsx`
|
||||
- `src/components/previewer/renderers.tsx`
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`
|
||||
- `src/features/planBoard/api.ts`
|
||||
- `src/styles.css`
|
||||
159
docs/worklogs/2026-04-07.md
Executable file
159
docs/worklogs/2026-04-07.md
Executable file
@@ -0,0 +1,159 @@
|
||||
# 2026-04-07 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 누락된 `2026-04-07` 작업일지 파일을 복구
|
||||
- Plan 재처리 요청 이력과 자동작업 실패 이슈를 기준으로 오늘 상태를 문서화
|
||||
- `Docs > 작업일지` 화면 캡처를 생성해 오늘자 스크린샷 자산과 문서 링크를 연결
|
||||
- `## 소스`, `## 실행 커맨드`, `## 변경/신규 파일` 섹션을 현재 정리 결과 기준으로 갱신
|
||||
|
||||
## 이슈 및 해결
|
||||
|
||||
- 오늘자 작업일지가 없어 Docs와 Plan 증적에서 `2026-04-07` 기록을 바로 확인할 수 없었음
|
||||
- 별도 기능 수정 없이 누락된 일자 문서를 추가해 조회 공백을 해소
|
||||
- 스크린샷 섹션이 비어 있어 오늘 작업 화면을 바로 검증하기 어려웠음
|
||||
- `vite` 개발 서버와 `capture:feature` 스크립트로 `feature-docs-worklogs.png`를 생성해 문서와 연결
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 오늘 조치 범위는 작업일지 정리와 증적 보강으로 한정
|
||||
- 2026-04-07 증적 대표 화면은 `Docs > 작업일지` 뷰로 통일
|
||||
- 앱 번들 타입 오류가 남아 있어 이번 캡처는 `build:app` 대신 `vite` 개발 서버 기준으로 수행
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- `docs/worklogs/2026-04-07.md`를 생성해 오늘자 작업일지 기본 섹션을 채움
|
||||
- 최근 조치 이력의 재처리 요청과 자동작업 실패 이력을 작업일지 맥락에 맞게 정리
|
||||
- `docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png`를 생성하고 `## 스크린샷`에 연결
|
||||
- 실행한 확인/캡처 커맨드와 오늘 기준 변경 파일 증적을 문서 하단 섹션에 정리
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `docs/worklogs/2026-04-07.md`
|
||||
|
||||
- 누락된 작업일지 복구 후 최신 증적까지 포함하도록 오늘자 문서를 다시 정리했습니다.
|
||||
- 오늘 작업 요약, 스크린샷 링크, 실행 커맨드, 변경/신규 파일 목록을 최신 상태로 반영했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/docs/worklogs/2026-04-07.md b/docs/worklogs/2026-04-07.md
|
||||
+## 스크린샷
|
||||
+
|
||||
+## 실행 커맨드
|
||||
+npm run dev -- --host 127.0.0.1 --port 4173
|
||||
```
|
||||
|
||||
### 파일 2: `docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png`
|
||||
|
||||
- `Docs > 작업일지` 화면을 오늘자 자산 폴더에 저장해 문서 증적에서 바로 미리볼 수 있게 했습니다.
|
||||
- 2026-04-07 작업일지의 스크린샷 섹션과 1:1로 대응하는 캡처 파일입니다.
|
||||
|
||||
```diff
|
||||
Binary file added: docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png
|
||||
```
|
||||
|
||||
## 변경/신규 파일 (전체, 중복 제거, KST 기준)
|
||||
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M scripts/run-plan-codex-once.mjs
|
||||
- M docs/worklogs/2026-04-07.md
|
||||
- M etc/servers/work-server/src/services/app-config-service.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M src/app/main/appConfig.ts
|
||||
- A .github/workflows/daily-docs-maintenance.yml
|
||||
- M README.md
|
||||
- M docs/README.md
|
||||
- A docs/assets/worklogs/2026-04-07/.gitkeep
|
||||
- A docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png
|
||||
- A docs/daily-maintenance.config.json
|
||||
- M docs/worklogs/2026-03-30.md
|
||||
- M docs/worklogs/2026-03-31.md
|
||||
- M docs/worklogs/2026-04-01.md
|
||||
- M docs/worklogs/2026-04-02.md
|
||||
- M docs/worklogs/2026-04-03.md
|
||||
- M docs/worklogs/2026-04-04.md
|
||||
- M docs/worklogs/2026-04-05.md
|
||||
- M docs/worklogs/2026-04-06.md
|
||||
- M package.json
|
||||
- A scripts/refresh-daily-docs.mjs
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- A etc/servers/work-server/src/services/plan-service.test.ts
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- A docs/worklogs/2026-04-07.md
|
||||
- M docs/templates/worklog-template.md
|
||||
- M etc/servers/work-server/src/services/chat-service.ts
|
||||
- M src/components/markdownPreview/MarkdownPreviewCard.tsx
|
||||
- M scripts/capture-component-screenshot.mjs
|
||||
- M scripts/capture-feature-screenshot.mjs
|
||||
- M scripts/capture-fullscreen-toggle-screenshot.mjs
|
||||
- M scripts/capture-menu-screenshot.mjs
|
||||
- M scripts/capture-plan-board-mobile-screenshot.mjs
|
||||
- M scripts/capture-search-command-screenshot.mjs
|
||||
- M scripts/capture-settings-screenshot.mjs
|
||||
- M scripts/worklog-capture-utils.mjs
|
||||
- M src/components/markdownPreview/MarkdownPreviewContent.tsx
|
||||
- M src/components/previewer/PreviewerUI.css
|
||||
- M src/components/previewer/PreviewerUI.tsx
|
||||
- A src/components/previewer/renderers.tsx
|
||||
- M src/components/previewer/types/index.ts
|
||||
- M src/components/previewer/types/previewer.ts
|
||||
- M etc/servers/work-server/src/routes/plan.ts
|
||||
- M src/features/planBoard/types.ts
|
||||
- M src/features/planBoard/api.ts
|
||||
- M src/styles.css
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M src/app/main/MainView.tsx
|
||||
- R docs/assets/worklogs/2026-04-03/feature-chat-live.png -> docs/assets/worklogs/2026-04-05/feature-chat-live.png
|
||||
- R docs/assets/worklogs/2026-04-03/feature-plans-charts.png -> docs/assets/worklogs/2026-04-05/feature-plans-charts.png
|
||||
- A docs/assets/worklogs/2026-04-03/feature-chat-live.png
|
||||
- A docs/assets/worklogs/2026-04-03/feature-plans-charts.png
|
||||
- A docs/assets/worklogs/2026-04-03/settings-app.png
|
||||
- A docs/assets/worklogs/2026-04-03/settings-notification.png
|
||||
- A scripts/capture-settings-screenshot.mjs
|
||||
- D docs/assets/worklogs/2026-04-06/docs-menu.png
|
||||
- D docs/assets/worklogs/2026-04-06/feature-apis-components.png
|
||||
- D docs/assets/worklogs/2026-04-06/feature-docs-worklogs.png
|
||||
- D docs/assets/worklogs/2026-04-06/feature-plans-board.png
|
||||
- D docs/assets/worklogs/2026-04-06/feature-plans-charts.png
|
||||
- D docs/assets/worklogs/2026-04-06/plans-menu.png
|
||||
- A docs/assets/worklogs/2026-04-06/docs-menu.png
|
||||
- A docs/assets/worklogs/2026-04-06/feature-apis-components.png
|
||||
- A docs/assets/worklogs/2026-04-06/feature-docs-worklogs.png
|
||||
- A docs/assets/worklogs/2026-04-06/feature-plans-board.png
|
||||
- A docs/assets/worklogs/2026-04-06/feature-plans-charts.png
|
||||
- A docs/assets/worklogs/2026-04-06/plans-menu.png
|
||||
- A scripts/capture-feature-screenshot.mjs
|
||||
- A scripts/capture-menu-screenshot.mjs
|
||||
- A docs/assets/worklogs/2026-03-30/input.png
|
||||
- A docs/assets/worklogs/2026-03-30/status-badge.png
|
||||
- A docs/assets/worklogs/2026-03-31/email-input.png
|
||||
- A docs/assets/worklogs/2026-03-31/multi-input.png
|
||||
- A docs/assets/worklogs/2026-04-01/check-combo-input.png
|
||||
- A docs/assets/worklogs/2026-04-01/dashboard-multi-progress.png
|
||||
- A docs/assets/worklogs/2026-04-01/dashboard-progress.png
|
||||
- A docs/assets/worklogs/2026-04-01/popup-input.png
|
||||
- A docs/assets/worklogs/2026-04-01/select-input.png
|
||||
- M etc/servers/work-server/src/services/plan-notification-service.ts
|
||||
- M etc/servers/work-server/src/services/plan-policy.test.ts
|
||||
- M src/app/main/MainChatPanel.tsx
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
git -c safe.directory=/workspace/auto_codex/repo status --short --branch
|
||||
sed -n '1,260p' docs/worklogs/2026-04-07.md
|
||||
sed -n '1,260p' docs/templates/worklog-template.md
|
||||
npm run dev -- --host 127.0.0.1 --port 4173
|
||||
CAPTURE_BASE_URL=http://127.0.0.1:4173 npm run capture:feature -- docs-worklogs 2026-04-07
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff -- docs/worklogs/2026-04-07.md
|
||||
```
|
||||
|
||||
## 자동 정리 상태
|
||||
|
||||
- 일일 정리 스크립트 실행일: `2026-04-07`
|
||||
- 작업일지 파일 점검: `docs/worklogs/2026-04-07.md`
|
||||
- 스크린샷 폴더 점검: `docs/assets/worklogs/2026-04-07/`
|
||||
- README / Docs 가이드 자동 요약 갱신 완료
|
||||
188
docs/worklogs/2026-04-08.md
Executable file
188
docs/worklogs/2026-04-08.md
Executable file
@@ -0,0 +1,188 @@
|
||||
# 2026-04-08 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- `Play` 상단 메뉴와 `Play > Test` 진입 경로를 추가했습니다.
|
||||
- 헤더 최상단 메뉴를 `Docs / Plan / Play` 3개 기준으로 정리했습니다.
|
||||
- `Play > layout` 화면을 만들고 상하/좌우 분할, 재귀 분할, 크기 단위, 최소 크기, 리사이즈 여부를 조절할 수 있게 확장했습니다.
|
||||
- Plan 상세에서 긴 `issue`/`error` 본문을 기본 접힘으로 바꾸고 아이콘으로만 펼치도록 정리했습니다.
|
||||
- `release` 환경에서 `main` 미반영 항목만 모아 보여주는 모달을 추가했습니다.
|
||||
- 자동화 작업 브랜치 기준을 `release`가 아니라 `main`으로 바로잡고, worklog 자동화는 `hotfix/*` 브랜치를 쓰도록 보강했습니다.
|
||||
- 누락됐던 `docs/worklogs/2026-04-08.md` 초안이 템플릿 상태로만 남아 있던 문제를 실제 작업 내역 기준으로 다시 정리했습니다.
|
||||
- 누락됐던 `Play > layout` 스크린샷을 `docs/assets/worklogs/2026-04-08/feature-play-layout.png`로 다시 연결했습니다.
|
||||
|
||||
## 이슈 및 해결
|
||||
|
||||
- 오늘자 작업일지가 템플릿 문구만 남아 있어 `play`, 자동화 브랜치 정책, release 대기 모달 작업 이력을 문서에서 확인할 수 없었습니다.
|
||||
- Git 기록 기준으로 2026-04-08 UTC/KST 커밋을 다시 확인해 실제 작업 항목과 변경 파일을 복원했습니다.
|
||||
- 사용자가 언급한 `history` 기능은 Git 이력상 2026-04-07 13:44:06 UTC 커밋(`plan(history)`)에 생성됐고, 2026-04-08에는 `play`/plan 상세/자동화 보강 작업이 집중됐습니다.
|
||||
- 스크린샷 섹션이 비어 있고 소스 뷰어의 diff 라벨도 일관되지 않아, 증적 확인 흐름이 한 번에 읽히지 않았습니다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 2026-04-08 문서는 placeholder 템플릿이 아니라 실제 커밋 증적 기준으로 유지합니다.
|
||||
- `Play`는 `Test`와 `layout` 두 축으로 관리하고, 레이아웃 실험은 전용 playground에서 확장합니다.
|
||||
- Plan 소스 뷰어 탭 명칭은 사용자가 바로 이해할 수 있게 `작업란 / 전체소스 / diff`로 정리합니다.
|
||||
- 자동화 작업 브랜치 base는 `main`, 자동 worklog 정리는 `hotfix/*` 흐름을 따릅니다.
|
||||
- 2026-04-08 대표 스크린샷은 `Play > layout` 화면으로 고정합니다.
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- `src/app/main/MainHeader.tsx`, `src/app/main/MainView.tsx`, `src/app/main/MainContent.tsx`, `src/app/main/types.ts`, `src/views/play/TestView.tsx`에서 `Play` 메뉴와 `Play > Test` 화면을 연결했습니다.
|
||||
- `src/views/play/LayoutPlaygroundView.tsx`와 `src/styles.css`에서 레이아웃 playground를 추가하고, 이후 같은 날 재귀 분할과 초기 빈 상태 진입까지 두 차례 확장했습니다.
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`에서 긴 이슈 본문 접힘 처리와 소스 작업 미리보기 경험을 보강했습니다.
|
||||
- `src/App.tsx`, `src/app/main/ReleasePendingMainModal.tsx`, `src/styles.css`에서 `release` 접속 시 `main` 미반영 항목 안내 모달을 추가했습니다.
|
||||
- `scripts/run-plan-codex-once.mjs`, `etc/servers/work-server/src/services/git-service.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`, `etc/servers/work-server/src/services/plan-service.ts`에서 자동화 브랜치/정리 정책을 수정했습니다.
|
||||
- 오늘 문서 누락 보완으로 `docs/worklogs/2026-04-08.md`를 실제 기록 기준으로 재작성했습니다.
|
||||
- `scripts/capture-feature-screenshot.mjs`에 `play-layout` 프리셋을 추가해 오늘자 대표 화면을 다시 캡처할 수 있게 했습니다.
|
||||
- `src/components/previewer/CodexDiffPreviewer.tsx`에서 소스 뷰어 탭 라벨을 `전체소스 / diff`로 맞췄습니다.
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `src/app/main/MainView.tsx`
|
||||
|
||||
- 상단 메뉴 체계를 `Docs / Plan / Play` 중심으로 재정리하고 `Play > Test`, `Play > layout` 탐색을 연결했습니다.
|
||||
- 검색 커맨드와 초기 URL 파라미터도 `playSection` 기준으로 함께 맞췄습니다.
|
||||
|
||||
```diff
|
||||
const PLAY_BASE_OPEN_KEYS = ['play-group', 'play-test-group', 'play-layout-group'] as const;
|
||||
...
|
||||
+ id: 'page:play:test',
|
||||
+ keywords: ['play', 'test', '테스트', 'TEST메뉴'],
|
||||
+ },
|
||||
+ {
|
||||
+ id: 'page:play:layout',
|
||||
+ keywords: ['play', 'layout', 'flex', 'split', '분할', '레이아웃', 'preview'],
|
||||
```
|
||||
|
||||
### 파일 2: `src/views/play/LayoutPlaygroundView.tsx`
|
||||
|
||||
- `play > layout` 화면을 추가하고, 같은 날 재귀 분할과 빈 상태 시작 플로우까지 확장했습니다.
|
||||
- 상하/좌우 시작, `px/%` 단위, 최소 크기, 리사이즈 허용 여부를 화면에서 바로 조정할 수 있습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/views/play/LayoutPlaygroundView.tsx b/src/views/play/LayoutPlaygroundView.tsx
|
||||
+const DEFAULT_LAYOUT_DIRECTION = 'row';
|
||||
+const EMPTY_LAYOUT: LayoutNode | null = null;
|
||||
...
|
||||
+<Button onClick={() => handleCreateRoot('row')}>좌우로 시작</Button>
|
||||
+<Button onClick={() => handleCreateRoot('column')}>상하로 시작</Button>
|
||||
...
|
||||
+<Button onClick={() => handleSplitNode(selectedNodeId, 'row')}>좌우 추가</Button>
|
||||
+<Button onClick={() => handleSplitNode(selectedNodeId, 'column')}>상하 추가</Button>
|
||||
```
|
||||
|
||||
### 파일 3: `src/app/main/ReleasePendingMainModal.tsx`
|
||||
|
||||
- `release` 환경에서 아직 `main`에 반영되지 않은 작업만 모아 보여주는 전용 모달을 추가했습니다.
|
||||
- `릴리즈완료`, `main반영대기/중/실패` 상태를 기준으로 요약과 현황을 표시합니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/app/main/ReleasePendingMainModal.tsx b/src/app/main/ReleasePendingMainModal.tsx
|
||||
+export function ReleasePendingMainModal() {
|
||||
+ // release 서버에서 main 미반영 항목만 조회
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 4: `scripts/run-plan-codex-once.mjs`
|
||||
|
||||
- `.auto_codex` 작업 브랜치의 base를 `release`가 아니라 `main`으로 맞췄습니다.
|
||||
- 같은 날 자동 worklog 쪽은 `hotfix/*` 흐름을 따르도록 서버 로직도 함께 보강됐습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs
|
||||
-const baseBranch = 'release';
|
||||
+const baseBranch = 'main';
|
||||
```
|
||||
|
||||
### 파일 5: `src/features/planBoard/PlanBoardPage.tsx`
|
||||
|
||||
- 긴 `issue`/`error` 본문은 기본 접힘으로 바꾸고, 이번 보완에서 소스 뷰어 탭 명칭도 `작업란 / 전체소스 / diff`로 정리했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/features/planBoard/PlanBoardPage.tsx b/src/features/planBoard/PlanBoardPage.tsx
|
||||
- label: '요약',
|
||||
+ label: '작업란',
|
||||
- label: '전체 소스',
|
||||
+ label: '전체소스',
|
||||
- label: 'Diff',
|
||||
+ label: 'diff',
|
||||
```
|
||||
|
||||
### 파일 6: `src/components/previewer/CodexDiffPreviewer.tsx`
|
||||
|
||||
- worklog 소스 탭과 Plan 소스 뷰어의 모드 라벨을 `전체소스 / diff`로 통일했습니다.
|
||||
- 기존 `Raw Diff` 표기를 `diff`로 바꿔 사용자가 탭 목적을 바로 읽을 수 있게 맞췄습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/src/components/previewer/CodexDiffPreviewer.tsx b/src/components/previewer/CodexDiffPreviewer.tsx
|
||||
- { label: '전체 소스', value: 'source' },
|
||||
- { label: 'Raw Diff', value: 'diff' },
|
||||
+ { label: '전체소스', value: 'source' },
|
||||
+ { label: 'diff', value: 'diff' },
|
||||
```
|
||||
|
||||
### 파일 7: `scripts/capture-feature-screenshot.mjs`
|
||||
|
||||
- `Play > layout` 화면도 기존 worklog 캡처 흐름으로 다시 만들 수 있게 프리셋을 추가했습니다.
|
||||
- 오늘자 누락 스크린샷은 이 프리셋으로 `feature-play-layout.png`를 생성해 연결했습니다.
|
||||
|
||||
```diff
|
||||
diff --git a/scripts/capture-feature-screenshot.mjs b/scripts/capture-feature-screenshot.mjs
|
||||
+ 'play-layout': {
|
||||
+ topMenu: 'play',
|
||||
+ screenshotFileName: 'feature-play-layout.png',
|
||||
+ targetSelector: '.app-main-card',
|
||||
+ query: { playSection: 'layout' },
|
||||
+ },
|
||||
```
|
||||
|
||||
### 파일 8: `docs/assets/worklogs/2026-04-08/feature-play-layout.png`
|
||||
|
||||
- `Play > layout` 대표 화면을 다시 캡처해 오늘자 스크린샷 섹션과 바로 연결했습니다.
|
||||
- 2026-04-08 작업일지에서 비어 있던 시각 증적을 채우는 실제 산출물 파일입니다.
|
||||
|
||||
```diff
|
||||
Binary file added: docs/assets/worklogs/2026-04-08/feature-play-layout.png
|
||||
```
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-08 00:00' --until='2026-04-08 23:59:59' --stat --oneline --decorate
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-08 00:00' --until='2026-04-08 23:59:59' --name-status --format='commit %H%nAuthor: %an%nDate: %ad%nSubject: %s' --date=iso
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --follow --name-status -- src/features/history/HistoryPage.tsx
|
||||
npm run dev -- --host 127.0.0.1 --port 4173
|
||||
CAPTURE_BASE_URL=http://127.0.0.1:4173 node scripts/capture-feature-screenshot.mjs play-layout 2026-04-08
|
||||
```
|
||||
|
||||
## 변경/신규 파일 (전체, 중복 제거, Git 기록 기준)
|
||||
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M src/app/main/MainSidebar.tsx
|
||||
- M src/app/main/MainView.tsx
|
||||
- M src/app/main/types.ts
|
||||
- M src/app/main/clientIdentity.ts
|
||||
- M src/App.tsx
|
||||
- A src/app/main/ReleasePendingMainModal.tsx
|
||||
- A src/views/play/TestView.tsx
|
||||
- A src/views/play/LayoutPlaygroundView.tsx
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- M src/styles.css
|
||||
- M src/store/appStore/types/index.ts
|
||||
- M scripts/run-plan-codex-once.mjs
|
||||
- M etc/servers/work-server/src/services/git-service.ts
|
||||
- A etc/servers/work-server/src/services/git-service.test.ts
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M etc/servers/work-server/src/services/plan-service.test.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M AGENTS.md
|
||||
- A docs/worklogs/2026-04-08.md
|
||||
- M src/components/previewer/CodexDiffPreviewer.tsx
|
||||
- M scripts/capture-feature-screenshot.mjs
|
||||
- A docs/assets/worklogs/2026-04-08/feature-play-layout.png
|
||||
446
docs/worklogs/2026-04-09.md
Executable file
446
docs/worklogs/2026-04-09.md
Executable file
@@ -0,0 +1,446 @@
|
||||
# 2026-04-09 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- `Play > Layout`을 컴포넌트/위젯 선택형 레이아웃 편집기로 확장하고, 저장 레이아웃 목록/상세 흐름을 여러 차례 다듬었습니다.
|
||||
- 레이아웃 저장소를 브라우저 `IndexedDB`에서 `work-db` API 기반 서버 저장 방식으로 전환했습니다.
|
||||
- `Window UI` 검색 중복 열림, 모바일 리사이즈 hit area, 더블탭/더블클릭 확장, 스크롤 처리 문제를 순차적으로 정리했습니다.
|
||||
- `EmbeddedMapUI`, `GPS Sample Widget`, 서버 경유 푸시 알림 흐름을 추가해 GPS 거점/반경/알림 시나리오를 앱 안에서 직접 다룰 수 있게 만들었습니다.
|
||||
- `Text Memo Widget`을 신규 추가하고, 이어서 iOS 메모 느낌의 시트형 UI로 재구성했습니다.
|
||||
- `Plan Board`에 로컬 페이지네이션, `release 반영 상태`/`자동화 실패` 바로가기, `main` 일괄 반영 흐름을 추가했습니다.
|
||||
- 앱 초기 1.9초 로딩 오버레이, 제스처 단축키 설정, `release 반영 상태` 메뉴/모달, Git flow 안전 규칙 문서 보강까지 반영했습니다.
|
||||
- 오늘자 작업 증적을 위해 전체 화면 1장과 위젯 단위 부분 캡처 1장을 `docs/assets/worklogs/2026-04-09/`에 저장하고 연결했습니다.
|
||||
|
||||
## 이슈 및 해결
|
||||
|
||||
- `Play > Layout` 저장본을 클라이언트 로컬 저장소만으로 관리하던 상태라 작업 환경이 바뀌면 레이아웃이 끊겼습니다.
|
||||
- `src/views/play/layoutStorage.ts`를 `work-db` API 호출 구조로 전환하고, 인증 토큰/클라이언트 ID 헤더를 함께 보내도록 바꿔 저장본을 서버 기준으로 유지했습니다.
|
||||
- `Window UI` 검색/열기 흐름에서 같은 항목이 짧은 시간 안에 2개씩 열리거나, 전체 부모 기준으로 과도하게 확장되는 재발 이슈가 있었습니다.
|
||||
- `SearchLayerContext`, `SearchCommandModal`, `WindowUI`를 함께 수정해 선택 잠금, 500ms 중복 차단, 보이는 viewport 기준 확장 로직으로 안정화했습니다.
|
||||
- 지도 반경 오버레이는 초기에 좌표 기반 렌더가 아니라 CSS 고정 오버레이 성격이 강해 지도를 움직일 때 의미가 깨졌습니다.
|
||||
- `EmbeddedMapUI`를 실제 좌표/경계 계산 기반 OSM iframe 오버레이로 바꾸고, GPS 위젯에서 선택 거점과 현재 위치를 함께 표시하도록 맞췄습니다.
|
||||
- 자동화 쪽은 `main` 반영이 단건 기준으로만 움직여 `release`에 쌓인 항목을 한 번에 넘기기 어려웠습니다.
|
||||
- `plan-service.ts`, `plan-worker.ts`에서 `release_target` 기준 `main` 일괄 반영/재시도 흐름으로 보강했습니다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- `Play > Layout`의 대표 명칭은 `Layout Editor`로 통일하고, 저장된 레이아웃은 별도 메뉴 엔트리로 노출합니다.
|
||||
- 레이아웃/플레이그라운드 저장은 브라우저 로컬 상태보다 `work-db` 서버 저장을 우선 기준으로 사용합니다.
|
||||
- `Plan` 상단/설정 영역의 `release 반영 상태`, `자동화 실패`는 별도 빠른 진입점으로 유지합니다.
|
||||
- `hotfix`와 `feature` 후속 작업은 항상 새 브랜치에서 다시 시작한다는 Git 안전 규칙을 문서에 명시합니다.
|
||||
- 오늘 대표 스크린샷은 `Play > Layout`, 부분 스크린샷은 실제 위젯 단위로 확인 가능한 `GPS Sample Widget` 카드로 남깁니다.
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- `src/views/play/LayoutPlaygroundView.tsx`에서 섹션별 `showHideAction`, 컴포넌트/위젯 검색 모달, preview-only 렌더, 저장 레이아웃 목록/상세, 전체보기/빈 상태 플로우를 누적 확장했습니다.
|
||||
- `src/views/play/layoutStorage.ts`에서 `play_layouts` 테이블을 다루는 서버 API 저장소로 전환하고, fallback URL과 timeout 처리까지 추가했습니다.
|
||||
- `src/components/search/SearchCommandModal.tsx`, `src/layer/search/context/SearchLayerContext.tsx`, `src/components/window/WindowUI.tsx`, `src/components/window/WindowUI.css`에서 검색 선택 중복, 모바일 hit area, 보이는 화면 기준 확장, 닫기 액션을 정리했습니다.
|
||||
- `src/components/embeddedMap/*`, `src/layer/gps/*`, `src/widgets/gps-sample-card/*`, `etc/servers/work-server/src/routes/notification.ts`, `src/app/main/notificationApi.ts`에서 지도 내장 UI, GPS 레이어, 웹푸시 알림 경로를 구축했습니다.
|
||||
- `src/widgets/text-memo-widget/*`, `src/widgets/core/WidgetShell.tsx`, `src/widgets/registry.ts`, `src/index.ts`에서 메모 위젯을 추가하고 위젯 카드 래퍼 옵션과 샘플 연결을 마쳤습니다.
|
||||
- `src/features/planBoard/PlanBoardPage.tsx`, `src/features/planBoard/quickFilters.ts`, `src/app/main/MainHeader.tsx`, `src/app/main/MainView.tsx`, `src/app/main/MainContent.tsx`에서 plan 빠른 필터/로컬 패이징/윈도우 열기/메뉴 정리를 반영했습니다.
|
||||
- `etc/servers/work-server/src/services/plan-service.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`에서 `main` 일괄 반영 요청과 배치 처리 로직을 추가했습니다.
|
||||
- `src/App.tsx`, `src/styles.css`에 앱 시작 로딩 오버레이와 로그 스트림 애니메이션을 넣었습니다.
|
||||
- `AGENTS.md`에 `release` 반영 직후 같은 브랜치에서 작업을 이어가지 않도록 하는 안전 체크리스트와 꼬임 위험 신호를 추가했습니다.
|
||||
- 오늘 업무일지 문서와 스크린샷 자산은 현재 날짜 기준 Git 이력과 실제 캡처 결과로 다시 정리했습니다.
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||
|
||||
- `Play > Layout` 전체 화면입니다. 저장 레이아웃/편집/분할/섹션 선택 흐름을 한 번에 확인할 수 있습니다.
|
||||
|
||||

|
||||
|
||||
- `GPS Sample Widget` 부분 캡처입니다. 오늘 추가된 지도/거점/반경 위젯 단위 증적입니다.
|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `src/views/play/LayoutPlaygroundView.tsx`
|
||||
|
||||
- `Layout Editor`의 핵심 화면으로, 컴포넌트/위젯 바인딩과 저장 레이아웃 상세 플로우를 대부분 이 파일에서 확장했습니다.
|
||||
|
||||
```diff
|
||||
+import { SearchCommandModal, type SearchKeywordOption } from '../../components/search';
|
||||
+import { componentSampleEntries, widgetSampleEntries } from '../../app/manifests/samples.manifest';
|
||||
+type LayoutComponentBinding = {
|
||||
+ optionId: string;
|
||||
+ label: string;
|
||||
+ description?: string;
|
||||
+ keywords: string[];
|
||||
+};
|
||||
+type LayoutPlaygroundViewProps = {
|
||||
+ savedLayoutViewId?: string | null;
|
||||
+ showSavedLayoutsOnly?: boolean;
|
||||
+ onSavedLayoutsChange?: (layouts: SavedLayoutRecord[]) => void;
|
||||
+};
|
||||
- title: string;
|
||||
- description: string;
|
||||
+ showHideAction: boolean;
|
||||
+ componentBinding: LayoutComponentBinding | null;
|
||||
```
|
||||
|
||||
### 파일 2: `src/views/play/layoutStorage.ts`
|
||||
|
||||
- 레이아웃 저장소를 `IndexedDB`에서 `work-db` API 기반 서버 저장 방식으로 전환했습니다.
|
||||
|
||||
```diff
|
||||
+import { appendClientIdHeader } from '../../app/main/clientIdentity';
|
||||
+import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
|
||||
-const DATABASE_NAME = 'play-layout-db';
|
||||
-const STORE_NAME = 'saved-layouts';
|
||||
-const DATABASE_VERSION = 1;
|
||||
+const WORK_SERVER_TIMEOUT_MS = 8000;
|
||||
+const PLAY_LAYOUTS_TABLE = 'play_layouts';
|
||||
+class LayoutStorageError extends Error {
|
||||
+ status: number;
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 3: `src/components/search/SearchCommandModal.tsx`
|
||||
|
||||
- 검색 모달 제목/설명 커스터마이징, 모바일 축약 문구, 선택 잠금 로직을 추가했습니다.
|
||||
|
||||
```diff
|
||||
+ title?: string;
|
||||
+ description?: string;
|
||||
+ placeholder?: string;
|
||||
+ submitHint?: string;
|
||||
+ const selectionLockRef = useRef(false);
|
||||
+ const [isMobileViewport, setIsMobileViewport] = useState(() => {
|
||||
+ return window.innerWidth <= 768;
|
||||
+ });
|
||||
+ const submitOption = (option: SearchKeywordOption | undefined) => {
|
||||
+ if (!option || selectionLockRef.current) {
|
||||
+ return;
|
||||
+ }
|
||||
+ selectionLockRef.current = true;
|
||||
+ onSelectOption(option);
|
||||
+ onClose();
|
||||
+ };
|
||||
```
|
||||
|
||||
### 파일 4: `src/components/window/WindowUI.tsx`
|
||||
|
||||
- `Window UI`는 닫기 액션, 모바일 리사이즈 개선, 보이는 viewport 기준 더블탭/더블클릭 확장 로직을 받았습니다.
|
||||
|
||||
```diff
|
||||
-import { FullscreenExitOutlined, FullscreenOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
+import { CloseOutlined, FullscreenExitOutlined, FullscreenOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
+type ResizeTapRecord = {
|
||||
+ direction: ResizeDirection;
|
||||
+ at: number;
|
||||
+ x: number;
|
||||
+ y: number;
|
||||
+};
|
||||
+const DOUBLE_TAP_DELAY = 320;
|
||||
+const DOUBLE_TAP_MOVE_TOLERANCE = 18;
|
||||
+const RESIZE_MOVE_THRESHOLD = 4;
|
||||
+function getVisibleParentBounds(element: HTMLDivElement | null) {
|
||||
+ const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 5: `src/components/embeddedMap/EmbeddedMapUI.tsx`
|
||||
|
||||
- 새 내장 지도 UI를 추가하고, 좌표/반경/보조 마커를 OSM iframe 위에 오버레이로 표시하도록 구성했습니다.
|
||||
|
||||
```diff
|
||||
+export type EmbeddedMapUIProps = {
|
||||
+ latitude: number;
|
||||
+ longitude: number;
|
||||
+ radiusMeters?: number;
|
||||
+ lockViewport?: boolean;
|
||||
+ secondaryMarker?: {
|
||||
+ latitude: number;
|
||||
+ longitude: number;
|
||||
+ label?: string;
|
||||
+ } | null;
|
||||
+ overlay?: ReactNode;
|
||||
+};
|
||||
+function createEmbedUrl(bounds: Bounds, latitude: number, longitude: number) {
|
||||
+ return `https://www.openstreetmap.org/export/embed.html?...`;
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 6: `src/widgets/gps-sample-card/GpsSampleWidget.tsx`
|
||||
|
||||
- GPS on/off, 현재 좌표, 거점 저장, 반경, In/Out 푸시 알림, 지도 표시를 한 위젯으로 묶었습니다.
|
||||
|
||||
```diff
|
||||
+import {
|
||||
+ AimOutlined,
|
||||
+ BellOutlined,
|
||||
+ EnvironmentOutlined,
|
||||
+ DeleteOutlined,
|
||||
+ RadarChartOutlined,
|
||||
+} from '@ant-design/icons';
|
||||
+import { EmbeddedMapUI } from '../../components/embeddedMap';
|
||||
+import { useGpsLayer } from '../../layer';
|
||||
+const selectedAnchor = anchors.find((anchor) => anchor.id === selectedAnchorId) ?? null;
|
||||
+const distanceToSelectedAnchor =
|
||||
+ selectedAnchor && currentPosition
|
||||
+ ? calculateDistanceMeters(...)
|
||||
+ : null;
|
||||
```
|
||||
|
||||
### 파일 7: `src/widgets/text-memo-widget/TextMemoWidget.tsx`
|
||||
|
||||
- `Text Memo Widget`을 신규 추가하고 최근 6개 메모 저장/불러오기/삭제와 시트형 편집 UI를 구성했습니다.
|
||||
|
||||
```diff
|
||||
+const STORAGE_KEY = 'ai-code-app:text-memo-widget';
|
||||
+const MAX_SAVED_NOTES = 6;
|
||||
+type SavedNote = {
|
||||
+ id: string;
|
||||
+ body: string;
|
||||
+ createdAt: string;
|
||||
+};
|
||||
+export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(function TextMemoWidget(
|
||||
+ { cardWrapper },
|
||||
+ ref,
|
||||
+) {
|
||||
```
|
||||
|
||||
### 파일 8: `src/features/planBoard/PlanBoardPage.tsx`
|
||||
|
||||
- plan 목록 로컬 패이징, 빠른 필터, 최대화 뒤 아이콘-only 돌아가기, 긴 본문 접힘 흐름을 오늘 기준으로 누적 반영했습니다.
|
||||
|
||||
```diff
|
||||
+ const [currentPage, setCurrentPage] = useState(1);
|
||||
+ const pageSize = 10;
|
||||
+ const pagedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
+ useEffect(() => {
|
||||
+ setCurrentPage(1);
|
||||
+ }, [filter, searchQuery]);
|
||||
- label: '요약',
|
||||
+ label: '작업란',
|
||||
```
|
||||
|
||||
### 파일 9: `src/app/main/MainHeader.tsx`
|
||||
|
||||
- 설정 메뉴에 `release 상태 작업`, `자동화 실패` 바로가기를 추가하고 현재 건수를 같이 보여주도록 확장했습니다.
|
||||
|
||||
```diff
|
||||
+import { isAutomationFailedItem, isReleasePendingMainItem } from '../../features/planBoard/quickFilters';
|
||||
+const [planShortcutCounts, setPlanShortcutCounts] = useState({
|
||||
+ releasePendingMain: 0,
|
||||
+ automationFailed: 0,
|
||||
+});
|
||||
+void fetchPlanItems('all')
|
||||
+ .then((items) => {
|
||||
+ setPlanShortcutCounts({
|
||||
+ releasePendingMain: items.filter(isReleasePendingMainItem).length,
|
||||
+ automationFailed: items.filter(isAutomationFailedItem).length,
|
||||
+ });
|
||||
+ })
|
||||
```
|
||||
|
||||
### 파일 10: `src/app/main/MainView.tsx`
|
||||
|
||||
- 상단/사이드 메뉴 구조를 `release 반영 상태`, `APIs / Widgets`, `Layout Editor`, 저장 레이아웃 메뉴까지 포괄하도록 재정리했습니다.
|
||||
|
||||
```diff
|
||||
- 'in-progress': '작업중',
|
||||
+ 'in-progress': '자동화 대기 / 작업 중',
|
||||
- error: '오류',
|
||||
+ error: '오류 (작업 완료 전)',
|
||||
+ release: 'release 반영 상태',
|
||||
+const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
|
||||
+function resolvePlanQuickFilterMenu(filter: PlanQuickFilter): Extract<PlanSidebarKey, 'release' | 'error'> {
|
||||
+ return filter === 'release-pending-main' ? 'release' : 'error';
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 11: `src/app/main/MainContent.tsx`
|
||||
|
||||
- 검색 결과를 `Window UI` 창으로 여는 흐름, 샘플 숨김 목록, `Plan`/저장 레이아웃 렌더 분기를 추가했습니다.
|
||||
|
||||
```diff
|
||||
+import { useMemo, useState, type ReactNode } from 'react';
|
||||
+import { WindowUI, type WindowFrame } from '../../components/window';
|
||||
+import { useSearchLayer } from '../../layer';
|
||||
+const HIDDEN_COMPONENT_IDS = ['search-command-modal', 'window-ui'];
|
||||
+const { windowSelections, clearWindowSelection } = useSearchLayer();
|
||||
+const [windowFrames, setWindowFrames] = useState<Record<string, WindowFrame>>({});
|
||||
+const [windowZIndexes, setWindowZIndexes] = useState<Record<string, number>>({});
|
||||
```
|
||||
|
||||
### 파일 12: `src/App.tsx`
|
||||
|
||||
- 앱 시작 직후 1.9초 동안 보여주는 풀스크린 로딩 오버레이와 로그 스트림 UI를 추가했습니다.
|
||||
|
||||
```diff
|
||||
+const INITIAL_LOADING_LOGS = [
|
||||
+ 'BOOT SEQUENCE :: app shell warmup',
|
||||
+ 'CONFIG SYNC :: workspace profile applied',
|
||||
+ 'SESSION LINK :: reconnecting realtime channel',
|
||||
+ 'MODULE CHECK :: dashboard widgets online',
|
||||
+ 'READY SIGNAL :: rendering main viewport',
|
||||
+];
|
||||
+const [showInitialLoading, setShowInitialLoading] = useState(true);
|
||||
+window.setTimeout(() => {
|
||||
+ setShowInitialLoading(false);
|
||||
+}, 1900);
|
||||
```
|
||||
|
||||
### 파일 13: `etc/servers/work-server/src/services/plan-service.ts`
|
||||
|
||||
- `main` 반영 요청/클레임/완료 처리 모두를 `release_target` 기준 배치 처리로 바꿨습니다.
|
||||
|
||||
```diff
|
||||
- isMainRetry ? 'main 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.',
|
||||
+ isMainRetry ? 'main 일괄 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.',
|
||||
+ const pendingRows = await db(PLAN_TABLE)
|
||||
+ .select('id')
|
||||
+ .where({ status: '릴리즈완료', release_target: releaseTarget });
|
||||
- .where({ id })
|
||||
+ .whereIn('id', targetIds.length > 0 ? targetIds : [id])
|
||||
+ message: `${releaseTarget} 브랜치 기준으로 ${Math.max(targetIds.length, 1)}건 main 일괄 반영을 요청했습니다.`,
|
||||
```
|
||||
|
||||
### 파일 14: `AGENTS.md`
|
||||
|
||||
- Git 안전 규칙을 보강해 `release` 반영 뒤 같은 브랜치에서 추가 작업을 이어가는 패턴을 금지하고 점검 절차를 명시했습니다.
|
||||
|
||||
```diff
|
||||
+* `release` 반영 상태에서 현재 브랜치 그대로 추가 작업 진행
|
||||
+* `release` 반영이 끝난 뒤 추가 수정 요청이 들어오면, 반드시 새 브랜치에서 다시 시작한다
|
||||
+## 안전 점검 체크리스트
|
||||
+1. 지금 반영할 변경이 `release`에 먼저 들어갔는지 확인
|
||||
+2. 추가 요청이라면 새 `feature/*` 또는 `hotfix/*` 브랜치에서 작업 중인지 확인
|
||||
+## 꼬임 위험 신호
|
||||
+* `release` 반영 직후 추가 요청을 현재 브랜치에서 바로 이어서 처리하는 경우
|
||||
```
|
||||
|
||||
### 파일 15: `docs/assets/worklogs/2026-04-09/feature-play-layout.png`
|
||||
|
||||
- 오늘 대표 전체 화면 스크린샷입니다.
|
||||
|
||||
```diff
|
||||
Binary file added: docs/assets/worklogs/2026-04-09/feature-play-layout.png
|
||||
```
|
||||
|
||||
### 파일 16: `docs/assets/worklogs/2026-04-09/widget-gps-sample.png`
|
||||
|
||||
- 오늘 위젯 단위 부분 스크린샷입니다.
|
||||
|
||||
```diff
|
||||
Binary file added: docs/assets/worklogs/2026-04-09/widget-gps-sample.png
|
||||
```
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-09 00:00' --until='2026-04-09 23:59:59' --stat --oneline --decorate
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-09 00:00' --until='2026-04-09 23:59:59' --name-status --format='commit %H%nAuthor: %an%nDate: %ad%nSubject: %s%n' --date=iso
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff --stat b70ce0c2549542703967b73aedf8d249050a40f7..HEAD
|
||||
npm run dev -- --host 127.0.0.1 --port 4173
|
||||
CAPTURE_BASE_URL=http://127.0.0.1:4173 node scripts/capture-feature-screenshot.mjs play-layout 2026-04-09
|
||||
node --input-type=module # GPS Sample Widget 부분 캡처
|
||||
npm run build:app
|
||||
```
|
||||
|
||||
## 변경/신규 파일 (전체, 중복 제거, Git 기록 기준 + 오늘 업무일지 산출물)
|
||||
|
||||
- A src/components/dashboard/multiProgress/samples/BaseSample.tsx
|
||||
- A src/components/dashboard/progress/samples/BaseSample.tsx
|
||||
- A src/components/embeddedMap/EmbeddedMapUI.css
|
||||
- A src/components/embeddedMap/EmbeddedMapUI.tsx
|
||||
- A src/components/embeddedMap/index.ts
|
||||
- A src/components/embeddedMap/samples/BaseSample.tsx
|
||||
- A src/components/embeddedMap/samples/Sample.tsx
|
||||
- A src/components/inputs/checkCombo/samples/BaseSample.tsx
|
||||
- A src/components/inputs/composite/multiInput/samples/BaseSample.tsx
|
||||
- A src/components/inputs/popup/samples/BaseSample.tsx
|
||||
- A src/components/inputs/primitives/input/samples/BaseSample.tsx
|
||||
- A src/components/inputs/select/samples/BaseSample.tsx
|
||||
- A src/components/inputs/specialized/buttonEditableInput/samples/BaseSample.tsx
|
||||
- A src/components/inputs/specialized/emailInput/samples/BaseSample.tsx
|
||||
- A src/components/markdownPreview/samples/MarkdownPreviewCardBaseSample.tsx
|
||||
- A src/components/markdownPreview/samples/MarkdownPreviewContentBaseSample.tsx
|
||||
- A src/components/markdownPreview/samples/MarkdownPreviewListBaseSample.tsx
|
||||
- A src/components/navigation/samples/FolderTreeNavBaseSample.tsx
|
||||
- A src/components/navigation/samples/SectionMenuLayoutBaseSample.tsx
|
||||
- A src/components/previewer/samples/BaseSample.tsx
|
||||
- A src/components/previewer/samples/CodexDiffBaseSample.tsx
|
||||
- A src/components/search/samples/BaseSample.tsx
|
||||
- A src/components/status-badge/samples/BaseSample.tsx
|
||||
- A src/components/window/samples/BaseSample.tsx
|
||||
- A src/features/planBoard/quickFilters.ts
|
||||
- A src/layer/gps/context/GpsLayerContext.tsx
|
||||
- A src/layer/gps/hooks/useGpsLayer.ts
|
||||
- A src/layer/gps/index.ts
|
||||
- A src/layer/gps/types/index.ts
|
||||
- A src/widgets/gps-sample-card/GpsSampleWidget.css
|
||||
- A src/widgets/gps-sample-card/GpsSampleWidget.tsx
|
||||
- A src/widgets/gps-sample-card/index.ts
|
||||
- A src/widgets/gps-sample-card/samples/Sample.tsx
|
||||
- A src/widgets/text-memo-widget/TextMemoWidget.css
|
||||
- A src/widgets/text-memo-widget/TextMemoWidget.tsx
|
||||
- A src/widgets/text-memo-widget/index.ts
|
||||
- A src/widgets/text-memo-widget/samples/Sample.tsx
|
||||
- M AGENTS.md
|
||||
- M docs/components/window-ui.md
|
||||
- M etc/servers/work-server/src/routes/notification.ts
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M package-lock.json
|
||||
- M package.json
|
||||
- M src/App.tsx
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M src/app/main/MainView.tsx
|
||||
- M src/app/main/ReleasePendingMainModal.tsx
|
||||
- M src/app/main/appConfig.ts
|
||||
- M src/app/main/notificationApi.ts
|
||||
- M src/app/main/types.ts
|
||||
- M src/components/dashboard/multiProgress/samples/Sample.tsx
|
||||
- M src/components/dashboard/progress/samples/Sample.tsx
|
||||
- M src/components/embeddedMap/EmbeddedMapUI.css
|
||||
- M src/components/embeddedMap/EmbeddedMapUI.tsx
|
||||
- M src/components/embeddedMap/samples/Sample.tsx
|
||||
- M src/components/inputs/checkCombo/samples/Sample.tsx
|
||||
- M src/components/inputs/composite/multiInput/samples/Sample.tsx
|
||||
- M src/components/inputs/popup/samples/Sample.tsx
|
||||
- M src/components/inputs/primitives/input/samples/Sample.tsx
|
||||
- M src/components/inputs/select/samples/Sample.tsx
|
||||
- M src/components/inputs/specialized/buttonEditableInput/samples/Sample.tsx
|
||||
- M src/components/inputs/specialized/emailInput/samples/Sample.tsx
|
||||
- M src/components/previewer/samples/CodexDiffSample.tsx
|
||||
- M src/components/previewer/samples/Sample.tsx
|
||||
- M src/components/search/SearchCommandModal.tsx
|
||||
- M src/components/status-badge/samples/Sample.tsx
|
||||
- M src/components/window/WindowUI.css
|
||||
- M src/components/window/WindowUI.tsx
|
||||
- M src/components/window/samples/Sample.tsx
|
||||
- M src/components/window/types/window.ts
|
||||
- M src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- M src/features/planBoard/index.ts
|
||||
- M src/index.ts
|
||||
- M src/layer/gps/context/GpsLayerContext.tsx
|
||||
- M src/layer/index.ts
|
||||
- M src/layer/search/context/SearchLayerContext.tsx
|
||||
- M src/layer/search/types/index.ts
|
||||
- M src/main.tsx
|
||||
- M src/styles.css
|
||||
- M src/views/play/LayoutPlaygroundView.tsx
|
||||
- M src/views/play/layoutStorage.ts
|
||||
- M src/widgets/api-sample-card/ApiSampleCardWidget.tsx
|
||||
- M src/widgets/api-sample-card/samples/Sample.tsx
|
||||
- M src/widgets/core/WidgetShell.tsx
|
||||
- M src/widgets/core/index.ts
|
||||
- M src/widgets/core/types/widget.ts
|
||||
- M src/widgets/dashboard-report-card/DashboardReportCardWidget.tsx
|
||||
- M src/widgets/dashboard-report-card/samples/TmsDeliveryFlowSample.tsx
|
||||
- M src/widgets/dashboard-report-card/samples/TmsDeliveryMetricsSample.tsx
|
||||
- M src/widgets/dashboard-report-card/samples/WmsInboundOutboundSample.tsx
|
||||
- M src/widgets/dashboard-report-card/samples/WmsInventoryTrendSample.tsx
|
||||
- M src/widgets/gps-sample-card/GpsSampleWidget.tsx
|
||||
- M src/widgets/gps-sample-card/samples/Sample.tsx
|
||||
- M src/widgets/registry.ts
|
||||
- M src/widgets/text-memo-widget/TextMemoWidget.css
|
||||
- M src/widgets/text-memo-widget/TextMemoWidget.tsx
|
||||
- A docs/worklogs/2026-04-09.md
|
||||
- A docs/assets/worklogs/2026-04-09/feature-play-layout.png
|
||||
- A docs/assets/worklogs/2026-04-09/widget-gps-sample.png
|
||||
357
docs/worklogs/2026-04-10.md
Executable file
357
docs/worklogs/2026-04-10.md
Executable file
@@ -0,0 +1,357 @@
|
||||
# 2026-04-10 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- `react-router-dom` 기반 앱 셸로 메인 화면을 재구성하고, 상단 메뉴와 사이드바 선택이 URL 경로와 직접 연결되도록 정리했습니다.
|
||||
- `AppShell`, `MainLayout`, `routes.tsx`를 추가해 `Docs / APIs / Plans / Chat / Play`를 페이지 단위로 분리했습니다.
|
||||
- `MainContent`는 공통 창 레이어와 콘텐츠 컨테이너 역할만 남기고, 실제 화면 렌더링은 각 페이지 컴포넌트로 이동시켰습니다.
|
||||
- 저장 레이아웃 상세 화면은 `savedLayoutViewId` 전용 fit 모드로 보정해, 상단 바를 포함한 전체 구성을 한 화면 안에 맞춰 보이도록 수정했습니다.
|
||||
- `Text Memo Widget`의 삭제 버튼이 실제로 동작하도록 `Modal.useModal()` 기반 확인 플로우를 연결했습니다.
|
||||
- 오늘 증적용으로 `APIs / Widgets` 전체 화면 1장과 `Text Memo Widget` 부분 화면 1장을 `docs/assets/worklogs/2026-04-10/`에 저장했습니다.
|
||||
- 오늘자 작업일지 문서를 새로 작성하고, 소스 탭에서 `전체소스 / diff` 전환에 대응할 수 있도록 파일별 raw diff 근거와 전체 변경 파일 목록을 정리했습니다.
|
||||
|
||||
## 이슈 및 해결
|
||||
|
||||
- 기존 `MainView` 한 파일에 메뉴 상태, 검색 옵션, 문서/플랜/채팅/플레이 렌더링이 모두 얽혀 있어 경로 기반 탐색과 확장이 어려웠습니다.
|
||||
- `AppShell + MainLayout + pages/* + routes.tsx` 구조로 나누고, 라우팅 파생 상태는 `parseRoute()`와 `MainLayoutContext`에서만 계산하도록 정리했습니다.
|
||||
- 저장 레이아웃 전용 화면은 내부 콘텐츠 높이와 스크롤 상태에 따라 미리보기가 잘리거나 빈 여백이 생겼습니다.
|
||||
- `useLayoutEffect`, `ResizeObserver`, `MutationObserver`로 실제 렌더 크기를 다시 재고 `savedLayoutFitScale`을 계산해 fit 렌더를 안정화했습니다.
|
||||
- 메모 위젯 삭제 버튼은 정적 모달 호출과 컨텍스트 미연결 상태 때문에 클릭해도 확인창이 뜨지 않는 문제가 있었습니다.
|
||||
- `Modal.useModal()`을 도입하고 `modalContextHolder`를 위젯 루트에 넣어 저장 메모와 작성 중 초안 모두 삭제 가능하게 바꿨습니다.
|
||||
- 이 작업 환경에서는 Git이 `dubious ownership`으로 차단되고 글로벌 `safe.directory` 등록도 실패했습니다.
|
||||
- 글로벌 설정 대신 모든 Git 조회에 `git -c safe.directory=/workspace/auto_codex/repo ...`를 사용해 증적 수집을 계속 진행했습니다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 메인 앱 진입은 상태 기반 조건 분기보다 URL 라우팅을 우선 기준으로 유지합니다.
|
||||
- 공통 레이아웃 상태는 `MainLayoutContext`로 전달하고, 페이지별 렌더링 책임은 `pages/*`로 분리합니다.
|
||||
- 저장 레이아웃 상세 뷰는 스크롤보다 fit 우선 미리보기 규칙을 사용합니다.
|
||||
- 메모 삭제는 즉시 삭제하지 않고 항상 확인 모달을 거치게 유지합니다.
|
||||
- 오늘 대표 스크린샷은 `APIs / Widgets` 전체 화면, 부분 스크린샷은 `Text Memo Widget`으로 고정합니다.
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- 오늘 Git 이력은 `WindowUI` 최소화/헤더 정리, 메모 삭제 플로우 복구, 저장 레이아웃 fit 모드 보정, 앱 셸 라우팅 분리 순서로 누적됐고 최종적으로 `release -> main` 동기화까지 진행됐습니다.
|
||||
- 라우팅 개편은 `MainView`에 모여 있던 메뉴/문서/플랜/채팅/플레이 조건 분기를 걷어내고, `AppShell` 아래에서 `MainLayout`이 공통 상태를 제공한 뒤 각 페이지가 자기 화면만 렌더링하도록 재조정한 작업입니다.
|
||||
- 검색 옵션 빌드도 같은 라우팅 구조를 따라가도록 재작성해서, 검색 결과가 단순 상태 변경이 아니라 실제 경로 전환과 포커스 이동을 함께 수행하도록 맞췄습니다.
|
||||
- 저장 레이아웃 보정은 단순 CSS 스케일링이 아니라 실제 콘텐츠 크기를 측정해 viewport 안에 맞추는 방식으로 바꿨고, 관련 overflow 규칙도 같이 조정했습니다.
|
||||
- 메모 위젯은 삭제 버튼 활성화와 확인 모달 흐름을 분리해, 저장된 메모와 작성 중 초안을 같은 UX 안에서 처리하도록 정리했습니다.
|
||||
- 오늘 작업일지 작성 단계에서는 Git 로그, diff, 빌드 결과, 실제 Playwright 캡처를 함께 확인해서 문서와 스크린샷 링크를 최신 상태로 맞췄습니다.
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||
|
||||
- `APIs / Widgets` 전체 화면 캡처입니다. 라우팅 기반 앱 셸 아래에서 위젯 갤러리 전체 내역이 한 번에 보이도록 full-page로 저장했습니다.
|
||||
|
||||

|
||||
|
||||
- `Text Memo Widget` 부분 캡처입니다. 오늘 복구한 메모 입력/삭제 흐름을 위젯 단위로 바로 확인할 수 있도록 별도로 남겼습니다.
|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `src/app/main/AppShell.tsx`
|
||||
|
||||
- 라우터 엔트리와 페이지 분리의 기준점입니다.
|
||||
|
||||
```diff
|
||||
+import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
+import { MainLayout } from './layout/MainLayout';
|
||||
+import { ApisPage } from './pages/ApisPage';
|
||||
+import { ChatPage } from './pages/ChatPage';
|
||||
+import { DocsPage } from './pages/DocsPage';
|
||||
+import { PlansPage } from './pages/PlansPage';
|
||||
+import { PlayPage } from './pages/PlayPage';
|
||||
+<Route path="/" element={<MainLayout />}>
|
||||
+ <Route path="docs/:folder" element={<DocsPage />} />
|
||||
+ <Route path="apis/:section" element={<ApisPage />} />
|
||||
+ <Route path="plans/:section" element={<PlansPage />} />
|
||||
+ <Route path="chat/:section" element={<ChatPage />} />
|
||||
+ <Route path="play/layout-record/:layoutId" element={<PlayPage />} />
|
||||
+</Route>
|
||||
```
|
||||
|
||||
### 파일 2: `src/app/main/layout/MainLayout.tsx`
|
||||
|
||||
- 기존 `MainView`의 거대한 상태 조합을 라우팅 기반 공통 레이아웃으로 옮겼습니다.
|
||||
|
||||
```diff
|
||||
+import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
+function parseRoute(pathname: string) {
|
||||
+ if (top === 'docs') { ... }
|
||||
+ if (top === 'apis' && (first === 'components' || first === 'widgets')) { ... }
|
||||
+ if (top === 'play' && first === 'layout-record' && second) { ... }
|
||||
+}
|
||||
+const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
|
||||
+const layoutData = useMainLayoutData();
|
||||
+<MainLayoutContextProvider value={...}>
|
||||
+ <MainHeader ... />
|
||||
+ <MainSidebar ... />
|
||||
+ <MainContent contentExpanded={contentExpanded} onToggleContentExpanded={...}>
|
||||
+ <Outlet />
|
||||
+ </MainContent>
|
||||
+</MainLayoutContextProvider>
|
||||
```
|
||||
|
||||
### 파일 3: `src/app/main/routes.tsx`
|
||||
|
||||
- 메뉴 라벨, 경로 빌더, 사이드바 공개 타입을 한곳에 모았습니다.
|
||||
|
||||
```diff
|
||||
+export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
|
||||
+export type PlanSectionKey = PlanFilterStatus | 'release' | 'charts' | 'history';
|
||||
+export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
||||
+export function buildDocsPath(folder = DOCS_DEFAULT_FOLDER) {
|
||||
+ return `/docs/${folder}`;
|
||||
+}
|
||||
+export function buildPlansPath(section: PlanSectionKey = 'all') {
|
||||
+ return `/plans/${section}`;
|
||||
+}
|
||||
+export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: string }>): MenuProps['items'] {
|
||||
+ return [{ key: 'play-group', children: [...] }];
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 4: `src/app/main/MainContent.tsx`
|
||||
|
||||
- 공통 창 레이어만 남기고 화면별 조건 분기를 페이지 컴포넌트로 밀어냈습니다.
|
||||
|
||||
```diff
|
||||
-import { Button, Card, Layout, Modal, Space, Typography } from 'antd';
|
||||
+import { Button, Layout, Modal, Space, Typography } from 'antd';
|
||||
-import { MarkdownPreviewCard } from '../../components/markdownPreview';
|
||||
+import { useMainLayoutContext } from './layout/MainLayoutContext';
|
||||
-export function MainContent({ activeTopMenu, selectedApiMenu, selectedDocsMenu, ... }: MainContentProps) {
|
||||
+export function MainContent({ contentExpanded, onToggleContentExpanded, children }: MainContentProps) {
|
||||
+ const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, initialSelectedPlanId, initialSelectedWorkId } =
|
||||
+ useMainLayoutContext();
|
||||
- <div className={activeTopMenu === 'chat' ? 'app-main-layout app-main-layout--single' : 'app-main-layout'}>
|
||||
- {activeTopMenu === 'docs' ? ... : activeTopMenu === 'plans' ? ... : ...}
|
||||
- </div>
|
||||
+ <div className="app-main-layout">{children}</div>
|
||||
```
|
||||
|
||||
### 파일 5: `src/app/main/MainSidebar.tsx`
|
||||
|
||||
- 소개 영역과 API 메뉴 선택 타입을 공통 라우팅 구조에 맞게 정리했습니다.
|
||||
|
||||
```diff
|
||||
+ introColor,
|
||||
+ introTag,
|
||||
+ introDescription,
|
||||
- const activeTagColor = isDocsGroup ? 'gold' : activeTopMenu === 'play' ? 'cyan' : 'green';
|
||||
- <Tag color={activeTagColor}>...</Tag>
|
||||
+ <Tag color={introColor}>{introTag}</Tag>
|
||||
+ <Text type="secondary">{introDescription}</Text>
|
||||
- onSelectApiMenu(key);
|
||||
+ onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']);
|
||||
```
|
||||
|
||||
### 파일 6: `src/app/main/layout/MainLayoutContext.ts`
|
||||
|
||||
- 페이지와 공통 레이어가 같은 상태를 공유하도록 컨텍스트를 신설했습니다.
|
||||
|
||||
```diff
|
||||
+export type MainLayoutContextValue = {
|
||||
+ topMenu: TopMenuKey;
|
||||
+ selectedDocsMenu: string;
|
||||
+ selectedApiMenu: ApiSectionKey;
|
||||
+ selectedPlanMenu: PlanSectionKey;
|
||||
+ selectedChatMenu: ChatSectionKey;
|
||||
+ selectedPlayMenu: PlaySidebarKey;
|
||||
+ searchOptions: SearchKeywordOption[];
|
||||
+};
|
||||
+const MainLayoutContext = createContext<MainLayoutContextValue | null>(null);
|
||||
+export function useMainLayoutContext() { ... }
|
||||
```
|
||||
|
||||
### 파일 7: `src/app/main/layout/buildSearchOptions.ts`
|
||||
|
||||
- 검색 결과가 상태 변경이 아니라 실제 라우팅 전환을 수행하도록 재작성했습니다.
|
||||
|
||||
```diff
|
||||
+import { buildApisPath, buildChatPath, buildDocsPath, buildPlansPath, buildPlayPath } from '../routes';
|
||||
+onSelect: () => {
|
||||
+ requestPlanQuickFilter(null);
|
||||
+ navigateTo(buildApisPath('widgets'));
|
||||
+ setFocusedComponentId(null);
|
||||
+},
|
||||
+onSelect: () => {
|
||||
+ requestPlanQuickFilter('release-pending-main');
|
||||
+ navigateTo(buildPlansPath('release'));
|
||||
+ setFocusedComponentId(null);
|
||||
+},
|
||||
+onSelect: () => {
|
||||
+ navigateTo(buildDocsPath(document.folder));
|
||||
+ setFocusedComponentId(`doc:${document.id}`);
|
||||
+ scrollToElement(`document-preview-${document.id}`);
|
||||
+},
|
||||
```
|
||||
|
||||
### 파일 8: `src/app/main/pages/ApisPage.tsx`
|
||||
|
||||
- APIs 화면 렌더링 책임을 페이지 단위로 분리했습니다.
|
||||
|
||||
```diff
|
||||
+export function ApisPage() {
|
||||
+ const { selectedApiMenu, componentSampleEntries, widgetSampleEntries } = useMainLayoutContext();
|
||||
+ return (
|
||||
+ <div className="app-main-panel">
|
||||
+ <Card title={selectedApiMenu === 'components' ? 'APIs / Components' : 'APIs / Widgets'} className="app-main-card" bordered={false}>
|
||||
+ {selectedApiMenu === 'components' ? (
|
||||
+ <ComponentSamplesLayout entries={componentSampleEntries} excludeComponentIds={HIDDEN_COMPONENT_IDS} />
|
||||
+ ) : (
|
||||
+ <SampleWidgetsLayout entries={widgetSampleEntries} />
|
||||
+ )}
|
||||
+ </Card>
|
||||
+ </div>
|
||||
+ );
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 9: `src/views/play/LayoutPlaygroundView.tsx`
|
||||
|
||||
- 저장 레이아웃 전용 fit 모드와 실제 크기 측정 로직을 추가했습니다.
|
||||
|
||||
```diff
|
||||
-import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
+import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
+const isSavedLayoutFitMode = Boolean(savedLayoutViewId);
|
||||
+const [savedLayoutFitScale, setSavedLayoutFitScale] = useState(1);
|
||||
+const [savedLayoutFitSize, setSavedLayoutFitSize] = useState<{ width: number; height: number } | null>(null);
|
||||
+const savedLayoutFitViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
+const savedLayoutFitContentRef = useRef<HTMLDivElement | null>(null);
|
||||
+useLayoutEffect(() => {
|
||||
+ const nextScale = Math.min(1, availableWidth / width, availableHeight / height);
|
||||
+ setSavedLayoutFitScale(...);
|
||||
+}, [savedLayoutViewId, selectedSavedLayoutRecord, sampleEntries.length]);
|
||||
+style={{ transform: `scale(${savedLayoutFitScale})`, width: `${savedLayoutFitSize.width}px` }}
|
||||
+previewSurface: isSavedLayoutFitMode,
|
||||
```
|
||||
|
||||
### 파일 10: `src/styles.css`
|
||||
|
||||
- fit 모드에서 overflow와 스크롤바를 억제하는 전용 스타일을 추가했습니다.
|
||||
|
||||
```diff
|
||||
+.layout-playground__fullscreen-shell--saved-fit {
|
||||
+ min-height: 0;
|
||||
+ overflow: hidden;
|
||||
+}
|
||||
+.layout-playground__saved-fit-viewport {
|
||||
+ display: flex;
|
||||
+ align-items: center;
|
||||
+ justify-content: center;
|
||||
+ overflow: hidden;
|
||||
+}
|
||||
+.layout-playground__saved-detail--fit * {
|
||||
+ scrollbar-width: none;
|
||||
+}
|
||||
+.layout-playground__saved-detail--fit *::-webkit-scrollbar {
|
||||
+ width: 0;
|
||||
+ height: 0;
|
||||
+}
|
||||
```
|
||||
|
||||
### 파일 11: `src/widgets/text-memo-widget/TextMemoWidget.tsx`
|
||||
|
||||
- 삭제 버튼 비활성 상태를 해제하고, 확인 모달을 거쳐 실제 삭제되도록 바꿨습니다.
|
||||
|
||||
```diff
|
||||
-import { Button, Empty, Input, message } from 'antd';
|
||||
+import { Button, Empty, Input, Modal, message } from 'antd';
|
||||
+const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
-const handleDelete = () => {
|
||||
- void messageApi.info('삭제 기능은 현재 비활성화되어 있습니다.');
|
||||
-};
|
||||
+const handleDelete = () => {
|
||||
+ if (!selectedNote && !hasDraft) {
|
||||
+ return;
|
||||
+ }
|
||||
+ void modalApi.confirm({
|
||||
+ title: isDraftOnly ? '작성 중인 메모를 삭제할까요?' : '선택한 메모를 삭제할까요?',
|
||||
+ onOk: () => { ... },
|
||||
+ });
|
||||
+};
|
||||
+{modalContextHolder}
|
||||
-disabled
|
||||
+disabled={!selectedNote && !hasDraft}
|
||||
```
|
||||
|
||||
### 파일 12: `src/App.tsx`, `src/main.tsx`, `src/app/main/index.ts`
|
||||
|
||||
- 앱 진입점을 라우터 기반 셸로 연결했습니다.
|
||||
|
||||
```diff
|
||||
-import { MainView } from './app/main';
|
||||
+import { BrowserRouter } from 'react-router-dom';
|
||||
+import { MainView } from './app/main';
|
||||
+<BrowserRouter>
|
||||
+ <MainView />
|
||||
+</BrowserRouter>
|
||||
-export { MainView } from './MainView';
|
||||
+export { AppShell } from './AppShell';
|
||||
+export { MainView } from './MainView';
|
||||
-export function MainView() { ...legacy layout... }
|
||||
+export function MainView() {
|
||||
+ return <AppShell />;
|
||||
+}
|
||||
```
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
find docs/worklogs docs/assets/worklogs -type f | sort
|
||||
grep -RIn "작업일지\|스크린샷\|소스 탭\|실행 커맨드\|상세형" docs/worklogs --include='*.md'
|
||||
git -c safe.directory=/workspace/auto_codex/repo status --short --branch
|
||||
sed -n '1,260p' docs/worklogs/2026-04-09.md
|
||||
sed -n '1,260p' docs/templates/worklog-template.md
|
||||
find scripts -maxdepth 1 -type f | sort
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-10 00:00:00' --until='2026-04-10 23:59:59' --stat --oneline
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-10 00:00:00' --until='2026-04-10 23:59:59' --name-only --pretty=format: | sed '/^$/d' | sort | uniq
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff --stat 0b105f8..HEAD -- src/app/main/MainView.tsx src/app/main/layout/MainLayout.tsx src/app/main/routes.tsx src/app/main/MainContent.tsx src/widgets/text-memo-widget/TextMemoWidget.tsx src/components/window/WindowUI.tsx src/styles.css src/views/play/LayoutPlaygroundView.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/MainView.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/layout/MainLayout.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/widgets/text-memo-widget/TextMemoWidget.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/routes.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/MainContent.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/views/play/LayoutPlaygroundView.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/AppShell.tsx src/app/main/MainSidebar.tsx src/app/main/layout/MainLayoutContext.ts src/app/main/layout/buildSearchOptions.ts src/app/main/layout/useMainLayoutData.ts src/app/main/pages/ApisPage.tsx src/app/main/pages/ChatPage.tsx src/app/main/pages/DocsPage.tsx src/app/main/pages/PlansPage.tsx src/main.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/components/window/WindowUI.tsx src/components/window/WindowUI.css src/widgets/text-memo-widget/TextMemoWidget.css src/styles.css
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff --name-status 0b105f8..HEAD
|
||||
npm run build:app
|
||||
PORT=4173 node scripts/serve-app-dist.mjs
|
||||
node --input-type=module <<'EOF'
|
||||
import { chromium } from 'playwright';
|
||||
// APIs / Widgets full-page screenshot and text memo widget crop capture
|
||||
EOF
|
||||
```
|
||||
|
||||
## 변경/신규 파일
|
||||
|
||||
- A docs/worklogs/2026-04-10.md
|
||||
- A docs/assets/worklogs/2026-04-10/feature-apis-widgets-full.png
|
||||
- A docs/assets/worklogs/2026-04-10/widget-text-memo.png
|
||||
- M package-lock.json
|
||||
- M package.json
|
||||
- M src/App.tsx
|
||||
- A src/app/main/AppShell.tsx
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M src/app/main/MainSidebar.tsx
|
||||
- M src/app/main/MainView.tsx
|
||||
- M src/app/main/index.ts
|
||||
- A src/app/main/layout/MainLayout.tsx
|
||||
- A src/app/main/layout/MainLayoutContext.ts
|
||||
- A src/app/main/layout/buildSearchOptions.ts
|
||||
- A src/app/main/layout/useMainLayoutData.ts
|
||||
- A src/app/main/pages/ApisPage.tsx
|
||||
- A src/app/main/pages/ChatPage.tsx
|
||||
- A src/app/main/pages/DocsPage.tsx
|
||||
- A src/app/main/pages/PlansPage.tsx
|
||||
- A src/app/main/pages/PlayPage.tsx
|
||||
- A src/app/main/routes.tsx
|
||||
- M src/app/main/types.ts
|
||||
- M src/main.tsx
|
||||
- M src/styles.css
|
||||
- M src/views/play/LayoutPlaygroundView.tsx
|
||||
- M src/widgets/text-memo-widget/TextMemoWidget.tsx
|
||||
296
docs/worklogs/2026-04-11.md
Executable file
296
docs/worklogs/2026-04-11.md
Executable file
@@ -0,0 +1,296 @@
|
||||
# 2026-04-11 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 게시판 글을 자동화 항목으로 접수하는 흐름을 열고, 중복 접수 상태와 권한 검사를 같이 묶어 게시판에서 바로 후속 작업으로 넘길 수 있게 정리했습니다.
|
||||
- 게시판 기반 Markdown 자동 등록 자동화는 설정, 워커 스케줄, 대상 폴더 규칙을 한 흐름으로 묶고 주기 계산을 분 단위 기준으로 다시 맞췄습니다.
|
||||
- Plan 반복 요청은 기존 메모 내부 옵션에서 분리해 전용 스케줄 기능으로 옮기고, 스케줄 화면과 API, 서버 저장 구조를 새로 연결했습니다.
|
||||
- Plan 화면은 목록 필터, 체크리스트, 릴리즈 요약, 증적 탭, 게시판 연결, 이슈/조치 기록, 본문 최대화, 읽기 전용 제어를 순차 보강했습니다.
|
||||
- Plans 진입 구조는 불필요한 중간 화면을 걷어내고 `Plan / 게시판 / 차트 / 스케줄 / 히스토리`가 직접 연결되도록 다시 정리했습니다.
|
||||
- 공통 컴포넌트 묶음으로 `FormField`, `StateKit`, `DataListTable`을 추가해 이후 화면 확장에 재사용할 수 있는 기반을 확보했습니다.
|
||||
- 오늘 증적용으로 `Plan/자동화` 전체 화면 1장과 상단 개요 영역 1장을 `docs/assets/worklogs/2026-04-11/`에 저장했습니다.
|
||||
|
||||
## 이슈 및 해결
|
||||
|
||||
- 게시판 초기화가 동시에 들어오면 테이블과 컬럼 생성 경합으로 첫 진입이 실패할 수 있었습니다.
|
||||
- 생성 전 존재 여부를 다시 확인하는 방식으로 setup 경합을 흡수해 초기 로드 실패를 줄였습니다.
|
||||
- 게시판 자동 등록은 대기 글이 없을 때 다음 주기까지 완전히 멈춰 추천 문서 Plan이 더 이상 생기지 않는 구간이 있었습니다.
|
||||
- 워커가 빈 큐에서도 다음 스케줄을 유지하도록 바꾸고, 분/초 단위 혼용 계산도 함께 보정했습니다.
|
||||
- Plan 반복 요청 옵션을 메모 등록 폼 안에 유지하니 일반 메모 편집과 스케줄성 작업이 한 화면에서 섞여 판단이 어려웠습니다.
|
||||
- 반복 요청 UI를 걷어내고 전용 스케줄 화면과 서버 테이블로 분리해 책임 경계를 명확히 했습니다.
|
||||
- Plan 상세는 자동화 접수 항목인데도 일부 상태값이나 이력 입력이 우회 수정될 수 있었습니다.
|
||||
- 잠금 판정을 공통 함수로 모으고, 권한 토큰이 없는 사용자는 조치 입력과 상태 액션이 모두 읽기 전용으로만 보이게 막았습니다.
|
||||
- 증적/미리보기 전체화면은 내부 스크롤 대신 부모 스크롤이 따라 움직여 읽기 흐름이 깨졌습니다.
|
||||
- 전체화면 진입 시 `body/html` 스크롤을 고정하고, 모달 내부 스크롤만 유지하도록 바꿔 사용 흐름을 안정화했습니다.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 게시판에서 만들어진 자동화 접수 건은 기본적으로 `release` 기준으로 처리하고 `main` 자동 반영은 별도 정책이 없는 한 꺼 둡니다.
|
||||
- 반복성 작업은 일반 메모 옵션이 아니라 전용 스케줄 목록에서 관리합니다.
|
||||
- Plan 상세에서 자동화로 접수된 원본 요청은 수정 대신 조치 기록과 증적 확인 중심으로 다룹니다.
|
||||
- Plans 화면은 중간 안내 카드보다 실제 작업 화면을 바로 여는 구성이 우선입니다.
|
||||
- 오늘 대표 증적 화면은 `Plan/자동화` 전체 화면, 부분 증적은 상단 개요 카드로 통일합니다.
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
- 오전 초반에는 게시판 setup 경합과 자동화 접수 진입점을 먼저 정리해, 게시판 글이 실패 없이 열리고 필요한 경우 바로 자동화 요청으로 이어지게 만들었습니다.
|
||||
- 이어서 게시판 글을 Markdown 생성 작업으로 돌리는 자동화 흐름을 붙였고, 추천 문서 생성 규칙과 스케줄 조건을 계속 수정하면서 빈 큐에서도 다음 주기를 유지하도록 다듬었습니다.
|
||||
- 공통 컴포넌트 추가는 이후 화면 정리에 필요한 기반 작업으로 진행했고, 새 입력/상태/목록 컴포넌트를 샘플과 함께 공개해 재사용 가능한 토대를 마련했습니다.
|
||||
- 중반 작업에서는 반복 요청 옵션을 걷어내고 전용 스케줄 기능으로 치환하는 쪽으로 방향을 바꿨습니다. 이 과정에서 저장 구조, API, 워커, 화면을 함께 움직여 반복 작업 관리의 중심을 스케줄 화면으로 옮겼습니다.
|
||||
- 이후 Plans 네비게이션은 실제 사용 흐름에 맞게 다시 접었고, 게시판 복구, 차트/스케줄/히스토리 재연결, 용어 정리, 검색 라벨 통일을 거쳐 지금 구조로 수렴시켰습니다.
|
||||
- 오후 후반에는 Plan 목록과 상세 화면 품질을 집중 보강했습니다. 조합 필터, 체크리스트, 릴리즈 요약, 증적 탭, 게시판 연결, 이슈 조치 기록이 순차적으로 붙었고, 본문 최대화와 전체화면 스크롤 제어도 여기서 함께 정리됐습니다.
|
||||
- 마감 단계에서는 자동화 접수 건의 잠금 규칙을 다시 점검해 우회 수정 가능성을 줄였고, 비토큰 사용자 읽기 전용 제어와 알림 링크 연결까지 보완한 뒤 오늘 작업일지와 증적 캡처를 최신 상태로 정리했습니다.
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||
|
||||
- `Plan/자동화` 전체 화면 캡처입니다. 오늘 정리한 목록, 상세 진입 흐름, 상단 개요 구성이 한 화면에서 보이도록 전체 페이지 기준으로 저장했습니다.
|
||||
|
||||

|
||||
|
||||
- 상단 개요 카드 부분 캡처입니다. 자동 새로고침 상태, 작업 개수 요약, 안내 문구처럼 오늘 보강한 운영 문맥을 빠르게 확인할 수 있도록 따로 남겼습니다.
|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `etc/servers/work-server/src/routes/board.ts`, `etc/servers/work-server/src/services/board-service.ts`, `src/features/board/BoardPage.tsx`, `src/features/board/api.ts`, `src/features/board/types.ts`
|
||||
|
||||
- 게시판 글을 즉시 자동화 Plan으로 접수하고, 접수 중복과 권한을 함께 처리하는 흐름입니다.
|
||||
|
||||
```diff
|
||||
+app.post('/api/board/posts/:id/actions/automation-receive', async (request, reply) => {
|
||||
+ if (!requireBoardAutomationAccess(request, reply)) {
|
||||
+ return;
|
||||
+ }
|
||||
+ const result = await receiveBoardPostAutomation(id);
|
||||
+ return { ok: true, item: result.item, planItemId: result.planItemId, alreadyReceived: result.alreadyReceived };
|
||||
+});
|
||||
+if (currentRow.automation_received_at || currentRow.automation_plan_item_id) {
|
||||
+ return { item: mapBoardPostRow(currentRow), planItemId: ..., alreadyReceived: true };
|
||||
+}
|
||||
+const workId = `board-post-${id}`;
|
||||
+note: [`게시판 제목: ${title}`, '', content].join('\n'),
|
||||
+auto_deploy_to_main: false,
|
||||
+const [automationReceivingId, setAutomationReceivingId] = useState<number | null>(null);
|
||||
+const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
|
||||
+icon={<PlayCircleOutlined />}
|
||||
+Tag color={automationStatus.color}
|
||||
```
|
||||
|
||||
### 파일 2: `etc/servers/work-server/src/services/board-service.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`, `etc/servers/work-server/src/services/app-config-service.ts`, `src/app/main/MainHeader.tsx`, `src/app/main/appConfig.ts`
|
||||
|
||||
- 게시판 Markdown 자동 등록 자동화를 설정과 워커 주기 계산까지 포함해 운영 가능한 상태로 묶었습니다.
|
||||
|
||||
```diff
|
||||
+markdownPlanItemId: number | null;
|
||||
+markdownExportedAt: string | null;
|
||||
+function buildBoardPostMarkdownPlanNote(row: Record<string, unknown>, targetFolder: string) {
|
||||
+ return [
|
||||
+ `게시판 추천 글 #${id}을 Markdown 문서로 등록해 주세요.`,
|
||||
+ `대상 파일: ${targetPath}`,
|
||||
+ ].join('\n');
|
||||
+}
|
||||
+export async function createNextBoardPostMarkdownAutomationPlan(targetFolder: string, releaseTarget = 'release') {
|
||||
+ work_id: `board-md-${id}`,
|
||||
+ auto_deploy_to_main: true,
|
||||
+}
|
||||
+private evaluateBoardMarkdownAutomationSchedule(config, now) {
|
||||
+ const scheduleType = config?.scheduleType ?? 'interval';
|
||||
+ return { due, nextEligibleAt, scheduleLabel };
|
||||
+}
|
||||
+await this.processBoardMarkdownAutomation(appConfig);
|
||||
+intervalMinutes: z.coerce.number().int().min(1).default(5)
|
||||
```
|
||||
|
||||
### 파일 3: `src/components/formField/FormField.tsx`, `src/components/stateKit/StateKit.tsx`, `src/components/dataListTable/DataListTable.tsx`, `src/index.ts`
|
||||
|
||||
- 입력/상태/목록 UI를 공통 컴포넌트로 분리해 이후 Plan, Board, History 화면이 같은 토대 위에서 확장되도록 만들었습니다.
|
||||
|
||||
```diff
|
||||
+export type DataListTableProps<T extends object> = {
|
||||
+ data: T[];
|
||||
+ searchFields?: ReadonlyArray<keyof T | ((item: T) => string)>;
|
||||
+ filters?: ReadonlyArray<DataListFilterOption<T>>;
|
||||
+ mobileCardRender?: (item: T) => ReactNode;
|
||||
+};
|
||||
+export function DataListTable<T extends object>({ ... }: DataListTableProps<T>) {
|
||||
+ const filteredData = useMemo(() => { ... }, [...]);
|
||||
+ return <Table<T> ... />;
|
||||
+}
|
||||
+export type FormFieldProps = Omit<FormItemProps, 'children' | 'help' | 'required'> & {
|
||||
+ error?: ReactNode;
|
||||
+ children: ReactNode | ((state: FormFieldRenderState) => ReactNode);
|
||||
+};
|
||||
+export { FormField } from './components/formField';
|
||||
+export { StateKit } from './components/stateKit';
|
||||
+export { DataListTable } from './components/dataListTable';
|
||||
```
|
||||
|
||||
### 파일 4: `etc/servers/work-server/src/services/plan-service.ts`, `etc/servers/work-server/src/services/plan-schedule-service.ts`, `etc/servers/work-server/src/routes/plan.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`, `src/features/planBoard/PlanSchedulePage.tsx`, `src/features/planBoard/api.ts`
|
||||
|
||||
- 반복 요청을 메모 옵션에서 분리하고 전용 스케줄 저장소와 화면으로 재편했습니다.
|
||||
|
||||
```diff
|
||||
+export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks';
|
||||
+export const createPlanScheduledTaskSchema = z.object({
|
||||
+ workId: z.string().trim().optional().default('반복작업'),
|
||||
+ repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60),
|
||||
+});
|
||||
+app.get('/api/plan/scheduled-tasks', async (request, reply) => { ... });
|
||||
+app.post('/api/plan/scheduled-tasks', async (request, reply) => { ... });
|
||||
+app.patch('/api/plan/scheduled-tasks/:id', async (request, reply) => { ... });
|
||||
+app.delete('/api/plan/scheduled-tasks/:id', async (request, reply) => { ... });
|
||||
+await ensurePlanScheduledTaskTable();
|
||||
+return fetchWithFallback('/api/plan/scheduled-tasks', '/api/plans/scheduled-tasks');
|
||||
+'/plan/schedule', '/plan/schedules', '/plans/schedule', '/plans/schedules'
|
||||
```
|
||||
|
||||
### 파일 5: `src/features/planBoard/PlanBoardPage.tsx`, `src/features/history/HistoryPage.tsx`, `src/features/board/BoardPage.tsx`, `src/styles.css`
|
||||
|
||||
- Plan 목록과 상세는 필터, 요약, 증적, 본문 최대화, 잠금 규칙, 읽기 전용 제어를 중심으로 계속 보강했습니다.
|
||||
|
||||
```diff
|
||||
+function isPlanItemRequestLocked(item: Pick<PlanItem, 'startedAt'> | null | undefined) {
|
||||
+ return Boolean(item?.startedAt);
|
||||
+}
|
||||
+const [noteExpanded, setNoteExpanded] = useState(false);
|
||||
+const canAppendActionHistory = hasAccess && Boolean(selectedItem) && isPlanItemRequestLocked(selectedItem);
|
||||
+if (!hasAccess) {
|
||||
+ messageApi.error('설정 > 토큰 관리에서 권한 토큰을 등록한 사용자만 조치 이력을 추가할 수 있습니다.');
|
||||
+}
|
||||
+if (isPlanItemRequestLocked(currentItem)) {
|
||||
+ messageApi.warning('자동화 접수된 항목은 기능동작확인을 수정할 수 없습니다.');
|
||||
+}
|
||||
+className={`plan-board-page__notepad-frame${noteExpanded ? ' plan-board-page__notepad-frame--expanded' : ''}`}
|
||||
+className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}
|
||||
+options={FUNCTION_CHECK_OPTIONS}
|
||||
+disabled={!hasAccess || jangsingProcessingSavingId === item.id || isPlanItemRequestLocked(item)}
|
||||
```
|
||||
|
||||
### 파일 6: `src/app/main/MainContent.tsx`, `src/app/main/layout/MainLayout.tsx`, `src/app/main/layout/buildSearchOptions.ts`, `src/app/main/mainView/constants.tsx`, `src/app/main/mainView/navigation.ts`, `src/app/main/mainView/searchOptions.ts`, `src/app/main/pages/PlansPage.tsx`, `src/app/main/routes.tsx`, `src/app/main/MainHeader.tsx`
|
||||
|
||||
- Plans 진입 구조를 단순화하고, 게시판/차트/스케줄/히스토리 복구와 용어 정리를 같은 축에서 마무리했습니다.
|
||||
|
||||
```diff
|
||||
+type PlanSectionKey = 'all' | 'board' | 'charts' | 'schedule' | 'history' | 'release';
|
||||
+navigateTo(buildPlansPath('board'));
|
||||
+navigateTo(buildPlansPath('schedule'));
|
||||
+navigateTo(buildPlansPath('history'));
|
||||
+label: '자동화'
|
||||
+label: 'Plan'
|
||||
+title: '자동화'
|
||||
+return <PlanBoardPage />;
|
||||
+release target link -> '*.sm-home.cloud'
|
||||
+search label: 'Plan'
|
||||
```
|
||||
|
||||
### 파일 7: `etc/servers/work-server/src/services/plan-notification-service.ts`, `src/sw.js`, `src/components/previewer/PreviewerUI.tsx`, `src/components/previewer/CodexDiffPreviewer.tsx`
|
||||
|
||||
- 알림 클릭 이동과 전체화면 미리보기는 마지막 품질 보완으로 정리했습니다.
|
||||
|
||||
```diff
|
||||
+function buildPlanNotificationTargetUrl(planId: number, workId: string | null | undefined, eventType: string) {
|
||||
+ const baseUrl = eventType.startsWith('release-') ? 'https://rel.sm-home.cloud/' : 'https://sm-home.cloud/';
|
||||
+ targetUrl.searchParams.set('planId', String(planId));
|
||||
+}
|
||||
+targetUrl = notificationData.targetUrl ? new URL(String(notificationData.targetUrl)) : new URL('/', self.location.origin);
|
||||
+const scrollY = window.scrollY;
|
||||
+document.body.style.position = 'fixed';
|
||||
+document.body.style.top = `-${scrollY}px`;
|
||||
+document.documentElement.style.overflow = 'hidden';
|
||||
+window.scrollTo(0, scrollY);
|
||||
```
|
||||
|
||||
### 파일 8: `docs/components/component-addition-suggestions.md`
|
||||
|
||||
- 게시판 논의 결과를 문서 증적으로 옮겨 오늘 제안 정리 흐름도 문서 세트 안에서 바로 확인할 수 있게 했습니다.
|
||||
|
||||
```diff
|
||||
+## 이번 반영 요약
|
||||
+- FormField: 폼 레이블, 도움말, 오류 메시지를 같은 포맷으로 묶는 공통 입력 레이어
|
||||
+- StateKit: 로딩, 비어 있음, 오류 상태를 화면마다 반복 작성하지 않도록 정리한 상태 표현 묶음
|
||||
+- DataListTable: 검색, 필터, 모바일 카드 대체 렌더를 함께 제공하는 공통 목록 레이어
|
||||
```
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
find docs/worklogs -maxdepth 2 -type f -name '*.md' | sort
|
||||
grep -RInE '작업일지|실행 커맨드|소스 탭|상세 작업 내역' docs/worklogs --include='*.md'
|
||||
git -c safe.directory=/workspace/auto_codex/repo branch --show-current
|
||||
git -c safe.directory=/workspace/auto_codex/repo status --short --branch
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-11 00:00' --pretty=format:'%h %ad %d %s' --date=iso-local --reverse --all
|
||||
git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-11 00:00' --name-status --pretty=format:'commit %h' --all
|
||||
git -c safe.directory=/workspace/auto_codex/repo diff 6f7a9aa..a6a3c92 -- src/features/planBoard/PlanBoardPage.tsx
|
||||
git -c safe.directory=/workspace/auto_codex/repo show 818cc2f -- src/features/board/BoardPage.tsx src/features/board/api.ts src/features/board/types.ts etc/servers/work-server/src/routes/board.ts etc/servers/work-server/src/services/board-service.ts
|
||||
git -c safe.directory=/workspace/auto_codex/repo show d9d124b -- src/features/planBoard/PlanSchedulePage.tsx src/features/planBoard/api.ts etc/servers/work-server/src/routes/plan.ts etc/servers/work-server/src/services/plan-schedule-service.ts etc/servers/work-server/src/workers/plan-worker.ts
|
||||
git -c safe.directory=/workspace/auto_codex/repo show dd2e975 -- src/components/formField/FormField.tsx src/components/stateKit/StateKit.tsx src/components/dataListTable/DataListTable.tsx src/index.ts
|
||||
git -c safe.directory=/workspace/auto_codex/repo show 4e48813 -- src/app/main/MainHeader.tsx src/app/main/appConfig.ts etc/servers/work-server/src/workers/plan-worker.ts etc/servers/work-server/src/services/board-service.ts
|
||||
npm run build:app
|
||||
PORT=4173 node scripts/serve-app-dist.mjs
|
||||
curl -I http://127.0.0.1:4173/plans/all
|
||||
node --input-type=module <<'EOF'
|
||||
// Playwright로 /plans/all 진입 후 전체 화면과 개요 카드 캡처 저장
|
||||
EOF
|
||||
find docs/assets/worklogs/2026-04-11 -maxdepth 1 -type f | sort
|
||||
```
|
||||
|
||||
## 변경/신규 파일
|
||||
|
||||
- A docs/worklogs/2026-04-11.md
|
||||
- A docs/assets/worklogs/2026-04-11/feature-plans-automation-full.png
|
||||
- A docs/assets/worklogs/2026-04-11/plan-board-overview.png
|
||||
- M docs/components/component-addition-suggestions.md
|
||||
- M etc/db/work-db/sql/board-posts.sql
|
||||
- M etc/servers/work-server/src/routes/board.ts
|
||||
- M etc/servers/work-server/src/routes/plan.ts
|
||||
- M etc/servers/work-server/src/routes/visitor-history.ts
|
||||
- M etc/servers/work-server/src/services/app-config-service.ts
|
||||
- M etc/servers/work-server/src/services/board-service.ts
|
||||
- M etc/servers/work-server/src/services/plan-notification-service.ts
|
||||
- A etc/servers/work-server/src/services/plan-schedule-service.ts
|
||||
- M etc/servers/work-server/src/services/plan-service.ts
|
||||
- M etc/servers/work-server/src/services/visitor-history-service.ts
|
||||
- M etc/servers/work-server/src/services/worklog-automation-service.ts
|
||||
- M etc/servers/work-server/src/services/worklog-automation-utils.ts
|
||||
- A etc/servers/work-server/src/workers/plan-worker.test.ts
|
||||
- M etc/servers/work-server/src/workers/plan-worker.ts
|
||||
- M src/app/main/MainContent.tsx
|
||||
- M src/app/main/MainHeader.tsx
|
||||
- M src/app/main/appConfig.ts
|
||||
- M src/app/main/clientIdentity.ts
|
||||
- M src/app/main/layout/MainLayout.tsx
|
||||
- M src/app/main/layout/buildSearchOptions.ts
|
||||
- M src/app/main/mainView/constants.tsx
|
||||
- M src/app/main/mainView/navigation.ts
|
||||
- M src/app/main/mainView/searchOptions.ts
|
||||
- M src/app/main/pages/PlansPage.tsx
|
||||
- M src/app/main/routes.tsx
|
||||
- A src/components/dataListTable/DataListTable.css
|
||||
- A src/components/dataListTable/DataListTable.tsx
|
||||
- A src/components/dataListTable/index.ts
|
||||
- A src/components/dataListTable/samples/BaseSample.tsx
|
||||
- A src/components/formField/FormField.css
|
||||
- A src/components/formField/FormField.tsx
|
||||
- A src/components/formField/index.ts
|
||||
- A src/components/formField/samples/BaseSample.tsx
|
||||
- M src/components/previewer/CodexDiffPreviewer.tsx
|
||||
- M src/components/previewer/PreviewerUI.tsx
|
||||
- A src/components/stateKit/StateKit.css
|
||||
- A src/components/stateKit/StateKit.tsx
|
||||
- A src/components/stateKit/index.ts
|
||||
- A src/components/stateKit/samples/BaseSample.tsx
|
||||
- M src/features/board/BoardPage.tsx
|
||||
- M src/features/board/api.ts
|
||||
- M src/features/board/types.ts
|
||||
- M src/features/history/HistoryPage.tsx
|
||||
- M src/features/history/api.ts
|
||||
- M src/features/planBoard/PlanBoardPage.tsx
|
||||
- A src/features/planBoard/PlanSchedulePage.tsx
|
||||
- M src/features/planBoard/api.ts
|
||||
- M src/features/planBoard/index.ts
|
||||
- M src/features/planBoard/types.ts
|
||||
- M src/index.ts
|
||||
- M src/store/appStore/context/AppStoreContext.tsx
|
||||
- M src/styles.css
|
||||
- M src/sw.js
|
||||
27
etc/commands/server-command/restart-rel.sh
Executable file
27
etc/commands/server-command/restart-rel.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||
SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}"
|
||||
SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-release-app}"
|
||||
SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release}"
|
||||
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
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
|
||||
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
|
||||
fi
|
||||
|
||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||
exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME"
|
||||
fi
|
||||
|
||||
echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2
|
||||
exit 127
|
||||
53
etc/commands/server-command/restart-server-command-runner.sh
Executable file
53
etc/commands/server-command/restart-server-command-runner.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
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}"
|
||||
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}"
|
||||
RUNNER_CPU_WATCHDOG_ENABLED="${SERVER_COMMAND_CPU_WATCHDOG_ENABLED:-true}"
|
||||
RUNNER_CPU_WATCHDOG_INTERVAL_MS="${SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS:-60000}"
|
||||
RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT="${SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT:-120}"
|
||||
RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT="${SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT:-8}"
|
||||
RUNNER_CPU_WATCHDOG_COOLDOWN_MS="${SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS:-1200000}"
|
||||
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
|
||||
|
||||
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
|
||||
# Fresh-PC shells often miss the nvm-managed Node.js path in non-login execution.
|
||||
. "$RUNNER_NVM_DIR/nvm.sh"
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
RUNNER_NODE_BIN=$(command -v node)
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then
|
||||
echo "node runtime not found: $RUNNER_NODE_BIN" >&2
|
||||
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
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
setsid env \
|
||||
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" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_ENABLED="$RUNNER_CPU_WATCHDOG_ENABLED" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS="$RUNNER_CPU_WATCHDOG_INTERVAL_MS" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT="$RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT="$RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS="$RUNNER_CPU_WATCHDOG_COOLDOWN_MS" \
|
||||
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
||||
|
||||
echo "server-command-runner restart requested"
|
||||
27
etc/commands/server-command/restart-test.sh
Executable file
27
etc/commands/server-command/restart-test.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||
SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}"
|
||||
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}"
|
||||
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
|
||||
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
|
||||
fi
|
||||
|
||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||
exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME"
|
||||
fi
|
||||
|
||||
echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2
|
||||
exit 127
|
||||
63
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file
63
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file
@@ -0,0 +1,63 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
|
||||
function requestDocker(socketPath, requestPath, method) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request(
|
||||
{
|
||||
socketPath,
|
||||
path: requestPath,
|
||||
method,
|
||||
},
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode ?? 500,
|
||||
body,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const containerName = process.argv[2]?.trim();
|
||||
const socketPath = process.env.SERVER_COMMAND_DOCKER_SOCKET?.trim() || '/var/run/docker.sock';
|
||||
|
||||
if (!containerName) {
|
||||
console.error('container name is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(socketPath)) {
|
||||
console.error(`Docker socket not found: ${socketPath}`);
|
||||
process.exit(127);
|
||||
}
|
||||
|
||||
const restartPath = `/containers/${encodeURIComponent(containerName)}/restart?t=30`;
|
||||
const response = await requestDocker(socketPath, restartPath, 'POST');
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
process.stdout.write(`${containerName} restarted via Docker socket`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode === 404) {
|
||||
console.error(`Container not found: ${containerName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(`Docker socket restart failed (${response.statusCode}): ${response.body.trim()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
9
etc/commands/server-command/restart-work-server.sh
Executable file
9
etc/commands/server-command/restart-work-server.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server
|
||||
4
etc/db/work-db/.env.example
Executable file
4
etc/db/work-db/.env.example
Executable file
@@ -0,0 +1,4 @@
|
||||
POSTGRES_DB=work_db
|
||||
POSTGRES_USER=work_user
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_PORT=5432
|
||||
2
etc/db/work-db/.gitignore
vendored
Executable file
2
etc/db/work-db/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
postgres-data
|
||||
28
etc/db/work-db/README.md
Executable file
28
etc/db/work-db/README.md
Executable file
@@ -0,0 +1,28 @@
|
||||
# Work DB
|
||||
|
||||
로컬 개발용 PostgreSQL 컨테이너입니다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
## 중지
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 기본 접속 정보
|
||||
|
||||
- Host: `localhost`
|
||||
- Port: `.env`의 `POSTGRES_PORT`
|
||||
- Database: `.env`의 `POSTGRES_DB`
|
||||
- User: `.env`의 `POSTGRES_USER`
|
||||
- Password: `.env`의 `POSTGRES_PASSWORD`
|
||||
|
||||
## work-server 연동
|
||||
|
||||
`etc/servers/work-server/.env`의 DB 설정과 맞춰서 사용합니다.
|
||||
41
etc/db/work-db/docker-compose.yml
Executable file
41
etc/db/work-db/docker-compose.yml
Executable file
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: work-db
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./.env.example
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- '${POSTGRES_PORT:-5432}:5432'
|
||||
volumes:
|
||||
- work-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- work-backend
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
work-db-data:
|
||||
|
||||
networks:
|
||||
work-backend:
|
||||
name: work-backend
|
||||
12
etc/db/work-db/sql/board-posts.sql
Executable file
12
etc/db/work-db/sql/board-posts.sql
Executable file
@@ -0,0 +1,12 @@
|
||||
create table if not exists board_posts (
|
||||
id serial primary key,
|
||||
title varchar(200) not null,
|
||||
content text not null,
|
||||
automation_plan_item_id integer null,
|
||||
automation_received_at timestamptz null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_board_posts_updated_at
|
||||
on board_posts (updated_at desc);
|
||||
16
etc/db/work-db/sql/notification-messages.sql
Executable file
16
etc/db/work-db/sql/notification-messages.sql
Executable file
@@ -0,0 +1,16 @@
|
||||
create table if not exists notification_messages (
|
||||
id serial primary key,
|
||||
title varchar(200) not null,
|
||||
body text not null,
|
||||
category varchar(60) not null default 'general',
|
||||
source varchar(80) not null default 'system',
|
||||
priority varchar(20) not null default 'normal',
|
||||
is_read boolean not null default false,
|
||||
read_at timestamptz null,
|
||||
metadata_json jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_notification_messages_unread_created_at
|
||||
on notification_messages (is_read, created_at desc, id desc);
|
||||
34
etc/db/work-db/sql/visitor-history.sql
Executable file
34
etc/db/work-db/sql/visitor-history.sql
Executable file
@@ -0,0 +1,34 @@
|
||||
-- 방문자 마스터 테이블: clientId 단위 집계 정보 보관
|
||||
create table if not exists visitor_clients (
|
||||
id serial primary key,
|
||||
client_id varchar(120) not null unique,
|
||||
nickname varchar(80) not null,
|
||||
first_visited_at timestamptz not null default now(),
|
||||
last_visited_at timestamptz not null default now(),
|
||||
visit_count integer not null default 1,
|
||||
last_visited_url varchar(2000),
|
||||
last_user_agent varchar(1000),
|
||||
last_ip varchar(120),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_visitor_clients_last_visited_at
|
||||
on visitor_clients (last_visited_at desc);
|
||||
|
||||
-- 방문 상세 이력 테이블: 중복 방문도 모두 적재
|
||||
create table if not exists visitor_visit_histories (
|
||||
id serial primary key,
|
||||
client_id varchar(120) not null,
|
||||
visited_at timestamptz not null default now(),
|
||||
url varchar(2000) not null,
|
||||
event_type varchar(80) not null default 'page_view',
|
||||
user_agent varchar(1000),
|
||||
ip varchar(120)
|
||||
);
|
||||
|
||||
create index if not exists idx_visitor_visit_histories_client_id
|
||||
on visitor_visit_histories (client_id);
|
||||
|
||||
create index if not exists idx_visitor_visit_histories_visited_at
|
||||
on visitor_visit_histories (visited_at desc);
|
||||
52
etc/servers/work-server/.env.example
Normal file
52
etc/servers/work-server/.env.example
Normal file
@@ -0,0 +1,52 @@
|
||||
NODE_VERSION=22.22.2
|
||||
PORT=3100
|
||||
APP_TIME_ZONE=Asia/Seoul
|
||||
DB_TIME_ZONE=Asia/Seoul
|
||||
DB_CLIENT=pg
|
||||
DB_HOST=work-db
|
||||
DB_PORT=5432
|
||||
DB_NAME=work_db
|
||||
DB_USER=work_user
|
||||
DB_PASSWORD=change-me
|
||||
DB_SSL=false
|
||||
PLAN_WORKER_ENABLED=true
|
||||
PLAN_WORKER_INTERVAL_MS=10000
|
||||
PLAN_WORKER_ID=
|
||||
PLAN_GIT_REPO_PATH=/workspace/auto_codex/repo
|
||||
PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project
|
||||
PLAN_RELEASE_BRANCH=release
|
||||
PLAN_MAIN_BRANCH=main
|
||||
PLAN_GIT_USER_NAME=how2ice
|
||||
PLAN_GIT_USER_EMAIL=how2ice@naver.com
|
||||
PLAN_CODEX_RUNNER_PATH=/workspace/repo-scripts/run-plan-codex-once.mjs
|
||||
PLAN_CODEX_ENABLED=true
|
||||
PLAN_LOCAL_MAIN_MODE=true
|
||||
PLAN_CODEX_BIN=codex
|
||||
IOS_NOTIFICATION_ENABLED=false
|
||||
WEB_PUSH_ENABLED=true
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY=BL1_f6BgOym_NhSs5QOmziKaYB5rTecl_2JG172w2AO_ru0hD-EG15S9F_6zgv0B6ajfzHEccgnwJAygfGDVv6Y
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY=BglyVgx-u1BnyFSIkTwbnamHnQDGxHewwmp5JLtyr3M
|
||||
WEB_PUSH_SUBJECT=mailto:how2ice@naver.com
|
||||
APNS_KEY_ID=
|
||||
APNS_TEAM_ID=
|
||||
APNS_BUNDLE_ID=
|
||||
APNS_PRIVATE_KEY=
|
||||
APNS_PRIVATE_KEY_PATH=
|
||||
APNS_PRODUCTION=false
|
||||
SERVER_COMMAND_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
|
||||
SERVER_COMMAND_API_BASE_URL=http://host.docker.internal:3211/api
|
||||
SERVER_COMMAND_API_ACCESS_TOKEN=local-server-command-runner
|
||||
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE=/api/server-commands/{key}/actions/restart
|
||||
SERVER_COMMAND_PROJECT_ROOT=/workspace/auto_codex/repo
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT=/workspace/main-project
|
||||
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
|
||||
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/
|
||||
SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/
|
||||
SERVER_COMMAND_WORK_SERVER_URL=http://127.0.0.1:3100/health
|
||||
SERVER_COMMAND_RUNNER_URL=http://host.docker.internal:3211/health
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN=local-server-command-runner
|
||||
SERVER_COMMAND_RUNNER_HOST=0.0.0.0
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE=/workspace/main-project/.server-command-runner-heartbeat.json
|
||||
SERVER_COMMAND_TEST_SERVICE=app
|
||||
SERVER_COMMAND_REL_SERVICE=release-app
|
||||
SERVER_COMMAND_WORK_SERVER_SERVICE=work-server
|
||||
3
etc/servers/work-server/.gitignore
vendored
Executable file
3
etc/servers/work-server/.gitignore
vendored
Executable file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
14
etc/servers/work-server/Dockerfile
Normal file
14
etc/servers/work-server/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22.22.2-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install -g @openai/codex && npm ci --legacy-peer-deps
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY scripts ./scripts
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
115
etc/servers/work-server/README.md
Normal file
115
etc/servers/work-server/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Work Server
|
||||
|
||||
`Fastify + Knex + PostgreSQL` 기반의 범용 작업용 API 서버입니다.
|
||||
|
||||
## 추천 DB
|
||||
|
||||
- `PostgreSQL`
|
||||
- 이유:
|
||||
- Node 생태계에서 검증된 조합
|
||||
- `Knex`로 CRUD와 DDL을 함께 다루기 편함
|
||||
- 운영/확장/마이그레이션 측면에서 무난함
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f work-server
|
||||
```
|
||||
|
||||
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
|
||||
|
||||
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner도 함께 켭니다.
|
||||
|
||||
```bash
|
||||
cd /home/how2ice/project/ai-code-app
|
||||
npm run server-command:runner
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
기본 실행은 `.env.example` 값으로도 가능합니다.
|
||||
로컬 환경에 맞는 값을 덮어쓰려면 `.env.example`를 참고해서 `.env`를 추가하면 됩니다.
|
||||
|
||||
주요 항목:
|
||||
|
||||
- `APP_TIME_ZONE`: Node 서버 런타임 기준 시간대. 기본값 `Asia/Seoul`
|
||||
- `DB_TIME_ZONE`: 앱이 여는 DB 세션 시간대. 기본값 `Asia/Seoul`
|
||||
- `DB_*`: PostgreSQL 접속 정보
|
||||
- `PLAN_WORKER_ENABLED`: Plan 자동화 worker 활성화 여부
|
||||
- `PLAN_WORKER_INTERVAL_MS`: Plan polling 주기
|
||||
- `PLAN_GIT_REPO_PATH`: 브랜치 생성/병합 대상 저장소 경로
|
||||
- `PLAN_MAIN_PROJECT_REPO_PATH`: main 반영 후 pull 받을 메인 루트 프로젝트 경로. 비우면 `PLAN_GIT_REPO_PATH`를 사용
|
||||
- `PLAN_RELEASE_BRANCH`: 자동 merge 대상 release 브랜치명
|
||||
- `IOS_NOTIFICATION_ENABLED`: iOS APNs 알림 활성화 여부
|
||||
- `APNS_*`: Apple Push Notification 인증 키 정보
|
||||
- `SERVER_COMMAND_DOCKER_SOCKET`: 서버 재기동 명령이 사용할 Docker Unix socket 경로. rootless Docker면 예: `/run/user/1000/docker.sock`
|
||||
- `SERVER_COMMAND_API_BASE_URL`: `work-server`가 서버 재기동 요청을 위임할 host runner 주소
|
||||
- `SERVER_COMMAND_API_ACCESS_TOKEN`: host runner 호출 토큰
|
||||
|
||||
서버 재기동 기능을 쓰려면 `work-server` 컨테이너가 Docker에 접근할 수 있어야 합니다. 기본값은 `/var/run/docker.sock`이며, rootless Docker 환경이면 `.env`에 `SERVER_COMMAND_DOCKER_SOCKET` 또는 `DOCKER_HOST=unix:///run/user/<uid>/docker.sock`를 맞춰 준 뒤 `work-server`를 다시 올려야 합니다.
|
||||
|
||||
기본 예시는 `http://host.docker.internal:3211/api`로 맞춰져 있어서, `work-server` 컨테이너가 아니라 호스트의 현재 프로젝트 루트에서 `restart-*.sh`를 실행합니다. 즉 `Server > Command`가 직접 CLI로 재기동한 것과 최대한 비슷한 문맥을 사용합니다.
|
||||
|
||||
## Codex Live
|
||||
|
||||
`Codex Live`는 현재 프로젝트 환경의 `main_project` 경로를 기준으로 실행됩니다. 기본값은 `PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project`이며, 소스 수정이 필요하면 이 경로의 실제 프로젝트를 바로 수정합니다.
|
||||
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 작업메모 반영 요청 모두 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. 별도 브랜치 생성이나 `release -> main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
|
||||
|
||||
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
|
||||
채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat/<chat-session-id>/resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다.
|
||||
|
||||
채팅 첨부 파일도 같은 기준을 사용하며 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래에 저장됩니다.
|
||||
|
||||
## Plan 자동화
|
||||
|
||||
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
|
||||
|
||||
현재 로컬 운영 모드에서는 아래 자동 브랜치 흐름을 기본 동작으로 강제하지 않습니다. 필요 시 사용자가 별도로 요청한 경우에만 사용합니다.
|
||||
|
||||
- `등록` 상태: worker가 읽어서 `feature/plan-{id}-{workId}` 브랜치 생성 시도
|
||||
- 성공 시: `작업중`, `브랜치준비`
|
||||
- 실패 시: `이슈`, 최근 오류 기록
|
||||
- `개발완료` 상태: worker가 `release` 브랜치 병합 시도
|
||||
- 병합 성공 시: `완료`
|
||||
- 병합 실패 시: `이슈`
|
||||
|
||||
안전 조건:
|
||||
|
||||
- Git worktree가 깨끗해야 동작
|
||||
- `release` 브랜치가 실제로 존재해야 병합 가능
|
||||
- 실패 시 자동으로 `이슈` 상태와 오류 메시지를 남김
|
||||
|
||||
## 주요 API
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/schema/tables`
|
||||
- `POST /api/ddl/create-table`
|
||||
- `POST /api/ddl/drop-table`
|
||||
- `POST /api/ddl/add-column`
|
||||
- `POST /api/ddl/drop-column`
|
||||
- `POST /api/ddl/raw`
|
||||
- `POST /api/crud/:table/select`
|
||||
- `POST /api/crud/:table/insert`
|
||||
- `PATCH /api/crud/:table/update`
|
||||
- `DELETE /api/crud/:table/delete`
|
||||
- `GET /api/plan/statuses`
|
||||
- `POST /api/plan/setup`
|
||||
- `GET /api/plan/items`
|
||||
- `GET /api/plan/items/:id`
|
||||
- `POST /api/plan/items`
|
||||
- `PATCH /api/plan/items/:id`
|
||||
- `DELETE /api/plan/items/:id`
|
||||
- `POST /api/notifications/setup`
|
||||
- `GET /api/notifications/tokens`
|
||||
- `PUT /api/notifications/tokens/ios`
|
||||
- `DELETE /api/notifications/tokens/ios`
|
||||
- `POST /api/notifications/send-test`
|
||||
|
||||
## iOS 알림 연동
|
||||
|
||||
- 프론트에서 알림 `On` 시 `PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다.
|
||||
- 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다.
|
||||
- Plan worker가 브랜치 준비, 자동 작업 완료/실패, release 반영 완료/실패, main 반영 완료/실패 시 등록된 iOS 토큰으로 APNs 알림을 전송합니다.
|
||||
53
etc/servers/work-server/docker-compose.yml
Normal file
53
etc/servers/work-server/docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
work-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: work-server
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
user: "0:0"
|
||||
group_add:
|
||||
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||
cpus: 1.5
|
||||
mem_limit: 2048m
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- path: ./.env.example
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
ports:
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ../../../:/workspace/main-project
|
||||
- ../../../.auto_codex:/workspace/auto_codex
|
||||
- ../../../scripts:/workspace/repo-scripts:ro
|
||||
- ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
- ./.docker/home:/home/how2ice
|
||||
- ./.docker/codex-home:/codex-home
|
||||
- ./.docker/codex-home-template:/codex-home-template
|
||||
environment:
|
||||
TZ: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
HOME: /home/how2ice
|
||||
CODEX_HOME: /codex-home
|
||||
PLAN_CODEX_TEMPLATE_HOME: /codex-home-template
|
||||
PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex}
|
||||
PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false}
|
||||
PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false}
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||
networks:
|
||||
- work-backend
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
networks:
|
||||
work-backend:
|
||||
name: work-backend
|
||||
1999
etc/servers/work-server/package-lock.json
generated
Executable file
1999
etc/servers/work-server/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
31
etc/servers/work-server/package.json
Normal file
31
etc/servers/work-server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "work-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@parse/node-apn": "^8.0.0",
|
||||
"dotenv": "^17.2.2",
|
||||
"fastify": "^5.6.0",
|
||||
"knex": "^3.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { db } from '../src/db/client.js';
|
||||
import { repairChatConversationRequestLinks } from '../src/services/chat-room-service.js';
|
||||
|
||||
const requestedSessionId = process.argv[2]?.trim() || null;
|
||||
|
||||
try {
|
||||
const result = await repairChatConversationRequestLinks(requestedSessionId);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { db } from '../src/db/client.js';
|
||||
import {
|
||||
CHAT_CONVERSATION_MESSAGE_TABLE,
|
||||
CHAT_CONVERSATION_REQUEST_TABLE,
|
||||
} from '../src/services/chat-room-service.js';
|
||||
|
||||
const LEGACY_CHAT_RESOURCE_PREFIX = '/.codex_chat/';
|
||||
const API_CHAT_RESOURCE_PREFIX = '/api/chat/resources/.codex_chat/';
|
||||
const requestedSessionId = process.argv[2]?.trim() || null;
|
||||
|
||||
function rewriteLegacyChatResourceUrls(text: string) {
|
||||
const normalized = String(text ?? '').replaceAll(LEGACY_CHAT_RESOURCE_PREFIX, API_CHAT_RESOURCE_PREFIX);
|
||||
|
||||
return normalized.replace(
|
||||
/\((?:\/[^)\s]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^)\s]*?)(?:\/api\/chat\/resources\/\.codex_chat\/[^)\s]*)?\)/g,
|
||||
(_match, resourcePath) => `(${resourcePath})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function backfillTable(
|
||||
tableName: string,
|
||||
textColumn: string,
|
||||
) {
|
||||
const rows = await db(tableName)
|
||||
.modify((query) => {
|
||||
if (requestedSessionId) {
|
||||
query.where('session_id', requestedSessionId);
|
||||
}
|
||||
})
|
||||
.where(textColumn, 'like', `%${LEGACY_CHAT_RESOURCE_PREFIX}%`)
|
||||
.select('id', 'session_id', textColumn);
|
||||
|
||||
let updatedCount = 0;
|
||||
const touchedSessionIds = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const currentText = String(row[textColumn] ?? '');
|
||||
const nextText = rewriteLegacyChatResourceUrls(currentText);
|
||||
|
||||
if (nextText === currentText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db(tableName)
|
||||
.where('id', row.id)
|
||||
.update({
|
||||
[textColumn]: nextText,
|
||||
});
|
||||
|
||||
updatedCount += 1;
|
||||
touchedSessionIds.add(String(row.session_id ?? ''));
|
||||
}
|
||||
|
||||
return {
|
||||
tableName,
|
||||
textColumn,
|
||||
updatedCount,
|
||||
touchedSessionIds: Array.from(touchedSessionIds).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const messageResult = await backfillTable(CHAT_CONVERSATION_MESSAGE_TABLE, 'text');
|
||||
const requestResult = await backfillTable(CHAT_CONVERSATION_REQUEST_TABLE, 'response_text');
|
||||
|
||||
console.log(JSON.stringify({
|
||||
requestedSessionId,
|
||||
updatedRowCount: messageResult.updatedCount + requestResult.updatedCount,
|
||||
tables: [messageResult, requestResult],
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
21
etc/servers/work-server/scripts/write-build-info.mjs
Executable file
21
etc/servers/work-server/scripts/write-build-info.mjs
Executable file
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const distDirectoryPath = path.join(projectRoot, 'dist');
|
||||
const buildInfoPath = path.join(distDirectoryPath, 'build-info.json');
|
||||
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
const builtAt = new Date().toISOString();
|
||||
|
||||
const buildInfo = {
|
||||
version: typeof packageJson.version === 'string' ? packageJson.version : '0.0.0',
|
||||
buildId: `${typeof packageJson.version === 'string' ? packageJson.version : '0.0.0'}@${builtAt}`,
|
||||
builtAt,
|
||||
};
|
||||
|
||||
await fs.mkdir(distDirectoryPath, { recursive: true });
|
||||
await fs.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2));
|
||||
|
||||
console.log(`work-server build info written to ${buildInfoPath}`);
|
||||
104
etc/servers/work-server/src/app.ts
Executable file
104
etc/servers/work-server/src/app.ts
Executable file
@@ -0,0 +1,104 @@
|
||||
import cors from '@fastify/cors';
|
||||
import Fastify from 'fastify';
|
||||
import { registerJsonBodyParser } from './json-body.js';
|
||||
import { registerBoardRoutes } from './routes/board.js';
|
||||
import { registerCrudRoutes } from './routes/crud.js';
|
||||
import { registerDdlRoutes } from './routes/ddl.js';
|
||||
import { registerErrorLogRoutes } from './routes/error-log.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerAppConfigRoutes } from './routes/app-config.js';
|
||||
import { registerChatRoutes } from './routes/chat.js';
|
||||
import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
import { createErrorLog } from './services/error-log-service.js';
|
||||
|
||||
export function createApp() {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
});
|
||||
|
||||
registerJsonBodyParser(app);
|
||||
app.register(registerBoardRoutes);
|
||||
app.register(registerHealthRoutes);
|
||||
app.register(registerAppConfigRoutes);
|
||||
app.register(registerChatRoutes);
|
||||
app.register(registerSchemaRoutes);
|
||||
app.register(registerDdlRoutes);
|
||||
app.register(registerCrudRoutes);
|
||||
app.register(registerErrorLogRoutes);
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
app.register(registerServerCommandRoutes);
|
||||
app.register(registerVisitorHistoryRoutes);
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (shouldPersistNotFoundErrorLog(request.url)) {
|
||||
try {
|
||||
await createErrorLog({
|
||||
source: 'server',
|
||||
sourceLabel: '워크서버 API',
|
||||
errorType: 'NotFound',
|
||||
errorMessage: `Route not found: ${request.method} ${request.url}`,
|
||||
statusCode: 404,
|
||||
requestMethod: request.method,
|
||||
requestPath: request.url,
|
||||
context: {
|
||||
route: request.routeOptions.url,
|
||||
},
|
||||
});
|
||||
} catch (loggingError) {
|
||||
app.log.error(loggingError, 'Failed to persist 404 error log');
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(404);
|
||||
return {
|
||||
message: '요청한 경로를 찾을 수 없습니다.',
|
||||
};
|
||||
});
|
||||
|
||||
app.setErrorHandler(async (error, request, reply) => {
|
||||
const handledError = error instanceof Error ? error : new Error(String(error));
|
||||
const errorWithMeta = handledError as Error & { statusCode?: number; code?: string };
|
||||
const statusCode = typeof errorWithMeta.statusCode === 'number'
|
||||
? Number(errorWithMeta.statusCode)
|
||||
: 500;
|
||||
|
||||
try {
|
||||
await createErrorLog({
|
||||
source: 'server',
|
||||
sourceLabel: '워크서버 API',
|
||||
errorType: errorWithMeta.code ?? handledError.name ?? 'ServerError',
|
||||
errorName: handledError.name,
|
||||
errorMessage: handledError.message || '서버 오류가 발생했습니다.',
|
||||
stackTrace: handledError.stack,
|
||||
statusCode,
|
||||
requestMethod: request.method,
|
||||
requestPath: request.url,
|
||||
context: {
|
||||
route: request.routeOptions.url,
|
||||
params: (request.params as Record<string, unknown> | undefined) ?? null,
|
||||
query: (request.query as Record<string, unknown> | undefined) ?? null,
|
||||
},
|
||||
});
|
||||
} catch (loggingError) {
|
||||
app.log.error(loggingError, 'Failed to persist server error log');
|
||||
}
|
||||
|
||||
app.log.error(handledError);
|
||||
reply.status(statusCode >= 400 ? statusCode : 500);
|
||||
return {
|
||||
message: handledError.message || '요청 처리에 실패했습니다.',
|
||||
};
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
103
etc/servers/work-server/src/config/env.ts
Normal file
103
etc/servers/work-server/src/config/env.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import path from 'node:path';
|
||||
import dotenv from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
dotenv.config({ override: true, quiet: true });
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(3100),
|
||||
APP_TIME_ZONE: z.string().default('Asia/Seoul'),
|
||||
DB_TIME_ZONE: z.string().default('Asia/Seoul'),
|
||||
DB_CLIENT: z.string().default('pg'),
|
||||
DB_HOST: z.string().default('localhost'),
|
||||
DB_PORT: z.coerce.number().default(5432),
|
||||
DB_NAME: z.string().default('work_db'),
|
||||
DB_USER: z.string().default('work_user'),
|
||||
DB_PASSWORD: z.string().default('change-me'),
|
||||
DB_SSL: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_WORKER_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_WORKER_INTERVAL_MS: z.coerce.number().default(10000),
|
||||
PLAN_WORKER_ID: z.string().optional(),
|
||||
PLAN_GIT_REPO_PATH: z.string().default('/workspace/repo'),
|
||||
PLAN_MAIN_PROJECT_REPO_PATH: z.string().optional(),
|
||||
PLAN_RELEASE_BRANCH: z.string().default('release'),
|
||||
PLAN_MAIN_BRANCH: z.string().default('main'),
|
||||
PLAN_GIT_USER_NAME: z.string().default('how2ice'),
|
||||
PLAN_GIT_USER_EMAIL: z.string().default('how2ice@naver.com'),
|
||||
PLAN_CODEX_RUNNER_PATH: z.string().default('/workspace/repo-scripts/run-plan-codex-once.mjs'),
|
||||
PLAN_CODEX_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_LOCAL_MAIN_MODE: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_CODEX_BIN: z.string().default('codex'),
|
||||
PLAN_CODEX_TEMPLATE_HOME: z.string().optional(),
|
||||
PLAN_PREVIEW_BASE_URL: z.string().optional(),
|
||||
PLAN_PREVIEW_URL_TEMPLATE: z.string().optional(),
|
||||
IOS_NOTIFICATION_ENABLED: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
WEB_PUSH_ENABLED: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(),
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(),
|
||||
WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'),
|
||||
APNS_KEY_ID: z.string().optional(),
|
||||
APNS_TEAM_ID: z.string().optional(),
|
||||
APNS_BUNDLE_ID: z.string().optional(),
|
||||
APNS_PRIVATE_KEY: z.string().optional(),
|
||||
APNS_PRIVATE_KEY_PATH: z.string().optional(),
|
||||
APNS_PRODUCTION: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
SERVER_COMMAND_ACCESS_TOKEN: z.string().default('usr_7f3a9c2d8e1b4a6f'),
|
||||
SERVER_COMMAND_API_BASE_URL: z.string().optional(),
|
||||
SERVER_COMMAND_API_ACCESS_TOKEN: z.string().optional(),
|
||||
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_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
||||
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
||||
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
||||
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
|
||||
});
|
||||
|
||||
function parseEnv() {
|
||||
dotenv.config({ override: true, quiet: true });
|
||||
const parsedEnv = envSchema.parse(process.env);
|
||||
|
||||
return {
|
||||
...parsedEnv,
|
||||
PLAN_MAIN_PROJECT_REPO_PATH: parsedEnv.PLAN_MAIN_PROJECT_REPO_PATH ?? parsedEnv.PLAN_GIT_REPO_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEnv() {
|
||||
const parsedEnv = parseEnv();
|
||||
|
||||
if (!process.env.TZ?.trim()) {
|
||||
process.env.TZ = parsedEnv.APP_TIME_ZONE;
|
||||
}
|
||||
|
||||
return parsedEnv;
|
||||
}
|
||||
|
||||
export const env = getEnv();
|
||||
37
etc/servers/work-server/src/db/client.ts
Executable file
37
etc/servers/work-server/src/db/client.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import knex from 'knex';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export const db = knex({
|
||||
client: env.DB_CLIENT,
|
||||
connection: {
|
||||
host: env.DB_HOST,
|
||||
port: env.DB_PORT,
|
||||
database: env.DB_NAME,
|
||||
user: env.DB_USER,
|
||||
password: env.DB_PASSWORD,
|
||||
ssl: env.DB_SSL ? { rejectUnauthorized: false } : false,
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 10,
|
||||
afterCreate(connection: any, done: (error: Error | null, connection: any) => void) {
|
||||
const clientName = String(env.DB_CLIENT ?? '').toLowerCase();
|
||||
|
||||
if (clientName === 'pg' || clientName === 'postgres' || clientName === 'postgresql') {
|
||||
connection.query(`SET TIME ZONE '${env.DB_TIME_ZONE}'`, (error: Error | null) => {
|
||||
done(error, connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientName === 'mysql' || clientName === 'mysql2') {
|
||||
connection.query('SET time_zone = "+09:00"', (error: Error | null) => {
|
||||
done(error, connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
done(null, connection);
|
||||
},
|
||||
},
|
||||
});
|
||||
25
etc/servers/work-server/src/json-body.ts
Executable file
25
etc/servers/work-server/src/json-body.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export function registerJsonBodyParser(app: FastifyInstance) {
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (request, body, done) => {
|
||||
const rawBody = typeof body === 'string' ? body : '';
|
||||
const normalizedBody = rawBody.trim();
|
||||
|
||||
if (!normalizedBody) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
done(null, JSON.parse(normalizedBody));
|
||||
} catch {
|
||||
const error = new Error('Body is not valid JSON.') as Error & {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
};
|
||||
error.statusCode = 400;
|
||||
error.code = 'FST_ERR_CTP_INVALID_JSON_BODY';
|
||||
done(error, undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
9
etc/servers/work-server/src/lib/identifier.ts
Executable file
9
etc/servers/work-server/src/lib/identifier.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
const IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
export function assertIdentifier(value: string, label = 'identifier') {
|
||||
if (!IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
22
etc/servers/work-server/src/not-found.test.ts
Executable file
22
etc/servers/work-server/src/not-found.test.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
|
||||
test('shouldPersistNotFoundErrorLog only keeps work-server API paths', () => {
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api'), true);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/notifications/preferences/automation'), true);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/notifications/preferences/automation?targetKind=client&targetId=abc'), true);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/1234567890abcdef1234567890abcdef'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/docs'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/docs/index.html'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/env'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/config'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/debug'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/debug/pprof'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/.env'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/.git/config'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/apis/components'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/apis/widgets?widgetId=dashboard-report-card'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/plans/release-review'), false);
|
||||
});
|
||||
61
etc/servers/work-server/src/not-found.ts
Executable file
61
etc/servers/work-server/src/not-found.ts
Executable file
@@ -0,0 +1,61 @@
|
||||
function normalizeRequestPath(requestUrl: string) {
|
||||
try {
|
||||
return new URL(requestUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return requestUrl.split('?')[0] ?? requestUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function hasHiddenPathSegment(pathname: string) {
|
||||
return pathname
|
||||
.split('/')
|
||||
.some((segment, index) => index > 1 && segment.startsWith('.'));
|
||||
}
|
||||
|
||||
function isIgnoredScannerProbePath(pathname: string) {
|
||||
return pathname === '/api/docs' ||
|
||||
pathname.startsWith('/api/docs/') ||
|
||||
pathname === '/api/env' ||
|
||||
pathname === '/api/config' ||
|
||||
pathname === '/api/debug' ||
|
||||
pathname.startsWith('/api/debug/');
|
||||
}
|
||||
|
||||
function isOpaqueScannerToken(segment: string) {
|
||||
if (segment.length < 24 || !/^[a-z0-9_-]+$/i.test(segment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^(.)\1+$/.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !/[/-]/.test(segment);
|
||||
}
|
||||
|
||||
function isIgnoredOpaqueApiProbePath(pathname: string) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
|
||||
if (segments.length !== 2 || segments[0] !== 'api') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isOpaqueScannerToken(segments[1] ?? '');
|
||||
}
|
||||
|
||||
export function shouldPersistNotFoundErrorLog(requestUrl: string) {
|
||||
const pathname = normalizeRequestPath(String(requestUrl ?? ''));
|
||||
if (!(pathname === '/api' || pathname.startsWith('/api/'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasHiddenPathSegment(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIgnoredScannerProbePath(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isIgnoredOpaqueApiProbePath(pathname);
|
||||
}
|
||||
48
etc/servers/work-server/src/routes/app-config.ts
Executable file
48
etc/servers/work-server/src/routes/app-config.ts
Executable file
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getAppConfig, upsertAppConfig } from '../services/app-config-service.js';
|
||||
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async () => {
|
||||
const config = await getAppConfig();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: config ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/app-config', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object' || !('config' in payload)) {
|
||||
throw new Error('저장할 설정 값이 비어 있습니다.');
|
||||
}
|
||||
|
||||
const config = (payload as { config: unknown }).config;
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('설정 값 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const savedConfig = await upsertAppConfig(config as Record<string, unknown>);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: savedConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
172
etc/servers/work-server/src/routes/board.ts
Executable file
172
etc/servers/work-server/src/routes/board.ts
Executable file
@@ -0,0 +1,172 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import {
|
||||
BoardPostAutomationLockedError,
|
||||
boardPostPayloadSchema,
|
||||
createBoardPost,
|
||||
deleteBoardPost,
|
||||
ensureBoardPostsTable,
|
||||
getBoardPost,
|
||||
listBoardPosts,
|
||||
receiveBoardPostAutomation,
|
||||
updateBoardPost,
|
||||
} from '../services/board-service.js';
|
||||
|
||||
export async function registerBoardRoutes(app: FastifyInstance) {
|
||||
function isLoopbackAddress(value: string | null | undefined) {
|
||||
const normalizedValue = String(value ?? '').trim();
|
||||
return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1';
|
||||
}
|
||||
|
||||
function hasBoardAutomationAccess(request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) {
|
||||
if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress);
|
||||
}
|
||||
|
||||
function requireBoardAutomationAccess(
|
||||
request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } },
|
||||
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
|
||||
) {
|
||||
if (hasBoardAutomationAccess(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 자동화 접수를 할 수 있습니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
async function respondWithBoardSetup() {
|
||||
await ensureBoardPostsTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table: 'board_posts',
|
||||
};
|
||||
}
|
||||
|
||||
async function respondWithBoardPosts() {
|
||||
const items = await listBoardPosts();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/board/setup', async () => respondWithBoardSetup());
|
||||
app.post('/api/board/setup', async () => {
|
||||
return respondWithBoardSetup();
|
||||
});
|
||||
|
||||
app.get('/api/board/posts', async () => respondWithBoardPosts());
|
||||
app.get('/api/board/items', async () => respondWithBoardPosts());
|
||||
|
||||
app.get('/api/board/posts/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getBoardPost(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/board/posts', async (request) => {
|
||||
const item = await createBoardPost(boardPostPayloadSchema.parse(request.body ?? {}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/board/posts/:id/actions/automation-receive', async (request, reply) => {
|
||||
if (!requireBoardAutomationAccess(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const result = await receiveBoardPostAutomation(id);
|
||||
|
||||
if (!result) {
|
||||
return reply.code(404).send({
|
||||
message: '자동화 접수할 게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.item,
|
||||
planItemId: result.planItemId,
|
||||
alreadyReceived: result.alreadyReceived,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/board/posts/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
let item;
|
||||
|
||||
try {
|
||||
item = await updateBoardPost(id, boardPostPayloadSchema.parse(request.body ?? {}));
|
||||
} catch (error) {
|
||||
if (error instanceof BoardPostAutomationLockedError) {
|
||||
return reply.code(409).send({
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/board/posts/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
let deleted;
|
||||
|
||||
try {
|
||||
deleted = await deleteBoardPost(id);
|
||||
} catch (error) {
|
||||
if (error instanceof BoardPostAutomationLockedError) {
|
||||
return reply.code(409).send({
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
};
|
||||
});
|
||||
}
|
||||
500
etc/servers/work-server/src/routes/chat.ts
Executable file
500
etc/servers/work-server/src/routes/chat.ts
Executable file
@@ -0,0 +1,500 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { access, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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 { getChatRuntimeController } from '../services/chat-service.js';
|
||||
import {
|
||||
createChatConversation,
|
||||
deleteUnansweredChatConversationRequest,
|
||||
deleteChatConversation,
|
||||
ensureChatConversationTables,
|
||||
getChatConversation,
|
||||
listChatConversationActivityLogs,
|
||||
listChatConversationMessages,
|
||||
listChatConversationRequests,
|
||||
listChatConversations,
|
||||
markChatConversationResponsesRead,
|
||||
updateChatConversationContext,
|
||||
} from '../services/chat-room-service.js';
|
||||
import { chatRuntimeService } from '../services/chat-runtime-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) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case '.ts':
|
||||
case '.tsx':
|
||||
case '.js':
|
||||
case '.jsx':
|
||||
case '.mjs':
|
||||
case '.cjs':
|
||||
case '.json':
|
||||
case '.css':
|
||||
case '.html':
|
||||
case '.md':
|
||||
case '.txt':
|
||||
case '.diff':
|
||||
return 'text/plain; charset=utf-8';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function buildChatResourcePublicUrl(relativePath: string) {
|
||||
const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
|
||||
return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')}`;
|
||||
}
|
||||
|
||||
function normalizeChatResourceWildcard(wildcard: string) {
|
||||
const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, '');
|
||||
|
||||
if (!cleaned) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (cleaned.startsWith('.codex_chat/')) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
return path.posix.join('.codex_chat', cleaned);
|
||||
}
|
||||
|
||||
async function serveChatPublicResource(
|
||||
repoPath: string,
|
||||
wildcard: string,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const requestedRelativePath = normalizeChatResourceWildcard(wildcard);
|
||||
|
||||
if (!requestedRelativePath) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅 리소스를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const publicRoot = path.join(repoPath, 'public');
|
||||
const absolutePath = path.resolve(publicRoot, requestedRelativePath);
|
||||
|
||||
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
|
||||
return reply.code(403).send({
|
||||
message: '허용되지 않은 경로입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await access(absolutePath);
|
||||
const fileStat = await stat(absolutePath);
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅 리소스를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return reply.code(404).send({
|
||||
message: '채팅 리소스를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.type(resolveStaticContentType(absolutePath));
|
||||
return reply.send(createReadStream(absolutePath));
|
||||
}
|
||||
|
||||
function sanitizeChatAttachmentFileName(fileName: string) {
|
||||
const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' ');
|
||||
const compact = normalized || 'attachment';
|
||||
return compact.length > 120 ? compact.slice(-120) : compact;
|
||||
}
|
||||
|
||||
function resolveChatAttachmentRepoPath() {
|
||||
return path.resolve(env.PLAN_MAIN_PROJECT_REPO_PATH ?? env.PLAN_GIT_REPO_PATH);
|
||||
}
|
||||
|
||||
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
||||
const raw = request.headers['x-client-id'];
|
||||
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||
}
|
||||
|
||||
function canViewAllConversations(request: { headers: Record<string, unknown> }) {
|
||||
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
|
||||
}
|
||||
|
||||
export async function registerChatRoutes(app: FastifyInstance) {
|
||||
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
||||
});
|
||||
|
||||
app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => {
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
||||
});
|
||||
|
||||
app.get('/api/chat/setup', async () => {
|
||||
await ensureChatConversationTables();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'],
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/conversations', async (request) => {
|
||||
const query = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
const viewerClientId = getClientIdHeader(request);
|
||||
const clientId = canViewAllConversations(request) ? null : viewerClientId;
|
||||
const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/runtime', async () => {
|
||||
return {
|
||||
ok: true,
|
||||
item: chatRuntimeService.getSnapshot(),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const buffer = Buffer.from(payload.contentBase64, 'base64');
|
||||
|
||||
if (buffer.byteLength === 0) {
|
||||
return reply.code(400).send({
|
||||
message: '업로드할 파일 내용을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) {
|
||||
return reply.code(413).send({
|
||||
message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const safeFileName = sanitizeChatAttachmentFileName(payload.fileName);
|
||||
const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
const relativePath = path.posix.join(
|
||||
'public',
|
||||
'.codex_chat',
|
||||
payload.sessionId,
|
||||
'resource',
|
||||
'uploads',
|
||||
`${fileToken}-${safeFileName}`,
|
||||
);
|
||||
const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/'));
|
||||
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, buffer);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: {
|
||||
id: randomUUID(),
|
||||
name: payload.fileName,
|
||||
path: relativePath,
|
||||
publicUrl: buildChatResourcePublicUrl(relativePath),
|
||||
size: buffer.byteLength,
|
||||
mimeType: payload.mimeType?.trim() || 'application/octet-stream',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const controller = getChatRuntimeController();
|
||||
|
||||
if (!controller) {
|
||||
return reply.code(503).send({
|
||||
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: controller.getJobDetail(params.requestId),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => {
|
||||
const params = z.object({
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const controller = getChatRuntimeController();
|
||||
|
||||
if (!controller) {
|
||||
return reply.code(503).send({
|
||||
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const cancelled = await controller.cancelJob(params.requestId);
|
||||
|
||||
if (!cancelled) {
|
||||
return reply.code(404).send({
|
||||
message: '취소할 실행 중 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cancelled: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => {
|
||||
const params = z.object({
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const controller = getChatRuntimeController();
|
||||
|
||||
if (!controller) {
|
||||
return reply.code(503).send({
|
||||
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const removed = await controller.removeQueuedJob(params.requestId);
|
||||
|
||||
if (!removed) {
|
||||
return reply.code(404).send({
|
||||
message: '제거할 대기 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/conversations', async (request) => {
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
title: z.string().trim().max(200).optional(),
|
||||
contextLabel: z.string().trim().max(200).optional(),
|
||||
contextDescription: z.string().trim().max(2000).optional(),
|
||||
notifyOffline: z.boolean().optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const clientId = getClientIdHeader(request);
|
||||
const item = await createChatConversation({
|
||||
sessionId: payload.sessionId,
|
||||
clientId: clientId || null,
|
||||
title: payload.title ?? '새 대화',
|
||||
contextLabel: payload.contextLabel ?? null,
|
||||
contextDescription: payload.contextDescription ?? null,
|
||||
notifyOffline: payload.notifyOffline ?? true,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/conversations/:sessionId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const query = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
beforeMessageId: z.coerce.number().int().positive().optional(),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||
const item = await getChatConversation(params.sessionId, clientId || null);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const messageLimit = query.limit ?? 500;
|
||||
const messages = await listChatConversationMessages(params.sessionId, {
|
||||
limit: messageLimit,
|
||||
beforeMessageId: query.beforeMessageId ?? null,
|
||||
});
|
||||
const requests = await listChatConversationRequests(params.sessionId, 500);
|
||||
const activityLogs = await listChatConversationActivityLogs(params.sessionId, 500);
|
||||
const oldestLoadedMessageId = messages[0]?.id ?? null;
|
||||
const hasOlderMessages =
|
||||
oldestLoadedMessageId != null
|
||||
? (await listChatConversationMessages(params.sessionId, {
|
||||
limit: 1,
|
||||
beforeMessageId: oldestLoadedMessageId,
|
||||
})).length > 0
|
||||
: false;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
messages,
|
||||
requests,
|
||||
activityLogs,
|
||||
oldestLoadedMessageId,
|
||||
hasOlderMessages,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
|
||||
const clientId = getClientIdHeader(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({
|
||||
message: '읽음 처리를 위한 clientId가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await markChatConversationResponsesRead(params.sessionId, clientId);
|
||||
|
||||
if (!result) {
|
||||
return reply.code(404).send({
|
||||
message: '읽음 처리할 채팅방을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
|
||||
const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId);
|
||||
|
||||
if (!result.deleted) {
|
||||
if (result.reason === 'not_found') {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (result.reason === 'answered') {
|
||||
return reply.code(409).send({
|
||||
message: '이미 답변이 연결된 요청은 삭제할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(409).send({
|
||||
message: '현재 처리 중인 요청은 삭제할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: true,
|
||||
sessionId: params.sessionId,
|
||||
requestId: params.requestId,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/chat/conversations/:sessionId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
contextLabel: z.string().trim().max(200).optional().nullable(),
|
||||
contextDescription: z.string().trim().max(2000).optional().nullable(),
|
||||
notifyOffline: z.boolean().optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||
const current = await getChatConversation(params.sessionId, clientId || null);
|
||||
|
||||
if (!current) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const item = await updateChatConversationContext(params.sessionId, {
|
||||
title: payload.title ?? current.title,
|
||||
clientId: current.clientId,
|
||||
contextLabel: payload.contextLabel ?? current.contextLabel,
|
||||
contextDescription: payload.contextDescription ?? current.contextDescription,
|
||||
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/chat/conversations/:sessionId', 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: '삭제할 채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteChatConversation(params.sessionId);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted,
|
||||
sessionId: params.sessionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
91
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file
91
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import { registerJsonBodyParser } from '../json-body.js';
|
||||
import { registerBoardRoutes } from './board.js';
|
||||
import { registerNotificationRoutes } from './notification.js';
|
||||
|
||||
function createRouteRecorder() {
|
||||
const routes: Array<{ method: string; path: string }> = [];
|
||||
const record = (method: string) => (path: string) => {
|
||||
routes.push({ method, path });
|
||||
return undefined;
|
||||
};
|
||||
const app = {
|
||||
get: record('GET'),
|
||||
post: record('POST'),
|
||||
put: record('PUT'),
|
||||
patch: record('PATCH'),
|
||||
delete: record('DELETE'),
|
||||
};
|
||||
|
||||
return {
|
||||
app: app as any,
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
test('registerJsonBodyParser treats empty json body as an empty object', async () => {
|
||||
const app = Fastify();
|
||||
registerJsonBodyParser(app);
|
||||
app.post('/json', async (request) => ({
|
||||
body: request.body,
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/json',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: '',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json(), {
|
||||
body: {},
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('registerJsonBodyParser still rejects malformed json', async () => {
|
||||
const app = Fastify();
|
||||
registerJsonBodyParser(app);
|
||||
app.post('/json', async (request) => ({
|
||||
body: request.body,
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/json',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: '{',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.match(response.body, /valid JSON/i);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('registerBoardRoutes keeps setup and items compatibility routes', async () => {
|
||||
const { app, routes } = createRouteRecorder();
|
||||
await registerBoardRoutes(app);
|
||||
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/setup'));
|
||||
assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/board/setup'));
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/posts'));
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/items'));
|
||||
});
|
||||
|
||||
test('registerNotificationRoutes exposes notification message routes', async () => {
|
||||
const { app, routes } = createRouteRecorder();
|
||||
await registerNotificationRoutes(app);
|
||||
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages'));
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages/:id'));
|
||||
assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/notifications/messages'));
|
||||
assert.ok(routes.some((route) => route.method === 'PATCH' && route.path === '/api/notifications/messages/:id'));
|
||||
assert.ok(routes.some((route) => route.method === 'DELETE' && route.path === '/api/notifications/messages/:id'));
|
||||
});
|
||||
220
etc/servers/work-server/src/routes/crud.ts
Executable file
220
etc/servers/work-server/src/routes/crud.ts
Executable file
@@ -0,0 +1,220 @@
|
||||
import type { Knex } from 'knex';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { assertIdentifier } from '../lib/identifier.js';
|
||||
|
||||
const filterSchema = z.object({
|
||||
field: z.string(),
|
||||
operator: z
|
||||
.enum(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'like', 'in', 'null', 'notNull'])
|
||||
.default('eq'),
|
||||
value: z.any().optional(),
|
||||
});
|
||||
|
||||
const orderBySchema = z.object({
|
||||
field: z.string(),
|
||||
direction: z.enum(['asc', 'desc']).default('asc'),
|
||||
});
|
||||
|
||||
const selectSchema = z.object({
|
||||
columns: z.array(z.string()).optional(),
|
||||
where: z.array(filterSchema).optional(),
|
||||
orderBy: z.array(orderBySchema).optional(),
|
||||
limit: z.number().int().positive().max(500).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const insertSchema = z.object({
|
||||
data: z.record(z.string(), z.any()).or(z.array(z.record(z.string(), z.any()))),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
data: z.record(z.string(), z.any()),
|
||||
where: z.array(filterSchema).default([]),
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
where: z.array(filterSchema).default([]),
|
||||
});
|
||||
|
||||
const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']);
|
||||
|
||||
function applyFilters(query: Knex.QueryBuilder, filters: z.infer<typeof filterSchema>[] = []) {
|
||||
filters.forEach((filter) => {
|
||||
const field = assertIdentifier(filter.field, 'field');
|
||||
|
||||
switch (filter.operator) {
|
||||
case 'eq':
|
||||
query.where(field, filter.value);
|
||||
break;
|
||||
case 'ne':
|
||||
query.whereNot(field, filter.value);
|
||||
break;
|
||||
case 'gt':
|
||||
query.where(field, '>', filter.value);
|
||||
break;
|
||||
case 'gte':
|
||||
query.where(field, '>=', filter.value);
|
||||
break;
|
||||
case 'lt':
|
||||
query.where(field, '<', filter.value);
|
||||
break;
|
||||
case 'lte':
|
||||
query.where(field, '<=', filter.value);
|
||||
break;
|
||||
case 'like':
|
||||
query.where(field, 'like', filter.value);
|
||||
break;
|
||||
case 'in':
|
||||
query.whereIn(field, Array.isArray(filter.value) ? filter.value : [filter.value]);
|
||||
break;
|
||||
case 'null':
|
||||
query.whereNull(field);
|
||||
break;
|
||||
case 'notNull':
|
||||
query.whereNotNull(field);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerCrudRoutes(app: FastifyInstance) {
|
||||
function getRequestTraceContext(request: FastifyRequest) {
|
||||
return {
|
||||
ip: request.ip,
|
||||
remoteAddress: request.raw.socket.remoteAddress,
|
||||
host: request.headers.host,
|
||||
origin: request.headers.origin,
|
||||
referer: request.headers.referer,
|
||||
userAgent: request.headers['user-agent'],
|
||||
clientId: request.headers['x-client-id'],
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCrudUpdatePayload(table: string, payload: z.infer<typeof updateSchema>) {
|
||||
if (table !== 'board_posts') {
|
||||
return {
|
||||
dataKeys: Object.keys(payload.data),
|
||||
where: payload.where,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dataKeys: Object.keys(payload.data),
|
||||
where: payload.where,
|
||||
automationPlanItemId: payload.data.automation_plan_item_id ?? null,
|
||||
automationReceivedAt: payload.data.automation_received_at ?? null,
|
||||
title: typeof payload.data.title === 'string' ? payload.data.title : undefined,
|
||||
contentLength: typeof payload.data.content === 'string' ? payload.data.content.length : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/crud/:table/select', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = selectSchema.parse(request.body ?? {});
|
||||
const columns = payload.columns?.map((column) => assertIdentifier(column, 'column')) ?? ['*'];
|
||||
|
||||
const query = db(table).select(columns);
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
payload.orderBy?.forEach((order) => {
|
||||
query.orderBy(assertIdentifier(order.field, 'order field'), order.direction);
|
||||
});
|
||||
|
||||
if (payload.limit) {
|
||||
query.limit(payload.limit);
|
||||
}
|
||||
|
||||
if (payload.offset) {
|
||||
query.offset(payload.offset);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/crud/:table/insert', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = insertSchema.parse(request.body);
|
||||
const inserted = await db(table).insert(payload.data).returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
rows: inserted,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/crud/:table/update', async (request, reply) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = updateSchema.parse(request.body);
|
||||
const query = db(table);
|
||||
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
if (table === 'board_posts') {
|
||||
request.log.warn(
|
||||
{
|
||||
table,
|
||||
payload: summarizeCrudUpdatePayload(table, payload),
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Board post CRUD update requested',
|
||||
);
|
||||
|
||||
const protectedFields = Object.keys(payload.data).filter((field) => protectedBoardPostAutomationFields.has(field));
|
||||
|
||||
if (protectedFields.length) {
|
||||
request.log.warn(
|
||||
{
|
||||
table,
|
||||
protectedFields,
|
||||
payload: summarizeCrudUpdatePayload(table, payload),
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Board post CRUD update blocked from changing automation link fields',
|
||||
);
|
||||
|
||||
return reply.code(409).send({
|
||||
message: '자동화 접수 연결 필드는 일반 CRUD 수정으로 변경할 수 없습니다.',
|
||||
protectedFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await query.update(payload.data).returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/crud/:table/delete', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = deleteSchema.parse(request.body ?? {});
|
||||
const query = db(table);
|
||||
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
const rows = await query.delete().returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
}
|
||||
119
etc/servers/work-server/src/routes/ddl.ts
Executable file
119
etc/servers/work-server/src/routes/ddl.ts
Executable file
@@ -0,0 +1,119 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { assertIdentifier } from '../lib/identifier.js';
|
||||
|
||||
const columnSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
nullable: z.boolean().optional(),
|
||||
primary: z.boolean().optional(),
|
||||
unique: z.boolean().optional(),
|
||||
defaultTo: z.any().optional(),
|
||||
});
|
||||
|
||||
const createTableSchema = z.object({
|
||||
tableName: z.string(),
|
||||
columns: z.array(columnSchema).min(1),
|
||||
});
|
||||
|
||||
const dropTableSchema = z.object({
|
||||
tableName: z.string(),
|
||||
});
|
||||
|
||||
const addColumnSchema = z.object({
|
||||
tableName: z.string(),
|
||||
column: columnSchema,
|
||||
});
|
||||
|
||||
const dropColumnSchema = z.object({
|
||||
tableName: z.string(),
|
||||
columnName: z.string(),
|
||||
});
|
||||
|
||||
const rawDdlSchema = z.object({
|
||||
sql: z.string().min(1),
|
||||
});
|
||||
|
||||
function applyColumn(tableBuilder: any, column: z.infer<typeof columnSchema>) {
|
||||
const name = assertIdentifier(column.name, 'column name');
|
||||
const definition = tableBuilder.specificType(name, column.type);
|
||||
|
||||
if (column.nullable === false) {
|
||||
definition.notNullable();
|
||||
}
|
||||
|
||||
if (column.nullable === true) {
|
||||
definition.nullable();
|
||||
}
|
||||
|
||||
if (column.primary) {
|
||||
definition.primary();
|
||||
}
|
||||
|
||||
if (column.unique) {
|
||||
definition.unique();
|
||||
}
|
||||
|
||||
if (column.defaultTo !== undefined) {
|
||||
definition.defaultTo(column.defaultTo);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerDdlRoutes(app: FastifyInstance) {
|
||||
app.post('/api/ddl/create-table', async (request) => {
|
||||
const payload = createTableSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
|
||||
await db.schema.createTable(tableName, (table) => {
|
||||
payload.columns.forEach((column) => {
|
||||
applyColumn(table, column);
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true, action: 'create-table', tableName };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/drop-table', async (request) => {
|
||||
const payload = dropTableSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
|
||||
await db.schema.dropTableIfExists(tableName);
|
||||
|
||||
return { ok: true, action: 'drop-table', tableName };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/add-column', async (request) => {
|
||||
const payload = addColumnSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
|
||||
await db.schema.alterTable(tableName, (table) => {
|
||||
applyColumn(table, payload.column);
|
||||
});
|
||||
|
||||
return { ok: true, action: 'add-column', tableName, column: payload.column.name };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/drop-column', async (request) => {
|
||||
const payload = dropColumnSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
const columnName = assertIdentifier(payload.columnName, 'column name');
|
||||
|
||||
await db.schema.alterTable(tableName, (table) => {
|
||||
table.dropColumn(columnName);
|
||||
});
|
||||
|
||||
return { ok: true, action: 'drop-column', tableName, columnName };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/raw', async (request) => {
|
||||
const payload = rawDdlSchema.parse(request.body);
|
||||
const result = await db.raw(payload.sql);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: 'raw',
|
||||
result,
|
||||
};
|
||||
});
|
||||
}
|
||||
49
etc/servers/work-server/src/routes/error-log.ts
Executable file
49
etc/servers/work-server/src/routes/error-log.ts
Executable file
@@ -0,0 +1,49 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createErrorLog,
|
||||
createErrorLogSchema,
|
||||
hasErrorLogViewAccessToken,
|
||||
listErrorLogs,
|
||||
setupErrorLogTable,
|
||||
} from '../services/error-log-service.js';
|
||||
|
||||
const errorLogListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export async function registerErrorLogRoutes(app: FastifyInstance) {
|
||||
app.post('/api/error-logs/setup', async () => {
|
||||
return setupErrorLogTable();
|
||||
});
|
||||
|
||||
app.get('/api/error-logs', async (request, reply) => {
|
||||
const accessToken = request.headers['x-access-token'];
|
||||
|
||||
if (!hasErrorLogViewAccessToken(accessToken)) {
|
||||
reply.status(403);
|
||||
return {
|
||||
ok: false,
|
||||
message: '에러 로그 조회 권한이 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const query = errorLogListQuerySchema.parse(request.query ?? {});
|
||||
const items = await listErrorLogs(query.limit ?? 50);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/error-logs/report', async (request) => {
|
||||
const payload = createErrorLogSchema.parse(request.body);
|
||||
const item = await createErrorLog(payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
}
|
||||
13
etc/servers/work-server/src/routes/health.ts
Executable file
13
etc/servers/work-server/src/routes/health.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance) {
|
||||
const respondHealth = async () => ({
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
app.get('/', respondHealth);
|
||||
app.get('/api', respondHealth);
|
||||
app.get('/health', respondHealth);
|
||||
}
|
||||
207
etc/servers/work-server/src/routes/notification.ts
Executable file
207
etc/servers/work-server/src/routes/notification.ts
Executable file
@@ -0,0 +1,207 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
listIosNotificationTokens,
|
||||
getAutomationNotificationPreference,
|
||||
getWebPushConfig,
|
||||
registerIosNotificationToken,
|
||||
registerAutomationNotificationPreferenceSchema,
|
||||
registerIosTokenSchema,
|
||||
registerWebPushSubscription,
|
||||
registerWebPushSubscriptionSchema,
|
||||
sendNotifications,
|
||||
sendIosNotificationSchema,
|
||||
setupNotificationTables,
|
||||
upsertAutomationNotificationPreference,
|
||||
unregisterIosNotificationToken,
|
||||
unregisterIosTokenSchema,
|
||||
unregisterWebPushSubscription,
|
||||
unregisterWebPushSubscriptionSchema,
|
||||
} from '../services/notification-service.js';
|
||||
import {
|
||||
createNotificationMessage,
|
||||
deleteNotificationMessage,
|
||||
getNotificationMessage,
|
||||
listNotificationMessages,
|
||||
notificationMessageListQuerySchema,
|
||||
notificationMessagePayloadSchema,
|
||||
notificationMessageReadPayloadSchema,
|
||||
updateNotificationMessageReadState,
|
||||
} from '../services/notification-message-service.js';
|
||||
|
||||
const automationNotificationPreferenceQuerySchema = z.object({
|
||||
targetKind: z.enum(['client', 'ios-token', 'ios-token-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;
|
||||
return clientId?.trim() ?? '';
|
||||
}
|
||||
|
||||
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/webpush/config', async () => getWebPushConfig());
|
||||
|
||||
app.get('/api/notifications/messages', async (request) => {
|
||||
const query = notificationMessageListQuerySchema.parse(request.query ?? {});
|
||||
return {
|
||||
ok: true,
|
||||
...(await listNotificationMessages(query)),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/notifications/messages/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getNotificationMessage(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '알림 메시지를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/notifications/messages', async (request) => {
|
||||
const item = await createNotificationMessage(notificationMessagePayloadSchema.parse(request.body ?? {}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/notifications/messages/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await updateNotificationMessageReadState(id, notificationMessageReadPayloadSchema.parse(request.body ?? {}));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '상태를 변경할 알림 메시지를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/messages/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const deleted = await deleteNotificationMessage(id);
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 알림 메시지를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/notifications/preferences/automation', async (request) => {
|
||||
const query = automationNotificationPreferenceQuerySchema.parse(request.query ?? {});
|
||||
const targetId = query.targetId || getClientIdHeader(request);
|
||||
const targetKind = query.targetId ? query.targetKind ?? 'client' : 'client';
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
automation: await getAutomationNotificationPreferenceWithFallback(targetId, targetKind),
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/notifications/preferences/automation', async (request, reply) => {
|
||||
try {
|
||||
const payload = registerAutomationNotificationPreferenceSchema.parse(request.body ?? {});
|
||||
const targetId = payload.targetId || getClientIdHeader(request);
|
||||
|
||||
if (!targetId) {
|
||||
throw new Error('알림 설정을 저장할 클라이언트 ID가 없습니다.');
|
||||
}
|
||||
|
||||
return upsertAutomationNotificationPreference({
|
||||
...payload,
|
||||
targetId,
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/subscriptions/web', async (request) => {
|
||||
const payload = unregisterWebPushSubscriptionSchema.parse(request.body ?? {});
|
||||
return unregisterWebPushSubscription(payload.endpoint);
|
||||
});
|
||||
|
||||
app.post('/api/notifications/send', async (request) => {
|
||||
const payload = sendIosNotificationSchema.parse(request.body ?? {});
|
||||
return sendNotifications(payload);
|
||||
});
|
||||
|
||||
app.post('/api/notifications/send-test', async (request) => {
|
||||
const payload = sendIosNotificationSchema.parse(request.body ?? {});
|
||||
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;
|
||||
}
|
||||
981
etc/servers/work-server/src/routes/plan.ts
Executable file
981
etc/servers/work-server/src/routes/plan.ts
Executable file
@@ -0,0 +1,981 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import { notifyPlanEvent } from '../services/plan-notification-service.js';
|
||||
import { shouldNotifyPlanRestart } from '../services/plan-notification-policy.js';
|
||||
import {
|
||||
PLAN_ACTION_TABLE,
|
||||
PLAN_ISSUE_TABLE,
|
||||
PLAN_RELEASE_REVIEW_TABLE,
|
||||
PLAN_SOURCE_WORK_TABLE,
|
||||
PLAN_TABLE,
|
||||
appendLatestIssueAction,
|
||||
cancelPlanRelease,
|
||||
createPlanItem,
|
||||
createPlanActionHistory,
|
||||
createPlanSourceWorkHistory,
|
||||
createPlanSchema,
|
||||
deletePlanItem,
|
||||
ensurePlanTable,
|
||||
getPlanSourceWorkHistory,
|
||||
getBoardPostLinkedToPlanItem,
|
||||
getPlanItemById,
|
||||
formatPlanNotificationLabel,
|
||||
issueActionSchema,
|
||||
listPlanActionHistories,
|
||||
listPlanIssueHistories,
|
||||
listPlanItems,
|
||||
listPlanReleaseReviewBoardItems,
|
||||
listPlanSourceWorkHistories,
|
||||
listPlanQuerySchema,
|
||||
mapPlanActionRow,
|
||||
mapPlanIssueRow,
|
||||
mapPlanSourceWorkRow,
|
||||
markPlanAsStarted,
|
||||
planStatuses,
|
||||
markPlanAsCompleted,
|
||||
markPlanAsDevelopmentComplete,
|
||||
queuePlanRetryFromFailure,
|
||||
queuePlanRetryFromIssueAction,
|
||||
requestPlanMainMerge,
|
||||
resumePlanDevelopmentFromRelease,
|
||||
retryPlanBranch,
|
||||
retryPlanWork,
|
||||
retryPlanMerge,
|
||||
setupSchema,
|
||||
updatePlanReleaseReviewSchema,
|
||||
upsertPlanReleaseReview,
|
||||
updatePlanItem,
|
||||
updatePlanItemJangsingProcessingRequired,
|
||||
updatePlanJangsingProcessingSchema,
|
||||
updatePlanSchema,
|
||||
} from '../services/plan-service.js';
|
||||
import { db } from '../db/client.js';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import { recreateReleaseBranchFromMain } from '../services/git-service.js';
|
||||
import { registerErrorLogBoardPosts } from '../services/error-log-plan-registration-service.js';
|
||||
import {
|
||||
PLAN_SCHEDULED_TASK_TABLE,
|
||||
createPlanScheduledTask,
|
||||
createPlanScheduledTaskSchema,
|
||||
deletePlanScheduledTask,
|
||||
ensurePlanScheduledTaskTable,
|
||||
getPlanScheduledTaskById,
|
||||
listPlanScheduledTasks,
|
||||
mapPlanScheduledTaskRow,
|
||||
registerPlanScheduledTaskNow,
|
||||
updatePlanScheduledTask,
|
||||
updatePlanScheduledTaskSchema,
|
||||
} from '../services/plan-schedule-service.js';
|
||||
import { getVisitorClientByClientId } from '../services/visitor-history-service.js';
|
||||
|
||||
const completeActionSchema = z.object({
|
||||
note: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
const actionNoteSchema = z.object({
|
||||
actionNote: z.string().trim().min(1),
|
||||
actionType: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
const createSourceWorkSchema = z.object({
|
||||
summary: z.string().trim().min(1),
|
||||
branchName: z.string().trim().min(1),
|
||||
commitHash: z.string().trim().min(1).nullable().optional(),
|
||||
previewUrl: z.string().trim().url().nullable().optional(),
|
||||
changedFiles: z.array(z.string()).default([]),
|
||||
commandLog: z.string().nullable().optional(),
|
||||
diffText: z.string().nullable().optional(),
|
||||
sourceFiles: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string().trim().min(1),
|
||||
previousPath: z.string().trim().min(1).nullable().optional(),
|
||||
status: z.enum(['added', 'modified', 'deleted', 'renamed', 'binary', 'unknown']),
|
||||
language: z.string().trim().min(1),
|
||||
content: z.string(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
function getRequestTraceContext(request: FastifyRequest) {
|
||||
return {
|
||||
ip: request.ip,
|
||||
remoteAddress: request.raw.socket.remoteAddress,
|
||||
host: request.headers.host,
|
||||
origin: request.headers.origin,
|
||||
referer: request.headers.referer,
|
||||
userAgent: request.headers['user-agent'],
|
||||
clientId: request.headers['x-client-id'],
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackAddress(value: string | null | undefined) {
|
||||
const normalizedValue = String(value ?? '').trim();
|
||||
return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1';
|
||||
}
|
||||
|
||||
function hasPlanAccessToken(accessToken: string | string[] | undefined) {
|
||||
return hasErrorLogViewAccessToken(accessToken);
|
||||
}
|
||||
|
||||
function hasPlanAccess(request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) {
|
||||
if (hasPlanAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress);
|
||||
}
|
||||
|
||||
function requirePlanAccessToken(
|
||||
request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } },
|
||||
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
|
||||
) {
|
||||
if (hasPlanAccess(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 수정할 수 있습니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleListPlanScheduledTasks(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanScheduledTasks();
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanScheduledTaskRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCreatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
|
||||
const row = await createPlanScheduledTask(payload);
|
||||
const immediateRegistration = payload.enabled && payload.immediateRunEnabled ? await registerPlanScheduledTaskNow(Number(row.id)) : null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetPlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await getPlanScheduledTaskById(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '스케줄을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUpdatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = updatePlanScheduledTaskSchema.parse(request.body ?? {});
|
||||
const row = await updatePlanScheduledTask(id, payload);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 스케줄을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const shouldTriggerImmediateRegistration =
|
||||
row &&
|
||||
Boolean(row.enabled ?? true) &&
|
||||
Boolean(row.immediate_run_enabled ?? true) &&
|
||||
payload.enabled !== false;
|
||||
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/plan/registrations/error-logs', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z.object({
|
||||
rangeStart: z.coerce.date().optional(),
|
||||
rangeEnd: z.coerce.date().optional(),
|
||||
maxGroups: z.coerce.number().int().min(1).max(24).optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const result = await registerErrorLogBoardPosts(payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rangeStart: result.rangeStart.toISOString(),
|
||||
rangeEnd: result.rangeEnd.toISOString(),
|
||||
recentLogCount: result.recentLogs.length,
|
||||
candidateCount: result.candidates.length,
|
||||
rawCandidateCount: result.rawCandidates.length,
|
||||
createdBoardPosts: result.createdPosts,
|
||||
skippedBoardPosts: result.skippedPosts,
|
||||
};
|
||||
});
|
||||
|
||||
async function handleDeletePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await deletePlanScheduledTask(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 스케줄을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/plan/statuses', async () => ({
|
||||
items: planStatuses,
|
||||
}));
|
||||
|
||||
app.post('/api/plan/setup', async (request) => {
|
||||
const payload = setupSchema.parse(request.body ?? {});
|
||||
|
||||
if (payload.recreate) {
|
||||
await db.schema.dropTableIfExists(PLAN_SCHEDULED_TASK_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_ACTION_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_ISSUE_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_SOURCE_WORK_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_TABLE);
|
||||
}
|
||||
|
||||
await ensurePlanTable();
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table: PLAN_TABLE,
|
||||
scheduleTable: PLAN_SCHEDULED_TASK_TABLE,
|
||||
releaseReviewTable: PLAN_RELEASE_REVIEW_TABLE,
|
||||
statuses: planStatuses,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/release-reviews', async (request) => {
|
||||
const items = await listPlanReleaseReviewBoardItems({
|
||||
maskNote: !hasPlanAccess(request),
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/plan/release-reviews/:planItemId', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const planItemId = z.coerce.number().int().positive().parse((request.params as { planItemId: string }).planItemId);
|
||||
const payload = updatePlanReleaseReviewSchema.parse(request.body ?? {});
|
||||
const clientId = String(request.headers['x-client-id'] ?? '').trim();
|
||||
const visitor = clientId ? await getVisitorClientByClientId(clientId) : null;
|
||||
const review = await upsertPlanReleaseReview(planItemId, payload, {
|
||||
clientId: clientId || null,
|
||||
nickname: visitor?.nickname ?? null,
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return reply.code(404).send({
|
||||
message: '검수 대상을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: review,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/scheduled-tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/schedule/tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/schedule', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/schedules', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/scheduled-tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/schedule/tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/schedule', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/schedules', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/scheduled-tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plan/schedule/tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plan/schedule/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plan/schedules/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/scheduled-tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/schedule/tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/schedule/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/schedules/:id', handleGetPlanScheduledTask);
|
||||
app.post('/api/plan/scheduled-tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plan/schedule/tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plan/schedule', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plan/schedules', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/scheduled-tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/schedule/tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/schedule', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/schedules', handleCreatePlanScheduledTask);
|
||||
app.patch('/api/plan/scheduled-tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plan/schedule/tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plan/schedule/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plan/schedules/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/scheduled-tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/schedule/tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/schedule/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/schedules/:id', handleUpdatePlanScheduledTask);
|
||||
app.delete('/api/plan/scheduled-tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plan/schedule/tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plan/schedule/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plan/schedules/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/scheduled-tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/schedule/tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/schedule/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/schedules/:id', handleDeletePlanScheduledTask);
|
||||
|
||||
app.get('/api/plan/items', async (request, reply) => {
|
||||
const parsedQuery = listPlanQuerySchema.safeParse(request.query ?? {});
|
||||
|
||||
if (!parsedQuery.success) {
|
||||
return reply.code(400).send({
|
||||
message: '유효하지 않은 status 쿼리입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const query = parsedQuery.data;
|
||||
const items = await listPlanItems(query.status, {
|
||||
maskNote: !hasPlanAccess(request),
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id', async (request, reply) => {
|
||||
const hasAccess = hasPlanAccess(request);
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await getPlanItemById(id, {
|
||||
maskNote: !hasAccess,
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item: row,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = createPlanSchema.parse(request.body ?? {});
|
||||
const createdRow = await createPlanItem(payload);
|
||||
const row = await getPlanItemById(Number(createdRow.id));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: row,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '작업 항목 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/plan/items/:id', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = updatePlanSchema.parse(request.body ?? {});
|
||||
const updatedRow = await updatePlanItem(id, payload);
|
||||
|
||||
if (!updatedRow) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const row = await getPlanItemById(id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: row,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '작업 항목 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/plan/items/:id/jangsing-processing', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = updatePlanJangsingProcessingSchema.parse(request.body ?? {});
|
||||
const updatedRow = await updatePlanItemJangsingProcessingRequired(id, payload.jangsingProcessingRequired);
|
||||
|
||||
if (!updatedRow) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '기능동작확인 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/plan/items/:id', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
request.log.warn(
|
||||
{
|
||||
planItemId: id,
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Plan item delete requested',
|
||||
);
|
||||
const linkedBoardPost = await getBoardPostLinkedToPlanItem(id);
|
||||
|
||||
if (linkedBoardPost) {
|
||||
request.log.warn(
|
||||
{
|
||||
planItemId: id,
|
||||
boardPostId: linkedBoardPost.id,
|
||||
boardPostTitle: linkedBoardPost.title,
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Plan item delete blocked because it is linked to a board post',
|
||||
);
|
||||
|
||||
return reply.code(409).send({
|
||||
message: `자동화 접수된 항목은 삭제할 수 없습니다. 연결 게시글 #${linkedBoardPost.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const row = await deletePlanItem(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/complete-development', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await markPlanAsDevelopmentComplete(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] release 반영 대기`,
|
||||
'수동 작업완료로 release 반영 대기 상태가 되었습니다.',
|
||||
'development-completed',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/complete', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = completeActionSchema.parse(request.body ?? {});
|
||||
const row = await markPlanAsCompleted(id, payload.note);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 완료 처리`,
|
||||
payload.note ?? '작업이 완료 처리되었습니다.',
|
||||
'plan-completed',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/start-work', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await markPlanAsStarted(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업시작`,
|
||||
'작업이 시작되었습니다.',
|
||||
'work-started',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/retry-branch', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await retryPlanBranch(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
'브랜치 재시도를 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/retry-work', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await retryPlanWork(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
'자동 작업 재처리를 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/retry-merge', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await retryPlanMerge(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
row.worker_status === 'main반영대기' ? 'main 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/cancel-release', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const env = getEnv();
|
||||
const isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패';
|
||||
|
||||
if (!isReleaseMergeFailure) {
|
||||
await recreateReleaseBranchFromMain(
|
||||
{
|
||||
repoPath: env.PLAN_GIT_REPO_PATH,
|
||||
releaseBranch: env.PLAN_RELEASE_BRANCH,
|
||||
mainBranch: env.PLAN_MAIN_BRANCH,
|
||||
},
|
||||
String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await cancelPlanRelease(id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result?.item ?? (await getPlanItemById(id)),
|
||||
message: result?.message,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : 'release 작업취소 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/request-main-merge', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const result = await requestPlanMainMerge(id);
|
||||
|
||||
if (!result) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.item,
|
||||
message: result.message,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/issues', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanIssueHistories(id);
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanIssueRow),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/issues/action', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = issueActionSchema.parse(request.body ?? {});
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await appendLatestIssueAction(id, payload.actionNote, payload.resolve);
|
||||
const retryResult = await queuePlanRetryFromIssueAction(id, payload.actionNote, payload.retry);
|
||||
|
||||
if (payload.resolve) {
|
||||
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 이슈 해결 처리`,
|
||||
`${row.issue_tag} 이슈가 해결 처리되었습니다.`,
|
||||
'issue-resolved',
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldNotifyPlanRestart(retryResult)) {
|
||||
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
retryResult?.message ?? '작업 재시작을 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanIssueRow(row),
|
||||
planItem: retryResult?.item ?? (await getPlanItemById(id)),
|
||||
message: retryResult?.message,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '이슈 조치 기록 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/actions', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanActionHistories(id);
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanActionRow),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/source-works', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanSourceWorkHistories(id);
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanSourceWorkRow),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/source-works/:sourceWorkId', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const params = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
sourceWorkId: z.coerce.number().int().positive(),
|
||||
}).parse(request.params);
|
||||
const row = await getPlanSourceWorkHistory(params.id, params.sourceWorkId);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '소스 작업 이력을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item: mapPlanSourceWorkRow(row),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/source-works', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = createSourceWorkSchema.parse(request.body ?? {});
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const row = await createPlanSourceWorkHistory(id, payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanSourceWorkRow(row),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/note', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = actionNoteSchema.parse(request.body ?? {});
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!item.startedAt) {
|
||||
return reply.code(409).send({
|
||||
message: '작업시작 이후부터 조치 이력을 기록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const row = await createPlanActionHistory(id, payload.actionType ?? '추가조치', payload.actionNote);
|
||||
const releaseResumeResult = await resumePlanDevelopmentFromRelease(id, payload.actionNote);
|
||||
const retryResult = releaseResumeResult?.message
|
||||
? releaseResumeResult
|
||||
: await queuePlanRetryFromFailure(id, payload.actionNote);
|
||||
|
||||
if (shouldNotifyPlanRestart(retryResult)) {
|
||||
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
retryResult?.message ?? '작업 재시작을 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanActionRow(row),
|
||||
planItem: retryResult?.item ?? (await getPlanItemById(id)),
|
||||
message: retryResult?.message,
|
||||
};
|
||||
});
|
||||
}
|
||||
17
etc/servers/work-server/src/routes/schema.ts
Executable file
17
etc/servers/work-server/src/routes/schema.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export async function registerSchemaRoutes(app: FastifyInstance) {
|
||||
app.get('/api/schema/tables', async () => {
|
||||
const tables = await db('information_schema.tables')
|
||||
.select('table_name', 'table_schema')
|
||||
.where('table_type', 'BASE TABLE')
|
||||
.whereNotIn('table_schema', ['pg_catalog', 'information_schema'])
|
||||
.orderBy('table_schema')
|
||||
.orderBy('table_name');
|
||||
|
||||
return {
|
||||
items: tables,
|
||||
};
|
||||
});
|
||||
}
|
||||
54
etc/servers/work-server/src/routes/server-command.ts
Executable file
54
etc/servers/work-server/src/routes/server-command.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
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';
|
||||
|
||||
const serverCommandParamSchema = z.object({
|
||||
key: z.enum(serverCommandKeys),
|
||||
});
|
||||
|
||||
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) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
reply.status(403);
|
||||
void reply.send({
|
||||
message: '권한 토큰이 필요합니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
app.get('/api/server-commands', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: await listServerCommands(),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = serverCommandParamSchema.parse(request.params);
|
||||
const result = await restartServerCommand(key);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
};
|
||||
});
|
||||
}
|
||||
129
etc/servers/work-server/src/routes/visitor-history.ts
Executable file
129
etc/servers/work-server/src/routes/visitor-history.ts
Executable file
@@ -0,0 +1,129 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import {
|
||||
ensureVisitorHistoryTables,
|
||||
getVisitorClientByClientId,
|
||||
listVisitorClients,
|
||||
listVisitorClientsQuerySchema,
|
||||
listVisitorHistories,
|
||||
trackVisit,
|
||||
trackVisitSchema,
|
||||
updateVisitorNickname,
|
||||
updateVisitorNicknameSchema,
|
||||
visitorHistoryQuerySchema,
|
||||
} from '../services/visitor-history-service.js';
|
||||
|
||||
export async function registerVisitorHistoryRoutes(app: FastifyInstance) {
|
||||
function requireHistoryAccessToken(
|
||||
request: { headers: Record<string, unknown> },
|
||||
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
|
||||
) {
|
||||
if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 방문 이력을 조회할 수 있습니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
app.post('/api/history/setup', async () => {
|
||||
await ensureVisitorHistoryTables();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/history/track', async (request) => {
|
||||
const bodyPayload =
|
||||
request.body && typeof request.body === 'object'
|
||||
? (request.body as Record<string, unknown>)
|
||||
: {};
|
||||
const clientIdFromHeader = String(request.headers['x-client-id'] ?? '').trim();
|
||||
const payload = trackVisitSchema.parse({
|
||||
clientId: clientIdFromHeader || bodyPayload.clientId,
|
||||
url: bodyPayload.url,
|
||||
eventType: bodyPayload.eventType,
|
||||
userAgent:
|
||||
typeof bodyPayload.userAgent === 'string'
|
||||
? bodyPayload.userAgent
|
||||
: String(request.headers['user-agent'] ?? ''),
|
||||
});
|
||||
|
||||
const client = await trackVisit(payload, request.ip);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/history/visitors', async (request, reply) => {
|
||||
if (!requireHistoryAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = listVisitorClientsQuerySchema.parse(request.query ?? {});
|
||||
const items = await listVisitorClients(query.limit ?? 100, {
|
||||
search: query.search,
|
||||
clientId: query.clientId,
|
||||
nickname: query.nickname,
|
||||
path: query.path,
|
||||
visitedFrom: query.visitedFrom,
|
||||
visitedTo: query.visitedTo,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/history/visitors/:clientId', async (request, reply) => {
|
||||
if (!requireHistoryAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId);
|
||||
const query = visitorHistoryQuerySchema.parse(request.query ?? {});
|
||||
const client = await getVisitorClientByClientId(clientId);
|
||||
|
||||
if (!client) {
|
||||
return reply.code(404).send({
|
||||
message: '방문자를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const visits = await listVisitorHistories(clientId, query.limit ?? 200);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
visits,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/history/visitors/:clientId/nickname', async (request, reply) => {
|
||||
if (!requireHistoryAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId);
|
||||
const payload = updateVisitorNicknameSchema.parse(request.body ?? {});
|
||||
const client = await updateVisitorNickname(clientId, payload.nickname);
|
||||
|
||||
if (!client) {
|
||||
return reply.code(404).send({
|
||||
message: '닉네임을 수정할 방문자를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
};
|
||||
});
|
||||
}
|
||||
47
etc/servers/work-server/src/server.ts
Executable file
47
etc/servers/work-server/src/server.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
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 } from './services/chat-room-service.js';
|
||||
import { shutdownNotificationProvider } from './services/notification-service.js';
|
||||
import { PlanWorker } from './workers/plan-worker.js';
|
||||
|
||||
const app = createApp();
|
||||
const planWorker = new PlanWorker(app.log);
|
||||
const chatService = new ChatService(app.log);
|
||||
app.server.on('upgrade', chatService.attachUpgradeHandler());
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await clearAllChatConversationJobStates();
|
||||
await app.listen({
|
||||
host: '0.0.0.0',
|
||||
port: env.PORT,
|
||||
});
|
||||
planWorker.start();
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
app.log.info(`Received ${signal}, closing server`);
|
||||
|
||||
await planWorker.stop();
|
||||
chatService.close();
|
||||
await app.close();
|
||||
await shutdownNotificationProvider();
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
|
||||
void start();
|
||||
131
etc/servers/work-server/src/services/app-config-service.ts
Executable file
131
etc/servers/work-server/src/services/app-config-service.ts
Executable file
@@ -0,0 +1,131 @@
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export const APP_CONFIG_TABLE = 'app_configs';
|
||||
|
||||
async function ensureAppConfigTable() {
|
||||
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(APP_CONFIG_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.jsonb('config_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['config_json', (table) => table.jsonb('config_json').notNullable().defaultTo('{}')],
|
||||
['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(APP_CONFIG_TABLE, columnName);
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(APP_CONFIG_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppConfig() {
|
||||
await ensureAppConfigTable();
|
||||
|
||||
const row = await db(APP_CONFIG_TABLE).first();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof row.config_json === 'string') {
|
||||
try {
|
||||
return JSON.parse(row.config_json) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return row.config_json ?? {};
|
||||
}
|
||||
|
||||
export type AppConfigSnapshot = {
|
||||
chat?: {
|
||||
maxContextMessages?: number;
|
||||
maxContextChars?: number;
|
||||
};
|
||||
automation?: {
|
||||
autoRefreshEnabled?: boolean;
|
||||
autoRefreshIntervalSeconds?: number;
|
||||
autoReceiveScheduleType?: 'interval' | 'daily' | 'weekly';
|
||||
autoReceiveIntervalSeconds?: number;
|
||||
autoReceiveDailyTime?: string;
|
||||
autoReceiveWeeklyDay?: 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
|
||||
autoReceiveWeeklyTime?: string;
|
||||
notifyOnAutomationStart?: boolean;
|
||||
notifyOnAutomationProgress?: boolean;
|
||||
notifyOnAutomationCompletion?: boolean;
|
||||
notifyOnAutomationRelease?: boolean;
|
||||
notifyOnAutomationMain?: boolean;
|
||||
notifyOnAutomationFailure?: boolean;
|
||||
notifyOnAutomationRestart?: boolean;
|
||||
notifyOnAutomationIssueResolved?: boolean;
|
||||
};
|
||||
worklogAutomation?: {
|
||||
autoCreateDailyWorklog?: boolean;
|
||||
dailyCreateTime?: string;
|
||||
repeatRequestEnabled?: boolean;
|
||||
repeatIntervalMinutes?: number;
|
||||
includeScreenshots?: boolean;
|
||||
includeChangedFiles?: boolean;
|
||||
includeCommandLogs?: boolean;
|
||||
template?: 'simple' | 'detailed';
|
||||
};
|
||||
};
|
||||
|
||||
export async function getAppConfigSnapshot(): Promise<AppConfigSnapshot> {
|
||||
const raw = await getAppConfig();
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const snapshot = raw as AppConfigSnapshot;
|
||||
|
||||
if (snapshot.worklogAutomation) {
|
||||
return {
|
||||
...snapshot,
|
||||
worklogAutomation: {
|
||||
...snapshot.worklogAutomation,
|
||||
repeatRequestEnabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function upsertAppConfig(config: Record<string, unknown>) {
|
||||
await ensureAppConfigTable();
|
||||
|
||||
const existing = await db(APP_CONFIG_TABLE).first();
|
||||
|
||||
if (!existing) {
|
||||
const rows = await db(APP_CONFIG_TABLE)
|
||||
.insert({
|
||||
config_json: config,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
return rows[0]?.config_json ?? config;
|
||||
}
|
||||
|
||||
const rows = await db(APP_CONFIG_TABLE)
|
||||
.update({
|
||||
config_json: config,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return rows[0]?.config_json ?? config;
|
||||
}
|
||||
23
etc/servers/work-server/src/services/board-service.test.ts
Normal file
23
etc/servers/work-server/src/services/board-service.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-service.js';
|
||||
|
||||
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
|
||||
assert.equal(
|
||||
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n'),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
'- 게시판 제목: 알림 개선',
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
'본문 첫 줄\n본문 둘째 줄',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
|
||||
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
|
||||
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
|
||||
});
|
||||
347
etc/servers/work-server/src/services/board-service.ts
Executable file
347
etc/servers/work-server/src/services/board-service.ts
Executable file
@@ -0,0 +1,347 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
ensurePlanTable,
|
||||
normalizePlanAutomationType,
|
||||
PLAN_TABLE,
|
||||
planAutomationTypeSchema,
|
||||
} from './plan-service.js';
|
||||
|
||||
export const BOARD_POSTS_TABLE = 'board_posts';
|
||||
|
||||
export const boardPostPayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
content: z.string().min(1).max(200000),
|
||||
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
|
||||
});
|
||||
|
||||
export type BoardPostItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
preview: string;
|
||||
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
|
||||
automationPlanItemId: number | null;
|
||||
automationReceivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export class BoardPostAutomationLockedError extends Error {
|
||||
constructor(action: 'update' | 'delete') {
|
||||
super(
|
||||
action === 'delete'
|
||||
? '자동화 접수된 작업메모는 삭제할 수 없습니다.'
|
||||
: '자동화 접수된 작업메모는 수정할 수 없습니다.',
|
||||
);
|
||||
this.name = 'BoardPostAutomationLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
function createPreview(content: string) {
|
||||
const normalized = content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/[#>*_`~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
|
||||
const content = String(row.content ?? '');
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
title: String(row.title ?? ''),
|
||||
content,
|
||||
preview: createPreview(content),
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
|
||||
? null
|
||||
: Number(row.automation_plan_item_id),
|
||||
automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined
|
||||
? null
|
||||
: String(row.automation_received_at),
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function isBoardPostAutomationLocked(row: Record<string, unknown>) {
|
||||
return Boolean(row.automation_received_at || row.automation_plan_item_id);
|
||||
}
|
||||
|
||||
export function buildBoardPostPlanNote(title: string, content: string) {
|
||||
const normalizedTitle = title.trim();
|
||||
const normalizedContent = content.trim();
|
||||
|
||||
return [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
`- 게시판 제목: ${normalizedTitle}`,
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
normalizedContent,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveInsertedId(result: unknown): number | null {
|
||||
if (typeof result === 'number' && Number.isInteger(result) && result > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const first = result[0];
|
||||
|
||||
if (typeof first === 'number' && Number.isInteger(first) && first > 0) {
|
||||
return first;
|
||||
}
|
||||
|
||||
if (first && typeof first === 'object' && 'id' in first) {
|
||||
const id = Number((first as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (result && typeof result === 'object' && 'id' in result) {
|
||||
const id = Number((result as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportsReturning() {
|
||||
const clientName = String(db.client.config.client ?? '').toLowerCase();
|
||||
return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName);
|
||||
}
|
||||
|
||||
function isDuplicateSchemaError(error: unknown, codes: string[], patterns: RegExp[]) {
|
||||
const candidate = error as { code?: unknown; message?: unknown };
|
||||
const code = typeof candidate?.code === 'string' ? candidate.code : '';
|
||||
const message = typeof candidate?.message === 'string' ? candidate.message : '';
|
||||
|
||||
return codes.includes(code) || patterns.some((pattern) => pattern.test(message));
|
||||
}
|
||||
|
||||
function isDuplicateTableError(error: unknown) {
|
||||
return isDuplicateSchemaError(error, ['42P07'], [/already exists/i]);
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown) {
|
||||
return isDuplicateSchemaError(error, ['42701'], [/already exists/i, /duplicate column/i]);
|
||||
}
|
||||
|
||||
export async function ensureBoardPostsTable() {
|
||||
const hasTable = await db.schema.hasTable(BOARD_POSTS_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
try {
|
||||
await db.schema.createTable(BOARD_POSTS_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('title', 200).notNullable();
|
||||
table.text('content').notNullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isDuplicateTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['title', (table) => table.string('title', 200).notNullable().defaultTo('제목 없음')],
|
||||
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
||||
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
||||
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
|
||||
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(BOARD_POSTS_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
try {
|
||||
await db.schema.alterTable(BOARD_POSTS_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db(BOARD_POSTS_TABLE)
|
||||
.where({ automation_type: 'plan_registration' })
|
||||
.update({ automation_type: 'plan' });
|
||||
await db(BOARD_POSTS_TABLE)
|
||||
.where({ automation_type: 'general_development' })
|
||||
.update({ automation_type: 'auto_worker' });
|
||||
}
|
||||
|
||||
export async function listBoardPosts() {
|
||||
await ensureBoardPostsTable();
|
||||
|
||||
const rows = await db(BOARD_POSTS_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc');
|
||||
return rows.map((row) => mapBoardPostRow(row));
|
||||
}
|
||||
|
||||
export async function getBoardPost(id: number) {
|
||||
await ensureBoardPostsTable();
|
||||
|
||||
const row = await db(BOARD_POSTS_TABLE).where({ id }).first();
|
||||
return row ? mapBoardPostRow(row) : null;
|
||||
}
|
||||
|
||||
export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSchema>) {
|
||||
await ensureBoardPostsTable();
|
||||
const parsedPayload = boardPostPayloadSchema.parse(payload);
|
||||
const insertQuery = db(BOARD_POSTS_TABLE).insert({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
automation_type: parsedPayload.automationType,
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery;
|
||||
|
||||
const insertedId = resolveInsertedId(insertResult);
|
||||
|
||||
if (!insertedId) {
|
||||
throw new Error('게시글 저장 후 ID를 확인하지 못했습니다.');
|
||||
}
|
||||
|
||||
const row = await db(BOARD_POSTS_TABLE).where({ id: insertedId }).first();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('저장된 게시글을 다시 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
return mapBoardPostRow(row);
|
||||
}
|
||||
|
||||
export async function receiveBoardPostAutomation(id: number) {
|
||||
await ensureBoardPostsTable();
|
||||
await ensurePlanTable();
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
|
||||
|
||||
if (!currentRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentRow.automation_received_at || currentRow.automation_plan_item_id) {
|
||||
return {
|
||||
item: mapBoardPostRow(currentRow),
|
||||
planItemId:
|
||||
currentRow.automation_plan_item_id === null || currentRow.automation_plan_item_id === undefined
|
||||
? null
|
||||
: Number(currentRow.automation_plan_item_id),
|
||||
alreadyReceived: true,
|
||||
};
|
||||
}
|
||||
|
||||
const title = String(currentRow.title ?? '').trim();
|
||||
const content = String(currentRow.content ?? '').trim();
|
||||
const workId = `board-post-${id}`;
|
||||
const insertQuery = trx(PLAN_TABLE).insert({
|
||||
work_id: workId,
|
||||
note: buildBoardPostPlanNote(title, content),
|
||||
automation_type: normalizePlanAutomationType(currentRow.automation_type),
|
||||
status: '등록',
|
||||
release_target: 'release',
|
||||
jangsing_processing_required: true,
|
||||
auto_deploy_to_main: false,
|
||||
worker_status: '대기',
|
||||
last_error: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery;
|
||||
const planItemId = resolveInsertedId(insertResult);
|
||||
|
||||
if (!planItemId) {
|
||||
throw new Error('자동화 접수 후 Plan ID를 확인하지 못했습니다.');
|
||||
}
|
||||
|
||||
const updateQuery = trx(BOARD_POSTS_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
automation_plan_item_id: planItemId,
|
||||
automation_received_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
const updatedRows = supportsReturning() ? await updateQuery.returning('*') : [];
|
||||
if (!supportsReturning()) {
|
||||
await updateQuery;
|
||||
}
|
||||
|
||||
const updatedRow = updatedRows[0] ?? (await trx(BOARD_POSTS_TABLE).where({ id }).first());
|
||||
|
||||
if (!updatedRow) {
|
||||
throw new Error('자동화 접수된 게시글을 다시 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
item: mapBoardPostRow(updatedRow),
|
||||
planItemId,
|
||||
alreadyReceived: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateBoardPost(id: number, payload: z.infer<typeof boardPostPayloadSchema>) {
|
||||
await ensureBoardPostsTable();
|
||||
const parsedPayload = boardPostPayloadSchema.parse(payload);
|
||||
return db.transaction(async (trx) => {
|
||||
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
|
||||
|
||||
if (!currentRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isBoardPostAutomationLocked(currentRow)) {
|
||||
throw new BoardPostAutomationLockedError('update');
|
||||
}
|
||||
|
||||
await trx(BOARD_POSTS_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
automation_type: parsedPayload.automationType,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
const row = await trx(BOARD_POSTS_TABLE).where({ id }).first();
|
||||
return row ? mapBoardPostRow(row) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBoardPost(id: number) {
|
||||
await ensureBoardPostsTable();
|
||||
return db.transaction(async (trx) => {
|
||||
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
|
||||
|
||||
if (!currentRow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBoardPostAutomationLocked(currentRow)) {
|
||||
throw new BoardPostAutomationLockedError('delete');
|
||||
}
|
||||
|
||||
const deletedCount = await trx(BOARD_POSTS_TABLE).where({ id }).del();
|
||||
return deletedCount > 0;
|
||||
});
|
||||
}
|
||||
211
etc/servers/work-server/src/services/chat-room-service.test.ts
Normal file
211
etc/servers/work-server/src/services/chat-room-service.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildChatConversationRequestPatchFromMessage,
|
||||
mergeChatConversationRequestStatus,
|
||||
shouldClearConversationJobState,
|
||||
selectChatConversationResponseCandidate,
|
||||
} from './chat-room-service.js';
|
||||
|
||||
test('mergeChatConversationRequestStatus keeps terminal states from being downgraded', () => {
|
||||
assert.equal(mergeChatConversationRequestStatus('completed', 'accepted'), 'completed');
|
||||
assert.equal(mergeChatConversationRequestStatus('started', 'accepted'), 'started');
|
||||
assert.equal(mergeChatConversationRequestStatus('queued', 'accepted'), 'queued');
|
||||
assert.equal(mergeChatConversationRequestStatus('accepted', 'completed'), 'completed');
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
||||
assert.equal(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
id: 10,
|
||||
author: 'system',
|
||||
text: '요청 실행 중입니다.',
|
||||
clientRequestId: 'chat-req-1',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
id: 11,
|
||||
author: 'user',
|
||||
text: '질문',
|
||||
clientRequestId: 'chat-req-2',
|
||||
}),
|
||||
{
|
||||
requestId: 'chat-req-2',
|
||||
status: 'accepted',
|
||||
userMessageId: 11,
|
||||
userText: '질문',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
id: 12,
|
||||
author: 'codex',
|
||||
text: '답변',
|
||||
clientRequestId: 'chat-req-2',
|
||||
}),
|
||||
{
|
||||
requestId: 'chat-req-2',
|
||||
status: 'started',
|
||||
responseMessageId: 12,
|
||||
responseText: '답변',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('selectChatConversationResponseCandidate falls back to codex replies in the request window', () => {
|
||||
const candidate = selectChatConversationResponseCandidate(
|
||||
{
|
||||
requestId: 'chat-req-3',
|
||||
createdAt: '2026-04-18T14:00:00.000Z',
|
||||
responseMessageId: null,
|
||||
},
|
||||
{
|
||||
createdAt: '2026-04-18T14:10:00.000Z',
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
messageId: 1001,
|
||||
author: 'codex',
|
||||
text: '이전 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T13:59:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messageId: 1002,
|
||||
author: 'codex',
|
||||
text: '현재 요청 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T14:05:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messageId: 1003,
|
||||
author: 'codex',
|
||||
text: '다음 요청 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T14:11:00.000Z',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(candidate, {
|
||||
id: 2,
|
||||
messageId: 1002,
|
||||
author: 'codex',
|
||||
text: '현재 요청 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T14:05:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears stale job state when terminal request already has a response', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-4',
|
||||
currentJobStatus: 'started',
|
||||
request: {
|
||||
requestId: 'chat-req-4',
|
||||
status: 'completed',
|
||||
responseMessageId: 101,
|
||||
responseText: '답변',
|
||||
terminalAt: '2026-04-19T01:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears orphaned job state without a current request id', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: null,
|
||||
currentJobStatus: 'started',
|
||||
request: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState keeps active job state when request is still running without a response', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-5',
|
||||
currentJobStatus: 'started',
|
||||
request: {
|
||||
requestId: 'chat-req-5',
|
||||
status: 'started',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
terminalAt: null,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState does not clear placeholder-only started responses', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-6',
|
||||
currentJobStatus: 'started',
|
||||
request: {
|
||||
requestId: 'chat-req-6',
|
||||
status: 'started',
|
||||
responseMessageId: 301,
|
||||
responseText: '응답을 준비하고 있습니다...',
|
||||
terminalAt: null,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears stale placeholder-only started responses when runtime is gone', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-7',
|
||||
currentJobStatus: 'started',
|
||||
currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z',
|
||||
runtimeActive: false,
|
||||
nowMs: Date.parse('2026-04-19T08:11:36.000Z'),
|
||||
request: {
|
||||
requestId: 'chat-req-7',
|
||||
status: 'started',
|
||||
responseMessageId: 302,
|
||||
responseText: '응답을 준비하고 있습니다...',
|
||||
terminalAt: null,
|
||||
updatedAt: '2026-04-19T08:08:56.086Z',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState keeps placeholder-only started responses while runtime is active', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-8',
|
||||
currentJobStatus: 'started',
|
||||
currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z',
|
||||
runtimeActive: true,
|
||||
nowMs: Date.parse('2026-04-19T08:11:36.000Z'),
|
||||
request: {
|
||||
requestId: 'chat-req-8',
|
||||
status: 'started',
|
||||
responseMessageId: 303,
|
||||
responseText: '응답을 준비하고 있습니다...',
|
||||
terminalAt: null,
|
||||
updatedAt: '2026-04-19T08:08:56.086Z',
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
1967
etc/servers/work-server/src/services/chat-room-service.ts
Normal file
1967
etc/servers/work-server/src/services/chat-room-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
392
etc/servers/work-server/src/services/chat-runtime-service.ts
Executable file
392
etc/servers/work-server/src/services/chat-runtime-service.ts
Executable file
@@ -0,0 +1,392 @@
|
||||
type ChatRuntimeJobMode = 'queue' | 'direct';
|
||||
type ChatRuntimeLifecycleStatus = 'queued' | 'running';
|
||||
type ChatRuntimeTerminalStatus = 'completed' | 'failed' | 'cancelled' | 'removed';
|
||||
|
||||
type RuntimeJobControl = {
|
||||
cancel?: () => Promise<boolean> | boolean;
|
||||
remove?: () => Promise<boolean> | boolean;
|
||||
};
|
||||
|
||||
type RuntimeJobRecord = ChatRuntimeJobItem & {
|
||||
logs: string[];
|
||||
lastUpdatedAt: string;
|
||||
terminalStatus: ChatRuntimeTerminalStatus | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeJobItem = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
mode: ChatRuntimeJobMode;
|
||||
status: ChatRuntimeLifecycleStatus;
|
||||
summary: string;
|
||||
enqueuedAt: string;
|
||||
startedAt: string | null;
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSessionSummary = {
|
||||
sessionId: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
latestRequestId: string | null;
|
||||
latestStatus: ChatRuntimeLifecycleStatus | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSnapshot = {
|
||||
generatedAt: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
sessionCount: number;
|
||||
running: ChatRuntimeJobItem[];
|
||||
queued: ChatRuntimeJobItem[];
|
||||
sessions: ChatRuntimeSessionSummary[];
|
||||
recent: Array<ChatRuntimeJobItem & { terminalStatus: ChatRuntimeTerminalStatus; lastUpdatedAt: string }>;
|
||||
};
|
||||
|
||||
export type ChatRuntimeJobDetail = {
|
||||
item: ChatRuntimeJobItem | null;
|
||||
logs: string[];
|
||||
lastUpdatedAt: string | null;
|
||||
terminalStatus: ChatRuntimeTerminalStatus | null;
|
||||
availableActions: {
|
||||
cancel: boolean;
|
||||
remove: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type RuntimeSubscriber = (snapshot: ChatRuntimeSnapshot) => void;
|
||||
|
||||
const MAX_LOG_LINES = 80;
|
||||
const MAX_ARCHIVED_JOBS = 40;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function summarizeText(text: string) {
|
||||
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 120 ? `${normalized.slice(0, 117).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function normalizeLogLine(line: string) {
|
||||
return String(line ?? '').replace(/\r/g, '').trimEnd();
|
||||
}
|
||||
|
||||
class ChatRuntimeService {
|
||||
private readonly queuedJobs = new Map<string, RuntimeJobRecord>();
|
||||
private readonly runningJobs = new Map<string, RuntimeJobRecord>();
|
||||
private readonly archivedJobs = new Map<string, RuntimeJobRecord>();
|
||||
private readonly controls = new Map<string, RuntimeJobControl>();
|
||||
private readonly subscribers = new Set<RuntimeSubscriber>();
|
||||
|
||||
subscribe(listener: RuntimeSubscriber) {
|
||||
this.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(): ChatRuntimeSnapshot {
|
||||
const running = [...this.runningJobs.values()].sort((left, right) =>
|
||||
(left.startedAt ?? left.enqueuedAt).localeCompare(right.startedAt ?? right.enqueuedAt),
|
||||
);
|
||||
const queued = [...this.queuedJobs.values()].sort((left, right) => left.enqueuedAt.localeCompare(right.enqueuedAt));
|
||||
const sessionMap = new Map<string, ChatRuntimeSessionSummary>();
|
||||
|
||||
for (const item of [...running, ...queued]) {
|
||||
const current = sessionMap.get(item.sessionId) ?? {
|
||||
sessionId: item.sessionId,
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
latestRequestId: null,
|
||||
latestStatus: null,
|
||||
};
|
||||
|
||||
if (item.status === 'running') {
|
||||
current.runningCount += 1;
|
||||
} else {
|
||||
current.queuedCount += 1;
|
||||
}
|
||||
|
||||
current.latestRequestId = item.requestId;
|
||||
current.latestStatus = item.status;
|
||||
sessionMap.set(item.sessionId, current);
|
||||
}
|
||||
|
||||
const sessions = [...sessionMap.values()].sort((left, right) => {
|
||||
const loadDiff = right.runningCount + right.queuedCount - (left.runningCount + left.queuedCount);
|
||||
return loadDiff !== 0 ? loadDiff : left.sessionId.localeCompare(right.sessionId);
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: nowIso(),
|
||||
runningCount: running.length,
|
||||
queuedCount: queued.length,
|
||||
sessionCount: sessions.length,
|
||||
running: running.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item),
|
||||
queued: queued.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item),
|
||||
sessions,
|
||||
recent: [...this.archivedJobs.values()]
|
||||
.sort((left, right) => right.lastUpdatedAt.localeCompare(left.lastUpdatedAt))
|
||||
.slice(0, 12)
|
||||
.map(({ logs: _logs, ...item }) => ({
|
||||
...item,
|
||||
terminalStatus: item.terminalStatus ?? 'completed',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
getJobDetail(requestId: string): ChatRuntimeJobDetail {
|
||||
const current =
|
||||
this.runningJobs.get(requestId) ??
|
||||
this.queuedJobs.get(requestId) ??
|
||||
this.archivedJobs.get(requestId) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
item: current
|
||||
? {
|
||||
requestId: current.requestId,
|
||||
sessionId: current.sessionId,
|
||||
mode: current.mode,
|
||||
status: current.status,
|
||||
summary: current.summary,
|
||||
enqueuedAt: current.enqueuedAt,
|
||||
startedAt: current.startedAt,
|
||||
pid: current.pid,
|
||||
}
|
||||
: null,
|
||||
logs: current?.logs ?? [],
|
||||
lastUpdatedAt: current?.lastUpdatedAt ?? null,
|
||||
terminalStatus: current?.terminalStatus ?? null,
|
||||
availableActions: {
|
||||
cancel: this.runningJobs.has(requestId) && this.controls.has(requestId),
|
||||
remove: this.queuedJobs.has(requestId) && this.controls.has(requestId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerQueuedControl(requestId: string, control: RuntimeJobControl) {
|
||||
this.controls.set(requestId, control);
|
||||
}
|
||||
|
||||
registerRunningControl(requestId: string, control: RuntimeJobControl) {
|
||||
this.controls.set(requestId, control);
|
||||
}
|
||||
|
||||
enqueueJob(args: {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
mode: ChatRuntimeJobMode;
|
||||
text: string;
|
||||
}) {
|
||||
const existingRunning = this.runningJobs.get(args.requestId);
|
||||
|
||||
if (existingRunning) {
|
||||
return existingRunning;
|
||||
}
|
||||
|
||||
const item: RuntimeJobRecord = {
|
||||
requestId: args.requestId,
|
||||
sessionId: args.sessionId,
|
||||
mode: args.mode,
|
||||
status: 'queued',
|
||||
summary: summarizeText(args.text),
|
||||
enqueuedAt: nowIso(),
|
||||
startedAt: null,
|
||||
pid: null,
|
||||
logs: ['큐에 등록되었습니다.'],
|
||||
lastUpdatedAt: nowIso(),
|
||||
terminalStatus: null,
|
||||
};
|
||||
|
||||
this.archivedJobs.delete(args.requestId);
|
||||
this.queuedJobs.set(args.requestId, item);
|
||||
this.emit();
|
||||
return item;
|
||||
}
|
||||
|
||||
startJob(args: {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
mode: ChatRuntimeJobMode;
|
||||
text: string;
|
||||
pid?: number | null;
|
||||
}) {
|
||||
const queuedItem = this.queuedJobs.get(args.requestId);
|
||||
const runningItem: RuntimeJobRecord = {
|
||||
requestId: args.requestId,
|
||||
sessionId: args.sessionId,
|
||||
mode: args.mode,
|
||||
status: 'running',
|
||||
summary: summarizeText(args.text),
|
||||
enqueuedAt: queuedItem?.enqueuedAt ?? nowIso(),
|
||||
startedAt: nowIso(),
|
||||
pid: args.pid == null ? null : Math.round(args.pid),
|
||||
logs: queuedItem?.logs ?? [],
|
||||
lastUpdatedAt: nowIso(),
|
||||
terminalStatus: null,
|
||||
};
|
||||
|
||||
runningItem.logs = [...runningItem.logs, '실행이 시작되었습니다.'].slice(-MAX_LOG_LINES);
|
||||
|
||||
this.queuedJobs.delete(args.requestId);
|
||||
this.archivedJobs.delete(args.requestId);
|
||||
this.runningJobs.set(args.requestId, runningItem);
|
||||
this.emit();
|
||||
return runningItem;
|
||||
}
|
||||
|
||||
attachProcess(requestId: string, pid?: number | null) {
|
||||
const current = this.runningJobs.get(requestId);
|
||||
|
||||
if (!current || pid == null) {
|
||||
return current ?? null;
|
||||
}
|
||||
|
||||
const next: RuntimeJobRecord = {
|
||||
...current,
|
||||
pid: Math.round(pid),
|
||||
lastUpdatedAt: nowIso(),
|
||||
logs: [...current.logs, `프로세스가 연결되었습니다. pid=${Math.round(pid)}`].slice(-MAX_LOG_LINES),
|
||||
};
|
||||
|
||||
this.runningJobs.set(requestId, next);
|
||||
this.emit();
|
||||
return next;
|
||||
}
|
||||
|
||||
appendLog(requestId: string, line: string) {
|
||||
const normalizedLine = normalizeLogLine(line);
|
||||
|
||||
if (!normalizedLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.runningJobs.get(requestId) ?? this.queuedJobs.get(requestId) ?? this.archivedJobs.get(requestId);
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next: RuntimeJobRecord = {
|
||||
...current,
|
||||
logs: [...current.logs, normalizedLine].slice(-MAX_LOG_LINES),
|
||||
lastUpdatedAt: nowIso(),
|
||||
};
|
||||
|
||||
if (this.runningJobs.has(requestId)) {
|
||||
this.runningJobs.set(requestId, next);
|
||||
} else if (this.queuedJobs.has(requestId)) {
|
||||
this.queuedJobs.set(requestId, next);
|
||||
} else {
|
||||
this.archivedJobs.set(requestId, next);
|
||||
}
|
||||
|
||||
this.emit();
|
||||
}
|
||||
|
||||
async cancelJob(requestId: string) {
|
||||
const control = this.controls.get(requestId);
|
||||
|
||||
if (!this.runningJobs.has(requestId) || !control?.cancel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await control.cancel();
|
||||
return result === true;
|
||||
}
|
||||
|
||||
async removeQueuedJob(requestId: string) {
|
||||
const control = this.controls.get(requestId);
|
||||
|
||||
if (!this.queuedJobs.has(requestId) || !control?.remove) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await control.remove();
|
||||
return result === true;
|
||||
}
|
||||
|
||||
finishJob(requestId: string, terminalStatus: ChatRuntimeTerminalStatus = 'completed') {
|
||||
const removedRunning = this.runningJobs.get(requestId);
|
||||
const removedQueued = this.queuedJobs.get(requestId);
|
||||
const removed = removedRunning ?? removedQueued ?? null;
|
||||
|
||||
this.runningJobs.delete(requestId);
|
||||
this.queuedJobs.delete(requestId);
|
||||
this.controls.delete(requestId);
|
||||
|
||||
if (!removed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archived: RuntimeJobRecord = {
|
||||
...removed,
|
||||
lastUpdatedAt: nowIso(),
|
||||
terminalStatus,
|
||||
logs: [...removed.logs, this.buildTerminalLog(terminalStatus)].slice(-MAX_LOG_LINES),
|
||||
};
|
||||
|
||||
this.archivedJobs.delete(requestId);
|
||||
this.archivedJobs.set(requestId, archived);
|
||||
this.trimArchivedJobs();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
if (
|
||||
this.runningJobs.size === 0 &&
|
||||
this.queuedJobs.size === 0 &&
|
||||
this.archivedJobs.size === 0 &&
|
||||
this.controls.size === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runningJobs.clear();
|
||||
this.queuedJobs.clear();
|
||||
this.archivedJobs.clear();
|
||||
this.controls.clear();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private buildTerminalLog(status: ChatRuntimeTerminalStatus) {
|
||||
if (status === 'completed') {
|
||||
return '실행이 완료되었습니다.';
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return '실행이 실패로 종료되었습니다.';
|
||||
}
|
||||
|
||||
if (status === 'cancelled') {
|
||||
return '실행이 강제 취소되었습니다.';
|
||||
}
|
||||
|
||||
return '대기열에서 제거되었습니다.';
|
||||
}
|
||||
|
||||
private trimArchivedJobs() {
|
||||
while (this.archivedJobs.size > MAX_ARCHIVED_JOBS) {
|
||||
const firstKey = this.archivedJobs.keys().next().value;
|
||||
|
||||
if (!firstKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.archivedJobs.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
private emit() {
|
||||
const snapshot = this.getSnapshot();
|
||||
|
||||
this.subscribers.forEach((listener) => {
|
||||
listener(snapshot);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const chatRuntimeService = new ChatRuntimeService();
|
||||
260
etc/servers/work-server/src/services/chat-service.test.ts
Normal file
260
etc/servers/work-server/src/services/chat-service.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
collectOfflineNotificationClientIds,
|
||||
createActivityLogMessage,
|
||||
extractDiffCodeBlocks,
|
||||
fitActivityLogLines,
|
||||
isAutomationRegistrationCountRequest,
|
||||
resolveResponseTimestamp,
|
||||
rewriteCodexOutputWithChatResources,
|
||||
shouldUseAgenticCodexReply,
|
||||
shouldUseTemplateMacroReply,
|
||||
validateAgenticCodexRuntime,
|
||||
} from './chat-service.js';
|
||||
|
||||
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
|
||||
assert.deepEqual(
|
||||
collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']),
|
||||
['client-b', 'client-a', 'client-c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => {
|
||||
assert.equal(isAutomationRegistrationCountRequest('오늘 자동화 등록 총 건수'), true);
|
||||
assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true);
|
||||
assert.equal(isAutomationRegistrationCountRequest('자동화 등록 기준이 뭐야'), false);
|
||||
});
|
||||
|
||||
test('shouldUseAgenticCodexReply routes read and modify style requests to real Codex execution', () => {
|
||||
assert.equal(shouldUseAgenticCodexReply('src/app/main/MainChatPanel.tsx 읽어서 구조 설명해줘'), true);
|
||||
assert.equal(shouldUseAgenticCodexReply('DB 직접 조회해서 오늘 오류 건수 확인해줘'), true);
|
||||
assert.equal(shouldUseAgenticCodexReply('MainChatPanel.hotfix.css 수정해줘'), true);
|
||||
});
|
||||
|
||||
test('shouldUseAgenticCodexReply keeps fast-path responses for automation registration count questions', () => {
|
||||
assert.equal(shouldUseAgenticCodexReply('오늘 자동화 등록 총 건수'), false);
|
||||
});
|
||||
|
||||
test('shouldUseTemplateMacroReply only matches template chats and template-scoped prompts', () => {
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
},
|
||||
'이 템플릿 예시 보여줘',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
},
|
||||
'아이패드 말풍선 폰트 조금 줄여줘',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청',
|
||||
chatTypeIsTemplate: false,
|
||||
},
|
||||
'템플릿 예시 보여줘',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
|
||||
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||
|
||||
assert.deepEqual(fitActivityLogLines(lines), lines);
|
||||
});
|
||||
|
||||
test('fitActivityLogLines keeps full activity history when it is within the configured limits', () => {
|
||||
const lines = Array.from({ length: 80 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||
const fitted = fitActivityLogLines(lines);
|
||||
|
||||
assert.equal(fitted.length, 80);
|
||||
assert.equal(fitted[0], '# 진행: step 1');
|
||||
assert.equal(fitted.at(-1), '# 진행: step 80');
|
||||
});
|
||||
|
||||
test('createActivityLogMessage keeps fitted activity history instead of the latest line only', () => {
|
||||
const lines = ['# 상태: 요청을 처리합니다.', '# 진행: 분석 중입니다.', '# 상태: 응답 생성이 완료되었습니다.'];
|
||||
const message = createActivityLogMessage('req-activity', lines);
|
||||
|
||||
assert.ok(message);
|
||||
assert.equal(
|
||||
message?.text,
|
||||
'[[activity-log]]\n# 상태: 요청을 처리합니다.\n\n# 진행: 분석 중입니다.\n\n# 상태: 응답 생성이 완료되었습니다.',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
|
||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
|
||||
});
|
||||
|
||||
test('resolveResponseTimestamp keeps the real time when reply is already later', () => {
|
||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 5)), '2026-04-16 09:00:05');
|
||||
});
|
||||
|
||||
test('extractDiffCodeBlocks collects fenced diff bodies', () => {
|
||||
const output = ['설명', '', '```diff', 'diff --git a/a.ts b/a.ts', '+hello', '```', '', '마무리'].join('\n');
|
||||
|
||||
assert.deepEqual(extractDiffCodeBlocks(output), ['diff --git a/a.ts b/a.ts\n+hello']);
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
await mkdir(path.join(repoPath, 'public'), { recursive: true });
|
||||
|
||||
const output = ['변경사항입니다.', '', '```diff', 'diff --git a/src/a.ts b/src/a.ts', '+hello', '```'].join('\n');
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
const expectedUrl = '/api/chat/resources/.codex_chat/chat-room/resource/_generated/response.diff';
|
||||
const savedDiffPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'_generated',
|
||||
'response.diff',
|
||||
);
|
||||
|
||||
assert.match(rewritten, new RegExp(`${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
|
||||
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx');
|
||||
const stagedPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'src',
|
||||
'app',
|
||||
'main',
|
||||
'MainChatPanel.tsx',
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await mkdir(path.dirname(stagedPath), { recursive: true });
|
||||
await writeFile(originalPath, 'export const value = 1;\n', 'utf8');
|
||||
await writeFile(stagedPath, 'export const value = 1;\n', 'utf8');
|
||||
|
||||
const output =
|
||||
'리소스 경로는 public/.codex_chat/chat-room/resource/src/app/main/MainChatPanel.tsx 입니다.';
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/app\/main\/MainChatPanel\.tsx/);
|
||||
assert.doesNotMatch(rewritten, /resource\/public\/\.codex_chat/);
|
||||
assert.equal(await readFile(stagedPath, 'utf8'), 'export const value = 1;\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources stages repo root files linked with a leading slash', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'docker-compose.yml');
|
||||
const stagedPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'docker-compose.yml',
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await writeFile(originalPath, 'services:\n app:\n image: node:22\n', 'utf8');
|
||||
|
||||
const output = '파일은 /docker-compose.yml 에 있습니다.';
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/docker-compose\.yml/);
|
||||
assert.equal(await readFile(stagedPath, 'utf8'), 'services:\n app:\n image: node:22\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources prefers absolute path replacements before nested relative paths', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'etc', 'servers', 'work-server', 'package.json');
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await mkdir(path.join(repoPath, 'public'), { recursive: true });
|
||||
await writeFile(originalPath, '{\n "name": "work-server"\n}\n', 'utf8');
|
||||
|
||||
const output =
|
||||
'변경 파일: [/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/package.json](' +
|
||||
`${originalPath})`;
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(
|
||||
rewritten,
|
||||
/\[\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\]\(\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\)/,
|
||||
);
|
||||
assert.doesNotMatch(rewritten, /\/home\/.+\/api\/chat\/resources/);
|
||||
});
|
||||
|
||||
test('validateAgenticCodexRuntime explains missing runtime paths clearly', async () => {
|
||||
const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL;
|
||||
const originalFetch = globalThis.fetch;
|
||||
env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health';
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error('connect ECONNREFUSED');
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
validateAgenticCodexRuntime('/tmp/chat-missing-repo-path', '/tmp/chat-missing-codex-bin'),
|
||||
/채팅 실행 환경이 준비되지 않았습니다\..*PLAN_MAIN_PROJECT_REPO_PATH.*SERVER_COMMAND_RUNNER_URL/s,
|
||||
);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl;
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('validateAgenticCodexRuntime accepts reachable command-runner api', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-repo-'));
|
||||
const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL;
|
||||
const originalFetch = globalThis.fetch;
|
||||
env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health';
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await assert.doesNotReject(validateAgenticCodexRuntime(repoPath, 'codex'));
|
||||
} finally {
|
||||
env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl;
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
3274
etc/servers/work-server/src/services/chat-service.ts
Normal file
3274
etc/servers/work-server/src/services/chat-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
460
etc/servers/work-server/src/services/error-log-plan-registration-service.ts
Executable file
460
etc/servers/work-server/src/services/error-log-plan-registration-service.ts
Executable file
@@ -0,0 +1,460 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/client.js';
|
||||
import { listBoardPosts, createBoardPost } from './board-service.js';
|
||||
import { listErrorLogs } from './error-log-service.js';
|
||||
import { ensurePlanTable, PLAN_TABLE } from './plan-service.js';
|
||||
|
||||
const DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT = 6;
|
||||
const ERROR_LOG_BOARD_POST_MARKER_PREFIX = '<!-- error-log-plan-work-id:';
|
||||
|
||||
type ErrorLogCandidate = {
|
||||
fingerprint: string;
|
||||
workId: string;
|
||||
source: string;
|
||||
sourceLabel: string | null;
|
||||
errorType: string;
|
||||
errorName: string | null;
|
||||
errorMessage: string;
|
||||
requestPath: string | null;
|
||||
requestPathGroup: string;
|
||||
requestPaths: string[];
|
||||
statusCode: number | null;
|
||||
count: number;
|
||||
firstCreatedAt: unknown;
|
||||
lastCreatedAt: unknown;
|
||||
sampleLogId: number | null;
|
||||
sampleLogIds: number[];
|
||||
errorNames: string[];
|
||||
representativeMessages: string[];
|
||||
groupedScopes?: string[];
|
||||
groupedCandidateCount?: number;
|
||||
};
|
||||
|
||||
type ErrorLogRegistrationSkip = {
|
||||
workId: string;
|
||||
reason: string;
|
||||
boardPostId?: number;
|
||||
planId?: number;
|
||||
};
|
||||
|
||||
type ErrorLogBoardPostRegistration = {
|
||||
postId: number;
|
||||
title: string;
|
||||
workId: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
function normalizeDateBoundary(value: unknown) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatIsoTimestamp(value: unknown) {
|
||||
const date = normalizeDateBoundary(value);
|
||||
return date ? date.toISOString() : String(value ?? '');
|
||||
}
|
||||
|
||||
function normalizeRequestPathGroup(requestPath: unknown) {
|
||||
const normalized = String(requestPath ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\?.*$/u, '')
|
||||
.replace(/\/\d+(?=\/|$)/gu, '/:id')
|
||||
.replace(/[0-9a-f]{8,}(?=\/|$)/giu, ':id');
|
||||
|
||||
if (!normalized) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const segments = normalized
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
|
||||
return segments.length > 0 ? `/${segments.join('/')}` : normalized;
|
||||
}
|
||||
|
||||
function buildErrorLogPlanFingerprint(log: Record<string, unknown>) {
|
||||
return createHash('sha1')
|
||||
.update(
|
||||
[
|
||||
String(log.source ?? '').trim().toLowerCase(),
|
||||
String(log.errorType ?? '').trim().toLowerCase(),
|
||||
log.statusCode == null ? '' : String(log.statusCode),
|
||||
].join('||'),
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildErrorLogPlanWorkId(log: Record<string, unknown>) {
|
||||
return `error-fix-${buildErrorLogPlanFingerprint(log)}`;
|
||||
}
|
||||
|
||||
function buildErrorLogPlanCandidates(logs: Array<Record<string, unknown>>) {
|
||||
const grouped = new Map<string, any>();
|
||||
|
||||
for (const log of logs) {
|
||||
const fingerprint = createHash('sha1')
|
||||
.update(
|
||||
[
|
||||
String(log.source ?? '').trim().toLowerCase(),
|
||||
String(log.errorType ?? '').trim().toLowerCase(),
|
||||
String(log.errorName ?? '').trim().toLowerCase(),
|
||||
log.statusCode == null ? '' : String(log.statusCode),
|
||||
normalizeRequestPathGroup(log.requestPath),
|
||||
].join('||'),
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
|
||||
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime() ?? 0;
|
||||
const existing = grouped.get(fingerprint);
|
||||
const requestPathGroup = normalizeRequestPathGroup(log.requestPath);
|
||||
const errorMessage = String(log.errorMessage ?? '').trim();
|
||||
|
||||
if (!existing) {
|
||||
grouped.set(fingerprint, {
|
||||
sample: log,
|
||||
count: 1,
|
||||
firstCreatedAt: log.createdAt,
|
||||
firstTimeMs: createdAtMs,
|
||||
lastCreatedAt: log.createdAt,
|
||||
lastTimeMs: createdAtMs,
|
||||
requestPathGroup,
|
||||
requestPaths: log.requestPath ? new Set([String(log.requestPath).trim()]) : new Set(),
|
||||
errorMessages: errorMessage ? new Set([errorMessage]) : new Set(),
|
||||
errorNames: log.errorName ? new Set([String(log.errorName).trim()]) : new Set(),
|
||||
sampleLogIds: log.id != null ? [Number(log.id)] : [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
|
||||
if (log.requestPath) {
|
||||
existing.requestPaths.add(String(log.requestPath).trim());
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
existing.errorMessages.add(errorMessage);
|
||||
}
|
||||
|
||||
if (log.errorName) {
|
||||
existing.errorNames.add(String(log.errorName).trim());
|
||||
}
|
||||
|
||||
if (log.id != null && existing.sampleLogIds.length < 5) {
|
||||
existing.sampleLogIds.push(Number(log.id));
|
||||
}
|
||||
|
||||
if (createdAtMs <= existing.firstTimeMs) {
|
||||
existing.firstCreatedAt = log.createdAt;
|
||||
existing.firstTimeMs = createdAtMs;
|
||||
}
|
||||
|
||||
if (createdAtMs >= existing.lastTimeMs) {
|
||||
existing.sample = log;
|
||||
existing.lastCreatedAt = log.createdAt;
|
||||
existing.lastTimeMs = createdAtMs;
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([fingerprint, entry]) => ({
|
||||
fingerprint,
|
||||
workId: buildErrorLogPlanWorkId(entry.sample),
|
||||
source: String(entry.sample.source ?? ''),
|
||||
sourceLabel: entry.sample.sourceLabel ? String(entry.sample.sourceLabel) : null,
|
||||
errorType: String(entry.sample.errorType ?? ''),
|
||||
errorName: entry.sample.errorName ? String(entry.sample.errorName) : null,
|
||||
errorMessage: String(entry.sample.errorMessage ?? ''),
|
||||
requestPath: entry.sample.requestPath ? String(entry.sample.requestPath) : null,
|
||||
requestPathGroup: entry.requestPathGroup,
|
||||
requestPaths: [...entry.requestPaths].filter(Boolean).slice(0, 5),
|
||||
statusCode: entry.sample.statusCode == null ? null : Number(entry.sample.statusCode),
|
||||
count: entry.count,
|
||||
firstCreatedAt: entry.firstCreatedAt,
|
||||
lastCreatedAt: entry.lastCreatedAt,
|
||||
sampleLogId: entry.sample.id == null ? null : Number(entry.sample.id),
|
||||
sampleLogIds: entry.sampleLogIds,
|
||||
errorNames: [...entry.errorNames].filter(Boolean).slice(0, 5),
|
||||
representativeMessages: [...entry.errorMessages].filter(Boolean).slice(0, 5),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
const leftLastTime = normalizeDateBoundary(left.lastCreatedAt)?.getTime() ?? 0;
|
||||
const rightLastTime = normalizeDateBoundary(right.lastCreatedAt)?.getTime() ?? 0;
|
||||
|
||||
if (rightLastTime !== leftLastTime) {
|
||||
return rightLastTime - leftLastTime;
|
||||
}
|
||||
|
||||
return Number(right.sampleLogId ?? 0) - Number(left.sampleLogId ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeErrorLogPlanCandidateBucket(bucket: ErrorLogCandidate[], bucketIndex: number): ErrorLogCandidate {
|
||||
const sortedBucket = [...bucket].sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
|
||||
});
|
||||
|
||||
const representative = sortedBucket[0];
|
||||
const uniqueFingerprints = [...new Set(sortedBucket.map((candidate) => candidate.fingerprint).filter(Boolean))].sort();
|
||||
const mergedFingerprint = createHash('sha1')
|
||||
.update(uniqueFingerprints.join('||'))
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
const firstCreatedAt = sortedBucket
|
||||
.map((candidate) => normalizeDateBoundary(candidate.firstCreatedAt)?.getTime() ?? Number.POSITIVE_INFINITY)
|
||||
.reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY);
|
||||
const lastCreatedAt = sortedBucket
|
||||
.map((candidate) => normalizeDateBoundary(candidate.lastCreatedAt)?.getTime() ?? 0)
|
||||
.reduce((max, value) => Math.max(max, value), 0);
|
||||
const requestPaths = [...new Set(sortedBucket.flatMap((candidate) => candidate.requestPaths ?? []).filter(Boolean))].slice(0, 8);
|
||||
const representativeMessages = [...new Set(sortedBucket.flatMap((candidate) => candidate.representativeMessages ?? []).filter(Boolean))].slice(0, 8);
|
||||
const errorNames = [...new Set(sortedBucket.flatMap((candidate) => candidate.errorNames ?? []).filter(Boolean))].slice(0, 8);
|
||||
const groupedScopes = sortedBucket
|
||||
.slice(0, 8)
|
||||
.map((candidate) => {
|
||||
const parts = [candidate.sourceLabel || candidate.source, candidate.errorType];
|
||||
|
||||
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
||||
parts.push(candidate.requestPathGroup);
|
||||
}
|
||||
|
||||
return parts.filter(Boolean).join(' / ');
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
fingerprint: mergedFingerprint,
|
||||
workId: `error-fix-bundle-${mergedFingerprint}`,
|
||||
source: representative.source,
|
||||
sourceLabel: representative.sourceLabel,
|
||||
errorType: sortedBucket.length > 1 ? '다중 에러 묶음' : representative.errorType,
|
||||
errorName: representative.errorName,
|
||||
errorMessage: representative.errorMessage,
|
||||
requestPath: representative.requestPath,
|
||||
requestPathGroup: representative.requestPathGroup,
|
||||
requestPaths,
|
||||
statusCode: representative.statusCode,
|
||||
count: sortedBucket.reduce((sum, candidate) => sum + Number(candidate.count ?? 0), 0),
|
||||
firstCreatedAt: Number.isFinite(firstCreatedAt) ? new Date(firstCreatedAt).toISOString() : representative.firstCreatedAt,
|
||||
lastCreatedAt: lastCreatedAt > 0 ? new Date(lastCreatedAt).toISOString() : representative.lastCreatedAt,
|
||||
sampleLogId: representative.sampleLogId,
|
||||
sampleLogIds: [...new Set(sortedBucket.flatMap((candidate) => candidate.sampleLogIds ?? []).filter((value) => value != null))].slice(0, 8),
|
||||
errorNames,
|
||||
representativeMessages,
|
||||
groupedScopes,
|
||||
groupedCandidateCount: sortedBucket.length,
|
||||
};
|
||||
}
|
||||
|
||||
function coalesceErrorLogPlanCandidates(candidates: ErrorLogCandidate[], maxGroups = DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT) {
|
||||
const sortedCandidates = [...candidates].sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
|
||||
});
|
||||
|
||||
if (sortedCandidates.length <= maxGroups) {
|
||||
return sortedCandidates;
|
||||
}
|
||||
|
||||
const bucketSize = Math.ceil(sortedCandidates.length / maxGroups);
|
||||
const merged: ErrorLogCandidate[] = [];
|
||||
|
||||
for (let index = 0; index < sortedCandidates.length; index += bucketSize) {
|
||||
const bucket = sortedCandidates.slice(index, index + bucketSize);
|
||||
|
||||
if (bucket.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(mergeErrorLogPlanCandidateBucket(bucket, merged.length + 1));
|
||||
}
|
||||
|
||||
return merged.slice(0, maxGroups);
|
||||
}
|
||||
|
||||
function filterLogsWithinRange(logs: Array<Record<string, unknown>>, rangeStart: Date, rangeEnd: Date) {
|
||||
const startTime = rangeStart.getTime();
|
||||
const endTime = rangeEnd.getTime();
|
||||
|
||||
return logs.filter((log) => {
|
||||
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime();
|
||||
return createdAtMs != null && createdAtMs >= startTime && createdAtMs <= endTime;
|
||||
});
|
||||
}
|
||||
|
||||
function formatErrorLogPlanNote(candidate: ErrorLogCandidate, rangeStart: Date, rangeEnd: Date) {
|
||||
const lines = [
|
||||
`조회 구간: ${formatIsoTimestamp(rangeStart)} ~ ${formatIsoTimestamp(rangeEnd)}`,
|
||||
`발생 건수: ${candidate.count}건`,
|
||||
`최근 발생: ${formatIsoTimestamp(candidate.lastCreatedAt)}`,
|
||||
`최초 발생: ${formatIsoTimestamp(candidate.firstCreatedAt)}`,
|
||||
`에러 유형: ${candidate.errorType}`,
|
||||
];
|
||||
|
||||
if (candidate.errorName) {
|
||||
lines.push(`에러 이름: ${candidate.errorName}`);
|
||||
}
|
||||
|
||||
if (candidate.sourceLabel || candidate.source) {
|
||||
lines.push(`발생 위치: ${candidate.sourceLabel || candidate.source}`);
|
||||
}
|
||||
|
||||
if (candidate.groupedCandidateCount && candidate.groupedCandidateCount > 1) {
|
||||
lines.push(`묶인 에러 그룹: ${candidate.groupedCandidateCount}개`);
|
||||
}
|
||||
|
||||
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
||||
lines.push(`주요 경로 그룹: ${candidate.requestPathGroup}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.requestPaths) && candidate.requestPaths.length > 0) {
|
||||
lines.push(`대표 경로: ${candidate.requestPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
if (candidate.statusCode != null) {
|
||||
lines.push(`상태 코드: ${candidate.statusCode}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.representativeMessages) && candidate.representativeMessages.length > 0) {
|
||||
lines.push('대표 메시지:');
|
||||
lines.push(...candidate.representativeMessages.map((message, index) => `${index + 1}. ${message}`));
|
||||
} else {
|
||||
lines.push(`대표 메시지: ${String(candidate.errorMessage ?? '').trim()}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.sampleLogIds) && candidate.sampleLogIds.length > 0) {
|
||||
lines.push(`대표 로그 ID: ${candidate.sampleLogIds.join(', ')}`);
|
||||
} else {
|
||||
lines.push(`대표 로그 ID: ${candidate.sampleLogId}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.groupedScopes) && candidate.groupedScopes.length > 0) {
|
||||
lines.push('묶인 에러 범위:');
|
||||
lines.push(...candidate.groupedScopes.map((scope, index) => `${index + 1}. ${scope}`));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('처리 요청:');
|
||||
lines.push('1. 재현 경로와 영향 범위를 확인합니다.');
|
||||
lines.push('2. 수정이 필요한 경우 별도 Plan으로 소스 작업을 진행합니다.');
|
||||
lines.push('3. 테스트와 재발 방지 필요 여부를 검토합니다.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildBoardPostMarker(workId: string) {
|
||||
return `${ERROR_LOG_BOARD_POST_MARKER_PREFIX}${workId} -->`;
|
||||
}
|
||||
|
||||
function buildErrorLogBoardPostTitle(candidate: ErrorLogCandidate) {
|
||||
const scope = candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown'
|
||||
? ` ${candidate.requestPathGroup}`
|
||||
: '';
|
||||
const title = `에러로그 조치 계획: ${candidate.errorType}${scope}`.replace(/\s+/g, ' ').trim();
|
||||
return title.length > 200 ? `${title.slice(0, 197).trimEnd()}...` : title;
|
||||
}
|
||||
|
||||
function buildErrorLogBoardPostContent(candidate: ErrorLogCandidate, rangeStart: Date, rangeEnd: Date) {
|
||||
const detailNote = formatErrorLogPlanNote(candidate, rangeStart, rangeEnd);
|
||||
return [
|
||||
buildBoardPostMarker(candidate.workId),
|
||||
`# ${buildErrorLogBoardPostTitle(candidate)}`,
|
||||
'',
|
||||
detailNote,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function registerErrorLogBoardPosts(args?: {
|
||||
rangeStart?: Date;
|
||||
rangeEnd?: Date;
|
||||
maxGroups?: number;
|
||||
}) {
|
||||
await ensurePlanTable();
|
||||
|
||||
const rangeEnd = args?.rangeEnd ?? new Date();
|
||||
const rangeStart = args?.rangeStart ?? new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000);
|
||||
const maxGroups = args?.maxGroups ?? DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT;
|
||||
const [errorLogs, existingBoardPosts] = await Promise.all([
|
||||
listErrorLogs(200),
|
||||
listBoardPosts(),
|
||||
]);
|
||||
|
||||
const recentLogs = filterLogsWithinRange(errorLogs as Array<Record<string, unknown>>, rangeStart, rangeEnd);
|
||||
const rawCandidates = buildErrorLogPlanCandidates(recentLogs as Array<Record<string, unknown>>);
|
||||
const candidates = coalesceErrorLogPlanCandidates(rawCandidates, maxGroups);
|
||||
const createdPosts: ErrorLogBoardPostRegistration[] = [];
|
||||
const skippedPosts: ErrorLogRegistrationSkip[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const marker = buildBoardPostMarker(candidate.workId);
|
||||
const existingBoardPost = existingBoardPosts.find((post) => String(post.content ?? '').includes(marker));
|
||||
|
||||
if (existingBoardPost) {
|
||||
skippedPosts.push({
|
||||
workId: candidate.workId,
|
||||
boardPostId: existingBoardPost.id,
|
||||
reason: `기존 게시글 #${existingBoardPost.id}가 있습니다.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const latestOpenPlan = await db(PLAN_TABLE)
|
||||
.select(['id', 'status'])
|
||||
.where({ work_id: candidate.workId })
|
||||
.whereNot({ status: '완료' as never })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
|
||||
if (latestOpenPlan) {
|
||||
skippedPosts.push({
|
||||
workId: candidate.workId,
|
||||
planId: Number(latestOpenPlan.id),
|
||||
reason: `기존 미완료 Plan #${latestOpenPlan.id}가 있습니다.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdPost = await createBoardPost({
|
||||
title: buildErrorLogBoardPostTitle(candidate),
|
||||
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
||||
automationType: 'none',
|
||||
});
|
||||
|
||||
createdPosts.push({
|
||||
postId: createdPost.id,
|
||||
title: createdPost.title,
|
||||
workId: candidate.workId,
|
||||
count: candidate.count,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
recentLogs,
|
||||
rawCandidates,
|
||||
candidates,
|
||||
createdPosts,
|
||||
skippedPosts,
|
||||
};
|
||||
}
|
||||
164
etc/servers/work-server/src/services/error-log-service.ts
Executable file
164
etc/servers/work-server/src/services/error-log-service.ts
Executable file
@@ -0,0 +1,164 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export const ERROR_LOG_TABLE = 'error_logs';
|
||||
export const ERROR_LOG_VIEW_TOKEN = 'usr_7f3a9c2d8e1b4a6f';
|
||||
|
||||
export const createErrorLogSchema = z.object({
|
||||
source: z.enum(['server', 'client', 'automation']).default('server'),
|
||||
sourceLabel: z.string().trim().max(80).optional().nullable(),
|
||||
errorType: z.string().trim().min(1).max(120),
|
||||
errorName: z.string().trim().max(255).optional().nullable(),
|
||||
errorMessage: z.string().trim().min(1).max(10000),
|
||||
detail: z.string().trim().max(50000).optional().nullable(),
|
||||
stackTrace: z.string().trim().max(50000).optional().nullable(),
|
||||
statusCode: z.number().int().min(100).max(599).optional().nullable(),
|
||||
requestMethod: z.string().trim().max(10).optional().nullable(),
|
||||
requestPath: z.string().trim().max(1000).optional().nullable(),
|
||||
relatedPlanId: z.number().int().positive().optional().nullable(),
|
||||
relatedWorkId: z.string().trim().max(120).optional().nullable(),
|
||||
context: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type ErrorLogPayload = z.infer<typeof createErrorLogSchema>;
|
||||
|
||||
async function ensureErrorLogTable() {
|
||||
const hasTable = await db.schema.hasTable(ERROR_LOG_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(ERROR_LOG_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('source', 20).notNullable().defaultTo('server');
|
||||
table.string('source_label', 80).nullable();
|
||||
table.string('error_type', 120).notNullable();
|
||||
table.string('error_name', 255).nullable();
|
||||
table.text('error_message').notNullable();
|
||||
table.text('detail').nullable();
|
||||
table.text('stack_trace').nullable();
|
||||
table.integer('status_code').nullable();
|
||||
table.string('request_method', 10).nullable();
|
||||
table.string('request_path', 1000).nullable();
|
||||
table.integer('related_plan_id').nullable();
|
||||
table.string('related_work_id', 120).nullable();
|
||||
table.jsonb('context_json').nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['source', (table) => table.string('source', 20).notNullable().defaultTo('server')],
|
||||
['source_label', (table) => table.string('source_label', 80).nullable()],
|
||||
['error_type', (table) => table.string('error_type', 120).notNullable().defaultTo('unknown')],
|
||||
['error_name', (table) => table.string('error_name', 255).nullable()],
|
||||
['error_message', (table) => table.text('error_message').notNullable().defaultTo('')],
|
||||
['detail', (table) => table.text('detail').nullable()],
|
||||
['stack_trace', (table) => table.text('stack_trace').nullable()],
|
||||
['status_code', (table) => table.integer('status_code').nullable()],
|
||||
['request_method', (table) => table.string('request_method', 10).nullable()],
|
||||
['request_path', (table) => table.string('request_path', 1000).nullable()],
|
||||
['related_plan_id', (table) => table.integer('related_plan_id').nullable()],
|
||||
['related_work_id', (table) => table.string('related_work_id', 120).nullable()],
|
||||
['context_json', (table) => table.jsonb('context_json').nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(ERROR_LOG_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(ERROR_LOG_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayload(payload: ErrorLogPayload) {
|
||||
const parsedPayload = createErrorLogSchema.parse(payload);
|
||||
const sourceLabel =
|
||||
parsedPayload.sourceLabel ??
|
||||
(parsedPayload.source === 'client'
|
||||
? '프론트엔드'
|
||||
: parsedPayload.source === 'automation'
|
||||
? 'Plan 자동화'
|
||||
: '워크서버 API');
|
||||
|
||||
return {
|
||||
source: parsedPayload.source,
|
||||
source_label: sourceLabel,
|
||||
error_type: parsedPayload.errorType,
|
||||
error_name: parsedPayload.errorName ?? null,
|
||||
error_message: parsedPayload.errorMessage,
|
||||
detail: parsedPayload.detail ?? null,
|
||||
stack_trace: parsedPayload.stackTrace ?? null,
|
||||
status_code: parsedPayload.statusCode ?? null,
|
||||
request_method: parsedPayload.requestMethod ?? null,
|
||||
request_path: parsedPayload.requestPath ?? null,
|
||||
related_plan_id: parsedPayload.relatedPlanId ?? null,
|
||||
related_work_id: parsedPayload.relatedWorkId ?? null,
|
||||
context_json: parsedPayload.context ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapErrorLogRow(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
source: String(row.source ?? 'server'),
|
||||
sourceLabel: row.source_label ? String(row.source_label) : null,
|
||||
errorType: String(row.error_type ?? ''),
|
||||
errorName: row.error_name ? String(row.error_name) : null,
|
||||
errorMessage: String(row.error_message ?? ''),
|
||||
detail: row.detail ? String(row.detail) : null,
|
||||
stackTrace: row.stack_trace ? String(row.stack_trace) : null,
|
||||
statusCode: typeof row.status_code === 'number' ? row.status_code : row.status_code ? Number(row.status_code) : null,
|
||||
requestMethod: row.request_method ? String(row.request_method) : null,
|
||||
requestPath: row.request_path ? String(row.request_path) : null,
|
||||
relatedPlanId:
|
||||
typeof row.related_plan_id === 'number'
|
||||
? row.related_plan_id
|
||||
: row.related_plan_id
|
||||
? Number(row.related_plan_id)
|
||||
: null,
|
||||
relatedWorkId: row.related_work_id ? String(row.related_work_id) : null,
|
||||
context: row.context_json && typeof row.context_json === 'object' ? (row.context_json as Record<string, unknown>) : null,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setupErrorLogTable() {
|
||||
await ensureErrorLogTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table: ERROR_LOG_TABLE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createErrorLog(payload: ErrorLogPayload) {
|
||||
await ensureErrorLogTable();
|
||||
|
||||
const [row] = await db(ERROR_LOG_TABLE)
|
||||
.insert({
|
||||
...normalizePayload(payload),
|
||||
created_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return mapErrorLogRow(row);
|
||||
}
|
||||
|
||||
export async function listErrorLogs(limit = 50) {
|
||||
await ensureErrorLogTable();
|
||||
|
||||
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 50;
|
||||
const rows = await db(ERROR_LOG_TABLE).select('*').orderBy('created_at', 'desc').limit(safeLimit);
|
||||
|
||||
return rows.map((row) => mapErrorLogRow(row));
|
||||
}
|
||||
|
||||
export function hasErrorLogViewAccessToken(token: string | string[] | undefined) {
|
||||
const normalizedToken = Array.isArray(token) ? token[0] : token;
|
||||
return String(normalizedToken ?? '').trim() === ERROR_LOG_VIEW_TOKEN;
|
||||
}
|
||||
150
etc/servers/work-server/src/services/git-service.test.ts
Executable file
150
etc/servers/work-server/src/services/git-service.test.ts
Executable file
@@ -0,0 +1,150 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import {
|
||||
ensureBranchExists,
|
||||
mergeBranchToRelease,
|
||||
mergeReleaseToMain,
|
||||
type GitAutomationConfig,
|
||||
} from './git-service.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function runGit(repoPath: string, args: string[]) {
|
||||
const { stdout } = await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'test',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'test',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function createRepo() {
|
||||
const rootPath = await mkdtemp(path.join(tmpdir(), 'git-service-test-'));
|
||||
const remotePath = path.join(rootPath, 'remote.git');
|
||||
const repoPath = path.join(rootPath, 'work');
|
||||
const config: GitAutomationConfig = {
|
||||
repoPath,
|
||||
mainBranch: 'main',
|
||||
releaseBranch: 'release',
|
||||
};
|
||||
|
||||
await execFileAsync('git', ['init', '--bare', '--initial-branch=main', remotePath], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
await execFileAsync('git', ['clone', remotePath, repoPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
},
|
||||
});
|
||||
|
||||
await runGit(repoPath, ['config', 'user.name', 'test']);
|
||||
await runGit(repoPath, ['config', 'user.email', 'test@example.com']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main root']);
|
||||
await runGit(repoPath, ['push', '-u', 'origin', 'main']);
|
||||
|
||||
await runGit(repoPath, ['switch', '-c', 'release']);
|
||||
await runGit(repoPath, ['push', '-u', 'origin', 'release']);
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
|
||||
return { repoPath, config };
|
||||
}
|
||||
|
||||
test('ensureBranchExists creates feature branch from main even when release diverged', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
const mainHead = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
await runGit(repoPath, ['switch', 'release']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release only']);
|
||||
await runGit(repoPath, ['push', 'origin', 'release']);
|
||||
const releaseHead = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
await ensureBranchExists(config, 'feature/test-branch', 'release');
|
||||
|
||||
const featureHead = await runGit(repoPath, ['rev-parse', 'feature/test-branch']);
|
||||
|
||||
assert.equal(featureHead, mainHead);
|
||||
assert.notEqual(featureHead, releaseHead, 'feature branch should point to main head, not release head');
|
||||
});
|
||||
|
||||
test('ensureBranchExists recreates missing local main from origin/main', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
const mainHead = await runGit(repoPath, ['rev-parse', 'main']);
|
||||
|
||||
await runGit(repoPath, ['branch', '-D', 'main']);
|
||||
|
||||
await ensureBranchExists(config, 'feature/recreated-from-origin-main');
|
||||
|
||||
const recreatedMainHead = await runGit(repoPath, ['rev-parse', 'main']);
|
||||
const featureHead = await runGit(repoPath, ['rev-parse', 'feature/recreated-from-origin-main']);
|
||||
|
||||
assert.equal(recreatedMainHead, mainHead);
|
||||
assert.equal(featureHead, mainHead);
|
||||
});
|
||||
|
||||
test('mergeBranchToRelease squashes hotfix changes into release', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
|
||||
await runGit(repoPath, ['switch', 'release']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'release']);
|
||||
|
||||
await runGit(repoPath, ['switch', '-c', 'hotfix/test']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'hotfix change']);
|
||||
await runGit(repoPath, ['push', '-u', 'origin', 'hotfix/test']);
|
||||
const hotfixHead = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
await mergeBranchToRelease(config, 'hotfix/test', 'release');
|
||||
|
||||
const releaseMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'release']);
|
||||
const parentCount = await runGit(repoPath, ['rev-list', '--parents', '-n', '1', 'release']);
|
||||
const releaseHistory = await runGit(repoPath, ['rev-list', 'release']);
|
||||
|
||||
assert.equal(releaseMessage, 'merge: hotfix/test -> release (squash)');
|
||||
assert.equal(parentCount.split(' ').length, 2);
|
||||
assert.ok(!releaseHistory.split('\n').includes(hotfixHead));
|
||||
});
|
||||
|
||||
test('mergeReleaseToMain keeps release to main as a normal merge commit', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
|
||||
await runGit(repoPath, ['switch', 'release']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release change']);
|
||||
await runGit(repoPath, ['push', 'origin', 'release']);
|
||||
|
||||
await mergeReleaseToMain(config, 'release');
|
||||
|
||||
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
|
||||
const parentCount = await runGit(repoPath, ['rev-list', '--parents', '-n', '1', 'main']);
|
||||
|
||||
assert.equal(mainMessage, 'merge: release -> main');
|
||||
assert.equal(parentCount.split(' ').length, 3);
|
||||
});
|
||||
267
etc/servers/work-server/src/services/git-service.ts
Executable file
267
etc/servers/work-server/src/services/git-service.ts
Executable file
@@ -0,0 +1,267 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, copyFile } from 'node:fs/promises';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { promisify } from 'node:util';
|
||||
import { getEnv } from '../config/env.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const gitCredentialSourcePath = '/root/.git-credentials';
|
||||
const gitCredentialCachePath = '/tmp/work-server-git-credentials';
|
||||
|
||||
async function prepareWritableCredentialStore() {
|
||||
try {
|
||||
await access(gitCredentialSourcePath, fsConstants.R_OK);
|
||||
await copyFile(gitCredentialSourcePath, gitCredentialCachePath);
|
||||
return gitCredentialCachePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type GitAutomationConfig = {
|
||||
repoPath: string;
|
||||
releaseBranch: string;
|
||||
mainBranch: string;
|
||||
};
|
||||
|
||||
async function runGit(repoPath: string, args: string[]) {
|
||||
const credentialStorePath = await prepareWritableCredentialStore();
|
||||
const gitArgs = ['-c', `safe.directory=${repoPath}`];
|
||||
const env = getEnv();
|
||||
|
||||
if (credentialStorePath) {
|
||||
gitArgs.push('-c', `credential.helper=store --file=${credentialStorePath}`);
|
||||
}
|
||||
|
||||
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', env.PLAN_GIT_USER_NAME], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.email', env.PLAN_GIT_USER_EMAIL], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
const { stdout, stderr } = await execFileAsync('git', [...gitArgs, '-C', repoPath, ...args], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertBranchExists(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['rev-parse', '--verify', branchName]);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`${branchName} 브랜치를 찾을 수 없습니다. 먼저 ${branchName} 브랜치를 생성한 뒤 다시 시도해 주세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasLocalBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['rev-parse', '--verify', '--quiet', branchName]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasRemoteBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branchName}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBranchWithRemote(repoPath: string, branchName: string) {
|
||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||
|
||||
if (!(await hasRemoteBranch(repoPath, branchName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await hasLocalBranch(repoPath, branchName))) {
|
||||
await runGit(repoPath, ['branch', branchName, `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
try {
|
||||
await runGit(repoPath, ['switch', branchName]);
|
||||
} catch {
|
||||
await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
await runGit(repoPath, ['reset', '--hard', `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
export async function assertCleanWorktree(repoPath: string) {
|
||||
const { stdout } = await runGit(repoPath, ['status', '--porcelain']);
|
||||
|
||||
if (stdout) {
|
||||
throw new Error('Git 작업 디렉터리가 깨끗하지 않습니다. 변경 사항을 정리한 뒤 다시 시도해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanAutomationWorktree(repoPath: string) {
|
||||
await runGit(repoPath, ['reset', '--hard']);
|
||||
await runGit(repoPath, ['clean', '-fd']);
|
||||
}
|
||||
|
||||
export async function ensureBranchExists(config: GitAutomationConfig, branchName: string, releaseTarget?: string) {
|
||||
const baseBranch = config.mainBranch;
|
||||
|
||||
await assertCleanWorktree(config.repoPath);
|
||||
await syncBranchWithRemote(config.repoPath, baseBranch);
|
||||
await assertBranchExists(config.repoPath, baseBranch);
|
||||
await runGit(config.repoPath, ['switch', baseBranch]);
|
||||
await runGit(config.repoPath, ['switch', '-C', branchName]);
|
||||
}
|
||||
|
||||
export async function pushBranch(repoPath: string, branchName: string, setUpstream = false) {
|
||||
await runGit(repoPath, setUpstream ? ['push', '-u', 'origin', branchName] : ['push', 'origin', branchName]);
|
||||
}
|
||||
|
||||
async function deleteLocalBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['branch', '-D', branchName]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRemoteBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['push', 'origin', '--delete', branchName]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function commitAllChanges(repoPath: string, message: string) {
|
||||
await runGit(repoPath, ['add', '-A']);
|
||||
await runGit(repoPath, ['commit', '-m', message]);
|
||||
}
|
||||
|
||||
export async function hasWorkingTreeChanges(repoPath: string) {
|
||||
const { stdout } = await runGit(repoPath, ['status', '--porcelain']);
|
||||
return Boolean(stdout);
|
||||
}
|
||||
|
||||
function isHotfixBranch(branchName: string) {
|
||||
return /^hotfix\//.test(branchName);
|
||||
}
|
||||
|
||||
function isReleaseBranch(branchName: string, config: GitAutomationConfig) {
|
||||
return branchName === config.releaseBranch || /^release([/-]|$)/.test(branchName);
|
||||
}
|
||||
|
||||
function shouldSquashMerge(sourceBranch: string, targetBranch: string, config: GitAutomationConfig) {
|
||||
return isHotfixBranch(sourceBranch) && (
|
||||
targetBranch === config.mainBranch || isReleaseBranch(targetBranch, config)
|
||||
);
|
||||
}
|
||||
|
||||
function assertAllowedMergeDirection(
|
||||
config: GitAutomationConfig,
|
||||
sourceBranch: string,
|
||||
targetBranch: string,
|
||||
) {
|
||||
if (
|
||||
targetBranch === config.mainBranch &&
|
||||
sourceBranch !== config.releaseBranch &&
|
||||
sourceBranch !== config.mainBranch &&
|
||||
!isHotfixBranch(sourceBranch)
|
||||
) {
|
||||
throw new Error(
|
||||
`브랜치 전략 위반: ${sourceBranch} -> ${targetBranch} 직접 머지는 허용되지 않습니다. release 반영 후 main에 반영해 주세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeBranch(
|
||||
config: GitAutomationConfig,
|
||||
sourceBranch: string,
|
||||
targetBranch: string,
|
||||
) {
|
||||
assertAllowedMergeDirection(config, sourceBranch, targetBranch);
|
||||
await assertCleanWorktree(config.repoPath);
|
||||
await syncBranchWithRemote(config.repoPath, targetBranch);
|
||||
await assertBranchExists(config.repoPath, targetBranch);
|
||||
await assertBranchExists(config.repoPath, sourceBranch);
|
||||
|
||||
if (sourceBranch === config.releaseBranch || sourceBranch === config.mainBranch) {
|
||||
await syncBranchWithRemote(config.repoPath, sourceBranch);
|
||||
}
|
||||
|
||||
await runGit(config.repoPath, ['switch', targetBranch]);
|
||||
if (shouldSquashMerge(sourceBranch, targetBranch, config)) {
|
||||
await runGit(config.repoPath, ['merge', '--squash', sourceBranch]);
|
||||
await runGit(config.repoPath, ['commit', '-m', `merge: ${sourceBranch} -> ${targetBranch} (squash)`]);
|
||||
return;
|
||||
}
|
||||
|
||||
await runGit(config.repoPath, ['merge', '--no-ff', sourceBranch, '-m', `merge: ${sourceBranch} -> ${targetBranch}`]);
|
||||
}
|
||||
|
||||
export async function mergeBranchToRelease(
|
||||
config: GitAutomationConfig,
|
||||
branchName: string,
|
||||
releaseTarget?: string,
|
||||
) {
|
||||
const baseBranch = releaseTarget || config.releaseBranch;
|
||||
await mergeBranch(config, branchName, baseBranch);
|
||||
}
|
||||
|
||||
export async function mergeReleaseToMain(
|
||||
config: GitAutomationConfig,
|
||||
releaseTarget?: string,
|
||||
) {
|
||||
const baseBranch = releaseTarget || config.releaseBranch;
|
||||
await mergeBranch(config, baseBranch, config.mainBranch);
|
||||
}
|
||||
|
||||
export async function mergeIssueBranchToMain(
|
||||
config: GitAutomationConfig,
|
||||
branchName: string,
|
||||
) {
|
||||
throw new Error(
|
||||
`브랜치 전략 위반: ${branchName} -> ${config.mainBranch} 직접 머지는 비활성화되었습니다. release 브랜치를 통해 반영해 주세요.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pullMainProjectBranch(repoPath: string, branchName: string) {
|
||||
await assertCleanWorktree(repoPath);
|
||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||
|
||||
try {
|
||||
await runGit(repoPath, ['switch', branchName]);
|
||||
} catch {
|
||||
await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
await runGit(repoPath, ['pull', '--ff-only', 'origin', branchName]);
|
||||
}
|
||||
|
||||
export async function recreateReleaseBranchFromMain(
|
||||
config: GitAutomationConfig,
|
||||
releaseTarget?: string,
|
||||
) {
|
||||
const targetBranch = releaseTarget || config.releaseBranch;
|
||||
|
||||
if (targetBranch === config.mainBranch) {
|
||||
throw new Error('release 브랜치를 main 브랜치와 동일하게 재생성할 수 없습니다.');
|
||||
}
|
||||
|
||||
await assertCleanWorktree(config.repoPath);
|
||||
await syncBranchWithRemote(config.repoPath, config.mainBranch);
|
||||
await assertBranchExists(config.repoPath, config.mainBranch);
|
||||
await runGit(config.repoPath, ['switch', config.mainBranch]);
|
||||
await deleteLocalBranch(config.repoPath, targetBranch);
|
||||
await deleteRemoteBranch(config.repoPath, targetBranch);
|
||||
await runGit(config.repoPath, ['switch', '-C', targetBranch, config.mainBranch]);
|
||||
await pushBranch(config.repoPath, targetBranch, true);
|
||||
}
|
||||
244
etc/servers/work-server/src/services/notification-message-service.ts
Executable file
244
etc/servers/work-server/src/services/notification-message-service.ts
Executable file
@@ -0,0 +1,244 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export const NOTIFICATION_MESSAGE_TABLE = 'notification_messages';
|
||||
|
||||
const notificationMessagePrioritySchema = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||
const notificationMessageListStatusSchema = z.enum(['all', 'unread']);
|
||||
|
||||
export const notificationMessageListQuerySchema = z.object({
|
||||
status: notificationMessageListStatusSchema.default('all'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const notificationMessagePayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
body: z.string().trim().min(1).max(20000),
|
||||
category: z.string().trim().min(1).max(60).default('general'),
|
||||
source: z.string().trim().min(1).max(80).default('system'),
|
||||
priority: notificationMessagePrioritySchema.default('normal'),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const notificationMessageReadPayloadSchema = z.object({
|
||||
read: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type NotificationMessageItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
preview: string;
|
||||
category: string;
|
||||
source: string;
|
||||
priority: z.infer<typeof notificationMessagePrioritySchema>;
|
||||
read: boolean;
|
||||
readAt: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function normalizePreviewText(value: string) {
|
||||
const normalized = value
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/[#>*_`~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function mapNotificationMessageRow(row: Record<string, unknown>): NotificationMessageItem {
|
||||
const body = String(row.body ?? '');
|
||||
const metadata =
|
||||
typeof row.metadata_json === 'object' && row.metadata_json ? (row.metadata_json as Record<string, unknown>) : {};
|
||||
const metadataPreview =
|
||||
typeof metadata.previewText === 'string'
|
||||
? metadata.previewText
|
||||
: typeof metadata.listPreviewText === 'string'
|
||||
? metadata.listPreviewText
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
title: String(row.title ?? ''),
|
||||
body,
|
||||
preview: normalizePreviewText(metadataPreview || body),
|
||||
category: String(row.category ?? 'general'),
|
||||
source: String(row.source ?? 'system'),
|
||||
priority: notificationMessagePrioritySchema.catch('normal').parse(row.priority),
|
||||
read: Boolean(row.is_read),
|
||||
readAt: row.read_at === null || row.read_at === undefined ? null : String(row.read_at),
|
||||
metadata,
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInsertedId(result: unknown): number | null {
|
||||
if (typeof result === 'number' && Number.isInteger(result) && result > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const first = result[0];
|
||||
|
||||
if (typeof first === 'number' && Number.isInteger(first) && first > 0) {
|
||||
return first;
|
||||
}
|
||||
|
||||
if (first && typeof first === 'object' && 'id' in first) {
|
||||
const id = Number((first as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (result && typeof result === 'object' && 'id' in result) {
|
||||
const id = Number((result as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportsReturning() {
|
||||
const clientName = String(db.client.config.client ?? '').toLowerCase();
|
||||
return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName);
|
||||
}
|
||||
|
||||
export async function ensureNotificationMessagesTable() {
|
||||
const hasTable = await db.schema.hasTable(NOTIFICATION_MESSAGE_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(NOTIFICATION_MESSAGE_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('title', 200).notNullable();
|
||||
table.text('body').notNullable();
|
||||
table.string('category', 60).notNullable().defaultTo('general');
|
||||
table.string('source', 80).notNullable().defaultTo('system');
|
||||
table.string('priority', 20).notNullable().defaultTo('normal');
|
||||
table.boolean('is_read').notNullable().defaultTo(false);
|
||||
table.timestamp('read_at', { useTz: true }).nullable();
|
||||
table.jsonb('metadata_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['title', (table) => table.string('title', 200).notNullable().defaultTo('알림')],
|
||||
['body', (table) => table.text('body').notNullable().defaultTo('')],
|
||||
['category', (table) => table.string('category', 60).notNullable().defaultTo('general')],
|
||||
['source', (table) => table.string('source', 80).notNullable().defaultTo('system')],
|
||||
['priority', (table) => table.string('priority', 20).notNullable().defaultTo('normal')],
|
||||
['is_read', (table) => table.boolean('is_read').notNullable().defaultTo(false)],
|
||||
['read_at', (table) => table.timestamp('read_at', { useTz: true }).nullable()],
|
||||
['metadata_json', (table) => table.jsonb('metadata_json').notNullable().defaultTo('{}')],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(NOTIFICATION_MESSAGE_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(NOTIFICATION_MESSAGE_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listNotificationMessages(query: z.infer<typeof notificationMessageListQuerySchema>) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const parsedQuery = notificationMessageListQuerySchema.parse(query);
|
||||
const builder = db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.select('*')
|
||||
.orderBy('is_read', 'asc')
|
||||
.orderBy('created_at', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(parsedQuery.limit);
|
||||
|
||||
if (parsedQuery.status === 'unread') {
|
||||
builder.where({ is_read: false });
|
||||
}
|
||||
|
||||
const rows = await builder;
|
||||
const unreadCountResult = await db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.where({ is_read: false })
|
||||
.count<{ count: string | number }>({ count: '*' })
|
||||
.first();
|
||||
|
||||
return {
|
||||
items: rows.map((row) => mapNotificationMessageRow(row)),
|
||||
unreadCount: Number(unreadCountResult?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNotificationMessage(id: number) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).first();
|
||||
return row ? mapNotificationMessageRow(row) : null;
|
||||
}
|
||||
|
||||
export async function createNotificationMessage(payload: z.infer<typeof notificationMessagePayloadSchema>) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const parsedPayload = notificationMessagePayloadSchema.parse(payload);
|
||||
const insertQuery = db(NOTIFICATION_MESSAGE_TABLE).insert({
|
||||
title: parsedPayload.title,
|
||||
body: parsedPayload.body,
|
||||
category: parsedPayload.category,
|
||||
source: parsedPayload.source,
|
||||
priority: parsedPayload.priority,
|
||||
metadata_json: parsedPayload.metadata,
|
||||
is_read: false,
|
||||
read_at: null,
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery;
|
||||
const insertedId = resolveInsertedId(insertResult);
|
||||
|
||||
if (!insertedId) {
|
||||
throw new Error('알림 메시지 저장 후 ID를 확인하지 못했습니다.');
|
||||
}
|
||||
|
||||
const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id: insertedId }).first();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('저장된 알림 메시지를 다시 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
return mapNotificationMessageRow(row);
|
||||
}
|
||||
|
||||
export async function updateNotificationMessageReadState(
|
||||
id: number,
|
||||
payload: z.infer<typeof notificationMessageReadPayloadSchema>,
|
||||
) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const parsedPayload = notificationMessageReadPayloadSchema.parse(payload);
|
||||
const updatedCount = await db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
is_read: parsedPayload.read,
|
||||
read_at: parsedPayload.read ? db.fn.now() : null,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (!updatedCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).first();
|
||||
return row ? mapNotificationMessageRow(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteNotificationMessage(id: number) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const deletedCount = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).del();
|
||||
return deletedCount > 0;
|
||||
}
|
||||
900
etc/servers/work-server/src/services/notification-service.ts
Executable file
900
etc/servers/work-server/src/services/notification-service.ts
Executable file
@@ -0,0 +1,900 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Notification, Provider } from '@parse/node-apn';
|
||||
import webpush from 'web-push';
|
||||
import { z } from 'zod';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import { db } from '../db/client.js';
|
||||
import { ensureNotificationMessagesTable } from './notification-message-service.js';
|
||||
|
||||
export const NOTIFICATION_TOKEN_TABLE = 'notification_tokens';
|
||||
export const WEB_PUSH_SUBSCRIPTION_TABLE = 'web_push_subscriptions';
|
||||
export const NOTIFICATION_PREFERENCE_TABLE = 'notification_preferences';
|
||||
|
||||
const automationNotificationPreferenceSchema = z.object({
|
||||
notifyOnAutomationStart: z.boolean().optional(),
|
||||
notifyOnAutomationProgress: z.boolean().optional(),
|
||||
notifyOnAutomationCompletion: z.boolean().optional(),
|
||||
notifyOnAutomationRelease: z.boolean().optional(),
|
||||
notifyOnAutomationMain: z.boolean().optional(),
|
||||
notifyOnAutomationFailure: z.boolean().optional(),
|
||||
notifyOnAutomationRestart: z.boolean().optional(),
|
||||
notifyOnAutomationIssueResolved: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const notificationTargetKindSchema = z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']);
|
||||
|
||||
export const registerAutomationNotificationPreferenceSchema = z.object({
|
||||
targetKind: notificationTargetKindSchema.default('client'),
|
||||
targetId: z.string().trim().min(1).max(1000).optional(),
|
||||
automation: automationNotificationPreferenceSchema,
|
||||
});
|
||||
|
||||
export const registerIosTokenSchema = z.object({
|
||||
token: z.string().trim().min(1),
|
||||
deviceId: z.string().trim().min(1).max(200).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const unregisterIosTokenSchema = z.object({
|
||||
token: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const registerWebPushSubscriptionSchema = z.object({
|
||||
subscription: z.object({
|
||||
endpoint: z.string().trim().url(),
|
||||
expirationTime: z.number().nullable().optional(),
|
||||
keys: z.object({
|
||||
p256dh: z.string().trim().min(1),
|
||||
auth: z.string().trim().min(1),
|
||||
}),
|
||||
}),
|
||||
deviceId: z.string().trim().min(1).max(200).optional(),
|
||||
userAgent: z.string().trim().max(500).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const unregisterWebPushSubscriptionSchema = z.object({
|
||||
endpoint: z.string().trim().url(),
|
||||
});
|
||||
|
||||
export const sendIosNotificationSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
body: z.string().trim().min(1),
|
||||
data: z.record(z.string(), z.string()).default({}),
|
||||
threadId: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
type IosNotificationPayload = z.infer<typeof sendIosNotificationSchema>;
|
||||
type WebPushSubscriptionPayload = z.infer<typeof registerWebPushSubscriptionSchema>['subscription'];
|
||||
type AutomationNotificationPreference = z.infer<typeof automationNotificationPreferenceSchema>;
|
||||
type NotificationTargetKind = z.infer<typeof notificationTargetKindSchema>;
|
||||
type NotificationPreferenceTarget = {
|
||||
kind: NotificationTargetKind;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type WebPushFailureDetail = {
|
||||
endpoint: string;
|
||||
statusCode?: number;
|
||||
detail?: string;
|
||||
code?: string;
|
||||
attemptCount: number;
|
||||
};
|
||||
|
||||
function buildScopedPwaNotificationTargetId(token: string, clientId: string) {
|
||||
return [token.trim(), clientId.trim()].filter(Boolean).join('::client::');
|
||||
}
|
||||
|
||||
let providerPromise: Promise<Provider | null> | null = null;
|
||||
let providerSignature: string | null = null;
|
||||
|
||||
function normalizePrivateKey(privateKey: string) {
|
||||
return privateKey.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
async function loadPrivateKey(env: ReturnType<typeof getEnv>) {
|
||||
if (env.APNS_PRIVATE_KEY?.trim()) {
|
||||
return normalizePrivateKey(env.APNS_PRIVATE_KEY.trim());
|
||||
}
|
||||
|
||||
if (env.APNS_PRIVATE_KEY_PATH?.trim()) {
|
||||
const file = await readFile(env.APNS_PRIVATE_KEY_PATH.trim(), 'utf8');
|
||||
return file.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasWebPushConfig(env: ReturnType<typeof getEnv>) {
|
||||
return Boolean(
|
||||
env.WEB_PUSH_ENABLED &&
|
||||
env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() &&
|
||||
env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() &&
|
||||
env.WEB_PUSH_SUBJECT?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureWebPushConfigured(env: ReturnType<typeof getEnv>) {
|
||||
if (!hasWebPushConfig(env)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(
|
||||
env.WEB_PUSH_SUBJECT,
|
||||
env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim(),
|
||||
env.WEB_PUSH_VAPID_PRIVATE_KEY!.trim(),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeNotificationDetailText(text?: string | null) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function isChatNotificationPayload(payload: IosNotificationPayload) {
|
||||
const category = String(payload.data?.category ?? '').trim().toLowerCase();
|
||||
const threadId = String(payload.threadId ?? '').trim().toLowerCase();
|
||||
return category === 'chat' || threadId.startsWith('chat:');
|
||||
}
|
||||
|
||||
function isRetryableWebPushError(error: any) {
|
||||
const statusCode = Number(error?.statusCode ?? 0);
|
||||
|
||||
if ([408, 425, 429, 500, 502, 503, 504].includes(statusCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const code = String(error?.code ?? '').trim().toUpperCase();
|
||||
return ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(code);
|
||||
}
|
||||
|
||||
async function waitForRetry(delayMs: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
async function sendWebPushWithRetry(subscription: WebPushSubscriptionPayload, payloadText: string) {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
try {
|
||||
await webpush.sendNotification(subscription, payloadText);
|
||||
return { attemptCount: attempt };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt >= 2 || !isRetryableWebPushError(error)) {
|
||||
throw {
|
||||
error,
|
||||
attemptCount: attempt,
|
||||
};
|
||||
}
|
||||
|
||||
await waitForRetry(250 * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
error: lastError,
|
||||
attemptCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderSignature(env: ReturnType<typeof getEnv>) {
|
||||
return [
|
||||
env.IOS_NOTIFICATION_ENABLED,
|
||||
env.APNS_KEY_ID,
|
||||
env.APNS_TEAM_ID,
|
||||
env.APNS_BUNDLE_ID,
|
||||
env.APNS_PRIVATE_KEY,
|
||||
env.APNS_PRIVATE_KEY_PATH,
|
||||
env.APNS_PRODUCTION,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
async function createProvider(env: ReturnType<typeof getEnv>) {
|
||||
if (
|
||||
!env.IOS_NOTIFICATION_ENABLED ||
|
||||
!env.APNS_KEY_ID ||
|
||||
!env.APNS_TEAM_ID ||
|
||||
!env.APNS_BUNDLE_ID
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const privateKey = await loadPrivateKey(env);
|
||||
|
||||
if (!privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Provider({
|
||||
token: {
|
||||
key: privateKey,
|
||||
keyId: env.APNS_KEY_ID,
|
||||
teamId: env.APNS_TEAM_ID,
|
||||
},
|
||||
production: env.APNS_PRODUCTION,
|
||||
});
|
||||
}
|
||||
|
||||
async function getProvider() {
|
||||
const env = getEnv();
|
||||
const signature = buildProviderSignature(env);
|
||||
|
||||
if (signature !== providerSignature) {
|
||||
providerSignature = signature;
|
||||
providerPromise = null;
|
||||
}
|
||||
|
||||
if (!providerPromise) {
|
||||
providerPromise = createProvider(env);
|
||||
}
|
||||
|
||||
return providerPromise;
|
||||
}
|
||||
|
||||
async function ensureNotificationTokenTable() {
|
||||
const hasTable = await db.schema.hasTable(NOTIFICATION_TOKEN_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(NOTIFICATION_TOKEN_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('platform', 20).notNullable().defaultTo('ios');
|
||||
table.string('device_token', 255).notNullable().unique();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.boolean('is_enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['platform', (table) => table.string('platform', 20).notNullable().defaultTo('ios')],
|
||||
['device_token', (table) => table.string('device_token', 255).notNullable()],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
|
||||
[
|
||||
'last_registered_at',
|
||||
(table) => table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()),
|
||||
],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(NOTIFICATION_TOKEN_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(NOTIFICATION_TOKEN_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWebPushSubscriptionTable() {
|
||||
const hasTable = await db.schema.hasTable(WEB_PUSH_SUBSCRIPTION_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(WEB_PUSH_SUBSCRIPTION_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('endpoint', 1000).notNullable().unique();
|
||||
table.jsonb('subscription_json').notNullable();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.boolean('is_enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['endpoint', (table) => table.string('endpoint', 1000).notNullable()],
|
||||
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['user_agent', (table) => table.text('user_agent').nullable()],
|
||||
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
|
||||
[
|
||||
'last_registered_at',
|
||||
(table) => table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()),
|
||||
],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(WEB_PUSH_SUBSCRIPTION_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(WEB_PUSH_SUBSCRIPTION_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNotificationPreferenceTable() {
|
||||
const hasTable = await db.schema.hasTable(NOTIFICATION_PREFERENCE_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(NOTIFICATION_PREFERENCE_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('target_kind', 40).notNullable();
|
||||
table.string('target_id', 1000).notNullable();
|
||||
table.jsonb('config_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['target_kind', 'target_id']);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['target_kind', (table) => table.string('target_kind', 40).notNullable().defaultTo('client')],
|
||||
['target_id', (table) => table.string('target_id', 1000).notNullable().defaultTo('')],
|
||||
['config_json', (table) => table.jsonb('config_json').notNullable().defaultTo('{}')],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(NOTIFICATION_PREFERENCE_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(NOTIFICATION_PREFERENCE_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupNotificationTables() {
|
||||
await ensureNotificationTokenTable();
|
||||
await ensureWebPushSubscriptionTable();
|
||||
await ensureNotificationPreferenceTable();
|
||||
await ensureNotificationMessagesTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tables: [NOTIFICATION_TOKEN_TABLE, WEB_PUSH_SUBSCRIPTION_TABLE, NOTIFICATION_PREFERENCE_TABLE, 'notification_messages'],
|
||||
};
|
||||
}
|
||||
|
||||
export function getWebPushConfig() {
|
||||
const env = getEnv();
|
||||
return {
|
||||
enabled: hasWebPushConfig(env),
|
||||
publicKey: hasWebPushConfig(env) ? env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function listIosNotificationTokens() {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
const rows = await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.where({ platform: 'ios' })
|
||||
.orderBy('updated_at', 'desc');
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
platform: row.platform,
|
||||
token: row.device_token,
|
||||
deviceId: row.device_id,
|
||||
enabled: row.is_enabled,
|
||||
lastRegisteredAt: row.last_registered_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function registerIosNotificationToken(payload: z.infer<typeof registerIosTokenSchema>) {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterIosNotificationToken(payload.token);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: true,
|
||||
token: payload.token,
|
||||
};
|
||||
}
|
||||
|
||||
await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.insert({
|
||||
platform: 'ios',
|
||||
device_token: payload.token,
|
||||
device_id: payload.deviceId ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.onConflict('device_token')
|
||||
.merge({
|
||||
platform: 'ios',
|
||||
device_id: payload.deviceId ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
token: payload.token,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAutomationNotificationPreference(raw: unknown): AutomationNotificationPreference {
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return automationNotificationPreferenceSchema.parse(JSON.parse(raw));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return automationNotificationPreferenceSchema.parse(raw ?? {});
|
||||
}
|
||||
|
||||
export async function getAutomationNotificationPreference(
|
||||
targetId: string,
|
||||
targetKind: NotificationTargetKind = 'client',
|
||||
) {
|
||||
const normalizedTargetId = targetId.trim();
|
||||
|
||||
if (!normalizedTargetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureNotificationPreferenceTable();
|
||||
|
||||
const row = await db(NOTIFICATION_PREFERENCE_TABLE)
|
||||
.where({
|
||||
target_kind: targetKind,
|
||||
target_id: normalizedTargetId,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseAutomationNotificationPreference(row.config_json);
|
||||
}
|
||||
|
||||
export async function upsertAutomationNotificationPreference(
|
||||
payload: z.infer<typeof registerAutomationNotificationPreferenceSchema> & { targetId: string },
|
||||
) {
|
||||
const targetId = payload.targetId.trim();
|
||||
await ensureNotificationPreferenceTable();
|
||||
|
||||
await db(NOTIFICATION_PREFERENCE_TABLE)
|
||||
.insert({
|
||||
target_kind: payload.targetKind,
|
||||
target_id: targetId,
|
||||
config_json: payload.automation,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.onConflict(['target_kind', 'target_id'])
|
||||
.merge({
|
||||
config_json: payload.automation,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: payload.targetKind,
|
||||
targetId,
|
||||
automation: payload.automation,
|
||||
};
|
||||
}
|
||||
|
||||
export async function unregisterIosNotificationToken(token: string) {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
const deletedCount = await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.where({
|
||||
platform: 'ios',
|
||||
device_token: token,
|
||||
})
|
||||
.delete();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: deletedCount > 0,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerWebPushSubscription(
|
||||
payload: z.infer<typeof registerWebPushSubscriptionSchema>,
|
||||
) {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterWebPushSubscription(payload.subscription.endpoint);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: true,
|
||||
endpoint: payload.subscription.endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.insert({
|
||||
endpoint: payload.subscription.endpoint,
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.onConflict('endpoint')
|
||||
.merge({
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (payload.deviceId?.trim()) {
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.where({ device_id: payload.deviceId.trim() })
|
||||
.whereNot({ endpoint: payload.subscription.endpoint })
|
||||
.delete();
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
endpoint: payload.subscription.endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export async function unregisterWebPushSubscription(endpoint: string) {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
|
||||
const deletedCount = await db(WEB_PUSH_SUBSCRIPTION_TABLE).where({ endpoint }).delete();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: deletedCount > 0,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
async function getEnabledIosTokens() {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
const rows = await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.where({
|
||||
platform: 'ios',
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('device_token', 'device_id');
|
||||
|
||||
return rows.map((row) => ({
|
||||
token: String(row.device_token),
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function getEnabledWebPushSubscriptions() {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
|
||||
const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.where({
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('endpoint', 'subscription_json', 'device_id');
|
||||
|
||||
return rows.map((row) => ({
|
||||
endpoint: String(row.endpoint),
|
||||
subscription: row.subscription_json as WebPushSubscriptionPayload,
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function removeInvalidIosTokens(tokens: string[]) {
|
||||
if (!tokens.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.whereIn('device_token', tokens)
|
||||
.delete();
|
||||
}
|
||||
|
||||
async function removeInvalidWebPushSubscriptions(endpoints: string[]) {
|
||||
if (!endpoints.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.whereIn('endpoint', endpoints)
|
||||
.delete();
|
||||
}
|
||||
|
||||
function shouldNotifyAutomationEvent(
|
||||
automation: AutomationNotificationPreference | null | undefined,
|
||||
eventType: string,
|
||||
) {
|
||||
if (eventType === 'work-started') {
|
||||
return automation?.notifyOnAutomationStart ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'work-progress') {
|
||||
return automation?.notifyOnAutomationProgress ?? true;
|
||||
}
|
||||
|
||||
if (
|
||||
eventType === 'work-completed' ||
|
||||
eventType === 'work-noop-complete' ||
|
||||
eventType === 'development-completed' ||
|
||||
eventType === 'plan-completed'
|
||||
) {
|
||||
return automation?.notifyOnAutomationCompletion ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'release-merged') {
|
||||
return automation?.notifyOnAutomationRelease ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'main-merged') {
|
||||
return automation?.notifyOnAutomationMain ?? true;
|
||||
}
|
||||
|
||||
if (
|
||||
eventType === 'branch-failed' ||
|
||||
eventType === 'work-failed' ||
|
||||
eventType === 'release-failed' ||
|
||||
eventType === 'main-failed'
|
||||
) {
|
||||
return automation?.notifyOnAutomationFailure ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'plan-restarted') {
|
||||
return automation?.notifyOnAutomationRestart ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'issue-resolved') {
|
||||
return automation?.notifyOnAutomationIssueResolved ?? true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isNotificationRecipientAllowed(
|
||||
preferenceTargets: NotificationPreferenceTarget[],
|
||||
payload: IosNotificationPayload,
|
||||
) {
|
||||
const eventType = payload.data.eventType?.trim();
|
||||
|
||||
if (!eventType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const target of preferenceTargets) {
|
||||
if (!target.id.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const automation = await getAutomationNotificationPreference(target.id, target.kind);
|
||||
|
||||
if (automation) {
|
||||
return shouldNotifyAutomationEvent(automation, eventType);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const provider = await getProvider();
|
||||
|
||||
if (!provider || !env.APNS_BUNDLE_ID) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: 'APNs 설정이 비어 있습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const tokenRows = await getEnabledIosTokens();
|
||||
const tokens = (
|
||||
await Promise.all(
|
||||
tokenRows.map(async (row) => ({
|
||||
token: row.token,
|
||||
allowed: await isNotificationRecipientAllowed(
|
||||
[
|
||||
{ kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) },
|
||||
{ kind: 'ios-token', id: row.token },
|
||||
{ kind: 'client', id: row.deviceId },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
})),
|
||||
)
|
||||
)
|
||||
.filter((row) => row.allowed)
|
||||
.map((row) => row.token);
|
||||
|
||||
if (!tokens.length) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: '등록된 iOS 알림 토큰이 없습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const notification = new Notification();
|
||||
notification.topic = env.APNS_BUNDLE_ID;
|
||||
notification.pushType = 'alert';
|
||||
notification.priority = 10;
|
||||
notification.expiry = Math.floor(Date.now() / 1000) + 3600;
|
||||
notification.alert = {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
};
|
||||
notification.sound = 'default';
|
||||
notification.badge = 1;
|
||||
notification.payload = payload.data;
|
||||
if (payload.threadId) {
|
||||
notification.threadId = payload.threadId;
|
||||
}
|
||||
|
||||
const response = await provider.send(notification, tokens);
|
||||
const invalidTokens = response.failed
|
||||
.map((result) => result.device)
|
||||
.filter((device): device is string => Boolean(device));
|
||||
|
||||
await removeInvalidIosTokens(invalidTokens);
|
||||
|
||||
return {
|
||||
ok: response.failed.length === 0,
|
||||
skipped: false,
|
||||
sentCount: response.sent.length,
|
||||
failedCount: response.failed.length,
|
||||
invalidTokens,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
if (!ensureWebPushConfigured(env)) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: 'Web Push 설정이 비어 있습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const subscriptions = (
|
||||
await Promise.all(
|
||||
(await getEnabledWebPushSubscriptions()).map(async (row) => ({
|
||||
...row,
|
||||
allowed: await isNotificationRecipientAllowed(
|
||||
[
|
||||
{ kind: 'web-endpoint', id: row.endpoint },
|
||||
{ kind: 'client', id: row.deviceId },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
})),
|
||||
)
|
||||
).filter((row) => row.allowed);
|
||||
|
||||
if (!subscriptions.length) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: '등록된 Web Push 구독이 없습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const payloadText = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
threadId: payload.threadId,
|
||||
});
|
||||
const preserveSubscriptions = isChatNotificationPayload(payload);
|
||||
|
||||
const invalidEndpoints: string[] = [];
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
const failures: WebPushFailureDetail[] = [];
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.map(async ({ endpoint, subscription }) => {
|
||||
try {
|
||||
await sendWebPushWithRetry(subscription, payloadText);
|
||||
sentCount += 1;
|
||||
} catch (error: any) {
|
||||
const deliveryError = error?.error ?? error;
|
||||
const attemptCount = Number(error?.attemptCount ?? 1);
|
||||
failedCount += 1;
|
||||
const statusCode = Number(deliveryError?.statusCode ?? 0);
|
||||
const detail =
|
||||
normalizeNotificationDetailText(deliveryError?.body) ?? normalizeNotificationDetailText(deliveryError?.message);
|
||||
const code = normalizeNotificationDetailText(deliveryError?.code);
|
||||
|
||||
if (!preserveSubscriptions && (statusCode === 404 || statusCode === 410)) {
|
||||
invalidEndpoints.push(endpoint);
|
||||
}
|
||||
|
||||
failures.push({
|
||||
endpoint,
|
||||
statusCode: statusCode || undefined,
|
||||
detail,
|
||||
code,
|
||||
attemptCount,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!preserveSubscriptions) {
|
||||
await removeInvalidWebPushSubscriptions(invalidEndpoints);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.warn(
|
||||
'[notification-service] web push delivery failed',
|
||||
JSON.stringify({
|
||||
failedCount,
|
||||
preserveSubscriptions,
|
||||
invalidEndpointCount: invalidEndpoints.length,
|
||||
failures,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: failedCount === 0,
|
||||
skipped: false,
|
||||
sentCount,
|
||||
failedCount,
|
||||
invalidEndpoints,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendNotifications(payload: IosNotificationPayload) {
|
||||
const [ios, web] = await Promise.all([
|
||||
sendIosNotifications(payload),
|
||||
sendWebPushNotifications(payload),
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: ios.ok || web.ok,
|
||||
ios,
|
||||
web,
|
||||
};
|
||||
}
|
||||
|
||||
export async function shutdownNotificationProvider() {
|
||||
const provider = await getProvider();
|
||||
provider?.shutdown();
|
||||
providerPromise = null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user