diff --git a/AGENTS.md b/AGENTS.md index 7d2d122..5a84f91 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,12 @@ * 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat//resource/` 아래 세션 전용 경로를 기준으로 사용한다 * 채팅 첨부 파일은 `public/.codex_chat//resource/uploads/` 아래 경로를 기준으로 사용한다 * 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다 +* 채팅 답변에서 링크 카드는 외부 공개 링크에만 사용하고 `[[link-card:제목|URL|버튼라벨]]` 형식을 정확히 지킨다 +* 세션 리소스, 내부 문서, 코드, 로그, 테이블 파일처럼 `/api/chat/resources/...`, `/.codex_chat/...`, `/public/.codex_chat/...` 아래 내부 리소스에는 링크 카드를 사용하지 않는다 +* 링크 카드 URL 안에 구분자 `|`를 `%7C`로 인코딩하거나 `https:/api/...`처럼 잘못 줄여 쓰지 않는다 +* 모바일 캡처 결과나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 형식으로 제공한다 +* 모바일 캡처 결과를 설명하는 본문 옆에 링크 카드를 덧붙이더라도, 같은 리소스의 `[[preview:URL]]` 표시는 생략하지 않는다 +* 내부 문서성 리소스는 일반 경로나 자동 프리뷰 가능한 리소스 URL로 제공하고, 표 형태 확인이 필요하면 preview 컴포넌트에서 바로 열 수 있는 형식을 우선 사용한다 * 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다 * 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다 diff --git a/README.md b/README.md index db3f9ee..4373d54 100755 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ src/ │ ├─ status-badge/ # 상태 표현 UI │ └─ window/ # 드래그/리사이즈 가능한 윈도우 UI ├─ features/ +│ ├─ board/ # 작업 요청 게시판과 자동화 접수 화면 │ ├─ dashboard/ # 프로젝트 전용 대시보드 샘플 │ ├─ layout/ # 프로젝트 전용 레이아웃 문서 │ ├─ markdownPreview/ # 기능 레벨 Markdown 카드 @@ -113,6 +114,7 @@ docs/ - `APIs / Components`: 공통 컴포넌트 샘플 탐색 - `APIs / Widgets`: 위젯 샘플 탐색 - `Docs`: `docs/**/*.md`와 일부 `src/features/**/*.md` 문서 탐색 +- `Plans / 작업 요청`: 게시글 1건 안에 여러 하위 요청을 묶고 자동화 접수를 추적 - `Plans`: 작업 항목, 조치 이력, 이슈 이력을 관리하는 Plan 게시판 ## 문서 위치 @@ -120,7 +122,16 @@ docs/ - 전체 문서 가이드: `docs/README.md` - 작업일지: `docs/worklogs` - 기능 문서: `docs/features` +- 작업 요청 기능 문서: `docs/features/work-request-board.md` - 컴포넌트 문서: `docs/components` +- 공통 컴포넌트 패키지 가이드: `src/components/README.md` +- 공통 위젯 패키지 가이드: `src/widgets/README.md` + +## 공통 패키지 문서 규칙 + +- `src/components`, `src/widgets`처럼 여러 화면에서 공통 재사용되는 패키지에는 해당 패키지 하위 `README.md`를 둡니다. +- 패키지 하위 문서에는 최소한 목적, 하위 구조, export 또는 registry 기준점, 샘플 및 문서 연결 규약을 적습니다. +- 컴포넌트 또는 위젯 구조를 바꾸면 구현 파일만 수정하지 말고 해당 패키지 `README.md`와 관련 `docs/components/*.md`도 함께 점검합니다. ## 운영 메모 diff --git a/docs/README.md b/docs/README.md index abc47b6..6eda030 100755 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,14 @@ - 예외 처리 - 테스트 포인트 +현재 주요 기능 문서: + +- `docs/features/work-request-board.md`: 작업 요청 게시글, 하위 요청, 순차 자동화 접수 +- `docs/features/plan-board-review.md`: Plan 게시판과 상세 처리 +- `docs/features/plan-automation.md`: 자동화 처리 흐름과 worker 기준 +- `docs/features/plan-schedule.md`: 반복 등록과 스케줄 관리 +- `docs/features/plan-usage.md`: 운영자/검수자 활용 순서 + ## 3. 컴포넌트 문서 - 위치: `docs/components` @@ -81,6 +89,11 @@ src/components 공통 플러그인 타입과 유틸은 `src/types/component-plugin.ts` 에서 관리합니다. +패키지 기준 안내 문서: + +- `src/components/README.md`: 공통 컴포넌트 패키지 목적, 구조, export 규약 +- `src/widgets/README.md`: 공통 위젯 패키지 목적, registry, feature 규약 + 샘플 운영 규칙: - `samples/Sample.tsx`는 해당 컴포넌트의 가장 기본형만 표현 @@ -94,6 +107,7 @@ src/components - 카드 내부는 `Base Sample` 아래에 `Plugin Samples`, `Feature Samples`를 순차적으로 배치 - 위젯 샘플은 `widgets/**/samples/*.tsx` 기준으로 별도 수집 - 실제 샘플 엔트리 로딩은 `src/app/manifests/samples.manifest.ts`, `src/samples/registry.ts`를 기준으로 동작 +- 위젯 공통 계약과 메타데이터 규약은 `src/widgets/README.md`, `src/widgets/registry.ts`, `src/widgets/core`를 함께 기준으로 봅니다 ## 5. 프로젝트 종속 레이아웃 @@ -103,6 +117,8 @@ src/components - `Layout Editor`의 기능 명세는 위젯 기본 스펙 정의가 아니라, 현재 레이아웃에 배치된 컴포넌트의 화면 역할과 상호작용을 기술하는 데이터로 취급 - 따라서 컴포넌트 간 이벤트/상태 연결과 API 연결 흐름도 `Layout Editor` 기능 명세에서 구현 가능한 범위로 본다 - 따라서 샘플 메타나 위젯 기본 기능을 `Layout Editor` 기능 명세에 자동 주입하지 않음 +- `Layout Editor` 구현은 공통 위젯/컴포넌트 본체 직접 수정보다 `props` 전달과 feature 레이어 조합을 우선한다 +- 공통 위젯/컴포넌트 변경이 필요하면 기본값 `props`를 기존 동작과 동일하게 유지해 기존 화면 영향이 없도록 설계한다 프로젝트 종속 기능 규칙: @@ -159,9 +175,10 @@ src/components ## 10. Plan 기능 문서 메모 - `Plan` 기능은 `src/features/planBoard`에서 관리 +- `작업 요청` 기능은 `src/features/board`에서 관리하며 게시글 1건에 N개 하위 요청을 둘 수 있습니다. - 현재 앱에는 목록/상세 보드, release 검수, 차트, 스케줄 화면이 함께 포함됨 - 기본 상태는 `등록`, `작업중`, `작업완료`, `릴리즈완료`, `완료` - 자동화 진행은 `workerStatus`로 별도 관리하며 브랜치 준비, 자동 작업, release/main 반영, 프로젝트 루트 pull 완료 상태를 포함한 흐름을 표현 - `작업시작` 이후에는 원본 요청(`작업 ID`, 원본 메모`)을 수정하지 않고 조치 이력으로 누적 기록 - 권한 토큰이 없으면 조회 중심으로 동작하며 민감 메모와 소스 작업 일부는 제한 또는 마스킹 -- 관련 기능 문서는 `docs/features/plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고 +- 관련 기능 문서는 `docs/features/work-request-board.md`, `plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고 diff --git a/docs/components/check-combo.md b/docs/components/check-combo.md index 775f83c..82e78fd 100755 --- a/docs/components/check-combo.md +++ b/docs/components/check-combo.md @@ -4,6 +4,13 @@ `code/value` 데이터를 받아 여러 항목을 체크박스 형태로 선택하고, 실제 값은 `code[]`로 유지하는 다중 선택 combo 입력 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 폴더 구조 ```text diff --git a/docs/components/codex-diff-previewer.md b/docs/components/codex-diff-previewer.md index 33e1abc..05e3ce9 100755 --- a/docs/components/codex-diff-previewer.md +++ b/docs/components/codex-diff-previewer.md @@ -4,6 +4,13 @@ 변경 파일의 전체 소스와 raw diff를 codex 스타일 아코디언으로 함께 보여주는 공통 preview 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 폴더 구조 ```text diff --git a/docs/components/component-addition-suggestions.md b/docs/components/component-addition-suggestions.md index 3620fe2..8f160cf 100755 --- a/docs/components/component-addition-suggestions.md +++ b/docs/components/component-addition-suggestions.md @@ -8,6 +8,13 @@ 이 글은 검토용 plan 게시판 작성만 수행하며, 자동화 접수는 하지 않고 미접수 상태로 유지합니다. +### 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트와 위젯에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트와 위젯은 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행해야 합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당해야 합니다. +- 공통 컴포넌트와 위젯은 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완해야 합니다. + ### release 기준 확인 - 이미 존재: Dashboard Report Card, Progress/MultiProgress, Search Command, Popup/Select/CheckCombo 입력, Markdown Preview, Previewer/Codex Diff, Status Badge, Window, DataListTable, EmbeddedMap, TextMemo/GPS/API 샘플 위젯 diff --git a/docs/components/evidence-attachment-strip-ui.md b/docs/components/evidence-attachment-strip-ui.md index f514db9..8586965 100755 --- a/docs/components/evidence-attachment-strip-ui.md +++ b/docs/components/evidence-attachment-strip-ui.md @@ -4,6 +4,13 @@ Plan/Board 계열 화면에서 반복되는 산출물 카드, 링크, 미리보기 진입 UI를 공통 스트립으로 정리하는 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 지원 타입 - `image` diff --git a/docs/components/input.md b/docs/components/input.md index 24073ce..b190809 100755 --- a/docs/components/input.md +++ b/docs/components/input.md @@ -4,6 +4,13 @@ Ant Design `Input`을 기반으로 하되, 타이핑 중에는 내부 상태만 변경하고 `Enter` 또는 `blur` 시점에만 외부 `onChange`를 호출하는 입력 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 폴더 구조 ```text diff --git a/docs/components/popup.md b/docs/components/popup.md index 6838cb1..286cc99 100755 --- a/docs/components/popup.md +++ b/docs/components/popup.md @@ -4,6 +4,13 @@ `[input][button][readonly input]` 형태로 검색어 입력, 버튼 액션, 선택 결과 표시를 한 줄에서 처리하는 입력 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 폴더 구조 ```text diff --git a/docs/components/previewer-ui.md b/docs/components/previewer-ui.md index c42f018..911d81d 100755 --- a/docs/components/previewer-ui.md +++ b/docs/components/previewer-ui.md @@ -4,6 +4,13 @@ 다양한 데이터를 공통 카드 형태로 미리보기할 수 있는 previewer 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 지원 타입 - `text` diff --git a/docs/components/process-flow-ui.md b/docs/components/process-flow-ui.md index 0d92cad..022f748 100755 --- a/docs/components/process-flow-ui.md +++ b/docs/components/process-flow-ui.md @@ -5,6 +5,13 @@ Plan, Board, History 화면에서 공통으로 사용할 수 있는 단계형 진행 표시 컴포넌트입니다. 현재 단계, 완료 단계, 실패 단계, 다음 대기 단계를 한 번에 보여주며 가로/세로 배치와 compact 모드를 지원합니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 폴더 구조 ```text diff --git a/docs/components/search-command.md b/docs/components/search-command.md index f8731fa..802c44c 100755 --- a/docs/components/search-command.md +++ b/docs/components/search-command.md @@ -4,6 +4,13 @@ 문서, API, 컴포넌트, 위젯을 키워드로 빠르게 찾고 바로 이동할 수 있는 통합 검색 모달입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트와 위젯에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트와 위젯은 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트와 위젯은 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 특징 - `AutoComplete` 기반 추천 드롭다운 diff --git a/docs/components/select.md b/docs/components/select.md index 632e18a..419d2cc 100755 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -4,6 +4,13 @@ `code/value` 데이터를 받아 실제 값은 `code`로 유지하고, 드롭다운 표시와 검색은 `value` 기준으로 처리하는 select combo 입력 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 폴더 구조 ```text diff --git a/docs/components/status-badge.md b/docs/components/status-badge.md index b718764..42617aa 100755 --- a/docs/components/status-badge.md +++ b/docs/components/status-badge.md @@ -4,6 +4,13 @@ 상태 값을 간단한 UI 배지로 표현하는 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 폴더 구조 ```text diff --git a/docs/components/stepper.md b/docs/components/stepper.md index 8b9adf1..1f44621 100755 --- a/docs/components/stepper.md +++ b/docs/components/stepper.md @@ -4,6 +4,13 @@ 여러 단계를 순서대로 표시하고 현재 진행 위치를 강조하는 stepper 컴포넌트입니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 구현 위치 ```text diff --git a/docs/components/window-ui.md b/docs/components/window-ui.md index c308890..04003b7 100755 --- a/docs/components/window-ui.md +++ b/docs/components/window-ui.md @@ -4,6 +4,13 @@ 부모 영역 안에서 이동 가능한 모달 스타일 윈도우를 제공합니다. +## 공통 설계 원칙 + +- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다. +- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다. +- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다. +- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다. + ## 특징 - 헤더 작업줄 드래그 이동 diff --git a/docs/features/work-request-board.md b/docs/features/work-request-board.md new file mode 100644 index 0000000..ee617a3 --- /dev/null +++ b/docs/features/work-request-board.md @@ -0,0 +1,71 @@ +# 작업 요청 게시판 + +## 목적 + +작업 요청 게시판은 게시글 1건 안에 여러 하위 요청을 묶어 등록하고, 각 하위 요청이 자동화 작업으로 어떻게 접수되고 완료되는지 추적하는 화면입니다. + +## 핵심 구조 + +- 게시글 1건은 공통 제목, 공통 메모, 공통 첨부 파일을 가집니다. +- 게시글 안에는 하위 요청을 1건 이상 둘 수 있습니다. +- 각 하위 요청은 별도 Plan 항목으로 등록될 수 있고, 상태도 개별로 추적됩니다. +- 공통 메모와 첨부 파일 경로는 하위 요청별 자동화 메모에 함께 포함됩니다. + +## 데이터 모델 + +- 게시글 테이블: `board_posts` +- 하위 요청 테이블: `board_post_requests` +- 주요 서버 구현: + - 라우트: `etc/servers/work-server/src/routes/board.ts` + - 서비스: `etc/servers/work-server/src/services/board-service.ts` +- 주요 프런트 구현: + - 화면: `src/features/board/BoardPage.tsx` + - API 클라이언트: `src/features/board/api.ts` + - 타입: `src/features/board/types.ts` + +## 실행 방식 + +하위 요청 등록 방식은 게시글 단위로 선택합니다. + +- `all_at_once`: 접수 가능한 하위 요청을 한 번에 모두 Plan으로 등록합니다. +- `after_previous_finished`: 앞 요청이 성공/실패와 무관하게 종료되면 다음 요청을 등록합니다. +- `after_previous_success`: 앞 요청이 성공으로 완료된 경우에만 다음 요청을 등록합니다. 실패하면 뒤 요청은 `blocked` 상태로 남습니다. + +## 상태 추적 + +각 하위 요청은 아래 정보를 기준으로 상태를 계산합니다. + +- 게시판 워크플로 상태: `pending`, `waiting`, `registered`, `completed`, `failed`, `blocked` +- 연결된 Plan 상태: `status`, `workerStatus`, `lastError` + +화면에서는 이를 바탕으로 다음처럼 보여줍니다. + +- `미접수`: 아직 Plan 등록 전 +- `선행 대기`: 순차 실행에서 앞 요청 완료를 기다리는 상태 +- `대기열`: Plan 등록은 됐지만 아직 본격 처리 전 +- `진행중`: worker가 작업 중 +- `완료`: Plan 완료 반영 +- `실패`: worker 실패 또는 오류 기록 존재 +- `차단`: 성공 의존 순차 모드에서 앞 요청 실패로 후속 요청 중단 + +## 화면 동작 + +- 목록 화면에서 게시글별로 `완료 x/y`, 실패 수, 진행 수를 요약해 보여줍니다. +- 상세 화면에서 하위 요청을 추가, 삭제, 순서 변경할 수 있습니다. +- 자동화 접수 후에는 게시글과 하위 요청 편집이 잠기고 읽기 전용으로 전환됩니다. +- 하위 요청별로 연결된 Plan 링크를 바로 열 수 있습니다. +- 여러 게시글을 선택해 일괄 접수할 수 있지만, 실제 순차 흐름은 각 게시글의 실행 옵션을 따릅니다. + +## 자동화 연동 + +- 접수 시 `receiveBoardPostAutomation()`이 하위 요청별 Plan을 생성합니다. +- Plan worker가 완료/실패를 기록하면 `progressBoardPostAutomationByPlanResult()`가 다음 하위 요청 등록 여부를 결정합니다. +- 레거시 호환용 `board_posts.automation_plan_item_id`, `automation_received_at`도 첫 접수 요청 기준으로 함께 동기화합니다. + +## 검증 포인트 + +- 새 게시글 저장 시 하위 요청이 1건 이상 생성되는지 확인 +- 실행 옵션별로 다음 요청 등록 시점이 의도대로 달라지는지 확인 +- 앞 요청 실패 시 `after_previous_success` 모드에서 후속 요청이 `차단`으로 남는지 확인 +- 자동화 접수 후 편집/삭제가 막히는지 확인 +- 하위 요청별 Plan 링크가 올바른 항목으로 연결되는지 확인 diff --git a/etc/servers/work-server/docker-compose.yml b/etc/servers/work-server/docker-compose.yml index 4ff9837..ec13377 100644 --- a/etc/servers/work-server/docker-compose.yml +++ b/etc/servers/work-server/docker-compose.yml @@ -42,6 +42,7 @@ services: APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul} DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul} NPM_CONFIG_CACHE: /home/how2ice/.npm + WORK_SERVER_DIST_DIR: /tmp/work-server-dist SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock} DOCKER_HOST: ${DOCKER_HOST:-} networks: diff --git a/etc/servers/work-server/package.json b/etc/servers/work-server/package.json index 9225408..78ea79c 100644 --- a/etc/servers/work-server/package.json +++ b/etc/servers/work-server/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "npm run build && npm run start", - "build": "tsc -p tsconfig.json && node ./scripts/write-build-info.mjs", - "start": "node dist/server.js", + "build": "sh -c 'tsc -p tsconfig.json --outDir \"${WORK_SERVER_DIST_DIR:-dist}\" && node ./scripts/write-build-info.mjs'", + "start": "sh -c 'node \"${WORK_SERVER_DIST_DIR:-dist}/server.js\"'", "backfill:chat-request-links": "node --import tsx ./scripts/backfill-chat-request-links.ts", "backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts", "test": "node --import tsx --test src/**/*.test.ts" diff --git a/etc/servers/work-server/scripts/container-supervisor.sh b/etc/servers/work-server/scripts/container-supervisor.sh index c4cddfd..9506cb2 100755 --- a/etc/servers/work-server/scripts/container-supervisor.sh +++ b/etc/servers/work-server/scripts/container-supervisor.sh @@ -45,7 +45,7 @@ prepare_runtime() { start_child() { log "starting server process" - node dist/server.js & + npm run start & CHILD_PID=$! } diff --git a/etc/servers/work-server/scripts/write-build-info.mjs b/etc/servers/work-server/scripts/write-build-info.mjs index 8926af1..149335f 100755 --- a/etc/servers/work-server/scripts/write-build-info.mjs +++ b/etc/servers/work-server/scripts/write-build-info.mjs @@ -3,8 +3,11 @@ import path from 'node:path'; const projectRoot = process.cwd(); const packageJsonPath = path.join(projectRoot, 'package.json'); -const distDirectoryPath = path.join(projectRoot, 'dist'); +const configuredDistPath = process.env.WORK_SERVER_DIST_DIR?.trim() || 'dist'; +const distDirectoryPath = path.resolve(projectRoot, configuredDistPath); const buildInfoPath = path.join(distDirectoryPath, 'build-info.json'); +const distNodeModulesPath = path.join(distDirectoryPath, 'node_modules'); +const projectNodeModulesPath = path.join(projectRoot, 'node_modules'); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); const builtAt = new Date().toISOString(); @@ -18,4 +21,23 @@ const buildInfo = { await fs.mkdir(distDirectoryPath, { recursive: true }); await fs.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2)); +try { + const existingNodeModulesLink = await fs.lstat(distNodeModulesPath); + if (existingNodeModulesLink.isSymbolicLink()) { + await fs.unlink(distNodeModulesPath); + } +} catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } +} + +try { + await fs.symlink(projectNodeModulesPath, distNodeModulesPath, 'dir'); +} catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } +} + console.log(`work-server build info written to ${buildInfoPath}`); diff --git a/etc/servers/work-server/src/app.ts b/etc/servers/work-server/src/app.ts index 8832f15..8e1cb48 100755 --- a/etc/servers/work-server/src/app.ts +++ b/etc/servers/work-server/src/app.ts @@ -10,6 +10,7 @@ import { registerAppConfigRoutes } from './routes/app-config.js'; import { registerChatRoutes } from './routes/chat.js'; import { registerNotificationRoutes } from './routes/notification.js'; import { registerPlanRoutes } from './routes/plan.js'; +import { registerResourceManagerRoutes } from './routes/resource-manager.js'; import { registerServerCommandRoutes } from './routes/server-command.js'; import { registerSchemaRoutes } from './routes/schema.js'; import { registerStockAlertRoutes } from './routes/stock-alert.js'; @@ -39,6 +40,7 @@ export function createApp() { app.register(registerErrorLogRoutes); app.register(registerNotificationRoutes); app.register(registerPlanRoutes); + app.register(registerResourceManagerRoutes); app.register(registerServerCommandRoutes); app.register(registerTextMemoRoutes); app.register(registerVisitorHistoryRoutes); diff --git a/etc/servers/work-server/src/config/env.js b/etc/servers/work-server/src/config/env.js new file mode 100644 index 0000000..af38a39 --- /dev/null +++ b/etc/servers/work-server/src/config/env.js @@ -0,0 +1,112 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.env = void 0; +exports.getEnv = getEnv; +var node_path_1 = require("node:path"); +var dotenv_1 = require("dotenv"); +var zod_1 = require("zod"); +dotenv_1.default.config({ override: true, quiet: true }); +var envSchema = zod_1.z.object({ + PORT: zod_1.z.coerce.number().default(3100), + APP_TIME_ZONE: zod_1.z.string().default('Asia/Seoul'), + DB_TIME_ZONE: zod_1.z.string().default('Asia/Seoul'), + DB_CLIENT: zod_1.z.string().default('pg'), + DB_HOST: zod_1.z.string().default('localhost'), + DB_PORT: zod_1.z.coerce.number().default(5432), + DB_NAME: zod_1.z.string().default('work_db'), + DB_USER: zod_1.z.string().default('work_user'), + DB_PASSWORD: zod_1.z.string().default('change-me'), + DB_SSL: zod_1.z + .string() + .default('false') + .transform(function (value) { return value === 'true'; }), + PLAN_WORKER_ENABLED: zod_1.z + .string() + .default('true') + .transform(function (value) { return value === 'true'; }), + PLAN_WORKER_INTERVAL_MS: zod_1.z.coerce.number().default(10000), + PLAN_WORKER_ID: zod_1.z.string().optional(), + PLAN_GIT_REPO_PATH: zod_1.z.string().default('/workspace/repo'), + PLAN_MAIN_PROJECT_REPO_PATH: zod_1.z.string().optional(), + PLAN_RELEASE_BRANCH: zod_1.z.string().default('release'), + PLAN_MAIN_BRANCH: zod_1.z.string().default('main'), + PLAN_GIT_USER_NAME: zod_1.z.string().default('how2ice'), + PLAN_GIT_USER_EMAIL: zod_1.z.string().default('how2ice@naver.com'), + PLAN_CODEX_RUNNER_PATH: zod_1.z.string().default('/workspace/repo-scripts/run-plan-codex-once.mjs'), + PLAN_CODEX_ENABLED: zod_1.z + .string() + .default('true') + .transform(function (value) { return value === 'true'; }), + PLAN_LOCAL_MAIN_MODE: zod_1.z + .string() + .default('true') + .transform(function (value) { return value === 'true'; }), + PLAN_CODEX_BIN: zod_1.z.string().default('codex'), + PLAN_CODEX_TEMPLATE_HOME: zod_1.z.string().optional(), + PLAN_PREVIEW_BASE_URL: zod_1.z.string().optional(), + PLAN_PREVIEW_URL_TEMPLATE: zod_1.z.string().optional(), + IOS_NOTIFICATION_ENABLED: zod_1.z + .string() + .default('false') + .transform(function (value) { return value === 'true'; }), + WEB_PUSH_ENABLED: zod_1.z + .string() + .default('false') + .transform(function (value) { return value === 'true'; }), + WEB_PUSH_VAPID_PUBLIC_KEY: zod_1.z.string().optional(), + WEB_PUSH_VAPID_PRIVATE_KEY: zod_1.z.string().optional(), + WEB_PUSH_SUBJECT: zod_1.z.string().default('mailto:how2ice@naver.com'), + APNS_KEY_ID: zod_1.z.string().optional(), + APNS_TEAM_ID: zod_1.z.string().optional(), + APNS_BUNDLE_ID: zod_1.z.string().optional(), + APNS_PRIVATE_KEY: zod_1.z.string().optional(), + APNS_PRIVATE_KEY_PATH: zod_1.z.string().optional(), + APNS_PRODUCTION: zod_1.z + .string() + .default('false') + .transform(function (value) { return value === 'true'; }), + SERVER_COMMAND_ACCESS_TOKEN: zod_1.z.string().default('usr_7f3a9c2d8e1b4a6f'), + SERVER_COMMAND_API_BASE_URL: zod_1.z.string().optional(), + SERVER_COMMAND_API_ACCESS_TOKEN: zod_1.z.string().optional(), + SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: zod_1.z.string().default('/api/server-commands/{key}/actions/restart'), + SERVER_COMMAND_PROJECT_ROOT: zod_1.z.string().default(node_path_1.default.resolve(process.cwd(), '../../..')), + SERVER_COMMAND_MAIN_PROJECT_ROOT: zod_1.z.string().default('/workspace/main-project'), + SERVER_COMMAND_TEST_URL: zod_1.z.string().default('https://test.sm-home.cloud/'), + SERVER_COMMAND_REL_URL: zod_1.z.string().default('https://rel.sm-home.cloud/'), + SERVER_COMMAND_PROD_URL: zod_1.z.string().default('https://sm-home.cloud/'), + SERVER_COMMAND_WORK_SERVER_URL: zod_1.z.string().default('http://127.0.0.1:3100/health'), + SERVER_COMMAND_RUNNER_URL: zod_1.z.string().default('http://host.docker.internal:3211/health'), + SERVER_COMMAND_RUNNER_ACCESS_TOKEN: zod_1.z.string().default('local-server-command-runner'), + SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: zod_1.z.string().optional(), + SERVER_COMMAND_TEST_SERVICE: zod_1.z.string().default('app'), + SERVER_COMMAND_REL_SERVICE: zod_1.z.string().default('release-app'), + SERVER_COMMAND_PROD_SERVICE: zod_1.z.string().default('prod-app'), + SERVER_COMMAND_WORK_SERVER_SERVICE: zod_1.z.string().default('work-server'), + WORK_SERVER_DIST_DIR: zod_1.z.string().default('dist'), +}); +function parseEnv() { + var _a; + dotenv_1.default.config({ override: true, quiet: true }); + var parsedEnv = envSchema.parse(process.env); + return __assign(__assign({}, parsedEnv), { PLAN_MAIN_PROJECT_REPO_PATH: (_a = parsedEnv.PLAN_MAIN_PROJECT_REPO_PATH) !== null && _a !== void 0 ? _a : parsedEnv.PLAN_GIT_REPO_PATH }); +} +function getEnv() { + var _a; + var parsedEnv = parseEnv(); + if (!((_a = process.env.TZ) === null || _a === void 0 ? void 0 : _a.trim())) { + process.env.TZ = parsedEnv.APP_TIME_ZONE; + } + return parsedEnv; +} +exports.env = getEnv(); diff --git a/etc/servers/work-server/src/config/env.ts b/etc/servers/work-server/src/config/env.ts index bead190..4203e02 100644 --- a/etc/servers/work-server/src/config/env.ts +++ b/etc/servers/work-server/src/config/env.ts @@ -80,6 +80,7 @@ const envSchema = z.object({ SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'), SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'), SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'), + WORK_SERVER_DIST_DIR: z.string().default('dist'), }); function parseEnv() { diff --git a/etc/servers/work-server/src/db/client.js b/etc/servers/work-server/src/db/client.js new file mode 100644 index 0000000..df0a3d6 --- /dev/null +++ b/etc/servers/work-server/src/db/client.js @@ -0,0 +1,37 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.db = void 0; +var knex_1 = require("knex"); +var env_js_1 = require("../config/env.js"); +exports.db = (0, knex_1.default)({ + client: env_js_1.env.DB_CLIENT, + connection: { + host: env_js_1.env.DB_HOST, + port: env_js_1.env.DB_PORT, + database: env_js_1.env.DB_NAME, + user: env_js_1.env.DB_USER, + password: env_js_1.env.DB_PASSWORD, + ssl: env_js_1.env.DB_SSL ? { rejectUnauthorized: false } : false, + }, + pool: { + min: 0, + max: 10, + afterCreate: function (connection, done) { + var _a; + var clientName = String((_a = env_js_1.env.DB_CLIENT) !== null && _a !== void 0 ? _a : '').toLowerCase(); + if (clientName === 'pg' || clientName === 'postgres' || clientName === 'postgresql') { + connection.query("SET TIME ZONE '".concat(env_js_1.env.DB_TIME_ZONE, "'"), function (error) { + done(error, connection); + }); + return; + } + if (clientName === 'mysql' || clientName === 'mysql2') { + connection.query('SET time_zone = "+09:00"', function (error) { + done(error, connection); + }); + return; + } + done(null, connection); + }, + }, +}); diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts index bca22b2..cf6e7ba 100755 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -2,9 +2,11 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { getAppConfig, + getChatContextSettingsConfig, getChatTypesConfig, normalizeAppConfigSnapshot, upsertAppConfig, + upsertChatContextSettingsConfig, upsertChatTypesConfig, } from '../services/app-config-service.js'; import { @@ -13,9 +15,44 @@ import { } from '../services/automation-context-config-service.js'; import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js'; +function getRequestAppOrigin(request: { headers: Record }) { + const rawAppOrigin = request.headers['x-app-origin']; + const appOrigin = Array.isArray(rawAppOrigin) ? rawAppOrigin[0] : rawAppOrigin; + + if (appOrigin?.trim()) { + return appOrigin.trim(); + } + + const rawOrigin = request.headers.origin; + const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin; + return origin?.trim() ?? ''; +} + +function getRequestAppDomain(request: { headers: Record }) { + const rawAppDomain = request.headers['x-app-domain']; + const appDomain = Array.isArray(rawAppDomain) ? rawAppDomain[0] : rawAppDomain; + + if (appDomain?.trim()) { + return appDomain.trim(); + } + + const appOrigin = getRequestAppOrigin(request); + + if (!appOrigin) { + return ''; + } + + try { + return new URL(appOrigin).hostname; + } catch { + return ''; + } +} + export async function registerAppConfigRoutes(app: FastifyInstance) { - app.get('/api/app-config', async () => { - const config = await getAppConfig(); + app.get('/api/app-config', async (request) => { + const appOrigin = getRequestAppOrigin(request); + const config = await getAppConfig(appOrigin); return { ok: true, @@ -23,8 +60,8 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { }; }); - app.get('/api/chat-types', async () => { - const chatTypes = await getChatTypesConfig(); + app.get('/api/chat-types', async (request) => { + const chatTypes = await getChatTypesConfig(getRequestAppOrigin(request)); return { ok: true, @@ -32,6 +69,15 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { }; }); + app.get('/api/chat-context-settings', async (request) => { + const settings = await getChatContextSettingsConfig(getRequestAppOrigin(request)); + + return { + ok: true, + settings, + }; + }); + app.get('/api/automation-types', async () => { const automationTypes = await getAutomationTypesConfig(); @@ -66,7 +112,9 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { chatTypes: z.array(z.unknown()), }).parse(payload ?? {}); - const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes); + const appOrigin = getRequestAppOrigin(request); + const appDomain = getRequestAppDomain(request); + const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes, appOrigin, appDomain); return { ok: true, @@ -79,6 +127,37 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { } }); + app.put('/api/chat-context-settings', async (request, reply) => { + try { + let payload: unknown = request.body ?? {}; + + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + payload = {}; + } + } + + const parsed = z.object({ + settings: z.unknown(), + }).parse(payload ?? {}); + + const appOrigin = getRequestAppOrigin(request); + const appDomain = getRequestAppDomain(request); + const savedSettings = await upsertChatContextSettingsConfig(parsed.settings, appOrigin, appDomain); + + return { + ok: true, + settings: savedSettings, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '채팅 Context 설정 저장에 실패했습니다.', + }); + } + }); + app.put('/api/automation-types', async (request, reply) => { try { let payload: unknown = request.body ?? {}; @@ -159,7 +238,9 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { throw new Error('설정 값 형식이 올바르지 않습니다.'); } - const savedConfig = await upsertAppConfig(config as Record); + const appOrigin = getRequestAppOrigin(request); + const appDomain = getRequestAppDomain(request); + const savedConfig = await upsertAppConfig(config as Record, appOrigin, appDomain); return { ok: true, diff --git a/etc/servers/work-server/src/routes/board.ts b/etc/servers/work-server/src/routes/board.ts index d21e23e..fba4898 100755 --- a/etc/servers/work-server/src/routes/board.ts +++ b/etc/servers/work-server/src/routes/board.ts @@ -109,7 +109,7 @@ export async function registerBoardRoutes(app: FastifyInstance) { return { ok: true, item: result.item, - planItemId: result.planItemId, + planItemIds: result.planItemIds, alreadyReceived: result.alreadyReceived, }; }); diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 0f9df76..4da6057 100755 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -6,9 +6,10 @@ import type { FastifyInstance, FastifyReply } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; -import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js'; +import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRuntimeController } from '../services/chat-service.js'; import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js'; import { + CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH, createChatConversation, deleteUnansweredChatConversationRequest, deleteChatConversation, @@ -40,10 +41,12 @@ function resolveStaticContentType(filePath: string) { case '.json': case '.css': case '.html': - case '.md': case '.txt': case '.diff': return 'text/plain; charset=utf-8'; + case '.md': + case '.markdown': + return 'text/markdown; charset=utf-8'; case '.svg': return 'image/svg+xml'; case '.png': @@ -190,7 +193,7 @@ export async function registerChatRoutes(app: FastifyInstance) { const viewerClientId = getClientIdHeader(request); const clientId = canViewAllConversations(request) ? null : viewerClientId; - const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null); + const items = await listChatConversations(clientId, query.limit ?? 200, viewerClientId || null); return { ok: true, @@ -239,7 +242,7 @@ export async function registerChatRoutes(app: FastifyInstance) { ); const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/')); - await mkdir(path.dirname(absolutePath), { recursive: true }); + await ensureChatSessionResourceDirectories(resolveChatAttachmentRepoPath(), payload.sessionId); await writeFile(absolutePath, buffer); return { @@ -375,8 +378,9 @@ export async function registerChatRoutes(app: FastifyInstance) { title: z.string().trim().max(200).optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), lastChatTypeId: z.string().trim().max(120).nullable().optional(), + generalSectionName: z.string().trim().max(120).optional().nullable(), contextLabel: z.string().trim().max(200).optional(), - contextDescription: z.string().trim().max(2000).optional(), + contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional(), notifyOffline: z.boolean().optional(), }).parse(request.body ?? {}); @@ -387,6 +391,7 @@ export async function registerChatRoutes(app: FastifyInstance) { title: payload.title ?? '새 대화', chatTypeId: payload.chatTypeId ?? null, lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null, + generalSectionName: payload.generalSectionName ?? null, contextLabel: payload.contextLabel ?? null, contextDescription: payload.contextDescription ?? null, notifyOffline: payload.notifyOffline ?? true, @@ -502,8 +507,9 @@ export async function registerChatRoutes(app: FastifyInstance) { title: z.string().trim().min(1).max(200).optional(), chatTypeId: z.string().trim().max(120).optional().nullable(), lastChatTypeId: z.string().trim().max(120).optional().nullable(), + generalSectionName: z.string().trim().max(120).optional().nullable(), contextLabel: z.string().trim().max(200).optional().nullable(), - contextDescription: z.string().trim().max(2000).optional().nullable(), + contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional().nullable(), notifyOffline: z.boolean().optional(), }).parse(request.body ?? {}); @@ -521,6 +527,7 @@ export async function registerChatRoutes(app: FastifyInstance) { clientId: current.clientId, chatTypeId: payload.chatTypeId ?? current.chatTypeId, lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId, + generalSectionName: payload.generalSectionName ?? current.generalSectionName, contextLabel: payload.contextLabel ?? current.contextLabel, contextDescription: payload.contextDescription ?? current.contextDescription, notifyOffline: payload.notifyOffline ?? current.notifyOffline, diff --git a/etc/servers/work-server/src/routes/plan.js b/etc/servers/work-server/src/routes/plan.js new file mode 100644 index 0000000..32e63bb --- /dev/null +++ b/etc/servers/work-server/src/routes/plan.js @@ -0,0 +1,1256 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.registerPlanRoutes = registerPlanRoutes; +var zod_1 = require("zod"); +var error_log_service_js_1 = require("../services/error-log-service.js"); +var plan_notification_service_js_1 = require("../services/plan-notification-service.js"); +var plan_notification_policy_js_1 = require("../services/plan-notification-policy.js"); +var plan_service_js_1 = require("../services/plan-service.js"); +var client_js_1 = require("../db/client.js"); +var env_js_1 = require("../config/env.js"); +var git_service_js_1 = require("../services/git-service.js"); +var error_log_plan_registration_service_js_1 = require("../services/error-log-plan-registration-service.js"); +var plan_schedule_service_js_1 = require("../services/plan-schedule-service.js"); +var visitor_history_service_js_1 = require("../services/visitor-history-service.js"); +var board_service_js_1 = require("../services/board-service.js"); +var completeActionSchema = zod_1.z.object({ + note: zod_1.z.string().trim().min(1).optional(), +}); +var actionNoteSchema = zod_1.z.object({ + actionNote: zod_1.z.string().trim().min(1), + actionType: zod_1.z.string().trim().min(1).optional(), +}); +var createSourceWorkSchema = zod_1.z.object({ + summary: zod_1.z.string().trim().min(1), + branchName: zod_1.z.string().trim().min(1), + commitHash: zod_1.z.string().trim().min(1).nullable().optional(), + previewUrl: zod_1.z.string().trim().url().nullable().optional(), + changedFiles: zod_1.z.array(zod_1.z.string()).default([]), + commandLog: zod_1.z.string().nullable().optional(), + diffText: zod_1.z.string().nullable().optional(), + sourceFiles: zod_1.z + .array(zod_1.z.object({ + path: zod_1.z.string().trim().min(1), + previousPath: zod_1.z.string().trim().min(1).nullable().optional(), + status: zod_1.z.enum(['added', 'modified', 'deleted', 'renamed', 'binary', 'unknown']), + language: zod_1.z.string().trim().min(1), + content: zod_1.z.string(), + })) + .default([]), +}); +function registerPlanRoutes(app) { + return __awaiter(this, void 0, void 0, function () { + function getRequestTraceContext(request) { + 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) { + var normalizedValue = String(value !== null && value !== void 0 ? value : '').trim(); + return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1'; + } + function hasPlanAccessToken(accessToken) { + return (0, error_log_service_js_1.hasErrorLogViewAccessToken)(accessToken); + } + function hasPlanAccess(request) { + var _a, _b, _c; + if (hasPlanAccessToken(request.headers['x-access-token'])) { + return true; + } + return isLoopbackAddress(request.ip) || isLoopbackAddress((_b = (_a = request.raw) === null || _a === void 0 ? void 0 : _a.socket) === null || _b === void 0 ? void 0 : _b.remoteAddress) || isLoopbackAddress((_c = request.socket) === null || _c === void 0 ? void 0 : _c.remoteAddress); + } + function requirePlanAccessToken(request, reply) { + if (hasPlanAccess(request)) { + return true; + } + void reply.code(403).send({ + message: '권한 토큰이 등록된 사용자만 수정할 수 있습니다.', + }); + return false; + } + function handleListPlanScheduledTasks(request, reply) { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!hasPlanAccess(request)) { + return [2 /*return*/, reply.code(403).send({ + message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.', + })]; + } + return [4 /*yield*/, (0, plan_schedule_service_js_1.listPlanScheduledTasks)()]; + case 1: + rows = _a.sent(); + return [2 /*return*/, { + items: rows.map(plan_schedule_service_js_1.mapPlanScheduledTaskRow), + }]; + } + }); + }); + } + function handleCreatePlanScheduledTask(request, reply) { + return __awaiter(this, void 0, void 0, function () { + var payload, row, ignoreScheduleDueForImmediateRegistration, immediateRegistration, _a, _b, latestRow, _c; + var _d; + var _e, _f; + return __generator(this, function (_g) { + switch (_g.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + payload = plan_schedule_service_js_1.createPlanScheduledTaskSchema.parse((_e = request.body) !== null && _e !== void 0 ? _e : {}); + return [4 /*yield*/, (0, plan_schedule_service_js_1.createPlanScheduledTask)(payload)]; + case 1: + row = _g.sent(); + ignoreScheduleDueForImmediateRegistration = payload.repeatWindows.length === 0 + && payload.scheduleWeekdays.length === 0 + && payload.scheduleDateRanges.length === 0; + if (!(payload.executionMode === 'managed-service' && payload.recreateManagedServiceOnNextSave)) return [3 /*break*/, 3]; + return [4 /*yield*/, (0, plan_schedule_service_js_1.registerPlanScheduledTaskNow)(Number(row.id), new Date(), { + forceManagedServiceGeneration: true, + })]; + case 2: + _a = _g.sent(); + return [3 /*break*/, 7]; + case 3: + if (!(payload.enabled && payload.immediateRunEnabled)) return [3 /*break*/, 5]; + return [4 /*yield*/, (0, plan_schedule_service_js_1.registerPlanScheduledTaskNow)(Number(row.id), new Date(), { + ignoreScheduleDue: ignoreScheduleDueForImmediateRegistration, + })]; + case 4: + _b = _g.sent(); + return [3 /*break*/, 6]; + case 5: + _b = null; + _g.label = 6; + case 6: + _a = _b; + _g.label = 7; + case 7: + immediateRegistration = _a; + return [4 /*yield*/, (0, plan_schedule_service_js_1.getPlanScheduledTaskById)(Number(row.id))]; + case 8: + latestRow = _g.sent(); + _d = { + ok: true, + item: (0, plan_schedule_service_js_1.mapPlanScheduledTaskRow)(latestRow !== null && latestRow !== void 0 ? latestRow : row) + }; + if (!(immediateRegistration === null || immediateRegistration === void 0 ? void 0 : immediateRegistration.createdPlan)) return [3 /*break*/, 10]; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(Number(immediateRegistration.createdPlan.id))]; + case 9: + _c = _g.sent(); + return [3 /*break*/, 11]; + case 10: + _c = null; + _g.label = 11; + case 11: return [2 /*return*/, (_d.registeredPlan = _c, + _d.registeredBoardPosts = (_f = immediateRegistration === null || immediateRegistration === void 0 ? void 0 : immediateRegistration.createdBoardPosts) !== null && _f !== void 0 ? _f : [], + _d)]; + } + }); + }); + } + function handleGetPlanScheduledTask(request, reply) { + return __awaiter(this, void 0, void 0, function () { + var id, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!hasPlanAccess(request)) { + return [2 /*return*/, reply.code(403).send({ + message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.', + })]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_schedule_service_js_1.getPlanScheduledTaskById)(id)]; + case 1: + row = _a.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '스케줄을 찾을 수 없습니다.', + })]; + } + return [2 /*return*/, { + item: (0, plan_schedule_service_js_1.mapPlanScheduledTaskRow)(row), + }]; + } + }); + }); + } + function handleUpdatePlanScheduledTask(request, reply) { + return __awaiter(this, void 0, void 0, function () { + var id, payload, row, shouldTriggerImmediateRegistration, effectiveRepeatWindows, effectiveScheduleDateRanges, effectiveScheduleWeekdays, immediateRegistration, _a, latestRow, _b; + var _c; + var _d, _e, _f, _g, _h; + return __generator(this, function (_j) { + switch (_j.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + payload = plan_schedule_service_js_1.updatePlanScheduledTaskSchema.parse((_d = request.body) !== null && _d !== void 0 ? _d : {}); + return [4 /*yield*/, (0, plan_schedule_service_js_1.updatePlanScheduledTask)(id, payload)]; + case 1: + row = _j.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '수정할 스케줄을 찾을 수 없습니다.', + })]; + } + shouldTriggerImmediateRegistration = (row + && String((_e = row.execution_mode) !== null && _e !== void 0 ? _e : '') === 'managed-service' + && payload.recreateManagedServiceOnNextSave === true) || (row + && Boolean((_f = row.enabled) !== null && _f !== void 0 ? _f : true) + && Boolean((_g = row.immediate_run_enabled) !== null && _g !== void 0 ? _g : true) + && payload.enabled !== false); + effectiveRepeatWindows = payload.repeatWindows !== undefined + ? payload.repeatWindows + : (row ? (0, plan_schedule_service_js_1.mapPlanScheduledTaskRow)(row).repeatWindows : []); + effectiveScheduleDateRanges = payload.scheduleDateRanges !== undefined + ? payload.scheduleDateRanges + : (row ? (0, plan_schedule_service_js_1.mapPlanScheduledTaskRow)(row).scheduleDateRanges : []); + effectiveScheduleWeekdays = payload.scheduleWeekdays !== undefined + ? payload.scheduleWeekdays + : (row ? (0, plan_schedule_service_js_1.mapPlanScheduledTaskRow)(row).scheduleWeekdays : []); + if (!shouldTriggerImmediateRegistration) return [3 /*break*/, 3]; + return [4 /*yield*/, (0, plan_schedule_service_js_1.registerPlanScheduledTaskNow)(id, new Date(), { + ignoreScheduleDue: payload.recreateManagedServiceOnNextSave === true + ? false + : effectiveRepeatWindows.length === 0 + && effectiveScheduleWeekdays.length === 0 + && effectiveScheduleDateRanges.length === 0, + forceManagedServiceGeneration: payload.recreateManagedServiceOnNextSave === true, + })]; + case 2: + _a = _j.sent(); + return [3 /*break*/, 4]; + case 3: + _a = null; + _j.label = 4; + case 4: + immediateRegistration = _a; + return [4 /*yield*/, (0, plan_schedule_service_js_1.getPlanScheduledTaskById)(Number(id))]; + case 5: + latestRow = _j.sent(); + _c = { + ok: true, + item: (0, plan_schedule_service_js_1.mapPlanScheduledTaskRow)(latestRow !== null && latestRow !== void 0 ? latestRow : row) + }; + if (!(immediateRegistration === null || immediateRegistration === void 0 ? void 0 : immediateRegistration.createdPlan)) return [3 /*break*/, 7]; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(Number(immediateRegistration.createdPlan.id))]; + case 6: + _b = _j.sent(); + return [3 /*break*/, 8]; + case 7: + _b = null; + _j.label = 8; + case 8: return [2 /*return*/, (_c.registeredPlan = _b, + _c.registeredBoardPosts = (_h = immediateRegistration === null || immediateRegistration === void 0 ? void 0 : immediateRegistration.createdBoardPosts) !== null && _h !== void 0 ? _h : [], + _c)]; + } + }); + }); + } + function handleDeletePlanScheduledTask(request, reply) { + return __awaiter(this, void 0, void 0, function () { + var id, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_schedule_service_js_1.deletePlanScheduledTask)(id)]; + case 1: + row = _a.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '삭제할 스케줄을 찾을 수 없습니다.', + })]; + } + return [2 /*return*/, { + ok: true, + id: id, + }]; + } + }); + }); + } + var _this = this; + return __generator(this, function (_a) { + app.post('/api/plan/registrations/error-logs', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var payload, result; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + payload = zod_1.z.object({ + rangeStart: zod_1.z.coerce.date().optional(), + rangeEnd: zod_1.z.coerce.date().optional(), + maxGroups: zod_1.z.coerce.number().int().min(1).max(24).optional(), + }).parse((_a = request.body) !== null && _a !== void 0 ? _a : {}); + return [4 /*yield*/, (0, error_log_plan_registration_service_js_1.registerErrorLogBoardPosts)(payload)]; + case 1: + result = _b.sent(); + return [2 /*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, + }]; + } + }); + }); }); + app.get('/api/plan/statuses', function () { return __awaiter(_this, void 0, void 0, function () { + return __generator(this, function (_a) { + return [2 /*return*/, ({ + items: plan_service_js_1.planStatuses, + })]; + }); + }); }); + app.post('/api/plan/setup', function (request) { return __awaiter(_this, void 0, void 0, function () { + var payload; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + payload = plan_service_js_1.setupSchema.parse((_a = request.body) !== null && _a !== void 0 ? _a : {}); + if (!payload.recreate) return [3 /*break*/, 6]; + return [4 /*yield*/, client_js_1.db.schema.dropTableIfExists(plan_schedule_service_js_1.PLAN_SCHEDULED_TASK_TABLE)]; + case 1: + _b.sent(); + return [4 /*yield*/, client_js_1.db.schema.dropTableIfExists(plan_service_js_1.PLAN_ACTION_TABLE)]; + case 2: + _b.sent(); + return [4 /*yield*/, client_js_1.db.schema.dropTableIfExists(plan_service_js_1.PLAN_ISSUE_TABLE)]; + case 3: + _b.sent(); + return [4 /*yield*/, client_js_1.db.schema.dropTableIfExists(plan_service_js_1.PLAN_SOURCE_WORK_TABLE)]; + case 4: + _b.sent(); + return [4 /*yield*/, client_js_1.db.schema.dropTableIfExists(plan_service_js_1.PLAN_TABLE)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: return [4 /*yield*/, (0, plan_service_js_1.ensurePlanTable)()]; + case 7: + _b.sent(); + return [4 /*yield*/, (0, plan_schedule_service_js_1.ensurePlanScheduledTaskTable)()]; + case 8: + _b.sent(); + return [2 /*return*/, { + ok: true, + table: plan_service_js_1.PLAN_TABLE, + scheduleTable: plan_schedule_service_js_1.PLAN_SCHEDULED_TASK_TABLE, + releaseReviewTable: plan_service_js_1.PLAN_RELEASE_REVIEW_TABLE, + statuses: plan_service_js_1.planStatuses, + }]; + } + }); + }); }); + app.get('/api/plan/release-reviews', function (request) { return __awaiter(_this, void 0, void 0, function () { + var items; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, plan_service_js_1.listPlanReleaseReviewBoardItems)({ + maskNote: !hasPlanAccess(request), + })]; + case 1: + items = _a.sent(); + return [2 /*return*/, { + items: items, + }]; + } + }); + }); }); + app.patch('/api/plan/release-reviews/:planItemId', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var planItemId, payload, clientId, visitor, _a, review; + var _b, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + planItemId = zod_1.z.coerce.number().int().positive().parse(request.params.planItemId); + payload = plan_service_js_1.updatePlanReleaseReviewSchema.parse((_b = request.body) !== null && _b !== void 0 ? _b : {}); + clientId = String((_c = request.headers['x-client-id']) !== null && _c !== void 0 ? _c : '').trim(); + if (!clientId) return [3 /*break*/, 2]; + return [4 /*yield*/, (0, visitor_history_service_js_1.getVisitorClientByClientId)(clientId)]; + case 1: + _a = _e.sent(); + return [3 /*break*/, 3]; + case 2: + _a = null; + _e.label = 3; + case 3: + visitor = _a; + return [4 /*yield*/, (0, plan_service_js_1.upsertPlanReleaseReview)(planItemId, payload, { + clientId: clientId || null, + nickname: (_d = visitor === null || visitor === void 0 ? void 0 : visitor.nickname) !== null && _d !== void 0 ? _d : null, + })]; + case 4: + review = _e.sent(); + if (!review) { + return [2 /*return*/, reply.code(404).send({ + message: '검수 대상을 찾을 수 없습니다.', + })]; + } + return [2 /*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', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var parsedQuery, query, items; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + parsedQuery = plan_service_js_1.listPlanQuerySchema.safeParse((_a = request.query) !== null && _a !== void 0 ? _a : {}); + if (!parsedQuery.success) { + return [2 /*return*/, reply.code(400).send({ + message: '유효하지 않은 status 쿼리입니다.', + })]; + } + query = parsedQuery.data; + return [4 /*yield*/, (0, plan_service_js_1.listPlanItems)(query.status, { + maskNote: !hasPlanAccess(request), + })]; + case 1: + items = _b.sent(); + return [2 /*return*/, { + items: items, + }]; + } + }); + }); }); + app.get('/api/plan/items/:id', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var hasAccess, id, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + hasAccess = hasPlanAccess(request); + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id, { + maskNote: !hasAccess, + })]; + case 1: + row = _a.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [2 /*return*/, { + item: row, + }]; + } + }); + }); }); + app.post('/api/plan/items', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var payload, createdRow, row, error_1; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + _b.label = 1; + case 1: + _b.trys.push([1, 4, , 5]); + payload = plan_service_js_1.createPlanSchema.parse((_a = request.body) !== null && _a !== void 0 ? _a : {}); + return [4 /*yield*/, (0, plan_service_js_1.createPlanItem)(payload)]; + case 2: + createdRow = _b.sent(); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(Number(createdRow.id))]; + case 3: + row = _b.sent(); + return [2 /*return*/, { + ok: true, + item: row, + }]; + case 4: + error_1 = _b.sent(); + return [2 /*return*/, reply.code(409).send({ + message: error_1 instanceof Error ? error_1.message : '작업 항목 등록에 실패했습니다.', + })]; + case 5: return [2 /*return*/]; + } + }); + }); }); + app.patch('/api/plan/items/:id', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, payload, updatedRow, row, error_2; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + _b.label = 1; + case 1: + _b.trys.push([1, 4, , 5]); + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + payload = plan_service_js_1.updatePlanSchema.parse((_a = request.body) !== null && _a !== void 0 ? _a : {}); + return [4 /*yield*/, (0, plan_service_js_1.updatePlanItem)(id, payload)]; + case 2: + updatedRow = _b.sent(); + if (!updatedRow) { + return [2 /*return*/, reply.code(404).send({ + message: '수정할 작업 항목을 찾을 수 없습니다.', + })]; + } + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 3: + row = _b.sent(); + return [2 /*return*/, { + ok: true, + item: row, + }]; + case 4: + error_2 = _b.sent(); + return [2 /*return*/, reply.code(409).send({ + message: error_2 instanceof Error ? error_2.message : '작업 항목 수정에 실패했습니다.', + })]; + case 5: return [2 /*return*/]; + } + }); + }); }); + app.patch('/api/plan/items/:id/jangsing-processing', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, payload, updatedRow, error_3; + var _a; + var _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + _c.label = 1; + case 1: + _c.trys.push([1, 4, , 5]); + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + payload = plan_service_js_1.updatePlanJangsingProcessingSchema.parse((_b = request.body) !== null && _b !== void 0 ? _b : {}); + return [4 /*yield*/, (0, plan_service_js_1.updatePlanItemJangsingProcessingRequired)(id, payload.jangsingProcessingRequired)]; + case 2: + updatedRow = _c.sent(); + if (!updatedRow) { + return [2 /*return*/, reply.code(404).send({ + message: '수정할 작업 항목을 찾을 수 없습니다.', + })]; + } + _a = { + ok: true + }; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 3: return [2 /*return*/, (_a.item = _c.sent(), + _a)]; + case 4: + error_3 = _c.sent(); + return [2 /*return*/, reply.code(409).send({ + message: error_3 instanceof Error ? error_3.message : '기능동작확인 수정에 실패했습니다.', + })]; + case 5: return [2 /*return*/]; + } + }); + }); }); + app.delete('/api/plan/items/:id', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, linkedBoardPost, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + request.log.warn({ + planItemId: id, + trace: getRequestTraceContext(request), + }, 'Plan item delete requested'); + return [4 /*yield*/, (0, plan_service_js_1.getBoardPostLinkedToPlanItem)(id)]; + case 1: + linkedBoardPost = _a.sent(); + 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 [2 /*return*/, reply.code(409).send({ + message: "\uC790\uB3D9\uD654 \uC811\uC218\uB41C \uD56D\uBAA9\uC740 \uC0AD\uC81C\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC5F0\uACB0 \uAC8C\uC2DC\uAE00 #".concat(linkedBoardPost.id), + })]; + } + return [4 /*yield*/, (0, plan_service_js_1.deletePlanItem)(id)]; + case 2: + row = _a.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '삭제할 작업 항목을 찾을 수 없습니다.', + })]; + } + return [2 /*return*/, { + ok: true, + id: id, + }]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/complete-development', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, row, planLabel; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.markPlanAsDevelopmentComplete)(id)]; + case 1: + row = _b.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [4 /*yield*/, (0, plan_schedule_service_js_1.syncManagedServiceGenerationCompletion)(id)]; + case 2: + _b.sent(); + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(row.work_id), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] release \uBC18\uC601 \uB300\uAE30"), '수동 작업완료로 release 반영 대기 상태가 되었습니다.', 'development-completed')]; + case 3: + _b.sent(); + return [4 /*yield*/, (0, board_service_js_1.progressBoardPostAutomationByPlanResult)(id, 'completed')]; + case 4: + _b.sent(); + _a = { + ok: true + }; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 5: return [2 /*return*/, (_a.item = _b.sent(), + _a)]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/complete', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, payload, row, planLabel; + var _a; + var _b, _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + payload = completeActionSchema.parse((_b = request.body) !== null && _b !== void 0 ? _b : {}); + return [4 /*yield*/, (0, plan_service_js_1.markPlanAsCompleted)(id, payload.note)]; + case 1: + row = _d.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [4 /*yield*/, (0, plan_schedule_service_js_1.syncManagedServiceGenerationCompletion)(id)]; + case 2: + _d.sent(); + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(row.work_id), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC644\uB8CC \uCC98\uB9AC"), (_c = payload.note) !== null && _c !== void 0 ? _c : '작업이 완료 처리되었습니다.', 'plan-completed')]; + case 3: + _d.sent(); + return [4 /*yield*/, (0, board_service_js_1.progressBoardPostAutomationByPlanResult)(id, 'completed')]; + case 4: + _d.sent(); + _a = { + ok: true + }; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 5: return [2 /*return*/, (_a.item = _d.sent(), + _a)]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/start-work', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, row, planLabel; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.markPlanAsStarted)(id)]; + case 1: + row = _b.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(row.work_id), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC791\uC5C5\uC2DC\uC791"), '작업이 시작되었습니다.', 'work-started')]; + case 2: + _b.sent(); + _a = { + ok: true + }; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 3: return [2 /*return*/, (_a.item = _b.sent(), + _a)]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/retry-branch', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, row, planLabel; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.retryPlanBranch)(id)]; + case 1: + row = _b.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(row.work_id), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC791\uC5C5 \uC7AC\uC2DC\uC791"), '브랜치 재시도를 요청했습니다.', 'plan-restarted')]; + case 2: + _b.sent(); + _a = { + ok: true + }; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 3: return [2 /*return*/, (_a.item = _b.sent(), + _a)]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/retry-work', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, row, planLabel; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.retryPlanWork)(id)]; + case 1: + row = _b.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(row.work_id), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC791\uC5C5 \uC7AC\uC2DC\uC791"), '자동 작업 재처리를 요청했습니다.', 'plan-restarted')]; + case 2: + _b.sent(); + _a = { + ok: true + }; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 3: return [2 /*return*/, (_a.item = _b.sent(), + _a)]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/retry-merge', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, row, planLabel; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.retryPlanMerge)(id)]; + case 1: + row = _b.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(row.work_id), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC791\uC5C5 \uC7AC\uC2DC\uC791"), row.worker_status === 'main반영대기' ? 'main 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.', 'plan-restarted')]; + case 2: + _b.sent(); + _a = { + ok: true + }; + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 3: return [2 /*return*/, (_a.item = _b.sent(), + _a)]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/cancel-release', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, item, env, isReleaseMergeFailure, sourceWorkCount, requiresRollbackBeforeCancel, result, _a, error_4; + var _b; + var _c, _d, _e, _f; + return __generator(this, function (_g) { + switch (_g.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 1: + item = _g.sent(); + if (!item) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + _g.label = 2; + case 2: + _g.trys.push([2, 9, , 10]); + env = (0, env_js_1.getEnv)(); + isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패'; + sourceWorkCount = Math.max(0, Number((_d = (_c = item.usageSnapshot) === null || _c === void 0 ? void 0 : _c.sourceWorkCount) !== null && _d !== void 0 ? _d : 0) || 0); + requiresRollbackBeforeCancel = sourceWorkCount > 0 && + (item.status === '릴리즈완료' || item.workerStatus === 'main반영실패'); + if (!(!isReleaseMergeFailure && requiresRollbackBeforeCancel)) return [3 /*break*/, 4]; + return [4 /*yield*/, (0, git_service_js_1.recreateReleaseBranchFromMain)({ + repoPath: env.PLAN_GIT_REPO_PATH, + releaseBranch: env.PLAN_RELEASE_BRANCH, + mainBranch: env.PLAN_MAIN_BRANCH, + }, String((_e = item.releaseTarget) !== null && _e !== void 0 ? _e : env.PLAN_RELEASE_BRANCH))]; + case 3: + _g.sent(); + _g.label = 4; + case 4: return [4 /*yield*/, (0, plan_service_js_1.cancelPlanRelease)(id)]; + case 5: + result = _g.sent(); + _b = { + ok: true + }; + if (!((_f = result === null || result === void 0 ? void 0 : result.item) !== null && _f !== void 0)) return [3 /*break*/, 6]; + _a = _f; + return [3 /*break*/, 8]; + case 6: return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 7: + _a = (_g.sent()); + _g.label = 8; + case 8: return [2 /*return*/, (_b.item = _a, + _b.message = result === null || result === void 0 ? void 0 : result.message, + _b)]; + case 9: + error_4 = _g.sent(); + return [2 /*return*/, reply.code(409).send({ + message: error_4 instanceof Error ? error_4.message : 'release 작업취소 처리에 실패했습니다.', + })]; + case 10: return [2 /*return*/]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/request-main-merge', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, result; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.requestPlanMainMerge)(id)]; + case 1: + result = _a.sent(); + if (!result) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [2 /*return*/, { + ok: true, + item: result.item, + message: result.message, + }]; + } + }); + }); }); + app.get('/api/plan/items/:id/issues', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, item, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!hasPlanAccess(request)) { + return [2 /*return*/, reply.code(403).send({ + message: '상세 조회 권한이 없습니다.', + })]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 1: + item = _a.sent(); + if (!item) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [4 /*yield*/, (0, plan_service_js_1.listPlanIssueHistories)(id)]; + case 2: + rows = _a.sent(); + return [2 /*return*/, { + items: rows.map(plan_service_js_1.mapPlanIssueRow), + }]; + } + }); + }); }); + app.post('/api/plan/items/:id/issues/action', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, payload, item, row, retryResult, planLabel, planLabel, _a, error_5; + var _b; + var _c, _d, _e; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + payload = plan_service_js_1.issueActionSchema.parse((_c = request.body) !== null && _c !== void 0 ? _c : {}); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 1: + item = _f.sent(); + if (!item) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + _f.label = 2; + case 2: + _f.trys.push([2, 12, , 13]); + return [4 /*yield*/, (0, plan_service_js_1.appendLatestIssueAction)(id, payload.actionNote, payload.resolve)]; + case 3: + row = _f.sent(); + return [4 /*yield*/, (0, plan_service_js_1.queuePlanRetryFromIssueAction)(id, payload.actionNote, payload.retry)]; + case 4: + retryResult = _f.sent(); + if (!payload.resolve) return [3 /*break*/, 6]; + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(item.workId), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC774\uC288 \uD574\uACB0 \uCC98\uB9AC"), "".concat(row.issue_tag, " \uC774\uC288\uAC00 \uD574\uACB0 \uCC98\uB9AC\uB418\uC5C8\uC2B5\uB2C8\uB2E4."), 'issue-resolved')]; + case 5: + _f.sent(); + _f.label = 6; + case 6: + if (!(0, plan_notification_policy_js_1.shouldNotifyPlanRestart)(retryResult)) return [3 /*break*/, 8]; + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(item.workId), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC791\uC5C5 \uC7AC\uC2DC\uC791"), (_d = retryResult === null || retryResult === void 0 ? void 0 : retryResult.message) !== null && _d !== void 0 ? _d : '작업 재시작을 요청했습니다.', 'plan-restarted')]; + case 7: + _f.sent(); + _f.label = 8; + case 8: + _b = { + ok: true, + item: (0, plan_service_js_1.mapPlanIssueRow)(row) + }; + if (!((_e = retryResult === null || retryResult === void 0 ? void 0 : retryResult.item) !== null && _e !== void 0)) return [3 /*break*/, 9]; + _a = _e; + return [3 /*break*/, 11]; + case 9: return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 10: + _a = (_f.sent()); + _f.label = 11; + case 11: return [2 /*return*/, (_b.planItem = _a, + _b.message = retryResult === null || retryResult === void 0 ? void 0 : retryResult.message, + _b)]; + case 12: + error_5 = _f.sent(); + return [2 /*return*/, reply.code(409).send({ + message: error_5 instanceof Error ? error_5.message : '이슈 조치 기록 저장에 실패했습니다.', + })]; + case 13: return [2 /*return*/]; + } + }); + }); }); + app.get('/api/plan/items/:id/actions', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, item, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!hasPlanAccess(request)) { + return [2 /*return*/, reply.code(403).send({ + message: '상세 조회 권한이 없습니다.', + })]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 1: + item = _a.sent(); + if (!item) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [4 /*yield*/, (0, plan_service_js_1.listPlanActionHistories)(id)]; + case 2: + rows = _a.sent(); + return [2 /*return*/, { + items: rows.map(plan_service_js_1.mapPlanActionRow), + }]; + } + }); + }); }); + app.get('/api/plan/items/:id/source-works', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, item, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!hasPlanAccess(request)) { + return [2 /*return*/, reply.code(403).send({ + message: '상세 조회 권한이 없습니다.', + })]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 1: + item = _a.sent(); + if (!item) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [4 /*yield*/, (0, plan_service_js_1.listPlanSourceWorkHistories)(id)]; + case 2: + rows = _a.sent(); + return [2 /*return*/, { + items: rows.map(plan_service_js_1.mapPlanSourceWorkRow), + }]; + } + }); + }); }); + app.get('/api/plan/items/:id/source-works/:sourceWorkId', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var params, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!hasPlanAccess(request)) { + return [2 /*return*/, reply.code(403).send({ + message: '상세 조회 권한이 없습니다.', + })]; + } + params = zod_1.z.object({ + id: zod_1.z.coerce.number().int().positive(), + sourceWorkId: zod_1.z.coerce.number().int().positive(), + }).parse(request.params); + return [4 /*yield*/, (0, plan_service_js_1.getPlanSourceWorkHistory)(params.id, params.sourceWorkId)]; + case 1: + row = _a.sent(); + if (!row) { + return [2 /*return*/, reply.code(404).send({ + message: '소스 작업 이력을 찾을 수 없습니다.', + })]; + } + return [2 /*return*/, { + item: (0, plan_service_js_1.mapPlanSourceWorkRow)(row), + }]; + } + }); + }); }); + app.post('/api/plan/items/:id/source-works', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, payload, item, row; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + payload = createSourceWorkSchema.parse((_a = request.body) !== null && _a !== void 0 ? _a : {}); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 1: + item = _b.sent(); + if (!item) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + return [4 /*yield*/, (0, plan_service_js_1.createPlanSourceWorkHistory)(id, payload)]; + case 2: + row = _b.sent(); + return [2 /*return*/, { + ok: true, + item: (0, plan_service_js_1.mapPlanSourceWorkRow)(row), + }]; + } + }); + }); }); + app.post('/api/plan/items/:id/actions/note', function (request, reply) { return __awaiter(_this, void 0, void 0, function () { + var id, payload, item, row, releaseResumeResult, retryResult, _a, planLabel, _b; + var _c; + var _d, _e, _f, _g; + return __generator(this, function (_h) { + switch (_h.label) { + case 0: + if (!requirePlanAccessToken(request, reply)) { + return [2 /*return*/]; + } + id = zod_1.z.coerce.number().int().positive().parse(request.params.id); + payload = actionNoteSchema.parse((_d = request.body) !== null && _d !== void 0 ? _d : {}); + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 1: + item = _h.sent(); + if (!item) { + return [2 /*return*/, reply.code(404).send({ + message: '작업 항목을 찾을 수 없습니다.', + })]; + } + if (!item.startedAt) { + return [2 /*return*/, reply.code(409).send({ + message: '작업시작 이후부터 조치 이력을 기록할 수 있습니다.', + })]; + } + return [4 /*yield*/, (0, plan_service_js_1.createPlanActionHistory)(id, (_e = payload.actionType) !== null && _e !== void 0 ? _e : '추가조치', payload.actionNote)]; + case 2: + row = _h.sent(); + return [4 /*yield*/, (0, plan_service_js_1.resumePlanDevelopmentFromRelease)(id, payload.actionNote)]; + case 3: + releaseResumeResult = _h.sent(); + if (!(releaseResumeResult === null || releaseResumeResult === void 0 ? void 0 : releaseResumeResult.message)) return [3 /*break*/, 4]; + _a = releaseResumeResult; + return [3 /*break*/, 6]; + case 4: return [4 /*yield*/, (0, plan_service_js_1.queuePlanRetryFromFailure)(id, payload.actionNote)]; + case 5: + _a = _h.sent(); + _h.label = 6; + case 6: + retryResult = _a; + if (!(0, plan_notification_policy_js_1.shouldNotifyPlanRestart)(retryResult)) return [3 /*break*/, 8]; + planLabel = (0, plan_service_js_1.formatPlanNotificationLabel)(String(item.workId), id); + return [4 /*yield*/, (0, plan_notification_service_js_1.notifyPlanEvent)(id, "[".concat(planLabel, "] \uC791\uC5C5 \uC7AC\uC2DC\uC791"), (_f = retryResult === null || retryResult === void 0 ? void 0 : retryResult.message) !== null && _f !== void 0 ? _f : '작업 재시작을 요청했습니다.', 'plan-restarted')]; + case 7: + _h.sent(); + _h.label = 8; + case 8: + _c = { + ok: true, + item: (0, plan_service_js_1.mapPlanActionRow)(row) + }; + if (!((_g = retryResult === null || retryResult === void 0 ? void 0 : retryResult.item) !== null && _g !== void 0)) return [3 /*break*/, 9]; + _b = _g; + return [3 /*break*/, 11]; + case 9: return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(id)]; + case 10: + _b = (_h.sent()); + _h.label = 11; + case 11: return [2 /*return*/, (_c.planItem = _b, + _c.message = retryResult === null || retryResult === void 0 ? void 0 : retryResult.message, + _c)]; + } + }); + }); }); + return [2 /*return*/]; + }); + }); +} diff --git a/etc/servers/work-server/src/routes/plan.ts b/etc/servers/work-server/src/routes/plan.ts index c2725b6..7e6c26c 100755 --- a/etc/servers/work-server/src/routes/plan.ts +++ b/etc/servers/work-server/src/routes/plan.ts @@ -64,10 +64,12 @@ import { listPlanScheduledTasks, mapPlanScheduledTaskRow, registerPlanScheduledTaskNow, + syncManagedServiceGenerationCompletion, updatePlanScheduledTask, updatePlanScheduledTaskSchema, } from '../services/plan-schedule-service.js'; import { getVisitorClientByClientId } from '../services/visitor-history-service.js'; +import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js'; const completeActionSchema = z.object({ note: z.string().trim().min(1).optional(), @@ -164,7 +166,10 @@ export async function registerPlanRoutes(app: FastifyInstance) { const payload = createPlanScheduledTaskSchema.parse(request.body ?? {}); const row = await createPlanScheduledTask(payload); - const ignoreScheduleDueForImmediateRegistration = !payload.repeatWindowStartTime; + const ignoreScheduleDueForImmediateRegistration = + payload.repeatWindows.length === 0 + && payload.scheduleWeekdays.length === 0 + && payload.scheduleDateRanges.length === 0; const immediateRegistration = payload.executionMode === 'managed-service' && payload.recreateManagedServiceOnNextSave ? await registerPlanScheduledTaskNow(Number(row.id), new Date(), { @@ -231,16 +236,26 @@ export async function registerPlanRoutes(app: FastifyInstance) { && Boolean(row.immediate_run_enabled ?? true) && payload.enabled !== false ); - const effectiveRepeatWindowStartTime = - payload.repeatWindowStartTime !== undefined - ? payload.repeatWindowStartTime - : (typeof row?.repeat_window_start_time === 'string' ? row.repeat_window_start_time : null); + const effectiveRepeatWindows = + payload.repeatWindows !== undefined + ? payload.repeatWindows + : (row ? mapPlanScheduledTaskRow(row).repeatWindows : []); + const effectiveScheduleDateRanges = + payload.scheduleDateRanges !== undefined + ? payload.scheduleDateRanges + : (row ? mapPlanScheduledTaskRow(row).scheduleDateRanges : []); + const effectiveScheduleWeekdays = + payload.scheduleWeekdays !== undefined + ? payload.scheduleWeekdays + : (row ? mapPlanScheduledTaskRow(row).scheduleWeekdays : []); const immediateRegistration = shouldTriggerImmediateRegistration - ? await registerPlanScheduledTaskNow(id, new Date(), { + ? await registerPlanScheduledTaskNow(id, new Date(), { ignoreScheduleDue: payload.recreateManagedServiceOnNextSave === true ? false - : !effectiveRepeatWindowStartTime, + : effectiveRepeatWindows.length === 0 + && effectiveScheduleWeekdays.length === 0 + && effectiveScheduleDateRanges.length === 0, forceManagedServiceGeneration: payload.recreateManagedServiceOnNextSave === true, }) : null; @@ -576,6 +591,8 @@ export async function registerPlanRoutes(app: FastifyInstance) { }); } + await syncManagedServiceGenerationCompletion(id); + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); await notifyPlanEvent( id, @@ -583,6 +600,7 @@ export async function registerPlanRoutes(app: FastifyInstance) { '수동 작업완료로 release 반영 대기 상태가 되었습니다.', 'development-completed', ); + await progressBoardPostAutomationByPlanResult(id, 'completed'); return { ok: true, @@ -605,6 +623,8 @@ export async function registerPlanRoutes(app: FastifyInstance) { }); } + await syncManagedServiceGenerationCompletion(id); + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); await notifyPlanEvent( id, @@ -612,6 +632,7 @@ export async function registerPlanRoutes(app: FastifyInstance) { payload.note ?? '작업이 완료 처리되었습니다.', 'plan-completed', ); + await progressBoardPostAutomationByPlanResult(id, 'completed'); return { ok: true, @@ -748,8 +769,12 @@ export async function registerPlanRoutes(app: FastifyInstance) { try { const env = getEnv(); const isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패'; + const sourceWorkCount = Math.max(0, Number(item.usageSnapshot?.sourceWorkCount ?? 0) || 0); + const requiresRollbackBeforeCancel = + sourceWorkCount > 0 && + (item.status === '릴리즈완료' || item.workerStatus === 'main반영실패'); - if (!isReleaseMergeFailure) { + if (!isReleaseMergeFailure && requiresRollbackBeforeCancel) { await recreateReleaseBranchFromMain( { repoPath: env.PLAN_GIT_REPO_PATH, diff --git a/etc/servers/work-server/src/routes/resource-manager.ts b/etc/servers/work-server/src/routes/resource-manager.ts new file mode 100644 index 0000000..1ec3fd2 --- /dev/null +++ b/etc/servers/work-server/src/routes/resource-manager.ts @@ -0,0 +1,239 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { env } from '../config/env.js'; +import { + copyResourceManagerItem, + createResourceManagerDirectory, + createResourceManagerFile, + deleteResourceManagerItem, + ensureResourceManagerRoot, + getResourceManagerTree, + listResourceManagerDirectory, + moveResourceManagerItem, + openResourceManagerPreviewStream, + readResourceManagerFile, + saveResourceManagerFile, + uploadResourceManagerFile, +} from '../services/resource-manager-service.js'; + +const queryPathSchema = z.object({ + path: z.string().trim().optional().default(''), +}); + +const createDirectoryBodySchema = z.object({ + parentPath: z.string().trim().optional().default(''), + name: z.string().trim().min(1).max(255), +}); + +const createFileBodySchema = z.object({ + parentPath: z.string().trim().optional().default(''), + name: z.string().trim().min(1).max(255), + content: z.string().optional().default(''), +}); + +const saveFileBodySchema = z.object({ + path: z.string().trim().min(1), + content: z.string(), +}); + +const uploadFileBodySchema = z.object({ + parentPath: z.string().trim().optional().default(''), + fileName: z.string().trim().min(1).max(255), + contentBase64: z.string().trim().min(1), +}); + +const copyMoveBodySchema = z.object({ + path: z.string().trim().min(1), + targetDirectoryPath: z.string().trim().optional().default(''), + nextName: z.string().trim().max(255).optional().nullable(), +}); + +function getRequestAccessToken(request: FastifyRequest) { + const tokenHeader = request.headers['x-access-token']; + return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); +} + +function ensureAuthorized(request: FastifyRequest, reply: FastifyReply, tokenOverride?: string | null) { + if ((tokenOverride ?? getRequestAccessToken(request)) === env.SERVER_COMMAND_ACCESS_TOKEN) { + return true; + } + + reply.status(403); + void reply.send({ + message: '권한 토큰이 필요합니다.', + }); + return false; +} + +function resolveRepoRootPath() { + return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; +} + +export async function registerResourceManagerRoutes(app: FastifyInstance) { + app.get('/api/resource-manager/tree', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const repoRootPath = resolveRepoRootPath(); + await ensureResourceManagerRoot(repoRootPath); + + return { + ok: true, + item: await getResourceManagerTree(repoRootPath), + }; + }); + + app.get('/api/resource-manager/directory', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const query = queryPathSchema.parse(request.query ?? {}); + + return { + ok: true, + item: await listResourceManagerDirectory(resolveRepoRootPath(), query.path), + }; + }); + + app.get('/api/resource-manager/file', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const query = queryPathSchema.extend({ + path: z.string().trim().min(1), + }).parse(request.query ?? {}); + + return { + ok: true, + item: await readResourceManagerFile(resolveRepoRootPath(), query.path), + }; + }); + + app.get('/api/resource-manager/preview/*', async (request, reply) => { + const query = z.object({ + token: z.string().trim().optional(), + }).parse(request.query ?? {}); + + if (!ensureAuthorized(request, reply, query.token ?? null)) { + return; + } + + const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); + const preview = await openResourceManagerPreviewStream(resolveRepoRootPath(), decodeURIComponent(wildcard)); + + reply.header('Cache-Control', 'no-store'); + reply.type(preview.contentType); + return reply.send(preview.stream); + }); + + app.post('/api/resource-manager/directories', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const payload = createDirectoryBodySchema.parse(request.body ?? {}); + await createResourceManagerDirectory(resolveRepoRootPath(), payload.parentPath, payload.name); + + return { + ok: true, + }; + }); + + app.post('/api/resource-manager/files', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const payload = createFileBodySchema.parse(request.body ?? {}); + await createResourceManagerFile(resolveRepoRootPath(), payload.parentPath, payload.name, payload.content); + + return { + ok: true, + }; + }); + + app.put('/api/resource-manager/files/content', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const payload = saveFileBodySchema.parse(request.body ?? {}); + await saveResourceManagerFile(resolveRepoRootPath(), payload.path, payload.content); + + return { + ok: true, + }; + }); + + app.post('/api/resource-manager/files/upload', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const payload = uploadFileBodySchema.parse(request.body ?? {}); + + return { + ok: true, + item: await uploadResourceManagerFile( + resolveRepoRootPath(), + payload.parentPath, + payload.fileName, + payload.contentBase64, + ), + }; + }); + + app.post('/api/resource-manager/items/copy', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const payload = copyMoveBodySchema.parse(request.body ?? {}); + + return { + ok: true, + item: await copyResourceManagerItem( + resolveRepoRootPath(), + payload.path, + payload.targetDirectoryPath, + payload.nextName, + ), + }; + }); + + app.post('/api/resource-manager/items/move', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const payload = copyMoveBodySchema.parse(request.body ?? {}); + + return { + ok: true, + item: await moveResourceManagerItem( + resolveRepoRootPath(), + payload.path, + payload.targetDirectoryPath, + payload.nextName, + ), + }; + }); + + app.delete('/api/resource-manager/items', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const query = queryPathSchema.extend({ + path: z.string().trim().min(1), + }).parse(request.query ?? {}); + await deleteResourceManagerItem(resolveRepoRootPath(), query.path); + + return { + ok: true, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/server-command.ts b/etc/servers/work-server/src/routes/server-command.ts index a207998..de82666 100755 --- a/etc/servers/work-server/src/routes/server-command.ts +++ b/etc/servers/work-server/src/routes/server-command.ts @@ -2,16 +2,45 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { env } from '../config/env.js'; import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js'; +import { + cancelServerRestartReservation, + confirmServerRestartReservation, + getRestartReservationWorkloadSummary, + getServerRestartReservation, + scheduleServerRestartReservation, +} from '../services/server-restart-reservation-service.js'; const serverCommandParamSchema = z.object({ key: z.enum(serverCommandKeys), }); +const restartReservationBodySchema = z.object({ + autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(), +}); + function getRequestAccessToken(request: FastifyRequest) { const tokenHeader = request.headers['x-access-token']; return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); } +function getRequestClientId(request: FastifyRequest) { + const clientIdHeader = request.headers['x-client-id']; + return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim(); +} + +function getRequestAppOrigin(request: FastifyRequest) { + const appOriginHeader = request.headers['x-app-origin']; + const appOrigin = Array.isArray(appOriginHeader) ? appOriginHeader[0] : appOriginHeader; + + if (appOrigin?.trim()) { + return appOrigin.trim(); + } + + const originHeader = request.headers.origin; + const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader; + return origin?.trim() ?? ''; +} + function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) { if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) { return true; @@ -42,6 +71,25 @@ export async function registerServerCommandRoutes(app: FastifyInstance) { } const { key } = serverCommandParamSchema.parse(request.params); + + if (key === 'test' || key === 'work-server') { + const workloadSummary = await getRestartReservationWorkloadSummary(); + const pendingCount = + workloadSummary.codexRunningCount + + workloadSummary.codexQueuedCount + + workloadSummary.automationRunningCount + + workloadSummary.automationQueuedCount; + + if (pendingCount > 0) { + reply.status(409); + return { + ok: false, + message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`, + workloadSummary, + }; + } + } + const result = await restartServerCommand(key); return { @@ -51,4 +99,64 @@ export async function registerServerCommandRoutes(app: FastifyInstance) { restartState: result.restartState, }; }); + + app.get('/api/server-commands/restart-reservation', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + return { + ok: true, + item: await getServerRestartReservation(), + }; + }); + + app.put('/api/server-commands/restart-reservation', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + let payload: unknown = request.body ?? {}; + + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + payload = {}; + } + } + + const parsed = restartReservationBodySchema.parse(payload ?? {}); + + return { + ok: true, + item: await scheduleServerRestartReservation({ + clientId: getRequestClientId(request), + appOrigin: getRequestAppOrigin(request), + autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds, + }), + }; + }); + + app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + return { + ok: true, + item: await confirmServerRestartReservation(app.log), + }; + }); + + app.delete('/api/server-commands/restart-reservation', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + return { + ok: true, + item: await cancelServerRestartReservation(), + }; + }); } diff --git a/etc/servers/work-server/src/server.ts b/etc/servers/work-server/src/server.ts index 949de0e..08cee39 100755 --- a/etc/servers/work-server/src/server.ts +++ b/etc/servers/work-server/src/server.ts @@ -2,12 +2,14 @@ import { env } from './config/env.js'; import { db } from './db/client.js'; import { createApp } from './app.js'; import { ChatService } from './services/chat-service.js'; -import { clearAllChatConversationJobStates, ensureChatConversationTables } from './services/chat-room-service.js'; +import { ensureChatConversationTables } from './services/chat-room-service.js'; import { shutdownNotificationProvider } from './services/notification-service.js'; +import { ServerRestartReservationWorker } from './services/server-restart-reservation-service.js'; import { PlanWorker } from './workers/plan-worker.js'; const app = createApp(); const planWorker = new PlanWorker(app.log); +const serverRestartReservationWorker = new ServerRestartReservationWorker(app.log); const chatService = new ChatService(app.log); const startedAt = Date.now(); let shutdownPromise: Promise | null = null; @@ -16,12 +18,13 @@ app.server.on('upgrade', chatService.attachUpgradeHandler()); async function start() { try { await ensureChatConversationTables(); - await clearAllChatConversationJobStates(); + await chatService.recoverInterruptedSessions(); await app.listen({ host: '0.0.0.0', port: env.PORT, }); planWorker.start(); + serverRestartReservationWorker.start(); } catch (error) { app.log.error(error); process.exit(1); @@ -43,6 +46,7 @@ async function shutdown(signal: string) { try { await planWorker.stop(); + await serverRestartReservationWorker.stop(); chatService.close(); await app.close(); await shutdownNotificationProvider(); diff --git a/etc/servers/work-server/src/services/app-config-service.js b/etc/servers/work-server/src/services/app-config-service.js new file mode 100644 index 0000000..165da13 --- /dev/null +++ b/etc/servers/work-server/src/services/app-config-service.js @@ -0,0 +1,449 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.APP_CONFIG_TABLE = void 0; +exports.resolveAppConfigByOrigin = resolveAppConfigByOrigin; +exports.getAppConfig = getAppConfig; +exports.mergeDefaultChatTypes = mergeDefaultChatTypes; +exports.normalizeAppConfigSnapshot = normalizeAppConfigSnapshot; +exports.getAppConfigSnapshot = getAppConfigSnapshot; +exports.upsertAppConfig = upsertAppConfig; +exports.getChatTypesConfig = getChatTypesConfig; +exports.upsertChatTypesConfig = upsertChatTypesConfig; +var client_js_1 = require("../db/client.js"); +var chat_type_defaults_js_1 = require("./chat-type-defaults.js"); +exports.APP_CONFIG_TABLE = 'app_configs'; +var CHAT_TYPES_CONFIG_KEY = 'chatTypes'; +var SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs'; +var DEFAULT_CHAT_APP_CONFIG = { + maxContextMessages: 12, + maxContextChars: 3200, + codexLiveMaxExecutionSeconds: 600, + codexLiveIdleTimeoutSeconds: 180, + receiveRoomNotifications: true, +}; +function ensureAppConfigTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.APP_CONFIG_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.APP_CONFIG_TABLE, function (table) { + table.increments('id').primary(); + table.jsonb('config_json').notNullable().defaultTo('{}'); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['config_json', function (table) { return table.jsonb('config_json').notNullable().defaultTo('{}'); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.APP_CONFIG_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.APP_CONFIG_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function normalizeAppOrigin(appOrigin) { + if (typeof appOrigin !== 'string') { + return ''; + } + try { + var url = new URL(appOrigin.trim()); + return url.origin; + } + catch (_a) { + return ''; + } +} +function normalizeAppDomain(appDomain) { + return typeof appDomain === 'string' ? appDomain.trim().toLowerCase() : ''; +} +function deepMergeConfigRecords(base, override) { + var merged = __assign({}, base); + for (var _i = 0, _a = Object.entries(override); _i < _a.length; _i++) { + var _b = _a[_i], key = _b[0], value = _b[1]; + var current = merged[key]; + var canMergeObject = value && + current && + typeof value === 'object' && + typeof current === 'object' && + !Array.isArray(value) && + !Array.isArray(current); + merged[key] = canMergeObject + ? deepMergeConfigRecords(normalizeConfigRecord(current), normalizeConfigRecord(value)) + : value; + } + return merged; +} +function getBaseAppConfigRecord(value) { + var normalized = normalizeConfigRecord(value); + var nextConfig = __assign({}, normalized); + delete nextConfig[SCOPED_APP_CONFIGS_KEY]; + return nextConfig; +} +function getScopedAppConfigsRecord(value) { + return normalizeConfigRecord(normalizeConfigRecord(value)[SCOPED_APP_CONFIGS_KEY]); +} +function resolveScopedAppConfig(value, appOrigin) { + var normalizedAppOrigin = normalizeAppOrigin(appOrigin); + if (!normalizedAppOrigin) { + return null; + } + var scopedEntry = normalizeConfigRecord(getScopedAppConfigsRecord(value)[normalizedAppOrigin]); + var scopedConfig = normalizeConfigRecord(scopedEntry.config); + return Object.keys(scopedConfig).length > 0 ? scopedConfig : null; +} +function mergeScopedAppConfig(currentConfig, nextConfig, appOrigin, appDomain) { + var _a, _b; + var _c; + var normalizedAppOrigin = normalizeAppOrigin(appOrigin); + if (!normalizedAppOrigin) { + return deepMergeConfigRecords(currentConfig, nextConfig); + } + var baseConfig = getBaseAppConfigRecord(currentConfig); + var scopedConfigs = getScopedAppConfigsRecord(currentConfig); + var currentResolvedScopedConfig = (_c = resolveScopedAppConfig(currentConfig, normalizedAppOrigin)) !== null && _c !== void 0 ? _c : baseConfig; + var nextResolvedScopedConfig = deepMergeConfigRecords(currentResolvedScopedConfig, nextConfig); + var normalizedAppDomain = normalizeAppDomain(appDomain); + return __assign(__assign({}, baseConfig), (_a = {}, _a[SCOPED_APP_CONFIGS_KEY] = __assign(__assign({}, scopedConfigs), (_b = {}, _b[normalizedAppOrigin] = { + config: nextResolvedScopedConfig, + updatedAt: new Date().toISOString(), + appDomain: normalizedAppDomain || null, + }, _b)), _a)); +} +function resolveAppConfigByOrigin(value, appOrigin) { + var baseConfig = getBaseAppConfigRecord(value); + var scopedConfig = resolveScopedAppConfig(value, appOrigin); + if (!scopedConfig) { + return baseConfig; + } + return deepMergeConfigRecords(baseConfig, scopedConfig); +} +function getAppConfig(appOrigin) { + return __awaiter(this, void 0, void 0, function () { + var row; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensureAppConfigTable()]; + case 1: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE).first()]; + case 2: + row = _b.sent(); + if (!row) { + return [2 /*return*/, null]; + } + if (typeof row.config_json === 'string') { + try { + return [2 /*return*/, resolveAppConfigByOrigin(JSON.parse(row.config_json), appOrigin)]; + } + catch (_c) { + return [2 /*return*/, {}]; + } + } + return [2 /*return*/, resolveAppConfigByOrigin((_a = row.config_json) !== null && _a !== void 0 ? _a : {}, appOrigin)]; + } + }); + }); +} +function normalizeConfigRecord(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value; +} +function normalizeIntegerInRange(value, fallback, min, max) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.round(value))); +} +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : ''; +} +function normalizePermissions(value) { + if (!Array.isArray(value)) { + return ['token-user']; + } + var permissions = Array.from(new Set(value.filter(function (item) { return item === 'guest' || item === 'token-user'; }))); + return permissions.length > 0 ? permissions : ['token-user']; +} +function normalizeChatTypeRecord(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + var record = value; + var name = normalizeText(record.name); + if (!name) { + return null; + } + return { + id: normalizeText(record.id) || "chat-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), + name: name, + description: normalizeText(record.description), + permissions: normalizePermissions(record.permissions), + enabled: record.enabled !== false, + updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), + }; +} +function buildChatTypeSemanticKey(record) { + return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); +} +function compareUpdatedAt(left, right) { + var leftTime = Date.parse(left.updatedAt); + var rightTime = Date.parse(right.updatedAt); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return 0; +} +function sanitizeChatTypes(items) { + var normalized = items + .map(function (item) { return normalizeChatTypeRecord(item); }) + .filter(function (item) { return Boolean(item); }); + var byId = new Map(); + var bySemanticKey = new Map(); + for (var _i = 0, normalized_1 = normalized; _i < normalized_1.length; _i++) { + var item = normalized_1[_i]; + var current = byId.get(item.id); + if (!current || compareUpdatedAt(current, item) <= 0) { + byId.set(item.id, item); + } + } + for (var _a = 0, _b = byId.values(); _a < _b.length; _a++) { + var item = _b[_a]; + var semanticKey = buildChatTypeSemanticKey(item); + var current = bySemanticKey.get(semanticKey); + if (!current || compareUpdatedAt(current, item) <= 0) { + bySemanticKey.set(semanticKey, item); + } + } + return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.name.localeCompare(right.name, 'ko-KR'); }); +} +function mergeDefaultChatTypes(items) { + var savedItems = sanitizeChatTypes(items); + var byId = new Map(savedItems.map(function (item) { return [item.id, item]; })); + for (var _i = 0, DEFAULT_CHAT_TYPES_1 = chat_type_defaults_js_1.DEFAULT_CHAT_TYPES; _i < DEFAULT_CHAT_TYPES_1.length; _i++) { + var defaultItem = DEFAULT_CHAT_TYPES_1[_i]; + var savedItem = byId.get(defaultItem.id); + if (!savedItem) { + byId.set(defaultItem.id, defaultItem); + } + } + return sanitizeChatTypes(Array.from(byId.values())); +} +function isSameChatTypeList(left, right) { + if (left.length !== right.length) { + return false; + } + return left.every(function (item, index) { + var target = right[index]; + return (target && + item.id === target.id && + item.name === target.name && + item.description === target.description && + item.enabled === target.enabled && + item.updatedAt === target.updatedAt && + item.permissions.length === target.permissions.length && + item.permissions.every(function (permission, permissionIndex) { return permission === target.permissions[permissionIndex]; })); + }); +} +function normalizeAppConfigSnapshot(value) { + var normalized = normalizeConfigRecord(value); + var chat = normalizeConfigRecord(normalized.chat); + var worklogAutomation = normalizeConfigRecord(normalized.worklogAutomation); + return __assign(__assign({}, normalized), { chat: { + maxContextMessages: normalizeIntegerInRange(chat.maxContextMessages, DEFAULT_CHAT_APP_CONFIG.maxContextMessages, 1, 50), + maxContextChars: normalizeIntegerInRange(chat.maxContextChars, DEFAULT_CHAT_APP_CONFIG.maxContextChars, 500, 20000), + codexLiveMaxExecutionSeconds: normalizeIntegerInRange(chat.codexLiveMaxExecutionSeconds, DEFAULT_CHAT_APP_CONFIG.codexLiveMaxExecutionSeconds, 60, 7200), + codexLiveIdleTimeoutSeconds: normalizeIntegerInRange(chat.codexLiveIdleTimeoutSeconds, DEFAULT_CHAT_APP_CONFIG.codexLiveIdleTimeoutSeconds, 30, 3600), + receiveRoomNotifications: typeof chat.receiveRoomNotifications === 'boolean' + ? chat.receiveRoomNotifications + : DEFAULT_CHAT_APP_CONFIG.receiveRoomNotifications, + }, worklogAutomation: Object.keys(worklogAutomation).length > 0 + ? __assign(__assign({}, normalized.worklogAutomation), { repeatRequestEnabled: false }) : undefined }); +} +function getAppConfigSnapshot(appOrigin) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _a = normalizeAppConfigSnapshot; + return [4 /*yield*/, getAppConfig(appOrigin)]; + case 1: return [2 /*return*/, _a.apply(void 0, [_b.sent()])]; + } + }); + }); +} +function upsertAppConfig(config, appOrigin, appDomain) { + return __awaiter(this, void 0, void 0, function () { + var nextConfig, existing, initialConfig, rows_1, mergedConfig, rows; + var _a, _b, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, ensureAppConfigTable()]; + case 1: + _e.sent(); + nextConfig = normalizeConfigRecord(config); + return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE).first()]; + case 2: + existing = _e.sent(); + if (!!existing) return [3 /*break*/, 4]; + initialConfig = mergeScopedAppConfig({}, nextConfig, appOrigin, appDomain); + return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE) + .insert({ + config_json: initialConfig, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows_1 = _e.sent(); + return [2 /*return*/, resolveAppConfigByOrigin((_b = (_a = rows_1[0]) === null || _a === void 0 ? void 0 : _a.config_json) !== null && _b !== void 0 ? _b : initialConfig, appOrigin)]; + case 4: + mergedConfig = mergeScopedAppConfig(normalizeConfigRecord(existing.config_json), nextConfig, appOrigin, appDomain); + return [4 /*yield*/, (0, client_js_1.db)(exports.APP_CONFIG_TABLE) + .update({ + config_json: mergedConfig, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 5: + rows = _e.sent(); + return [2 /*return*/, resolveAppConfigByOrigin((_d = (_c = rows[0]) === null || _c === void 0 ? void 0 : _c.config_json) !== null && _d !== void 0 ? _d : mergedConfig, appOrigin)]; + } + }); + }); +} +function getChatTypesConfig(appOrigin) { + return __awaiter(this, void 0, void 0, function () { + var config, normalized, chatTypes, savedChatTypes, mergedChatTypes; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, getAppConfig(appOrigin)]; + case 1: + config = _b.sent(); + normalized = normalizeConfigRecord(config); + chatTypes = normalized[CHAT_TYPES_CONFIG_KEY]; + if (chatTypes == null) { + return [2 /*return*/, null]; + } + savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : []; + mergedChatTypes = mergeDefaultChatTypes(savedChatTypes); + if (!!isSameChatTypeList(savedChatTypes, mergedChatTypes)) return [3 /*break*/, 3]; + return [4 /*yield*/, upsertAppConfig((_a = {}, + _a[CHAT_TYPES_CONFIG_KEY] = mergedChatTypes, + _a), appOrigin)]; + case 2: + _b.sent(); + _b.label = 3; + case 3: return [2 /*return*/, mergedChatTypes]; + } + }); + }); +} +function upsertChatTypesConfig(chatTypes, appOrigin, appDomain) { + return __awaiter(this, void 0, void 0, function () { + var current, _a, resolvedChatTypes, nextConfig; + var _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: + _a = normalizeConfigRecord; + return [4 /*yield*/, getAppConfig(appOrigin)]; + case 1: + current = _a.apply(void 0, [_c.sent()]); + resolvedChatTypes = mergeDefaultChatTypes(chatTypes); + nextConfig = __assign(__assign({}, current), (_b = {}, _b[CHAT_TYPES_CONFIG_KEY] = resolvedChatTypes, _b)); + return [4 /*yield*/, upsertAppConfig(nextConfig, appOrigin, appDomain)]; + case 2: + _c.sent(); + return [2 /*return*/, resolvedChatTypes]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/app-config-service.test.ts b/etc/servers/work-server/src/services/app-config-service.test.ts index 3f3504a..9300115 100644 --- a/etc/servers/work-server/src/services/app-config-service.test.ts +++ b/etc/servers/work-server/src/services/app-config-service.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mergeDefaultChatTypes } from './app-config-service.js'; +import { mergeDefaultChatTypes, resolveAppConfigByOrigin } from './app-config-service.js'; test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => { const merged = mergeDefaultChatTypes([ @@ -39,6 +39,24 @@ test('mergeDefaultChatTypes preserves saved edits for layout editor execution', assert.equal(layoutEditorExecution.description, '호출 가능한 API 요청만 처리합니다.'); }); +test('mergeDefaultChatTypes preserves saved edits for guided layout editor execution', () => { + const merged = mergeDefaultChatTypes([ + { + id: 'layout-editor-guided-execution', + name: 'Layout editor 단계별 실행', + description: '사용자가 정리한 단계별 Layout 실행 문맥', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-05-01T09:00:00.000Z', + }, + ]); + + const guidedLayoutEditorExecution = merged.find((item) => item.id === 'layout-editor-guided-execution'); + + assert.ok(guidedLayoutEditorExecution); + assert.equal(guidedLayoutEditorExecution.description, '사용자가 정리한 단계별 Layout 실행 문맥'); +}); + test('mergeDefaultChatTypes still appends missing built-in chat types', () => { const merged = mergeDefaultChatTypes([]); @@ -46,4 +64,51 @@ test('mergeDefaultChatTypes still appends missing built-in chat types', () => { assert.ok(merged.some((item) => item.id === 'layout-editor-execution')); assert.ok(merged.some((item) => item.id === 'api-request-template')); assert.ok(merged.some((item) => item.id === 'general-inquiry')); + assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution')); +}); + +test('resolveAppConfigByOrigin prefers scoped app config over legacy global config', () => { + const resolved = resolveAppConfigByOrigin( + { + chat: { + maxContextMessages: 12, + receiveRoomNotifications: true, + }, + automation: { + notifyOnAutomationStart: true, + }, + scopedAppConfigs: { + 'https://rel.sm-home.cloud': { + config: { + chat: { + receiveRoomNotifications: false, + }, + }, + }, + }, + }, + 'https://rel.sm-home.cloud', + ) as { + chat?: { maxContextMessages?: number; receiveRoomNotifications?: boolean }; + automation?: { notifyOnAutomationStart?: boolean }; + }; + + assert.equal(resolved.chat?.maxContextMessages, 12); + assert.equal(resolved.chat?.receiveRoomNotifications, false); + assert.equal(resolved.automation?.notifyOnAutomationStart, true); +}); + +test('resolveAppConfigByOrigin falls back to legacy global config when scoped config is missing', () => { + const resolved = resolveAppConfigByOrigin( + { + chat: { + receiveRoomNotifications: true, + }, + }, + 'https://test.sm-home.cloud', + ) as { + chat?: { receiveRoomNotifications?: boolean }; + }; + + assert.equal(resolved.chat?.receiveRoomNotifications, true); }); diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts index 6b1785f..d9c09db 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -1,16 +1,17 @@ import { db } from '../db/client.js'; +import { DEFAULT_CHAT_TYPES } from './chat-type-defaults.js'; export const APP_CONFIG_TABLE = 'app_configs'; const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; -const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution'; -const LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION = - '## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.'; +const CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings'; +const SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs'; const DEFAULT_CHAT_APP_CONFIG = { maxContextMessages: 12, maxContextChars: 3200, codexLiveMaxExecutionSeconds: 600, codexLiveIdleTimeoutSeconds: 180, receiveRoomNotifications: true, + restartReservationCompletionDelaySeconds: 10, } as const; type ChatPermissionRole = 'guest' | 'token-user'; @@ -24,40 +25,50 @@ type ChatTypeRecord = { updatedAt: string; }; -const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ +type ChatDefaultContextRecord = { + id: string; + title: string; + content: string; + enabled: boolean; + updatedAt: string; +}; + +type ChatTypeDefaultContextSelection = { + chatTypeId: string; + defaultContextIds: string[]; + updatedAt: string; +}; + +type ChatRoomContextSettings = { + sessionId: string; + defaultContextIds: string[]; + customContextTitle: string; + customContextContent: string; + updatedAt: string; +}; + +export type ChatContextSettingsSnapshot = { + defaultContexts: ChatDefaultContextRecord[]; + chatTypeDefaults: ChatTypeDefaultContextSelection[]; + roomContexts: ChatRoomContextSettings[]; +}; + +const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [ { - id: 'general-request', - name: '일반 요청', - description: - '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', - permissions: ['token-user'], + id: 'chat-default-mobile-verification', + title: '모바일 검증', + content: + '## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.', enabled: true, - updatedAt: '2026-04-23T00:00:00.000Z', + updatedAt: '2026-05-03T00:00:00.000Z', }, { - id: LAYOUT_EDITOR_CHAT_TYPE_ID, - name: 'Layout editor 실행', - description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION, - permissions: ['token-user'], + id: 'chat-default-resource-output', + title: '리소스 출력', + content: + '## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat//resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.', enabled: true, - updatedAt: '2026-04-28T23:55:00.000Z', - }, - { - id: 'api-request-template', - name: 'API요청', - description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-23T00:00:00.000Z', - }, - { - id: 'general-inquiry', - name: '일반 문의', - description: - '## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat//resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-24T00:00:00.000Z', + updatedAt: '2026-05-03T00:00:00.000Z', }, ]; @@ -88,7 +99,117 @@ async function ensureAppConfigTable() { } } -export async function getAppConfig() { +function normalizeAppOrigin(appOrigin?: string | null) { + if (typeof appOrigin !== 'string') { + return ''; + } + + try { + const url = new URL(appOrigin.trim()); + return url.origin; + } catch { + return ''; + } +} + +function normalizeAppDomain(appDomain?: string | null) { + return typeof appDomain === 'string' ? appDomain.trim().toLowerCase() : ''; +} + +function deepMergeConfigRecords( + base: Record, + override: Record, +): Record { + const merged = { ...base }; + + for (const [key, value] of Object.entries(override)) { + const current = merged[key]; + const canMergeObject = + value && + current && + typeof value === 'object' && + typeof current === 'object' && + !Array.isArray(value) && + !Array.isArray(current); + + merged[key] = canMergeObject + ? deepMergeConfigRecords( + normalizeConfigRecord(current), + normalizeConfigRecord(value), + ) + : value; + } + + return merged; +} + +function getBaseAppConfigRecord(value: unknown) { + const normalized = normalizeConfigRecord(value); + const nextConfig = { ...normalized }; + delete nextConfig[SCOPED_APP_CONFIGS_KEY]; + return nextConfig; +} + +function getScopedAppConfigsRecord(value: unknown) { + return normalizeConfigRecord(normalizeConfigRecord(value)[SCOPED_APP_CONFIGS_KEY]); +} + +function resolveScopedAppConfig(value: unknown, appOrigin?: string | null) { + const normalizedAppOrigin = normalizeAppOrigin(appOrigin); + + if (!normalizedAppOrigin) { + return null; + } + + const scopedEntry = normalizeConfigRecord(getScopedAppConfigsRecord(value)[normalizedAppOrigin]); + const scopedConfig = normalizeConfigRecord(scopedEntry.config); + return Object.keys(scopedConfig).length > 0 ? scopedConfig : null; +} + +function mergeScopedAppConfig( + currentConfig: Record, + nextConfig: Record, + appOrigin?: string | null, + appDomain?: string | null, +) { + const normalizedAppOrigin = normalizeAppOrigin(appOrigin); + + if (!normalizedAppOrigin) { + return deepMergeConfigRecords(currentConfig, nextConfig); + } + + const baseConfig = getBaseAppConfigRecord(currentConfig); + const scopedConfigs = getScopedAppConfigsRecord(currentConfig); + const currentResolvedScopedConfig = + resolveScopedAppConfig(currentConfig, normalizedAppOrigin) ?? baseConfig; + const nextResolvedScopedConfig = deepMergeConfigRecords(currentResolvedScopedConfig, nextConfig); + const normalizedAppDomain = normalizeAppDomain(appDomain); + + return { + ...baseConfig, + [SCOPED_APP_CONFIGS_KEY]: { + ...scopedConfigs, + [normalizedAppOrigin]: { + config: nextResolvedScopedConfig, + updatedAt: new Date().toISOString(), + appDomain: normalizedAppDomain || null, + }, + }, + }; +} + +export function resolveAppConfigByOrigin(value: unknown, appOrigin?: string | null) { + const baseConfig = getBaseAppConfigRecord(value); + const scopedConfig = resolveScopedAppConfig(value, appOrigin); + + if (!scopedConfig) { + return baseConfig; + } + + return deepMergeConfigRecords(baseConfig, scopedConfig); +} + +export async function getAppConfig(appOrigin?: string | null) { await ensureAppConfigTable(); const row = await db(APP_CONFIG_TABLE).first(); @@ -99,13 +220,13 @@ export async function getAppConfig() { if (typeof row.config_json === 'string') { try { - return JSON.parse(row.config_json) as Record; + return resolveAppConfigByOrigin(JSON.parse(row.config_json), appOrigin); } catch { return {}; } } - return row.config_json ?? {}; + return resolveAppConfigByOrigin(row.config_json ?? {}, appOrigin); } function normalizeConfigRecord(value: unknown) { @@ -144,6 +265,135 @@ function normalizePermissions(value: unknown): ChatPermissionRole[] { return permissions.length > 0 ? permissions : ['token-user']; } +function normalizeDefaultContextIds(value: unknown) { + if (!Array.isArray(value)) { + return [] as string[]; + } + + return Array.from(new Set(value.map((item) => normalizeText(item)).filter(Boolean))); +} + +function normalizeDefaultContextRecord(value: unknown): ChatDefaultContextRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const record = value as Partial; + const title = normalizeText(record.title); + const content = normalizeText(record.content); + + if (!title && !content) { + return null; + } + + return { + id: normalizeText(record.id) || `chat-default-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + title: title || '기본 유형', + content, + enabled: record.enabled !== false, + updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), + }; +} + +function sanitizeDefaultContexts(items: unknown) { + const byId = new Map(); + const sourceItems = Array.isArray(items) ? items : []; + + [...sourceItems, ...DEFAULT_CHAT_DEFAULT_CONTEXTS] + .map((item) => normalizeDefaultContextRecord(item)) + .filter((item): item is ChatDefaultContextRecord => Boolean(item)) + .forEach((item) => { + const current = byId.get(item.id); + + if (!current || compareUpdatedAt(current, item) <= 0) { + byId.set(item.id, item); + } + }); + + return Array.from(byId.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR')); +} + +function sanitizeChatTypeDefaultSelections(items: unknown) { + const byChatTypeId = new Map(); + const sourceItems = Array.isArray(items) ? items : []; + + sourceItems.forEach((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return; + } + + const record = item as Partial; + const chatTypeId = normalizeText(record.chatTypeId); + + if (!chatTypeId) { + return; + } + + const nextRecord: ChatTypeDefaultContextSelection = { + chatTypeId, + defaultContextIds: normalizeDefaultContextIds(record.defaultContextIds), + updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), + }; + const current = byChatTypeId.get(chatTypeId); + + if (!current || compareUpdatedAt(current, nextRecord) <= 0) { + byChatTypeId.set(chatTypeId, nextRecord); + } + }); + + return Array.from(byChatTypeId.values()).sort((left, right) => left.chatTypeId.localeCompare(right.chatTypeId, 'ko-KR')); +} + +function sanitizeRoomContexts(items: unknown) { + const bySessionId = new Map(); + const sourceItems = Array.isArray(items) ? items : []; + + sourceItems.forEach((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return; + } + + const record = item as Partial; + const sessionId = normalizeText(record.sessionId); + + if (!sessionId) { + return; + } + + const nextRecord: ChatRoomContextSettings = { + sessionId, + defaultContextIds: normalizeDefaultContextIds(record.defaultContextIds), + customContextTitle: normalizeText(record.customContextTitle), + customContextContent: normalizeText(record.customContextContent), + updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), + }; + const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent); + const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0; + + if (!hasCustomContext && !hasDefaultOverrides) { + return; + } + + const current = bySessionId.get(sessionId); + + if (!current || compareUpdatedAt(current, nextRecord) <= 0) { + bySessionId.set(sessionId, nextRecord); + } + }); + + return Array.from(bySessionId.values()).sort((left, right) => left.sessionId.localeCompare(right.sessionId, 'ko-KR')); +} + +function sanitizeChatContextSettings(value: unknown): ChatContextSettingsSnapshot { + const record = normalizeConfigRecord(value); + + return { + defaultContexts: sanitizeDefaultContexts(record.defaultContexts), + chatTypeDefaults: sanitizeChatTypeDefaultSelections(record.chatTypeDefaults), + roomContexts: sanitizeRoomContexts(record.roomContexts), + }; +} + function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; @@ -170,7 +420,7 @@ function buildChatTypeSemanticKey(record: Pick) { return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); } -function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) { +function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: string }) { const leftTime = Date.parse(left.updatedAt); const rightTime = Date.parse(right.updatedAt); @@ -250,6 +500,7 @@ export type AppConfigSnapshot = { codexLiveMaxExecutionSeconds?: number; codexLiveIdleTimeoutSeconds?: number; receiveRoomNotifications?: boolean; + restartReservationCompletionDelaySeconds?: number; }; automation?: { autoRefreshEnabled?: boolean; @@ -316,6 +567,12 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot { typeof chat.receiveRoomNotifications === 'boolean' ? chat.receiveRoomNotifications : DEFAULT_CHAT_APP_CONFIG.receiveRoomNotifications, + restartReservationCompletionDelaySeconds: normalizeIntegerInRange( + chat.restartReservationCompletionDelaySeconds, + DEFAULT_CHAT_APP_CONFIG.restartReservationCompletionDelaySeconds, + 1, + 300, + ), }, worklogAutomation: Object.keys(worklogAutomation).length > 0 @@ -327,30 +584,37 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot { }; } -export async function getAppConfigSnapshot(): Promise { - return normalizeAppConfigSnapshot(await getAppConfig()); +export async function getAppConfigSnapshot(appOrigin?: string | null): Promise { + return normalizeAppConfigSnapshot(await getAppConfig(appOrigin)); } -export async function upsertAppConfig(config: Record) { +export async function upsertAppConfig( + config: Record, + appOrigin?: string | null, + appDomain?: string | null, +) { await ensureAppConfigTable(); const nextConfig = normalizeConfigRecord(config); const existing = await db(APP_CONFIG_TABLE).first(); if (!existing) { + const initialConfig = mergeScopedAppConfig({}, nextConfig, appOrigin, appDomain); const rows = await db(APP_CONFIG_TABLE) .insert({ - config_json: nextConfig, + config_json: initialConfig, updated_at: db.fn.now(), }) .returning('*'); - return rows[0]?.config_json ?? nextConfig; + return resolveAppConfigByOrigin(rows[0]?.config_json ?? initialConfig, appOrigin); } - const mergedConfig = { - ...normalizeConfigRecord(existing.config_json), - ...nextConfig, - }; + const mergedConfig = mergeScopedAppConfig( + normalizeConfigRecord(existing.config_json), + nextConfig, + appOrigin, + appDomain, + ); const rows = await db(APP_CONFIG_TABLE) .update({ @@ -359,11 +623,11 @@ export async function upsertAppConfig(config: Record) { }) .returning('*'); - return rows[0]?.config_json ?? mergedConfig; + return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin); } -export async function getChatTypesConfig() { - const config = await getAppConfig(); +export async function getChatTypesConfig(appOrigin?: string | null) { + const config = await getAppConfig(appOrigin); const normalized = normalizeConfigRecord(config); const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY]; if (chatTypes == null) { @@ -376,20 +640,42 @@ export async function getChatTypesConfig() { if (!isSameChatTypeList(savedChatTypes, mergedChatTypes)) { await upsertAppConfig({ [CHAT_TYPES_CONFIG_KEY]: mergedChatTypes, - }); + }, appOrigin); } return mergedChatTypes; } -export async function upsertChatTypesConfig(chatTypes: unknown[]) { - const current = normalizeConfigRecord(await getAppConfig()); +export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) { + const current = normalizeConfigRecord(await getAppConfig(appOrigin)); const resolvedChatTypes = mergeDefaultChatTypes(chatTypes); const nextConfig = { ...current, [CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes, }; - await upsertAppConfig(nextConfig); + await upsertAppConfig(nextConfig, appOrigin, appDomain); return resolvedChatTypes; } + +export async function getChatContextSettingsConfig(appOrigin?: string | null) { + const config = await getAppConfig(appOrigin); + const normalized = normalizeConfigRecord(config); + return sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]); +} + +export async function upsertChatContextSettingsConfig( + settings: unknown, + appOrigin?: string | null, + appDomain?: string | null, +) { + const current = normalizeConfigRecord(await getAppConfig(appOrigin)); + const nextSettings = sanitizeChatContextSettings(settings); + const nextConfig = { + ...current, + [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: nextSettings, + }; + + await upsertAppConfig(nextConfig, appOrigin, appDomain); + return nextSettings; +} diff --git a/etc/servers/work-server/src/services/automation-context-config-service.js b/etc/servers/work-server/src/services/automation-context-config-service.js new file mode 100644 index 0000000..930677a --- /dev/null +++ b/etc/servers/work-server/src/services/automation-context-config-service.js @@ -0,0 +1,437 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_AUTOMATION_CONTEXTS = void 0; +exports.sanitizeAutomationContexts = sanitizeAutomationContexts; +exports.getAutomationContextsConfig = getAutomationContextsConfig; +exports.upsertAutomationContextsConfig = upsertAutomationContextsConfig; +exports.normalizeAutomationContextSelection = normalizeAutomationContextSelection; +exports.resolveAutomationContexts = resolveAutomationContexts; +var client_js_1 = require("../db/client.js"); +var AUTOMATION_CONTEXTS_TABLE = 'automation_contexts'; +exports.DEFAULT_AUTOMATION_CONTEXTS = [ + { + id: 'general-inquiry-default', + title: '기본 확인', + content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + { + id: 'none-default', + title: '기본 처리', + content: '## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + { + id: 'plan-default', + title: '문서형 처리', + content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.', + enabled: true, + defaultSelected: false, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + { + id: 'command-execution-default', + title: '명령 실행', + content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.', + enabled: true, + defaultSelected: false, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + { + id: 'non-source-work-default', + title: '비소스 작업', + content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.', + enabled: true, + defaultSelected: false, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + { + id: 'auto-worker-default', + title: '자동화 기본 규칙', + content: '## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, +]; +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : ''; +} +function normalizeEnabled(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + if (typeof value === 'string') { + var normalizedValue = value.trim().toLowerCase(); + if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) { + return false; + } + if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) { + return true; + } + } + return value !== false; +} +function buildContextTitleKey(value) { + return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); +} +function compareContextUpdatedAt(left, right) { + var leftTime = Date.parse(left.updatedAt); + var rightTime = Date.parse(right.updatedAt); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return 0; +} +function normalizeAutomationContext(record) { + var title = normalizeText(record.title); + var content = normalizeText(record.content); + if (!title && !content) { + return null; + } + var rawId = normalizeText(record.id); + var normalizedId = rawId || "automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)); + return { + id: normalizedId, + title: title || 'Context', + content: content, + enabled: normalizeEnabled(record.enabled), + defaultSelected: normalizeEnabled(record.defaultSelected), + updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), + }; +} +function sanitizeAutomationContexts(items) { + var byId = new Map(); + var bySemanticKey = new Map(); + (items !== null && items !== void 0 ? items : []) + .map(function (item) { return normalizeAutomationContext(item); }) + .filter(function (item) { return Boolean(item); }) + .forEach(function (item) { + var currentById = byId.get(item.id); + if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) { + byId.set(item.id, item); + } + }); + for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) { + var item = _a[_i]; + var semanticKey = buildContextTitleKey(item.title); + var current = bySemanticKey.get(semanticKey); + if (!current || compareContextUpdatedAt(current, item) <= 0) { + bySemanticKey.set(semanticKey, item); + } + } + var values = Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); }); + return values.length > 0 ? values : exports.DEFAULT_AUTOMATION_CONTEXTS; +} +function ensureAutomationContextsTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, function (table) { + table.string('id').primary(); + table.string('title').notNullable(); + table.text('content').notNullable().defaultTo(''); + table.boolean('enabled').notNullable().defaultTo(true); + table.boolean('default_selected').notNullable().defaultTo(false); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['title', function (table) { return table.string('title').notNullable().defaultTo(''); }], + ['content', function (table) { return table.text('content').notNullable().defaultTo(''); }], + ['enabled', function (table) { return table.boolean('enabled').notNullable().defaultTo(true); }], + ['default_selected', function (table) { return table.boolean('default_selected').notNullable().defaultTo(false); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function parseContextsFromLegacyValue(value) { + if (typeof value !== 'string') { + return []; + } + try { + var parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } + catch (_a) { + return []; + } +} +function toAutomationContextRecord(row) { + return normalizeAutomationContext({ + id: typeof row.id === 'string' ? row.id : undefined, + title: typeof row.title === 'string' ? row.title : undefined, + content: typeof row.content === 'string' ? row.content : undefined, + enabled: normalizeEnabled(row.enabled), + defaultSelected: normalizeEnabled(row.default_selected), + updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined, + }); +} +function replaceAutomationContextsInTable(items) { + return __awaiter(this, void 0, void 0, function () { + var nextItems; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureAutomationContextsTable()]; + case 1: + _a.sent(); + nextItems = sanitizeAutomationContexts(items); + return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(AUTOMATION_CONTEXTS_TABLE).del()]; + case 1: + _a.sent(); + return [4 /*yield*/, trx(AUTOMATION_CONTEXTS_TABLE).insert(nextItems.map(function (item) { return ({ + id: item.id, + title: item.title, + content: item.content, + enabled: item.enabled, + default_selected: item.defaultSelected, + updated_at: item.updatedAt, + }); }))]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); })]; + case 2: + _a.sent(); + return [2 /*return*/, nextItems]; + } + }); + }); +} +function seedAutomationContextsFromLegacySources() { + return __awaiter(this, void 0, void 0, function () { + var seededItems, hasAutomationTypesTable, rows, _i, rows_1, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + seededItems = __spreadArray([], exports.DEFAULT_AUTOMATION_CONTEXTS, true); + return [4 /*yield*/, client_js_1.db.schema.hasTable('automation_types')]; + case 1: + hasAutomationTypesTable = _a.sent(); + if (!hasAutomationTypesTable) return [3 /*break*/, 3]; + return [4 /*yield*/, (0, client_js_1.db)('automation_types').select('contexts_json')]; + case 2: + rows = _a.sent(); + for (_i = 0, rows_1 = rows; _i < rows_1.length; _i++) { + row = rows_1[_i]; + seededItems.push.apply(seededItems, parseContextsFromLegacyValue(row.contexts_json)); + } + _a.label = 3; + case 3: return [2 /*return*/, replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems))]; + } + }); + }); +} +function isSameAutomationContextList(left, right) { + if (left.length !== right.length) { + return false; + } + return left.every(function (item, index) { + var target = right[index]; + return (target && + item.id === target.id && + item.title === target.title && + item.content === target.content && + item.enabled === target.enabled && + item.defaultSelected === target.defaultSelected && + item.updatedAt === target.updatedAt); + }); +} +function mergeDefaultAutomationContexts(items) { + var byId = new Map(items.map(function (item) { return [item.id, item]; })); + for (var _i = 0, DEFAULT_AUTOMATION_CONTEXTS_1 = exports.DEFAULT_AUTOMATION_CONTEXTS; _i < DEFAULT_AUTOMATION_CONTEXTS_1.length; _i++) { + var defaultItem = DEFAULT_AUTOMATION_CONTEXTS_1[_i]; + var existingItem = byId.get(defaultItem.id); + if (!existingItem) { + byId.set(defaultItem.id, defaultItem); + continue; + } + byId.set(defaultItem.id, __assign(__assign({}, existingItem), { title: defaultItem.title, content: existingItem.content || defaultItem.content })); + } + return sanitizeAutomationContexts(Array.from(byId.values())); +} +function readAutomationContextsFromTable() { + return __awaiter(this, void 0, void 0, function () { + var rows, savedItems; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureAutomationContextsTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(AUTOMATION_CONTEXTS_TABLE) + .select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at') + .orderBy('title', 'asc')]; + case 2: + rows = _a.sent(); + savedItems = rows + .map(function (row) { return toAutomationContextRecord(row); }) + .filter(function (item) { return Boolean(item); }); + return [2 /*return*/, sanitizeAutomationContexts(savedItems)]; + } + }); + }); +} +function getAutomationContextsConfig() { + return __awaiter(this, void 0, void 0, function () { + var savedContexts, mergedContexts; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, readAutomationContextsFromTable()]; + case 1: + savedContexts = _a.sent(); + if (savedContexts.length === 0 || savedContexts === exports.DEFAULT_AUTOMATION_CONTEXTS) { + return [2 /*return*/, seedAutomationContextsFromLegacySources()]; + } + mergedContexts = mergeDefaultAutomationContexts(savedContexts); + if (!!isSameAutomationContextList(savedContexts, mergedContexts)) return [3 /*break*/, 3]; + return [4 /*yield*/, replaceAutomationContextsInTable(mergedContexts)]; + case 2: + _a.sent(); + _a.label = 3; + case 3: return [2 /*return*/, mergedContexts]; + } + }); + }); +} +function upsertAutomationContextsConfig(items) { + return __awaiter(this, void 0, void 0, function () { + var nextContexts; + return __generator(this, function (_a) { + nextContexts = mergeDefaultAutomationContexts(sanitizeAutomationContexts(Array.isArray(items) ? items : [])); + return [2 /*return*/, replaceAutomationContextsInTable(nextContexts)]; + }); + }); +} +function normalizeAutomationContextSelection(value) { + var rawValues = Array.isArray(value) + ? value + : typeof value === 'string' + ? value + .split(',') + .map(function (item) { return item.trim(); }) + .filter(Boolean) + : []; + return __spreadArray([], new Set(rawValues.map(function (item) { return normalizeText(String(item)); }).filter(Boolean)), true); +} +function resolveAutomationContexts(contexts, selectedContextIds) { + var normalizedContexts = sanitizeAutomationContexts(contexts); + var requestedIds = normalizeAutomationContextSelection(selectedContextIds); + if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) { + return []; + } + if (requestedIds.length === 0) { + return normalizedContexts.filter(function (item) { return item.enabled && item.defaultSelected; }); + } + var requestedIdSet = new Set(requestedIds); + return normalizedContexts.filter(function (item) { return requestedIdSet.has(item.id); }); +} diff --git a/etc/servers/work-server/src/services/automation-context-service.js b/etc/servers/work-server/src/services/automation-context-service.js new file mode 100644 index 0000000..4861b58 --- /dev/null +++ b/etc/servers/work-server/src/services/automation-context-service.js @@ -0,0 +1,235 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.stringifyAutomationContextIds = stringifyAutomationContextIds; +exports.parseAutomationContextIds = parseAutomationContextIds; +exports.buildAutomationContextMarkdown = buildAutomationContextMarkdown; +exports.buildAutomationNoteSections = buildAutomationNoteSections; +exports.ensureSchedulePromptSnapshot = ensureSchedulePromptSnapshot; +var node_path_1 = require("node:path"); +var promises_1 = require("node:fs/promises"); +var env_js_1 = require("../config/env.js"); +var automation_context_config_service_js_1 = require("./automation-context-config-service.js"); +function stringifyAutomationContextIds(value) { + return JSON.stringify((0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(value)); +} +function parseAutomationContextIds(value) { + if (Array.isArray(value)) { + return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(value); + } + if (typeof value !== 'string') { + return []; + } + var trimmed = value.trim(); + if (!trimmed) { + return []; + } + try { + var parsed = JSON.parse(trimmed); + return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(parsed); + } + catch (_a) { + return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(trimmed); + } +} +function buildAutomationContextMarkdown(contexts, selectedContextIds) { + var resolvedContexts = (0, automation_context_config_service_js_1.resolveAutomationContexts)(contexts, selectedContextIds); + if (resolvedContexts.length === 0) { + return '선택된 자동화 Context 없음'; + } + return resolvedContexts + .map(function (item) { return ["### ".concat(item.title), item.content.trim() || '(내용 없음)'].join('\n'); }) + .join('\n\n'); +} +function buildAutomationNoteSections(options) { + return __awaiter(this, void 0, void 0, function () { + var availableContexts, _a, lines; + var _b, _c, _d, _e, _f, _g; + return __generator(this, function (_h) { + switch (_h.label) { + case 0: + if (!((_b = options.availableContexts) !== null && _b !== void 0)) return [3 /*break*/, 1]; + _a = _b; + return [3 /*break*/, 3]; + case 1: return [4 /*yield*/, (0, automation_context_config_service_js_1.getAutomationContextsConfig)()]; + case 2: + _a = (_h.sent()); + _h.label = 3; + case 3: + availableContexts = _a; + lines = __spreadArray(__spreadArray(__spreadArray([ + '# 자동화 작업메모', + '', + ((_c = options.title) === null || _c === void 0 ? void 0 : _c.trim()) ? "- \uAC8C\uC2DC\uD310 \uC81C\uBAA9: ".concat(options.title.trim()) : null, + "- \uBA54\uBAA8 \uCD9C\uCC98: ".concat(options.sourceLabel), + ((_e = (_d = options.automationType) === null || _d === void 0 ? void 0 : _d.name) === null || _e === void 0 ? void 0 : _e.trim()) ? "- \uC120\uD0DD \uC790\uB3D9\uD654 \uC720\uD615: ".concat(options.automationType.name.trim()) : null, + '- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.', + '', + '## 자동화 Context', + buildAutomationContextMarkdown(availableContexts, options.selectedContextIds), + '' + ], ((_f = options.extraSections) !== null && _f !== void 0 ? _f : []), true), [ + '## 요청 본문', + options.requestContent.trim() + ], false), (((_g = options.attachments) === null || _g === void 0 ? void 0 : _g.length) ? __spreadArray(['', '## 첨부 파일'], options.attachments, true) : []), true); + return [2 /*return*/, lines.filter(function (line) { return line !== null && line !== undefined; }).join('\n')]; + } + }); + }); +} +function extractRequestedPaths(note) { + var _a; + var matches = (_a = note.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g)) !== null && _a !== void 0 ? _a : []; + return __spreadArray([], new Set(matches.map(function (item) { return item.replace(/\\/g, '/'); })), true); +} +function tryReadFile(filePath) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _b.trys.push([0, 2, , 3]); + return [4 /*yield*/, (0, promises_1.readFile)(filePath, 'utf8')]; + case 1: return [2 /*return*/, _b.sent()]; + case 2: + _a = _b.sent(); + return [2 /*return*/, null]; + case 3: return [2 /*return*/]; + } + }); + }); +} +function limitText(value, maxChars) { + if (maxChars === void 0) { maxChars = 12000; } + var normalized = value.trim(); + return normalized.length <= maxChars ? normalized : "".concat(normalized.slice(0, maxChars).trimEnd(), "\n\n..."); +} +function getScheduleRepoRoot() { + var env = (0, env_js_1.getEnv)(); + return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd(); +} +function ensureSchedulePromptSnapshot(options) { + return __awaiter(this, void 0, void 0, function () { + var repoRoot, scheduleDir, requestPath, contextPath, manifestPath, requestedPaths, candidatePaths, uniqueRelativePaths, references, _i, uniqueRelativePaths_1, relativePath, absolutePath, content, requestMarkdown, contextMarkdown, relativeDir; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + repoRoot = getScheduleRepoRoot(); + scheduleDir = node_path_1.default.join(repoRoot, '.auto_codex', 'schedule', String(options.scheduleId)); + return [4 /*yield*/, (0, promises_1.mkdir)(scheduleDir, { recursive: true })]; + case 1: + _a.sent(); + requestPath = node_path_1.default.join(scheduleDir, 'request.md'); + contextPath = node_path_1.default.join(scheduleDir, 'context.md'); + manifestPath = node_path_1.default.join(scheduleDir, 'manifest.json'); + requestedPaths = extractRequestedPaths(options.note); + candidatePaths = __spreadArray([ + 'AGENTS.md', + 'docs/README.md' + ], requestedPaths.filter(function (item) { return !item.startsWith('http://') && !item.startsWith('https://'); }), true); + uniqueRelativePaths = __spreadArray([], new Set(candidatePaths), true); + references = []; + _i = 0, uniqueRelativePaths_1 = uniqueRelativePaths; + _a.label = 2; + case 2: + if (!(_i < uniqueRelativePaths_1.length)) return [3 /*break*/, 5]; + relativePath = uniqueRelativePaths_1[_i]; + absolutePath = node_path_1.default.resolve(repoRoot, relativePath); + return [4 /*yield*/, tryReadFile(absolutePath)]; + case 3: + content = _a.sent(); + if (!content) { + return [3 /*break*/, 4]; + } + references.push("## ".concat(relativePath, "\n\n```\n").concat(limitText(content), "\n```")); + _a.label = 4; + case 4: + _i++; + return [3 /*break*/, 2]; + case 5: + requestMarkdown = [ + '# 스케줄 요청 원문', + '', + "- \uC2A4\uCF00\uC904 ID: ".concat(options.scheduleId), + "- \uC791\uC5C5 ID: ".concat(options.workId), + '', + '## 원본 메모', + options.note.trim() || '(비어 있음)', + ].join('\n'); + contextMarkdown = __spreadArray([ + '# 스케줄 전용 참조', + '', + '- 최초 활성화 시점에 읽은 요청/문서/소스 일부를 이 디렉터리 아래로 정리했습니다.', + '- 이후 자동화 실행은 우선 이 디렉터리의 Markdown 문서를 참조하고, 원본 소스 재탐색은 꼭 필요할 때만 제한적으로 수행합니다.', + '' + ], (references.length > 0 ? references : ['## 참조 문서', '별도로 추출된 문서가 없습니다. request.md를 우선 참조합니다.']), true).join('\n\n'); + return [4 /*yield*/, (0, promises_1.writeFile)(requestPath, "".concat(requestMarkdown, "\n"), 'utf8')]; + case 6: + _a.sent(); + return [4 /*yield*/, (0, promises_1.writeFile)(contextPath, "".concat(contextMarkdown, "\n"), 'utf8')]; + case 7: + _a.sent(); + return [4 /*yield*/, (0, promises_1.writeFile)(manifestPath, JSON.stringify({ + scheduleId: options.scheduleId, + workId: options.workId, + refreshedAt: new Date().toISOString(), + forceRefresh: Boolean(options.forceRefresh), + sourcePaths: uniqueRelativePaths, + }, null, 2), 'utf8')]; + case 8: + _a.sent(); + relativeDir = node_path_1.default.relative(repoRoot, scheduleDir).replace(/\\/g, '/'); + return [2 /*return*/, { + directory: relativeDir, + requestPath: "".concat(relativeDir, "/request.md"), + contextPath: "".concat(relativeDir, "/context.md"), + manifestPath: "".concat(relativeDir, "/manifest.json"), + }]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/automation-type-config-service.js b/etc/servers/work-server/src/services/automation-type-config-service.js new file mode 100644 index 0000000..eb85727 --- /dev/null +++ b/etc/servers/work-server/src/services/automation-type-config-service.js @@ -0,0 +1,635 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_AUTOMATION_TYPES = exports.AUTOMATION_BEHAVIOR_TYPES = void 0; +exports.normalizeLegacyAutomationBehaviorType = normalizeLegacyAutomationBehaviorType; +exports.sanitizeAutomationContexts = sanitizeAutomationContexts; +exports.sanitizeAutomationTypes = sanitizeAutomationTypes; +exports.getAutomationTypesConfig = getAutomationTypesConfig; +exports.upsertAutomationTypesConfig = upsertAutomationTypesConfig; +exports.resolveAutomationType = resolveAutomationType; +exports.resolveStoredAutomationTypeId = resolveStoredAutomationTypeId; +exports.normalizeAutomationContextSelection = normalizeAutomationContextSelection; +exports.resolveAutomationTypeContexts = resolveAutomationTypeContexts; +var client_js_1 = require("../db/client.js"); +var app_config_service_js_1 = require("./app-config-service.js"); +var AUTOMATION_TYPES_TABLE = 'automation_types'; +var AUTOMATION_TYPES_CONFIG_KEY = 'automationTypes'; +exports.AUTOMATION_BEHAVIOR_TYPES = [ + 'none', + 'plan', + 'command_execution', + 'non_source_work', + 'auto_worker', +]; +exports.DEFAULT_AUTOMATION_TYPES = [ + { + id: 'general-inquiry', + name: '일반 문의', + description: '일반 문의/확인 요청으로 처리합니다.', + contexts: [ + { + id: 'general-inquiry-default', + title: '기본 확인', + content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + ], + behaviorType: 'command_execution', + enabled: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + { + id: 'none', + name: '기본유형', + description: '기본 자동화 처리용 유형입니다.', + contexts: [ + { + id: 'none-default', + title: '기본 처리', + content: '## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + ], + behaviorType: 'none', + enabled: true, + updatedAt: '2026-04-23T00:00:00.000Z', + }, + { + id: 'plan', + name: '작업 요청 등록', + description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.', + contexts: [ + { + id: 'plan-default', + title: '문서형 처리', + content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + ], + behaviorType: 'plan', + enabled: true, + updatedAt: '2026-04-23T00:00:00.000Z', + }, + { + id: 'command_execution', + name: 'Command 실행', + description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.', + contexts: [ + { + id: 'command-execution-default', + title: '명령 실행', + content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + ], + behaviorType: 'command_execution', + enabled: true, + updatedAt: '2026-04-23T00:00:00.000Z', + }, + { + id: 'non_source_work', + name: '비 소스작업', + description: '문서/운영/확인 등 비소스 작업으로 처리합니다.', + contexts: [ + { + id: 'non-source-work-default', + title: '비소스 작업', + content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + ], + behaviorType: 'non_source_work', + enabled: true, + updatedAt: '2026-04-23T00:00:00.000Z', + }, + { + id: 'auto_worker', + name: 'autoWorker', + description: '자동화 작업메모로 처리합니다.', + contexts: [ + { + id: 'auto-worker-default', + title: '자동화 기본 규칙', + content: '## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.', + enabled: true, + defaultSelected: true, + updatedAt: '2026-04-29T00:00:00.000Z', + }, + ], + behaviorType: 'auto_worker', + enabled: true, + updatedAt: '2026-04-23T00:00:00.000Z', + }, +]; +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : ''; +} +function normalizeEnabled(value) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + if (typeof value === 'string') { + var normalizedValue = value.trim().toLowerCase(); + if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) { + return false; + } + if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) { + return true; + } + } + return value !== false; +} +function normalizeLegacyAutomationTypeId(value) { + var normalizedValue = normalizeLegacyAutomationBehaviorType(value); + if (normalizedValue === 'stock-alert') { + return 'general-inquiry'; + } + return normalizedValue; +} +function normalizeBehaviorType(value) { + var normalizedValue = normalizeLegacyAutomationBehaviorType(value); + return exports.AUTOMATION_BEHAVIOR_TYPES.includes(normalizedValue) + ? normalizedValue + : 'none'; +} +function normalizeLegacyAutomationBehaviorType(value) { + var normalizedValue = normalizeText(value); + if (normalizedValue === 'plan_registration') { + return 'plan'; + } + if (normalizedValue === 'general_development') { + return 'auto_worker'; + } + return normalizedValue; +} +function buildNameKey(value) { + return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); +} +function buildContextTitleKey(value) { + return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); +} +function normalizeAutomationContext(record) { + var title = normalizeText(record.title); + var content = normalizeText(record.content); + if (!title && !content) { + return null; + } + var rawId = normalizeText(record.id); + var normalizedId = rawId || "automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)); + return { + id: normalizedId, + title: title || 'Context', + content: content, + enabled: normalizeEnabled(record.enabled), + defaultSelected: normalizeEnabled(record.defaultSelected), + updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), + }; +} +function compareContextUpdatedAt(left, right) { + var leftTime = Date.parse(left.updatedAt); + var rightTime = Date.parse(right.updatedAt); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return 0; +} +function sanitizeAutomationContexts(items) { + var byId = new Map(); + var bySemanticKey = new Map(); + (items !== null && items !== void 0 ? items : []) + .map(function (item) { return normalizeAutomationContext(item); }) + .filter(function (item) { return Boolean(item); }) + .forEach(function (item) { + var currentById = byId.get(item.id); + if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) { + byId.set(item.id, item); + } + }); + for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) { + var item = _a[_i]; + var semanticKey = buildContextTitleKey(item.title); + var current = bySemanticKey.get(semanticKey); + if (!current || compareContextUpdatedAt(current, item) <= 0) { + bySemanticKey.set(semanticKey, item); + } + } + return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); }); +} +function normalizeAutomationType(record) { + var name = normalizeText(record.name); + if (!name) { + return null; + } + var rawId = normalizeText(record.id); + var normalizedId = rawId || "automation-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)); + return { + id: normalizedId, + name: name, + description: normalizeText(record.description), + contexts: sanitizeAutomationContexts(record.contexts), + behaviorType: normalizeBehaviorType(record.behaviorType), + enabled: normalizeEnabled(record.enabled), + updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), + }; +} +function compareUpdatedAt(left, right) { + var leftTime = Date.parse(left.updatedAt); + var rightTime = Date.parse(right.updatedAt); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return 0; +} +function dedupeAutomationTypes(items) { + var byId = new Map(); + var bySemanticKey = new Map(); + for (var _i = 0, items_1 = items; _i < items_1.length; _i++) { + var item = items_1[_i]; + var currentById = byId.get(item.id); + if (!currentById || compareUpdatedAt(currentById, item) <= 0) { + byId.set(item.id, item); + } + } + for (var _a = 0, _b = byId.values(); _a < _b.length; _a++) { + var item = _b[_a]; + var semanticKey = "".concat(item.behaviorType, ":").concat(buildNameKey(item.name)); + var current = bySemanticKey.get(semanticKey); + if (!current || compareUpdatedAt(current, item) <= 0) { + bySemanticKey.set(semanticKey, item); + } + } + return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.name.localeCompare(right.name, 'ko-KR'); }); +} +function sanitizeAutomationTypes(items) { + var normalized = (items !== null && items !== void 0 ? items : []) + .map(function (item) { return normalizeAutomationType(item); }) + .filter(function (item) { return Boolean(item); }); + if (normalized.length === 0) { + return exports.DEFAULT_AUTOMATION_TYPES; + } + return dedupeAutomationTypes(normalized); +} +function mergeDefaultAutomationTypes(items) { + var _a; + var byId = new Map(items.map(function (item) { return [item.id, item]; })); + for (var _i = 0, DEFAULT_AUTOMATION_TYPES_1 = exports.DEFAULT_AUTOMATION_TYPES; _i < DEFAULT_AUTOMATION_TYPES_1.length; _i++) { + var defaultItem = DEFAULT_AUTOMATION_TYPES_1[_i]; + var existingItem = byId.get(defaultItem.id); + if (!existingItem) { + byId.set(defaultItem.id, defaultItem); + continue; + } + byId.set(defaultItem.id, __assign(__assign({}, existingItem), { name: defaultItem.name, description: existingItem.description || defaultItem.description, contexts: sanitizeAutomationContexts(((_a = existingItem.contexts) === null || _a === void 0 ? void 0 : _a.length) ? existingItem.contexts : defaultItem.contexts), behaviorType: defaultItem.behaviorType })); + } + return sanitizeAutomationTypes(Array.from(byId.values())); +} +function isSameAutomationTypeList(left, right) { + if (left.length !== right.length) { + return false; + } + return left.every(function (item, index) { + var target = right[index]; + return (target && + item.id === target.id && + item.name === target.name && + item.description === target.description && + JSON.stringify(item.contexts) === JSON.stringify(target.contexts) && + item.behaviorType === target.behaviorType && + item.enabled === target.enabled && + item.updatedAt === target.updatedAt); + }); +} +function ensureAutomationTypesTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(AUTOMATION_TYPES_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(AUTOMATION_TYPES_TABLE, function (table) { + table.string('id').primary(); + table.string('name').notNullable(); + table.text('description').notNullable().defaultTo(''); + table.text('contexts_json').notNullable().defaultTo('[]'); + table.string('behavior_type').notNullable(); + table.boolean('enabled').notNullable().defaultTo(true); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['name', function (table) { return table.string('name').notNullable().defaultTo(''); }], + ['description', function (table) { return table.text('description').notNullable().defaultTo(''); }], + ['contexts_json', function (table) { return table.text('contexts_json').notNullable().defaultTo('[]'); }], + ['behavior_type', function (table) { return table.string('behavior_type').notNullable().defaultTo('none'); }], + ['enabled', function (table) { return table.boolean('enabled').notNullable().defaultTo(true); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(AUTOMATION_TYPES_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function normalizeConfigRecord(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value; +} +function parseContextsFromRow(row) { + var rawValue = row.contexts_json; + if (typeof rawValue !== 'string') { + return []; + } + try { + var parsed = JSON.parse(rawValue); + return Array.isArray(parsed) ? parsed : []; + } + catch (_a) { + return []; + } +} +function toAutomationTypeRecord(row) { + return normalizeAutomationType({ + id: typeof row.id === 'string' ? row.id : undefined, + name: typeof row.name === 'string' ? row.name : undefined, + description: typeof row.description === 'string' ? row.description : undefined, + contexts: parseContextsFromRow(row), + behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type), + enabled: normalizeEnabled(row.enabled), + updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined, + }); +} +function seedAutomationTypesFromLegacyConfig() { + return __awaiter(this, void 0, void 0, function () { + var config, _a, raw, legacyItems; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _a = normalizeConfigRecord; + return [4 /*yield*/, (0, app_config_service_js_1.getAppConfig)()]; + case 1: + config = _a.apply(void 0, [_b.sent()]); + raw = config[AUTOMATION_TYPES_CONFIG_KEY]; + if (!Array.isArray(raw) || raw.length === 0) { + return [2 /*return*/, mergeDefaultAutomationTypes(exports.DEFAULT_AUTOMATION_TYPES)]; + } + legacyItems = mergeDefaultAutomationTypes(sanitizeAutomationTypes(raw)); + return [4 /*yield*/, replaceAutomationTypesInTable(legacyItems)]; + case 2: + _b.sent(); + return [2 /*return*/, legacyItems]; + } + }); + }); +} +function readAutomationTypesFromTable() { + return __awaiter(this, void 0, void 0, function () { + var rows, savedItems; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureAutomationTypesTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(AUTOMATION_TYPES_TABLE) + .select('id', 'name', 'description', 'contexts_json', 'behavior_type', 'enabled', 'updated_at') + .orderBy('name', 'asc')]; + case 2: + rows = _a.sent(); + savedItems = rows + .map(function (row) { return toAutomationTypeRecord(row); }) + .filter(function (item) { return Boolean(item); }); + return [2 /*return*/, sanitizeAutomationTypes(savedItems)]; + } + }); + }); +} +function replaceAutomationTypesInTable(items) { + return __awaiter(this, void 0, void 0, function () { + var nextItems; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureAutomationTypesTable()]; + case 1: + _a.sent(); + nextItems = sanitizeAutomationTypes(items); + return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(AUTOMATION_TYPES_TABLE).del()]; + case 1: + _a.sent(); + return [4 /*yield*/, trx(AUTOMATION_TYPES_TABLE).insert(nextItems.map(function (item) { + var _a; + return ({ + id: item.id, + name: item.name, + description: item.description, + contexts_json: JSON.stringify((_a = item.contexts) !== null && _a !== void 0 ? _a : []), + behavior_type: item.behaviorType, + enabled: item.enabled, + updated_at: item.updatedAt, + }); + }))]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); })]; + case 2: + _a.sent(); + return [2 /*return*/, nextItems]; + } + }); + }); +} +function getAutomationTypesConfig() { + return __awaiter(this, void 0, void 0, function () { + var savedAutomationTypes, automationTypes; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, readAutomationTypesFromTable()]; + case 1: + savedAutomationTypes = _a.sent(); + automationTypes = mergeDefaultAutomationTypes(savedAutomationTypes); + if (automationTypes.length === 0 || automationTypes === exports.DEFAULT_AUTOMATION_TYPES) { + return [2 /*return*/, seedAutomationTypesFromLegacyConfig()]; + } + if (!!isSameAutomationTypeList(savedAutomationTypes, automationTypes)) return [3 /*break*/, 3]; + return [4 /*yield*/, replaceAutomationTypesInTable(automationTypes)]; + case 2: + _a.sent(); + _a.label = 3; + case 3: return [2 /*return*/, automationTypes]; + } + }); + }); +} +function upsertAutomationTypesConfig(items) { + return __awaiter(this, void 0, void 0, function () { + var nextAutomationTypes; + return __generator(this, function (_a) { + nextAutomationTypes = mergeDefaultAutomationTypes(sanitizeAutomationTypes(Array.isArray(items) ? items : [])); + return [2 /*return*/, replaceAutomationTypesInTable(nextAutomationTypes)]; + }); + }); +} +function resolveAutomationType(input) { + return __awaiter(this, void 0, void 0, function () { + var requestedId, automationTypes, matched, matchedByBehavior; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + requestedId = normalizeLegacyAutomationTypeId(input); + return [4 /*yield*/, getAutomationTypesConfig()]; + case 1: + automationTypes = _b.sent(); + matched = automationTypes.find(function (item) { return item.id === requestedId; }); + if (matched) { + return [2 /*return*/, matched]; + } + matchedByBehavior = automationTypes.find(function (item) { return item.behaviorType === normalizeBehaviorType(requestedId); }); + if (matchedByBehavior) { + return [2 /*return*/, matchedByBehavior]; + } + return [2 /*return*/, ((_a = exports.DEFAULT_AUTOMATION_TYPES.find(function (item) { return item.id === normalizeBehaviorType(requestedId); })) !== null && _a !== void 0 ? _a : exports.DEFAULT_AUTOMATION_TYPES[0])]; + } + }); + }); +} +function resolveStoredAutomationTypeId(row) { + var automationTypeId = normalizeText(row.automation_type_id); + if (automationTypeId) { + return normalizeLegacyAutomationTypeId(automationTypeId); + } + return normalizeLegacyAutomationTypeId(row.automation_type) || 'none'; +} +function normalizeAutomationContextSelection(value) { + var rawValues = Array.isArray(value) + ? value + : typeof value === 'string' + ? value + .split(',') + .map(function (item) { return item.trim(); }) + .filter(Boolean) + : []; + return __spreadArray([], new Set(rawValues.map(function (item) { return normalizeText(String(item)); }).filter(Boolean)), true); +} +function resolveAutomationTypeContexts(automationType, selectedContextIds) { + var contexts = sanitizeAutomationContexts(automationType === null || automationType === void 0 ? void 0 : automationType.contexts); + var requestedIds = normalizeAutomationContextSelection(selectedContextIds); + if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) { + return []; + } + if (requestedIds.length === 0) { + return contexts.filter(function (item) { return item.enabled && item.defaultSelected; }); + } + var requestedIdSet = new Set(requestedIds); + return contexts.filter(function (item) { return requestedIdSet.has(item.id); }); +} diff --git a/etc/servers/work-server/src/services/board-service.js b/etc/servers/work-server/src/services/board-service.js new file mode 100644 index 0000000..c58673e --- /dev/null +++ b/etc/servers/work-server/src/services/board-service.js @@ -0,0 +1,1261 @@ +"use strict"; +var __extends = (this && this.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + if (typeof b !== "function" && b !== null) + throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.BoardPostAutomationLockedError = exports.boardPostPayloadSchema = exports.boardPostRequestExecutionModeSchema = exports.boardPostRequestSchema = exports.BOARD_POST_REQUESTS_TABLE = exports.BOARD_POSTS_TABLE = void 0; +exports.buildBoardPostPlanNote = buildBoardPostPlanNote; +exports.resolveSequencedPlanWorkId = resolveSequencedPlanWorkId; +exports.ensureBoardPostsTable = ensureBoardPostsTable; +exports.listBoardPosts = listBoardPosts; +exports.getBoardPost = getBoardPost; +exports.createBoardPost = createBoardPost; +exports.receiveBoardPostAutomation = receiveBoardPostAutomation; +exports.progressBoardPostAutomationByPlanResult = progressBoardPostAutomationByPlanResult; +exports.updateBoardPost = updateBoardPost; +exports.deleteBoardPost = deleteBoardPost; +var zod_1 = require("zod"); +var client_js_1 = require("../db/client.js"); +var plan_service_js_1 = require("./plan-service.js"); +var automation_type_config_service_js_1 = require("./automation-type-config-service.js"); +var automation_context_service_js_1 = require("./automation-context-service.js"); +exports.BOARD_POSTS_TABLE = 'board_posts'; +exports.BOARD_POST_REQUESTS_TABLE = 'board_post_requests'; +var boardRequestExecutionModes = ['all_at_once', 'after_previous_finished', 'after_previous_success']; +var boardRequestWorkflowStates = ['pending', 'waiting', 'registered', 'completed', 'failed', 'blocked']; +var planFailureWorkerStatuses = new Set(['브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패', '작업취소']); +var planRunningStatuses = new Set(['등록', '작업중', '작업완료', '릴리즈완료']); +var planRunningWorkerStatuses = new Set([ + '대기', + '브랜치생성중', + '브랜치준비', + '자동작업중', + 'release반영대기', + 'release반영중', + 'release반영완료', + 'main반영대기', + 'main반영중', + 'main반영완료', +]); +exports.boardPostRequestSchema = zod_1.z.object({ + title: zod_1.z.string().trim().min(1).max(200), + content: zod_1.z.string().min(1).max(200000), +}); +exports.boardPostRequestExecutionModeSchema = zod_1.z.enum(boardRequestExecutionModes); +exports.boardPostPayloadSchema = zod_1.z.object({ + title: zod_1.z.string().trim().min(1).max(200), + content: zod_1.z.string().max(200000).default(''), + attachments: zod_1.z.array(zod_1.z.object({ + id: zod_1.z.string().trim().min(1).max(120), + name: zod_1.z.string().trim().min(1).max(255), + path: zod_1.z.string().trim().min(1).max(2000), + publicUrl: zod_1.z.string().trim().min(1).max(2000), + size: zod_1.z.coerce.number().int().min(0).max(10 * 1024 * 1024), + mimeType: zod_1.z.string().trim().min(1).max(200), + })).max(20).default([]), + automationType: zod_1.z.preprocess(function (value) { return value; }, plan_service_js_1.planAutomationTypeSchema.default('none')), + automationContextIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(20).optional().default([]), + requestExecutionMode: exports.boardPostRequestExecutionModeSchema.default('all_at_once'), + requestItems: zod_1.z.array(exports.boardPostRequestSchema).max(20).optional().default([]), +}); +var BoardPostAutomationLockedError = /** @class */ (function (_super) { + __extends(BoardPostAutomationLockedError, _super); + function BoardPostAutomationLockedError(action) { + var _this = _super.call(this, action === 'delete' + ? '자동화 접수된 작업메모는 삭제할 수 없습니다.' + : '자동화 접수된 작업메모는 수정할 수 없습니다.') || this; + _this.name = 'BoardPostAutomationLockedError'; + return _this; + } + return BoardPostAutomationLockedError; +}(Error)); +exports.BoardPostAutomationLockedError = BoardPostAutomationLockedError; +var MAX_SEQUENCED_PLAN_WORK_ID = 999; +var PLAN_WORK_ID_MAX_LENGTH = 120; +function createPreview(content) { + var normalized = content + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/[#>*_`~-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return normalized.length > 140 ? "".concat(normalized.slice(0, 137).trimEnd(), "...") : normalized; +} +function normalizeBoardPostRequestItems(requestItems, fallbackTitle, fallbackContent) { + if (requestItems.length > 0) { + return requestItems.map(function (item) { return ({ + title: item.title.trim(), + content: item.content.trim(), + }); }); + } + return [ + { + title: fallbackTitle.trim() || '요청 1', + content: fallbackContent.trim(), + }, + ]; +} +function resolveBoardRequestWorkflowState(value) { + return boardRequestWorkflowStates.includes(value) + ? value + : 'pending'; +} +function resolveBoardRequestExecutionMode(value) { + return boardRequestExecutionModes.includes(value) + ? value + : 'all_at_once'; +} +function resolveBoardRequestStatus(args) { + var workflowState = args.workflowState, planStatus = args.planStatus, workerStatus = args.workerStatus, lastError = args.lastError; + if (workflowState === 'blocked') { + return { status: 'blocked', statusLabel: '차단' }; + } + if (planStatus === '완료' || workflowState === 'completed') { + return { status: 'completed', statusLabel: '완료' }; + } + if (lastError || (workerStatus && planFailureWorkerStatuses.has(workerStatus)) || workflowState === 'failed') { + return { status: 'failed', statusLabel: '실패' }; + } + if ((planStatus && planRunningStatuses.has(planStatus)) + || (workerStatus && planRunningWorkerStatuses.has(workerStatus))) { + return { + status: planStatus === '등록' && workerStatus === '대기' ? 'queued' : 'in_progress', + statusLabel: planStatus === '등록' && workerStatus === '대기' ? '대기열' : '진행중', + }; + } + if (workflowState === 'waiting') { + return { status: 'waiting', statusLabel: '선행 대기' }; + } + if (workflowState === 'registered' || args.planStatus || args.workerStatus) { + return { status: 'queued', statusLabel: '대기열' }; + } + return { status: 'pending', statusLabel: '미접수' }; +} +function buildBoardAttachmentSection(attachments) { + var lines = attachments + .map(function (attachment) { + var label = attachment.name.trim() || attachment.path.trim().split('/').pop() || '첨부 파일'; + var path = attachment.path.trim(); + if (!path) { + return null; + } + return "- ".concat(label, ": ").concat(path); + }) + .filter(function (line) { return Boolean(line); }); + if (lines.length === 0) { + return []; + } + return __spreadArray(['## 첨부 파일'], lines, true); +} +function buildBoardPostPlanNote(title_1, content_1) { + return __awaiter(this, arguments, void 0, function (title, content, attachments, automationType, automationContextIds, availableContexts, requestItem, sequenceInfo) { + var note, commonContent, requestTitle, requestContent, lines, contextSection, contextBody, attachmentSection; + var _a; + if (attachments === void 0) { attachments = []; } + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, (0, automation_context_service_js_1.buildAutomationNoteSections)({ + title: title.trim(), + sourceLabel: 'board_posts 자동화 접수', + requestContent: requestItem ? requestItem.content.trim() : content.trim(), + attachments: attachments.length ? buildBoardAttachmentSection(attachments).slice(1) : [], + automationType: automationType, + availableContexts: availableContexts, + selectedContextIds: automationContextIds, + })]; + case 1: + note = _b.sent(); + if (!requestItem) { + return [2 /*return*/, note]; + } + commonContent = content.trim(); + requestTitle = requestItem.title.trim(); + requestContent = requestItem.content.trim(); + lines = [ + '# 자동화 작업메모', + '', + "- \uAC8C\uC2DC\uD310 \uC81C\uBAA9: ".concat(title.trim()), + '- 메모 출처: board_posts 자동화 접수', + "- \uD558\uC704 \uC694\uCCAD: ".concat(requestTitle), + ]; + if (sequenceInfo) { + lines.push("- \uC694\uCCAD \uC21C\uC11C: ".concat(sequenceInfo.index, "/").concat(sequenceInfo.total)); + } + if (automationType === null || automationType === void 0 ? void 0 : automationType.name) { + lines.push("- \uC120\uD0DD \uC790\uB3D9\uD654 \uC720\uD615: ".concat(automationType.name)); + } + lines.push('- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.', '', '## 자동화 Context'); + contextSection = (_a = note.split('## 자동화 Context\n')[1]) !== null && _a !== void 0 ? _a : '선택된 자동화 Context 없음\n\n## 요청 본문\n'; + contextBody = contextSection.split('\n\n## 요청 본문\n')[0]; + lines.push(contextBody.trim() || '선택된 자동화 Context 없음'); + if (commonContent) { + lines.push('', '## 공통 메모', commonContent); + } + lines.push('', '## 요청 본문', requestContent); + attachmentSection = buildBoardAttachmentSection(attachments); + if (attachmentSection.length > 0) { + lines.push.apply(lines, __spreadArray([''], attachmentSection, false)); + } + return [2 /*return*/, lines.join('\n')]; + } + }); + }); +} +function buildSequencedPlanWorkId(baseWorkId, sequence, suffixLabel) { + var normalizedBaseWorkId = (0, plan_service_js_1.normalizePlanWorkId)(baseWorkId); + var normalizedSuffixLabel = String(suffixLabel !== null && suffixLabel !== void 0 ? suffixLabel : '').trim().replace(/^-+|-+$/g, ''); + var suffix = normalizedSuffixLabel ? "-".concat(normalizedSuffixLabel, "-").concat(sequence) : "-".concat(sequence); + var maxBaseLength = Math.max(1, PLAN_WORK_ID_MAX_LENGTH - suffix.length); + var trimmedBaseWorkId = normalizedBaseWorkId.slice(0, maxBaseLength).trimEnd() || '작업ID'; + return "".concat(trimmedBaseWorkId).concat(suffix); +} +function resolveSequencedPlanWorkId(baseWorkId, existingWorkIds, suffixLabel) { + var existingWorkIdSet = new Set(Array.from(existingWorkIds, function (value) { return String(value !== null && value !== void 0 ? value : '').trim(); }).filter(Boolean)); + for (var sequence = 1; sequence <= MAX_SEQUENCED_PLAN_WORK_ID; sequence += 1) { + var candidate = buildSequencedPlanWorkId(baseWorkId, sequence, suffixLabel); + if (!existingWorkIdSet.has(candidate)) { + return candidate; + } + } + throw new Error("\uC790\uB3D9\uD654 \uC811\uC218 ID suffix\uB97C \uB354 \uC774\uC0C1 \uC0DD\uC131\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. (".concat(MAX_SEQUENCED_PLAN_WORK_ID, "\uAC1C \uCD08\uACFC)")); +} +function resolveInsertedId(result) { + if (typeof result === 'number' && Number.isInteger(result) && result > 0) { + return result; + } + if (Array.isArray(result)) { + var first = result[0]; + if (typeof first === 'number' && Number.isInteger(first) && first > 0) { + return first; + } + if (first && typeof first === 'object' && 'id' in first) { + var id = Number(first.id); + return Number.isInteger(id) && id > 0 ? id : null; + } + } + if (result && typeof result === 'object' && 'id' in result) { + var id = Number(result.id); + return Number.isInteger(id) && id > 0 ? id : null; + } + return null; +} +function supportsReturning() { + var _a; + var clientName = String((_a = client_js_1.db.client.config.client) !== null && _a !== void 0 ? _a : '').toLowerCase(); + return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName); +} +function isDuplicateSchemaError(error, codes, patterns) { + var candidate = error; + var code = typeof (candidate === null || candidate === void 0 ? void 0 : candidate.code) === 'string' ? candidate.code : ''; + var message = typeof (candidate === null || candidate === void 0 ? void 0 : candidate.message) === 'string' ? candidate.message : ''; + return codes.includes(code) || patterns.some(function (pattern) { return pattern.test(message); }); +} +function isDuplicateTableError(error) { + return isDuplicateSchemaError(error, ['42P07'], [/already exists/i]); +} +function isDuplicateColumnError(error) { + return isDuplicateSchemaError(error, ['42701'], [/already exists/i, /duplicate column/i]); +} +function ensureBoardPostRequestsTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, error_1, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.BOARD_POST_REQUESTS_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 5]; + _b.label = 2; + case 2: + _b.trys.push([2, 4, , 5]); + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.BOARD_POST_REQUESTS_TABLE, function (table) { + table.increments('id').primary(); + table.integer('board_post_id').notNullable().index(); + table.integer('sequence_no').notNullable().defaultTo(1); + table.string('title', 200).notNullable(); + table.text('content').notNullable(); + table.integer('plan_item_id').nullable().index(); + table.timestamp('automation_received_at', { useTz: true }).nullable(); + table.string('workflow_state', 40).notNullable().defaultTo('pending'); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 3: + _b.sent(); + return [3 /*break*/, 5]; + case 4: + error_1 = _b.sent(); + if (!isDuplicateTableError(error_1)) { + throw error_1; + } + return [3 /*break*/, 5]; + case 5: + requiredColumns = [ + ['board_post_id', function (table) { return table.integer('board_post_id').notNullable().index(); }], + ['sequence_no', function (table) { return table.integer('sequence_no').notNullable().defaultTo(1); }], + ['title', function (table) { return table.string('title', 200).notNullable().defaultTo('요청 1'); }], + ['content', function (table) { return table.text('content').notNullable().defaultTo(''); }], + ['plan_item_id', function (table) { return table.integer('plan_item_id').nullable().index(); }], + ['automation_received_at', function (table) { return table.timestamp('automation_received_at', { useTz: true }).nullable(); }], + ['workflow_state', function (table) { return table.string('workflow_state', 40).notNullable().defaultTo('pending'); }], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn, error_2; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.BOARD_POST_REQUESTS_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 5]; + _c.label = 2; + case 2: + _c.trys.push([2, 4, , 5]); + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.BOARD_POST_REQUESTS_TABLE, function (table) { + createColumn(table); + })]; + case 3: + _c.sent(); + return [3 /*break*/, 5]; + case 4: + error_2 = _c.sent(); + if (!isDuplicateColumnError(error_2)) { + throw error_2; + } + return [3 /*break*/, 5]; + case 5: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 6; + case 6: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 9]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 7: + _b.sent(); + _b.label = 8; + case 8: + _i++; + return [3 /*break*/, 6]; + case 9: return [2 /*return*/]; + } + }); + }); +} +function syncLegacyBoardPostRequests() { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.BOARD_POSTS_TABLE) + .leftJoin(exports.BOARD_POST_REQUESTS_TABLE, "".concat(exports.BOARD_POSTS_TABLE, ".id"), "".concat(exports.BOARD_POST_REQUESTS_TABLE, ".board_post_id")) + .whereNull("".concat(exports.BOARD_POST_REQUESTS_TABLE, ".id")) + .select("".concat(exports.BOARD_POSTS_TABLE, ".id"), "".concat(exports.BOARD_POSTS_TABLE, ".title"), "".concat(exports.BOARD_POSTS_TABLE, ".content"), "".concat(exports.BOARD_POSTS_TABLE, ".automation_plan_item_id"), "".concat(exports.BOARD_POSTS_TABLE, ".automation_received_at"))]; + case 1: + rows = _a.sent(); + if (!rows.length) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.BOARD_POST_REQUESTS_TABLE).insert(rows.map(function (row) { + var _a, _b, _c; + return ({ + board_post_id: Number(row.id), + sequence_no: 1, + title: String((_a = row.title) !== null && _a !== void 0 ? _a : '').trim() || '요청 1', + content: String((_b = row.content) !== null && _b !== void 0 ? _b : ''), + plan_item_id: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined + ? null + : Number(row.automation_plan_item_id), + automation_received_at: (_c = row.automation_received_at) !== null && _c !== void 0 ? _c : null, + workflow_state: row.automation_plan_item_id || row.automation_received_at ? 'registered' : 'pending', + created_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }); + }))]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensureBoardPostsTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, error_3, requiredColumns, _loop_2, _i, requiredColumns_2, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.BOARD_POSTS_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 5]; + _b.label = 2; + case 2: + _b.trys.push([2, 4, , 5]); + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.BOARD_POSTS_TABLE, function (table) { + table.increments('id').primary(); + table.string('title', 200).notNullable(); + table.text('content').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 3: + _b.sent(); + return [3 /*break*/, 5]; + case 4: + error_3 = _b.sent(); + if (!isDuplicateTableError(error_3)) { + throw error_3; + } + return [3 /*break*/, 5]; + case 5: + requiredColumns = [ + ['title', function (table) { return table.string('title', 200).notNullable().defaultTo('제목 없음'); }], + ['content', function (table) { return table.text('content').notNullable().defaultTo(''); }], + ['automation_type', function (table) { return table.string('automation_type', 40).notNullable().defaultTo('none'); }], + ['automation_type_id', function (table) { return table.string('automation_type_id', 120).nullable(); }], + ['attachments_json', function (table) { return table.text('attachments_json').notNullable().defaultTo('[]'); }], + ['automation_context_ids_json', function (table) { return table.text('automation_context_ids_json').notNullable().defaultTo('[]'); }], + ['request_execution_mode', function (table) { return table.string('request_execution_mode', 40).notNullable().defaultTo('all_at_once'); }], + ['automation_plan_item_id', function (table) { return table.integer('automation_plan_item_id').nullable(); }], + ['automation_received_at', function (table) { return table.timestamp('automation_received_at', { useTz: true }).nullable(); }], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_2 = function (columnName, createColumn) { + var hasColumn, error_4; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.BOARD_POSTS_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 5]; + _c.label = 2; + case 2: + _c.trys.push([2, 4, , 5]); + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.BOARD_POSTS_TABLE, function (table) { + createColumn(table); + })]; + case 3: + _c.sent(); + return [3 /*break*/, 5]; + case 4: + error_4 = _c.sent(); + if (!isDuplicateColumnError(error_4)) { + throw error_4; + } + return [3 /*break*/, 5]; + case 5: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_2 = requiredColumns; + _b.label = 6; + case 6: + if (!(_i < requiredColumns_2.length)) return [3 /*break*/, 9]; + _a = requiredColumns_2[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_2(columnName, createColumn)]; + case 7: + _b.sent(); + _b.label = 8; + case 8: + _i++; + return [3 /*break*/, 6]; + case 9: return [4 /*yield*/, (0, client_js_1.db)(exports.BOARD_POSTS_TABLE).where({ automation_type: 'plan_registration' }).update({ automation_type: 'plan' })]; + case 10: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.BOARD_POSTS_TABLE).where({ automation_type: 'general_development' }).update({ automation_type: 'auto_worker' })]; + case 11: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.BOARD_POSTS_TABLE).whereNull('automation_type_id').update({ automation_type_id: client_js_1.db.raw('automation_type') })]; + case 12: + _b.sent(); + return [4 /*yield*/, ensureBoardPostRequestsTable()]; + case 13: + _b.sent(); + return [4 /*yield*/, syncLegacyBoardPostRequests()]; + case 14: + _b.sent(); + return [2 /*return*/]; + } + }); + }); +} +function mapBoardPostRequestRow(row, planRow) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; + var workflowState = resolveBoardRequestWorkflowState(row.workflow_state); + var planStatus = (_a = planRow === null || planRow === void 0 ? void 0 : planRow.status) !== null && _a !== void 0 ? _a : null; + var workerStatus = (_b = planRow === null || planRow === void 0 ? void 0 : planRow.worker_status) !== null && _b !== void 0 ? _b : null; + var lastError = (_c = planRow === null || planRow === void 0 ? void 0 : planRow.last_error) !== null && _c !== void 0 ? _c : null; + var resolvedStatus = resolveBoardRequestStatus({ + workflowState: workflowState, + planStatus: planStatus, + workerStatus: workerStatus, + lastError: lastError, + }); + return { + id: Number((_d = row.id) !== null && _d !== void 0 ? _d : 0), + boardPostId: Number((_e = row.board_post_id) !== null && _e !== void 0 ? _e : 0), + sequence: Number((_f = row.sequence_no) !== null && _f !== void 0 ? _f : 0), + title: String((_g = row.title) !== null && _g !== void 0 ? _g : ''), + content: String((_h = row.content) !== null && _h !== void 0 ? _h : ''), + planItemId: row.plan_item_id === null || row.plan_item_id === undefined ? null : Number(row.plan_item_id), + automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined + ? null + : String(row.automation_received_at), + workflowState: workflowState, + status: resolvedStatus.status, + statusLabel: resolvedStatus.statusLabel, + planStatus: planStatus, + workerStatus: workerStatus, + lastError: lastError, + createdAt: String((_j = row.created_at) !== null && _j !== void 0 ? _j : ''), + updatedAt: String((_k = row.updated_at) !== null && _k !== void 0 ? _k : ''), + }; +} +function buildBoardPostSummary(requestItems) { + return requestItems.reduce(function (summary, requestItem) { + summary.total += 1; + if (requestItem.status === 'completed') { + summary.completed += 1; + } + else if (requestItem.status === 'failed') { + summary.failed += 1; + } + else if (requestItem.status === 'in_progress') { + summary.running += 1; + } + else if (requestItem.status === 'queued') { + summary.queued += 1; + } + else if (requestItem.status === 'waiting' || requestItem.status === 'pending') { + summary.waiting += 1; + } + else if (requestItem.status === 'blocked') { + summary.blocked += 1; + } + return summary; + }, { + total: 0, + completed: 0, + failed: 0, + running: 0, + queued: 0, + waiting: 0, + blocked: 0, + }); +} +function mapBoardPostRow(row, requestRows, planRowMap) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j; + var content = String((_a = row.content) !== null && _a !== void 0 ? _a : ''); + var rawAttachments = (_c = (_b = row.attachments_json) !== null && _b !== void 0 ? _b : row.attachments) !== null && _c !== void 0 ? _c : '[]'; + var attachments = []; + try { + var parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments; + attachments = exports.boardPostPayloadSchema.shape.attachments.parse(parsed); + } + catch (_k) { + attachments = []; + } + var requestItems = requestRows + .sort(function (left, right) { var _a, _b; return Number((_a = left.sequence_no) !== null && _a !== void 0 ? _a : 0) - Number((_b = right.sequence_no) !== null && _b !== void 0 ? _b : 0); }) + .map(function (requestRow) { + var planItemId = requestRow.plan_item_id === null || requestRow.plan_item_id === undefined + ? null + : Number(requestRow.plan_item_id); + return mapBoardPostRequestRow(requestRow, planItemId ? planRowMap.get(planItemId) : null); + }); + var firstRegisteredRequest = requestItems.find(function (item) { return item.planItemId || item.automationReceivedAt; }); + var firstReceivedAt = (_d = requestItems + .map(function (item) { return item.automationReceivedAt; }) + .filter(function (value) { return Boolean(value); }) + .sort()[0]) !== null && _d !== void 0 ? _d : null; + return { + id: Number((_e = row.id) !== null && _e !== void 0 ? _e : 0), + title: String((_f = row.title) !== null && _f !== void 0 ? _f : ''), + content: content, + preview: createPreview(content || requestItems.map(function (item) { return item.content; }).join('\n\n')), + attachments: attachments, + automationType: (0, automation_type_config_service_js_1.resolveStoredAutomationTypeId)(row), + automationPlanItemId: (_g = firstRegisteredRequest === null || firstRegisteredRequest === void 0 ? void 0 : firstRegisteredRequest.planItemId) !== null && _g !== void 0 ? _g : null, + automationReceivedAt: firstReceivedAt, + automationContextIds: (0, automation_context_service_js_1.parseAutomationContextIds)(row.automation_context_ids_json), + requestExecutionMode: resolveBoardRequestExecutionMode(row.request_execution_mode), + requestItems: requestItems, + requestSummary: buildBoardPostSummary(requestItems), + createdAt: String((_h = row.created_at) !== null && _h !== void 0 ? _h : ''), + updatedAt: String((_j = row.updated_at) !== null && _j !== void 0 ? _j : ''), + }; +} +function loadBoardPostsWithRequests(postIds) { + return __awaiter(this, void 0, void 0, function () { + var postQuery, rows, boardPostIds, requestRows, planItemIds, planRows, _a, requestRowsByPostId, planRowMap, _i, requestRows_1, requestRow, boardPostId, currentRows, _b, planRows_1, planRow; + var _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, ensureBoardPostsTable()]; + case 1: + _e.sent(); + postQuery = (0, client_js_1.db)(exports.BOARD_POSTS_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc'); + if (postIds === null || postIds === void 0 ? void 0 : postIds.length) { + postQuery.whereIn('id', postIds); + } + return [4 /*yield*/, postQuery]; + case 2: + rows = _e.sent(); + if (!rows.length) { + return [2 /*return*/, []]; + } + boardPostIds = rows.map(function (row) { return Number(row.id); }); + return [4 /*yield*/, (0, client_js_1.db)(exports.BOARD_POST_REQUESTS_TABLE) + .select('*') + .whereIn('board_post_id', boardPostIds) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc')]; + case 3: + requestRows = _e.sent(); + planItemIds = requestRows + .map(function (row) { return Number(row.plan_item_id); }) + .filter(function (value) { return Number.isFinite(value) && value > 0; }); + if (!(planItemIds.length > 0)) return [3 /*break*/, 5]; + return [4 /*yield*/, (0, client_js_1.db)(plan_service_js_1.PLAN_TABLE).select('id', 'status', 'worker_status', 'last_error').whereIn('id', planItemIds)]; + case 4: + _a = _e.sent(); + return [3 /*break*/, 6]; + case 5: + _a = []; + _e.label = 6; + case 6: + planRows = _a; + requestRowsByPostId = new Map(); + planRowMap = new Map(); + for (_i = 0, requestRows_1 = requestRows; _i < requestRows_1.length; _i++) { + requestRow = requestRows_1[_i]; + boardPostId = Number((_c = requestRow.board_post_id) !== null && _c !== void 0 ? _c : 0); + currentRows = (_d = requestRowsByPostId.get(boardPostId)) !== null && _d !== void 0 ? _d : []; + currentRows.push(requestRow); + requestRowsByPostId.set(boardPostId, currentRows); + } + for (_b = 0, planRows_1 = planRows; _b < planRows_1.length; _b++) { + planRow = planRows_1[_b]; + planRowMap.set(Number(planRow.id), { + id: Number(planRow.id), + status: planRow.status === null || planRow.status === undefined ? null : String(planRow.status), + worker_status: planRow.worker_status === null || planRow.worker_status === undefined ? null : String(planRow.worker_status), + last_error: planRow.last_error === null || planRow.last_error === undefined ? null : String(planRow.last_error), + }); + } + return [2 /*return*/, rows.map(function (row) { var _a; return mapBoardPostRow(row, (_a = requestRowsByPostId.get(Number(row.id))) !== null && _a !== void 0 ? _a : [], planRowMap); })]; + } + }); + }); +} +function getExistingPlanWorkIds(trx) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(plan_service_js_1.PLAN_TABLE).select('work_id')]; + case 1: return [2 /*return*/, (_a.sent()).map(function (row) { var _a; return String((_a = row.work_id) !== null && _a !== void 0 ? _a : ''); })]; + } + }); + }); +} +function createPlanFromBoardRequest(trx, args) { + return __awaiter(this, void 0, void 0, function () { + var boardPost, automationType, existingWorkIds, suffixLabel, workId, insertQuery, _a, _b, insertResult, _c, planItemId; + var _d; + var _e, _f, _g, _h, _j, _k, _l, _m, _o; + return __generator(this, function (_p) { + switch (_p.label) { + case 0: + boardPost = mapBoardPostRow(args.boardPostRow, [args.requestRow], new Map()); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)((_e = args.boardPostRow.automation_type_id) !== null && _e !== void 0 ? _e : args.boardPostRow.automation_type)]; + case 1: + automationType = _p.sent(); + return [4 /*yield*/, getExistingPlanWorkIds(trx)]; + case 2: + existingWorkIds = _p.sent(); + suffixLabel = ((_f = args.planWorkIdSuffixLabel) === null || _f === void 0 ? void 0 : _f.trim()) || "req-".concat(Number((_g = args.requestRow.sequence_no) !== null && _g !== void 0 ? _g : 1)); + workId = ((_h = args.planWorkIdBase) === null || _h === void 0 ? void 0 : _h.trim()) + ? resolveSequencedPlanWorkId(args.planWorkIdBase, existingWorkIds, suffixLabel) + : "board-post-".concat(Number(args.boardPostRow.id), "-req-").concat(Number((_j = args.requestRow.sequence_no) !== null && _j !== void 0 ? _j : 1)); + _b = (_a = trx(plan_service_js_1.PLAN_TABLE)).insert; + _d = { + work_id: workId + }; + return [4 /*yield*/, buildBoardPostPlanNote(boardPost.title, boardPost.content, boardPost.attachments, automationType, boardPost.automationContextIds, undefined, { + title: String((_k = args.requestRow.title) !== null && _k !== void 0 ? _k : ''), + content: String((_l = args.requestRow.content) !== null && _l !== void 0 ? _l : ''), + }, args.sequenceInfo)]; + case 3: + insertQuery = _b.apply(_a, [(_d.note = _p.sent(), + _d.automation_type = (0, plan_service_js_1.normalizePlanAutomationType)(args.boardPostRow.automation_type), + _d.automation_type_id = (_m = args.boardPostRow.automation_type_id) !== null && _m !== void 0 ? _m : args.boardPostRow.automation_type, + _d.automation_context_ids_json = (0, automation_context_service_js_1.stringifyAutomationContextIds)(boardPost.automationContextIds), + _d.status = '등록', + _d.release_target = 'release', + _d.jangsing_processing_required = true, + _d.auto_deploy_to_main = false, + _d.suppress_web_push = (_o = args.suppressWebPush) !== null && _o !== void 0 ? _o : false, + _d.worker_status = '대기', + _d.last_error = null, + _d.updated_at = trx.fn.now(), + _d)]); + if (!supportsReturning()) return [3 /*break*/, 5]; + return [4 /*yield*/, insertQuery.returning('id')]; + case 4: + _c = _p.sent(); + return [3 /*break*/, 7]; + case 5: return [4 /*yield*/, insertQuery]; + case 6: + _c = _p.sent(); + _p.label = 7; + case 7: + insertResult = _c; + planItemId = resolveInsertedId(insertResult); + if (!planItemId) { + throw new Error('자동화 접수 후 Plan ID를 확인하지 못했습니다.'); + } + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .where({ id: Number(args.requestRow.id) }) + .update({ + plan_item_id: planItemId, + automation_received_at: trx.fn.now(), + workflow_state: 'registered', + updated_at: trx.fn.now(), + })]; + case 8: + _p.sent(); + return [2 /*return*/, planItemId]; + } + }); + }); +} +function syncBoardPostLegacyLinkColumns(trx, boardPostId) { + return __awaiter(this, void 0, void 0, function () { + var requestRows, firstRegisteredRequest, firstReceivedAt; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .select('plan_item_id', 'automation_received_at') + .where({ board_post_id: boardPostId }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc')]; + case 1: + requestRows = _b.sent(); + firstRegisteredRequest = requestRows.find(function (row) { return row.plan_item_id || row.automation_received_at; }); + firstReceivedAt = (_a = requestRows + .map(function (row) { return row.automation_received_at; }) + .filter(function (value) { return Boolean(value); }) + .sort()[0]) !== null && _a !== void 0 ? _a : null; + return [4 /*yield*/, trx(exports.BOARD_POSTS_TABLE) + .where({ id: boardPostId }) + .update({ + automation_plan_item_id: (firstRegisteredRequest === null || firstRegisteredRequest === void 0 ? void 0 : firstRegisteredRequest.plan_item_id) === null || (firstRegisteredRequest === null || firstRegisteredRequest === void 0 ? void 0 : firstRegisteredRequest.plan_item_id) === undefined + ? null + : Number(firstRegisteredRequest.plan_item_id), + automation_received_at: firstReceivedAt, + updated_at: trx.fn.now(), + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + } + }); + }); +} +function isBoardPostAutomationLocked(requestRows, row) { + if (row.automation_received_at || row.automation_plan_item_id) { + return true; + } + return requestRows.some(function (requestRow) { return requestRow.automation_received_at || requestRow.plan_item_id; }); +} +function listBoardPosts() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + return [2 /*return*/, loadBoardPostsWithRequests()]; + }); + }); +} +function getBoardPost(id) { + return __awaiter(this, void 0, void 0, function () { + var items; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, loadBoardPostsWithRequests([id])]; + case 1: + items = _b.sent(); + return [2 /*return*/, (_a = items[0]) !== null && _a !== void 0 ? _a : null]; + } + }); + }); +} +function createBoardPost(payload) { + return __awaiter(this, void 0, void 0, function () { + var parsedPayload, normalizedRequestItems, automationType, insertQuery, insertResult, _a, insertedId, item; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensureBoardPostsTable()]; + case 1: + _b.sent(); + parsedPayload = exports.boardPostPayloadSchema.parse(payload); + normalizedRequestItems = normalizeBoardPostRequestItems(parsedPayload.requestItems, parsedPayload.title, parsedPayload.content); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)(parsedPayload.automationType)]; + case 2: + automationType = _b.sent(); + insertQuery = (0, client_js_1.db)(exports.BOARD_POSTS_TABLE).insert({ + title: parsedPayload.title, + content: parsedPayload.content, + attachments_json: JSON.stringify(parsedPayload.attachments), + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(parsedPayload.automationContextIds), + request_execution_mode: parsedPayload.requestExecutionMode, + created_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }); + if (!supportsReturning()) return [3 /*break*/, 4]; + return [4 /*yield*/, insertQuery.returning('id')]; + case 3: + _a = _b.sent(); + return [3 /*break*/, 6]; + case 4: return [4 /*yield*/, insertQuery]; + case 5: + _a = _b.sent(); + _b.label = 6; + case 6: + insertResult = _a; + insertedId = resolveInsertedId(insertResult); + if (!insertedId) { + throw new Error('게시글 저장 후 ID를 확인하지 못했습니다.'); + } + return [4 /*yield*/, (0, client_js_1.db)(exports.BOARD_POST_REQUESTS_TABLE).insert(normalizedRequestItems.map(function (requestItem, index) { return ({ + board_post_id: insertedId, + sequence_no: index + 1, + title: requestItem.title, + content: requestItem.content, + workflow_state: 'pending', + created_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }); }))]; + case 7: + _b.sent(); + return [4 /*yield*/, getBoardPost(insertedId)]; + case 8: + item = _b.sent(); + if (!item) { + throw new Error('저장된 게시글을 다시 불러오지 못했습니다.'); + } + return [2 /*return*/, item]; + } + }); + }); +} +function receiveBoardPostAutomation(id, options) { + return __awaiter(this, void 0, void 0, function () { + var transactionResult; + var _a; + var _this = this; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensureBoardPostsTable()]; + case 1: + _b.sent(); + return [4 /*yield*/, (0, plan_service_js_1.ensurePlanTable)()]; + case 2: + _b.sent(); + return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var currentRow, requestRows, requestExecutionMode, registeredPlanItemIds, pendingRequestRows, targetRequestRows, firstPendingRequest, waitingIds, createdPlanItemIds, _i, targetRequestRows_1, requestRow, _a, _b; + var _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, trx(exports.BOARD_POSTS_TABLE).where({ id: id }).forUpdate().first()]; + case 1: + currentRow = _d.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: id }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc') + .forUpdate()]; + case 2: + requestRows = _d.sent(); + requestExecutionMode = resolveBoardRequestExecutionMode(currentRow.request_execution_mode); + registeredPlanItemIds = requestRows + .map(function (requestRow) { return Number(requestRow.plan_item_id); }) + .filter(function (value) { return Number.isFinite(value) && value > 0; }); + pendingRequestRows = requestRows.filter(function (requestRow) { return !requestRow.plan_item_id && !requestRow.automation_received_at; }); + targetRequestRows = []; + if (!(requestExecutionMode === 'all_at_once')) return [3 /*break*/, 3]; + targetRequestRows = pendingRequestRows.filter(function (requestRow) { return resolveBoardRequestWorkflowState(requestRow.workflow_state) !== 'blocked'; }); + return [3 /*break*/, 5]; + case 3: + if (!(registeredPlanItemIds.length === 0)) return [3 /*break*/, 5]; + firstPendingRequest = pendingRequestRows[0]; + if (firstPendingRequest) { + targetRequestRows = [firstPendingRequest]; + } + waitingIds = pendingRequestRows.slice(1).map(function (requestRow) { return Number(requestRow.id); }).filter(Number.isFinite); + if (!(waitingIds.length > 0)) return [3 /*break*/, 5]; + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .whereIn('id', waitingIds) + .update({ + workflow_state: 'waiting', + updated_at: trx.fn.now(), + })]; + case 4: + _d.sent(); + _d.label = 5; + case 5: + if (targetRequestRows.length === 0) { + return [2 /*return*/, { + planItemIds: registeredPlanItemIds, + alreadyReceived: true, + }]; + } + createdPlanItemIds = []; + _i = 0, targetRequestRows_1 = targetRequestRows; + _d.label = 6; + case 6: + if (!(_i < targetRequestRows_1.length)) return [3 /*break*/, 9]; + requestRow = targetRequestRows_1[_i]; + _b = (_a = createdPlanItemIds).push; + return [4 /*yield*/, createPlanFromBoardRequest(trx, { + boardPostRow: currentRow, + requestRow: requestRow, + sequenceInfo: { + index: Number((_c = requestRow.sequence_no) !== null && _c !== void 0 ? _c : 1), + total: requestRows.length, + }, + planWorkIdBase: options === null || options === void 0 ? void 0 : options.planWorkIdBase, + planWorkIdSuffixLabel: options === null || options === void 0 ? void 0 : options.planWorkIdSuffixLabel, + suppressWebPush: options === null || options === void 0 ? void 0 : options.suppressWebPush, + })]; + case 7: + _b.apply(_a, [_d.sent()]); + _d.label = 8; + case 8: + _i++; + return [3 /*break*/, 6]; + case 9: return [4 /*yield*/, syncBoardPostLegacyLinkColumns(trx, id)]; + case 10: + _d.sent(); + return [2 /*return*/, { + planItemIds: createdPlanItemIds, + alreadyReceived: false, + }]; + } + }); + }); })]; + case 3: + transactionResult = _b.sent(); + if (!transactionResult) { + return [2 /*return*/, null]; + } + _a = {}; + return [4 /*yield*/, getBoardPost(id)]; + case 4: return [2 /*return*/, __assign.apply(void 0, [(_a.item = _b.sent(), _a), transactionResult])]; + } + }); + }); +} +function progressBoardPostAutomationByPlanResult(planItemId, outcome) { + return __awaiter(this, void 0, void 0, function () { + var updatedBoardPostId; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureBoardPostsTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, plan_service_js_1.ensurePlanTable)()]; + case 2: + _a.sent(); + return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var currentRequestRow, boardPostId, boardPostRow, requestExecutionMode, requestRows, nextWaitingRequest, blockedRequestIds; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE).where({ plan_item_id: planItemId }).forUpdate().first()]; + case 1: + currentRequestRow = _b.sent(); + if (!currentRequestRow) { + return [2 /*return*/, null]; + } + boardPostId = Number(currentRequestRow.board_post_id); + return [4 /*yield*/, trx(exports.BOARD_POSTS_TABLE).where({ id: boardPostId }).forUpdate().first()]; + case 2: + boardPostRow = _b.sent(); + if (!boardPostRow) { + return [2 /*return*/, null]; + } + requestExecutionMode = resolveBoardRequestExecutionMode(boardPostRow.request_execution_mode); + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: boardPostId }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc') + .forUpdate()]; + case 3: + requestRows = _b.sent(); + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .where({ id: Number(currentRequestRow.id) }) + .update({ + workflow_state: outcome === 'completed' ? 'completed' : 'failed', + updated_at: trx.fn.now(), + })]; + case 4: + _b.sent(); + if (!(requestExecutionMode === 'all_at_once')) return [3 /*break*/, 6]; + return [4 /*yield*/, syncBoardPostLegacyLinkColumns(trx, boardPostId)]; + case 5: + _b.sent(); + return [2 /*return*/, boardPostId]; + case 6: + nextWaitingRequest = requestRows.find(function (requestRow) { + if (Number(requestRow.id) === Number(currentRequestRow.id)) { + return false; + } + var state = resolveBoardRequestWorkflowState(requestRow.workflow_state); + return !requestRow.plan_item_id && !requestRow.automation_received_at && (state === 'waiting' || state === 'pending'); + }); + if (!!nextWaitingRequest) return [3 /*break*/, 8]; + return [4 /*yield*/, syncBoardPostLegacyLinkColumns(trx, boardPostId)]; + case 7: + _b.sent(); + return [2 /*return*/, boardPostId]; + case 8: + if (!(requestExecutionMode === 'after_previous_success' && outcome === 'failed')) return [3 /*break*/, 11]; + blockedRequestIds = requestRows + .filter(function (requestRow) { var _a, _b; return Number((_a = requestRow.sequence_no) !== null && _a !== void 0 ? _a : 0) > Number((_b = currentRequestRow.sequence_no) !== null && _b !== void 0 ? _b : 0); }) + .map(function (requestRow) { return Number(requestRow.id); }) + .filter(Number.isFinite); + if (!(blockedRequestIds.length > 0)) return [3 /*break*/, 10]; + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .whereIn('id', blockedRequestIds) + .update({ + workflow_state: 'blocked', + updated_at: trx.fn.now(), + })]; + case 9: + _b.sent(); + _b.label = 10; + case 10: return [3 /*break*/, 13]; + case 11: return [4 /*yield*/, createPlanFromBoardRequest(trx, { + boardPostRow: boardPostRow, + requestRow: nextWaitingRequest, + sequenceInfo: { + index: Number((_a = nextWaitingRequest.sequence_no) !== null && _a !== void 0 ? _a : 1), + total: requestRows.length, + }, + })]; + case 12: + _b.sent(); + _b.label = 13; + case 13: return [4 /*yield*/, syncBoardPostLegacyLinkColumns(trx, boardPostId)]; + case 14: + _b.sent(); + return [2 /*return*/, boardPostId]; + } + }); + }); })]; + case 3: + updatedBoardPostId = _a.sent(); + if (!updatedBoardPostId) { + return [2 /*return*/, null]; + } + return [2 /*return*/, getBoardPost(updatedBoardPostId)]; + } + }); + }); +} +function updateBoardPost(id, payload) { + return __awaiter(this, void 0, void 0, function () { + var parsedPayload, normalizedRequestItems, automationType, updatedBoardPostId; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureBoardPostsTable()]; + case 1: + _a.sent(); + parsedPayload = exports.boardPostPayloadSchema.parse(payload); + normalizedRequestItems = normalizeBoardPostRequestItems(parsedPayload.requestItems, parsedPayload.title, parsedPayload.content); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)(parsedPayload.automationType)]; + case 2: + automationType = _a.sent(); + return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var currentRow, currentRequestRows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(exports.BOARD_POSTS_TABLE).where({ id: id }).forUpdate().first()]; + case 1: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: id }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc') + .forUpdate()]; + case 2: + currentRequestRows = _a.sent(); + if (isBoardPostAutomationLocked(currentRequestRows, currentRow)) { + throw new BoardPostAutomationLockedError('update'); + } + return [4 /*yield*/, trx(exports.BOARD_POSTS_TABLE) + .where({ id: id }) + .update({ + title: parsedPayload.title, + content: parsedPayload.content, + attachments_json: JSON.stringify(parsedPayload.attachments), + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(parsedPayload.automationContextIds), + request_execution_mode: parsedPayload.requestExecutionMode, + updated_at: trx.fn.now(), + })]; + case 3: + _a.sent(); + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE).where({ board_post_id: id }).delete()]; + case 4: + _a.sent(); + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE).insert(normalizedRequestItems.map(function (requestItem, index) { return ({ + board_post_id: id, + sequence_no: index + 1, + title: requestItem.title, + content: requestItem.content, + workflow_state: 'pending', + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); }))]; + case 5: + _a.sent(); + return [2 /*return*/, id]; + } + }); + }); })]; + case 3: + updatedBoardPostId = _a.sent(); + if (!updatedBoardPostId) { + return [2 /*return*/, null]; + } + return [2 /*return*/, getBoardPost(updatedBoardPostId)]; + } + }); + }); +} +function deleteBoardPost(id) { + return __awaiter(this, void 0, void 0, function () { + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureBoardPostsTable()]; + case 1: + _a.sent(); + return [2 /*return*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var currentRow, currentRequestRows, deletedCount; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(exports.BOARD_POSTS_TABLE).where({ id: id }).forUpdate().first()]; + case 1: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, false]; + } + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: id }) + .forUpdate()]; + case 2: + currentRequestRows = _a.sent(); + if (isBoardPostAutomationLocked(currentRequestRows, currentRow)) { + throw new BoardPostAutomationLockedError('delete'); + } + return [4 /*yield*/, trx(exports.BOARD_POST_REQUESTS_TABLE).where({ board_post_id: id }).delete()]; + case 3: + _a.sent(); + return [4 /*yield*/, trx(exports.BOARD_POSTS_TABLE).where({ id: id }).del()]; + case 4: + deletedCount = _a.sent(); + return [2 /*return*/, deletedCount > 0]; + } + }); + }); })]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/board-service.ts b/etc/servers/work-server/src/services/board-service.ts old mode 100755 new mode 100644 index 153f84a..1919c06 --- a/etc/servers/work-server/src/services/board-service.ts +++ b/etc/servers/work-server/src/services/board-service.ts @@ -20,10 +20,35 @@ import { import type { AutomationContextRecord } from './automation-context-config-service.js'; export const BOARD_POSTS_TABLE = 'board_posts'; +export const BOARD_POST_REQUESTS_TABLE = 'board_post_requests'; + +const boardRequestExecutionModes = ['all_at_once', 'after_previous_finished', 'after_previous_success'] as const; +const boardRequestWorkflowStates = ['pending', 'waiting', 'registered', 'completed', 'failed', 'blocked'] as const; +const planFailureWorkerStatuses = new Set(['브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패', '작업취소']); +const planRunningStatuses = new Set(['등록', '작업중', '작업완료', '릴리즈완료']); +const planRunningWorkerStatuses = new Set([ + '대기', + '브랜치생성중', + '브랜치준비', + '자동작업중', + 'release반영대기', + 'release반영중', + 'release반영완료', + 'main반영대기', + 'main반영중', + 'main반영완료', +]); + +export const boardPostRequestSchema = z.object({ + title: z.string().trim().min(1).max(200), + content: z.string().min(1).max(200000), +}); + +export const boardPostRequestExecutionModeSchema = z.enum(boardRequestExecutionModes); export const boardPostPayloadSchema = z.object({ title: z.string().trim().min(1).max(200), - content: z.string().min(1).max(200000), + content: z.string().max(200000).default(''), attachments: z.array( z.object({ id: z.string().trim().min(1).max(120), @@ -36,8 +61,35 @@ export const boardPostPayloadSchema = z.object({ ).max(20).default([]), automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')), automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]), + requestExecutionMode: boardPostRequestExecutionModeSchema.default('all_at_once'), + requestItems: z.array(boardPostRequestSchema).max(20).optional().default([]), }); +export type BoardPostRequestExecutionMode = z.infer; +export type BoardPostRequestPayload = z.infer; +export type BoardPostPayload = z.infer; +export type BoardPostPayloadInput = z.input; + +export type BoardPostRequestWorkflowState = (typeof boardRequestWorkflowStates)[number]; + +export type BoardPostRequestItem = { + id: number; + boardPostId: number; + sequence: number; + title: string; + content: string; + planItemId: number | null; + automationReceivedAt: string | null; + workflowState: BoardPostRequestWorkflowState; + status: 'pending' | 'waiting' | 'queued' | 'in_progress' | 'completed' | 'failed' | 'blocked'; + statusLabel: string; + planStatus: string | null; + workerStatus: string | null; + lastError: string | null; + createdAt: string; + updatedAt: string; +}; + export type BoardPostItem = { id: number; title: string; @@ -48,6 +100,17 @@ export type BoardPostItem = { automationPlanItemId: number | null; automationReceivedAt: string | null; automationContextIds: string[]; + requestExecutionMode: BoardPostRequestExecutionMode; + requestItems: BoardPostRequestItem[]; + requestSummary: { + total: number; + completed: number; + failed: number; + running: number; + queued: number; + waiting: number; + blocked: number; + }; createdAt: string; updatedAt: string; }; @@ -78,39 +141,73 @@ function createPreview(content: string) { return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } -function mapBoardPostRow(row: Record): BoardPostItem { - const content = String(row.content ?? ''); - const rawAttachments = row.attachments_json ?? row.attachments ?? '[]'; - let attachments: BoardPostItem['attachments'] = []; - - try { - const parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments; - attachments = boardPostPayloadSchema.shape.attachments.parse(parsed); - } catch { - attachments = []; +function normalizeBoardPostRequestItems(requestItems: BoardPostRequestPayload[], fallbackTitle: string, fallbackContent: string) { + if (requestItems.length > 0) { + return requestItems.map((item) => ({ + title: item.title.trim(), + content: item.content.trim(), + })); } - return { - id: Number(row.id ?? 0), - title: String(row.title ?? ''), - content, - preview: createPreview(content), - attachments, - automationType: resolveStoredAutomationTypeId(row), - 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), - automationContextIds: parseAutomationContextIds(row.automation_context_ids_json), - createdAt: String(row.created_at ?? ''), - updatedAt: String(row.updated_at ?? ''), - }; + return [ + { + title: fallbackTitle.trim() || '요청 1', + content: fallbackContent.trim(), + }, + ]; } -function isBoardPostAutomationLocked(row: Record) { - return Boolean(row.automation_received_at || row.automation_plan_item_id); +function resolveBoardRequestWorkflowState(value: unknown): BoardPostRequestWorkflowState { + return boardRequestWorkflowStates.includes(value as BoardPostRequestWorkflowState) + ? (value as BoardPostRequestWorkflowState) + : 'pending'; +} + +function resolveBoardRequestExecutionMode(value: unknown): BoardPostRequestExecutionMode { + return boardRequestExecutionModes.includes(value as BoardPostRequestExecutionMode) + ? (value as BoardPostRequestExecutionMode) + : 'all_at_once'; +} + +function resolveBoardRequestStatus(args: { + workflowState: BoardPostRequestWorkflowState; + planStatus: string | null; + workerStatus: string | null; + lastError: string | null; +}) { + const { workflowState, planStatus, workerStatus, lastError } = args; + + if (workflowState === 'blocked') { + return { status: 'blocked' as const, statusLabel: '차단' }; + } + + if (planStatus === '완료' || workflowState === 'completed') { + return { status: 'completed' as const, statusLabel: '완료' }; + } + + if (lastError || (workerStatus && planFailureWorkerStatuses.has(workerStatus)) || workflowState === 'failed') { + return { status: 'failed' as const, statusLabel: '실패' }; + } + + if ( + (planStatus && planRunningStatuses.has(planStatus)) + || (workerStatus && planRunningWorkerStatuses.has(workerStatus)) + ) { + return { + status: planStatus === '등록' && workerStatus === '대기' ? 'queued' as const : 'in_progress' as const, + statusLabel: planStatus === '등록' && workerStatus === '대기' ? '대기열' : '진행중', + }; + } + + if (workflowState === 'waiting') { + return { status: 'waiting' as const, statusLabel: '선행 대기' }; + } + + if (workflowState === 'registered' || args.planStatus || args.workerStatus) { + return { status: 'queued' as const, statusLabel: '대기열' }; + } + + return { status: 'pending' as const, statusLabel: '미접수' }; } function buildBoardAttachmentSection(attachments: z.infer['attachments']) { @@ -141,16 +238,60 @@ export async function buildBoardPostPlanNote( automationType?: Pick | null, automationContextIds?: string[], availableContexts?: AutomationContextRecord[], + requestItem?: Pick | null, + sequenceInfo?: { index: number; total: number } | null, ) { - return buildAutomationNoteSections({ + const note = await buildAutomationNoteSections({ title: title.trim(), sourceLabel: 'board_posts 자동화 접수', - requestContent: content.trim(), + requestContent: requestItem ? requestItem.content.trim() : content.trim(), attachments: attachments.length ? buildBoardAttachmentSection(attachments).slice(1) : [], automationType, availableContexts, selectedContextIds: automationContextIds, }); + + if (!requestItem) { + return note; + } + + const commonContent = content.trim(); + const requestTitle = requestItem.title.trim(); + const requestContent = requestItem.content.trim(); + const lines: string[] = [ + '# 자동화 작업메모', + '', + `- 게시판 제목: ${title.trim()}`, + '- 메모 출처: board_posts 자동화 접수', + `- 하위 요청: ${requestTitle}`, + ]; + + if (sequenceInfo) { + lines.push(`- 요청 순서: ${sequenceInfo.index}/${sequenceInfo.total}`); + } + + if (automationType?.name) { + lines.push(`- 선택 자동화 유형: ${automationType.name}`); + } + + lines.push('- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.', '', '## 자동화 Context'); + + const contextSection = note.split('## 자동화 Context\n')[1] ?? '선택된 자동화 Context 없음\n\n## 요청 본문\n'; + const [contextBody] = contextSection.split('\n\n## 요청 본문\n'); + lines.push(contextBody.trim() || '선택된 자동화 Context 없음'); + + if (commonContent) { + lines.push('', '## 공통 메모', commonContent); + } + + lines.push('', '## 요청 본문', requestContent); + + const attachmentSection = buildBoardAttachmentSection(attachments); + if (attachmentSection.length > 0) { + lines.push('', ...attachmentSection); + } + + return lines.join('\n'); } function buildSequencedPlanWorkId(baseWorkId: string, sequence: number, suffixLabel?: string | null) { @@ -229,6 +370,93 @@ function isDuplicateColumnError(error: unknown) { return isDuplicateSchemaError(error, ['42701'], [/already exists/i, /duplicate column/i]); } +async function ensureBoardPostRequestsTable() { + const hasTable = await db.schema.hasTable(BOARD_POST_REQUESTS_TABLE); + + if (!hasTable) { + try { + await db.schema.createTable(BOARD_POST_REQUESTS_TABLE, (table) => { + table.increments('id').primary(); + table.integer('board_post_id').notNullable().index(); + table.integer('sequence_no').notNullable().defaultTo(1); + table.string('title', 200).notNullable(); + table.text('content').notNullable(); + table.integer('plan_item_id').nullable().index(); + table.timestamp('automation_received_at', { useTz: true }).nullable(); + table.string('workflow_state', 40).notNullable().defaultTo('pending'); + 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]> = [ + ['board_post_id', (table) => table.integer('board_post_id').notNullable().index()], + ['sequence_no', (table) => table.integer('sequence_no').notNullable().defaultTo(1)], + ['title', (table) => table.string('title', 200).notNullable().defaultTo('요청 1')], + ['content', (table) => table.text('content').notNullable().defaultTo('')], + ['plan_item_id', (table) => table.integer('plan_item_id').nullable().index()], + ['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()], + ['workflow_state', (table) => table.string('workflow_state', 40).notNullable().defaultTo('pending')], + ['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_POST_REQUESTS_TABLE, columnName); + + if (!hasColumn) { + try { + await db.schema.alterTable(BOARD_POST_REQUESTS_TABLE, (table) => { + createColumn(table); + }); + } catch (error) { + if (!isDuplicateColumnError(error)) { + throw error; + } + } + } + } +} + +async function syncLegacyBoardPostRequests() { + const rows = await db(BOARD_POSTS_TABLE) + .leftJoin(BOARD_POST_REQUESTS_TABLE, `${BOARD_POSTS_TABLE}.id`, `${BOARD_POST_REQUESTS_TABLE}.board_post_id`) + .whereNull(`${BOARD_POST_REQUESTS_TABLE}.id`) + .select( + `${BOARD_POSTS_TABLE}.id`, + `${BOARD_POSTS_TABLE}.title`, + `${BOARD_POSTS_TABLE}.content`, + `${BOARD_POSTS_TABLE}.automation_plan_item_id`, + `${BOARD_POSTS_TABLE}.automation_received_at`, + ); + + if (!rows.length) { + return; + } + + await db(BOARD_POST_REQUESTS_TABLE).insert( + rows.map((row) => ({ + board_post_id: Number(row.id), + sequence_no: 1, + title: String(row.title ?? '').trim() || '요청 1', + content: String(row.content ?? ''), + plan_item_id: + row.automation_plan_item_id === null || row.automation_plan_item_id === undefined + ? null + : Number(row.automation_plan_item_id), + automation_received_at: row.automation_received_at ?? null, + workflow_state: row.automation_plan_item_id || row.automation_received_at ? 'registered' : 'pending', + created_at: db.fn.now(), + updated_at: db.fn.now(), + })), + ); +} + export async function ensureBoardPostsTable() { const hasTable = await db.schema.hasTable(BOARD_POSTS_TABLE); @@ -255,6 +483,7 @@ export async function ensureBoardPostsTable() { ['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()], ['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')], ['automation_context_ids_json', (table) => table.text('automation_context_ids_json').notNullable().defaultTo('[]')], + ['request_execution_mode', (table) => table.string('request_execution_mode', 40).notNullable().defaultTo('all_at_once')], ['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())], @@ -277,36 +506,304 @@ export async function ensureBoardPostsTable() { } } - 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' }); - await db(BOARD_POSTS_TABLE) - .whereNull('automation_type_id') + 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' }); + await db(BOARD_POSTS_TABLE).whereNull('automation_type_id').update({ automation_type_id: db.raw('automation_type') }); + + await ensureBoardPostRequestsTable(); + await syncLegacyBoardPostRequests(); +} + +type PlanRow = { + id: number; + status: string | null; + worker_status: string | null; + last_error: string | null; +}; + +function mapBoardPostRequestRow(row: Record, planRow?: PlanRow | null): BoardPostRequestItem { + const workflowState = resolveBoardRequestWorkflowState(row.workflow_state); + const planStatus = planRow?.status ?? null; + const workerStatus = planRow?.worker_status ?? null; + const lastError = planRow?.last_error ?? null; + const resolvedStatus = resolveBoardRequestStatus({ + workflowState, + planStatus, + workerStatus, + lastError, + }); + + return { + id: Number(row.id ?? 0), + boardPostId: Number(row.board_post_id ?? 0), + sequence: Number(row.sequence_no ?? 0), + title: String(row.title ?? ''), + content: String(row.content ?? ''), + planItemId: row.plan_item_id === null || row.plan_item_id === undefined ? null : Number(row.plan_item_id), + automationReceivedAt: + row.automation_received_at === null || row.automation_received_at === undefined + ? null + : String(row.automation_received_at), + workflowState, + status: resolvedStatus.status, + statusLabel: resolvedStatus.statusLabel, + planStatus, + workerStatus, + lastError, + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + }; +} + +function buildBoardPostSummary(requestItems: BoardPostRequestItem[]) { + return requestItems.reduce( + (summary, requestItem) => { + summary.total += 1; + + if (requestItem.status === 'completed') { + summary.completed += 1; + } else if (requestItem.status === 'failed') { + summary.failed += 1; + } else if (requestItem.status === 'in_progress') { + summary.running += 1; + } else if (requestItem.status === 'queued') { + summary.queued += 1; + } else if (requestItem.status === 'waiting' || requestItem.status === 'pending') { + summary.waiting += 1; + } else if (requestItem.status === 'blocked') { + summary.blocked += 1; + } + + return summary; + }, + { + total: 0, + completed: 0, + failed: 0, + running: 0, + queued: 0, + waiting: 0, + blocked: 0, + }, + ); +} + +function mapBoardPostRow( + row: Record, + requestRows: Record[], + planRowMap: Map, +): BoardPostItem { + const content = String(row.content ?? ''); + const rawAttachments = row.attachments_json ?? row.attachments ?? '[]'; + let attachments: BoardPostItem['attachments'] = []; + + try { + const parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments; + attachments = boardPostPayloadSchema.shape.attachments.parse(parsed); + } catch { + attachments = []; + } + + const requestItems = requestRows + .sort((left, right) => Number(left.sequence_no ?? 0) - Number(right.sequence_no ?? 0)) + .map((requestRow) => { + const planItemId = + requestRow.plan_item_id === null || requestRow.plan_item_id === undefined + ? null + : Number(requestRow.plan_item_id); + return mapBoardPostRequestRow(requestRow, planItemId ? planRowMap.get(planItemId) : null); + }); + + const firstRegisteredRequest = requestItems.find((item) => item.planItemId || item.automationReceivedAt); + const firstReceivedAt = requestItems + .map((item) => item.automationReceivedAt) + .filter((value): value is string => Boolean(value)) + .sort()[0] ?? null; + + return { + id: Number(row.id ?? 0), + title: String(row.title ?? ''), + content, + preview: createPreview(content || requestItems.map((item) => item.content).join('\n\n')), + attachments, + automationType: resolveStoredAutomationTypeId(row), + automationPlanItemId: firstRegisteredRequest?.planItemId ?? null, + automationReceivedAt: firstReceivedAt, + automationContextIds: parseAutomationContextIds(row.automation_context_ids_json), + requestExecutionMode: resolveBoardRequestExecutionMode(row.request_execution_mode), + requestItems, + requestSummary: buildBoardPostSummary(requestItems), + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + }; +} + +async function loadBoardPostsWithRequests(postIds?: number[]) { + await ensureBoardPostsTable(); + + const postQuery = db(BOARD_POSTS_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc'); + + if (postIds?.length) { + postQuery.whereIn('id', postIds); + } + + const rows = await postQuery; + + if (!rows.length) { + return []; + } + + const boardPostIds = rows.map((row) => Number(row.id)); + const requestRows = await db(BOARD_POST_REQUESTS_TABLE) + .select('*') + .whereIn('board_post_id', boardPostIds) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc'); + const planItemIds = requestRows + .map((row) => Number(row.plan_item_id)) + .filter((value) => Number.isFinite(value) && value > 0); + const planRows = planItemIds.length > 0 + ? await db(PLAN_TABLE).select('id', 'status', 'worker_status', 'last_error').whereIn('id', planItemIds) + : []; + const requestRowsByPostId = new Map[]>(); + const planRowMap = new Map(); + + for (const requestRow of requestRows) { + const boardPostId = Number(requestRow.board_post_id ?? 0); + const currentRows = requestRowsByPostId.get(boardPostId) ?? []; + currentRows.push(requestRow); + requestRowsByPostId.set(boardPostId, currentRows); + } + + for (const planRow of planRows) { + planRowMap.set(Number(planRow.id), { + id: Number(planRow.id), + status: planRow.status === null || planRow.status === undefined ? null : String(planRow.status), + worker_status: planRow.worker_status === null || planRow.worker_status === undefined ? null : String(planRow.worker_status), + last_error: planRow.last_error === null || planRow.last_error === undefined ? null : String(planRow.last_error), + }); + } + + return rows.map((row) => mapBoardPostRow(row, requestRowsByPostId.get(Number(row.id)) ?? [], planRowMap)); +} + +async function getExistingPlanWorkIds(trx: typeof db) { + return (await trx(PLAN_TABLE).select('work_id')).map((row) => String(row.work_id ?? '')); +} + +async function createPlanFromBoardRequest( + trx: typeof db, + args: { + boardPostRow: Record; + requestRow: Record; + sequenceInfo: { index: number; total: number }; + planWorkIdBase?: string | null; + planWorkIdSuffixLabel?: string | null; + suppressWebPush?: boolean; + }, +) { + const boardPost = mapBoardPostRow(args.boardPostRow, [args.requestRow], new Map()); + const automationType = await resolveAutomationType(args.boardPostRow.automation_type_id ?? args.boardPostRow.automation_type); + const existingWorkIds = await getExistingPlanWorkIds(trx); + const suffixLabel = args.planWorkIdSuffixLabel?.trim() || `req-${Number(args.requestRow.sequence_no ?? 1)}`; + const workId = args.planWorkIdBase?.trim() + ? resolveSequencedPlanWorkId(args.planWorkIdBase, existingWorkIds, suffixLabel) + : `board-post-${Number(args.boardPostRow.id)}-req-${Number(args.requestRow.sequence_no ?? 1)}`; + const insertQuery = trx(PLAN_TABLE).insert({ + work_id: workId, + note: await buildBoardPostPlanNote( + boardPost.title, + boardPost.content, + boardPost.attachments, + automationType, + boardPost.automationContextIds, + undefined, + { + title: String(args.requestRow.title ?? ''), + content: String(args.requestRow.content ?? ''), + }, + args.sequenceInfo, + ), + automation_type: normalizePlanAutomationType(args.boardPostRow.automation_type), + automation_type_id: args.boardPostRow.automation_type_id ?? args.boardPostRow.automation_type, + automation_context_ids_json: stringifyAutomationContextIds(boardPost.automationContextIds), + status: '등록', + release_target: 'release', + jangsing_processing_required: true, + auto_deploy_to_main: false, + suppress_web_push: args.suppressWebPush ?? 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를 확인하지 못했습니다.'); + } + + await trx(BOARD_POST_REQUESTS_TABLE) + .where({ id: Number(args.requestRow.id) }) .update({ - automation_type_id: db.raw('automation_type'), + plan_item_id: planItemId, + automation_received_at: trx.fn.now(), + workflow_state: 'registered', + updated_at: trx.fn.now(), + }); + + return planItemId; +} + +async function syncBoardPostLegacyLinkColumns(trx: typeof db, boardPostId: number) { + const requestRows = await trx(BOARD_POST_REQUESTS_TABLE) + .select('plan_item_id', 'automation_received_at') + .where({ board_post_id: boardPostId }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc'); + const firstRegisteredRequest = requestRows.find((row) => row.plan_item_id || row.automation_received_at); + const firstReceivedAt = requestRows + .map((row) => row.automation_received_at) + .filter((value): value is string => Boolean(value)) + .sort()[0] ?? null; + + await trx(BOARD_POSTS_TABLE) + .where({ id: boardPostId }) + .update({ + automation_plan_item_id: + firstRegisteredRequest?.plan_item_id === null || firstRegisteredRequest?.plan_item_id === undefined + ? null + : Number(firstRegisteredRequest.plan_item_id), + automation_received_at: firstReceivedAt, + updated_at: trx.fn.now(), }); } -export async function listBoardPosts() { - await ensureBoardPostsTable(); +function isBoardPostAutomationLocked(requestRows: Record[], row: Record) { + if (row.automation_received_at || row.automation_plan_item_id) { + return true; + } - const rows = await db(BOARD_POSTS_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc'); - return rows.map((row) => mapBoardPostRow(row)); + return requestRows.some((requestRow) => requestRow.automation_received_at || requestRow.plan_item_id); +} + +export async function listBoardPosts() { + return loadBoardPostsWithRequests(); } export async function getBoardPost(id: number) { - await ensureBoardPostsTable(); - - const row = await db(BOARD_POSTS_TABLE).where({ id }).first(); - return row ? mapBoardPostRow(row) : null; + const items = await loadBoardPostsWithRequests([id]); + return items[0] ?? null; } -export async function createBoardPost(payload: z.infer) { +export async function createBoardPost(payload: BoardPostPayloadInput) { await ensureBoardPostsTable(); const parsedPayload = boardPostPayloadSchema.parse(payload); + const normalizedRequestItems = normalizeBoardPostRequestItems( + parsedPayload.requestItems, + parsedPayload.title, + parsedPayload.content, + ); const automationType = await resolveAutomationType(parsedPayload.automationType); const insertQuery = db(BOARD_POSTS_TABLE).insert({ title: parsedPayload.title, @@ -315,24 +812,36 @@ export async function createBoardPost(payload: z.infer ({ + board_post_id: insertedId, + sequence_no: index + 1, + title: requestItem.title, + content: requestItem.content, + workflow_state: 'pending', + created_at: db.fn.now(), + updated_at: db.fn.now(), + })), + ); - if (!row) { + const item = await getBoardPost(insertedId); + + if (!item) { throw new Error('저장된 게시글을 다시 불러오지 못했습니다.'); } - return mapBoardPostRow(row); + return item; } export async function receiveBoardPostAutomation( @@ -346,96 +855,201 @@ export async function receiveBoardPostAutomation( await ensureBoardPostsTable(); await ensurePlanTable(); - return db.transaction(async (trx) => { + const transactionResult = await 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) { + const requestRows = await trx(BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: id }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc') + .forUpdate(); + + const requestExecutionMode = resolveBoardRequestExecutionMode(currentRow.request_execution_mode); + const registeredPlanItemIds = requestRows + .map((requestRow) => Number(requestRow.plan_item_id)) + .filter((value) => Number.isFinite(value) && value > 0); + const pendingRequestRows = requestRows.filter((requestRow) => !requestRow.plan_item_id && !requestRow.automation_received_at); + let targetRequestRows: Record[] = []; + + if (requestExecutionMode === 'all_at_once') { + targetRequestRows = pendingRequestRows.filter((requestRow) => resolveBoardRequestWorkflowState(requestRow.workflow_state) !== 'blocked'); + } else if (registeredPlanItemIds.length === 0) { + const firstPendingRequest = pendingRequestRows[0]; + if (firstPendingRequest) { + targetRequestRows = [firstPendingRequest]; + } + const waitingIds = pendingRequestRows.slice(1).map((requestRow) => Number(requestRow.id)).filter(Number.isFinite); + if (waitingIds.length > 0) { + await trx(BOARD_POST_REQUESTS_TABLE) + .whereIn('id', waitingIds) + .update({ + workflow_state: 'waiting', + updated_at: trx.fn.now(), + }); + } + } + + if (targetRequestRows.length === 0) { return { - item: mapBoardPostRow(currentRow), - planItemId: - currentRow.automation_plan_item_id === null || currentRow.automation_plan_item_id === undefined - ? null - : Number(currentRow.automation_plan_item_id), + planItemIds: registeredPlanItemIds, alreadyReceived: true, }; } - const title = String(currentRow.title ?? '').trim(); - const content = String(currentRow.content ?? '').trim(); - const attachments = mapBoardPostRow(currentRow).attachments; - const automationContextIds = mapBoardPostRow(currentRow).automationContextIds; - const workId = options?.planWorkIdBase?.trim() - ? resolveSequencedPlanWorkId( - options.planWorkIdBase, - (await trx(PLAN_TABLE).select('work_id')).map((row) => String(row.work_id ?? '')), - options.planWorkIdSuffixLabel, - ) - : `board-post-${id}`; - const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type); - const insertQuery = trx(PLAN_TABLE).insert({ - work_id: workId, - note: await buildBoardPostPlanNote(title, content, attachments, automationType, automationContextIds), - automation_type: normalizePlanAutomationType(currentRow.automation_type), - automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type, - automation_context_ids_json: stringifyAutomationContextIds(automationContextIds), - status: '등록', - release_target: 'release', - jangsing_processing_required: true, - auto_deploy_to_main: false, - suppress_web_push: options?.suppressWebPush ?? false, - worker_status: '대기', - last_error: null, - updated_at: trx.fn.now(), - }); - const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery; - const planItemId = resolveInsertedId(insertResult); + const createdPlanItemIds: number[] = []; - if (!planItemId) { - throw new Error('자동화 접수 후 Plan ID를 확인하지 못했습니다.'); + for (const requestRow of targetRequestRows) { + createdPlanItemIds.push( + await createPlanFromBoardRequest(trx, { + boardPostRow: currentRow, + requestRow, + sequenceInfo: { + index: Number(requestRow.sequence_no ?? 1), + total: requestRows.length, + }, + planWorkIdBase: options?.planWorkIdBase, + planWorkIdSuffixLabel: options?.planWorkIdSuffixLabel, + suppressWebPush: options?.suppressWebPush, + }), + ); } - 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('자동화 접수된 게시글을 다시 불러오지 못했습니다.'); - } + await syncBoardPostLegacyLinkColumns(trx, id); return { - item: mapBoardPostRow(updatedRow), - planItemId, + planItemIds: createdPlanItemIds, alreadyReceived: false, }; }); + + if (!transactionResult) { + return null; + } + + return { + item: await getBoardPost(id), + ...transactionResult, + }; } -export async function updateBoardPost(id: number, payload: z.infer) { +export async function progressBoardPostAutomationByPlanResult( + planItemId: number, + outcome: 'completed' | 'failed', +) { + await ensureBoardPostsTable(); + await ensurePlanTable(); + + const updatedBoardPostId = await db.transaction(async (trx) => { + const currentRequestRow = await trx(BOARD_POST_REQUESTS_TABLE).where({ plan_item_id: planItemId }).forUpdate().first(); + + if (!currentRequestRow) { + return null; + } + + const boardPostId = Number(currentRequestRow.board_post_id); + const boardPostRow = await trx(BOARD_POSTS_TABLE).where({ id: boardPostId }).forUpdate().first(); + + if (!boardPostRow) { + return null; + } + + const requestExecutionMode = resolveBoardRequestExecutionMode(boardPostRow.request_execution_mode); + const requestRows = await trx(BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: boardPostId }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc') + .forUpdate(); + + await trx(BOARD_POST_REQUESTS_TABLE) + .where({ id: Number(currentRequestRow.id) }) + .update({ + workflow_state: outcome === 'completed' ? 'completed' : 'failed', + updated_at: trx.fn.now(), + }); + + if (requestExecutionMode === 'all_at_once') { + await syncBoardPostLegacyLinkColumns(trx, boardPostId); + return boardPostId; + } + + const nextWaitingRequest = requestRows.find((requestRow) => { + if (Number(requestRow.id) === Number(currentRequestRow.id)) { + return false; + } + + const state = resolveBoardRequestWorkflowState(requestRow.workflow_state); + return !requestRow.plan_item_id && !requestRow.automation_received_at && (state === 'waiting' || state === 'pending'); + }); + + if (!nextWaitingRequest) { + await syncBoardPostLegacyLinkColumns(trx, boardPostId); + return boardPostId; + } + + if (requestExecutionMode === 'after_previous_success' && outcome === 'failed') { + const blockedRequestIds = requestRows + .filter((requestRow) => Number(requestRow.sequence_no ?? 0) > Number(currentRequestRow.sequence_no ?? 0)) + .map((requestRow) => Number(requestRow.id)) + .filter(Number.isFinite); + + if (blockedRequestIds.length > 0) { + await trx(BOARD_POST_REQUESTS_TABLE) + .whereIn('id', blockedRequestIds) + .update({ + workflow_state: 'blocked', + updated_at: trx.fn.now(), + }); + } + } else { + await createPlanFromBoardRequest(trx, { + boardPostRow, + requestRow: nextWaitingRequest, + sequenceInfo: { + index: Number(nextWaitingRequest.sequence_no ?? 1), + total: requestRows.length, + }, + }); + } + + await syncBoardPostLegacyLinkColumns(trx, boardPostId); + return boardPostId; + }); + + if (!updatedBoardPostId) { + return null; + } + + return getBoardPost(updatedBoardPostId); +} + +export async function updateBoardPost(id: number, payload: BoardPostPayloadInput) { await ensureBoardPostsTable(); const parsedPayload = boardPostPayloadSchema.parse(payload); + const normalizedRequestItems = normalizeBoardPostRequestItems( + parsedPayload.requestItems, + parsedPayload.title, + parsedPayload.content, + ); const automationType = await resolveAutomationType(parsedPayload.automationType); - return db.transaction(async (trx) => { + + const updatedBoardPostId = await db.transaction(async (trx) => { const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); if (!currentRow) { return null; } - if (isBoardPostAutomationLocked(currentRow)) { + const currentRequestRows = await trx(BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: id }) + .orderBy('sequence_no', 'asc') + .orderBy('id', 'asc') + .forUpdate(); + + if (isBoardPostAutomationLocked(currentRequestRows, currentRow)) { throw new BoardPostAutomationLockedError('update'); } @@ -448,12 +1062,31 @@ export async function updateBoardPost(id: number, payload: z.infer ({ + board_post_id: id, + sequence_no: index + 1, + title: requestItem.title, + content: requestItem.content, + workflow_state: 'pending', + created_at: trx.fn.now(), + updated_at: trx.fn.now(), + })), + ); + + return id; }); + + if (!updatedBoardPostId) { + return null; + } + + return getBoardPost(updatedBoardPostId); } export async function deleteBoardPost(id: number) { @@ -465,10 +1098,15 @@ export async function deleteBoardPost(id: number) { return false; } - if (isBoardPostAutomationLocked(currentRow)) { + const currentRequestRows = await trx(BOARD_POST_REQUESTS_TABLE) + .where({ board_post_id: id }) + .forUpdate(); + + if (isBoardPostAutomationLocked(currentRequestRows, currentRow)) { throw new BoardPostAutomationLockedError('delete'); } + await trx(BOARD_POST_REQUESTS_TABLE).where({ board_post_id: id }).delete(); const deletedCount = await trx(BOARD_POSTS_TABLE).where({ id }).del(); return deletedCount > 0; }); diff --git a/etc/servers/work-server/src/services/chat-message-parts.ts b/etc/servers/work-server/src/services/chat-message-parts.ts index 0b95d43..112b02c 100644 --- a/etc/servers/work-server/src/services/chat-message-parts.ts +++ b/etc/servers/work-server/src/services/chat-message-parts.ts @@ -22,6 +22,11 @@ function normalizeUrl(value: string) { return ''; } + const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i); + if (malformedResourceMatch?.[1]) { + return `/${malformedResourceMatch[1]}`; + } + if (/^(?:https?:\/\/|\/)/i.test(normalized)) { return normalized; } @@ -29,11 +34,43 @@ function normalizeUrl(value: string) { return ''; } +function decodeUrlComponentSafely(value: string) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function resolveLinkCardUrlAndActionLabel(rawUrl: string, rawActionLabel?: string) { + let resolvedUrl = normalizeText(rawUrl); + let resolvedActionLabel = normalizeText(rawActionLabel); + + if (!resolvedActionLabel) { + const decodedUrl = decodeUrlComponentSafely(resolvedUrl); + const dividerIndex = decodedUrl.lastIndexOf('|'); + + if (dividerIndex > 0 && dividerIndex < decodedUrl.length - 1) { + resolvedUrl = decodedUrl.slice(0, dividerIndex).trim(); + resolvedActionLabel = decodedUrl.slice(dividerIndex + 1).trim(); + } + } + + return { + url: normalizeUrl(resolvedUrl), + actionLabel: resolvedActionLabel || null, + }; +} + function hasKnownFileExtension(url: string) { const pathname = url.split('?')[0] ?? ''; return /\.[a-z0-9]{1,8}$/i.test(pathname); } +function isInternalResourceUrl(url: string) { + return RESOURCE_PATH_PREFIXES.some((prefix) => url.startsWith(prefix)); +} + function isStructuredLinkCardCandidate(url: string) { const normalized = normalizeUrl(url); @@ -41,15 +78,11 @@ function isStructuredLinkCardCandidate(url: string) { return false; } - if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + if (isInternalResourceUrl(normalized)) { return false; } - if (/^https?:\/\//i.test(normalized)) { - return !hasKnownFileExtension(normalized); - } - - return !hasKnownFileExtension(normalized); + return /^https?:\/\//i.test(normalized) && !hasKnownFileExtension(normalized); } function buildFallbackLinkTitle(url: string) { @@ -94,8 +127,7 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null { const [rawTitle, rawUrl, rawActionLabel] = segments; const title = normalizeText(rawTitle); - const url = normalizeUrl(rawUrl); - const actionLabel = normalizeText(rawActionLabel) || null; + const { url, actionLabel } = resolveLinkCardUrlAndActionLabel(rawUrl, rawActionLabel); if (!title || !url) { return null; @@ -160,6 +192,14 @@ export function extractChatMessageParts(text: string) { if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) { keptLines.push(line); + continue; + } + + const latestPart = parts.at(-1); + if (latestPart && isInternalResourceUrl(latestPart.url)) { + parts.pop(); + seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`); + keptLines.push(latestPart.url); } } diff --git a/etc/servers/work-server/src/services/chat-room-service.test.ts b/etc/servers/work-server/src/services/chat-room-service.test.ts index 04b77d1..501d5bd 100644 --- a/etc/servers/work-server/src/services/chat-room-service.test.ts +++ b/etc/servers/work-server/src/services/chat-room-service.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH, buildChatConversationRequestPatchFromMessage, isVisibleConversationMessage, mergeChatConversationRequestStatus, @@ -46,6 +47,10 @@ test('buildChatConversationRequestPatchFromMessage ignores system progress messa ); }); +test('CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH allows long chat type guidance text', () => { + assert.ok(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH >= 2691); +}); + test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => { assert.equal( isVisibleConversationMessage({ diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index 0965dbe..52e5ec2 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -9,6 +9,7 @@ export const CHAT_CONVERSATION_CLIENT_TABLE = 'chat_conversation_clients'; export const CHAT_CONVERSATION_REQUEST_TABLE = 'chat_conversation_requests'; export const CHAT_CONVERSATION_ACTIVITY_TABLE = 'chat_conversation_request_activities'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; +export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000; const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000; const conversationPayloadSchema = z.object({ @@ -17,8 +18,9 @@ const conversationPayloadSchema = z.object({ title: z.string().trim().max(200).nullable().optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), lastChatTypeId: z.string().trim().max(120).nullable().optional(), + generalSectionName: z.string().trim().max(120).nullable().optional(), contextLabel: z.string().trim().max(200).nullable().optional(), - contextDescription: z.string().trim().max(2000).nullable().optional(), + contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).nullable().optional(), notifyOffline: z.boolean().optional(), }); @@ -38,6 +40,7 @@ export type ChatConversationItem = { title: string; chatTypeId: string | null; lastChatTypeId: string | null; + generalSectionName: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; @@ -47,7 +50,9 @@ export type ChatConversationItem = { currentJobMessage: string | null; currentQueueSize: number; currentStatusUpdatedAt: string | null; + lastRequestPreview: string; lastMessagePreview: string; + lastResponsePreview: string; createdAt: string; updatedAt: string; lastMessageAt: string | null; @@ -95,6 +100,22 @@ export type ChatConversationActivityLogItem = { updatedAt: string | null; }; +export type RecoverableChatConversationRequestItem = { + sessionId: string; + clientId: string | null; + chatTypeId: string | null; + lastChatTypeId: string | null; + generalSectionName: string | null; + contextLabel: string | null; + contextDescription: string | null; + currentRequestId: string | null; + currentJobStatus: ChatConversationItem['currentJobStatus']; + requestId: string; + status: ChatConversationRequestStatus; + userText: string; + createdAt: string; +}; + export type ChatConversationDetailPage = { messages: StoredChatMessage[]; requests: ChatConversationRequestItem[]; @@ -152,6 +173,75 @@ function createPreview(text: string) { return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } +const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [ + /이전\s*(채팅|대화|문맥)/u, + /이전\s*요청/u, + /마지막\s*요청/u, + /요청내역/u, + /두\s*단어/u, + /최근\s*작업\s*(뱃지|badge|라벨)/iu, + /(?:이어서|이어진|이어가|계속|추가로|연달아|후속|마저)/u, + /^(?:그리고|그럼|그러면|또|또한|근데|그런데|여기도|여기서도|이것도|그것도|저것도|이거|그거|저거)/u, + /\b(?:also|continue|continued|follow[\s-]?up|same|again)\b/i, +] as const; + +function normalizeRequestPreviewText(text: string) { + return String(text ?? '').replace(/\s+/g, ' ').trim(); +} + +function isContextDependentRequestPreview(text: string) { + const normalized = normalizeRequestPreviewText(text); + + if (!normalized) { + return false; + } + + if (CONTEXT_DEPENDENT_REQUEST_PATTERNS.some((pattern) => pattern.test(normalized))) { + return true; + } + + if (normalized.length <= 16) { + return true; + } + + return false; +} + +function buildLatestRequestPreview( + requests: Array<{ text: string; createdAt: string | null }>, +): { text: string; createdAt: string | null } | null { + const normalizedRequests = requests + .map((request) => ({ + text: normalizeRequestPreviewText(request.text), + createdAt: request.createdAt, + })) + .filter((request) => Boolean(request.text)); + + const latestRequest = normalizedRequests[0]; + + if (!latestRequest) { + return null; + } + + if (!isContextDependentRequestPreview(latestRequest.text)) { + return latestRequest; + } + + const previousRequest = + normalizedRequests.slice(1).find((request) => !isContextDependentRequestPreview(request.text)) ?? + normalizedRequests[1] ?? + null; + + if (!previousRequest) { + return latestRequest; + } + + return { + text: `${previousRequest.text} ${latestRequest.text}`.trim(), + createdAt: latestRequest.createdAt, + }; +} + function isPreviewableConversationMessage(row: { author?: unknown; text?: unknown }) { const author = String(row.author ?? ''); const text = String(row.text ?? '').trim(); @@ -179,6 +269,7 @@ function mapConversationRow(row: Record): ChatConversationItem title: String(row.title ?? '새 대화'), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), + generalSectionName: row.general_section_name == null ? null : String(row.general_section_name), contextLabel: row.context_label == null ? null : String(row.context_label), contextDescription: row.context_description == null ? null : String(row.context_description), notifyOffline: Boolean(row.notify_offline), @@ -188,7 +279,9 @@ function mapConversationRow(row: Record): ChatConversationItem currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message), currentQueueSize: Number(row.current_queue_size ?? 0), currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at), + lastRequestPreview: '', lastMessagePreview: String(row.last_message_preview ?? ''), + lastResponsePreview: '', createdAt: normalizeDateTimeValue(row.created_at) ?? '', updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', lastMessageAt: normalizeDateTimeValue(row.last_message_at), @@ -322,6 +415,30 @@ function isConversationRequestActive( return currentJobStatus === 'queued' || currentJobStatus === 'started'; } +function hasConversationMetadata( + conversation: { + title?: unknown; + chat_type_id?: unknown; + last_chat_type_id?: unknown; + general_section_name?: unknown; + context_label?: unknown; + context_description?: unknown; + current_request_id?: unknown; + current_job_status?: unknown; + } | null | undefined, +) { + return [ + conversation?.title, + conversation?.chat_type_id, + conversation?.last_chat_type_id, + conversation?.general_section_name, + conversation?.context_label, + conversation?.context_description, + conversation?.current_request_id, + conversation?.current_job_status, + ].some((value) => String(value ?? '').trim().length > 0); +} + function normalizeStaleRequestItem( item: ChatConversationRequestItem, conversation: { @@ -626,24 +743,81 @@ async function getLatestRequestPreviewMap(sessionIds: string[]) { .orderBy('request_id', 'desc'); const requestMap = new Map(); + const requestRowsBySession = new Map>(); + const completedSessionIds = new Set(); for (const row of rows) { const sessionId = String(row.session_id ?? '').trim(); const userText = String(row.user_text ?? '').trim(); - if (!sessionId || requestMap.has(sessionId) || !userText) { + if (!sessionId || completedSessionIds.has(sessionId) || !userText) { continue; } - requestMap.set(sessionId, { + const requestRows = requestRowsBySession.get(sessionId) ?? []; + requestRows.push({ text: userText, createdAt: normalizeDateTimeValue(row.created_at), }); + + if (requestRows.length >= 5) { + completedSessionIds.add(sessionId); + } + + requestRowsBySession.set(sessionId, requestRows); + + if (completedSessionIds.size >= normalizedSessionIds.length) { + break; + } + } + + for (const sessionId of normalizedSessionIds) { + const preview = buildLatestRequestPreview(requestRowsBySession.get(sessionId) ?? []); + + if (!preview) { + continue; + } + + requestMap.set(sessionId, preview); } return requestMap; } +async function getLatestResponsePreviewMap(sessionIds: string[]) { + const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); + + if (normalizedSessionIds.length === 0) { + return new Map(); + } + + const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('session_id', 'response_text', 'answered_at', 'updated_at', 'status', 'request_id') + .whereIn('session_id', normalizedSessionIds) + .whereNot('status', 'removed') + .orderBy('session_id', 'asc') + .orderByRaw('COALESCE(answered_at, updated_at, created_at) desc') + .orderBy('request_id', 'desc'); + + const responseMap = new Map(); + + for (const row of rows) { + const sessionId = String(row.session_id ?? '').trim(); + const responseText = String(row.response_text ?? '').trim(); + + if (!sessionId || responseMap.has(sessionId) || !responseText) { + continue; + } + + responseMap.set(sessionId, { + text: responseText, + createdAt: normalizeDateTimeValue(row.answered_at ?? row.updated_at), + }); + } + + return responseMap; +} + function resolveConversationPreviewOverride( mapped: ChatConversationItem, latestMessage: { text: string; createdAt: string | null } | undefined, @@ -717,6 +891,7 @@ export async function ensureChatConversationTables() { table.string('title', 200).notNullable().defaultTo('새 대화'); table.string('chat_type_id', 120).nullable(); table.string('last_chat_type_id', 120).nullable(); + table.string('general_section_name', 120).nullable(); table.string('context_label', 200).nullable(); table.text('context_description').nullable(); table.boolean('notify_offline').notNullable().defaultTo(false); @@ -737,6 +912,7 @@ export async function ensureChatConversationTables() { ['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], ['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()], + ['general_section_name', (table) => table.string('general_section_name', 120).nullable()], ['context_label', (table) => table.string('context_label', 200).nullable()], ['context_description', (table) => table.text('context_description').nullable()], ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], @@ -1024,6 +1200,7 @@ export async function createChatConversation(payload: z.input 0) { + const candidateSessionIds = rows.map((row) => String(row.session_id ?? '').trim()).filter(Boolean); + const [messageSessionRows, requestSessionRows] = await Promise.all([ + db(CHAT_CONVERSATION_MESSAGE_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds), + db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').whereIn('session_id', candidateSessionIds), + ]); + const visibleSessionIds = new Set( + [...messageSessionRows, ...requestSessionRows] + .map((row) => String(row.session_id ?? '').trim()) + .filter(Boolean), + ); + + rows = rows.filter((row) => { + const sessionId = String(row.session_id ?? '').trim(); + return visibleSessionIds.has(sessionId) || hasConversationMetadata(row); + }); + } + const latestPreviewMessageMap = await getLatestPreviewableMessageMap( rows.map((row) => String(row.session_id ?? '')), ); const latestRequestPreviewMap = await getLatestRequestPreviewMap( rows.map((row) => String(row.session_id ?? '')), ); + const latestResponsePreviewMap = await getLatestResponsePreviewMap( + rows.map((row) => String(row.session_id ?? '')), + ); const latestResponseMessageIdMap = await getLatestResponseMessageIdMap( rows.map((row) => String(row.session_id ?? '')), ); @@ -1255,6 +1455,8 @@ export async function listChatConversations( latestPreviewMessageMap.get(mapped.sessionId), latestRequestPreviewMap.get(mapped.sessionId), ), + lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''), + lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''), hasUnreadResponse: false, }; }) @@ -1294,6 +1496,8 @@ export async function listChatConversations( latestPreviewMessage, latestRequestPreviewMap.get(mapped.sessionId), ), + lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''), + lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''), clientId: normalizedUnreadStateClientId, notifyOffline: preference?.notifyOffline ?? mapped.notifyOffline, hasUnreadResponse: @@ -1781,6 +1985,71 @@ export async function listChatConversationActivityLogs( return requestIds.map((requestId) => activityMap.get(requestId)).filter(Boolean) as ChatConversationActivityLogItem[]; } +export async function listRecoverableChatConversationRequests(): Promise { + await ensureChatConversationTables(); + + const rows = await db(`${CHAT_CONVERSATION_REQUEST_TABLE} as request`) + .join(`${CHAT_CONVERSATION_TABLE} as conversation`, 'conversation.session_id', 'request.session_id') + .select( + 'request.session_id', + 'request.request_id', + 'request.status', + 'request.user_text', + 'request.created_at', + 'conversation.client_id', + 'conversation.chat_type_id', + 'conversation.last_chat_type_id', + 'conversation.general_section_name', + 'conversation.context_label', + 'conversation.context_description', + 'conversation.current_request_id', + 'conversation.current_job_status', + ) + .whereIn('request.status', ['accepted', 'queued', 'started']) + .andWhere((builder) => { + builder.whereNull('request.terminal_at'); + }) + .andWhere((builder) => { + builder.whereNull('request.response_message_id').orWhere('request.response_message_id', 0); + }) + .orderByRaw( + "case when request.request_id = conversation.current_request_id then 0 else 1 end asc", + ) + .orderBy('request.session_id', 'asc') + .orderBy('request.created_at', 'asc') + .orderBy('request.request_id', 'asc'); + + return rows + .map((row) => { + const sessionId = String(row.session_id ?? '').trim(); + const requestId = String(row.request_id ?? '').trim(); + const userText = String(row.user_text ?? '').trim(); + const createdAt = normalizeDateTimeValue(row.created_at) ?? ''; + + if (!sessionId || !requestId || !userText || !createdAt) { + return null; + } + + return { + sessionId, + clientId: row.client_id == null ? null : String(row.client_id), + chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), + lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), + generalSectionName: row.general_section_name == null ? null : String(row.general_section_name), + contextLabel: row.context_label == null ? null : String(row.context_label), + contextDescription: row.context_description == null ? null : String(row.context_description), + currentRequestId: row.current_request_id == null ? null : String(row.current_request_id), + currentJobStatus: + row.current_job_status == null ? null : (String(row.current_job_status) as ChatConversationItem['currentJobStatus']), + requestId, + status: String(row.status ?? 'accepted') as ChatConversationRequestStatus, + userText, + createdAt, + } satisfies RecoverableChatConversationRequestItem; + }) + .filter(Boolean) as RecoverableChatConversationRequestItem[]; +} + export async function updateChatConversationJobState( sessionId: string, payload: { diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 347003b..de85df4 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { env } from '../config/env.js'; @@ -8,12 +8,16 @@ import { collectOfflineNotificationClientIds, createActivityLogMessage, buildAgenticCodexPrompt, + ensureChatSessionResourceDirectories, + ensureChatSessionReferenceResource, extractDiffCodeBlocks, extractCodexStreamText, fitActivityLogLines, isAutomationRegistrationCountRequest, resolveResponseTimestamp, rewriteCodexOutputWithChatResources, + summarizeActivityProgressLine, + shouldSendOfflineChatNotification, shouldUseAgenticCodexReply, shouldUseTemplateMacroReply, validateAgenticCodexRuntime, @@ -27,6 +31,32 @@ test('collectOfflineNotificationClientIds merges session and conversation target ); }); +test('shouldSendOfflineChatNotification blocks chat push when app setting disables room notifications', () => { + assert.equal( + shouldSendOfflineChatNotification({ + receiveRoomNotifications: false, + conversationNotifyOffline: true, + }), + false, + ); + + assert.equal( + shouldSendOfflineChatNotification({ + receiveRoomNotifications: true, + conversationNotifyOffline: false, + }), + false, + ); + + assert.equal( + shouldSendOfflineChatNotification({ + receiveRoomNotifications: true, + conversationNotifyOffline: true, + }), + true, + ); +}); + test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => { assert.equal(isAutomationRegistrationCountRequest('오늘 자동화 등록 총 건수'), true); assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true); @@ -117,10 +147,141 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions' assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/); assert.match(prompt, /### 반드시 지킬 context 원문/); assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./); + assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/); assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/); + assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/); + assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/); + assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./); assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)')); }); +test('ensureChatSessionReferenceResource creates a persistent per-room markdown resource and preserves manual notes', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-')); + + const resourcePath = await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-1', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '일반 요청', + chatTypeDescription: '일반 요청 설명', + }, + input: '첫 요청', + recentHistoryLines: ['[user] 이전 요청'], + omittedHistoryCount: 0, + }); + + assert.equal(resourcePath, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md'); + + const absolutePath = path.join(tempDir, resourcePath); + const firstContent = await readFile(absolutePath, 'utf8'); + assert.match(firstContent, /# 채팅방 참고 리소스/); + assert.match(firstContent, /## 자동 갱신 문맥/); + assert.match(firstContent, /## 수동 메모/); + + const manuallyEditedContent = firstContent.replace( + '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.', + '- 유지 메모: 이 줄은 보존되어야 합니다.', + ); + await writeFile(absolutePath, manuallyEditedContent, 'utf8'); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-2', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: 'message-list', + pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '일반 요청', + chatTypeDescription: '일반 요청 설명', + }, + input: '둘째 요청', + recentHistoryLines: ['[user] 첫 요청', '[codex] 첫 답변'], + omittedHistoryCount: 1, + }); + + const updatedContent = await readFile(absolutePath, 'utf8'); + assert.match(updatedContent, /request-2/); + assert.match(updatedContent, /둘째 요청/); + assert.match(updatedContent, /이전 1개 메시지는 제외되었습니다\./); + assert.match(updatedContent, /유지 메모: 이 줄은 보존되어야 합니다\./); +}); + +test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-session-dirs-')); + const resourceRoot = path.join(tempDir, 'public/.codex_chat/chat-room/resource'); + + await mkdir(resourceRoot, { recursive: true }); + + const ensured = await ensureChatSessionResourceDirectories(tempDir, 'chat-room'); + const uploadStat = await stat(ensured.uploadRoot); + const sourceStat = await stat(ensured.sourceRoot); + + assert.equal(uploadStat.isDirectory(), true); + assert.equal(sourceStat.isDirectory(), true); + assert.equal(uploadStat.mode & 0o777, 0o777); + assert.equal(sourceStat.mode & 0o777, 0o777); +}); + +test('ensureChatSessionReferenceResource rebuilds a corrupted auto section without keeping duplicated history text', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-corrupted-')); + const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md'); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile( + absolutePath, + [ + '# 채팅방 참고 리소스', + '', + '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.', + '', + '', + '## 자동 갱신 문맥', + '- 오래된 본문', + '', + "이전 응답 조각'; @@ +export async function ensureChatSessionReferenceResource(args: { ... }) {", + '', + '', + '## 수동 메모', + '- 유지 메모', + '', + ].join('\n'), + 'utf8', + ); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-3', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '일반 요청', + chatTypeDescription: '일반 요청 설명', + }, + input: '셋째 요청', + recentHistoryLines: ['[user] 둘째 요청'], + omittedHistoryCount: 0, + }); + + const rebuiltContent = await readFile(absolutePath, 'utf8'); + assert.equal((rebuiltContent.match(//g) ?? []).length, 1); + assert.equal((rebuiltContent.match(//g) ?? []).length, 1); + assert.match(rebuiltContent, /셋째 요청/); + assert.match(rebuiltContent, /## 수동 메모\n- 유지 메모/); + assert.doesNotMatch(rebuiltContent, /이전 응답 조각/); +}); + test('extractChatMessageParts strips link-card markers into structured parts', () => { assert.deepEqual( extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://test.sm-home.cloud/chat/live|열기]]'].join('\n')), @@ -138,6 +299,24 @@ test('extractChatMessageParts strips link-card markers into structured parts', ( ); }); +test('extractChatMessageParts repairs malformed resource link-card urls and encoded action labels', () => { + assert.deepEqual( + extractChatMessageParts( + [ + '결과 본문', + '[[link-card:업무용 모바일 시안 미리보기|https:/api/chat/resources/.codex_chat/69784018-c492-4021-ad98-1751c8cc90da/resource/mobile-ui-samples.html%7C미리보기 열기]]', + ].join('\n'), + ), + { + strippedText: [ + '결과 본문', + '/api/chat/resources/.codex_chat/69784018-c492-4021-ad98-1751c8cc90da/resource/mobile-ui-samples.html', + ].join('\n'), + parts: [], + }, + ); +}); + test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => { assert.deepEqual( extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')), @@ -204,6 +383,20 @@ test('createActivityLogMessage keeps fitted activity history instead of the late ); }); +test('summarizeActivityProgressLine prefers meaningful command reasons', () => { + assert.equal( + summarizeActivityProgressLine('# 이유: 관련 파일을 읽어 현재 구조를 파악합니다.\n$ sed -n \'1,40p\' AGENTS.md'), + '관련 파일을 읽어 현재 구조를 파악합니다.', + ); +}); + +test('summarizeActivityProgressLine skips boilerplate status lines', () => { + assert.equal( + summarizeActivityProgressLine('# 상태: 요청을 처리합니다.\n# 상태: 응답 생성이 완료되었습니다.'), + '', + ); +}); + test('extractCodexStreamText ignores command execution item completions', () => { assert.deepEqual( extractCodexStreamText({ @@ -279,6 +472,31 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n'); }); +test('rewriteCodexOutputWithChatResources strips document-style preview markers and missing resource previews', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); + const markdownPath = path.join(repoPath, 'public', '.codex_chat', 'chat-room', 'resource', 'source', 'chat-room-reference.md'); + const imagePath = path.join(repoPath, 'public', '.codex_chat', 'chat-room', 'resource', 'captures', 'chat-mobile.png'); + await mkdir(path.dirname(markdownPath), { recursive: true }); + await mkdir(path.dirname(imagePath), { recursive: true }); + await writeFile(markdownPath, '# 참고 문서\n', 'utf8'); + await writeFile(imagePath, 'png', 'utf8'); + + const rewritten = await rewriteCodexOutputWithChatResources( + [ + '문서 preview는 제거되어야 합니다.', + '[[preview:/api/chat/resources/.codex_chat/chat-room/resource/source/chat-room-reference.md]]', + '[[preview:https:/api/chat/resources/.codex_chat/chat-room/resource/missing.md]]', + '[[preview:/api/chat/resources/.codex_chat/chat-room/resource/captures/chat-mobile.png]]', + ].join('\n'), + repoPath, + 'chat-room', + ); + + assert.doesNotMatch(rewritten, /chat-room-reference\.md\]\]/); + assert.doesNotMatch(rewritten, /missing\.md\]\]/); + assert.match(rewritten, /\[\[preview:\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/captures\/chat-mobile\.png\]\]/); +}); + test('rewriteCodexOutputWithChatResources keeps diff paths intact while rewriting prose file paths', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); const sourcePath = path.join(repoPath, 'src', 'a.ts'); diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 61cf053..15e9baf 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, stat, writeFile } from 'node:fs/promises'; +import { chmod, cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import webpush from 'web-push'; import type { FastifyBaseLogger } from 'fastify'; @@ -14,6 +14,7 @@ import { appendChatConversationActivityLine, getChatConversationRequest, getChatConversation, + listRecoverableChatConversationRequests, listChatConversationMessages, listChatConversationOfflineNotificationClientIds, listChatConversationRequests, @@ -87,6 +88,7 @@ type ChatInboundMessage = text: string; requestId?: string; mode?: 'queue' | 'direct'; + omitPromptHistory?: boolean; chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; @@ -170,6 +172,7 @@ type ChatSessionState = { text: string; mode: 'queue' | 'direct'; requestedAtMs: number; + omitPromptHistory?: boolean; context: ChatContext | null; }>; activeRequestCount: number; @@ -208,6 +211,10 @@ const STREAM_CAPTURE_LIMIT = 256 * 1024; const CHAT_PUBLIC_RESOURCE_DIR = '.codex_chat'; const CHAT_PUBLIC_RESOURCE_SUBDIR = 'resource'; const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; +const CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH = 'source/chat-room-reference.md'; +const CHAT_SESSION_REFERENCE_AUTO_START = ''; +const CHAT_SESSION_REFERENCE_AUTO_END = ''; +const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777; const SOCKET_READY_STATE_OPEN = 1; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const MAX_CHAT_ACTIVITY_MESSAGE_LINES = 240; @@ -352,6 +359,17 @@ export function collectOfflineNotificationClientIds(sessionClientId?: string | n return [...nextClientIds]; } +export function shouldSendOfflineChatNotification(options: { + receiveRoomNotifications?: boolean | null; + conversationNotifyOffline?: boolean | null; +}) { + if (options.receiveRoomNotifications === false) { + return false; + } + + return options.conversationNotifyOffline === true; +} + function isPreparingChatReply(text?: string | null) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.startsWith('응답을 준비하고 있습니다'); @@ -380,6 +398,11 @@ export function resolveResponseTimestamp(requestedAtMs?: number | null, nowMs = return formatTime(new Date(Math.max(nowMs, Number(requestedAtMs) + 1_000))); } +function parseRequestedAtMs(value: string) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + function formatKstDate(date = new Date()) { return new Intl.DateTimeFormat('en-CA', { timeZone: KST_TIME_ZONE, @@ -1166,10 +1189,12 @@ function buildChatResourcePublicUrl(relativePath: string) { } function normalizeEmbeddedChatResourceUrls(text: string) { - return String(text ?? '').replace( - /(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g, - (_match, resourcePath) => resourcePath, - ); + return String(text ?? '') + .replace(/https?:\/(api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/gi, '/$1') + .replace( + /(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g, + (_match, resourcePath) => resourcePath, + ); } function toPosixPath(value: string) { @@ -1286,6 +1311,7 @@ export async function stageChatResourceFile(repoPath: string, sessionId: string, return null; } + await ensureChatSessionResourceDirectories(repoPath, sessionId); const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/${resolvedSource.relativePath}`; const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath); await mkdir(path.dirname(targetAbsolutePath), { recursive: true }); @@ -1302,6 +1328,7 @@ async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, o } const urls: string[] = []; + await ensureChatSessionResourceDirectories(repoPath, sessionId); for (const [index, diffText] of diffBlocks.entries()) { const fileName = diffBlocks.length === 1 ? 'response.diff' : `response-${index + 1}.diff`; @@ -1316,6 +1343,30 @@ async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, o return urls; } +async function ensureWorldWritableDirectory(absolutePath: string) { + await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE }); + await chmod(absolutePath, CHAT_SESSION_RESOURCE_DIR_MODE).catch(() => {}); +} + +export async function ensureChatSessionResourceDirectories(repoPath: string, sessionId: string) { + const sessionRoot = path.join(repoPath, 'public', CHAT_PUBLIC_RESOURCE_DIR, sessionId); + const resourceRoot = path.join(sessionRoot, CHAT_PUBLIC_RESOURCE_SUBDIR); + const sourceRoot = path.join(resourceRoot, 'source'); + const uploadRoot = path.join(resourceRoot, 'uploads'); + + await ensureWorldWritableDirectory(sessionRoot); + await ensureWorldWritableDirectory(resourceRoot); + await ensureWorldWritableDirectory(sourceRoot); + await ensureWorldWritableDirectory(uploadRoot); + + return { + sessionRoot, + resourceRoot, + sourceRoot, + uploadRoot, + }; +} + function appendDiffResourceLinks(output: string, diffUrls: string[]) { if (diffUrls.length === 0) { return output; @@ -1331,6 +1382,80 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) { return `${output}\n\n${hiddenPreviewTags}`; } +function isPreviewEligibleChatResourceUrl(url: string) { + const pathname = url.split('?')[0]?.toLowerCase() ?? ''; + return /\.(?:png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|pdf|html?)$/i.test(pathname); +} + +function resolveChatResourcePublicUrlToAbsolutePath(repoPath: string, url: string) { + const normalizedUrl = normalizeEmbeddedChatResourceUrls(url).trim(); + const relativeUrl = normalizedUrl.replace(/^\/+/, ''); + const routePrefix = `${CHAT_API_RESOURCE_ROUTE_PREFIX.replace(/^\/+/, '')}/`; + + if (!relativeUrl.startsWith(routePrefix)) { + return null; + } + + const encodedResourcePath = relativeUrl.slice(routePrefix.length); + + try { + const decodedPath = encodedResourcePath + .split('/') + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)) + .join('/'); + + if (!decodedPath.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) { + return null; + } + + return path.resolve(repoPath, 'public', decodedPath); + } catch { + return null; + } +} + +async function doesChatResourceUrlExist(repoPath: string, url: string) { + const absolutePath = resolveChatResourcePublicUrlToAbsolutePath(repoPath, url); + + if (!absolutePath) { + return false; + } + + try { + const fileStat = await stat(absolutePath); + return fileStat.isFile(); + } catch { + return false; + } +} + +async function sanitizeChatResourcePresentation(output: string, repoPath: string) { + let sanitized = normalizeEmbeddedChatResourceUrls(output); + const previewMatches = Array.from(sanitized.matchAll(/\[\[preview:([^\]\n]+)\]\]/gi)); + const previewReplacementMap = new Map(); + + for (const match of previewMatches) { + const marker = match[0] ?? ''; + const rawUrl = match[1]?.trim() ?? ''; + + if (!marker || previewReplacementMap.has(marker)) { + continue; + } + + const normalizedUrl = normalizeEmbeddedChatResourceUrls(rawUrl); + const exists = await doesChatResourceUrlExist(repoPath, normalizedUrl); + const keepMarker = exists && isPreviewEligibleChatResourceUrl(normalizedUrl); + previewReplacementMap.set(marker, keepMarker ? `[[preview:${normalizedUrl}]]` : ''); + } + + for (const [source, target] of previewReplacementMap.entries()) { + sanitized = sanitized.replaceAll(source, target); + } + + return sanitized.replace(/\n{3,}/g, '\n\n').trim(); +} + export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) { const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output); const escapedRepoPath = escapeRegExp(path.resolve(repoPath)); @@ -1366,11 +1491,11 @@ export async function rewriteCodexOutputWithChatResources(output: string, repoPa } } - rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput); + rewrittenOutput = await sanitizeChatResourcePresentation(rewrittenOutput, repoPath); rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks); const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output); - return appendDiffResourceLinks(rewrittenOutput, diffUrls); + return sanitizeChatResourcePresentation(appendDiffResourceLinks(rewrittenOutput, diffUrls), repoPath); } function normalizeChatPromptHistoryText(text: string) { @@ -1399,8 +1524,16 @@ async function buildRecentChatPromptHistory( limits?: { maxMessages?: number; maxChars?: number; + disabled?: boolean; }, ) { + if (limits?.disabled) { + return { + items: [], + omittedCount: 0, + }; + } + const maxMessages = normalizePromptHistoryMessageLimit(limits?.maxMessages); const maxChars = normalizePromptHistoryCharLimit(limits?.maxChars); const messages = await listChatConversationMessages(sessionId, { limit: 80 }); @@ -1447,6 +1580,125 @@ function cloneChatContext(context: ChatContext | null): ChatContext | null { return context ? { ...context } : null; } +function buildChatSessionReferenceAutoSection(args: { + context: ChatContext | null; + sessionId: string; + requestId: string; + input: string; + recentHistoryLines: string[]; + omittedHistoryCount: number; +}) { + const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청'; + const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음'; + const pageTitle = args.context?.pageTitle?.trim() || '없음'; + const topMenu = args.context?.topMenu?.trim() || '없음'; + const pageUrl = args.context?.pageUrl?.trim() || '없음'; + const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음'; + const historyLines = + args.recentHistoryLines.length > 0 + ? args.recentHistoryLines.map((line) => `- ${line}`) + : ['- 최근 대화 없음']; + + if (args.omittedHistoryCount > 0) { + historyLines.push(`- 최근 문맥 일부만 포함했습니다. 이전 ${args.omittedHistoryCount}개 메시지는 제외되었습니다.`); + } + + return [ + CHAT_SESSION_REFERENCE_AUTO_START, + '## 자동 갱신 문맥', + `- 마지막 갱신 시각: ${formatTime(new Date())}`, + `- sessionId: ${args.sessionId}`, + `- requestId: ${args.requestId}`, + `- 채팅 유형: ${chatTypeLabel}`, + `- 화면 제목: ${pageTitle}`, + `- topMenu: ${topMenu}`, + `- focusedComponentId: ${focusedComponentId}`, + `- pageUrl: ${pageUrl}`, + '', + '## 현재 채팅 유형 context', + chatTypeDescription, + '', + '## 최신 사용자 요청', + args.input.trim() || '없음', + '', + '## 최근 대화 요약', + ...historyLines, + CHAT_SESSION_REFERENCE_AUTO_END, + ].join('\n'); +} + +function mergeChatSessionReferenceContent(existingContent: string, autoSection: string) { + const trimmedExisting = existingContent.trim(); + const defaultHeader = [ + '# 채팅방 참고 리소스', + '', + '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.', + '사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.', + ].join('\n'); + const defaultManualSection = ['## 수동 메모', '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.'].join('\n'); + + if (!trimmedExisting) { + return [ + defaultHeader, + '', + autoSection, + '', + defaultManualSection, + '', + ].join('\n'); + } + + const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START); + const manualSectionMatch = existingContent.match(/(^|\n)(## 수동 메모[\s\S]*)$/m); + const preservedManualSection = manualSectionMatch?.[2]?.trim() || defaultManualSection; + + if (firstAutoStartIndex >= 0) { + const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader; + return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`; + } + + const preservedHeader = existingContent.replace(/(^|\n)(## 수동 메모[\s\S]*)$/m, '').trim() || defaultHeader; + return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`; +} + +export async function ensureChatSessionReferenceResource(args: { + repoPath: string; + sessionId: string; + requestId: string; + context: ChatContext | null; + input: string; + recentHistoryLines: string[]; + omittedHistoryCount: number; +}) { + const ensuredDirectories = await ensureChatSessionResourceDirectories(args.repoPath, args.sessionId); + const resourceRelativePath = `public/.codex_chat/${args.sessionId}/resource/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`; + const absolutePath = path.join(ensuredDirectories.sourceRoot, 'chat-room-reference.md'); + const autoSection = buildChatSessionReferenceAutoSection({ + context: args.context, + sessionId: args.sessionId, + requestId: args.requestId, + input: args.input, + recentHistoryLines: args.recentHistoryLines, + omittedHistoryCount: args.omittedHistoryCount, + }); + + let existingContent = ''; + + try { + existingContent = await readFile(absolutePath, 'utf8'); + } catch { + existingContent = ''; + } + + const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection); + + if (nextContent !== existingContent) { + await writeFile(absolutePath, nextContent, 'utf8'); + } + + return resourceRelativePath; +} + function buildChatTypeInstructionBlock(context: ChatContext | null) { const chatTypeLabel = context?.chatTypeLabel?.trim() || ''; const chatTypeDescription = context?.chatTypeDescription?.trim() || ''; @@ -1484,11 +1736,14 @@ export function buildAgenticCodexPrompt( promptContext?: { recentHistoryLines?: string[]; omittedHistoryCount?: number; + sessionReferenceResourcePath?: string; }, ) { const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`; const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`; + const sessionReferenceResourcePath = + promptContext?.sessionReferenceResourcePath || `${chatSessionResourceDir}/${CHAT_SESSION_REFERENCE_RESOURCE_RELATIVE_PATH}`; const recentHistoryLines = promptContext?.recentHistoryLines ?? []; const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0); @@ -1505,16 +1760,22 @@ export function buildAgenticCodexPrompt( '- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.', `- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`, `- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`, + `- 이 채팅방의 지속 참고 문서: ${sessionReferenceResourcePath}`, '- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.', '- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.', '- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.', + '- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.', + '- 이 채팅방에서 참고사항이나 설계 메모를 유지해야 하면 위 참고 문서를 계속 갱신하세요.', + '- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.', ...buildChatTypeInstructionBlock(context), '', '응답 규칙:', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.', '- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.', - '- 링크를 본문과 분리된 결과 컴포넌트로 보여줘야 하면 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.', + '- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.', + '- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.', + '- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.', '- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.', '- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.', '- 한국어로 간결하게 답하세요.', @@ -1679,6 +1940,9 @@ async function runAgenticCodexReply( input: string, sessionId: string, requestId: string, + options?: { + omitPromptHistory?: boolean; + }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, isCancellationRequested?: () => boolean, @@ -1689,6 +1953,7 @@ async function runAgenticCodexReply( const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, { maxMessages: appConfig.chat?.maxContextMessages, maxChars: appConfig.chat?.maxContextChars, + disabled: options?.omitPromptHistory === true, }); const codexLiveMaxExecutionSeconds = typeof appConfig.chat?.codexLiveMaxExecutionSeconds === 'number' && @@ -1700,9 +1965,19 @@ async function runAgenticCodexReply( Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds) ? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds))) : null; + const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({ + repoPath, + sessionId, + requestId, + context, + input, + recentHistoryLines: recentHistory.items, + omittedHistoryCount: recentHistory.omittedCount, + }); const prompt = buildAgenticCodexPrompt(context, input, sessionId, { recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, + sessionReferenceResourcePath, }); let streamedOutput = ''; let stdoutTail = ''; @@ -2017,29 +2292,74 @@ function buildAutomationRegistrationDefinitionReply() { } function buildProgressMessages(input: string) { - const messages = ['생각 중입니다. 요청 의도와 현재 화면 문맥을 먼저 정리하고 있습니다.']; + const messages = ['요청을 분석하고 있습니다.']; if (isAutomationRegistrationCountRequest(input)) { - messages.push('오늘 기준 집계가 필요한 요청이라 DB 기준과 시간대 기준을 확인하고 있습니다.'); + messages.push('오늘 기준 집계라 DB 기준과 시간대 기준을 확인하고 있습니다.'); return messages; } if (/db|데이터베이스|sql|쿼리|집계|건수/i.test(input)) { - messages.push('DB를 직접 확인할 수 있는지와 집계 기준을 함께 보고 있습니다.'); + messages.push('DB와 집계 기준을 확인하고 있습니다.'); } if (/api|응답|endpoint|엔드포인트|fetch|호출/i.test(input)) { - messages.push('관련 API 경로와 실제 응답 기준을 확인하고 있습니다.'); + messages.push('API 경로와 실제 응답을 확인하고 있습니다.'); } if (/파일|소스|코드|tsx|ts|js|css|수정|변경|구현|fix|edit|implement/i.test(input)) { - messages.push('관련 소스 파일과 연결된 흐름을 읽고 있습니다.'); + messages.push('관련 소스와 연결 흐름을 확인하고 있습니다.'); } - messages.push('필요하면 실제 Codex 실행기로 이어서 조회하거나 수정하겠습니다.'); return [...new Set(messages)]; } +function normalizeProgressSummary(text: string) { + return text.replace(/\s+/g, ' ').trim(); +} + +function shouldSkipActivityProgressSummary(text: string) { + return ( + !text || + /^요청을 처리합니다\./.test(text) || + /^응답을 실시간으로 전송 중입니다\./.test(text) || + /^응답 생성이 완료되었습니다\./.test(text) || + /^완료(?:\(\d+\))?$/i.test(text) || + /^종료\(\d+\)$/i.test(text) + ); +} + +export function summarizeActivityProgressLine(activityLine: string) { + const lines = String(activityLine ?? '') + .split('\n') + .map((line) => normalizeProgressSummary(line)) + .filter(Boolean); + + for (const line of lines) { + if (line.startsWith('# 이유:')) { + const summary = normalizeProgressSummary(line.slice('# 이유:'.length)); + if (!shouldSkipActivityProgressSummary(summary)) { + return summary; + } + } + } + + for (const line of lines) { + for (const prefix of ['# 진행:', '# 상태:', '# 경고:']) { + if (!line.startsWith(prefix)) { + continue; + } + + const summary = normalizeProgressSummary(line.slice(prefix.length)); + if (!shouldSkipActivityProgressSummary(summary)) { + return summary; + } + } + } + + return ''; +} + function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { const normalized = input.toLowerCase(); const pageTitle = context?.pageTitle ?? '현재 화면'; @@ -2249,6 +2569,9 @@ async function buildCodexReply( input: string, sessionId: string, requestId: string, + options?: { + omitPromptHistory?: boolean; + }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, isCancellationRequested?: () => boolean, @@ -2258,6 +2581,7 @@ async function buildCodexReply( input, sessionId, requestId, + options, onProgress, onActivity, isCancellationRequested, @@ -2336,6 +2660,109 @@ export class ChatService { this.wss.close(); } + async recoverInterruptedSessions() { + const recoverableRequests = await listRecoverableChatConversationRequests(); + + if (recoverableRequests.length === 0) { + return { + sessionCount: 0, + restartedCount: 0, + requeuedCount: 0, + }; + } + + const requestsBySession = new Map(); + + for (const item of recoverableRequests) { + const existing = requestsBySession.get(item.sessionId); + + if (existing) { + existing.push(item); + } else { + requestsBySession.set(item.sessionId, [item]); + } + } + + let restartedCount = 0; + let requeuedCount = 0; + + for (const [sessionId, items] of requestsBySession.entries()) { + const session = this.getOrCreateSession(sessionId, items[0]?.clientId ?? null); + const primaryItem = + items.find((item) => item.requestId === item.currentRequestId && item.currentJobStatus === 'started') ?? + items.find((item) => item.status === 'started') ?? + items[0]; + + session.clientId = primaryItem?.clientId?.trim() || session.clientId; + session.context = { + pageId: null, + pageTitle: '', + topMenu: '', + focusedComponentId: null, + pageUrl: '', + chatTypeId: primaryItem?.chatTypeId ?? primaryItem?.lastChatTypeId ?? null, + chatTypeLabel: primaryItem?.contextLabel ?? '', + chatTypeDescription: primaryItem?.contextDescription ?? '', + }; + + for (const item of items) { + if (item.requestId === primaryItem?.requestId) { + continue; + } + + const requestedAtMs = parseRequestedAtMs(item.createdAt); + session.queue.push({ + requestId: item.requestId, + text: item.userText, + mode: 'queue', + requestedAtMs, + context: session.context ? cloneChatContext(session.context) : null, + }); + chatRuntimeService.enqueueJob({ + sessionId, + requestId: item.requestId, + mode: 'queue', + text: item.userText, + }); + chatRuntimeService.appendLog(item.requestId, '워크서버 재기동 후 대기열 복구를 준비합니다.'); + requeuedCount += 1; + } + + if (!primaryItem) { + continue; + } + + restartedCount += 1; + void this.executeRequest(session, { + requestId: primaryItem.requestId, + text: primaryItem.userText, + mode: 'queue', + requestedAtMs: parseRequestedAtMs(primaryItem.createdAt), + context: session.context ? cloneChatContext(session.context) : null, + }).catch((error: unknown) => { + this.logger.error( + { error, sessionId: session.sessionId, requestId: primaryItem.requestId }, + 'failed to recover interrupted chat request', + ); + }); + } + + this.logger.info( + { + sessionCount: requestsBySession.size, + restartedCount, + requeuedCount, + }, + 'recovered interrupted chat sessions after work-server restart', + ); + + return { + sessionCount: requestsBySession.size, + restartedCount, + requeuedCount, + }; + } + private getOrCreateSession(sessionId: string, clientId?: string | null) { const existing = this.sessions.get(sessionId); @@ -2480,6 +2907,12 @@ export class ChatService { } } + getSessionClientIdMap() { + return new Map( + [...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]), + ); + } + private pushRuntimeDetail(session: ChatSessionState) { const requestId = session.watchedRuntimeRequestId?.trim(); @@ -2713,16 +3146,24 @@ export class ChatService { return; } - const conversation = await getChatConversation(session.sessionId, session.clientId); + const [conversation, appConfig] = await Promise.all([ + getChatConversation(session.sessionId, session.clientId), + getAppConfigSnapshot(), + ]); - if (!conversation?.notifyOffline) { + if ( + !shouldSendOfflineChatNotification({ + receiveRoomNotifications: appConfig.chat?.receiveRoomNotifications, + conversationNotifyOffline: conversation?.notifyOffline, + }) + ) { return; } const notificationPayload = await this.buildOfflineChatNotificationPayload( session, message, - conversation.title || '현재 채팅방', + conversation?.title || '현재 채팅방', ); if (!notificationPayload) { @@ -3069,6 +3510,9 @@ export class ChatService { chatTypeLabel: message.payload.chatTypeLabel, chatTypeDescription: message.payload.chatTypeDescription, }, + { + omitPromptHistory: message.payload.omitPromptHistory === true, + }, ).catch((error: unknown) => { this.logger.error(error, 'chat reply build failed'); const session = this.clientStates.get(socket); @@ -3153,6 +3597,9 @@ export class ChatService { requestId?: string, mode: 'queue' | 'direct' = 'queue', contextOverride?: Partial | null, + requestOptions?: { + omitPromptHistory?: boolean; + }, ) { const trimmed = text.trim(); @@ -3198,6 +3645,7 @@ export class ChatService { text: trimmed, mode, requestedAtMs, + omitPromptHistory: requestOptions?.omitPromptHistory === true, context: cloneChatContext(state.context), }; @@ -3250,6 +3698,7 @@ export class ChatService { text: string; mode: 'queue' | 'direct'; requestedAtMs: number; + omitPromptHistory?: boolean; context: ChatContext | null; }, ) { @@ -3329,42 +3778,60 @@ export class ChatService { ), }); - const progressMessages = buildProgressMessages(request.text); - let progressIndex = progressMessages.length > 1 ? 1 : 0; - let lastProgressMessage = progressMessages[0] ?? ''; - let progressTimer: ReturnType | null = setInterval(() => { - const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)]; - - if (!nextMessage || nextMessage === lastProgressMessage) { - if (progressIndex >= progressMessages.length - 1) { - stopProgressTimer(); - } - return; - } - - lastProgressMessage = nextMessage; - chatRuntimeService.appendLog(request.requestId, nextMessage); - appendActivityLine(`# 진행: ${nextMessage}`); - - this.sendToSession(session, { - type: 'chat:message', - payload: createMessage('system', nextMessage, request.requestId), - }); - - if (progressIndex < progressMessages.length - 1) { - progressIndex += 1; - } else { - stopProgressTimer(); - } - }, 2200); - - const codexReplyMessage = createMessage('codex', '', request.requestId); const stopProgressTimer = () => { if (progressTimer !== null) { - clearInterval(progressTimer); + clearTimeout(progressTimer); progressTimer = null; } }; + const progressMessages = buildProgressMessages(request.text); + let progressIndex = progressMessages.length > 1 ? 1 : 0; + let lastProgressMessage = progressMessages[0] ?? ''; + let lastMeaningfulProgressSummary = ''; + let progressTimer: ReturnType | null = null; + const emitSystemProgressMessage = (nextMessage: string, options?: { appendActivity?: boolean }) => { + const normalizedMessage = normalizeProgressSummary(nextMessage); + + if (!normalizedMessage || normalizedMessage === lastProgressMessage) { + return false; + } + + lastProgressMessage = normalizedMessage; + chatRuntimeService.appendLog(request.requestId, normalizedMessage); + + if (options?.appendActivity !== false) { + appendActivityLine(`# 진행: ${normalizedMessage}`); + } + + this.sendToSession(session, { + type: 'chat:message', + payload: createMessage('system', normalizedMessage, request.requestId), + }); + return true; + }; + const scheduleFallbackProgressMessage = () => { + if (progressIndex >= progressMessages.length) { + return; + } + + stopProgressTimer(); + progressTimer = setTimeout(() => { + const nextMessage = progressMessages[progressIndex] ?? ''; + + if (!nextMessage || lastMeaningfulProgressSummary) { + stopProgressTimer(); + return; + } + + if (emitSystemProgressMessage(nextMessage)) { + progressIndex += 1; + } + + stopProgressTimer(); + }, 2200); + }; + + const codexReplyMessage = createMessage('codex', '', request.requestId); try { chatRuntimeService.appendLog(request.requestId, '요청 분석을 시작합니다.'); @@ -3373,6 +3840,7 @@ export class ChatService { type: 'chat:message', payload: createMessage('system', progressMessages[0] ?? '요청을 분석하고 있습니다.', request.requestId), }); + scheduleFallbackProgressMessage(); this.updateMessageInSession(session, { ...codexReplyMessage, text: '응답을 준비하고 있습니다...', @@ -3384,6 +3852,9 @@ export class ChatService { request.text, session.sessionId, request.requestId, + { + omitPromptHistory: request.omitPromptHistory === true, + }, (partialReply) => { stopProgressTimer(); if (!hasAnnouncedStreaming) { @@ -3399,6 +3870,13 @@ export class ChatService { }, (activityLine) => { appendActivityLine(activityLine); + const activitySummary = summarizeActivityProgressLine(activityLine); + + if (activitySummary && activitySummary !== lastMeaningfulProgressSummary) { + lastMeaningfulProgressSummary = activitySummary; + stopProgressTimer(); + emitSystemProgressMessage(activitySummary, { appendActivity: false }); + } }, () => this.cancelledRequestIds.has(request.requestId), ); diff --git a/etc/servers/work-server/src/services/chat-type-defaults.js b/etc/servers/work-server/src/services/chat-type-defaults.js new file mode 100644 index 0000000..6b463cd --- /dev/null +++ b/etc/servers/work-server/src/services/chat-type-defaults.js @@ -0,0 +1,37 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_CHAT_TYPES = void 0; +exports.DEFAULT_CHAT_TYPES = [ + { + id: 'general-request', + name: '일반 요청', + description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-21T00:00:00.000Z', + }, + { + id: 'layout-editor-execution', + name: 'Layout editor 실행', + description: '## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 대체하지 않고 `[[preview:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-27T00:00:00.000Z', + }, + { + id: 'api-request-template', + name: 'API요청', + description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-16T00:00:00.000Z', + }, + { + id: 'general-inquiry', + name: '일반 문의', + description: '## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat//resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-24T00:00:00.000Z', + }, +]; diff --git a/etc/servers/work-server/src/services/chat-type-defaults.ts b/etc/servers/work-server/src/services/chat-type-defaults.ts new file mode 100644 index 0000000..bc5a289 --- /dev/null +++ b/etc/servers/work-server/src/services/chat-type-defaults.ts @@ -0,0 +1,47 @@ +export type DefaultChatTypeRecord = { + id: string; + name: string; + description: string; + permissions: Array<'guest' | 'token-user'>; + enabled: boolean; + updatedAt: string; +}; + +export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [ + { + id: 'general-request', + name: '일반 요청', + description: + '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-21T00:00:00.000Z', + }, + { + id: 'layout-editor-execution', + name: 'Layout editor 실행', + description: + '## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 대체하지 않고 `[[preview:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-27T00:00:00.000Z', + }, + { + id: 'api-request-template', + name: 'API요청', + description: + '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-16T00:00:00.000Z', + }, + { + id: 'general-inquiry', + name: '일반 문의', + description: + '## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat//resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-24T00:00:00.000Z', + }, +]; diff --git a/etc/servers/work-server/src/services/error-log-plan-registration-service.js b/etc/servers/work-server/src/services/error-log-plan-registration-service.js new file mode 100644 index 0000000..03375e3 --- /dev/null +++ b/etc/servers/work-server/src/services/error-log-plan-registration-service.js @@ -0,0 +1,450 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.registerErrorLogBoardPosts = registerErrorLogBoardPosts; +var node_crypto_1 = require("node:crypto"); +var client_js_1 = require("../db/client.js"); +var board_service_js_1 = require("./board-service.js"); +var error_log_service_js_1 = require("./error-log-service.js"); +var plan_service_js_1 = require("./plan-service.js"); +var DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT = 6; +var ERROR_LOG_BOARD_POST_MARKER_PREFIX = '"); +} +function buildErrorLogBoardPostTitle(candidate) { + var scope = candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown' + ? " ".concat(candidate.requestPathGroup) + : ''; + var title = "\uC5D0\uB7EC\uB85C\uADF8 \uC870\uCE58 \uACC4\uD68D: ".concat(candidate.errorType).concat(scope).replace(/\s+/g, ' ').trim(); + return title.length > 200 ? "".concat(title.slice(0, 197).trimEnd(), "...") : title; +} +function buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd) { + var detailNote = formatErrorLogPlanNote(candidate, rangeStart, rangeEnd); + return [ + buildBoardPostMarker(candidate.workId), + "# ".concat(buildErrorLogBoardPostTitle(candidate)), + '', + detailNote, + ].join('\n'); +} +function registerErrorLogBoardPosts(args) { + return __awaiter(this, void 0, void 0, function () { + var rangeEnd, rangeStart, maxGroups, _a, errorLogs, existingBoardPosts, recentLogs, rawCandidates, candidates, createdPosts, skippedPosts, _loop_1, _i, candidates_1, candidate; + var _b, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, (0, plan_service_js_1.ensurePlanTable)()]; + case 1: + _e.sent(); + rangeEnd = (_b = args === null || args === void 0 ? void 0 : args.rangeEnd) !== null && _b !== void 0 ? _b : new Date(); + rangeStart = (_c = args === null || args === void 0 ? void 0 : args.rangeStart) !== null && _c !== void 0 ? _c : new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000); + maxGroups = (_d = args === null || args === void 0 ? void 0 : args.maxGroups) !== null && _d !== void 0 ? _d : DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT; + return [4 /*yield*/, Promise.all([ + (0, error_log_service_js_1.listErrorLogs)(200), + (0, board_service_js_1.listBoardPosts)(), + ])]; + case 2: + _a = _e.sent(), errorLogs = _a[0], existingBoardPosts = _a[1]; + recentLogs = filterLogsWithinRange(errorLogs, rangeStart, rangeEnd); + rawCandidates = buildErrorLogPlanCandidates(recentLogs); + candidates = coalesceErrorLogPlanCandidates(rawCandidates, maxGroups); + createdPosts = []; + skippedPosts = []; + _loop_1 = function (candidate) { + var marker, existingBoardPost, latestOpenPlan, createdPost; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: + marker = buildBoardPostMarker(candidate.workId); + existingBoardPost = existingBoardPosts.find(function (post) { var _a; return String((_a = post.content) !== null && _a !== void 0 ? _a : '').includes(marker); }); + if (existingBoardPost) { + skippedPosts.push({ + workId: candidate.workId, + boardPostId: existingBoardPost.id, + reason: "\uAE30\uC874 \uAC8C\uC2DC\uAE00 #".concat(existingBoardPost.id, "\uAC00 \uC788\uC2B5\uB2C8\uB2E4."), + }); + return [2 /*return*/, "continue"]; + } + return [4 /*yield*/, (0, client_js_1.db)(plan_service_js_1.PLAN_TABLE) + .select(['id', 'status']) + .where({ work_id: candidate.workId }) + .whereNot({ status: '완료' }) + .orderBy('id', 'desc') + .first()]; + case 1: + latestOpenPlan = _f.sent(); + if (latestOpenPlan) { + skippedPosts.push({ + workId: candidate.workId, + planId: Number(latestOpenPlan.id), + reason: "\uAE30\uC874 \uBBF8\uC644\uB8CC Plan #".concat(latestOpenPlan.id, "\uAC00 \uC788\uC2B5\uB2C8\uB2E4."), + }); + return [2 /*return*/, "continue"]; + } + return [4 /*yield*/, (0, board_service_js_1.createBoardPost)({ + title: buildErrorLogBoardPostTitle(candidate), + content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd), + attachments: [], + automationType: 'none', + automationContextIds: [], + requestExecutionMode: 'all_at_once', + requestItems: [], + })]; + case 2: + createdPost = _f.sent(); + createdPosts.push({ + postId: createdPost.id, + title: createdPost.title, + workId: candidate.workId, + count: candidate.count, + }); + return [2 /*return*/]; + } + }); + }; + _i = 0, candidates_1 = candidates; + _e.label = 3; + case 3: + if (!(_i < candidates_1.length)) return [3 /*break*/, 6]; + candidate = candidates_1[_i]; + return [5 /*yield**/, _loop_1(candidate)]; + case 4: + _e.sent(); + _e.label = 5; + case 5: + _i++; + return [3 /*break*/, 3]; + case 6: return [2 /*return*/, { + rangeStart: rangeStart, + rangeEnd: rangeEnd, + recentLogs: recentLogs, + rawCandidates: rawCandidates, + candidates: candidates, + createdPosts: createdPosts, + skippedPosts: skippedPosts, + }]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts index 0daddf2..7f29227 100755 --- a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts +++ b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts @@ -440,6 +440,8 @@ export async function registerErrorLogBoardPosts(args?: { attachments: [], automationType: 'none', automationContextIds: [], + requestExecutionMode: 'all_at_once', + requestItems: [], }); createdPosts.push({ diff --git a/etc/servers/work-server/src/services/error-log-service.js b/etc/servers/work-server/src/services/error-log-service.js new file mode 100644 index 0000000..4e77df3 --- /dev/null +++ b/etc/servers/work-server/src/services/error-log-service.js @@ -0,0 +1,257 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createErrorLogSchema = exports.ERROR_LOG_VIEW_TOKEN = exports.ERROR_LOG_TABLE = void 0; +exports.setupErrorLogTable = setupErrorLogTable; +exports.createErrorLog = createErrorLog; +exports.listErrorLogs = listErrorLogs; +exports.hasErrorLogViewAccessToken = hasErrorLogViewAccessToken; +var zod_1 = require("zod"); +var client_js_1 = require("../db/client.js"); +exports.ERROR_LOG_TABLE = 'error_logs'; +exports.ERROR_LOG_VIEW_TOKEN = 'usr_7f3a9c2d8e1b4a6f'; +exports.createErrorLogSchema = zod_1.z.object({ + source: zod_1.z.enum(['server', 'client', 'automation']).default('server'), + sourceLabel: zod_1.z.string().trim().max(80).optional().nullable(), + errorType: zod_1.z.string().trim().min(1).max(120), + errorName: zod_1.z.string().trim().max(255).optional().nullable(), + errorMessage: zod_1.z.string().trim().min(1).max(10000), + detail: zod_1.z.string().trim().max(50000).optional().nullable(), + stackTrace: zod_1.z.string().trim().max(50000).optional().nullable(), + statusCode: zod_1.z.number().int().min(100).max(599).optional().nullable(), + requestMethod: zod_1.z.string().trim().max(10).optional().nullable(), + requestPath: zod_1.z.string().trim().max(1000).optional().nullable(), + relatedPlanId: zod_1.z.number().int().positive().optional().nullable(), + relatedWorkId: zod_1.z.string().trim().max(120).optional().nullable(), + context: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional().nullable(), +}); +function ensureErrorLogTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.ERROR_LOG_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.ERROR_LOG_TABLE, function (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(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['source', function (table) { return table.string('source', 20).notNullable().defaultTo('server'); }], + ['source_label', function (table) { return table.string('source_label', 80).nullable(); }], + ['error_type', function (table) { return table.string('error_type', 120).notNullable().defaultTo('unknown'); }], + ['error_name', function (table) { return table.string('error_name', 255).nullable(); }], + ['error_message', function (table) { return table.text('error_message').notNullable().defaultTo(''); }], + ['detail', function (table) { return table.text('detail').nullable(); }], + ['stack_trace', function (table) { return table.text('stack_trace').nullable(); }], + ['status_code', function (table) { return table.integer('status_code').nullable(); }], + ['request_method', function (table) { return table.string('request_method', 10).nullable(); }], + ['request_path', function (table) { return table.string('request_path', 1000).nullable(); }], + ['related_plan_id', function (table) { return table.integer('related_plan_id').nullable(); }], + ['related_work_id', function (table) { return table.string('related_work_id', 120).nullable(); }], + ['context_json', function (table) { return table.jsonb('context_json').nullable(); }], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.ERROR_LOG_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.ERROR_LOG_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function normalizePayload(payload) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; + var parsedPayload = exports.createErrorLogSchema.parse(payload); + var sourceLabel = (_a = parsedPayload.sourceLabel) !== null && _a !== void 0 ? _a : (parsedPayload.source === 'client' + ? '프론트엔드' + : parsedPayload.source === 'automation' + ? 'Plan 자동화' + : '워크서버 API'); + return { + source: parsedPayload.source, + source_label: sourceLabel, + error_type: parsedPayload.errorType, + error_name: (_b = parsedPayload.errorName) !== null && _b !== void 0 ? _b : null, + error_message: parsedPayload.errorMessage, + detail: (_c = parsedPayload.detail) !== null && _c !== void 0 ? _c : null, + stack_trace: (_d = parsedPayload.stackTrace) !== null && _d !== void 0 ? _d : null, + status_code: (_e = parsedPayload.statusCode) !== null && _e !== void 0 ? _e : null, + request_method: (_f = parsedPayload.requestMethod) !== null && _f !== void 0 ? _f : null, + request_path: (_g = parsedPayload.requestPath) !== null && _g !== void 0 ? _g : null, + related_plan_id: (_h = parsedPayload.relatedPlanId) !== null && _h !== void 0 ? _h : null, + related_work_id: (_j = parsedPayload.relatedWorkId) !== null && _j !== void 0 ? _j : null, + context_json: (_k = parsedPayload.context) !== null && _k !== void 0 ? _k : null, + }; +} +function mapErrorLogRow(row) { + var _a, _b, _c; + return { + id: Number(row.id), + source: String((_a = row.source) !== null && _a !== void 0 ? _a : 'server'), + sourceLabel: row.source_label ? String(row.source_label) : null, + errorType: String((_b = row.error_type) !== null && _b !== void 0 ? _b : ''), + errorName: row.error_name ? String(row.error_name) : null, + errorMessage: String((_c = row.error_message) !== null && _c !== void 0 ? _c : ''), + 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 : null, + createdAt: row.created_at, + }; +} +function setupErrorLogTable() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureErrorLogTable()]; + case 1: + _a.sent(); + return [2 /*return*/, { + ok: true, + table: exports.ERROR_LOG_TABLE, + }]; + } + }); + }); +} +function createErrorLog(payload) { + return __awaiter(this, void 0, void 0, function () { + var row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureErrorLogTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.ERROR_LOG_TABLE) + .insert(__assign(__assign({}, normalizePayload(payload)), { created_at: client_js_1.db.fn.now() })) + .returning('*')]; + case 2: + row = (_a.sent())[0]; + return [2 /*return*/, mapErrorLogRow(row)]; + } + }); + }); +} +function listErrorLogs() { + return __awaiter(this, arguments, void 0, function (limit) { + var safeLimit, rows; + if (limit === void 0) { limit = 50; } + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureErrorLogTable()]; + case 1: + _a.sent(); + safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 50; + return [4 /*yield*/, (0, client_js_1.db)(exports.ERROR_LOG_TABLE).select('*').orderBy('created_at', 'desc').limit(safeLimit)]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows.map(function (row) { return mapErrorLogRow(row); })]; + } + }); + }); +} +function hasErrorLogViewAccessToken(token) { + var normalizedToken = Array.isArray(token) ? token[0] : token; + return String(normalizedToken !== null && normalizedToken !== void 0 ? normalizedToken : '').trim() === exports.ERROR_LOG_VIEW_TOKEN; +} diff --git a/etc/servers/work-server/src/services/git-service.js b/etc/servers/work-server/src/services/git-service.js new file mode 100644 index 0000000..f3e09ce --- /dev/null +++ b/etc/servers/work-server/src/services/git-service.js @@ -0,0 +1,526 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.assertCleanWorktree = assertCleanWorktree; +exports.cleanAutomationWorktree = cleanAutomationWorktree; +exports.ensureBranchExists = ensureBranchExists; +exports.pushBranch = pushBranch; +exports.commitAllChanges = commitAllChanges; +exports.hasWorkingTreeChanges = hasWorkingTreeChanges; +exports.mergeBranch = mergeBranch; +exports.mergeBranchToRelease = mergeBranchToRelease; +exports.mergeReleaseToMain = mergeReleaseToMain; +exports.mergeIssueBranchToMain = mergeIssueBranchToMain; +exports.pullMainProjectBranch = pullMainProjectBranch; +exports.recreateReleaseBranchFromMain = recreateReleaseBranchFromMain; +var node_child_process_1 = require("node:child_process"); +var promises_1 = require("node:fs/promises"); +var node_fs_1 = require("node:fs"); +var node_util_1 = require("node:util"); +var env_js_1 = require("../config/env.js"); +var execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile); +var gitCredentialSourcePath = '/root/.git-credentials'; +var gitCredentialCachePath = '/tmp/work-server-git-credentials'; +function prepareWritableCredentialStore() { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _b.trys.push([0, 3, , 4]); + return [4 /*yield*/, (0, promises_1.access)(gitCredentialSourcePath, node_fs_1.constants.R_OK)]; + case 1: + _b.sent(); + return [4 /*yield*/, (0, promises_1.copyFile)(gitCredentialSourcePath, gitCredentialCachePath)]; + case 2: + _b.sent(); + return [2 /*return*/, gitCredentialCachePath]; + case 3: + _a = _b.sent(); + return [2 /*return*/, null]; + case 4: return [2 /*return*/]; + } + }); + }); +} +function runGit(repoPath, args) { + return __awaiter(this, void 0, void 0, function () { + var credentialStorePath, gitArgs, env, _a, stdout, stderr; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, prepareWritableCredentialStore()]; + case 1: + credentialStorePath = _b.sent(); + gitArgs = ['-c', "safe.directory=".concat(repoPath)]; + env = (0, env_js_1.getEnv)(); + if (credentialStorePath) { + gitArgs.push('-c', "credential.helper=store --file=".concat(credentialStorePath)); + } + return [4 /*yield*/, execFileAsync('git', ['-c', "safe.directory=".concat(repoPath), '-C', repoPath, 'config', 'user.name', env.PLAN_GIT_USER_NAME], { + encoding: 'utf8', + })]; + case 2: + _b.sent(); + return [4 /*yield*/, execFileAsync('git', ['-c', "safe.directory=".concat(repoPath), '-C', repoPath, 'config', 'user.email', env.PLAN_GIT_USER_EMAIL], { + encoding: 'utf8', + })]; + case 3: + _b.sent(); + return [4 /*yield*/, execFileAsync('git', __spreadArray(__spreadArray(__spreadArray([], gitArgs, true), ['-C', repoPath], false), args, true), { + encoding: 'utf8', + })]; + case 4: + _a = _b.sent(), stdout = _a.stdout, stderr = _a.stderr; + return [2 /*return*/, { + stdout: stdout.trim(), + stderr: stderr.trim(), + }]; + } + }); + }); +} +function assertBranchExists(repoPath, branchName) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _b.trys.push([0, 2, , 3]); + return [4 /*yield*/, runGit(repoPath, ['rev-parse', '--verify', branchName])]; + case 1: + _b.sent(); + return [3 /*break*/, 3]; + case 2: + _a = _b.sent(); + throw new Error("".concat(branchName, " \uBE0C\uB79C\uCE58\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 ").concat(branchName, " \uBE0C\uB79C\uCE58\uB97C \uC0DD\uC131\uD55C \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574 \uC8FC\uC138\uC694.")); + case 3: return [2 /*return*/]; + } + }); + }); +} +function hasLocalBranch(repoPath, branchName) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _b.trys.push([0, 2, , 3]); + return [4 /*yield*/, runGit(repoPath, ['rev-parse', '--verify', '--quiet', branchName])]; + case 1: + _b.sent(); + return [2 /*return*/, true]; + case 2: + _a = _b.sent(); + return [2 /*return*/, false]; + case 3: return [2 /*return*/]; + } + }); + }); +} +function hasRemoteBranch(repoPath, branchName) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _b.trys.push([0, 2, , 3]); + return [4 /*yield*/, runGit(repoPath, ['rev-parse', '--verify', '--quiet', "refs/remotes/origin/".concat(branchName)])]; + case 1: + _b.sent(); + return [2 /*return*/, true]; + case 2: + _a = _b.sent(); + return [2 /*return*/, false]; + case 3: return [2 /*return*/]; + } + }); + }); +} +function syncBranchWithRemote(repoPath, branchName) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, runGit(repoPath, ['fetch', 'origin', branchName])]; + case 1: + _b.sent(); + return [4 /*yield*/, hasRemoteBranch(repoPath, branchName)]; + case 2: + if (!(_b.sent())) { + return [2 /*return*/]; + } + return [4 /*yield*/, hasLocalBranch(repoPath, branchName)]; + case 3: + if (!!(_b.sent())) return [3 /*break*/, 5]; + return [4 /*yield*/, runGit(repoPath, ['branch', branchName, "origin/".concat(branchName)])]; + case 4: + _b.sent(); + _b.label = 5; + case 5: + _b.trys.push([5, 7, , 9]); + return [4 /*yield*/, runGit(repoPath, ['switch', branchName])]; + case 6: + _b.sent(); + return [3 /*break*/, 9]; + case 7: + _a = _b.sent(); + return [4 /*yield*/, runGit(repoPath, ['switch', '-C', branchName, "origin/".concat(branchName)])]; + case 8: + _b.sent(); + return [3 /*break*/, 9]; + case 9: return [4 /*yield*/, runGit(repoPath, ['reset', '--hard', "origin/".concat(branchName)])]; + case 10: + _b.sent(); + return [2 /*return*/]; + } + }); + }); +} +function assertCleanWorktree(repoPath) { + return __awaiter(this, void 0, void 0, function () { + var stdout; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, runGit(repoPath, ['status', '--porcelain'])]; + case 1: + stdout = (_a.sent()).stdout; + if (stdout) { + throw new Error('Git 작업 디렉터리가 깨끗하지 않습니다. 변경 사항을 정리한 뒤 다시 시도해 주세요.'); + } + return [2 /*return*/]; + } + }); + }); +} +function cleanAutomationWorktree(repoPath) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, runGit(repoPath, ['reset', '--hard'])]; + case 1: + _a.sent(); + return [4 /*yield*/, runGit(repoPath, ['clean', '-fd'])]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensureBranchExists(config, branchName, releaseTarget) { + return __awaiter(this, void 0, void 0, function () { + var baseBranch; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + baseBranch = config.mainBranch; + return [4 /*yield*/, assertCleanWorktree(config.repoPath)]; + case 1: + _a.sent(); + return [4 /*yield*/, syncBranchWithRemote(config.repoPath, baseBranch)]; + case 2: + _a.sent(); + return [4 /*yield*/, assertBranchExists(config.repoPath, baseBranch)]; + case 3: + _a.sent(); + return [4 /*yield*/, runGit(config.repoPath, ['switch', baseBranch])]; + case 4: + _a.sent(); + return [4 /*yield*/, runGit(config.repoPath, ['switch', '-C', branchName])]; + case 5: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function pushBranch(repoPath_1, branchName_1) { + return __awaiter(this, arguments, void 0, function (repoPath, branchName, setUpstream) { + if (setUpstream === void 0) { setUpstream = false; } + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, runGit(repoPath, setUpstream ? ['push', '-u', 'origin', branchName] : ['push', 'origin', branchName])]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function deleteLocalBranch(repoPath, branchName) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _b.trys.push([0, 2, , 3]); + return [4 /*yield*/, runGit(repoPath, ['branch', '-D', branchName])]; + case 1: + _b.sent(); + return [3 /*break*/, 3]; + case 2: + _a = _b.sent(); + return [2 /*return*/]; + case 3: return [2 /*return*/]; + } + }); + }); +} +function deleteRemoteBranch(repoPath, branchName) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _b.trys.push([0, 2, , 3]); + return [4 /*yield*/, runGit(repoPath, ['push', 'origin', '--delete', branchName])]; + case 1: + _b.sent(); + return [3 /*break*/, 3]; + case 2: + _a = _b.sent(); + return [2 /*return*/]; + case 3: return [2 /*return*/]; + } + }); + }); +} +function commitAllChanges(repoPath, message) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, runGit(repoPath, ['add', '-A'])]; + case 1: + _a.sent(); + return [4 /*yield*/, runGit(repoPath, ['commit', '-m', message])]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function hasWorkingTreeChanges(repoPath) { + return __awaiter(this, void 0, void 0, function () { + var stdout; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, runGit(repoPath, ['status', '--porcelain'])]; + case 1: + stdout = (_a.sent()).stdout; + return [2 /*return*/, Boolean(stdout)]; + } + }); + }); +} +function isHotfixBranch(branchName) { + return /^hotfix\//.test(branchName); +} +function isReleaseBranch(branchName, config) { + return branchName === config.releaseBranch || /^release([/-]|$)/.test(branchName); +} +function shouldSquashMerge(sourceBranch, targetBranch, config) { + return isHotfixBranch(sourceBranch) && (targetBranch === config.mainBranch || isReleaseBranch(targetBranch, config)); +} +function assertAllowedMergeDirection(config, sourceBranch, targetBranch) { + if (targetBranch === config.mainBranch && + sourceBranch !== config.releaseBranch && + sourceBranch !== config.mainBranch && + !isHotfixBranch(sourceBranch)) { + throw new Error("\uBE0C\uB79C\uCE58 \uC804\uB7B5 \uC704\uBC18: ".concat(sourceBranch, " -> ").concat(targetBranch, " \uC9C1\uC811 \uBA38\uC9C0\uB294 \uD5C8\uC6A9\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. release \uBC18\uC601 \uD6C4 main\uC5D0 \uBC18\uC601\uD574 \uC8FC\uC138\uC694.")); + } +} +function mergeBranch(config, sourceBranch, targetBranch) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + assertAllowedMergeDirection(config, sourceBranch, targetBranch); + return [4 /*yield*/, assertCleanWorktree(config.repoPath)]; + case 1: + _a.sent(); + return [4 /*yield*/, syncBranchWithRemote(config.repoPath, targetBranch)]; + case 2: + _a.sent(); + return [4 /*yield*/, assertBranchExists(config.repoPath, targetBranch)]; + case 3: + _a.sent(); + return [4 /*yield*/, assertBranchExists(config.repoPath, sourceBranch)]; + case 4: + _a.sent(); + if (!(sourceBranch === config.releaseBranch || sourceBranch === config.mainBranch)) return [3 /*break*/, 6]; + return [4 /*yield*/, syncBranchWithRemote(config.repoPath, sourceBranch)]; + case 5: + _a.sent(); + _a.label = 6; + case 6: return [4 /*yield*/, runGit(config.repoPath, ['switch', targetBranch])]; + case 7: + _a.sent(); + if (!shouldSquashMerge(sourceBranch, targetBranch, config)) return [3 /*break*/, 10]; + return [4 /*yield*/, runGit(config.repoPath, ['merge', '--squash', sourceBranch])]; + case 8: + _a.sent(); + return [4 /*yield*/, runGit(config.repoPath, ['commit', '-m', "merge: ".concat(sourceBranch, " -> ").concat(targetBranch, " (squash)")])]; + case 9: + _a.sent(); + return [2 /*return*/]; + case 10: return [4 /*yield*/, runGit(config.repoPath, ['merge', '--no-ff', sourceBranch, '-m', "merge: ".concat(sourceBranch, " -> ").concat(targetBranch)])]; + case 11: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function mergeBranchToRelease(config, branchName, releaseTarget) { + return __awaiter(this, void 0, void 0, function () { + var baseBranch; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + baseBranch = releaseTarget || config.releaseBranch; + return [4 /*yield*/, mergeBranch(config, branchName, baseBranch)]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function mergeReleaseToMain(config, releaseTarget) { + return __awaiter(this, void 0, void 0, function () { + var baseBranch; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + baseBranch = releaseTarget || config.releaseBranch; + return [4 /*yield*/, mergeBranch(config, baseBranch, config.mainBranch)]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function mergeIssueBranchToMain(config, branchName) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + throw new Error("\uBE0C\uB79C\uCE58 \uC804\uB7B5 \uC704\uBC18: ".concat(branchName, " -> ").concat(config.mainBranch, " \uC9C1\uC811 \uBA38\uC9C0\uB294 \uBE44\uD65C\uC131\uD654\uB418\uC5C8\uC2B5\uB2C8\uB2E4. release \uBE0C\uB79C\uCE58\uB97C \uD1B5\uD574 \uBC18\uC601\uD574 \uC8FC\uC138\uC694.")); + }); + }); +} +function pullMainProjectBranch(repoPath, branchName) { + return __awaiter(this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, assertCleanWorktree(repoPath)]; + case 1: + _b.sent(); + return [4 /*yield*/, runGit(repoPath, ['fetch', 'origin', branchName])]; + case 2: + _b.sent(); + _b.label = 3; + case 3: + _b.trys.push([3, 5, , 7]); + return [4 /*yield*/, runGit(repoPath, ['switch', branchName])]; + case 4: + _b.sent(); + return [3 /*break*/, 7]; + case 5: + _a = _b.sent(); + return [4 /*yield*/, runGit(repoPath, ['switch', '-C', branchName, "origin/".concat(branchName)])]; + case 6: + _b.sent(); + return [3 /*break*/, 7]; + case 7: return [4 /*yield*/, runGit(repoPath, ['pull', '--ff-only', 'origin', branchName])]; + case 8: + _b.sent(); + return [2 /*return*/]; + } + }); + }); +} +function recreateReleaseBranchFromMain(config, releaseTarget) { + return __awaiter(this, void 0, void 0, function () { + var targetBranch; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + targetBranch = releaseTarget || config.releaseBranch; + if (targetBranch === config.mainBranch) { + throw new Error('release 브랜치를 main 브랜치와 동일하게 재생성할 수 없습니다.'); + } + return [4 /*yield*/, assertCleanWorktree(config.repoPath)]; + case 1: + _a.sent(); + return [4 /*yield*/, syncBranchWithRemote(config.repoPath, config.mainBranch)]; + case 2: + _a.sent(); + return [4 /*yield*/, assertBranchExists(config.repoPath, config.mainBranch)]; + case 3: + _a.sent(); + return [4 /*yield*/, runGit(config.repoPath, ['switch', config.mainBranch])]; + case 4: + _a.sent(); + return [4 /*yield*/, deleteLocalBranch(config.repoPath, targetBranch)]; + case 5: + _a.sent(); + return [4 /*yield*/, deleteRemoteBranch(config.repoPath, targetBranch)]; + case 6: + _a.sent(); + return [4 /*yield*/, runGit(config.repoPath, ['switch', '-C', targetBranch, config.mainBranch])]; + case 7: + _a.sent(); + return [4 /*yield*/, pushBranch(config.repoPath, targetBranch, true)]; + case 8: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/managed-schedule-service.js b/etc/servers/work-server/src/services/managed-schedule-service.js new file mode 100644 index 0000000..f6eb90d --- /dev/null +++ b/etc/servers/work-server/src/services/managed-schedule-service.js @@ -0,0 +1,221 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.buildManagedScheduleServiceMetadata = buildManagedScheduleServiceMetadata; +exports.prepareManagedScheduleServiceDirectory = prepareManagedScheduleServiceDirectory; +exports.removeManagedScheduleServiceArtifacts = removeManagedScheduleServiceArtifacts; +exports.hasManagedScheduleServicePackage = hasManagedScheduleServicePackage; +exports.runManagedScheduleService = runManagedScheduleService; +var node_path_1 = require("node:path"); +var promises_1 = require("node:fs/promises"); +var node_url_1 = require("node:url"); +var env_js_1 = require("../config/env.js"); +var stock_alert_service_js_1 = require("./stock-alert-service.js"); +function sanitizeManagedServiceToken(value) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} +function appendScheduleSuffixOnce(value, suffix) { + var normalizedValue = value.trim(); + var normalizedSuffix = suffix.trim().toLowerCase(); + if (!normalizedValue || !normalizedSuffix) { + return normalizedValue; + } + return normalizedValue.toLowerCase().endsWith("-".concat(normalizedSuffix)) + ? normalizedValue + : "".concat(normalizedValue, "-").concat(suffix.trim()); +} +function getScheduleRepoRoot() { + var env = (0, env_js_1.getEnv)(); + return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd(); +} +function buildManagedScheduleServiceMetadata(scheduleId, workId) { + var workToken = sanitizeManagedServiceToken(appendScheduleSuffixOnce(workId, 'service')) || 'task-service'; + var serviceKey = "schedule-".concat(scheduleId, "-").concat(workToken); + var relativeDirectory = ".auto_codex/schedule/".concat(scheduleId); + var packageName = workToken || "schedule-".concat(scheduleId, "-service"); + return { + scheduleId: scheduleId, + serviceKey: serviceKey, + packageName: packageName, + relativeDirectory: relativeDirectory, + manifestPath: "".concat(relativeDirectory, "/service-manifest.json"), + readmePath: "".concat(relativeDirectory, "/README.md"), + sourcePath: "".concat(relativeDirectory, "/service.ts"), + runtimePath: "".concat(relativeDirectory, "/service.mjs"), + }; +} +function prepareManagedScheduleServiceDirectory(scheduleId) { + return __awaiter(this, void 0, void 0, function () { + var repoRoot, absoluteDirectory; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + repoRoot = getScheduleRepoRoot(); + absoluteDirectory = node_path_1.default.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId)); + return [4 /*yield*/, (0, promises_1.mkdir)(absoluteDirectory, { recursive: true })]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, promises_1.rm)(node_path_1.default.join(absoluteDirectory, 'managed-service'), { recursive: true, force: true })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function removeManagedScheduleServiceArtifacts(scheduleId) { + return __awaiter(this, void 0, void 0, function () { + var repoRoot, scheduleDirectory; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + repoRoot = getScheduleRepoRoot(); + scheduleDirectory = node_path_1.default.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId)); + return [4 /*yield*/, (0, promises_1.rm)(node_path_1.default.join(scheduleDirectory, 'managed-service'), { recursive: true, force: true })]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, promises_1.rm)(node_path_1.default.join(scheduleDirectory, 'README.md'), { force: true })]; + case 2: + _a.sent(); + return [4 /*yield*/, (0, promises_1.rm)(node_path_1.default.join(scheduleDirectory, 'service-manifest.json'), { force: true })]; + case 3: + _a.sent(); + return [4 /*yield*/, (0, promises_1.rm)(node_path_1.default.join(scheduleDirectory, 'service.ts'), { force: true })]; + case 4: + _a.sent(); + return [4 /*yield*/, (0, promises_1.rm)(node_path_1.default.join(scheduleDirectory, 'service.mjs'), { force: true })]; + case 5: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function hasManagedScheduleServicePackage(relativeDirectory) { + return __awaiter(this, void 0, void 0, function () { + var trimmedDirectory, repoRoot, runtimePath, manifestPath, _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + trimmedDirectory = String(relativeDirectory !== null && relativeDirectory !== void 0 ? relativeDirectory : '').trim(); + if (!trimmedDirectory) { + return [2 /*return*/, false]; + } + repoRoot = getScheduleRepoRoot(); + runtimePath = node_path_1.default.join(repoRoot, trimmedDirectory, 'service.mjs'); + manifestPath = node_path_1.default.join(repoRoot, trimmedDirectory, 'service-manifest.json'); + _b.label = 1; + case 1: + _b.trys.push([1, 3, , 4]); + return [4 /*yield*/, Promise.all([(0, promises_1.access)(runtimePath), (0, promises_1.access)(manifestPath)])]; + case 2: + _b.sent(); + return [2 /*return*/, true]; + case 3: + _a = _b.sent(); + return [2 /*return*/, false]; + case 4: return [2 /*return*/]; + } + }); + }); +} +function runManagedScheduleService(relativeDirectory) { + return __awaiter(this, void 0, void 0, function () { + var repoRoot, runtimePath, importedModule, run; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + repoRoot = getScheduleRepoRoot(); + runtimePath = node_path_1.default.join(repoRoot, relativeDirectory, 'service.mjs'); + return [4 /*yield*/, Promise.resolve("".concat("".concat((0, node_url_1.pathToFileURL)(runtimePath).href, "?t=").concat(Date.now()))).then(function (s) { return require(s); })]; + case 1: + importedModule = _b.sent(); + run = typeof importedModule.run === 'function' + ? importedModule.run + : typeof ((_a = importedModule.default) === null || _a === void 0 ? void 0 : _a.run) === 'function' + ? importedModule.default.run + : null; + if (!run) { + throw new Error("\uC2A4\uCF00\uC904 \uC11C\uBE44\uC2A4 \uC2E4\uD589 \uD568\uC218\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ".concat(relativeDirectory, "/service.mjs")); + } + return [2 /*return*/, run({ + scheduleRoot: node_path_1.default.join(repoRoot, trimmedRelativeDirectory(relativeDirectory)), + scheduleDirectory: trimmedRelativeDirectory(relativeDirectory), + repoRoot: repoRoot, + now: new Date().toISOString(), + runCurrentPriceStockAlertService: function (definition) { + return (0, stock_alert_service_js_1.sendManagedStockAlertWebPush)({ + scheduleId: definition.scheduleId, + serviceKey: definition.serviceKey, + title: definition.title, + mode: 'price', + }); + }, + runChangeRateThresholdStockAlertService: function (definition) { + return (0, stock_alert_service_js_1.sendManagedStockAlertWebPush)({ + scheduleId: definition.scheduleId, + serviceKey: definition.serviceKey, + title: definition.title, + mode: 'change-threshold', + thresholdPercent: definition.thresholdPercent, + }); + }, + runChangeRateAndVolumeSpikeStockAlertService: function (definition) { + return (0, stock_alert_service_js_1.sendManagedStockAlertWebPush)({ + scheduleId: definition.scheduleId, + serviceKey: definition.serviceKey, + title: definition.title, + mode: 'change-threshold-volume-spike', + thresholdPercent: definition.thresholdPercent, + minVolumeIncreasePercent: definition.minVolumeIncreasePercent, + }); + }, + })]; + } + }); + }); +} +function trimmedRelativeDirectory(relativeDirectory) { + return String(relativeDirectory !== null && relativeDirectory !== void 0 ? relativeDirectory : '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); +} diff --git a/etc/servers/work-server/src/services/managed-schedule-service.ts b/etc/servers/work-server/src/services/managed-schedule-service.ts index 794900b..34cdfe9 100644 --- a/etc/servers/work-server/src/services/managed-schedule-service.ts +++ b/etc/servers/work-server/src/services/managed-schedule-service.ts @@ -160,6 +160,24 @@ export async function runManagedScheduleService(relativeDirectory: string) { thresholdPercent: definition.thresholdPercent, }); }, + runChangeRateAndVolumeSpikeStockAlertService( + definition: { + scheduleId: number; + serviceKey: string; + title: string; + thresholdPercent: number; + minVolumeIncreasePercent: number; + }, + ) { + return sendManagedStockAlertWebPush({ + scheduleId: definition.scheduleId, + serviceKey: definition.serviceKey, + title: definition.title, + mode: 'change-threshold-volume-spike', + thresholdPercent: definition.thresholdPercent, + minVolumeIncreasePercent: definition.minVolumeIncreasePercent, + }); + }, }) as Promise; } diff --git a/etc/servers/work-server/src/services/notification-message-prune.ts b/etc/servers/work-server/src/services/notification-message-prune.ts new file mode 100644 index 0000000..b9b7348 --- /dev/null +++ b/etc/servers/work-server/src/services/notification-message-prune.ts @@ -0,0 +1,26 @@ +export type NotificationMessageTimestampedId = { + id: number; + createdAt: string | null | undefined; +}; + +export function selectNotificationMessageIdsToDelete( + items: NotificationMessageTimestampedId[], + keepLatestCount = 1, +) { + const normalizedKeepLatestCount = Number.isInteger(keepLatestCount) && keepLatestCount > 0 ? keepLatestCount : 1; + + return items + .slice() + .sort((left, right) => { + const leftCreatedAt = Date.parse(left.createdAt ?? ''); + const rightCreatedAt = Date.parse(right.createdAt ?? ''); + + if (Number.isFinite(leftCreatedAt) && Number.isFinite(rightCreatedAt) && leftCreatedAt !== rightCreatedAt) { + return rightCreatedAt - leftCreatedAt; + } + + return right.id - left.id; + }) + .slice(normalizedKeepLatestCount) + .map((item) => item.id); +} diff --git a/etc/servers/work-server/src/services/notification-message-service.js b/etc/servers/work-server/src/services/notification-message-service.js new file mode 100644 index 0000000..9e535ef --- /dev/null +++ b/etc/servers/work-server/src/services/notification-message-service.js @@ -0,0 +1,389 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.notificationMessageReadPayloadSchema = exports.notificationMessagePayloadSchema = exports.notificationMessageListQuerySchema = exports.NOTIFICATION_MESSAGE_TABLE = void 0; +exports.ensureNotificationMessagesTable = ensureNotificationMessagesTable; +exports.listNotificationMessages = listNotificationMessages; +exports.getNotificationMessage = getNotificationMessage; +exports.createNotificationMessage = createNotificationMessage; +exports.selectNotificationMessageIdsToDelete = selectNotificationMessageIdsToDelete; +exports.deleteOlderNotificationMessagesBySource = deleteOlderNotificationMessagesBySource; +exports.updateNotificationMessageReadState = updateNotificationMessageReadState; +exports.deleteNotificationMessage = deleteNotificationMessage; +var zod_1 = require("zod"); +var client_js_1 = require("../db/client.js"); +exports.NOTIFICATION_MESSAGE_TABLE = 'notification_messages'; +var notificationMessagePrioritySchema = zod_1.z.enum(['low', 'normal', 'high', 'urgent']); +var notificationMessageListStatusSchema = zod_1.z.enum(['all', 'unread']); +exports.notificationMessageListQuerySchema = zod_1.z.object({ + status: notificationMessageListStatusSchema.default('all'), + limit: zod_1.z.coerce.number().int().min(1).max(100).default(20), +}); +exports.notificationMessagePayloadSchema = zod_1.z.object({ + title: zod_1.z.string().trim().min(1).max(200), + body: zod_1.z.string().trim().min(1).max(20000), + category: zod_1.z.string().trim().min(1).max(60).default('general'), + source: zod_1.z.string().trim().min(1).max(80).default('system'), + priority: notificationMessagePrioritySchema.default('normal'), + metadata: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).default({}), +}); +exports.notificationMessageReadPayloadSchema = zod_1.z.object({ + read: zod_1.z.boolean().default(true), +}); +function normalizePreviewText(value) { + var normalized = value + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/[#>*_`~-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return normalized.length > 140 ? "".concat(normalized.slice(0, 137).trimEnd(), "...") : normalized; +} +function mapNotificationMessageRow(row) { + var _a, _b, _c, _d, _e, _f, _g; + var body = String((_a = row.body) !== null && _a !== void 0 ? _a : ''); + var metadata = typeof row.metadata_json === 'object' && row.metadata_json ? row.metadata_json : {}; + var metadataPreview = typeof metadata.previewText === 'string' + ? metadata.previewText + : typeof metadata.listPreviewText === 'string' + ? metadata.listPreviewText + : ''; + return { + id: Number((_b = row.id) !== null && _b !== void 0 ? _b : 0), + title: String((_c = row.title) !== null && _c !== void 0 ? _c : ''), + body: body, + preview: normalizePreviewText(metadataPreview || body), + category: String((_d = row.category) !== null && _d !== void 0 ? _d : 'general'), + source: String((_e = row.source) !== null && _e !== void 0 ? _e : '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: metadata, + createdAt: String((_f = row.created_at) !== null && _f !== void 0 ? _f : ''), + updatedAt: String((_g = row.updated_at) !== null && _g !== void 0 ? _g : ''), + }; +} +function resolveInsertedId(result) { + if (typeof result === 'number' && Number.isInteger(result) && result > 0) { + return result; + } + if (Array.isArray(result)) { + var first = result[0]; + if (typeof first === 'number' && Number.isInteger(first) && first > 0) { + return first; + } + if (first && typeof first === 'object' && 'id' in first) { + var id = Number(first.id); + return Number.isInteger(id) && id > 0 ? id : null; + } + } + if (result && typeof result === 'object' && 'id' in result) { + var id = Number(result.id); + return Number.isInteger(id) && id > 0 ? id : null; + } + return null; +} +function supportsReturning() { + var _a; + var clientName = String((_a = client_js_1.db.client.config.client) !== null && _a !== void 0 ? _a : '').toLowerCase(); + return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName); +} +function ensureNotificationMessagesTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.NOTIFICATION_MESSAGE_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.NOTIFICATION_MESSAGE_TABLE, function (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(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + _b.label = 3; + case 3: + requiredColumns = [ + ['title', function (table) { return table.string('title', 200).notNullable().defaultTo('알림'); }], + ['body', function (table) { return table.text('body').notNullable().defaultTo(''); }], + ['category', function (table) { return table.string('category', 60).notNullable().defaultTo('general'); }], + ['source', function (table) { return table.string('source', 80).notNullable().defaultTo('system'); }], + ['priority', function (table) { return table.string('priority', 20).notNullable().defaultTo('normal'); }], + ['is_read', function (table) { return table.boolean('is_read').notNullable().defaultTo(false); }], + ['read_at', function (table) { return table.timestamp('read_at', { useTz: true }).nullable(); }], + ['metadata_json', function (table) { return table.jsonb('metadata_json').notNullable().defaultTo('{}'); }], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.NOTIFICATION_MESSAGE_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.NOTIFICATION_MESSAGE_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function listNotificationMessages(query) { + return __awaiter(this, void 0, void 0, function () { + var parsedQuery, builder, rows, unreadCountResult; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensureNotificationMessagesTable()]; + case 1: + _b.sent(); + parsedQuery = exports.notificationMessageListQuerySchema.parse(query); + builder = (0, client_js_1.db)(exports.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 }); + } + return [4 /*yield*/, builder]; + case 2: + rows = _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE) + .where({ is_read: false }) + .count({ count: '*' }) + .first()]; + case 3: + unreadCountResult = _b.sent(); + return [2 /*return*/, { + items: rows.map(function (row) { return mapNotificationMessageRow(row); }), + unreadCount: Number((_a = unreadCountResult === null || unreadCountResult === void 0 ? void 0 : unreadCountResult.count) !== null && _a !== void 0 ? _a : 0), + }]; + } + }); + }); +} +function getNotificationMessage(id) { + return __awaiter(this, void 0, void 0, function () { + var row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationMessagesTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE).where({ id: id }).first()]; + case 2: + row = _a.sent(); + return [2 /*return*/, row ? mapNotificationMessageRow(row) : null]; + } + }); + }); +} +function createNotificationMessage(payload) { + return __awaiter(this, void 0, void 0, function () { + var parsedPayload, insertQuery, insertResult, _a, insertedId, row; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensureNotificationMessagesTable()]; + case 1: + _b.sent(); + parsedPayload = exports.notificationMessagePayloadSchema.parse(payload); + insertQuery = (0, client_js_1.db)(exports.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: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }); + if (!supportsReturning()) return [3 /*break*/, 3]; + return [4 /*yield*/, insertQuery.returning('id')]; + case 2: + _a = _b.sent(); + return [3 /*break*/, 5]; + case 3: return [4 /*yield*/, insertQuery]; + case 4: + _a = _b.sent(); + _b.label = 5; + case 5: + insertResult = _a; + insertedId = resolveInsertedId(insertResult); + if (!insertedId) { + throw new Error('알림 메시지 저장 후 ID를 확인하지 못했습니다.'); + } + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE).where({ id: insertedId }).first()]; + case 6: + row = _b.sent(); + if (!row) { + throw new Error('저장된 알림 메시지를 다시 불러오지 못했습니다.'); + } + return [2 /*return*/, mapNotificationMessageRow(row)]; + } + }); + }); +} +function selectNotificationMessageIdsToDelete(items, keepLatestCount) { + if (keepLatestCount === void 0) { keepLatestCount = 1; } + var normalizedKeepLatestCount = Number.isInteger(keepLatestCount) && keepLatestCount > 0 ? keepLatestCount : 1; + return items + .slice() + .sort(function (left, right) { + var leftCreatedAt = Date.parse(left.createdAt !== null && left.createdAt !== void 0 ? left.createdAt : ''); + var rightCreatedAt = Date.parse(right.createdAt !== null && right.createdAt !== void 0 ? right.createdAt : ''); + if (Number.isFinite(leftCreatedAt) && Number.isFinite(rightCreatedAt) && leftCreatedAt !== rightCreatedAt) { + return rightCreatedAt - leftCreatedAt; + } + return right.id - left.id; + }) + .slice(normalizedKeepLatestCount) + .map(function (item) { return item.id; }); +} +function deleteOlderNotificationMessagesBySource(source, keepLatestCount) { + if (keepLatestCount === void 0) { keepLatestCount = 1; } + return __awaiter(this, void 0, void 0, function () { + var normalizedSource, rows, idsToDelete; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationMessagesTable()]; + case 1: + _a.sent(); + normalizedSource = source.trim(); + if (!normalizedSource) { + return [2 /*return*/, 0]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE) + .select('id', 'created_at') + .where({ source: normalizedSource })]; + case 2: + rows = _a.sent(); + idsToDelete = selectNotificationMessageIdsToDelete(rows.map(function (row) { return ({ + id: Number(row.id !== null && row.id !== void 0 ? row.id : 0), + createdAt: row.created_at === null || row.created_at === undefined ? null : String(row.created_at), + }); }), keepLatestCount).filter(function (id) { return Number.isInteger(id) && id > 0; }); + if (idsToDelete.length === 0) { + return [2 /*return*/, 0]; + } + return [2 /*return*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE).whereIn('id', idsToDelete).del()]; + } + }); + }); +} +function updateNotificationMessageReadState(id, payload) { + return __awaiter(this, void 0, void 0, function () { + var parsedPayload, updatedCount, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationMessagesTable()]; + case 1: + _a.sent(); + parsedPayload = exports.notificationMessageReadPayloadSchema.parse(payload); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE) + .where({ id: id }) + .update({ + is_read: parsedPayload.read, + read_at: parsedPayload.read ? client_js_1.db.fn.now() : null, + updated_at: client_js_1.db.fn.now(), + })]; + case 2: + updatedCount = _a.sent(); + if (!updatedCount) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE).where({ id: id }).first()]; + case 3: + row = _a.sent(); + return [2 /*return*/, row ? mapNotificationMessageRow(row) : null]; + } + }); + }); +} +function deleteNotificationMessage(id) { + return __awaiter(this, void 0, void 0, function () { + var deletedCount; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationMessagesTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_MESSAGE_TABLE).where({ id: id }).del()]; + case 2: + deletedCount = _a.sent(); + return [2 /*return*/, deletedCount > 0]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/notification-message-service.test.ts b/etc/servers/work-server/src/services/notification-message-service.test.ts new file mode 100644 index 0000000..3b41e4f --- /dev/null +++ b/etc/servers/work-server/src/services/notification-message-service.test.ts @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { selectNotificationMessageIdsToDelete } from './notification-message-prune.js'; + +test('selectNotificationMessageIdsToDelete keeps only the latest notification by createdAt and id', () => { + const ids = selectNotificationMessageIdsToDelete([ + { id: 1, createdAt: '2026-05-06T03:00:00.000Z' }, + { id: 2, createdAt: '2026-05-06T05:00:00.000Z' }, + { id: 3, createdAt: '2026-05-06T05:00:00.000Z' }, + { id: 4, createdAt: '2026-05-06T01:00:00.000Z' }, + ]); + + assert.deepEqual(ids, [2, 1, 4]); +}); + +test('selectNotificationMessageIdsToDelete respects keepLatestCount', () => { + const ids = selectNotificationMessageIdsToDelete([ + { id: 10, createdAt: '2026-05-06T02:00:00.000Z' }, + { id: 11, createdAt: '2026-05-06T03:00:00.000Z' }, + { id: 12, createdAt: '2026-05-06T04:00:00.000Z' }, + ], 2); + + assert.deepEqual(ids, [10]); +}); diff --git a/etc/servers/work-server/src/services/notification-message-service.ts b/etc/servers/work-server/src/services/notification-message-service.ts index 17ad497..8b6d377 100755 --- a/etc/servers/work-server/src/services/notification-message-service.ts +++ b/etc/servers/work-server/src/services/notification-message-service.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { db } from '../db/client.js'; +import { selectNotificationMessageIdsToDelete } from './notification-message-prune.js'; export const NOTIFICATION_MESSAGE_TABLE = 'notification_messages'; @@ -215,6 +216,32 @@ export async function createNotificationMessage(payload: z.infer ({ + id: Number(row.id ?? 0), + createdAt: row.created_at === null || row.created_at === undefined ? null : String(row.created_at), + })), + keepLatestCount, + ).filter((id) => Number.isInteger(id) && id > 0); + + if (idsToDelete.length === 0) { + return 0; + } + + return db(NOTIFICATION_MESSAGE_TABLE).whereIn('id', idsToDelete).del(); +} + export async function updateNotificationMessageReadState( id: number, payload: z.infer, diff --git a/etc/servers/work-server/src/services/notification-service.js b/etc/servers/work-server/src/services/notification-service.js new file mode 100644 index 0000000..7101b8a --- /dev/null +++ b/etc/servers/work-server/src/services/notification-service.js @@ -0,0 +1,1352 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.sendIosNotificationSchema = exports.unregisterWebPushSubscriptionSchema = exports.registerWebPushSubscriptionSchema = exports.unregisterIosTokenSchema = exports.registerIosTokenSchema = exports.registerAutomationNotificationPreferenceSchema = exports.NOTIFICATION_PREFERENCE_TABLE = exports.WEB_PUSH_SUBSCRIPTION_TABLE = exports.NOTIFICATION_TOKEN_TABLE = void 0; +exports.setupNotificationTables = setupNotificationTables; +exports.getWebPushConfig = getWebPushConfig; +exports.listIosNotificationTokens = listIosNotificationTokens; +exports.listWebPushSubscriptions = listWebPushSubscriptions; +exports.registerIosNotificationToken = registerIosNotificationToken; +exports.getAutomationNotificationPreference = getAutomationNotificationPreference; +exports.upsertAutomationNotificationPreference = upsertAutomationNotificationPreference; +exports.unregisterIosNotificationToken = unregisterIosNotificationToken; +exports.registerWebPushSubscription = registerWebPushSubscription; +exports.unregisterWebPushSubscription = unregisterWebPushSubscription; +exports.sendIosNotifications = sendIosNotifications; +exports.sendNotifications = sendNotifications; +exports.resolveNotificationAggregateResult = resolveNotificationAggregateResult; +exports.shutdownNotificationProvider = shutdownNotificationProvider; +var promises_1 = require("node:fs/promises"); +var node_apn_1 = require("@parse/node-apn"); +var web_push_1 = require("web-push"); +var zod_1 = require("zod"); +var env_js_1 = require("../config/env.js"); +var client_js_1 = require("../db/client.js"); +var notification_message_service_js_1 = require("./notification-message-service.js"); +exports.NOTIFICATION_TOKEN_TABLE = 'notification_tokens'; +exports.WEB_PUSH_SUBSCRIPTION_TABLE = 'web_push_subscriptions'; +exports.NOTIFICATION_PREFERENCE_TABLE = 'notification_preferences'; +var automationNotificationPreferenceSchema = zod_1.z.object({ + notifyOnAutomationStart: zod_1.z.boolean().optional(), + notifyOnAutomationProgress: zod_1.z.boolean().optional(), + notifyOnAutomationCompletion: zod_1.z.boolean().optional(), + notifyOnAutomationRelease: zod_1.z.boolean().optional(), + notifyOnAutomationMain: zod_1.z.boolean().optional(), + notifyOnAutomationFailure: zod_1.z.boolean().optional(), + notifyOnAutomationRestart: zod_1.z.boolean().optional(), + notifyOnAutomationIssueResolved: zod_1.z.boolean().optional(), +}); +var notificationTargetKindSchema = zod_1.z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']); +exports.registerAutomationNotificationPreferenceSchema = zod_1.z.object({ + targetKind: notificationTargetKindSchema.default('client'), + targetId: zod_1.z.string().trim().min(1).max(1000).optional(), + automation: automationNotificationPreferenceSchema, +}); +exports.registerIosTokenSchema = zod_1.z.object({ + token: zod_1.z.string().trim().min(1), + deviceId: zod_1.z.string().trim().min(1).max(200).optional(), + appOrigin: zod_1.z.string().trim().url().max(500).optional(), + appDomain: zod_1.z.string().trim().min(1).max(255).optional(), + enabled: zod_1.z.boolean().default(true), +}); +exports.unregisterIosTokenSchema = zod_1.z.object({ + token: zod_1.z.string().trim().min(1), +}); +exports.registerWebPushSubscriptionSchema = zod_1.z.object({ + subscription: zod_1.z.object({ + endpoint: zod_1.z.string().trim().url(), + expirationTime: zod_1.z.number().nullable().optional(), + keys: zod_1.z.object({ + p256dh: zod_1.z.string().trim().min(1), + auth: zod_1.z.string().trim().min(1), + }), + }), + deviceId: zod_1.z.string().trim().min(1).max(200).optional(), + userAgent: zod_1.z.string().trim().max(500).optional(), + appOrigin: zod_1.z.string().trim().url().max(500).optional(), + appDomain: zod_1.z.string().trim().min(1).max(255).optional(), + enabled: zod_1.z.boolean().default(true), +}); +exports.unregisterWebPushSubscriptionSchema = zod_1.z.object({ + endpoint: zod_1.z.string().trim().url(), +}); +exports.sendIosNotificationSchema = zod_1.z.object({ + title: zod_1.z.string().trim().min(1), + body: zod_1.z.string().trim().min(1), + data: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).default({}), + threadId: zod_1.z.string().trim().min(1).optional(), + targetClientIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(200)).max(50).optional(), + targetAppOrigins: zod_1.z.array(zod_1.z.string().trim().url().max(500)).max(50).optional(), + targetAppDomains: zod_1.z.array(zod_1.z.string().trim().min(1).max(255)).max(50).optional(), +}); +function normalizeTargetClientIds(targetClientIds) { + return __spreadArray([], new Set((targetClientIds !== null && targetClientIds !== void 0 ? targetClientIds : []).map(function (value) { return String(value !== null && value !== void 0 ? value : '').trim(); }).filter(Boolean)), true); +} +function normalizeTargetAppOrigins(targetAppOrigins) { + return __spreadArray([], new Set((targetAppOrigins !== null && targetAppOrigins !== void 0 ? targetAppOrigins : []).map(function (value) { return normalizeAppOrigin(value); }).filter(Boolean)), true); +} +function normalizeTargetAppDomains(targetAppDomains) { + return __spreadArray([], new Set((targetAppDomains !== null && targetAppDomains !== void 0 ? targetAppDomains : []).map(function (value) { return normalizeAppDomain(value); }).filter(Boolean)), true); +} +function isAllowedTargetClientId(deviceId, targetClientIds) { + if (targetClientIds.length === 0) { + return true; + } + return Boolean(deviceId) && targetClientIds.includes(deviceId); +} +function normalizeAppOrigin(value) { + var normalized = String(value !== null && value !== void 0 ? value : '').trim(); + if (!normalized) { + return ''; + } + try { + var url = new URL(normalized); + return url.origin; + } + catch (_a) { + return ''; + } +} +function normalizeAppDomain(value) { + return String(value !== null && value !== void 0 ? value : '').trim().toLowerCase(); +} +function resolveAppDomainFromOrigin(origin) { + if (!origin) { + return ''; + } + try { + return new URL(origin).hostname.trim().toLowerCase(); + } + catch (_a) { + return ''; + } +} +function isAllowedAppTarget(item, targetAppOrigins, targetAppDomains) { + if (targetAppOrigins.length === 0 && targetAppDomains.length === 0) { + return true; + } + if (targetAppOrigins.length > 0) { + if (!item.appOrigin || !targetAppOrigins.includes(item.appOrigin)) { + return false; + } + } + if (targetAppDomains.length > 0) { + if (!item.appDomain || !targetAppDomains.includes(item.appDomain)) { + return false; + } + } + return true; +} +function buildScopedPwaNotificationTargetId(token, clientId) { + return [token.trim(), clientId.trim()].filter(Boolean).join('::client::'); +} +var providerPromise = null; +var providerSignature = null; +function normalizePrivateKey(privateKey) { + return privateKey.replace(/\\n/g, '\n'); +} +function loadPrivateKey(env) { + return __awaiter(this, void 0, void 0, function () { + var file; + var _a, _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: + if ((_a = env.APNS_PRIVATE_KEY) === null || _a === void 0 ? void 0 : _a.trim()) { + return [2 /*return*/, normalizePrivateKey(env.APNS_PRIVATE_KEY.trim())]; + } + if (!((_b = env.APNS_PRIVATE_KEY_PATH) === null || _b === void 0 ? void 0 : _b.trim())) return [3 /*break*/, 2]; + return [4 /*yield*/, (0, promises_1.readFile)(env.APNS_PRIVATE_KEY_PATH.trim(), 'utf8')]; + case 1: + file = _c.sent(); + return [2 /*return*/, file.trim()]; + case 2: return [2 /*return*/, null]; + } + }); + }); +} +function hasWebPushConfig(env) { + var _a, _b, _c; + return Boolean(env.WEB_PUSH_ENABLED && + ((_a = env.WEB_PUSH_VAPID_PUBLIC_KEY) === null || _a === void 0 ? void 0 : _a.trim()) && + ((_b = env.WEB_PUSH_VAPID_PRIVATE_KEY) === null || _b === void 0 ? void 0 : _b.trim()) && + ((_c = env.WEB_PUSH_SUBJECT) === null || _c === void 0 ? void 0 : _c.trim())); +} +function ensureWebPushConfigured(env) { + if (!hasWebPushConfig(env)) { + return false; + } + web_push_1.default.setVapidDetails(env.WEB_PUSH_SUBJECT, env.WEB_PUSH_VAPID_PUBLIC_KEY.trim(), env.WEB_PUSH_VAPID_PRIVATE_KEY.trim()); + return true; +} +function normalizeNotificationDetailText(text) { + var normalized = String(text !== null && text !== void 0 ? text : '').trim(); + return normalized || undefined; +} +function isChatNotificationPayload(payload) { + var _a, _b, _c; + var category = String((_b = (_a = payload.data) === null || _a === void 0 ? void 0 : _a.category) !== null && _b !== void 0 ? _b : '').trim().toLowerCase(); + var threadId = String((_c = payload.threadId) !== null && _c !== void 0 ? _c : '').trim().toLowerCase(); + return category === 'chat' || threadId.startsWith('chat:'); +} +function isRetryableWebPushError(error) { + var _a, _b; + var statusCode = Number((_a = error === null || error === void 0 ? void 0 : error.statusCode) !== null && _a !== void 0 ? _a : 0); + if ([408, 425, 429, 500, 502, 503, 504].includes(statusCode)) { + return true; + } + var code = String((_b = error === null || error === void 0 ? void 0 : error.code) !== null && _b !== void 0 ? _b : '').trim().toUpperCase(); + return ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(code); +} +function waitForRetry(delayMs) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, delayMs); })]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function sendWebPushWithRetry(subscription, payloadText) { + return __awaiter(this, void 0, void 0, function () { + var lastError, attempt, error_1; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + attempt = 1; + _a.label = 1; + case 1: + if (!(attempt <= 2)) return [3 /*break*/, 7]; + _a.label = 2; + case 2: + _a.trys.push([2, 4, , 6]); + return [4 /*yield*/, web_push_1.default.sendNotification(subscription, payloadText)]; + case 3: + _a.sent(); + return [2 /*return*/, { attemptCount: attempt }]; + case 4: + error_1 = _a.sent(); + lastError = error_1; + if (attempt >= 2 || !isRetryableWebPushError(error_1)) { + throw { + error: error_1, + attemptCount: attempt, + }; + } + return [4 /*yield*/, waitForRetry(250 * attempt)]; + case 5: + _a.sent(); + return [3 /*break*/, 6]; + case 6: + attempt += 1; + return [3 /*break*/, 1]; + case 7: throw { + error: lastError, + attemptCount: 2, + }; + } + }); + }); +} +function buildProviderSignature(env) { + 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('|'); +} +function createProvider(env) { + return __awaiter(this, void 0, void 0, function () { + var privateKey; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!env.IOS_NOTIFICATION_ENABLED || + !env.APNS_KEY_ID || + !env.APNS_TEAM_ID || + !env.APNS_BUNDLE_ID) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, loadPrivateKey(env)]; + case 1: + privateKey = _a.sent(); + if (!privateKey) { + return [2 /*return*/, null]; + } + return [2 /*return*/, new node_apn_1.Provider({ + token: { + key: privateKey, + keyId: env.APNS_KEY_ID, + teamId: env.APNS_TEAM_ID, + }, + production: env.APNS_PRODUCTION, + })]; + } + }); + }); +} +function getProvider() { + return __awaiter(this, void 0, void 0, function () { + var env, signature; + return __generator(this, function (_a) { + env = (0, env_js_1.getEnv)(); + signature = buildProviderSignature(env); + if (signature !== providerSignature) { + providerSignature = signature; + providerPromise = null; + } + if (!providerPromise) { + providerPromise = createProvider(env); + } + return [2 /*return*/, providerPromise]; + }); + }); +} +function ensureNotificationTokenTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.NOTIFICATION_TOKEN_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.NOTIFICATION_TOKEN_TABLE, function (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.string('app_origin', 500).nullable(); + table.string('app_domain', 255).nullable(); + table.boolean('is_enabled').notNullable().defaultTo(true); + table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['platform', function (table) { return table.string('platform', 20).notNullable().defaultTo('ios'); }], + ['device_token', function (table) { return table.string('device_token', 255).notNullable(); }], + ['device_id', function (table) { return table.string('device_id', 200).nullable(); }], + ['app_origin', function (table) { return table.string('app_origin', 500).nullable(); }], + ['app_domain', function (table) { return table.string('app_domain', 255).nullable(); }], + ['is_enabled', function (table) { return table.boolean('is_enabled').notNullable().defaultTo(true); }], + [ + 'last_registered_at', + function (table) { return table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }, + ], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.NOTIFICATION_TOKEN_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.NOTIFICATION_TOKEN_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function ensureWebPushSubscriptionTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_2, _i, requiredColumns_2, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.WEB_PUSH_SUBSCRIPTION_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.WEB_PUSH_SUBSCRIPTION_TABLE, function (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.string('app_origin', 500).nullable(); + table.string('app_domain', 255).nullable(); + table.boolean('is_enabled').notNullable().defaultTo(true); + table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['endpoint', function (table) { return table.string('endpoint', 1000).notNullable(); }], + ['subscription_json', function (table) { return table.jsonb('subscription_json').notNullable().defaultTo('{}'); }], + ['device_id', function (table) { return table.string('device_id', 200).nullable(); }], + ['user_agent', function (table) { return table.text('user_agent').nullable(); }], + ['app_origin', function (table) { return table.string('app_origin', 500).nullable(); }], + ['app_domain', function (table) { return table.string('app_domain', 255).nullable(); }], + ['is_enabled', function (table) { return table.boolean('is_enabled').notNullable().defaultTo(true); }], + [ + 'last_registered_at', + function (table) { return table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }, + ], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_2 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.WEB_PUSH_SUBSCRIPTION_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.WEB_PUSH_SUBSCRIPTION_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_2 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_2.length)) return [3 /*break*/, 7]; + _a = requiredColumns_2[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_2(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function ensureNotificationPreferenceTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_3, _i, requiredColumns_3, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.NOTIFICATION_PREFERENCE_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.NOTIFICATION_PREFERENCE_TABLE, function (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(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.unique(['target_kind', 'target_id']); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['target_kind', function (table) { return table.string('target_kind', 40).notNullable().defaultTo('client'); }], + ['target_id', function (table) { return table.string('target_id', 1000).notNullable().defaultTo(''); }], + ['config_json', function (table) { return table.jsonb('config_json').notNullable().defaultTo('{}'); }], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_3 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.NOTIFICATION_PREFERENCE_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.NOTIFICATION_PREFERENCE_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_3 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_3.length)) return [3 /*break*/, 7]; + _a = requiredColumns_3[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_3(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function setupNotificationTables() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationTokenTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, ensureWebPushSubscriptionTable()]; + case 2: + _a.sent(); + return [4 /*yield*/, ensureNotificationPreferenceTable()]; + case 3: + _a.sent(); + return [4 /*yield*/, (0, notification_message_service_js_1.ensureNotificationMessagesTable)()]; + case 4: + _a.sent(); + return [2 /*return*/, { + ok: true, + tables: [exports.NOTIFICATION_TOKEN_TABLE, exports.WEB_PUSH_SUBSCRIPTION_TABLE, exports.NOTIFICATION_PREFERENCE_TABLE, 'notification_messages'], + }]; + } + }); + }); +} +function getWebPushConfig() { + var env = (0, env_js_1.getEnv)(); + return { + enabled: hasWebPushConfig(env), + publicKey: hasWebPushConfig(env) ? env.WEB_PUSH_VAPID_PUBLIC_KEY.trim() : '', + }; +} +function listIosNotificationTokens() { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationTokenTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_TOKEN_TABLE) + .where({ platform: 'ios' }) + .orderBy('updated_at', 'desc')]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows.map(function (row) { return ({ + id: row.id, + platform: row.platform, + token: row.device_token, + deviceId: row.device_id, + appOrigin: row.app_origin ? String(row.app_origin) : '', + appDomain: row.app_domain ? String(row.app_domain) : '', + enabled: row.is_enabled, + lastRegisteredAt: row.last_registered_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); })]; + } + }); + }); +} +function listWebPushSubscriptions() { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureWebPushSubscriptionTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.WEB_PUSH_SUBSCRIPTION_TABLE).orderBy('updated_at', 'desc')]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows.map(function (row) { + var _a; + return ({ + id: row.id, + endpoint: String((_a = row.endpoint) !== null && _a !== void 0 ? _a : ''), + deviceId: row.device_id ? String(row.device_id) : '', + userAgent: row.user_agent ? String(row.user_agent) : '', + appOrigin: row.app_origin ? String(row.app_origin) : '', + appDomain: row.app_domain ? String(row.app_domain) : '', + enabled: Boolean(row.is_enabled), + lastRegisteredAt: row.last_registered_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + })]; + } + }); + }); +} +function registerIosNotificationToken(payload) { + return __awaiter(this, void 0, void 0, function () { + var appOrigin, appDomain; + var _a, _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, ensureNotificationTokenTable()]; + case 1: + _c.sent(); + appOrigin = normalizeAppOrigin(payload.appOrigin); + appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin); + if (!!payload.enabled) return [3 /*break*/, 3]; + return [4 /*yield*/, unregisterIosNotificationToken(payload.token)]; + case 2: + _c.sent(); + return [2 /*return*/, { + ok: true, + removed: true, + token: payload.token, + }]; + case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_TOKEN_TABLE) + .insert({ + platform: 'ios', + device_token: payload.token, + device_id: (_a = payload.deviceId) !== null && _a !== void 0 ? _a : null, + app_origin: appOrigin || null, + app_domain: appDomain || null, + is_enabled: true, + last_registered_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .onConflict('device_token') + .merge({ + platform: 'ios', + device_id: (_b = payload.deviceId) !== null && _b !== void 0 ? _b : null, + app_origin: appOrigin || null, + app_domain: appDomain || null, + is_enabled: true, + last_registered_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + })]; + case 4: + _c.sent(); + return [2 /*return*/, { + ok: true, + token: payload.token, + }]; + } + }); + }); +} +function parseAutomationNotificationPreference(raw) { + if (typeof raw === 'string') { + try { + return automationNotificationPreferenceSchema.parse(JSON.parse(raw)); + } + catch (_a) { + return {}; + } + } + return automationNotificationPreferenceSchema.parse(raw !== null && raw !== void 0 ? raw : {}); +} +function getAutomationNotificationPreference(targetId_1) { + return __awaiter(this, arguments, void 0, function (targetId, targetKind) { + var normalizedTargetId, row; + if (targetKind === void 0) { targetKind = 'client'; } + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + normalizedTargetId = targetId.trim(); + if (!normalizedTargetId) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, ensureNotificationPreferenceTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_PREFERENCE_TABLE) + .where({ + target_kind: targetKind, + target_id: normalizedTargetId, + }) + .first()]; + case 2: + row = _a.sent(); + if (!row) { + return [2 /*return*/, null]; + } + return [2 /*return*/, parseAutomationNotificationPreference(row.config_json)]; + } + }); + }); +} +function upsertAutomationNotificationPreference(payload) { + return __awaiter(this, void 0, void 0, function () { + var targetId; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + targetId = payload.targetId.trim(); + return [4 /*yield*/, ensureNotificationPreferenceTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_PREFERENCE_TABLE) + .insert({ + target_kind: payload.targetKind, + target_id: targetId, + config_json: payload.automation, + updated_at: client_js_1.db.fn.now(), + }) + .onConflict(['target_kind', 'target_id']) + .merge({ + config_json: payload.automation, + updated_at: client_js_1.db.fn.now(), + })]; + case 2: + _a.sent(); + return [2 /*return*/, { + ok: true, + targetKind: payload.targetKind, + targetId: targetId, + automation: payload.automation, + }]; + } + }); + }); +} +function unregisterIosNotificationToken(token) { + return __awaiter(this, void 0, void 0, function () { + var deletedCount; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationTokenTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_TOKEN_TABLE) + .where({ + platform: 'ios', + device_token: token, + }) + .delete()]; + case 2: + deletedCount = _a.sent(); + return [2 /*return*/, { + ok: true, + removed: deletedCount > 0, + token: token, + }]; + } + }); + }); +} +function registerWebPushSubscription(payload) { + return __awaiter(this, void 0, void 0, function () { + var appOrigin, appDomain; + var _a, _b, _c, _d, _e; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: return [4 /*yield*/, ensureWebPushSubscriptionTable()]; + case 1: + _f.sent(); + appOrigin = normalizeAppOrigin(payload.appOrigin); + appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin); + if (!!payload.enabled) return [3 /*break*/, 3]; + return [4 /*yield*/, unregisterWebPushSubscription(payload.subscription.endpoint)]; + case 2: + _f.sent(); + return [2 /*return*/, { + ok: true, + removed: true, + endpoint: payload.subscription.endpoint, + }]; + case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.WEB_PUSH_SUBSCRIPTION_TABLE) + .insert({ + endpoint: payload.subscription.endpoint, + subscription_json: payload.subscription, + device_id: (_a = payload.deviceId) !== null && _a !== void 0 ? _a : null, + user_agent: (_b = payload.userAgent) !== null && _b !== void 0 ? _b : null, + app_origin: appOrigin || null, + app_domain: appDomain || null, + is_enabled: true, + last_registered_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .onConflict('endpoint') + .merge({ + subscription_json: payload.subscription, + device_id: (_c = payload.deviceId) !== null && _c !== void 0 ? _c : null, + user_agent: (_d = payload.userAgent) !== null && _d !== void 0 ? _d : null, + app_origin: appOrigin || null, + app_domain: appDomain || null, + is_enabled: true, + last_registered_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + })]; + case 4: + _f.sent(); + if (!((_e = payload.deviceId) === null || _e === void 0 ? void 0 : _e.trim())) return [3 /*break*/, 6]; + return [4 /*yield*/, (0, client_js_1.db)(exports.WEB_PUSH_SUBSCRIPTION_TABLE) + .where({ device_id: payload.deviceId.trim() }) + .whereNot({ endpoint: payload.subscription.endpoint }) + .delete()]; + case 5: + _f.sent(); + _f.label = 6; + case 6: return [2 /*return*/, { + ok: true, + endpoint: payload.subscription.endpoint, + }]; + } + }); + }); +} +function unregisterWebPushSubscription(endpoint) { + return __awaiter(this, void 0, void 0, function () { + var deletedCount; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureWebPushSubscriptionTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.WEB_PUSH_SUBSCRIPTION_TABLE).where({ endpoint: endpoint }).delete()]; + case 2: + deletedCount = _a.sent(); + return [2 /*return*/, { + ok: true, + removed: deletedCount > 0, + endpoint: endpoint, + }]; + } + }); + }); +} +function getEnabledIosTokens() { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureNotificationTokenTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_TOKEN_TABLE) + .where({ + platform: 'ios', + is_enabled: true, + }) + .select('device_token', 'device_id', 'app_origin', 'app_domain')]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows.map(function (row) { return ({ + token: String(row.device_token), + deviceId: row.device_id ? String(row.device_id) : '', + appOrigin: row.app_origin ? String(row.app_origin) : '', + appDomain: row.app_domain ? String(row.app_domain) : '', + }); })]; + } + }); + }); +} +function getEnabledWebPushSubscriptions() { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureWebPushSubscriptionTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.WEB_PUSH_SUBSCRIPTION_TABLE) + .where({ + is_enabled: true, + }) + .select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain')]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows.map(function (row) { return ({ + endpoint: String(row.endpoint), + subscription: row.subscription_json, + deviceId: row.device_id ? String(row.device_id) : '', + appOrigin: row.app_origin ? String(row.app_origin) : '', + appDomain: row.app_domain ? String(row.app_domain) : '', + }); })]; + } + }); + }); +} +function removeInvalidIosTokens(tokens) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!tokens.length) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.NOTIFICATION_TOKEN_TABLE) + .whereIn('device_token', tokens) + .delete()]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function removeInvalidWebPushSubscriptions(endpoints) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (!endpoints.length) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.WEB_PUSH_SUBSCRIPTION_TABLE) + .whereIn('endpoint', endpoints) + .delete()]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function shouldNotifyAutomationEvent(automation, eventType) { + var _a, _b, _c, _d, _e, _f, _g, _h; + if (eventType === 'work-started') { + return (_a = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationStart) !== null && _a !== void 0 ? _a : true; + } + if (eventType === 'work-progress') { + return (_b = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationProgress) !== null && _b !== void 0 ? _b : true; + } + if (eventType === 'work-completed' || + eventType === 'work-noop-complete' || + eventType === 'development-completed' || + eventType === 'plan-completed') { + return (_c = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationCompletion) !== null && _c !== void 0 ? _c : true; + } + if (eventType === 'release-merged') { + return (_d = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRelease) !== null && _d !== void 0 ? _d : true; + } + if (eventType === 'main-merged') { + return (_e = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationMain) !== null && _e !== void 0 ? _e : true; + } + if (eventType === 'branch-failed' || + eventType === 'work-failed' || + eventType === 'release-failed' || + eventType === 'main-failed') { + return (_f = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationFailure) !== null && _f !== void 0 ? _f : true; + } + if (eventType === 'plan-restarted') { + return (_g = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRestart) !== null && _g !== void 0 ? _g : true; + } + if (eventType === 'issue-resolved') { + return (_h = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationIssueResolved) !== null && _h !== void 0 ? _h : true; + } + return true; +} +function isNotificationRecipientAllowed(preferenceTargets, payload) { + return __awaiter(this, void 0, void 0, function () { + var eventType, _i, preferenceTargets_1, target, automation; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + eventType = (_a = payload.data.eventType) === null || _a === void 0 ? void 0 : _a.trim(); + if (!eventType) { + return [2 /*return*/, true]; + } + _i = 0, preferenceTargets_1 = preferenceTargets; + _b.label = 1; + case 1: + if (!(_i < preferenceTargets_1.length)) return [3 /*break*/, 4]; + target = preferenceTargets_1[_i]; + if (!target.id.trim()) { + return [3 /*break*/, 3]; + } + return [4 /*yield*/, getAutomationNotificationPreference(target.id, target.kind)]; + case 2: + automation = _b.sent(); + if (automation) { + return [2 /*return*/, shouldNotifyAutomationEvent(automation, eventType)]; + } + _b.label = 3; + case 3: + _i++; + return [3 /*break*/, 1]; + case 4: return [2 /*return*/, true]; + } + }); + }); +} +function sendIosNotifications(payload) { + return __awaiter(this, void 0, void 0, function () { + var env, provider, targetClientIds, targetAppOrigins, targetAppDomains, tokenRows, tokens, notification, response, invalidTokens; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + env = (0, env_js_1.getEnv)(); + return [4 /*yield*/, getProvider()]; + case 1: + provider = _a.sent(); + targetClientIds = normalizeTargetClientIds(payload.targetClientIds); + targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins); + targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains); + if (!provider || !env.APNS_BUNDLE_ID) { + return [2 /*return*/, { + ok: false, + skipped: true, + reason: 'APNs 설정이 비어 있습니다.', + sentCount: 0, + failedCount: 0, + }]; + } + return [4 /*yield*/, getEnabledIosTokens()]; + case 2: + tokenRows = _a.sent(); + return [4 /*yield*/, Promise.all(tokenRows.map(function (row) { return __awaiter(_this, void 0, void 0, function () { + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + _a = { + token: row.token, + deviceId: row.deviceId, + appOrigin: row.appOrigin, + appDomain: row.appDomain + }; + return [4 /*yield*/, isNotificationRecipientAllowed([ + { kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) }, + { kind: 'ios-token', id: row.token }, + { kind: 'client', id: row.deviceId }, + ], payload)]; + case 1: return [2 /*return*/, (_a.allowed = _b.sent(), + _a)]; + } + }); + }); }))]; + case 3: + tokens = (_a.sent()) + .filter(function (row) { + return row.allowed && + isAllowedTargetClientId(row.deviceId, targetClientIds) && + isAllowedAppTarget(row, targetAppOrigins, targetAppDomains); + }) + .map(function (row) { return row.token; }); + if (!tokens.length) { + return [2 /*return*/, { + ok: true, + skipped: true, + reason: '등록된 iOS 알림 토큰이 없습니다.', + sentCount: 0, + failedCount: 0, + }]; + } + notification = new node_apn_1.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; + } + return [4 /*yield*/, provider.send(notification, tokens)]; + case 4: + response = _a.sent(); + invalidTokens = response.failed + .map(function (result) { return result.device; }) + .filter(function (device) { return Boolean(device); }); + return [4 /*yield*/, removeInvalidIosTokens(invalidTokens)]; + case 5: + _a.sent(); + return [2 /*return*/, { + ok: response.failed.length === 0, + skipped: false, + sentCount: response.sent.length, + failedCount: response.failed.length, + invalidTokens: invalidTokens, + }]; + } + }); + }); +} +function sendWebPushNotifications(payload) { + return __awaiter(this, void 0, void 0, function () { + var env, targetClientIds, targetAppOrigins, targetAppDomains, subscriptions, _a, _b, payloadText, preserveSubscriptions, invalidEndpoints, sentCount, failedCount, failures; + var _this = this; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: + env = (0, env_js_1.getEnv)(); + targetClientIds = normalizeTargetClientIds(payload.targetClientIds); + targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins); + targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains); + if (!ensureWebPushConfigured(env)) { + return [2 /*return*/, { + ok: false, + skipped: true, + reason: 'Web Push 설정이 비어 있습니다.', + sentCount: 0, + failedCount: 0, + }]; + } + _b = (_a = Promise).all; + return [4 /*yield*/, getEnabledWebPushSubscriptions()]; + case 1: return [4 /*yield*/, _b.apply(_a, [(_c.sent()).map(function (row) { return __awaiter(_this, void 0, void 0, function () { + var _a; + var _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: + _a = [__assign({}, row)]; + _b = {}; + return [4 /*yield*/, isNotificationRecipientAllowed([ + { kind: 'web-endpoint', id: row.endpoint }, + { kind: 'client', id: row.deviceId }, + ], payload)]; + case 1: return [2 /*return*/, (__assign.apply(void 0, _a.concat([(_b.allowed = _c.sent(), _b)])))]; + } + }); + }); })])]; + case 2: + subscriptions = (_c.sent()).filter(function (row) { + return row.allowed && + isAllowedTargetClientId(row.deviceId, targetClientIds) && + isAllowedAppTarget(row, targetAppOrigins, targetAppDomains); + }); + if (!subscriptions.length) { + return [2 /*return*/, { + ok: true, + skipped: true, + reason: '등록된 Web Push 구독이 없습니다.', + sentCount: 0, + failedCount: 0, + }]; + } + payloadText = JSON.stringify({ + title: payload.title, + body: payload.body, + data: payload.data, + threadId: payload.threadId, + }); + preserveSubscriptions = isChatNotificationPayload(payload); + invalidEndpoints = []; + sentCount = 0; + failedCount = 0; + failures = []; + return [4 /*yield*/, Promise.all(subscriptions.map(function (_a) { return __awaiter(_this, [_a], void 0, function (_b) { + var error_2, deliveryError, attemptCount, statusCode, detail, code; + var _c, _d, _e, _f; + var endpoint = _b.endpoint, subscription = _b.subscription; + return __generator(this, function (_g) { + switch (_g.label) { + case 0: + _g.trys.push([0, 2, , 3]); + return [4 /*yield*/, sendWebPushWithRetry(subscription, payloadText)]; + case 1: + _g.sent(); + sentCount += 1; + return [3 /*break*/, 3]; + case 2: + error_2 = _g.sent(); + deliveryError = (_c = error_2 === null || error_2 === void 0 ? void 0 : error_2.error) !== null && _c !== void 0 ? _c : error_2; + attemptCount = Number((_d = error_2 === null || error_2 === void 0 ? void 0 : error_2.attemptCount) !== null && _d !== void 0 ? _d : 1); + failedCount += 1; + statusCode = Number((_e = deliveryError === null || deliveryError === void 0 ? void 0 : deliveryError.statusCode) !== null && _e !== void 0 ? _e : 0); + detail = (_f = normalizeNotificationDetailText(deliveryError === null || deliveryError === void 0 ? void 0 : deliveryError.body)) !== null && _f !== void 0 ? _f : normalizeNotificationDetailText(deliveryError === null || deliveryError === void 0 ? void 0 : deliveryError.message); + code = normalizeNotificationDetailText(deliveryError === null || deliveryError === void 0 ? void 0 : deliveryError.code); + if (!preserveSubscriptions && (statusCode === 404 || statusCode === 410)) { + invalidEndpoints.push(endpoint); + } + failures.push({ + endpoint: endpoint, + statusCode: statusCode || undefined, + detail: detail, + code: code, + attemptCount: attemptCount, + }); + return [3 /*break*/, 3]; + case 3: return [2 /*return*/]; + } + }); + }); }))]; + case 3: + _c.sent(); + if (!!preserveSubscriptions) return [3 /*break*/, 5]; + return [4 /*yield*/, removeInvalidWebPushSubscriptions(invalidEndpoints)]; + case 4: + _c.sent(); + _c.label = 5; + case 5: + if (failures.length) { + console.warn('[notification-service] web push delivery failed', JSON.stringify({ + failedCount: failedCount, + preserveSubscriptions: preserveSubscriptions, + invalidEndpointCount: invalidEndpoints.length, + failures: failures, + })); + } + return [2 /*return*/, { + ok: failedCount === 0, + skipped: false, + sentCount: sentCount, + failedCount: failedCount, + invalidEndpoints: invalidEndpoints, + }]; + } + }); + }); +} +function sendNotifications(payload, options) { + return __awaiter(this, void 0, void 0, function () { + var _a, ios, web, aggregate; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, Promise.all([ + (options === null || options === void 0 ? void 0 : options.disableIos) + ? Promise.resolve({ + ok: true, + skipped: true, + reason: '요청 설정에 따라 iOS 알림 발송을 건너뛰었습니다.', + sentCount: 0, + failedCount: 0, + invalidTokens: [], + }) + : sendIosNotifications(payload), + (options === null || options === void 0 ? void 0 : options.disableWebPush) + ? Promise.resolve({ + ok: true, + skipped: true, + reason: '요청 설정에 따라 Web Push 발송을 건너뛰었습니다.', + sentCount: 0, + failedCount: 0, + invalidEndpoints: [], + }) + : sendWebPushNotifications(payload), + ])]; + case 1: + _a = _b.sent(), ios = _a[0], web = _a[1]; + aggregate = resolveNotificationAggregateResult({ + ios: ios, + web: web, + }, options); + return [2 /*return*/, { + ok: aggregate.ok, + skipped: aggregate.skipped, + ios: ios, + web: web, + }]; + } + }); + }); +} +function resolveNotificationAggregateResult(result, options) { + var enabledResults = [ + (options === null || options === void 0 ? void 0 : options.disableIos) ? null : result.ios, + (options === null || options === void 0 ? void 0 : options.disableWebPush) ? null : result.web, + ].filter(function (entry) { return Boolean(entry); }); + return { + ok: enabledResults.length === 0 ? true : enabledResults.every(function (entry) { return entry.ok; }), + skipped: enabledResults.length === 0 ? true : enabledResults.every(function (entry) { return entry.skipped; }), + }; +} +function shutdownNotificationProvider() { + return __awaiter(this, void 0, void 0, function () { + var provider; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, getProvider()]; + case 1: + provider = _a.sent(); + provider === null || provider === void 0 ? void 0 : provider.shutdown(); + providerPromise = null; + return [2 /*return*/]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/plan-notification-policy.js b/etc/servers/work-server/src/services/plan-notification-policy.js new file mode 100644 index 0000000..1c65214 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-notification-policy.js @@ -0,0 +1,6 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.shouldNotifyPlanRestart = shouldNotifyPlanRestart; +function shouldNotifyPlanRestart(result) { + return Boolean(result === null || result === void 0 ? void 0 : result.didScheduleRetry); +} diff --git a/etc/servers/work-server/src/services/plan-notification-service.js b/etc/servers/work-server/src/services/plan-notification-service.js new file mode 100644 index 0000000..af74638 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-notification-service.js @@ -0,0 +1,142 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.shouldNotifyPlanEventType = shouldNotifyPlanEventType; +exports.shouldSendPlanNotification = shouldSendPlanNotification; +exports.buildPlanNotificationData = buildPlanNotificationData; +exports.notifyPlanEvent = notifyPlanEvent; +var notification_service_js_1 = require("./notification-service.js"); +var plan_service_js_1 = require("./plan-service.js"); +function buildPlanNotificationTargetUrl(planId, workId) { + var targetUrl = new URL('https://sm-home.cloud/'); + targetUrl.searchParams.set('topMenu', 'plans'); + targetUrl.searchParams.set('planId', String(planId)); + if (workId === null || workId === void 0 ? void 0 : workId.trim()) { + targetUrl.searchParams.set('workId', workId.trim()); + } + return targetUrl.toString(); +} +function shouldNotifyPlanEventType(automation, eventType) { + var _a, _b, _c, _d, _e, _f, _g, _h; + if (eventType === 'work-started') { + return (_a = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationStart) !== null && _a !== void 0 ? _a : true; + } + if (eventType === 'work-progress') { + return (_b = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationProgress) !== null && _b !== void 0 ? _b : true; + } + if (eventType === 'work-completed' || + eventType === 'work-noop-complete' || + eventType === 'development-completed' || + eventType === 'plan-completed') { + return (_c = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationCompletion) !== null && _c !== void 0 ? _c : true; + } + if (eventType === 'release-merged') { + return (_d = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRelease) !== null && _d !== void 0 ? _d : true; + } + if (eventType === 'main-merged') { + return (_e = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationMain) !== null && _e !== void 0 ? _e : true; + } + if (eventType === 'branch-failed' || + eventType === 'work-failed' || + eventType === 'release-failed' || + eventType === 'main-failed') { + return (_f = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationFailure) !== null && _f !== void 0 ? _f : true; + } + if (eventType === 'plan-restarted') { + return (_g = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRestart) !== null && _g !== void 0 ? _g : true; + } + if (eventType === 'issue-resolved') { + return (_h = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationIssueResolved) !== null && _h !== void 0 ? _h : true; + } + return true; +} +function shouldSendPlanNotification(eventType) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + void eventType; + return [2 /*return*/, true]; + }); + }); +} +function buildPlanNotificationData(planId, workId, eventType) { + return { + category: 'automation', + planId: String(planId), + workId: String(workId !== null && workId !== void 0 ? workId : ''), + eventType: eventType, + notificationScope: 'automation', + targetUrl: buildPlanNotificationTargetUrl(planId, workId), + notificationKey: "plan:".concat(planId), + }; +} +function notifyPlanEvent(planId, title, body, eventType) { + return __awaiter(this, void 0, void 0, function () { + var item; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, shouldSendPlanNotification(eventType)]; + case 1: + if (!(_a.sent())) { + return [2 /*return*/, { + ok: true, + skipped: true, + reason: '앱 설정에서 이 알림 항목이 꺼져 있습니다.', + }]; + } + return [4 /*yield*/, (0, plan_service_js_1.getPlanItemById)(planId)]; + case 2: + item = _a.sent(); + if (!item) { + return [2 /*return*/, { + ok: false, + skipped: true, + reason: '작업 항목을 찾을 수 없습니다.', + }]; + } + return [2 /*return*/, (0, notification_service_js_1.sendNotifications)({ + title: title, + body: body, + threadId: "plan-".concat(planId), + data: buildPlanNotificationData(planId, String(item.workId), eventType), + }, { + disableWebPush: Boolean(item.suppressWebPush), + })]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/plan-retry-policy.js b/etc/servers/work-server/src/services/plan-retry-policy.js new file mode 100644 index 0000000..2256da3 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-retry-policy.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.shouldTriggerRetryFromActionNote = shouldTriggerRetryFromActionNote; +function shouldTriggerRetryFromActionNote(actionNote) { + var text = String(actionNote !== null && actionNote !== void 0 ? actionNote : '').trim(); + if (!text) { + return false; + } + return /(재처리|다시|누락|빠진|빼먹|보완|조치해|처리해|해결해|고쳐|반영해|수정해|시도해|진행해|테스트해|검증해|부탁)/.test(text); +} diff --git a/etc/servers/work-server/src/services/plan-schedule-service.js b/etc/servers/work-server/src/services/plan-schedule-service.js new file mode 100644 index 0000000..d6d4f4f --- /dev/null +++ b/etc/servers/work-server/src/services/plan-schedule-service.js @@ -0,0 +1,1788 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updatePlanScheduledTaskSchema = exports.createPlanScheduledTaskSchema = exports.PLAN_SCHEDULED_TASK_TABLE = void 0; +exports.buildScheduledBoardPostTitle = buildScheduledBoardPostTitle; +exports.buildScheduledPlanWorkIdBase = buildScheduledPlanWorkIdBase; +exports.shouldCreatePlanForScheduleExecution = shouldCreatePlanForScheduleExecution; +exports.isPlanScheduledTaskDue = isPlanScheduledTaskDue; +exports.mapPlanScheduledTaskRow = mapPlanScheduledTaskRow; +exports.ensurePlanScheduledTaskTable = ensurePlanScheduledTaskTable; +exports.listPlanScheduledTasks = listPlanScheduledTasks; +exports.getPlanScheduledTaskById = getPlanScheduledTaskById; +exports.syncManagedServiceGenerationCompletion = syncManagedServiceGenerationCompletion; +exports.createPlanScheduledTask = createPlanScheduledTask; +exports.updatePlanScheduledTask = updatePlanScheduledTask; +exports.deletePlanScheduledTask = deletePlanScheduledTask; +exports.registerDuePlanScheduledTasks = registerDuePlanScheduledTasks; +exports.registerPlanScheduledTaskNow = registerPlanScheduledTaskNow; +var zod_1 = require("zod"); +var client_js_1 = require("../db/client.js"); +var automation_type_config_service_js_1 = require("./automation-type-config-service.js"); +var board_service_js_1 = require("./board-service.js"); +var managed_schedule_service_js_1 = require("./managed-schedule-service.js"); +var automation_context_service_js_1 = require("./automation-context-service.js"); +var plan_service_js_1 = require("./plan-service.js"); +var worklog_automation_utils_js_1 = require("./worklog-automation-utils.js"); +exports.PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks'; +var scheduleModes = ['interval', 'daily']; +var repeatIntervalUnits = ['second', 'minute', 'hour', 'day', 'week', 'month']; +var scheduleExecutionModes = ['codex', 'managed-service']; +var DEFAULT_DAILY_RUN_TIME = '09:00'; +var TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +var DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +var MAX_REPEAT_INTERVAL_SECONDS = 31536000; +var DEFAULT_REPEAT_INTERVAL_SECONDS = 60 * 60; +var scheduleDateRangeSchema = zod_1.z + .object({ + startDate: zod_1.z.string().regex(DATE_KEY_PATTERN), + endDate: zod_1.z.string().regex(DATE_KEY_PATTERN), +}) + .superRefine(function (value, context) { + if (value.startDate > value.endDate) { + context.addIssue({ + code: zod_1.z.ZodIssueCode.custom, + message: '시작일은 종료일보다 늦을 수 없습니다.', + path: ['endDate'], + }); + } +}); +var scheduleWeekdaySchema = zod_1.z.number().int().min(0).max(6); +var scheduleTimeWindowSchema = zod_1.z + .object({ + startTime: zod_1.z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), + endTime: zod_1.z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), +}) + .refine(function (value) { return Boolean(value.startTime || value.endTime); }, { + message: '시작시간 또는 종료시간 중 하나는 입력해야 합니다.', +}); +exports.createPlanScheduledTaskSchema = zod_1.z.object({ + workId: zod_1.z.string().trim().optional().default('반복작업'), + note: zod_1.z.string().default(''), + automationType: zod_1.z.preprocess(function (value) { return value; }, plan_service_js_1.planAutomationTypeSchema.default('none')), + automationContextIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(20).optional().default([]), + releaseTarget: zod_1.z.string().trim().min(1).default('release'), + jangsingProcessingRequired: zod_1.z.boolean().default(true), + autoDeployToMain: zod_1.z.boolean().default(true), + suppressWebPush: zod_1.z.boolean().default(false), + enabled: zod_1.z.boolean().default(true), + immediateRunEnabled: zod_1.z.boolean().default(true), + refreshContextSnapshotOnNextRun: zod_1.z.boolean().default(false), + executionMode: zod_1.z.enum(scheduleExecutionModes).default('codex'), + recreateManagedServiceOnNextSave: zod_1.z.boolean().default(false), + scheduleMode: zod_1.z.enum(scheduleModes).default('interval'), + repeatIntervalValue: zod_1.z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).default(60), + repeatIntervalUnit: zod_1.z.enum(repeatIntervalUnits).default('minute'), + repeatIntervalMinutes: zod_1.z.coerce.number().int().min(1).max(525600).optional(), + repeatIntervalSeconds: zod_1.z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), + dailyRunTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default(DEFAULT_DAILY_RUN_TIME), + scheduleWeekdays: zod_1.z.array(scheduleWeekdaySchema).max(7).optional().default([]), + scheduleDateRanges: zod_1.z.array(scheduleDateRangeSchema).max(40).optional().default([]), + repeatWindows: zod_1.z.array(scheduleTimeWindowSchema).max(24).optional().default([]), + repeatWindowStartTime: zod_1.z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), + repeatWindowEndTime: zod_1.z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), +}); +exports.updatePlanScheduledTaskSchema = zod_1.z.object({ + workId: zod_1.z.string().trim().optional(), + note: zod_1.z.string().optional(), + automationType: zod_1.z.preprocess(function (value) { return value; }, plan_service_js_1.planAutomationTypeSchema.optional()), + automationContextIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(20).optional(), + releaseTarget: zod_1.z.string().trim().min(1).optional(), + jangsingProcessingRequired: zod_1.z.boolean().optional(), + autoDeployToMain: zod_1.z.boolean().optional(), + suppressWebPush: zod_1.z.boolean().optional(), + enabled: zod_1.z.boolean().optional(), + immediateRunEnabled: zod_1.z.boolean().optional(), + refreshContextSnapshotOnNextRun: zod_1.z.boolean().optional(), + executionMode: zod_1.z.enum(scheduleExecutionModes).optional(), + recreateManagedServiceOnNextSave: zod_1.z.boolean().optional(), + scheduleMode: zod_1.z.enum(scheduleModes).optional(), + repeatIntervalValue: zod_1.z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), + repeatIntervalUnit: zod_1.z.enum(repeatIntervalUnits).optional(), + repeatIntervalMinutes: zod_1.z.coerce.number().int().min(1).max(525600).optional(), + repeatIntervalSeconds: zod_1.z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), + dailyRunTime: zod_1.z.string().regex(TIME_OF_DAY_PATTERN).optional(), + scheduleWeekdays: zod_1.z.array(scheduleWeekdaySchema).max(7).optional(), + scheduleDateRanges: zod_1.z.array(scheduleDateRangeSchema).max(40).optional(), + repeatWindows: zod_1.z.array(scheduleTimeWindowSchema).max(24).optional(), + repeatWindowStartTime: zod_1.z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), + repeatWindowEndTime: zod_1.z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), +}); +function normalizeScheduledWorkId(value) { + var workId = String(value !== null && value !== void 0 ? value : '').trim(); + var normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); + if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') { + return '반복작업'; + } + return workId; +} +function normalizeRepeatIntervalMinutes(value) { + var numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return 60; + } + return Math.min(525600, Math.max(1, Math.round(numericValue))); +} +function normalizeRepeatIntervalValue(value) { + var numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return 60; + } + return Math.min(MAX_REPEAT_INTERVAL_SECONDS, Math.max(1, Math.round(numericValue))); +} +function normalizeRepeatIntervalSeconds(value) { + var numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return DEFAULT_REPEAT_INTERVAL_SECONDS; + } + return Math.min(MAX_REPEAT_INTERVAL_SECONDS, Math.max(1, Math.round(numericValue))); +} +function normalizeRepeatIntervalUnit(value) { + return repeatIntervalUnits.includes(value) + ? value + : 'minute'; +} +function normalizeScheduleMode(value) { + return scheduleModes.includes(value) + ? value + : 'interval'; +} +function normalizeDailyRunTime(value) { + return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) + ? value + : DEFAULT_DAILY_RUN_TIME; +} +function normalizeOptionalTimeOfDay(value) { + return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null; +} +function normalizeDateKey(value) { + var trimmedValue = typeof value === 'string' ? value.trim() : ''; + return DATE_KEY_PATTERN.test(trimmedValue) ? trimmedValue : null; +} +function normalizeScheduleDateRanges(value) { + if (!Array.isArray(value)) { + return []; + } + var normalizedRanges = value + .map(function (item) { + return scheduleDateRangeSchema.safeParse({ + startDate: normalizeDateKey(item === null || item === void 0 ? void 0 : item.startDate), + endDate: normalizeDateKey(item === null || item === void 0 ? void 0 : item.endDate), + }); + }) + .filter(function (result) { return result.success; }) + .map(function (result) { return result.data; }) + .sort(function (left, right) { + return left.startDate === right.startDate + ? left.endDate.localeCompare(right.endDate) + : left.startDate.localeCompare(right.startDate); + }); + var uniqueRanges = []; + var seen = new Set(); + for (var _i = 0, normalizedRanges_1 = normalizedRanges; _i < normalizedRanges_1.length; _i++) { + var range = normalizedRanges_1[_i]; + var key = "".concat(range.startDate, ":").concat(range.endDate); + if (seen.has(key)) { + continue; + } + seen.add(key); + uniqueRanges.push(range); + } + return uniqueRanges; +} +function normalizeScheduleWeekdays(value) { + if (!Array.isArray(value)) { + return []; + } + return Array.from(new Set(value + .map(function (item) { return Number(item); }) + .filter(function (item) { return Number.isInteger(item) && item >= 0 && item <= 6; }))).sort(function (left, right) { return left - right; }); +} +function parseScheduleWeekdays(value) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + try { + return normalizeScheduleWeekdays(JSON.parse(value)); + } + catch (_a) { + return []; + } +} +function stringifyScheduleWeekdays(value) { + return JSON.stringify(normalizeScheduleWeekdays(value)); +} +function parseScheduleDateRanges(value) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + try { + return normalizeScheduleDateRanges(JSON.parse(value)); + } + catch (_a) { + return []; + } +} +function stringifyScheduleDateRanges(value) { + return JSON.stringify(normalizeScheduleDateRanges(value)); +} +function normalizeScheduleTimeWindows(value) { + var _a, _b; + if (!Array.isArray(value)) { + return []; + } + var normalizedWindows = value + .map(function (item) { + return scheduleTimeWindowSchema.safeParse({ + startTime: normalizeOptionalTimeOfDay(item === null || item === void 0 ? void 0 : item.startTime), + endTime: normalizeOptionalTimeOfDay(item === null || item === void 0 ? void 0 : item.endTime), + }); + }) + .filter(function (result) { return result.success; }) + .map(function (result) { return result.data; }) + .sort(function (left, right) { + var _a, _b, _c, _d; + var leftKey = "".concat((_a = left.startTime) !== null && _a !== void 0 ? _a : '', ":").concat((_b = left.endTime) !== null && _b !== void 0 ? _b : ''); + var rightKey = "".concat((_c = right.startTime) !== null && _c !== void 0 ? _c : '', ":").concat((_d = right.endTime) !== null && _d !== void 0 ? _d : ''); + return leftKey.localeCompare(rightKey); + }); + var uniqueWindows = []; + var seen = new Set(); + for (var _i = 0, normalizedWindows_1 = normalizedWindows; _i < normalizedWindows_1.length; _i++) { + var window_1 = normalizedWindows_1[_i]; + var key = "".concat((_a = window_1.startTime) !== null && _a !== void 0 ? _a : '', ":").concat((_b = window_1.endTime) !== null && _b !== void 0 ? _b : ''); + if (seen.has(key)) { + continue; + } + seen.add(key); + uniqueWindows.push(window_1); + } + return uniqueWindows; +} +function parseScheduleTimeWindows(value) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + try { + return normalizeScheduleTimeWindows(JSON.parse(value)); + } + catch (_a) { + return []; + } +} +function stringifyScheduleTimeWindows(value) { + return JSON.stringify(normalizeScheduleTimeWindows(value)); +} +function toMinutesOfDay(value) { + var _a = value.split(':').map(function (part) { return Number(part); }), hours = _a[0], minutes = _a[1]; + return hours * 60 + minutes; +} +function resolveScheduleTimeWindows(row) { + var storedWindows = parseScheduleTimeWindows(row.repeat_windows_json); + if (storedWindows.length) { + return storedWindows; + } + var startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); + var endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); + return startTime || endTime + ? [ + { + startTime: startTime, + endTime: endTime, + }, + ] + : []; +} +function buildKstDateTime(dateKey, timeOfDay) { + return new Date("".concat(dateKey, "T").concat(timeOfDay, ":00+09:00")); +} +function resolveActiveRepeatWindow(row, now) { + var nowParts = (0, worklog_automation_utils_js_1.getKstNowParts)(now); + for (var _i = 0, _a = resolveScheduleTimeWindows(row); _i < _a.length; _i++) { + var window_2 = _a[_i]; + var startMinutesOfDay = window_2.startTime ? toMinutesOfDay(window_2.startTime) : null; + var endMinutesOfDay = window_2.endTime ? toMinutesOfDay(window_2.endTime) : null; + var crossesMidnight = startMinutesOfDay !== null && endMinutesOfDay !== null && startMinutesOfDay > endMinutesOfDay; + var isActive = startMinutesOfDay !== null && endMinutesOfDay !== null + ? crossesMidnight + ? nowParts.minutesOfDay >= startMinutesOfDay || nowParts.minutesOfDay <= endMinutesOfDay + : nowParts.minutesOfDay >= startMinutesOfDay && nowParts.minutesOfDay <= endMinutesOfDay + : startMinutesOfDay !== null + ? nowParts.minutesOfDay >= startMinutesOfDay + : endMinutesOfDay !== null + ? nowParts.minutesOfDay <= endMinutesOfDay + : false; + if (!isActive) { + continue; + } + var anchorDateKey = crossesMidnight && endMinutesOfDay !== null && nowParts.minutesOfDay <= endMinutesOfDay + ? shiftKstDateKey(nowParts.dateKey, -1) + : nowParts.dateKey; + return { + startTime: window_2.startTime, + endTime: window_2.endTime, + anchorDateKey: anchorDateKey, + }; + } + return null; +} +function shiftKstDateKey(dateKey, offsetDays) { + var _a; + var baseDate = buildKstDateTime(dateKey, '00:00'); + if (Number.isNaN(baseDate.getTime())) { + return dateKey; + } + baseDate.setUTCDate(baseDate.getUTCDate() + offsetDays); + return (_a = getKstDateKey(baseDate)) !== null && _a !== void 0 ? _a : dateKey; +} +function isDateWithinScheduleDateRanges(row, now) { + var scheduleDateRanges = parseScheduleDateRanges(row.schedule_date_ranges_json); + if (!scheduleDateRanges.length) { + return true; + } + var dateKey = getKstDateKey(now); + if (!dateKey) { + return false; + } + return scheduleDateRanges.some(function (range) { return range.startDate <= dateKey && dateKey <= range.endDate; }); +} +function getKstWeekday(now) { + var dateKey = getKstDateKey(now); + if (!dateKey) { + return null; + } + var _a = dateKey.split('-').map(function (value) { return Number(value); }), year = _a[0], month = _a[1], day = _a[2]; + var date = new Date(Date.UTC(year, month - 1, day)); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.getUTCDay(); +} +function isWithinScheduleWeekdays(row, now) { + var scheduleWeekdays = parseScheduleWeekdays(row.schedule_weekdays_json); + if (!scheduleWeekdays.length) { + return true; + } + var weekday = getKstWeekday(now); + return weekday !== null && scheduleWeekdays.includes(weekday); +} +function normalizeScheduleExecutionMode(value) { + return scheduleExecutionModes.includes(value) + ? value + : 'codex'; +} +function toRepeatIntervalMinutes(value, unit) { + return Math.max(1, Math.ceil(toRepeatIntervalSeconds(value, unit) / 60)); +} +function toRepeatIntervalSeconds(value, unit) { + var repeatIntervalValue = normalizeRepeatIntervalValue(value); + var repeatIntervalUnit = normalizeRepeatIntervalUnit(unit); + if (repeatIntervalUnit === 'second') { + return repeatIntervalValue; + } + if (repeatIntervalUnit === 'day') { + return repeatIntervalValue * 24 * 60 * 60; + } + if (repeatIntervalUnit === 'week') { + return repeatIntervalValue * 7 * 24 * 60 * 60; + } + if (repeatIntervalUnit === 'month') { + return repeatIntervalValue * 30 * 24 * 60 * 60; + } + if (repeatIntervalUnit === 'hour') { + return repeatIntervalValue * 60 * 60; + } + return repeatIntervalValue * 60; +} +function resolveStoredRepeatIntervalValue(row) { + var _a, _b; + return normalizeRepeatIntervalValue((_b = (_a = row.repeat_interval_value) !== null && _a !== void 0 ? _a : row.repeat_interval_minutes) !== null && _b !== void 0 ? _b : 60); +} +function resolveStoredRepeatIntervalUnit(row) { + return normalizeRepeatIntervalUnit(row.repeat_interval_unit); +} +function resolveStoredRepeatIntervalSeconds(row) { + return toRepeatIntervalSeconds(resolveStoredRepeatIntervalValue(row), resolveStoredRepeatIntervalUnit(row)); +} +function resolveStoredRepeatIntervalMinutes(row) { + return toRepeatIntervalMinutes(resolveStoredRepeatIntervalValue(row), resolveStoredRepeatIntervalUnit(row)); +} +function normalizeBoolean(value, fallback) { + if (typeof value === 'boolean') { + return value; + } + if (value === 0 || value === '0' || value === 'false') { + return false; + } + if (value === 1 || value === '1' || value === 'true') { + return true; + } + return fallback; +} +function buildManagedServiceFailureSummary(result) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; + var summaryParts = [ + result.title ? "title=".concat(result.title) : null, + "itemCount=".concat(Number((_a = result.itemCount) !== null && _a !== void 0 ? _a : 0)), + "skipped=".concat(result.skipped ? 'true' : 'false'), + "webOk=".concat(((_b = result.web) === null || _b === void 0 ? void 0 : _b.ok) ? 'true' : 'false'), + "webSkipped=".concat(((_c = result.web) === null || _c === void 0 ? void 0 : _c.skipped) ? 'true' : 'false'), + "webSent=".concat(Number((_e = (_d = result.web) === null || _d === void 0 ? void 0 : _d.sentCount) !== null && _e !== void 0 ? _e : 0)), + "webFailed=".concat(Number((_g = (_f = result.web) === null || _f === void 0 ? void 0 : _f.failedCount) !== null && _g !== void 0 ? _g : 0)), + ((_h = result.web) === null || _h === void 0 ? void 0 : _h.reason) ? "webReason=".concat(result.web.reason) : null, + "iosOk=".concat(((_j = result.ios) === null || _j === void 0 ? void 0 : _j.ok) ? 'true' : 'false'), + "iosSkipped=".concat(((_k = result.ios) === null || _k === void 0 ? void 0 : _k.skipped) ? 'true' : 'false'), + ((_l = result.ios) === null || _l === void 0 ? void 0 : _l.reason) ? "iosReason=".concat(result.ios.reason) : null, + ].filter(function (value) { return Boolean(value); }); + return summaryParts.join(', '); +} +function buildScheduledBoardPostTitle(row) { + var _a, _b; + var workId = normalizeScheduledWorkId(String((_a = row.work_id) !== null && _a !== void 0 ? _a : '반복작업')); + var note = String((_b = row.note) !== null && _b !== void 0 ? _b : '') + .split('\n') + .map(function (line) { return line.trim(); }) + .find(Boolean); + var title = note || workId; + return title.length > 200 ? "".concat(title.slice(0, 197).trimEnd(), "...") : title; +} +function buildScheduledPlanWorkIdBase(row) { + var _a; + var workId = normalizeScheduledWorkId(String((_a = row.work_id) !== null && _a !== void 0 ? _a : '반복작업')); + if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { + return workId; + } + var scheduleId = Number(row.id); + if (!Number.isInteger(scheduleId) || scheduleId <= 0) { + return workId; + } + return normalizeScheduledWorkId("schedule-".concat(scheduleId, "-").concat(workId)); +} +function shouldCreatePlanForScheduleExecution(row) { + return normalizeScheduleExecutionMode(row.execution_mode) === 'managed-service'; +} +function getKstDateKey(value) { + if (!value) { + return null; + } + var date = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(date.getTime())) { + return null; + } + return (0, worklog_automation_utils_js_1.getKstNowParts)(date).dateKey; +} +function isDailyScheduleDue(row, now) { + var nowParts = (0, worklog_automation_utils_js_1.getKstNowParts)(now); + var _a = normalizeDailyRunTime(row.daily_run_time).split(':').map(function (value) { return Number(value); }), hours = _a[0], minutes = _a[1]; + var scheduledMinutesOfDay = hours * 60 + minutes; + return nowParts.minutesOfDay >= scheduledMinutesOfDay && getKstDateKey(row.last_registered_at) !== nowParts.dateKey; +} +function isIntervalScheduleDue(row, now) { + var _a; + var lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null; + var repeatIntervalSeconds = resolveStoredRepeatIntervalSeconds(row); + if (!lastRegisteredAt || Number.isNaN(lastRegisteredAt.getTime())) { + var activeWindow = resolveActiveRepeatWindow(row, now); + if (activeWindow === null || activeWindow === void 0 ? void 0 : activeWindow.startTime) { + var startAt = buildKstDateTime(activeWindow.anchorDateKey, activeWindow.startTime); + if (!Number.isNaN(startAt.getTime())) { + if (normalizeBoolean(row.immediate_run_enabled, true)) { + return now.getTime() >= startAt.getTime(); + } + return now.getTime() >= startAt.getTime() + repeatIntervalSeconds * 1000; + } + } + } + var intervalBaseAt = lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime()) + ? lastRegisteredAt + : new Date(String((_a = row.created_at) !== null && _a !== void 0 ? _a : now.toISOString())); + if (Number.isNaN(intervalBaseAt.getTime())) { + return true; + } + return intervalBaseAt.getTime() <= now.getTime() - repeatIntervalSeconds * 1000; +} +function isWithinRepeatWindow(row, now) { + var timeWindows = resolveScheduleTimeWindows(row); + if (!timeWindows.length) { + return true; + } + return resolveActiveRepeatWindow(row, now) !== null; +} +function isScheduleDue(row, now) { + if (!isDateWithinScheduleDateRanges(row, now)) { + return false; + } + var scheduleMode = normalizeScheduleMode(row.schedule_mode); + if (scheduleMode === 'daily' && !isWithinScheduleWeekdays(row, now)) { + return false; + } + if (scheduleMode === 'interval' && !isWithinScheduleWeekdays(row, now)) { + return false; + } + if (scheduleMode === 'interval' && !isWithinRepeatWindow(row, now)) { + return false; + } + if (!row.last_registered_at && normalizeBoolean(row.immediate_run_enabled, true)) { + return true; + } + return scheduleMode === 'daily' ? isDailyScheduleDue(row, now) : isIntervalScheduleDue(row, now); +} +function isPlanScheduledTaskDue(row, now) { + if (now === void 0) { now = new Date(); } + return isScheduleDue(row, now); +} +function mapPlanScheduledTaskRow(row) { + var _a, _b, _c, _d, _e; + var repeatIntervalValue = resolveStoredRepeatIntervalValue(row); + var repeatIntervalUnit = resolveStoredRepeatIntervalUnit(row); + return { + id: row.id, + workId: row.work_id, + note: row.note, + automationType: (0, automation_type_config_service_js_1.resolveStoredAutomationTypeId)(row), + automationContextIds: (0, automation_context_service_js_1.parseAutomationContextIds)(row.automation_context_ids_json), + releaseTarget: row.release_target, + jangsingProcessingRequired: Boolean((_a = row.jangsing_processing_required) !== null && _a !== void 0 ? _a : true), + autoDeployToMain: Boolean((_b = row.auto_deploy_to_main) !== null && _b !== void 0 ? _b : true), + suppressWebPush: Boolean((_c = row.suppress_web_push) !== null && _c !== void 0 ? _c : false), + enabled: Boolean((_d = row.enabled) !== null && _d !== void 0 ? _d : true), + immediateRunEnabled: normalizeBoolean(row.immediate_run_enabled, true), + refreshContextSnapshotOnNextRun: normalizeBoolean(row.context_snapshot_refresh_requested, false), + executionMode: normalizeScheduleExecutionMode(row.execution_mode), + managedServiceKey: typeof row.managed_service_key === 'string' ? row.managed_service_key : null, + managedServicePackageName: typeof row.managed_service_package_name === 'string' ? row.managed_service_package_name : null, + managedServiceDirectory: typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null, + managedServiceManifestPath: typeof row.managed_service_manifest_path === 'string' ? row.managed_service_manifest_path : null, + managedServiceGeneratedAt: (_e = row.managed_service_generated_at) !== null && _e !== void 0 ? _e : null, + managedServiceGenerationPlanItemId: row.managed_service_generation_plan_item_id === null || row.managed_service_generation_plan_item_id === undefined + ? null + : Number(row.managed_service_generation_plan_item_id), + managedServiceGenerationBoardPostId: row.managed_service_generation_board_post_id === null || row.managed_service_generation_board_post_id === undefined + ? null + : Number(row.managed_service_generation_board_post_id), + recreateManagedServiceOnNextSave: normalizeBoolean(row.managed_service_recreate_requested, false), + scheduleMode: normalizeScheduleMode(row.schedule_mode), + repeatIntervalValue: repeatIntervalValue, + repeatIntervalUnit: repeatIntervalUnit, + repeatIntervalSeconds: resolveStoredRepeatIntervalSeconds(row), + repeatIntervalMinutes: resolveStoredRepeatIntervalMinutes(row), + dailyRunTime: normalizeDailyRunTime(row.daily_run_time), + scheduleWeekdays: parseScheduleWeekdays(row.schedule_weekdays_json), + scheduleDateRanges: parseScheduleDateRanges(row.schedule_date_ranges_json), + repeatWindows: resolveScheduleTimeWindows(row), + repeatWindowStartTime: normalizeOptionalTimeOfDay(row.repeat_window_start_time), + repeatWindowEndTime: normalizeOptionalTimeOfDay(row.repeat_window_end_time), + lastRegisteredAt: row.last_registered_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} +function removeManagedServicePackage(scheduleId) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, managed_schedule_service_js_1.removeManagedScheduleServiceArtifacts)(scheduleId)]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensureManagedServicePackage(options) { + return __awaiter(this, void 0, void 0, function () { + var metadata; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + metadata = (0, managed_schedule_service_js_1.buildManagedScheduleServiceMetadata)(options.scheduleId, options.workId); + return [4 /*yield*/, (0, managed_schedule_service_js_1.prepareManagedScheduleServiceDirectory)(options.scheduleId)]; + case 1: + _a.sent(); + return [2 /*return*/, metadata]; + } + }); + }); +} +function buildScheduledManagedServicePlanWorkIdBase(row) { + return buildScheduledPlanWorkIdBase(row); +} +function isManagedServiceGenerationPlanPending(row) { + var _a, _b; + var status = String((_a = row === null || row === void 0 ? void 0 : row.status) !== null && _a !== void 0 ? _a : '').trim(); + var workerStatus = String((_b = row === null || row === void 0 ? void 0 : row.worker_status) !== null && _b !== void 0 ? _b : '').trim(); + if (!status) { + return false; + } + if (['완료', '릴리즈완료', '작업완료'].includes(status)) { + return false; + } + if (['작업취소', '브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패'].includes(workerStatus)) { + return false; + } + return true; +} +function buildManagedServiceGenerationPlanNote(options) { + var _a; + var scheduleId = Number(options.row.id); + var metadata = (0, managed_schedule_service_js_1.buildManagedScheduleServiceMetadata)(scheduleId, String((_a = options.row.work_id) !== null && _a !== void 0 ? _a : '반복작업')); + var requestedReason = options.reason === 'requested' ? '사용자가 패키지 재처리를 요청함' : '실행 대상 서비스 패키지가 누락됨'; + return [ + '## 스케줄 서비스 패키지 생성 지시', + "- \uB300\uC0C1 \uC2A4\uCF00\uC904 PK: ".concat(scheduleId), + "- \uC791\uC5C5 ID base: ".concat(buildScheduledManagedServicePlanWorkIdBase(options.row)), + "- \uC0DD\uC131 \uC0AC\uC720: ".concat(requestedReason), + "- \uD328\uD0A4\uC9C0 \uB8E8\uD2B8: ".concat(metadata.relativeDirectory), + "- \uC11C\uBE44\uC2A4 \uD0A4: ".concat(metadata.serviceKey), + "- \uD328\uD0A4\uC9C0\uBA85: ".concat(metadata.packageName), + '', + '반드시 아래 파일을 위 패키지 루트에 직접 생성하거나 갱신하세요.', + "- ".concat(metadata.readmePath), + "- ".concat(metadata.sourcePath), + "- ".concat(metadata.runtimePath), + "- ".concat(metadata.manifestPath), + '', + '생성 규칙:', + '- service.ts 와 service.mjs 는 둘 다 유지합니다.', + '- service.mjs 는 스케줄 실행 시 직접 import 되어 `run(runtime)` 를 호출합니다.', + '- README.md 에 서비스 목적, 실행 방식, 의존 경로, 검증 방법을 적습니다.', + '- service-manifest.json 에 schedulePk, workId, serviceKey, packageName, relativeDirectory, sourcePath, runtimePath, readmePath, createdAt 를 기록합니다.', + '- managed-service 같은 하위 임시 디렉터리를 다시 만들지 말고, 위 루트 경로만 사용합니다.', + '- 실제 서비스 로직 요구사항은 아래 request/context 문서를 읽고 구현하세요. 본 자동화 메모 안에 원문 요구사항을 다시 복사하지 마세요.', + '- generic placeholder 같은 빈 구현으로 남기지 마세요.', + '', + '참고 문서:', + "- \uC694\uCCAD \uC815\uB9AC: ".concat(options.scheduleSnapshot.requestPath), + "- \uCEE8\uD14D\uC2A4\uD2B8 \uC815\uB9AC: ".concat(options.scheduleSnapshot.contextPath), + "- \uC2A4\uB0C5\uC0F7 manifest: ".concat(options.scheduleSnapshot.manifestPath), + '', + '검증 기준:', + '- 생성 직후 위 네 파일이 모두 존재해야 합니다.', + '- service.mjs 가 현재 저장소 코드 기준으로 바로 실행 가능한 상태여야 합니다.', + ] + .filter(Boolean) + .join('\n') + .trim(); +} +function queueManagedServiceGenerationPlan(options) { + return __awaiter(this, void 0, void 0, function () { + var boardPost, automationReceipt, createdPlanItemId, updatedRows, createdPlan; + var _a, _b, _c, _d, _e; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: return [4 /*yield*/, (0, board_service_js_1.createBoardPost)({ + title: "[\uC2A4\uCF00\uC904 \uC11C\uBE44\uC2A4 \uC0DD\uC131] ".concat(buildScheduledBoardPostTitle(options.row)), + content: buildManagedServiceGenerationPlanNote({ + row: options.row, + scheduleSnapshot: options.scheduleSnapshot, + reason: options.reason, + }), + attachments: [], + automationType: String((_b = (_a = options.row.automation_type_id) !== null && _a !== void 0 ? _a : options.row.automation_type) !== null && _b !== void 0 ? _b : 'none'), + automationContextIds: options.automationContextIds, + requestExecutionMode: 'all_at_once', + requestItems: [], + })]; + case 1: + boardPost = _f.sent(); + return [4 /*yield*/, (0, board_service_js_1.receiveBoardPostAutomation)(Number(boardPost.id), { + planWorkIdBase: buildScheduledManagedServicePlanWorkIdBase(options.row), + planWorkIdSuffixLabel: 'service', + suppressWebPush: Boolean((_c = options.row.suppress_web_push) !== null && _c !== void 0 ? _c : false), + })]; + case 2: + automationReceipt = _f.sent(); + createdPlanItemId = (_d = automationReceipt === null || automationReceipt === void 0 ? void 0 : automationReceipt.planItemIds[0]) !== null && _d !== void 0 ? _d : null; + if (!createdPlanItemId) { + throw new Error("Plan \uC2A4\uCF00\uC904 #".concat(options.row.id, " \uC11C\uBE44\uC2A4 \uD328\uD0A4\uC9C0 \uC790\uB3D9 \uC811\uC218\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4.")); + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: options.row.id }) + .update({ + managed_service_generation_board_post_id: Number(boardPost.id), + managed_service_generation_plan_item_id: Number(createdPlanItemId), + managed_service_recreate_requested: false, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + updatedRows = _f.sent(); + return [4 /*yield*/, (0, client_js_1.db)(plan_service_js_1.PLAN_TABLE).where({ id: createdPlanItemId }).first()]; + case 4: + createdPlan = _f.sent(); + return [2 /*return*/, { + row: (_e = updatedRows[0]) !== null && _e !== void 0 ? _e : options.row, + createdPlan: createdPlan !== null && createdPlan !== void 0 ? createdPlan : null, + createdBoardPost: boardPost, + }]; + } + }); + }); +} +function ensureManagedServiceExecutionReady(options) { + return __awaiter(this, void 0, void 0, function () { + var row, scheduleSnapshot, automationContextIds, recreateRequested, packageExists, existingPlanId, existingPlan, _a, updatedRows, queued; + var _b, _c, _d, _e, _f, _g, _h; + return __generator(this, function (_j) { + switch (_j.label) { + case 0: + row = options.row, scheduleSnapshot = options.scheduleSnapshot, automationContextIds = options.automationContextIds; + if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { + return [2 /*return*/, { + row: row, + ready: true, + generationTriggered: false, + reason: null, + createdPlan: null, + createdBoardPosts: [], + }]; + } + recreateRequested = normalizeBoolean(row.managed_service_recreate_requested, false); + return [4 /*yield*/, (0, managed_schedule_service_js_1.hasManagedScheduleServicePackage)(typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null)]; + case 1: + packageExists = _j.sent(); + if (packageExists && !recreateRequested) { + return [2 /*return*/, { + row: row, + ready: true, + generationTriggered: false, + reason: null, + createdPlan: null, + createdBoardPosts: [], + }]; + } + existingPlanId = Number((_b = row.managed_service_generation_plan_item_id) !== null && _b !== void 0 ? _b : 0); + if (!(existingPlanId > 0)) return [3 /*break*/, 3]; + return [4 /*yield*/, (0, client_js_1.db)(plan_service_js_1.PLAN_TABLE).where({ id: existingPlanId }).first()]; + case 2: + _a = _j.sent(); + return [3 /*break*/, 4]; + case 3: + _a = null; + _j.label = 4; + case 4: + existingPlan = _a; + if (!(packageExists && existingPlan && !recreateRequested)) return [3 /*break*/, 6]; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + managed_service_recreate_requested: false, + managed_service_generated_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 5: + updatedRows = _j.sent(); + return [2 /*return*/, { + row: (_c = updatedRows[0]) !== null && _c !== void 0 ? _c : row, + ready: true, + generationTriggered: false, + reason: null, + createdPlan: null, + createdBoardPosts: [], + }]; + case 6: + if (existingPlan && isManagedServiceGenerationPlanPending(existingPlan)) { + return [2 /*return*/, { + row: row, + ready: false, + generationTriggered: false, + reason: recreateRequested ? 'requested' : 'missing', + createdPlan: existingPlan, + createdBoardPosts: [], + }]; + } + return [4 /*yield*/, removeManagedServicePackage(Number(row.id))]; + case 7: + _j.sent(); + return [4 /*yield*/, ensureManagedServicePackage({ + scheduleId: Number(row.id), + workId: String((_d = row.work_id) !== null && _d !== void 0 ? _d : '반복작업'), + note: String((_e = row.note) !== null && _e !== void 0 ? _e : ''), + releaseTarget: String((_f = row.release_target) !== null && _f !== void 0 ? _f : 'release'), + automationType: String((_h = (_g = row.automation_type_id) !== null && _g !== void 0 ? _g : row.automation_type) !== null && _h !== void 0 ? _h : 'none'), + })]; + case 8: + _j.sent(); + return [4 /*yield*/, queueManagedServiceGenerationPlan({ + row: row, + scheduleSnapshot: scheduleSnapshot, + automationContextIds: automationContextIds, + reason: recreateRequested ? 'requested' : 'missing', + })]; + case 9: + queued = _j.sent(); + return [2 /*return*/, { + row: queued.row, + ready: false, + generationTriggered: true, + reason: recreateRequested ? 'requested' : 'missing', + createdPlan: queued.createdPlan, + createdBoardPosts: [queued.createdBoardPost], + }]; + } + }); + }); +} +function ensurePlanScheduledTaskColumn(columnName, addColumn) { + return __awaiter(this, void 0, void 0, function () { + var hasColumn; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.PLAN_SCHEDULED_TASK_TABLE, columnName)]; + case 1: + hasColumn = _a.sent(); + if (hasColumn) { + return [2 /*return*/]; + } + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.PLAN_SCHEDULED_TASK_TABLE, function (table) { + addColumn(table); + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensurePlanScheduledTaskTable() { + return __awaiter(this, void 0, void 0, function () { + var exists, existingWindowRows, _i, existingWindowRows_1, row, normalizedWindows, currentWindows, existingRows, _a, existingRows_1, row, repeatIntervalSeconds, repeatIntervalMinutes, currentSeconds, currentMinutes; + var _b, _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.PLAN_SCHEDULED_TASK_TABLE)]; + case 1: + exists = _d.sent(); + if (!!exists) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.PLAN_SCHEDULED_TASK_TABLE, function (table) { + table.increments('id').primary(); + table.string('work_id', 120).notNullable().defaultTo('반복작업'); + table.text('note').notNullable().defaultTo(''); + table.string('automation_type', 40).notNullable().defaultTo('none'); + table.string('automation_type_id', 120).nullable(); + table.text('automation_context_ids_json').notNullable().defaultTo('[]'); + table.string('release_target', 120).notNullable().defaultTo('release'); + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + table.boolean('suppress_web_push').notNullable().defaultTo(false); + table.boolean('enabled').notNullable().defaultTo(true); + table.boolean('immediate_run_enabled').notNullable().defaultTo(true); + table.boolean('context_snapshot_refresh_requested').notNullable().defaultTo(false); + table.string('execution_mode', 40).notNullable().defaultTo('codex'); + table.string('managed_service_key', 160).nullable(); + table.string('managed_service_package_name', 200).nullable(); + table.text('managed_service_directory').nullable(); + table.text('managed_service_manifest_path').nullable(); + table.timestamp('managed_service_generated_at', { useTz: true }).nullable(); + table.integer('managed_service_generation_plan_item_id').nullable(); + table.integer('managed_service_generation_board_post_id').nullable(); + table.boolean('managed_service_recreate_requested').notNullable().defaultTo(false); + table.string('schedule_mode', 20).notNullable().defaultTo('interval'); + table.integer('repeat_interval_value').notNullable().defaultTo(60); + table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute'); + table.integer('repeat_interval_minutes').notNullable().defaultTo(60); + table.integer('repeat_interval_seconds').notNullable().defaultTo(DEFAULT_REPEAT_INTERVAL_SECONDS); + table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); + table.text('schedule_weekdays_json').notNullable().defaultTo('[]'); + table.text('schedule_date_ranges_json').notNullable().defaultTo('[]'); + table.text('repeat_windows_json').notNullable().defaultTo('[]'); + table.string('repeat_window_start_time', 5).nullable(); + table.string('repeat_window_end_time', 5).nullable(); + table.timestamp('last_registered_at', { useTz: true }).nullable(); + table.timestamp('context_snapshot_generated_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _d.sent(); + return [2 /*return*/]; + case 3: return [4 /*yield*/, ensurePlanScheduledTaskColumn('release_target', function (table) { + table.string('release_target', 120).notNullable().defaultTo('release'); + })]; + case 4: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('automation_type', function (table) { + table.string('automation_type', 40).notNullable().defaultTo('none'); + })]; + case 5: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('automation_type_id', function (table) { + table.string('automation_type_id', 120).nullable(); + })]; + case 6: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('automation_context_ids_json', function (table) { + table.text('automation_context_ids_json').notNullable().defaultTo('[]'); + })]; + case 7: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('jangsing_processing_required', function (table) { + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + })]; + case 8: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('auto_deploy_to_main', function (table) { + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + })]; + case 9: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('suppress_web_push', function (table) { + table.boolean('suppress_web_push').notNullable().defaultTo(false); + })]; + case 10: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('enabled', function (table) { + table.boolean('enabled').notNullable().defaultTo(true); + })]; + case 11: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('immediate_run_enabled', function (table) { + table.boolean('immediate_run_enabled').notNullable().defaultTo(true); + })]; + case 12: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('context_snapshot_refresh_requested', function (table) { + table.boolean('context_snapshot_refresh_requested').notNullable().defaultTo(false); + })]; + case 13: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('execution_mode', function (table) { + table.string('execution_mode', 40).notNullable().defaultTo('codex'); + })]; + case 14: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_key', function (table) { + table.string('managed_service_key', 160).nullable(); + })]; + case 15: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_package_name', function (table) { + table.string('managed_service_package_name', 200).nullable(); + })]; + case 16: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_directory', function (table) { + table.text('managed_service_directory').nullable(); + })]; + case 17: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_manifest_path', function (table) { + table.text('managed_service_manifest_path').nullable(); + })]; + case 18: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_generated_at', function (table) { + table.timestamp('managed_service_generated_at', { useTz: true }).nullable(); + })]; + case 19: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_generation_plan_item_id', function (table) { + table.integer('managed_service_generation_plan_item_id').nullable(); + })]; + case 20: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_generation_board_post_id', function (table) { + table.integer('managed_service_generation_board_post_id').nullable(); + })]; + case 21: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('managed_service_recreate_requested', function (table) { + table.boolean('managed_service_recreate_requested').notNullable().defaultTo(false); + })]; + case 22: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('schedule_mode', function (table) { + table.string('schedule_mode', 20).notNullable().defaultTo('interval'); + })]; + case 23: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('repeat_interval_value', function (table) { + table.integer('repeat_interval_value').notNullable().defaultTo(60); + })]; + case 24: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('repeat_interval_unit', function (table) { + table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute'); + })]; + case 25: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('repeat_interval_minutes', function (table) { + table.integer('repeat_interval_minutes').notNullable().defaultTo(60); + })]; + case 26: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('repeat_interval_seconds', function (table) { + table.integer('repeat_interval_seconds').notNullable().defaultTo(DEFAULT_REPEAT_INTERVAL_SECONDS); + })]; + case 27: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('daily_run_time', function (table) { + table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); + })]; + case 28: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('schedule_weekdays_json', function (table) { + table.text('schedule_weekdays_json').notNullable().defaultTo('[]'); + })]; + case 29: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('schedule_date_ranges_json', function (table) { + table.text('schedule_date_ranges_json').notNullable().defaultTo('[]'); + })]; + case 30: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('repeat_windows_json', function (table) { + table.text('repeat_windows_json').notNullable().defaultTo('[]'); + })]; + case 31: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('repeat_window_start_time', function (table) { + table.string('repeat_window_start_time', 5).nullable(); + })]; + case 32: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('repeat_window_end_time', function (table) { + table.string('repeat_window_end_time', 5).nullable(); + })]; + case 33: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('last_registered_at', function (table) { + table.timestamp('last_registered_at', { useTz: true }).nullable(); + })]; + case 34: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('context_snapshot_generated_at', function (table) { + table.timestamp('context_snapshot_generated_at', { useTz: true }).nullable(); + })]; + case 35: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('created_at', function (table) { + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 36: + _d.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskColumn('updated_at', function (table) { + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 37: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ automation_type: 'plan_registration' }) + .update({ automation_type: 'plan' })]; + case 38: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ automation_type: 'general_development' }) + .update({ automation_type: 'auto_worker' })]; + case 39: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .whereNull('automation_type_id') + .update({ + automation_type_id: client_js_1.db.raw('automation_type'), + })]; + case 40: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ repeat_interval_unit: 'minute' }) + .update({ + repeat_interval_value: client_js_1.db.raw('repeat_interval_minutes'), + })]; + case 41: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .whereNull('repeat_interval_seconds') + .update({ + repeat_interval_seconds: client_js_1.db.raw('repeat_interval_minutes * 60'), + })]; + case 42: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .whereNull('suppress_web_push') + .update({ + suppress_web_push: false, + })]; + case 43: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).select('id', 'repeat_windows_json', 'repeat_window_start_time', 'repeat_window_end_time')]; + case 44: + existingWindowRows = _d.sent(); + _i = 0, existingWindowRows_1 = existingWindowRows; + _d.label = 45; + case 45: + if (!(_i < existingWindowRows_1.length)) return [3 /*break*/, 48]; + row = existingWindowRows_1[_i]; + normalizedWindows = resolveScheduleTimeWindows(row); + currentWindows = parseScheduleTimeWindows(row.repeat_windows_json); + if (JSON.stringify(currentWindows) === JSON.stringify(normalizedWindows)) { + return [3 /*break*/, 47]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + repeat_windows_json: stringifyScheduleTimeWindows(normalizedWindows), + })]; + case 46: + _d.sent(); + _d.label = 47; + case 47: + _i++; + return [3 /*break*/, 45]; + case 48: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).select('id', 'repeat_interval_value', 'repeat_interval_unit', 'repeat_interval_minutes', 'repeat_interval_seconds')]; + case 49: + existingRows = _d.sent(); + _a = 0, existingRows_1 = existingRows; + _d.label = 50; + case 50: + if (!(_a < existingRows_1.length)) return [3 /*break*/, 53]; + row = existingRows_1[_a]; + repeatIntervalSeconds = resolveStoredRepeatIntervalSeconds(row); + repeatIntervalMinutes = resolveStoredRepeatIntervalMinutes(row); + currentSeconds = normalizeRepeatIntervalSeconds((_b = row.repeat_interval_seconds) !== null && _b !== void 0 ? _b : repeatIntervalSeconds); + currentMinutes = normalizeRepeatIntervalMinutes((_c = row.repeat_interval_minutes) !== null && _c !== void 0 ? _c : repeatIntervalMinutes); + if (currentSeconds === repeatIntervalSeconds && currentMinutes === repeatIntervalMinutes) { + return [3 /*break*/, 52]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + repeat_interval_seconds: repeatIntervalSeconds, + repeat_interval_minutes: repeatIntervalMinutes, + })]; + case 51: + _d.sent(); + _d.label = 52; + case 52: + _a++; + return [3 /*break*/, 50]; + case 53: return [2 /*return*/]; + } + }); + }); +} +function listPlanScheduledTasks() { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).select('*').orderBy('id', 'desc')]; + case 2: + rows = _a.sent(); + return [4 /*yield*/, Promise.all(rows.map(function (row) { return syncManagedServiceGenerationCompletionForScheduleRow(row); }))]; + case 3: return [2 /*return*/, _a.sent()]; + } + }); + }); +} +function getPlanScheduledTaskById(id) { + return __awaiter(this, void 0, void 0, function () { + var row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).where({ id: id }).first()]; + case 2: + row = _a.sent(); + return [4 /*yield*/, (row ? syncManagedServiceGenerationCompletionForScheduleRow(row) : null)]; + case 3: return [2 /*return*/, _a.sent()]; + } + }); + }); +} +function syncManagedServiceGenerationCompletionForScheduleRow(row) { + return __awaiter(this, void 0, void 0, function () { + var generationPlanItemId, packageExists, generationPlan, generationPlanStatus, generationPlanWorkerStatus, isCompletedPlan, updatedRows; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { + return [2 /*return*/, row]; + } + generationPlanItemId = Number((_a = row.managed_service_generation_plan_item_id) !== null && _a !== void 0 ? _a : 0); + if (generationPlanItemId <= 0) { + return [2 /*return*/, row]; + } + return [4 /*yield*/, (0, managed_schedule_service_js_1.hasManagedScheduleServicePackage)(typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null)]; + case 1: + packageExists = _b.sent(); + if (!packageExists) { + return [2 /*return*/, row]; + } + return [4 /*yield*/, (0, client_js_1.db)(plan_service_js_1.PLAN_TABLE) + .select('id', 'status', 'worker_status') + .where({ id: generationPlanItemId }) + .first()]; + case 2: + generationPlan = _b.sent(); + generationPlanStatus = String((generationPlan === null || generationPlan === void 0 ? void 0 : generationPlan.status) !== null && (generationPlan === null || generationPlan === void 0 ? void 0 : generationPlan.status) !== void 0 ? generationPlan.status : '').trim(); + generationPlanWorkerStatus = String((generationPlan === null || generationPlan === void 0 ? void 0 : generationPlan.worker_status) !== null && (generationPlan === null || generationPlan === void 0 ? void 0 : generationPlan.worker_status) !== void 0 ? generationPlan.worker_status : '').trim(); + isCompletedPlan = Boolean(generationPlan) + && ['완료', '작업완료', '릴리즈완료'].includes(generationPlanStatus) + && !['브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패'].includes(generationPlanWorkerStatus); + if (!isCompletedPlan) { + return [2 /*return*/, row]; + } + if (row.managed_service_generated_at && !normalizeBoolean(row.managed_service_recreate_requested, false)) { + return [2 /*return*/, row]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + managed_service_generated_at: client_js_1.db.fn.now(), + managed_service_recreate_requested: false, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + updatedRows = _b.sent(); + return [2 /*return*/, updatedRows[0] || row]; + } + }); + }); +} +function syncManagedServiceGenerationCompletion(planItemId) { + return __awaiter(this, void 0, void 0, function () { + var rows, syncedRows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .select('*') + .where({ managed_service_generation_plan_item_id: planItemId })]; + case 2: + rows = _a.sent(); + return [4 /*yield*/, Promise.all(rows.map(function (row) { return syncManagedServiceGenerationCompletionForScheduleRow(row); }))]; + case 3: + syncedRows = _a.sent(); + return [2 /*return*/, syncedRows.filter(function (row) { return Boolean(row.managed_service_generated_at); })]; + } + }); + }); +} +function createPlanScheduledTask(payload) { + return __awaiter(this, void 0, void 0, function () { + var scheduleMode, repeatIntervalValue, repeatIntervalUnit, automationType, executionMode, repeatWindows, firstRepeatWindow, shouldAcknowledgeManagedServiceRefreshOnNextRun, rows, row, managedService, updatedRows; + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; + return __generator(this, function (_o) { + switch (_o.label) { + case 0: return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 1: + _o.sent(); + scheduleMode = normalizeScheduleMode(payload.scheduleMode); + repeatIntervalValue = normalizeRepeatIntervalValue(payload.repeatIntervalValue); + repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)(payload.automationType)]; + case 2: + automationType = _o.sent(); + executionMode = normalizeScheduleExecutionMode(payload.executionMode); + repeatWindows = normalizeScheduleTimeWindows(payload.repeatWindows); + firstRepeatWindow = (_a = repeatWindows[0]) !== null && _a !== void 0 ? _a : null; + shouldAcknowledgeManagedServiceRefreshOnNextRun = executionMode === 'managed-service' && Boolean(payload.recreateManagedServiceOnNextSave); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .insert({ + work_id: normalizeScheduledWorkId(payload.workId), + note: payload.note, + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(payload.automationContextIds), + release_target: payload.releaseTarget, + jangsing_processing_required: payload.jangsingProcessingRequired, + auto_deploy_to_main: payload.autoDeployToMain, + suppress_web_push: payload.suppressWebPush, + enabled: payload.enabled, + immediate_run_enabled: payload.immediateRunEnabled, + context_snapshot_refresh_requested: payload.refreshContextSnapshotOnNextRun, + execution_mode: executionMode, + managed_service_recreate_requested: shouldAcknowledgeManagedServiceRefreshOnNextRun, + schedule_mode: scheduleMode, + repeat_interval_value: repeatIntervalValue, + repeat_interval_unit: repeatIntervalUnit, + repeat_interval_seconds: toRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit), + repeat_interval_minutes: toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit), + daily_run_time: normalizeDailyRunTime(payload.dailyRunTime), + schedule_weekdays_json: stringifyScheduleWeekdays(payload.scheduleWeekdays), + schedule_date_ranges_json: stringifyScheduleDateRanges(payload.scheduleDateRanges), + repeat_windows_json: stringifyScheduleTimeWindows(repeatWindows), + repeat_window_start_time: (_b = firstRepeatWindow === null || firstRepeatWindow === void 0 ? void 0 : firstRepeatWindow.startTime) !== null && _b !== void 0 ? _b : null, + repeat_window_end_time: (_c = firstRepeatWindow === null || firstRepeatWindow === void 0 ? void 0 : firstRepeatWindow.endTime) !== null && _c !== void 0 ? _c : null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _o.sent(); + row = rows[0]; + if (!(executionMode === 'managed-service' && (row === null || row === void 0 ? void 0 : row.id))) return [3 /*break*/, 6]; + return [4 /*yield*/, ensureManagedServicePackage({ + scheduleId: Number(row.id), + workId: String((_e = (_d = row.work_id) !== null && _d !== void 0 ? _d : payload.workId) !== null && _e !== void 0 ? _e : '반복작업'), + note: String((_g = (_f = row.note) !== null && _f !== void 0 ? _f : payload.note) !== null && _g !== void 0 ? _g : ''), + releaseTarget: String((_j = (_h = row.release_target) !== null && _h !== void 0 ? _h : payload.releaseTarget) !== null && _j !== void 0 ? _j : 'release'), + automationType: String((_l = (_k = row.automation_type_id) !== null && _k !== void 0 ? _k : row.automation_type) !== null && _l !== void 0 ? _l : automationType.id), + })]; + case 4: + managedService = _o.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + managed_service_key: managedService.serviceKey, + managed_service_package_name: managedService.packageName, + managed_service_directory: managedService.relativeDirectory, + managed_service_manifest_path: managedService.manifestPath, + managed_service_generated_at: null, + managed_service_generation_plan_item_id: null, + managed_service_generation_board_post_id: null, + managed_service_recreate_requested: shouldAcknowledgeManagedServiceRefreshOnNextRun, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 5: + updatedRows = _o.sent(); + row = (_m = updatedRows[0]) !== null && _m !== void 0 ? _m : row; + _o.label = 6; + case 6: return [2 /*return*/, row]; + } + }); + }); +} +function updatePlanScheduledTask(id, payload) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, scheduleMode, repeatIntervalValue, repeatIntervalUnit, executionMode, automationType, repeatWindows, firstRepeatWindow, shouldRecreateManagedService, rows, row, updatedRows, managedService, updatedRows; + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20, _21, _22, _23, _24; + return __generator(this, function (_25) { + switch (_25.label) { + case 0: return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 1: + _25.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _25.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + scheduleMode = normalizeScheduleMode((_a = payload.scheduleMode) !== null && _a !== void 0 ? _a : currentRow.schedule_mode); + repeatIntervalValue = normalizeRepeatIntervalValue((_d = (_c = (_b = payload.repeatIntervalValue) !== null && _b !== void 0 ? _b : currentRow.repeat_interval_value) !== null && _c !== void 0 ? _c : currentRow.repeat_interval_minutes) !== null && _d !== void 0 ? _d : 60); + repeatIntervalUnit = normalizeRepeatIntervalUnit((_e = payload.repeatIntervalUnit) !== null && _e !== void 0 ? _e : currentRow.repeat_interval_unit); + executionMode = normalizeScheduleExecutionMode((_f = payload.executionMode) !== null && _f !== void 0 ? _f : currentRow.execution_mode); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)((_h = (_g = payload.automationType) !== null && _g !== void 0 ? _g : currentRow.automation_type_id) !== null && _h !== void 0 ? _h : currentRow.automation_type)]; + case 3: + automationType = _25.sent(); + repeatWindows = normalizeScheduleTimeWindows((_j = payload.repeatWindows) !== null && _j !== void 0 ? _j : resolveScheduleTimeWindows(currentRow)); + firstRepeatWindow = (_k = repeatWindows[0]) !== null && _k !== void 0 ? _k : null; + shouldRecreateManagedService = executionMode === 'managed-service' && + (normalizeBoolean(payload.recreateManagedServiceOnNextSave, false) + || executionMode !== normalizeScheduleExecutionMode(currentRow.execution_mode) + || (payload.workId !== undefined && normalizeScheduledWorkId(payload.workId) !== String((_l = currentRow.work_id) !== null && _l !== void 0 ? _l : ''))); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: id }) + .update({ + work_id: payload.workId === undefined ? currentRow.work_id : normalizeScheduledWorkId(payload.workId), + note: (_m = payload.note) !== null && _m !== void 0 ? _m : currentRow.note, + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)((_o = payload.automationContextIds) !== null && _o !== void 0 ? _o : (0, automation_context_service_js_1.parseAutomationContextIds)(currentRow.automation_context_ids_json)), + release_target: (_q = (_p = payload.releaseTarget) !== null && _p !== void 0 ? _p : currentRow.release_target) !== null && _q !== void 0 ? _q : 'release', + jangsing_processing_required: (_s = (_r = payload.jangsingProcessingRequired) !== null && _r !== void 0 ? _r : currentRow.jangsing_processing_required) !== null && _s !== void 0 ? _s : true, + auto_deploy_to_main: (_u = (_t = payload.autoDeployToMain) !== null && _t !== void 0 ? _t : currentRow.auto_deploy_to_main) !== null && _u !== void 0 ? _u : true, + suppress_web_push: (_w = (_v = payload.suppressWebPush) !== null && _v !== void 0 ? _v : currentRow.suppress_web_push) !== null && _w !== void 0 ? _w : false, + enabled: (_y = (_x = payload.enabled) !== null && _x !== void 0 ? _x : currentRow.enabled) !== null && _y !== void 0 ? _y : true, + immediate_run_enabled: (_0 = (_z = payload.immediateRunEnabled) !== null && _z !== void 0 ? _z : currentRow.immediate_run_enabled) !== null && _0 !== void 0 ? _0 : true, + context_snapshot_refresh_requested: (_2 = (_1 = payload.refreshContextSnapshotOnNextRun) !== null && _1 !== void 0 ? _1 : currentRow.context_snapshot_refresh_requested) !== null && _2 !== void 0 ? _2 : false, + execution_mode: executionMode, + managed_service_recreate_requested: shouldRecreateManagedService, + managed_service_generation_plan_item_id: shouldRecreateManagedService || executionMode !== normalizeScheduleExecutionMode(currentRow.execution_mode) + ? null + : (_3 = currentRow.managed_service_generation_plan_item_id) !== null && _3 !== void 0 ? _3 : null, + managed_service_generation_board_post_id: shouldRecreateManagedService || executionMode !== normalizeScheduleExecutionMode(currentRow.execution_mode) + ? null + : (_4 = currentRow.managed_service_generation_board_post_id) !== null && _4 !== void 0 ? _4 : null, + schedule_mode: scheduleMode, + repeat_interval_value: repeatIntervalValue, + repeat_interval_unit: repeatIntervalUnit, + repeat_interval_seconds: toRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit), + repeat_interval_minutes: toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit), + daily_run_time: normalizeDailyRunTime((_5 = payload.dailyRunTime) !== null && _5 !== void 0 ? _5 : currentRow.daily_run_time), + schedule_weekdays_json: stringifyScheduleWeekdays((_6 = payload.scheduleWeekdays) !== null && _6 !== void 0 ? _6 : parseScheduleWeekdays(currentRow.schedule_weekdays_json)), + schedule_date_ranges_json: stringifyScheduleDateRanges((_7 = payload.scheduleDateRanges) !== null && _7 !== void 0 ? _7 : parseScheduleDateRanges(currentRow.schedule_date_ranges_json)), + repeat_windows_json: stringifyScheduleTimeWindows(repeatWindows), + repeat_window_start_time: (_8 = firstRepeatWindow === null || firstRepeatWindow === void 0 ? void 0 : firstRepeatWindow.startTime) !== null && _8 !== void 0 ? _8 : null, + repeat_window_end_time: (_9 = firstRepeatWindow === null || firstRepeatWindow === void 0 ? void 0 : firstRepeatWindow.endTime) !== null && _9 !== void 0 ? _9 : null, + context_snapshot_generated_at: payload.enabled !== undefined && Boolean(payload.enabled) !== Boolean((_10 = currentRow.enabled) !== null && _10 !== void 0 ? _10 : true) + ? null + : (_11 = currentRow.context_snapshot_generated_at) !== null && _11 !== void 0 ? _11 : null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 4: + rows = _25.sent(); + row = (_12 = rows[0]) !== null && _12 !== void 0 ? _12 : null; + if (!row) { + return [2 /*return*/, null]; + } + if (!(executionMode !== 'managed-service')) return [3 /*break*/, 7]; + return [4 /*yield*/, removeManagedServicePackage(Number(row.id))]; + case 5: + _25.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + managed_service_key: null, + managed_service_package_name: null, + managed_service_directory: null, + managed_service_manifest_path: null, + managed_service_generated_at: null, + managed_service_generation_plan_item_id: null, + managed_service_generation_board_post_id: null, + managed_service_recreate_requested: false, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 6: + updatedRows = _25.sent(); + return [2 /*return*/, (_13 = updatedRows[0]) !== null && _13 !== void 0 ? _13 : row]; + case 7: + if (!(shouldRecreateManagedService + || !String((_14 = row.managed_service_directory) !== null && _14 !== void 0 ? _14 : '').trim() + || !String((_15 = row.managed_service_key) !== null && _15 !== void 0 ? _15 : '').trim())) return [3 /*break*/, 11]; + return [4 /*yield*/, removeManagedServicePackage(Number(row.id))]; + case 8: + _25.sent(); + return [4 /*yield*/, ensureManagedServicePackage({ + scheduleId: Number(row.id), + workId: String((_17 = (_16 = row.work_id) !== null && _16 !== void 0 ? _16 : currentRow.work_id) !== null && _17 !== void 0 ? _17 : '반복작업'), + note: String((_19 = (_18 = row.note) !== null && _18 !== void 0 ? _18 : currentRow.note) !== null && _19 !== void 0 ? _19 : ''), + releaseTarget: String((_21 = (_20 = row.release_target) !== null && _20 !== void 0 ? _20 : currentRow.release_target) !== null && _21 !== void 0 ? _21 : 'release'), + automationType: String((_23 = (_22 = row.automation_type_id) !== null && _22 !== void 0 ? _22 : row.automation_type) !== null && _23 !== void 0 ? _23 : automationType.id), + })]; + case 9: + managedService = _25.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + managed_service_key: managedService.serviceKey, + managed_service_package_name: managedService.packageName, + managed_service_directory: managedService.relativeDirectory, + managed_service_manifest_path: managedService.manifestPath, + managed_service_generated_at: null, + managed_service_generation_plan_item_id: null, + managed_service_generation_board_post_id: null, + managed_service_recreate_requested: shouldRecreateManagedService, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 10: + updatedRows = _25.sent(); + row = (_24 = updatedRows[0]) !== null && _24 !== void 0 ? _24 : row; + _25.label = 11; + case 11: return [2 /*return*/, row]; + } + }); + }); +} +function deletePlanScheduledTask(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, removeManagedServicePackage(id)]; + case 3: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).where({ id: id }).delete()]; + case 4: + _a.sent(); + return [2 /*return*/, currentRow]; + } + }); + }); +} +function registerDuePlanScheduledTasks() { + return __awaiter(this, arguments, void 0, function (now) { + var rows, registered, _i, _a, row, registration; + if (now === void 0) { now = new Date(); } + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, (0, plan_service_js_1.ensurePlanTable)()]; + case 1: + _b.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 2: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).where({ enabled: true }).orderBy('id', 'asc')]; + case 3: + rows = _b.sent(); + registered = []; + _i = 0, _a = rows.filter(function (item) { return isScheduleDue(item, now); }); + _b.label = 4; + case 4: + if (!(_i < _a.length)) return [3 /*break*/, 7]; + row = _a[_i]; + return [4 /*yield*/, registerPlanScheduledTaskRow(row, now)]; + case 5: + registration = _b.sent(); + if (registration.createdPlan || registration.createdBoardPosts.length > 0) { + registered.push(registration); + } + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/, registered]; + } + }); + }); +} +function registerPlanScheduledTaskRow(row, now) { + return __awaiter(this, void 0, void 0, function () { + var executionMode, automationContextIds, shouldRefreshSnapshot, scheduleSnapshot, _a, managedServiceReady, effectiveRow, scheduleNote, managedServiceDirectory, managedServiceResult, createdPlan, managedServiceChangedFiles, boardPost; + var _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w; + return __generator(this, function (_x) { + switch (_x.label) { + case 0: + executionMode = normalizeScheduleExecutionMode(row.execution_mode); + automationContextIds = (0, automation_context_service_js_1.parseAutomationContextIds)(row.automation_context_ids_json); + shouldRefreshSnapshot = !row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false); + if (!shouldRefreshSnapshot) return [3 /*break*/, 2]; + return [4 /*yield*/, (0, automation_context_service_js_1.ensureSchedulePromptSnapshot)({ + scheduleId: Number(row.id), + workId: buildScheduledPlanWorkIdBase(row), + note: String((_b = row.note) !== null && _b !== void 0 ? _b : ''), + forceRefresh: true, + })]; + case 1: + _a = _x.sent(); + return [3 /*break*/, 3]; + case 2: + _a = { + directory: ".auto_codex/schedule/".concat(row.id), + requestPath: ".auto_codex/schedule/".concat(row.id, "/request.md"), + contextPath: ".auto_codex/schedule/".concat(row.id, "/context.md"), + manifestPath: ".auto_codex/schedule/".concat(row.id, "/manifest.json"), + }; + _x.label = 3; + case 3: + scheduleSnapshot = _a; + return [4 /*yield*/, ensureManagedServiceExecutionReady({ + row: row, + scheduleSnapshot: scheduleSnapshot, + automationContextIds: automationContextIds, + })]; + case 4: + managedServiceReady = _x.sent(); + effectiveRow = managedServiceReady.row; + scheduleNote = [ + String((_c = effectiveRow.note) !== null && _c !== void 0 ? _c : '').trim(), + '', + '## 스케줄 전용 참조 문서', + "- ".concat(scheduleSnapshot.requestPath), + "- ".concat(scheduleSnapshot.contextPath), + '', + '위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.', + executionMode === 'managed-service' + ? [ + '', + '## 스케줄 관리 서비스', + "- \uC11C\uBE44\uC2A4 \uD0A4: ".concat(String((_d = effectiveRow.managed_service_key) !== null && _d !== void 0 ? _d : "schedule-".concat(effectiveRow.id, "-service"))), + "- \uC11C\uBE44\uC2A4 \uACBD\uB85C: ".concat(String((_e = effectiveRow.managed_service_directory) !== null && _e !== void 0 ? _e : ".auto_codex/schedule/".concat(effectiveRow.id))), + managedServiceReady.ready + ? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.' + : "- \uD604\uC7AC \uC11C\uBE44\uC2A4 \uD328\uD0A4\uC9C0 \uC0DD\uC131 Plan\uC744 ".concat(managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며', " \uC0DD\uC131 \uC644\uB8CC \uC804\uAE4C\uC9C0 \uC2E4\uC81C \uC11C\uBE44\uC2A4 \uC2E4\uD589\uC740 \uBCF4\uB958\uD569\uB2C8\uB2E4."), + managedServiceReady.reason + ? "- \uC0DD\uC131 \uC0AC\uC720: ".concat(managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청') + : null, + '- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.', + ].join('\n') + : null, + ] + .filter(function (value) { return Boolean(value); }) + .join('\n') + .trim(); + if (!(executionMode === 'managed-service')) return [3 /*break*/, 10]; + if (!managedServiceReady.ready) { + return [2 /*return*/, { + createdPlan: managedServiceReady.createdPlan, + createdBoardPosts: managedServiceReady.createdBoardPosts, + }]; + } + managedServiceDirectory = String((_f = effectiveRow.managed_service_directory) !== null && _f !== void 0 ? _f : ".auto_codex/schedule/".concat(effectiveRow.id)); + return [4 /*yield*/, (0, managed_schedule_service_js_1.runManagedScheduleService)(managedServiceDirectory)]; + case 5: + managedServiceResult = _x.sent(); + if (!managedServiceResult.ok) { + throw new Error("\uC2A4\uCF00\uC904 \uC11C\uBE44\uC2A4 \uC2E4\uD589 \uC2E4\uD328: ".concat(buildManagedServiceFailureSummary(managedServiceResult))); + } + return [4 /*yield*/, (0, plan_service_js_1.createCompletedPlanExecutionLogItem)({ + workId: buildScheduledPlanWorkIdBase(effectiveRow), + note: scheduleNote, + automationType: String((_h = (_g = effectiveRow.automation_type_id) !== null && _g !== void 0 ? _g : effectiveRow.automation_type) !== null && _h !== void 0 ? _h : 'none'), + automationContextIds: automationContextIds, + releaseTarget: String((_j = effectiveRow.release_target) !== null && _j !== void 0 ? _j : 'release'), + jangsingProcessingRequired: Boolean((_k = effectiveRow.jangsing_processing_required) !== null && _k !== void 0 ? _k : true), + autoDeployToMain: Boolean((_l = effectiveRow.auto_deploy_to_main) !== null && _l !== void 0 ? _l : true), + suppressWebPush: Boolean((_m = effectiveRow.suppress_web_push) !== null && _m !== void 0 ? _m : false), + repeatRequestEnabled: false, + repeatIntervalMinutes: 60, + })]; + case 6: + createdPlan = _x.sent(); + managedServiceChangedFiles = [ + "".concat(managedServiceDirectory, "/README.md"), + "".concat(managedServiceDirectory, "/service.ts"), + "".concat(managedServiceDirectory, "/service.mjs"), + "".concat(managedServiceDirectory, "/service-manifest.json"), + ]; + return [4 /*yield*/, (0, plan_service_js_1.createPlanSourceWorkHistory)(Number(createdPlan.id), { + summary: [ + "\uC2A4\uCF00\uC904 \uC11C\uBE44\uC2A4 \uC2E4\uD589: schedule #".concat(effectiveRow.id), + "\uC11C\uBE44\uC2A4 \uD0A4: ".concat(String((_o = effectiveRow.managed_service_key) !== null && _o !== void 0 ? _o : "schedule-".concat(effectiveRow.id, "-service"))), + "\uACB0\uACFC: ".concat(managedServiceResult.skipped + ? "\uC2A4\uD0B5 (".concat((_q = (_p = managedServiceResult.web.reason) !== null && _p !== void 0 ? _p : managedServiceResult.ios.reason) !== null && _q !== void 0 ? _q : '사유 없음', ")") + : "".concat(managedServiceResult.itemCount, "\uAC74 \uC804\uC1A1 \uC2DC\uB3C4")), + ].join('\n'), + branchName: String((_s = (_r = createdPlan.releaseTarget) !== null && _r !== void 0 ? _r : createdPlan.assignedBranch) !== null && _s !== void 0 ? _s : 'main'), + commitHash: null, + changedFiles: managedServiceChangedFiles, + commandLog: [ + "schedule-managed-service run scheduleId=".concat(String(effectiveRow.id)), + "servicePath=".concat(managedServiceDirectory, "/service.mjs"), + "itemCount=".concat(managedServiceResult.itemCount), + "webSent=".concat(managedServiceResult.web.sentCount), + "webFailed=".concat(managedServiceResult.web.failedCount), + "skipped=".concat(managedServiceResult.skipped ? 'true' : 'false'), + "reason=".concat((_u = (_t = managedServiceResult.web.reason) !== null && _t !== void 0 ? _t : managedServiceResult.ios.reason) !== null && _u !== void 0 ? _u : ''), + ].join('\n'), + diffText: null, + sourceFiles: [], + })]; + case 7: + _x.sent(); + return [4 /*yield*/, (0, plan_service_js_1.createPlanActionHistory)(Number(createdPlan.id), '스케줄서비스실행', "Plan \uC2A4\uCF00\uC904 #".concat(effectiveRow.id, " \uC804\uC6A9 \uC11C\uBE44\uC2A4 \uD30C\uC77C\uC744 \uC9C1\uC811 \uC2E4\uD589\uD588\uC2B5\uB2C8\uB2E4."))]; + case 8: + _x.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: effectiveRow.id }) + .update({ + last_registered_at: now, + context_snapshot_generated_at: now, + context_snapshot_refresh_requested: false, + managed_service_generated_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + })]; + case 9: + _x.sent(); + return [2 /*return*/, { + createdPlan: createdPlan, + createdBoardPosts: [], + }]; + case 10: return [4 /*yield*/, (0, board_service_js_1.createBoardPost)({ + title: buildScheduledBoardPostTitle(effectiveRow), + content: scheduleNote, + attachments: [], + automationType: String((_w = (_v = effectiveRow.automation_type_id) !== null && _v !== void 0 ? _v : effectiveRow.automation_type) !== null && _w !== void 0 ? _w : 'none'), + automationContextIds: automationContextIds, + requestExecutionMode: 'all_at_once', + requestItems: [], + })]; + case 11: + boardPost = _x.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE) + .where({ id: effectiveRow.id }) + .update({ + last_registered_at: now, + context_snapshot_generated_at: now, + context_snapshot_refresh_requested: false, + updated_at: client_js_1.db.fn.now(), + })]; + case 12: + _x.sent(); + return [2 /*return*/, { + createdPlan: null, + createdBoardPosts: [boardPost], + }]; + } + }); + }); +} +function registerPlanScheduledTaskNow(id_1) { + return __awaiter(this, arguments, void 0, function (id, now, options) { + var row, _a; + if (now === void 0) { now = new Date(); } + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, (0, plan_service_js_1.ensurePlanTable)()]; + case 1: + _b.sent(); + return [4 /*yield*/, ensurePlanScheduledTaskTable()]; + case 2: + _b.sent(); + if (!(options === null || options === void 0 ? void 0 : options.forceManagedServiceGeneration)) return [3 /*break*/, 4]; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).where({ id: id }).first()]; + case 3: + _a = _b.sent(); + return [3 /*break*/, 6]; + case 4: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SCHEDULED_TASK_TABLE).where({ id: id, enabled: true }).first()]; + case 5: + _a = _b.sent(); + _b.label = 6; + case 6: + row = _a; + if (!row + || (!(options === null || options === void 0 ? void 0 : options.forceManagedServiceGeneration) && !(options === null || options === void 0 ? void 0 : options.ignoreScheduleDue) && !isScheduleDue(row, now))) { + return [2 /*return*/, null]; + } + return [2 /*return*/, registerPlanScheduledTaskRow(row, now)]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/plan-schedule-service.test.ts b/etc/servers/work-server/src/services/plan-schedule-service.test.ts index f5d7399..33048a5 100644 --- a/etc/servers/work-server/src/services/plan-schedule-service.test.ts +++ b/etc/servers/work-server/src/services/plan-schedule-service.test.ts @@ -105,6 +105,23 @@ test('updatePlanScheduledTaskSchema accepts second-based interval updates', () = ); }); +test('updatePlanScheduledTaskSchema accepts multiple interval time windows', () => { + assert.deepEqual( + updatePlanScheduledTaskSchema.parse({ + repeatWindows: [ + { startTime: '09:00', endTime: '12:00' }, + { startTime: '13:00', endTime: '18:00' }, + ], + }), + { + repeatWindows: [ + { startTime: '09:00', endTime: '12:00' }, + { startTime: '13:00', endTime: '18:00' }, + ], + }, + ); +}); + test('interval schedule with start time waits until start time when immediate run is enabled', () => { assert.equal( isPlanScheduledTaskDue( @@ -197,6 +214,124 @@ test('interval schedule supports second-based due calculation', () => { ); }); +test('schedule date ranges block execution outside configured dates', () => { + assert.equal( + isPlanScheduledTaskDue( + { + schedule_mode: 'interval', + immediate_run_enabled: true, + repeat_interval_minutes: 60, + schedule_date_ranges_json: JSON.stringify([ + { + startDate: '2026-05-10', + endDate: '2026-05-12', + }, + ]), + created_at: '2026-05-01T09:00:00+09:00', + }, + new Date('2026-05-09T10:00:00+09:00'), + ), + false, + ); + + assert.equal( + isPlanScheduledTaskDue( + { + schedule_mode: 'interval', + immediate_run_enabled: true, + repeat_interval_minutes: 60, + schedule_date_ranges_json: JSON.stringify([ + { + startDate: '2026-05-10', + endDate: '2026-05-12', + }, + ]), + created_at: '2026-05-01T09:00:00+09:00', + }, + new Date('2026-05-10T10:00:00+09:00'), + ), + true, + ); +}); + +test('daily schedule weekdays block execution outside configured days', () => { + assert.equal( + isPlanScheduledTaskDue( + { + schedule_mode: 'daily', + immediate_run_enabled: true, + daily_run_time: '09:00', + schedule_weekdays_json: JSON.stringify([1, 3, 5]), + created_at: '2026-05-01T09:00:00+09:00', + }, + new Date('2026-05-10T10:00:00+09:00'), + ), + false, + ); + + assert.equal( + isPlanScheduledTaskDue( + { + schedule_mode: 'daily', + immediate_run_enabled: true, + daily_run_time: '09:00', + schedule_weekdays_json: JSON.stringify([1, 3, 5]), + created_at: '2026-05-01T09:00:00+09:00', + }, + new Date('2026-05-11T10:00:00+09:00'), + ), + true, + ); +}); + +test('interval schedule weekdays block execution outside configured days', () => { + assert.equal( + isPlanScheduledTaskDue( + { + schedule_mode: 'interval', + immediate_run_enabled: true, + repeat_interval_minutes: 60, + schedule_weekdays_json: JSON.stringify([1, 3, 5]), + created_at: '2026-05-01T09:00:00+09:00', + }, + new Date('2026-05-10T10:00:00+09:00'), + ), + false, + ); + + assert.equal( + isPlanScheduledTaskDue( + { + schedule_mode: 'interval', + immediate_run_enabled: true, + repeat_interval_minutes: 60, + schedule_weekdays_json: JSON.stringify([1, 3, 5]), + created_at: '2026-05-01T09:00:00+09:00', + }, + new Date('2026-05-11T10:00:00+09:00'), + ), + true, + ); +}); + +test('interval schedule respects multiple configured time windows', () => { + const row = { + schedule_mode: 'interval', + immediate_run_enabled: true, + repeat_interval_minutes: 60, + repeat_windows_json: JSON.stringify([ + { startTime: '09:00', endTime: '12:00' }, + { startTime: '14:00', endTime: '18:00' }, + ]), + created_at: '2026-05-11T08:00:00+09:00', + }; + + assert.equal(isPlanScheduledTaskDue(row, new Date('2026-05-11T08:59:00+09:00')), false); + assert.equal(isPlanScheduledTaskDue(row, new Date('2026-05-11T09:00:00+09:00')), true); + assert.equal(isPlanScheduledTaskDue(row, new Date('2026-05-11T13:00:00+09:00')), false); + assert.equal(isPlanScheduledTaskDue(row, new Date('2026-05-11T14:00:00+09:00')), true); +}); + test('interval schedule uses repeat interval value and unit when stored seconds are stale', () => { assert.equal( isPlanScheduledTaskDue( @@ -244,4 +379,56 @@ test('mapPlanScheduledTaskRow normalizes stale stored repeat interval seconds', assert.equal(mapped.repeatIntervalUnit, 'minute'); assert.equal(mapped.repeatIntervalSeconds, 600); assert.equal(mapped.repeatIntervalMinutes, 10); + assert.deepEqual(mapped.scheduleDateRanges, []); +}); + +test('mapPlanScheduledTaskRow parses stored schedule date ranges', () => { + const mapped = mapPlanScheduledTaskRow({ + id: 3, + work_id: 'range-task', + schedule_date_ranges_json: JSON.stringify([ + { startDate: '2026-05-10', endDate: '2026-05-12' }, + { startDate: '2026-05-20', endDate: '2026-05-21' }, + ]), + }); + + assert.deepEqual(mapped.scheduleDateRanges, [ + { startDate: '2026-05-10', endDate: '2026-05-12' }, + { startDate: '2026-05-20', endDate: '2026-05-21' }, + ]); +}); + +test('mapPlanScheduledTaskRow parses stored schedule weekdays', () => { + const mapped = mapPlanScheduledTaskRow({ + id: 4, + work_id: 'weekday-task', + schedule_weekdays_json: JSON.stringify([5, 1, 3, 1]), + }); + + assert.deepEqual(mapped.scheduleWeekdays, [1, 3, 5]); +}); + +test('mapPlanScheduledTaskRow parses stored repeat windows and falls back to legacy columns', () => { + const mapped = mapPlanScheduledTaskRow({ + id: 5, + work_id: 'window-task', + repeat_windows_json: JSON.stringify([ + { startTime: '14:00', endTime: '18:00' }, + { startTime: '09:00', endTime: '12:00' }, + ]), + }); + + assert.deepEqual(mapped.repeatWindows, [ + { startTime: '09:00', endTime: '12:00' }, + { startTime: '14:00', endTime: '18:00' }, + ]); + + const legacyMapped = mapPlanScheduledTaskRow({ + id: 6, + work_id: 'legacy-window-task', + repeat_window_start_time: '10:00', + repeat_window_end_time: '12:00', + }); + + assert.deepEqual(legacyMapped.repeatWindows, [{ startTime: '10:00', endTime: '12:00' }]); }); diff --git a/etc/servers/work-server/src/services/plan-schedule-service.ts b/etc/servers/work-server/src/services/plan-schedule-service.ts index c5c407b..e5f2738 100755 --- a/etc/servers/work-server/src/services/plan-schedule-service.ts +++ b/etc/servers/work-server/src/services/plan-schedule-service.ts @@ -30,9 +30,35 @@ const repeatIntervalUnits = ['second', 'minute', 'hour', 'day', 'week', 'month'] const scheduleExecutionModes = ['codex', 'managed-service'] as const; const DEFAULT_DAILY_RUN_TIME = '09:00'; const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const MAX_REPEAT_INTERVAL_SECONDS = 31_536_000; const DEFAULT_REPEAT_INTERVAL_SECONDS = 60 * 60; +const scheduleDateRangeSchema = z + .object({ + startDate: z.string().regex(DATE_KEY_PATTERN), + endDate: z.string().regex(DATE_KEY_PATTERN), + }) + .superRefine((value, context) => { + if (value.startDate > value.endDate) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: '시작일은 종료일보다 늦을 수 없습니다.', + path: ['endDate'], + }); + } + }); + +const scheduleWeekdaySchema = z.number().int().min(0).max(6); +const scheduleTimeWindowSchema = z + .object({ + startTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), + endTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), + }) + .refine((value) => Boolean(value.startTime || value.endTime), { + message: '시작시간 또는 종료시간 중 하나는 입력해야 합니다.', + }); + export const createPlanScheduledTaskSchema = z.object({ workId: z.string().trim().optional().default('반복작업'), note: z.string().default(''), @@ -53,6 +79,9 @@ export const createPlanScheduledTaskSchema = z.object({ repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(), repeatIntervalSeconds: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), dailyRunTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default(DEFAULT_DAILY_RUN_TIME), + scheduleWeekdays: z.array(scheduleWeekdaySchema).max(7).optional().default([]), + scheduleDateRanges: z.array(scheduleDateRangeSchema).max(40).optional().default([]), + repeatWindows: z.array(scheduleTimeWindowSchema).max(24).optional().default([]), repeatWindowStartTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), repeatWindowEndTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), }); @@ -77,6 +106,9 @@ export const updatePlanScheduledTaskSchema = z.object({ repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(), repeatIntervalSeconds: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), dailyRunTime: z.string().regex(TIME_OF_DAY_PATTERN).optional(), + scheduleWeekdays: z.array(scheduleWeekdaySchema).max(7).optional(), + scheduleDateRanges: z.array(scheduleDateRangeSchema).max(40).optional(), + repeatWindows: z.array(scheduleTimeWindowSchema).max(24).optional(), repeatWindowStartTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), repeatWindowEndTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), }); @@ -144,15 +176,221 @@ function normalizeOptionalTimeOfDay(value: unknown) { return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null; } +function normalizeDateKey(value: unknown) { + const trimmedValue = typeof value === 'string' ? value.trim() : ''; + return DATE_KEY_PATTERN.test(trimmedValue) ? trimmedValue : null; +} + +function normalizeScheduleDateRanges(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + const normalizedRanges = value + .map((item) => + scheduleDateRangeSchema.safeParse({ + startDate: normalizeDateKey((item as { startDate?: unknown })?.startDate), + endDate: normalizeDateKey((item as { endDate?: unknown })?.endDate), + }), + ) + .filter((result) => result.success) + .map((result) => result.data) + .sort((left, right) => + left.startDate === right.startDate + ? left.endDate.localeCompare(right.endDate) + : left.startDate.localeCompare(right.startDate), + ); + + const uniqueRanges = []; + const seen = new Set(); + + for (const range of normalizedRanges) { + const key = `${range.startDate}:${range.endDate}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + uniqueRanges.push(range); + } + + return uniqueRanges; +} + +function normalizeScheduleWeekdays(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((item) => Number(item)) + .filter((item) => Number.isInteger(item) && item >= 0 && item <= 6), + ), + ).sort((left, right) => left - right); +} + +function parseScheduleWeekdays(value: unknown) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + + try { + return normalizeScheduleWeekdays(JSON.parse(value)); + } catch { + return []; + } +} + +function stringifyScheduleWeekdays(value: unknown) { + return JSON.stringify(normalizeScheduleWeekdays(value)); +} + +function parseScheduleDateRanges(value: unknown) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + + try { + return normalizeScheduleDateRanges(JSON.parse(value)); + } catch { + return []; + } +} + +function stringifyScheduleDateRanges(value: unknown) { + return JSON.stringify(normalizeScheduleDateRanges(value)); +} + +function normalizeScheduleTimeWindows(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + const normalizedWindows = value + .map((item) => + scheduleTimeWindowSchema.safeParse({ + startTime: normalizeOptionalTimeOfDay((item as { startTime?: unknown })?.startTime), + endTime: normalizeOptionalTimeOfDay((item as { endTime?: unknown })?.endTime), + }), + ) + .filter((result) => result.success) + .map((result) => result.data) + .sort((left, right) => { + const leftKey = `${left.startTime ?? ''}:${left.endTime ?? ''}`; + const rightKey = `${right.startTime ?? ''}:${right.endTime ?? ''}`; + return leftKey.localeCompare(rightKey); + }); + + const uniqueWindows = []; + const seen = new Set(); + + for (const window of normalizedWindows) { + const key = `${window.startTime ?? ''}:${window.endTime ?? ''}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + uniqueWindows.push(window); + } + + return uniqueWindows; +} + +function parseScheduleTimeWindows(value: unknown) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + + try { + return normalizeScheduleTimeWindows(JSON.parse(value)); + } catch { + return []; + } +} + +function stringifyScheduleTimeWindows(value: unknown) { + return JSON.stringify(normalizeScheduleTimeWindows(value)); +} + function toMinutesOfDay(value: string) { const [hours, minutes] = value.split(':').map((part) => Number(part)); return hours * 60 + minutes; } +function resolveScheduleTimeWindows(row: Record) { + const storedWindows = parseScheduleTimeWindows(row.repeat_windows_json); + + if (storedWindows.length) { + return storedWindows; + } + + const startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); + const endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); + + return startTime || endTime + ? [ + { + startTime, + endTime, + }, + ] + : []; +} + function buildKstDateTime(dateKey: string, timeOfDay: string) { return new Date(`${dateKey}T${timeOfDay}:00+09:00`); } +function resolveActiveRepeatWindow( + row: Record, + now: Date, +): { + startTime: string | null; + endTime: string | null; + anchorDateKey: string; +} | null { + const nowParts = getKstNowParts(now); + + for (const window of resolveScheduleTimeWindows(row)) { + const startMinutesOfDay = window.startTime ? toMinutesOfDay(window.startTime) : null; + const endMinutesOfDay = window.endTime ? toMinutesOfDay(window.endTime) : null; + const crossesMidnight = + startMinutesOfDay !== null && endMinutesOfDay !== null && startMinutesOfDay > endMinutesOfDay; + const isActive = + startMinutesOfDay !== null && endMinutesOfDay !== null + ? crossesMidnight + ? nowParts.minutesOfDay >= startMinutesOfDay || nowParts.minutesOfDay <= endMinutesOfDay + : nowParts.minutesOfDay >= startMinutesOfDay && nowParts.minutesOfDay <= endMinutesOfDay + : startMinutesOfDay !== null + ? nowParts.minutesOfDay >= startMinutesOfDay + : endMinutesOfDay !== null + ? nowParts.minutesOfDay <= endMinutesOfDay + : false; + + if (!isActive) { + continue; + } + + const anchorDateKey = + crossesMidnight && endMinutesOfDay !== null && nowParts.minutesOfDay <= endMinutesOfDay + ? shiftKstDateKey(nowParts.dateKey, -1) + : nowParts.dateKey; + + return { + startTime: window.startTime, + endTime: window.endTime, + anchorDateKey, + }; + } + + return null; +} + function shiftKstDateKey(dateKey: string, offsetDays: number) { const baseDate = buildKstDateTime(dateKey, '00:00'); @@ -164,6 +402,50 @@ function shiftKstDateKey(dateKey: string, offsetDays: number) { return getKstDateKey(baseDate) ?? dateKey; } +function isDateWithinScheduleDateRanges(row: Record, now: Date) { + const scheduleDateRanges = parseScheduleDateRanges(row.schedule_date_ranges_json); + + if (!scheduleDateRanges.length) { + return true; + } + + const dateKey = getKstDateKey(now); + + if (!dateKey) { + return false; + } + + return scheduleDateRanges.some((range) => range.startDate <= dateKey && dateKey <= range.endDate); +} + +function getKstWeekday(now: Date) { + const dateKey = getKstDateKey(now); + + if (!dateKey) { + return null; + } + + const [year, month, day] = dateKey.split('-').map((value) => Number(value)); + const date = new Date(Date.UTC(year, month - 1, day)); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return date.getUTCDay(); +} + +function isWithinScheduleWeekdays(row: Record, now: Date) { + const scheduleWeekdays = parseScheduleWeekdays(row.schedule_weekdays_json); + + if (!scheduleWeekdays.length) { + return true; + } + + const weekday = getKstWeekday(now); + return weekday !== null && scheduleWeekdays.includes(weekday); +} + function normalizeScheduleExecutionMode(value: unknown): (typeof scheduleExecutionModes)[number] { return scheduleExecutionModes.includes(value as (typeof scheduleExecutionModes)[number]) ? (value as (typeof scheduleExecutionModes)[number]) @@ -314,18 +596,10 @@ function isIntervalScheduleDue(row: Record, now: Date) { const repeatIntervalSeconds = resolveStoredRepeatIntervalSeconds(row); if (!lastRegisteredAt || Number.isNaN(lastRegisteredAt.getTime())) { - const startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); + const activeWindow = resolveActiveRepeatWindow(row, now); - if (startTime) { - const nowParts = getKstNowParts(now); - const endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); - const startMinutesOfDay = toMinutesOfDay(startTime); - const endMinutesOfDay = endTime ? toMinutesOfDay(endTime) : null; - const anchorDateKey = - endMinutesOfDay !== null && startMinutesOfDay > endMinutesOfDay && nowParts.minutesOfDay <= endMinutesOfDay - ? shiftKstDateKey(nowParts.dateKey, -1) - : nowParts.dateKey; - const startAt = buildKstDateTime(anchorDateKey, startTime); + if (activeWindow?.startTime) { + const startAt = buildKstDateTime(activeWindow.anchorDateKey, activeWindow.startTime); if (!Number.isNaN(startAt.getTime())) { if (normalizeBoolean(row.immediate_run_enabled, true)) { @@ -349,35 +623,30 @@ function isIntervalScheduleDue(row: Record, now: Date) { } function isWithinRepeatWindow(row: Record, now: Date) { - const startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); - const endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); + const timeWindows = resolveScheduleTimeWindows(row); - if (!startTime && !endTime) { + if (!timeWindows.length) { return true; } - const nowMinutesOfDay = getKstNowParts(now).minutesOfDay; - const startMinutesOfDay = startTime ? toMinutesOfDay(startTime) : null; - const endMinutesOfDay = endTime ? toMinutesOfDay(endTime) : null; - - if (startMinutesOfDay !== null && endMinutesOfDay !== null) { - if (startMinutesOfDay <= endMinutesOfDay) { - return nowMinutesOfDay >= startMinutesOfDay && nowMinutesOfDay <= endMinutesOfDay; - } - - return nowMinutesOfDay >= startMinutesOfDay || nowMinutesOfDay <= endMinutesOfDay; - } - - if (startMinutesOfDay !== null) { - return nowMinutesOfDay >= startMinutesOfDay; - } - - return nowMinutesOfDay <= (endMinutesOfDay ?? nowMinutesOfDay); + return resolveActiveRepeatWindow(row, now) !== null; } function isScheduleDue(row: Record, now: Date) { + if (!isDateWithinScheduleDateRanges(row, now)) { + return false; + } + const scheduleMode = normalizeScheduleMode(row.schedule_mode); + if (scheduleMode === 'daily' && !isWithinScheduleWeekdays(row, now)) { + return false; + } + + if (scheduleMode === 'interval' && !isWithinScheduleWeekdays(row, now)) { + return false; + } + if (scheduleMode === 'interval' && !isWithinRepeatWindow(row, now)) { return false; } @@ -431,6 +700,9 @@ export function mapPlanScheduledTaskRow(row: Record) { repeatIntervalSeconds: resolveStoredRepeatIntervalSeconds(row), repeatIntervalMinutes: resolveStoredRepeatIntervalMinutes(row), dailyRunTime: normalizeDailyRunTime(row.daily_run_time), + scheduleWeekdays: parseScheduleWeekdays(row.schedule_weekdays_json), + scheduleDateRanges: parseScheduleDateRanges(row.schedule_date_ranges_json), + repeatWindows: resolveScheduleTimeWindows(row), repeatWindowStartTime: normalizeOptionalTimeOfDay(row.repeat_window_start_time), repeatWindowEndTime: normalizeOptionalTimeOfDay(row.repeat_window_end_time), lastRegisteredAt: row.last_registered_at, @@ -549,6 +821,8 @@ async function queueManagedServiceGenerationPlan(options: { attachments: [], automationType: String(options.row.automation_type_id ?? options.row.automation_type ?? 'none'), automationContextIds: options.automationContextIds, + requestExecutionMode: 'all_at_once', + requestItems: [], }); const automationReceipt = await receiveBoardPostAutomation(Number(boardPost.id), { planWorkIdBase: buildScheduledManagedServicePlanWorkIdBase(options.row), @@ -556,7 +830,9 @@ async function queueManagedServiceGenerationPlan(options: { suppressWebPush: Boolean(options.row.suppress_web_push ?? false), }); - if (!automationReceipt?.planItemId) { + const createdPlanItemId = automationReceipt?.planItemIds[0] ?? null; + + if (!createdPlanItemId) { throw new Error(`Plan 스케줄 #${options.row.id} 서비스 패키지 자동 접수에 실패했습니다.`); } @@ -564,12 +840,12 @@ async function queueManagedServiceGenerationPlan(options: { .where({ id: options.row.id }) .update({ managed_service_generation_board_post_id: Number(boardPost.id), - managed_service_generation_plan_item_id: Number(automationReceipt.planItemId), + managed_service_generation_plan_item_id: Number(createdPlanItemId), managed_service_recreate_requested: false, updated_at: db.fn.now(), }) .returning('*'); - const createdPlan = await db(PLAN_TABLE).where({ id: automationReceipt.planItemId }).first(); + const createdPlan = await db(PLAN_TABLE).where({ id: createdPlanItemId }).first(); return { row: updatedRows[0] ?? options.row, @@ -723,6 +999,9 @@ export async function ensurePlanScheduledTaskTable() { table.integer('repeat_interval_minutes').notNullable().defaultTo(60); table.integer('repeat_interval_seconds').notNullable().defaultTo(DEFAULT_REPEAT_INTERVAL_SECONDS); table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); + table.text('schedule_weekdays_json').notNullable().defaultTo('[]'); + table.text('schedule_date_ranges_json').notNullable().defaultTo('[]'); + table.text('repeat_windows_json').notNullable().defaultTo('[]'); table.string('repeat_window_start_time', 5).nullable(); table.string('repeat_window_end_time', 5).nullable(); table.timestamp('last_registered_at', { useTz: true }).nullable(); @@ -808,6 +1087,15 @@ export async function ensurePlanScheduledTaskTable() { await ensurePlanScheduledTaskColumn('daily_run_time', (table) => { table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); }); + await ensurePlanScheduledTaskColumn('schedule_weekdays_json', (table) => { + table.text('schedule_weekdays_json').notNullable().defaultTo('[]'); + }); + await ensurePlanScheduledTaskColumn('schedule_date_ranges_json', (table) => { + table.text('schedule_date_ranges_json').notNullable().defaultTo('[]'); + }); + await ensurePlanScheduledTaskColumn('repeat_windows_json', (table) => { + table.text('repeat_windows_json').notNullable().defaultTo('[]'); + }); await ensurePlanScheduledTaskColumn('repeat_window_start_time', (table) => { table.string('repeat_window_start_time', 5).nullable(); }); @@ -855,6 +1143,28 @@ export async function ensurePlanScheduledTaskTable() { suppress_web_push: false, }); + const existingWindowRows = await db(PLAN_SCHEDULED_TASK_TABLE).select( + 'id', + 'repeat_windows_json', + 'repeat_window_start_time', + 'repeat_window_end_time', + ); + + for (const row of existingWindowRows) { + const normalizedWindows = resolveScheduleTimeWindows(row); + const currentWindows = parseScheduleTimeWindows(row.repeat_windows_json); + + if (JSON.stringify(currentWindows) === JSON.stringify(normalizedWindows)) { + continue; + } + + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + repeat_windows_json: stringifyScheduleTimeWindows(normalizedWindows), + }); + } + const existingRows = await db(PLAN_SCHEDULED_TASK_TABLE).select( 'id', 'repeat_interval_value', @@ -885,13 +1195,76 @@ export async function ensurePlanScheduledTaskTable() { export async function listPlanScheduledTasks() { await ensurePlanScheduledTaskTable(); - return db(PLAN_SCHEDULED_TASK_TABLE).select('*').orderBy('id', 'desc'); + const rows = await db(PLAN_SCHEDULED_TASK_TABLE).select('*').orderBy('id', 'desc'); + return Promise.all(rows.map((row) => syncManagedServiceGenerationCompletionForScheduleRow(row))); } export async function getPlanScheduledTaskById(id: number) { await ensurePlanScheduledTaskTable(); - return db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); + const row = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); + return row ? syncManagedServiceGenerationCompletionForScheduleRow(row) : null; +} + +async function syncManagedServiceGenerationCompletionForScheduleRow(row: Record) { + if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { + return row; + } + + const generationPlanItemId = Number(row.managed_service_generation_plan_item_id ?? 0); + + if (generationPlanItemId <= 0) { + return row; + } + + const packageExists = await hasManagedScheduleServicePackage( + typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null, + ); + + if (!packageExists) { + return row; + } + + const generationPlan = await db(PLAN_TABLE) + .select('id', 'status', 'worker_status') + .where({ id: generationPlanItemId }) + .first(); + const generationPlanStatus = String(generationPlan?.status ?? '').trim(); + const generationPlanWorkerStatus = String(generationPlan?.worker_status ?? '').trim(); + const isCompletedPlan = + Boolean(generationPlan) + && ['완료', '작업완료', '릴리즈완료'].includes(generationPlanStatus) + && !['브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패'].includes(generationPlanWorkerStatus); + + if (!isCompletedPlan) { + return row; + } + + if (row.managed_service_generated_at && !normalizeBoolean(row.managed_service_recreate_requested, false)) { + return row; + } + + const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + managed_service_generated_at: db.fn.now(), + managed_service_recreate_requested: false, + updated_at: db.fn.now(), + }) + .returning('*'); + + return updatedRows[0] ?? row; +} + +export async function syncManagedServiceGenerationCompletion(planItemId: number) { + await ensurePlanScheduledTaskTable(); + + const rows = await db(PLAN_SCHEDULED_TASK_TABLE) + .select('*') + .where({ managed_service_generation_plan_item_id: planItemId }); + + const syncedRows = await Promise.all(rows.map((row) => syncManagedServiceGenerationCompletionForScheduleRow(row))); + return syncedRows.filter((row) => Boolean(row.managed_service_generated_at)); } export async function createPlanScheduledTask(payload: z.infer) { @@ -901,6 +1274,8 @@ export async function createPlanScheduledTask(payload: z.infer, now: D attachments: [], automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), automationContextIds, + requestExecutionMode: 'all_at_once', + requestItems: [], }); await db(PLAN_SCHEDULED_TASK_TABLE) diff --git a/etc/servers/work-server/src/services/plan-service.js b/etc/servers/work-server/src/services/plan-service.js new file mode 100644 index 0000000..dc011d3 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-service.js @@ -0,0 +1,3482 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updatePlanReleaseReviewSchema = exports.planReleaseReviewStatusSchema = exports.listPlanQuerySchema = exports.issueActionSchema = exports.updatePlanJangsingProcessingSchema = exports.updatePlanSchema = exports.createPlanSchema = exports.planAutomationTypeSchema = exports.setupSchema = exports.statusSchema = exports.planReleaseReviewStatuses = exports.planWorkerStatuses = exports.planAutomationTypes = exports.planStatuses = exports.PLAN_RELEASE_REVIEW_TABLE = exports.PLAN_SOURCE_WORK_TABLE = exports.PLAN_ACTION_TABLE = exports.PLAN_ISSUE_TABLE = exports.PLAN_TABLE = void 0; +exports.normalizePlanWorkId = normalizePlanWorkId; +exports.normalizePlanAutomationType = normalizePlanAutomationType; +exports.formatPlanNotificationLabel = formatPlanNotificationLabel; +exports.maskPlanNote = maskPlanNote; +exports.normalizeChangedFiles = normalizeChangedFiles; +exports.filterRetryWorklogEvidencePaths = filterRetryWorklogEvidencePaths; +exports.buildPlanBranchName = buildPlanBranchName; +exports.shouldUseLocalMainPlanMode = shouldUseLocalMainPlanMode; +exports.mapPlanRow = mapPlanRow; +exports.mapPlanIssueRow = mapPlanIssueRow; +exports.mapPlanActionRow = mapPlanActionRow; +exports.mapPlanSourceWorkRow = mapPlanSourceWorkRow; +exports.mapPlanAutomationUsageSnapshot = mapPlanAutomationUsageSnapshot; +exports.mapPlanReleaseReviewMetadata = mapPlanReleaseReviewMetadata; +exports.mapPlanReleaseReviewRow = mapPlanReleaseReviewRow; +exports.syncPlanAutomationUsageSnapshot = syncPlanAutomationUsageSnapshot; +exports.ensurePlanTable = ensurePlanTable; +exports.createPlanItem = createPlanItem; +exports.createCompletedPlanExecutionLogItem = createCompletedPlanExecutionLogItem; +exports.upsertAutoPlanItem = upsertAutoPlanItem; +exports.updatePlanItem = updatePlanItem; +exports.updatePlanItemJangsingProcessingRequired = updatePlanItemJangsingProcessingRequired; +exports.deletePlanItem = deletePlanItem; +exports.getBoardPostLinkedToPlanItem = getBoardPostLinkedToPlanItem; +exports.markPlanAsDevelopmentComplete = markPlanAsDevelopmentComplete; +exports.markPlanAsCompleted = markPlanAsCompleted; +exports.markPlanAsStarted = markPlanAsStarted; +exports.retryPlanBranch = retryPlanBranch; +exports.retryPlanMerge = retryPlanMerge; +exports.requestPlanMainMerge = requestPlanMainMerge; +exports.retryPlanWork = retryPlanWork; +exports.isPlanLockedByWorker = isPlanLockedByWorker; +exports.resumePlanDevelopmentFromRelease = resumePlanDevelopmentFromRelease; +exports.queuePlanRetryFromFailure = queuePlanRetryFromFailure; +exports.shouldResumePlanDevelopmentFromIssueAction = shouldResumePlanDevelopmentFromIssueAction; +exports.queuePlanRetryFromIssueAction = queuePlanRetryFromIssueAction; +exports.cancelPlanRelease = cancelPlanRelease; +exports.createPlanActionHistory = createPlanActionHistory; +exports.resolveAutomationIssueHistories = resolveAutomationIssueHistories; +exports.listPlanActionHistories = listPlanActionHistories; +exports.createPlanSourceWorkHistory = createPlanSourceWorkHistory; +exports.listPlanSourceWorkHistories = listPlanSourceWorkHistories; +exports.getPlanSourceWorkHistory = getPlanSourceWorkHistory; +exports.listLatestPlanSourceWorkMap = listLatestPlanSourceWorkMap; +exports.upsertPlanReleaseReview = upsertPlanReleaseReview; +exports.listPlanReleaseReviewBoardItems = listPlanReleaseReviewBoardItems; +exports.createPlanIssueHistory = createPlanIssueHistory; +exports.appendLatestIssueAction = appendLatestIssueAction; +exports.listPlanIssueHistories = listPlanIssueHistories; +exports.listPlanIssueSummaries = listPlanIssueSummaries; +exports.claimNextPlanForBranch = claimNextPlanForBranch; +exports.claimNextPlanForExecution = claimNextPlanForExecution; +exports.claimNextPlanForMerge = claimNextPlanForMerge; +exports.claimNextPlanForMainMerge = claimNextPlanForMainMerge; +exports.markPlanBranchReady = markPlanBranchReady; +exports.markPlanWorkCompleted = markPlanWorkCompleted; +exports.markPlanReleaseMerged = markPlanReleaseMerged; +exports.markPlanMerged = markPlanMerged; +exports.markPlanMainMergeFailure = markPlanMainMergeFailure; +exports.markPlanAutomationFailure = markPlanAutomationFailure; +exports.listPlanItems = listPlanItems; +exports.getPlanItemById = getPlanItemById; +exports.findLatestPlanItem = findLatestPlanItem; +exports.findPlanItemByWorkId = findPlanItemByWorkId; +exports.findPlanItemByPreviewUrl = findPlanItemByPreviewUrl; +var zod_1 = require("zod"); +var env_js_1 = require("../config/env.js"); +var client_js_1 = require("../db/client.js"); +var automation_context_service_js_1 = require("./automation-context-service.js"); +var automation_type_config_service_js_1 = require("./automation-type-config-service.js"); +var plan_retry_policy_js_1 = require("./plan-retry-policy.js"); +exports.PLAN_TABLE = 'plan_items'; +exports.PLAN_ISSUE_TABLE = 'plan_issue_histories'; +exports.PLAN_ACTION_TABLE = 'plan_action_histories'; +exports.PLAN_SOURCE_WORK_TABLE = 'plan_source_work_histories'; +exports.PLAN_RELEASE_REVIEW_TABLE = 'plan_release_reviews'; +exports.planStatuses = ['등록', '작업중', '작업완료', '릴리즈완료', '완료']; +exports.planAutomationTypes = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker']; +exports.planWorkerStatuses = [ + '대기', + '브랜치생성중', + '브랜치준비', + '자동작업중', + 'release반영대기', + 'release반영중', + 'release반영완료', + 'main반영대기', + 'main반영중', + 'main반영완료', + '자동완료', + '브랜치실패', + 'release반영실패', + 'main반영실패', + '자동작업실패', + '작업취소', +]; +exports.planReleaseReviewStatuses = ['pending', 'reviewing', 'approved', 'changes-requested']; +exports.statusSchema = zod_1.z.enum(exports.planStatuses); +exports.setupSchema = zod_1.z.object({ + recreate: zod_1.z.boolean().optional(), +}); +function resolvePlanAutomationTypeAlias(value) { + return (0, automation_type_config_service_js_1.normalizeLegacyAutomationBehaviorType)(value); +} +exports.planAutomationTypeSchema = zod_1.z.preprocess(resolvePlanAutomationTypeAlias, zod_1.z.string().trim().min(1).max(120)); +exports.createPlanSchema = zod_1.z.object({ + workId: zod_1.z.string().trim().optional().default('작업ID'), + note: zod_1.z.string().default(''), + automationType: zod_1.z.preprocess(resolvePlanAutomationTypeAlias, zod_1.z.string().trim().min(1).max(120).default('none')), + automationContextIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(20).optional().default([]), + releaseTarget: zod_1.z.string().trim().min(1).default('release'), + jangsingProcessingRequired: zod_1.z.boolean().default(true), + autoDeployToMain: zod_1.z.boolean().default(true), + suppressWebPush: zod_1.z.boolean().default(false), + repeatRequestEnabled: zod_1.z.boolean().default(false), + repeatIntervalMinutes: zod_1.z.coerce.number().int().min(1).max(1440).default(60), +}); +exports.updatePlanSchema = zod_1.z.object({ + workId: zod_1.z.string().trim().optional(), + note: zod_1.z.string().optional(), + automationType: zod_1.z.preprocess(resolvePlanAutomationTypeAlias, zod_1.z.string().trim().min(1).max(120).optional()), + automationContextIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(20).optional(), + releaseTarget: zod_1.z.string().trim().min(1).optional(), + jangsingProcessingRequired: zod_1.z.boolean().optional(), + autoDeployToMain: zod_1.z.boolean().optional(), + suppressWebPush: zod_1.z.boolean().optional(), + repeatRequestEnabled: zod_1.z.boolean().optional(), + repeatIntervalMinutes: zod_1.z.coerce.number().int().min(1).max(1440).optional(), +}); +exports.updatePlanJangsingProcessingSchema = zod_1.z.object({ + jangsingProcessingRequired: zod_1.z.boolean(), +}); +exports.issueActionSchema = zod_1.z.object({ + actionNote: zod_1.z.string().trim().min(1), + resolve: zod_1.z.boolean().default(false), + retry: zod_1.z.boolean().default(false), +}); +var planListStatusFilterAliases = new Set(['all', 'in-progress', 'done', 'error']); +exports.listPlanQuerySchema = zod_1.z.object({ + status: zod_1.z.preprocess(function (value) { + if (typeof value !== 'string') { + return value; + } + var normalizedValue = value.trim(); + if (!normalizedValue || planListStatusFilterAliases.has(normalizedValue)) { + return undefined; + } + return normalizedValue; + }, exports.statusSchema.optional()), +}); +exports.planReleaseReviewStatusSchema = zod_1.z.enum(exports.planReleaseReviewStatuses); +var planReleaseReviewMetadataSchema = zod_1.z.object({ + summary: zod_1.z.string().trim().min(1).max(500).optional(), + pageSelectionIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(12).optional(), + checkedPageSelectionIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(12).optional(), + docIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(12).optional(), + componentIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(12).optional(), + widgetIds: zod_1.z.array(zod_1.z.string().trim().min(1).max(160)).max(12).optional(), +}); +exports.updatePlanReleaseReviewSchema = zod_1.z.object({ + status: exports.planReleaseReviewStatusSchema.optional(), + reviewNote: zod_1.z.string().max(4000).optional(), + metadata: planReleaseReviewMetadataSchema.optional(), +}); +var planFailureWorkerStatuses = new Set([ + '브랜치실패', + '자동작업실패', + 'release반영실패', + 'main반영실패', +]); +var functionCheckEditableStatuses = new Set(['작업완료', '릴리즈완료', '완료']); +var automationIssueTags = ['#브랜치실패', '#자동작업실패', '#release반영실패', '#main반영실패']; +var WORKLOG_EVIDENCE_PATH_PATTERN = /^docs\/(?:worklogs\/.+\.md|assets\/worklogs\/.+)/i; +function sanitizeBranchToken(value) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); +} +function normalizePlanWorkId(value) { + var workId = String(value !== null && value !== void 0 ? value : '').trim(); + var normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); + if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') { + return '작업ID'; + } + return workId; +} +function normalizePlanAutomationType(value) { + var normalizedValue = resolvePlanAutomationTypeAlias(value); + return exports.planAutomationTypes.includes(normalizedValue) + ? normalizedValue + : 'none'; +} +function shouldSkipLifecycleSourceWork(row) { + return normalizePlanAutomationType(row === null || row === void 0 ? void 0 : row.automation_type) === 'plan'; +} +function formatPlanNotificationLabel(workId, id) { + var normalizedWorkId = normalizePlanWorkId(workId); + return normalizedWorkId === '작업ID' ? "#".concat(id) : normalizedWorkId; +} +function maskPlanNote(value) { + var normalized = String(value !== null && value !== void 0 ? value : '').replace(/\s+/g, ' ').trim(); + if (!normalized) { + return ''; + } + var words = normalized.split(' ').filter(Boolean); + if (words.length === 1) { + var word = words[0]; + if (word.length <= 1) { + return '*'; + } + return "".concat(word[0]).concat('*'.repeat(word.length - 1)); + } + return words + .map(function (word, index) { + if (word.length <= 1) { + return '*'; + } + if (index === 0) { + return "".concat(word[0]).concat('*'.repeat(word.length - 1)); + } + if (index === words.length - 1) { + return "".concat('*'.repeat(word.length - 1)).concat(word[word.length - 1]); + } + return word; + }) + .join(' '); +} +function normalizeChangedFiles(rawChangedFiles) { + return __spreadArray([], new Set((rawChangedFiles !== null && rawChangedFiles !== void 0 ? rawChangedFiles : []).map(function (file) { return String(file !== null && file !== void 0 ? file : '').trim(); }).filter(Boolean)), true); +} +function filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList) { + var normalizedChangedFiles = normalizeChangedFiles(changedFiles); + var existingEvidencePaths = new Set(existingChangedFilesList + .flatMap(function (files) { return normalizeChangedFiles(files); }) + .filter(function (file) { return WORKLOG_EVIDENCE_PATH_PATTERN.test(file); })); + return normalizedChangedFiles.filter(function (file) { return !WORKLOG_EVIDENCE_PATH_PATTERN.test(file) || !existingEvidencePaths.has(file); }); +} +function buildPlanBranchName(workId, id) { + var token = sanitizeBranchToken(workId) || "plan-".concat(id); + var prefix = /^auto-worklog-\d{4}-\d{2}-\d{2}$/i.test(String(workId !== null && workId !== void 0 ? workId : '').trim()) ? 'hotfix' : 'feature'; + return "".concat(prefix, "/plan-").concat(id, "-").concat(token); +} +function shouldUseLocalMainPlanMode(automationType) { + var env = (0, env_js_1.getEnv)(); + return Boolean(env.PLAN_LOCAL_MAIN_MODE) && normalizePlanAutomationType(automationType) !== 'auto_worker'; +} +function mapPlanRow(row, options) { + var _a, _b, _c, _d, _e, _f, _g; + return { + id: row.id, + workId: row.work_id, + note: (options === null || options === void 0 ? void 0 : options.maskNote) ? maskPlanNote(row.note) : row.note, + automationType: (options === null || options === void 0 ? void 0 : options.exposeConfiguredAutomationType) ? (0, automation_type_config_service_js_1.resolveStoredAutomationTypeId)(row) : normalizePlanAutomationType(row.automation_type), + automationBehaviorType: normalizePlanAutomationType(row.automation_type), + automationContextIds: (0, automation_context_service_js_1.parseAutomationContextIds)(row.automation_context_ids_json), + releaseReviewNote: (_a = options === null || options === void 0 ? void 0 : options.releaseReviewNote) !== null && _a !== void 0 ? _a : '', + noteMasked: Boolean(options === null || options === void 0 ? void 0 : options.noteMasked), + status: row.status, + jangsingProcessingRequired: typeof row.jangsing_processing_required === 'boolean' + ? row.jangsing_processing_required + : row.normal_processing_level === '상', + autoDeployToMain: Boolean((_b = row.auto_deploy_to_main) !== null && _b !== void 0 ? _b : true), + suppressWebPush: Boolean((_c = row.suppress_web_push) !== null && _c !== void 0 ? _c : false), + repeatRequestEnabled: Boolean((_d = row.repeat_request_enabled) !== null && _d !== void 0 ? _d : false), + repeatIntervalMinutes: Number((_e = row.repeat_interval_minutes) !== null && _e !== void 0 ? _e : 60), + assignedBranch: row.assigned_branch, + releaseTarget: row.release_target, + workerStatus: row.worker_status, + lastError: row.last_error, + issueTags: (_f = options === null || options === void 0 ? void 0 : options.issueTags) !== null && _f !== void 0 ? _f : [], + hasOpenIssues: (_g = options === null || options === void 0 ? void 0 : options.hasOpenIssues) !== null && _g !== void 0 ? _g : false, + startedAt: row.started_at, + completedAt: row.completed_at, + mergedAt: row.merged_at, + usageSnapshot: mapPlanAutomationUsageSnapshot(row.usage_snapshot), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} +function mapPlanIssueRow(row) { + return { + id: row.id, + planItemId: row.plan_item_id, + issueTag: row.issue_tag, + message: row.message, + actionNote: row.action_note, + resolved: row.resolved, + resolvedAt: row.resolved_at, + createdAt: row.created_at, + }; +} +function mapPlanActionRow(row) { + return { + id: row.id, + planItemId: row.plan_item_id, + actionType: row.action_type, + note: row.note, + createdAt: row.created_at, + }; +} +function mapPlanSourceWorkRow(row) { + var _a, _b, _c; + var changedFilesText = String((_a = row.changed_files) !== null && _a !== void 0 ? _a : '[]'); + var changedFiles = []; + var sourceFilesText = String((_b = row.source_files) !== null && _b !== void 0 ? _b : '[]'); + var sourceFiles = []; + try { + changedFiles = normalizeChangedFiles(JSON.parse(changedFilesText)); + } + catch (_d) { + changedFiles = []; + } + try { + sourceFiles = JSON.parse(sourceFilesText); + } + catch (_e) { + sourceFiles = []; + } + return { + id: row.id, + planItemId: row.plan_item_id, + summary: row.summary, + branchName: row.branch_name, + commitHash: row.commit_hash, + previewUrl: (_c = row.preview_url) !== null && _c !== void 0 ? _c : null, + changedFiles: changedFiles, + commandLog: row.command_log, + diffText: row.diff_text, + sourceFiles: sourceFiles, + createdAt: row.created_at, + }; +} +function normalizePlanAutomationUsageSnapshotMetric(value) { + var normalized = Number(value !== null && value !== void 0 ? value : 0); + return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0; +} +function mapPlanAutomationUsageSnapshot(value) { + var _a; + var parsedValue = value; + if (typeof value === 'string') { + var trimmedValue = value.trim(); + if (!trimmedValue) { + return null; + } + try { + parsedValue = JSON.parse(trimmedValue); + } + catch (_b) { + return null; + } + } + if (!parsedValue || typeof parsedValue !== 'object') { + return null; + } + var snapshot = parsedValue; + var tokenTotals = (_a = snapshot.tokenTotals) !== null && _a !== void 0 ? _a : {}; + return { + tokenTotals: { + total: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.total), + input: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.input), + output: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.output), + cached: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.cached), + reasoning: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.reasoning), + }, + totalTokens: normalizePlanAutomationUsageSnapshotMetric(snapshot.totalTokens), + retryCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.retryCount), + sourceWorkCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.sourceWorkCount), + processingStartedAt: typeof snapshot.processingStartedAt === 'string' && snapshot.processingStartedAt.trim() + ? snapshot.processingStartedAt + : null, + processingEndedAt: typeof snapshot.processingEndedAt === 'string' && snapshot.processingEndedAt.trim() + ? snapshot.processingEndedAt + : null, + processingEndedAtSource: typeof snapshot.processingEndedAtSource === 'string' && snapshot.processingEndedAtSource.trim() + ? snapshot.processingEndedAtSource + : null, + processingDurationSeconds: Number.isFinite(Number(snapshot.processingDurationSeconds)) + ? Math.max(0, Math.round(Number(snapshot.processingDurationSeconds))) + : null, + }; +} +function mapPlanReleaseReviewMetadata(value) { + if (typeof value === 'string' && value.trim()) { + try { + return planReleaseReviewMetadataSchema.parse(JSON.parse(value)); + } + catch (_a) { + return {}; + } + } + if (value && typeof value === 'object') { + try { + return planReleaseReviewMetadataSchema.parse(value); + } + catch (_b) { + return {}; + } + } + return {}; +} +function mapPlanReleaseReviewRow(row, planItemId) { + var _a; + var metadata = mapPlanReleaseReviewMetadata((_a = row === null || row === void 0 ? void 0 : row.metadata) !== null && _a !== void 0 ? _a : null); + return { + id: (row === null || row === void 0 ? void 0 : row.id) ? Number(row.id) : null, + planItemId: planItemId, + status: ((row === null || row === void 0 ? void 0 : row.status) ? String(row.status) : 'pending'), + reviewNote: (row === null || row === void 0 ? void 0 : row.review_note) ? String(row.review_note) : '', + checkedByClientId: (row === null || row === void 0 ? void 0 : row.checked_by_client_id) ? String(row.checked_by_client_id) : null, + checkedByNickname: (row === null || row === void 0 ? void 0 : row.checked_by_nickname) ? String(row.checked_by_nickname) : null, + checkedAt: (row === null || row === void 0 ? void 0 : row.checked_at) ? String(row.checked_at) : null, + metadata: metadata, + createdAt: (row === null || row === void 0 ? void 0 : row.created_at) ? String(row.created_at) : null, + updatedAt: (row === null || row === void 0 ? void 0 : row.updated_at) ? String(row.updated_at) : null, + }; +} +function extractAutomationTokenUsageText(sourceWork) { + var candidates = [ + typeof sourceWork.commandLog === 'string' ? sourceWork.commandLog : null, + typeof sourceWork.summary === 'string' ? sourceWork.summary : null, + ]; + for (var _i = 0, candidates_1 = candidates; _i < candidates_1.length; _i++) { + var candidate = candidates_1[_i]; + if (!candidate) { + continue; + } + var line = candidate + .split('\n') + .map(function (entry) { return entry.trim(); }) + .find(function (entry) { return /^토큰 사용량:\s*/.test(entry); }); + if (line) { + return line.replace(/^토큰 사용량:\s*/u, '').trim(); + } + } + return null; +} +function parseTokenMetricValue(valueText, unitText) { + var normalizedValue = Number(String(valueText !== null && valueText !== void 0 ? valueText : '').replace(/,/g, '')); + if (!Number.isFinite(normalizedValue)) { + return null; + } + var unit = String(unitText !== null && unitText !== void 0 ? unitText : '').trim().toLowerCase(); + if (unit === 'k') { + return Math.round(normalizedValue * 1000); + } + if (unit === 'm') { + return Math.round(normalizedValue * 1000000); + } + return Math.round(normalizedValue); +} +function parseAutomationTokenUsageMetrics(tokenUsageText) { + var _a, _b, _c, _d, _e, _f; + var normalizedText = tokenUsageText + .replace(/^tokens?\s+used\s*:?\s*/iu, '') + .replace(/\(([^)]+)\)/g, ', $1') + .trim(); + var metrics = new Map(); + for (var _i = 0, _g = normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu); _i < _g.length; _i++) { + var match = _g[_i]; + var label = (_b = (_a = match[3]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) !== null && _b !== void 0 ? _b : ''; + var value = parseTokenMetricValue((_c = match[1]) !== null && _c !== void 0 ? _c : '', match[2]); + if (!label || value === null) { + continue; + } + metrics.set(label, value); + } + for (var _h = 0, _j = normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu); _h < _j.length; _h++) { + var match = _j[_h]; + var label = (_e = (_d = match[1]) === null || _d === void 0 ? void 0 : _d.toLowerCase()) !== null && _e !== void 0 ? _e : ''; + var value = parseTokenMetricValue((_f = match[2]) !== null && _f !== void 0 ? _f : '', match[3]); + if (!label || value === null) { + continue; + } + metrics.set(label, value); + } + if (metrics.size > 0) { + return metrics; + } + var fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i); + if (!fallbackMatch) { + return null; + } + var fallbackValue = parseTokenMetricValue(fallbackMatch[1], fallbackMatch[2]); + if (fallbackValue === null) { + return null; + } + return new Map([['total', fallbackValue]]); +} +function getAutomationTotalTokenCount(metrics) { + var total = metrics.get('total'); + if (typeof total === 'number' && Number.isFinite(total)) { + return total; + } + return ['input', 'output', 'cached', 'reasoning'].reduce(function (sum, key) { var _a; return sum + ((_a = metrics.get(key)) !== null && _a !== void 0 ? _a : 0); }, 0); +} +function isPlanAutomationWorkerActive(workerStatus) { + return [ + '브랜치생성중', + '브랜치준비', + '자동작업중', + 'release반영대기', + 'release반영중', + 'main반영대기', + 'main반영중', + ].includes(String(workerStatus !== null && workerStatus !== void 0 ? workerStatus : '').trim()); +} +function getLatestPlanReviewCheckedAt(planItemId) { + return __awaiter(this, void 0, void 0, function () { + var row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_RELEASE_REVIEW_TABLE) + .select('checked_at', 'updated_at', 'id') + .where({ plan_item_id: planItemId }) + .orderBy([ + { column: 'updated_at', order: 'desc' }, + { column: 'id', order: 'desc' }, + ]) + .first()]; + case 1: + row = _a.sent(); + return [2 /*return*/, (row === null || row === void 0 ? void 0 : row.checked_at) ? String(row.checked_at) : null]; + } + }); + }); +} +function resolvePlanProcessingEndedAt(row, reviewCheckedAt) { + if (reviewCheckedAt) { + return { + endedAt: reviewCheckedAt, + source: 'review_checked_at', + }; + } + if (row.merged_at) { + return { + endedAt: String(row.merged_at), + source: 'merged_at', + }; + } + if (row.completed_at) { + return { + endedAt: String(row.completed_at), + source: 'completed_at', + }; + } + if (row.updated_at && row.started_at && !isPlanAutomationWorkerActive(row.worker_status)) { + return { + endedAt: String(row.updated_at), + source: 'updated_at', + }; + } + if (row.started_at && isPlanAutomationWorkerActive(row.worker_status)) { + return { + endedAt: new Date().toISOString(), + source: 'in_progress', + }; + } + return { + endedAt: null, + source: null, + }; +} +function buildPlanAutomationUsageSnapshot(sourceWorks, row, reviewCheckedAt) { + var _a, _b, _c, _d, _e, _f; + var totals = new Map(); + sourceWorks.forEach(function (sourceWork) { + var tokenUsageText = extractAutomationTokenUsageText(sourceWork); + if (!tokenUsageText) { + return; + } + var metrics = parseAutomationTokenUsageMetrics(tokenUsageText); + if (!metrics) { + return; + } + metrics.forEach(function (value, key) { + var _a; + totals.set(key, ((_a = totals.get(key)) !== null && _a !== void 0 ? _a : 0) + value); + }); + }); + var sourceWorkCount = sourceWorks.length; + var processingStartedAt = row.started_at ? String(row.started_at) : ((_a = sourceWorks[0]) === null || _a === void 0 ? void 0 : _a.createdAt) ? String(sourceWorks[0].createdAt) : null; + var _g = resolvePlanProcessingEndedAt(row, reviewCheckedAt), endedAt = _g.endedAt, source = _g.source; + var processingDurationSeconds = processingStartedAt && endedAt + ? Math.max(0, Math.round((new Date(endedAt).getTime() - new Date(processingStartedAt).getTime()) / 1000)) + : null; + var totalTokens = getAutomationTotalTokenCount(totals); + if (sourceWorkCount === 0 && !processingStartedAt && totalTokens === 0) { + return null; + } + return { + tokenTotals: { + total: Math.max(0, (_b = totals.get('total')) !== null && _b !== void 0 ? _b : 0), + input: Math.max(0, (_c = totals.get('input')) !== null && _c !== void 0 ? _c : 0), + output: Math.max(0, (_d = totals.get('output')) !== null && _d !== void 0 ? _d : 0), + cached: Math.max(0, (_e = totals.get('cached')) !== null && _e !== void 0 ? _e : 0), + reasoning: Math.max(0, (_f = totals.get('reasoning')) !== null && _f !== void 0 ? _f : 0), + }, + totalTokens: totalTokens, + retryCount: Math.max(0, sourceWorkCount - 1), + sourceWorkCount: sourceWorkCount, + processingStartedAt: processingStartedAt, + processingEndedAt: endedAt, + processingEndedAtSource: source, + processingDurationSeconds: processingDurationSeconds, + }; +} +function syncPlanAutomationUsageSnapshot(planItemId) { + return __awaiter(this, void 0, void 0, function () { + var row, sourceWorkRows, reviewCheckedAt, snapshot; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: planItemId }).first()]; + case 2: + row = _a.sent(); + if (!row) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .select('summary', 'command_log', 'created_at') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc')]; + case 3: + sourceWorkRows = _a.sent(); + return [4 /*yield*/, getLatestPlanReviewCheckedAt(planItemId)]; + case 4: + reviewCheckedAt = _a.sent(); + snapshot = buildPlanAutomationUsageSnapshot(sourceWorkRows.map(function (sourceWorkRow) { return ({ + summary: sourceWorkRow.summary, + commandLog: sourceWorkRow.command_log, + createdAt: String(sourceWorkRow.created_at), + }); }), row, reviewCheckedAt); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: planItemId }) + .update({ + usage_snapshot: snapshot ? JSON.stringify(snapshot) : null, + })]; + case 5: + _a.sent(); + return [2 /*return*/, snapshot]; + } + }); + }); +} +function listPlanReleaseReviewNoteMap(planItemIds) { + return __awaiter(this, void 0, void 0, function () { + var rows, noteMap, _i, rows_1, row, planItemId; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (planItemIds.length === 0) { + return [2 /*return*/, new Map()]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_RELEASE_REVIEW_TABLE) + .select('plan_item_id', 'review_note', 'updated_at', 'id') + .whereIn('plan_item_id', planItemIds) + .orderBy([ + { column: 'updated_at', order: 'desc' }, + { column: 'id', order: 'desc' }, + ])]; + case 1: + rows = _a.sent(); + noteMap = new Map(); + for (_i = 0, rows_1 = rows; _i < rows_1.length; _i++) { + row = rows_1[_i]; + planItemId = Number(row.plan_item_id); + if (!Number.isFinite(planItemId) || noteMap.has(planItemId)) { + continue; + } + noteMap.set(planItemId, row.review_note ? String(row.review_note) : ''); + } + return [2 /*return*/, noteMap]; + } + }); + }); +} +function ensurePlanFailureIssueHistory(planItemId) { + return __awaiter(this, void 0, void 0, function () { + var planRow, existingIssue; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: planItemId }).first()]; + case 1: + planRow = _a.sent(); + if (!planRow || !planRow.worker_status || !planFailureWorkerStatuses.has(planRow.worker_status)) { + return [2 /*return*/]; + } + if (!planRow.last_error) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .where({ + plan_item_id: planItemId, + issue_tag: "#".concat(planRow.worker_status), + message: planRow.last_error, + }) + .first()]; + case 2: + existingIssue = _a.sent(); + if (existingIssue) { + return [2 /*return*/]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE).insert({ + plan_item_id: planItemId, + issue_tag: "#".concat(planRow.worker_status), + message: planRow.last_error, + resolved: false, + })]; + case 3: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensureColumn(columnName, addColumn) { + return __awaiter(this, void 0, void 0, function () { + var hasColumn; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.PLAN_TABLE, columnName)]; + case 1: + hasColumn = _a.sent(); + if (hasColumn) { + return [2 /*return*/]; + } + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.PLAN_TABLE, function (table) { + addColumn(table); + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function syncPlanColumns() { + return __awaiter(this, void 0, void 0, function () { + var hasIssueNoteColumn; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.PLAN_TABLE, 'issue_note')]; + case 1: + hasIssueNoteColumn = _a.sent(); + if (!hasIssueNoteColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.PLAN_TABLE, function (table) { + table.dropColumn('issue_note'); + })]; + case 2: + _a.sent(); + _a.label = 3; + case 3: return [4 /*yield*/, ensureColumn('assigned_branch', function (table) { + table.string('assigned_branch', 200).nullable(); + })]; + case 4: + _a.sent(); + return [4 /*yield*/, ensureColumn('release_target', function (table) { + table.string('release_target', 120).notNullable().defaultTo('release'); + })]; + case 5: + _a.sent(); + return [4 /*yield*/, ensureColumn('automation_type', function (table) { + table.string('automation_type', 40).notNullable().defaultTo('none'); + })]; + case 6: + _a.sent(); + return [4 /*yield*/, ensureColumn('automation_type_id', function (table) { + table.string('automation_type_id', 120).nullable(); + })]; + case 7: + _a.sent(); + return [4 /*yield*/, ensureColumn('automation_context_ids_json', function (table) { + table.text('automation_context_ids_json').notNullable().defaultTo('[]'); + })]; + case 8: + _a.sent(); + return [4 /*yield*/, ensureColumn('auto_deploy_to_main', function (table) { + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + })]; + case 9: + _a.sent(); + return [4 /*yield*/, ensureColumn('suppress_web_push', function (table) { + table.boolean('suppress_web_push').notNullable().defaultTo(false); + })]; + case 10: + _a.sent(); + return [4 /*yield*/, ensureColumn('repeat_request_enabled', function (table) { + table.boolean('repeat_request_enabled').notNullable().defaultTo(false); + })]; + case 11: + _a.sent(); + return [4 /*yield*/, ensureColumn('repeat_interval_minutes', function (table) { + table.integer('repeat_interval_minutes').notNullable().defaultTo(60); + })]; + case 12: + _a.sent(); + return [4 /*yield*/, ensureColumn('normal_processing_level', function (table) { + table.string('normal_processing_level', 20).notNullable().defaultTo('중'); + })]; + case 13: + _a.sent(); + return [4 /*yield*/, ensureColumn('jangsing_processing_required', function (table) { + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + })]; + case 14: + _a.sent(); + return [4 /*yield*/, ensureColumn('worker_status', function (table) { + table.string('worker_status', 80).nullable(); + })]; + case 15: + _a.sent(); + return [4 /*yield*/, ensureColumn('last_error', function (table) { + table.text('last_error').nullable(); + })]; + case 16: + _a.sent(); + return [4 /*yield*/, ensureColumn('locked_by', function (table) { + table.string('locked_by', 120).nullable(); + })]; + case 17: + _a.sent(); + return [4 /*yield*/, ensureColumn('locked_at', function (table) { + table.timestamp('locked_at', { useTz: true }).nullable(); + })]; + case 18: + _a.sent(); + return [4 /*yield*/, ensureColumn('started_at', function (table) { + table.timestamp('started_at', { useTz: true }).nullable(); + })]; + case 19: + _a.sent(); + return [4 /*yield*/, ensureColumn('completed_at', function (table) { + table.timestamp('completed_at', { useTz: true }).nullable(); + })]; + case 20: + _a.sent(); + return [4 /*yield*/, ensureColumn('merged_at', function (table) { + table.timestamp('merged_at', { useTz: true }).nullable(); + })]; + case 21: + _a.sent(); + return [4 /*yield*/, ensureColumn('usage_snapshot', function (table) { + table.text('usage_snapshot').nullable(); + })]; + case 22: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ automation_type: 'plan_registration' }) + .update({ automation_type: 'plan' })]; + case 23: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ automation_type: 'general_development' }) + .update({ automation_type: 'auto_worker' })]; + case 24: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereNull('automation_type_id') + .update({ + automation_type_id: client_js_1.db.raw('automation_type'), + })]; + case 25: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function dropPlanWorkIdUniqueConstraint() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.raw("\n do $$\n declare constraint_name text;\n begin\n for constraint_name in\n select con.conname\n from pg_constraint con\n join pg_class rel on rel.oid = con.conrelid\n join pg_namespace nsp on nsp.oid = rel.relnamespace\n join pg_attribute att on att.attrelid = rel.oid and att.attnum = any(con.conkey)\n where nsp.nspname = current_schema()\n and rel.relname = '".concat(exports.PLAN_TABLE, "'\n and con.contype = 'u'\n and att.attname = 'work_id'\n loop\n execute format('alter table %I.%I drop constraint %I', current_schema(), '").concat(exports.PLAN_TABLE, "', constraint_name);\n end loop;\n end\n $$;\n "))]; + case 1: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensurePlanIssueTable() { + return __awaiter(this, void 0, void 0, function () { + var exists; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.PLAN_ISSUE_TABLE)]; + case 1: + exists = _a.sent(); + if (exists) { + return [2 /*return*/]; + } + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.PLAN_ISSUE_TABLE, function (table) { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().index(); + table.string('issue_tag', 120).notNullable(); + table.text('message').notNullable(); + table.text('action_note').nullable(); + table.boolean('resolved').notNullable().defaultTo(false); + table.timestamp('resolved_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensurePlanActionTable() { + return __awaiter(this, void 0, void 0, function () { + var exists; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.PLAN_ACTION_TABLE)]; + case 1: + exists = _a.sent(); + if (exists) { + return [2 /*return*/]; + } + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.PLAN_ACTION_TABLE, function (table) { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().index(); + table.string('action_type', 120).notNullable(); + table.text('note').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensurePlanSourceWorkTable() { + return __awaiter(this, void 0, void 0, function () { + var exists, hasPreviewUrlColumn, hasSourceFilesColumn; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.PLAN_SOURCE_WORK_TABLE)]; + case 1: + exists = _a.sent(); + if (!exists) return [3 /*break*/, 8]; + return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.PLAN_SOURCE_WORK_TABLE, 'preview_url')]; + case 2: + hasPreviewUrlColumn = _a.sent(); + if (!!hasPreviewUrlColumn) return [3 /*break*/, 4]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.PLAN_SOURCE_WORK_TABLE, function (table) { + table.string('preview_url', 2000).nullable(); + })]; + case 3: + _a.sent(); + _a.label = 4; + case 4: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.PLAN_SOURCE_WORK_TABLE, 'source_files')]; + case 5: + hasSourceFilesColumn = _a.sent(); + if (!!hasSourceFilesColumn) return [3 /*break*/, 7]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.PLAN_SOURCE_WORK_TABLE, function (table) { + table.text('source_files').notNullable().defaultTo('[]'); + })]; + case 6: + _a.sent(); + _a.label = 7; + case 7: return [2 /*return*/]; + case 8: return [4 /*yield*/, client_js_1.db.schema.createTable(exports.PLAN_SOURCE_WORK_TABLE, function (table) { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().index(); + table.text('summary').notNullable(); + table.string('branch_name', 200).notNullable(); + table.string('commit_hash', 80).nullable(); + table.string('preview_url', 2000).nullable(); + table.text('changed_files').notNullable().defaultTo('[]'); + table.text('command_log').nullable(); + table.text('diff_text').nullable(); + table.text('source_files').notNullable().defaultTo('[]'); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 9: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensurePlanReleaseReviewTable() { + return __awaiter(this, void 0, void 0, function () { + var exists, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.PLAN_RELEASE_REVIEW_TABLE)]; + case 1: + exists = _b.sent(); + if (!!exists) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.PLAN_RELEASE_REVIEW_TABLE, function (table) { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().unique().index(); + table.string('status', 40).notNullable().defaultTo('pending'); + table.text('review_note').notNullable().defaultTo(''); + table.string('checked_by_client_id', 120).nullable(); + table.string('checked_by_nickname', 80).nullable(); + table.timestamp('checked_at', { useTz: true }).nullable(); + table.text('metadata').notNullable().defaultTo('{}'); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['plan_item_id', function (table) { return table.integer('plan_item_id').notNullable().unique().index(); }], + ['status', function (table) { return table.string('status', 40).notNullable().defaultTo('pending'); }], + ['review_note', function (table) { return table.text('review_note').notNullable().defaultTo(''); }], + ['checked_by_client_id', function (table) { return table.string('checked_by_client_id', 120).nullable(); }], + ['checked_by_nickname', function (table) { return table.string('checked_by_nickname', 80).nullable(); }], + ['checked_at', function (table) { return table.timestamp('checked_at', { useTz: true }).nullable(); }], + ['metadata', function (table) { return table.text('metadata').notNullable().defaultTo('{}'); }], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.PLAN_RELEASE_REVIEW_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.PLAN_RELEASE_REVIEW_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function ensurePlanTable() { + return __awaiter(this, void 0, void 0, function () { + var exists, error_1, dbError; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.PLAN_TABLE)]; + case 1: + exists = _a.sent(); + if (!!exists) return [3 /*break*/, 5]; + _a.label = 2; + case 2: + _a.trys.push([2, 4, , 5]); + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.PLAN_TABLE, function (table) { + table.increments('id').primary(); + table.string('work_id', 120).notNullable().unique(); + table.text('note').notNullable().defaultTo(''); + table.string('status', 40).notNullable().defaultTo('등록'); + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + table.boolean('suppress_web_push').notNullable().defaultTo(false); + table.string('assigned_branch', 200).nullable(); + table.string('release_target', 120).notNullable().defaultTo('release'); + table.string('worker_status', 80).nullable(); + table.text('last_error').nullable(); + table.string('locked_by', 120).nullable(); + table.timestamp('locked_at', { useTz: true }).nullable(); + table.timestamp('started_at', { useTz: true }).nullable(); + table.timestamp('completed_at', { useTz: true }).nullable(); + table.timestamp('merged_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 3: + _a.sent(); + return [3 /*break*/, 5]; + case 4: + error_1 = _a.sent(); + dbError = error_1; + if (dbError.code !== '42P07' && dbError.code !== '23505') { + throw error_1; + } + return [3 /*break*/, 5]; + case 5: return [4 /*yield*/, syncPlanColumns()]; + case 6: + _a.sent(); + return [4 /*yield*/, dropPlanWorkIdUniqueConstraint()]; + case 7: + _a.sent(); + return [4 /*yield*/, ensurePlanIssueTable()]; + case 8: + _a.sent(); + return [4 /*yield*/, ensurePlanActionTable()]; + case 9: + _a.sent(); + return [4 /*yield*/, ensurePlanSourceWorkTable()]; + case 10: + _a.sent(); + return [4 /*yield*/, ensurePlanReleaseReviewTable()]; + case 11: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereNull('jangsing_processing_required') + .update({ + jangsing_processing_required: true, + })]; + case 12: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereNull('suppress_web_push') + .update({ + suppress_web_push: false, + })]; + case 13: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereIn('status', ['작업완료', '릴리즈완료', '완료']) + .whereNull('completed_at') + .update({ + completed_at: client_js_1.db.raw('coalesce(merged_at, updated_at, created_at)'), + updated_at: client_js_1.db.fn.now(), + })]; + case 14: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ status: '이슈' }) + .update({ + status: '등록', + worker_status: client_js_1.db.raw("coalesce(worker_status, '브랜치실패')"), + updated_at: client_js_1.db.fn.now(), + })]; + case 15: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ status: '개발완료' }) + .update({ + status: '작업완료', + worker_status: client_js_1.db.raw("case when worker_status in ('병합대기','병합중') then 'release반영대기' when worker_status='병합완료' then 'main반영완료' else worker_status end"), + updated_at: client_js_1.db.fn.now(), + })]; + case 16: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereIn('worker_status', ['release반영완료', 'main반영대기', 'main반영중', 'main반영실패']) + .whereNot({ status: '완료' }) + .update({ + status: '릴리즈완료', + updated_at: client_js_1.db.fn.now(), + })]; + case 17: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function createPlanItem(payload) { + return __awaiter(this, void 0, void 0, function () { + var workId, automationType, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + workId = normalizePlanWorkId(payload.workId); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)(payload.automationType)]; + case 2: + automationType = _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .insert({ + work_id: workId, + note: payload.note, + status: '등록', + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(payload.automationContextIds), + release_target: payload.releaseTarget, + jangsing_processing_required: payload.jangsingProcessingRequired, + auto_deploy_to_main: payload.autoDeployToMain, + suppress_web_push: payload.suppressWebPush, + repeat_request_enabled: payload.repeatRequestEnabled, + repeat_interval_minutes: payload.repeatIntervalMinutes, + worker_status: '대기', + last_error: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function createCompletedPlanExecutionLogItem(payload) { + return __awaiter(this, void 0, void 0, function () { + var workId, automationType, rows; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _b.sent(); + workId = normalizePlanWorkId(payload.workId); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)(payload.automationType)]; + case 2: + automationType = _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .insert({ + work_id: workId, + note: payload.note, + status: '완료', + automation_type: automationType.behaviorType, + automation_type_id: automationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(payload.automationContextIds), + release_target: payload.releaseTarget, + jangsing_processing_required: payload.jangsingProcessingRequired, + auto_deploy_to_main: payload.autoDeployToMain, + suppress_web_push: payload.suppressWebPush, + repeat_request_enabled: payload.repeatRequestEnabled, + repeat_interval_minutes: payload.repeatIntervalMinutes, + worker_status: '자동완료', + last_error: null, + started_at: client_js_1.db.fn.now(), + completed_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _b.sent(); + if (!((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.id)) return [3 /*break*/, 5]; + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(Number(rows[0].id))]; + case 4: + _b.sent(); + _b.label = 5; + case 5: return [2 /*return*/, rows[0]]; + } + }); + }); +} +function upsertAutoPlanItem(args) { + return __awaiter(this, void 0, void 0, function () { + var workId, existingRow, automationType, nextReleaseTarget, nextAutomationType, nextJangsingProcessingRequired, nextAutoDeployToMain, nextSuppressWebPush, nextNote, nextAutomationContextIds, currentJangsingProcessingRequired, hasPayloadChange, rows_2, rows; + var _a; + var _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o; + return __generator(this, function (_p) { + switch (_p.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _p.sent(); + workId = normalizePlanWorkId(args.workId); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ work_id: workId }) + .orderBy('id', 'desc') + .first()]; + case 2: + existingRow = _p.sent(); + if (!!existingRow) return [3 /*break*/, 5]; + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)(args.automationType)]; + case 3: + automationType = _p.sent(); + _a = {}; + return [4 /*yield*/, createPlanItem({ + workId: workId, + note: args.note, + automationType: automationType.id, + automationContextIds: (_b = args.automationContextIds) !== null && _b !== void 0 ? _b : [], + releaseTarget: args.releaseTarget, + jangsingProcessingRequired: args.jangsingProcessingRequired, + autoDeployToMain: args.autoDeployToMain, + suppressWebPush: (_c = args.suppressWebPush) !== null && _c !== void 0 ? _c : false, + repeatRequestEnabled: false, + repeatIntervalMinutes: 60, + })]; + case 4: return [2 /*return*/, (_a.row = _p.sent(), + _a.action = 'created', + _a)]; + case 5: + nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release'; + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)((_e = (_d = args.automationType) !== null && _d !== void 0 ? _d : existingRow.automation_type_id) !== null && _e !== void 0 ? _e : existingRow.automation_type)]; + case 6: + nextAutomationType = _p.sent(); + nextJangsingProcessingRequired = args.jangsingProcessingRequired; + nextAutoDeployToMain = args.autoDeployToMain; + nextSuppressWebPush = (_f = args.suppressWebPush) !== null && _f !== void 0 ? _f : Boolean((_g = existingRow.suppress_web_push) !== null && _g !== void 0 ? _g : false); + nextNote = args.note; + nextAutomationContextIds = (_h = args.automationContextIds) !== null && _h !== void 0 ? _h : (0, automation_context_service_js_1.parseAutomationContextIds)(existingRow.automation_context_ids_json); + currentJangsingProcessingRequired = typeof existingRow.jangsing_processing_required === 'boolean' + ? existingRow.jangsing_processing_required + : existingRow.normal_processing_level === '상'; + hasPayloadChange = existingRow.note !== nextNote || + String((_k = (_j = existingRow.automation_type_id) !== null && _j !== void 0 ? _j : existingRow.automation_type) !== null && _k !== void 0 ? _k : 'none') !== nextAutomationType.id || + ((_l = existingRow.release_target) !== null && _l !== void 0 ? _l : 'release') !== nextReleaseTarget || + Boolean((_m = existingRow.auto_deploy_to_main) !== null && _m !== void 0 ? _m : true) !== nextAutoDeployToMain || + Boolean((_o = existingRow.suppress_web_push) !== null && _o !== void 0 ? _o : false) !== nextSuppressWebPush || + JSON.stringify((0, automation_context_service_js_1.parseAutomationContextIds)(existingRow.automation_context_ids_json)) !== JSON.stringify(nextAutomationContextIds) || + Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired; + if (!!args.requeue) return [3 /*break*/, 8]; + if (!hasPayloadChange || existingRow.status !== '등록') { + return [2 /*return*/, { + row: existingRow, + action: 'unchanged', + }]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: existingRow.id }) + .update({ + note: nextNote, + automation_type: nextAutomationType.behaviorType, + automation_type_id: nextAutomationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(nextAutomationContextIds), + release_target: nextReleaseTarget, + jangsing_processing_required: nextJangsingProcessingRequired, + auto_deploy_to_main: nextAutoDeployToMain, + suppress_web_push: nextSuppressWebPush, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 7: + rows_2 = _p.sent(); + return [2 /*return*/, { + row: rows_2[0], + action: 'updated', + }]; + case 8: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: existingRow.id }) + .update({ + work_id: workId, + note: nextNote, + status: '등록', + assigned_branch: null, + automation_type: nextAutomationType.behaviorType, + automation_type_id: nextAutomationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(nextAutomationContextIds), + release_target: nextReleaseTarget, + jangsing_processing_required: nextJangsingProcessingRequired, + auto_deploy_to_main: nextAutoDeployToMain, + suppress_web_push: nextSuppressWebPush, + worker_status: '대기', + last_error: null, + locked_by: null, + locked_at: null, + started_at: null, + completed_at: null, + merged_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 9: + rows = _p.sent(); + return [4 /*yield*/, createPlanActionHistory(Number(existingRow.id), '자동갱신', '업무일지 자동화 설정 기준으로 Plan 항목을 최신 상태로 갱신했습니다.')]; + case 10: + _p.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(Number(existingRow.id))]; + case 11: + _p.sent(); + return [2 /*return*/, { + row: rows[0], + action: 'requeued', + }]; + } + }); + }); +} +function updatePlanItem(id, payload) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, nextWorkId, nextAutomationType, nextReleaseTarget, nextJangsingProcessingRequired, nextAutoDeployToMain, nextSuppressWebPush, nextRepeatRequestEnabled, nextRepeatIntervalMinutes, nextNote, nextAutomationContextIds, isOnlyJangsingUpdate, rows; + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x; + return __generator(this, function (_y) { + switch (_y.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _y.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _y.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + nextWorkId = payload.workId === undefined ? currentRow.work_id : normalizePlanWorkId(payload.workId); + return [4 /*yield*/, (0, automation_type_config_service_js_1.resolveAutomationType)((_b = (_a = payload.automationType) !== null && _a !== void 0 ? _a : currentRow.automation_type_id) !== null && _b !== void 0 ? _b : currentRow.automation_type)]; + case 3: + nextAutomationType = _y.sent(); + nextReleaseTarget = (_d = (_c = payload.releaseTarget) !== null && _c !== void 0 ? _c : currentRow.release_target) !== null && _d !== void 0 ? _d : 'release'; + nextJangsingProcessingRequired = (_e = payload.jangsingProcessingRequired) !== null && _e !== void 0 ? _e : (typeof currentRow.jangsing_processing_required === 'boolean' + ? currentRow.jangsing_processing_required + : currentRow.normal_processing_level === '상'); + nextAutoDeployToMain = (_g = (_f = payload.autoDeployToMain) !== null && _f !== void 0 ? _f : currentRow.auto_deploy_to_main) !== null && _g !== void 0 ? _g : true; + nextSuppressWebPush = (_j = (_h = payload.suppressWebPush) !== null && _h !== void 0 ? _h : currentRow.suppress_web_push) !== null && _j !== void 0 ? _j : false; + nextRepeatRequestEnabled = (_l = (_k = payload.repeatRequestEnabled) !== null && _k !== void 0 ? _k : currentRow.repeat_request_enabled) !== null && _l !== void 0 ? _l : false; + nextRepeatIntervalMinutes = (_o = (_m = payload.repeatIntervalMinutes) !== null && _m !== void 0 ? _m : currentRow.repeat_interval_minutes) !== null && _o !== void 0 ? _o : 60; + nextNote = (_p = payload.note) !== null && _p !== void 0 ? _p : currentRow.note; + nextAutomationContextIds = (_q = payload.automationContextIds) !== null && _q !== void 0 ? _q : (0, automation_context_service_js_1.parseAutomationContextIds)(currentRow.automation_context_ids_json); + isOnlyJangsingUpdate = nextWorkId === currentRow.work_id && + nextAutomationType.id === String((_s = (_r = currentRow.automation_type_id) !== null && _r !== void 0 ? _r : currentRow.automation_type) !== null && _s !== void 0 ? _s : 'none') && + nextReleaseTarget === ((_t = currentRow.release_target) !== null && _t !== void 0 ? _t : 'release') && + nextAutoDeployToMain === ((_u = currentRow.auto_deploy_to_main) !== null && _u !== void 0 ? _u : true) && + nextSuppressWebPush === ((_v = currentRow.suppress_web_push) !== null && _v !== void 0 ? _v : false) && + nextRepeatRequestEnabled === ((_w = currentRow.repeat_request_enabled) !== null && _w !== void 0 ? _w : false) && + nextRepeatIntervalMinutes === ((_x = currentRow.repeat_interval_minutes) !== null && _x !== void 0 ? _x : 60) && + JSON.stringify((0, automation_context_service_js_1.parseAutomationContextIds)(currentRow.automation_context_ids_json)) === JSON.stringify(nextAutomationContextIds) && + nextNote === currentRow.note; + if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) { + return [2 /*return*/, updatePlanItemJangsingProcessingRequired(id, nextJangsingProcessingRequired)]; + } + if (currentRow.started_at || currentRow.status !== '등록') { + throw new Error('작업시작 이후에는 원본 요청을 수정할 수 없습니다. 추가 조치사항은 이력에 기록해 주세요.'); + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + work_id: nextWorkId, + note: nextNote, + status: currentRow.status, + automation_type: nextAutomationType.behaviorType, + automation_type_id: nextAutomationType.id, + automation_context_ids_json: (0, automation_context_service_js_1.stringifyAutomationContextIds)(nextAutomationContextIds), + release_target: nextReleaseTarget, + jangsing_processing_required: nextJangsingProcessingRequired, + auto_deploy_to_main: nextAutoDeployToMain, + suppress_web_push: nextSuppressWebPush, + repeat_request_enabled: nextRepeatRequestEnabled, + repeat_interval_minutes: nextRepeatIntervalMinutes, + worker_status: currentRow.worker_status, + last_error: currentRow.last_error, + completed_at: currentRow.completed_at, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 4: + rows = _y.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function updatePlanItemJangsingProcessingRequired(id, jangsingProcessingRequired) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, rows; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _b.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + if (!functionCheckEditableStatuses.has(currentRow.status)) { + throw new Error('기능동작확인은 작업완료 건만 수정할 수 있습니다.'); + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + jangsing_processing_required: jangsingProcessingRequired, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _b.sent(); + return [2 /*return*/, (_a = rows[0]) !== null && _a !== void 0 ? _a : null]; + } + }); + }); +} +function deletePlanItem(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(exports.PLAN_SOURCE_WORK_TABLE).where({ plan_item_id: id }).delete()]; + case 1: + _a.sent(); + return [4 /*yield*/, trx(exports.PLAN_ACTION_TABLE).where({ plan_item_id: id }).delete()]; + case 2: + _a.sent(); + return [4 /*yield*/, trx(exports.PLAN_ISSUE_TABLE).where({ plan_item_id: id }).delete()]; + case 3: + _a.sent(); + return [4 /*yield*/, trx(exports.PLAN_TABLE).where({ id: id }).delete()]; + case 4: + _a.sent(); + return [2 /*return*/]; + } + }); + }); })]; + case 3: + _a.sent(); + return [2 /*return*/, currentRow]; + } + }); + }); +} +function getBoardPostLinkedToPlanItem(planItemId) { + return __awaiter(this, void 0, void 0, function () { + var boardPostsTable, boardPostRequestsTable, hasBoardPostsTable, hasBoardPostRequestsTable, linkedRequestRow; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + boardPostsTable = 'board_posts'; + boardPostRequestsTable = 'board_post_requests'; + return [4 /*yield*/, client_js_1.db.schema.hasTable(boardPostsTable)]; + case 1: + hasBoardPostsTable = _a.sent(); + if (!hasBoardPostsTable) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, client_js_1.db.schema.hasTable(boardPostRequestsTable)]; + case 2: + hasBoardPostRequestsTable = _a.sent(); + if (!hasBoardPostRequestsTable) return [3 /*break*/, 4]; + return [4 /*yield*/, (0, client_js_1.db)(boardPostRequestsTable) + .join(boardPostsTable, "".concat(boardPostRequestsTable, ".board_post_id"), "".concat(boardPostsTable, ".id")) + .select("".concat(boardPostsTable, ".id"), "".concat(boardPostsTable, ".title")) + .where("".concat(boardPostRequestsTable, ".plan_item_id"), planItemId) + .first()]; + case 3: + linkedRequestRow = _a.sent(); + if (linkedRequestRow) { + return [2 /*return*/, linkedRequestRow]; + } + _a.label = 4; + case 4: return [2 /*return*/, (0, client_js_1.db)(boardPostsTable).select('id', 'title').where({ automation_plan_item_id: planItemId }).first()]; + } + }); + }); +} +function markPlanAsDevelopmentComplete(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, rows; + var _a, _b, _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _e.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _e.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + status: '작업완료', + jangsing_processing_required: true, + worker_status: 'release반영대기', + last_error: null, + locked_by: null, + locked_at: null, + completed_at: (_a = currentRow.completed_at) !== null && _a !== void 0 ? _a : client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _e.sent(); + return [4 /*yield*/, createPlanLifecycleSourceWorkHistory(id, '작업완료 처리로 release 반영 대기 상태로 전환했습니다.', (_c = (_b = currentRow.assigned_branch) !== null && _b !== void 0 ? _b : currentRow.release_target) !== null && _c !== void 0 ? _c : 'release', currentRow.assigned_branch + ? "\uD604\uC7AC \uC791\uC5C5 \uBE0C\uB79C\uCE58: ".concat(currentRow.assigned_branch) + : "release \uB300\uAE30 \uBE0C\uB79C\uCE58: ".concat((_d = currentRow.release_target) !== null && _d !== void 0 ? _d : 'release'))]; + case 4: + _e.sent(); + return [4 /*yield*/, createPlanActionHistory(id, '작업완료', '작업완료 처리')]; + case 5: + _e.sent(); + return [4 /*yield*/, resolveAutomationIssueHistories(id)]; + case 6: + _e.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 7: + _e.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function markPlanAsCompleted(id_1, note_1) { + return __awaiter(this, arguments, void 0, function (id, note, workerStatus) { + var currentRow, rows, sourceWorkCountRow, sourceWorkCount; + var _a, _b, _c, _d, _e; + if (workerStatus === void 0) { workerStatus = '자동완료'; } + return __generator(this, function (_f) { + switch (_f.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _f.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _f.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + status: '완료', + worker_status: workerStatus, + last_error: null, + locked_by: null, + locked_at: null, + completed_at: (_a = currentRow.completed_at) !== null && _a !== void 0 ? _a : client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _f.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .where({ plan_item_id: id }) + .count('id as count') + .first()]; + case 4: + sourceWorkCountRow = _f.sent(); + sourceWorkCount = Number((_b = sourceWorkCountRow === null || sourceWorkCountRow === void 0 ? void 0 : sourceWorkCountRow.count) !== null && _b !== void 0 ? _b : 0); + if (!(sourceWorkCount === 0 && !shouldSkipLifecycleSourceWork(currentRow))) return [3 /*break*/, 6]; + return [4 /*yield*/, createPlanLifecycleSourceWorkHistory(id, note !== null && note !== void 0 ? note : '작업 결과를 검토하고 완료 처리했습니다.', (_d = (_c = currentRow.assigned_branch) !== null && _c !== void 0 ? _c : currentRow.release_target) !== null && _d !== void 0 ? _d : (0, env_js_1.getEnv)().PLAN_MAIN_BRANCH, currentRow.assigned_branch + ? "\uC644\uB8CC \uAE30\uC900 \uBE0C\uB79C\uCE58: ".concat(currentRow.assigned_branch) + : "\uC644\uB8CC \uAE30\uC900 \uBE0C\uB79C\uCE58: ".concat((_e = currentRow.release_target) !== null && _e !== void 0 ? _e : (0, env_js_1.getEnv)().PLAN_MAIN_BRANCH))]; + case 5: + _f.sent(); + _f.label = 6; + case 6: return [4 /*yield*/, createPlanActionHistory(id, '완료처리', note !== null && note !== void 0 ? note : '작업을 완료 처리했습니다.')]; + case 7: + _f.sent(); + return [4 /*yield*/, resolveAutomationIssueHistories(id)]; + case 8: + _f.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 9: + _f.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function markPlanAsStarted(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, rows; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _b.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + status: '작업중', + worker_status: currentRow.worker_status === '대기' ? null : currentRow.worker_status, + last_error: currentRow.last_error, + started_at: (_a = currentRow.started_at) !== null && _a !== void 0 ? _a : client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _b.sent(); + return [4 /*yield*/, createPlanActionHistory(id, '작업시작', '작업을 시작했습니다.')]; + case 4: + _b.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 5: + _b.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function retryPlanBranch(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + status: '등록', + worker_status: '대기', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _a.sent(); + return [4 /*yield*/, createPlanActionHistory(id, '브랜치재시도', '브랜치 재시도를 요청했습니다.')]; + case 4: + _a.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 5: + _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function retryPlanMerge(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, isMainRetry, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + isMainRetry = currentRow.worker_status === 'main반영실패' || + currentRow.worker_status === 'main반영대기' || + currentRow.worker_status === 'main반영중' || + currentRow.status === '릴리즈완료'; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + status: isMainRetry ? '릴리즈완료' : '작업완료', + worker_status: isMainRetry ? 'main반영대기' : 'release반영대기', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _a.sent(); + return [4 /*yield*/, createPlanActionHistory(id, isMainRetry ? 'main반영재시도' : 'release반영재시도', isMainRetry ? 'main 일괄 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.')]; + case 4: + _a.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 5: + _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function requestPlanMainMerge(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, releaseTarget, pendingRows, targetIds; + var _a, _b; + var _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _d.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + if (!(currentRow.status !== '릴리즈완료')) return [3 /*break*/, 4]; + _a = {}; + return [4 /*yield*/, getPlanItemById(id)]; + case 3: return [2 /*return*/, (_a.item = _d.sent(), + _a.message = 'main 반영 요청은 release 반영이 완료된 이후에만 가능합니다.', + _a)]; + case 4: + releaseTarget = String((_c = currentRow.release_target) !== null && _c !== void 0 ? _c : 'release'); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .select('id') + .where({ status: '릴리즈완료', release_target: releaseTarget })]; + case 5: + pendingRows = _d.sent(); + targetIds = pendingRows.map(function (row) { return Number(row.id); }).filter(Number.isFinite); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereIn('id', targetIds.length > 0 ? targetIds : [id]) + .update({ + last_error: null, + locked_by: null, + locked_at: null, + worker_status: 'main반영대기', + updated_at: client_js_1.db.fn.now(), + })]; + case 6: + _d.sent(); + return [4 /*yield*/, createPlanActionHistory(id, 'main반영요청', "".concat(releaseTarget, " \uBE0C\uB79C\uCE58 \uAE30\uC900 main \uC77C\uAD04 \uBC18\uC601\uC744 \uC694\uCCAD\uD588\uC2B5\uB2C8\uB2E4."))]; + case 7: + _d.sent(); + return [4 /*yield*/, Promise.all((targetIds.length > 0 ? targetIds : [id]).map(function (targetId) { return syncPlanAutomationUsageSnapshot(targetId); }))]; + case 8: + _d.sent(); + _b = {}; + return [4 /*yield*/, getPlanItemById(id)]; + case 9: return [2 /*return*/, (_b.item = _d.sent(), + _b.message = "".concat(releaseTarget, " \uBE0C\uB79C\uCE58 \uAE30\uC900\uC73C\uB85C ").concat(Math.max(targetIds.length, 1), "\uAC74 main \uC77C\uAD04 \uBC18\uC601\uC744 \uC694\uCCAD\uD588\uC2B5\uB2C8\uB2E4."), + _b)]; + } + }); + }); +} +function retryPlanWork(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + status: '작업중', + worker_status: '브랜치준비', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _a.sent(); + return [4 /*yield*/, createPlanActionHistory(id, '작업재처리', '자동 작업 재처리를 요청했습니다.')]; + case 4: + _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function isPlanLockedByWorker(id, workerId) { + return __awaiter(this, void 0, void 0, function () { + var row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .select('id') + .where({ id: id, locked_by: workerId }) + .first()]; + case 2: + row = _a.sent(); + return [2 /*return*/, Boolean(row)]; + } + }); + }); +} +function resumePlanDevelopmentFromRelease(id, actionNote) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, rows; + var _a, _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _c.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _c.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + if (!(currentRow.status !== '릴리즈완료')) return [3 /*break*/, 4]; + _a = { + didScheduleRetry: false + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 3: return [2 /*return*/, (_a.item = _c.sent(), + _a.message = null, + _a)]; + case 4: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .update({ + status: '작업중', + worker_status: '브랜치준비', + last_error: null, + locked_by: null, + locked_at: null, + merged_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 5: + rows = _c.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 6: + _c.sent(); + return [4 /*yield*/, createPlanActionHistory(id, '릴리즈추가조치', "release \uC0C1\uD0DC \uCD94\uAC00 \uC870\uCE58\uB85C \uAC1C\uBC1C\uC744 \uC7AC\uAC1C\uD588\uC2B5\uB2C8\uB2E4.\n".concat(actionNote))]; + case 7: + _c.sent(); + _b = { + didScheduleRetry: false + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 8: return [2 /*return*/, (_b.item = _c.sent(), + _b.message = 'release 상태의 추가 조치를 반영해 추가 개발로 전환했습니다.', + _b.row = rows[0], + _b)]; + } + }); + }); +} +function queuePlanRetryFromFailure(id, actionNote) { + return __awaiter(this, void 0, void 0, function () { + var currentRow; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _b.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + _a = { + didScheduleRetry: false + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 3: return [2 /*return*/, (_a.item = _b.sent(), + _a.message = (0, plan_retry_policy_js_1.shouldTriggerRetryFromActionNote)(actionNote !== null && actionNote !== void 0 ? actionNote : '') + ? '조치 이력을 저장했습니다. 자동 재시작은 하지 않았습니다. 필요하면 재시도 액션을 직접 실행해 주세요.' + : '조치 이력을 저장했습니다. 자동화는 중단된 상태로 유지합니다.', + _a)]; + } + }); + }); +} +function shouldResumePlanDevelopmentFromIssueAction(status, retry) { + return retry && status === '릴리즈완료'; +} +function queuePlanRetryForCurrentStatus(id, workerStatus) { + return __awaiter(this, void 0, void 0, function () { + var _a; + var _b, _c, _d, _e, _f; + return __generator(this, function (_g) { + switch (_g.label) { + case 0: + if (!(workerStatus === '자동작업중')) return [3 /*break*/, 3]; + return [4 /*yield*/, retryPlanWork(id)]; + case 1: + _g.sent(); + _b = { + didScheduleRetry: true + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 2: return [2 /*return*/, (_b.item = _g.sent(), + _b.message = '실행 중인 자동 작업을 정리하고 이슈 조치를 반영하도록 다시 실행합니다.', + _b)]; + case 3: + if (!(workerStatus === '브랜치실패')) return [3 /*break*/, 6]; + return [4 /*yield*/, retryPlanBranch(id)]; + case 4: + _g.sent(); + _c = { + didScheduleRetry: true + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 5: return [2 /*return*/, (_c.item = _g.sent(), + _c.message = '이슈 조치를 반영해 브랜치 재시도를 예약했습니다.', + _c)]; + case 6: + if (!(workerStatus === '자동작업실패')) return [3 /*break*/, 9]; + return [4 /*yield*/, retryPlanWork(id)]; + case 7: + _g.sent(); + _d = { + didScheduleRetry: true + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 8: return [2 /*return*/, (_d.item = _g.sent(), + _d.message = '이슈 조치를 반영해 자동 작업 재처리를 예약했습니다.', + _d)]; + case 9: + if (!(workerStatus === 'release반영실패')) return [3 /*break*/, 12]; + return [4 /*yield*/, retryPlanMerge(id)]; + case 10: + _g.sent(); + _e = { + didScheduleRetry: true + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 11: return [2 /*return*/, (_e.item = _g.sent(), + _e.message = '이슈 조치를 반영해 release 반영 재처리를 예약했습니다.', + _e)]; + case 12: + if (!(workerStatus === 'main반영실패')) return [3 /*break*/, 14]; + _a = [{ didScheduleRetry: true }]; + return [4 /*yield*/, requestPlanMainMerge(id)]; + case 13: return [2 /*return*/, __assign.apply(void 0, _a.concat([(_g.sent())]))]; + case 14: + _f = { + didScheduleRetry: false + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 15: return [2 /*return*/, (_f.item = _g.sent(), + _f.message = '이슈 조치 이력을 저장했고 자동 재처리 대상은 없습니다.', + _f)]; + } + }); + }); +} +function queuePlanRetryFromIssueAction(id_1, actionNote_1) { + return __awaiter(this, arguments, void 0, function (id, actionNote, retry) { + var currentRow, resumeResult; + var _a; + if (retry === void 0) { retry = false; } + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _b.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + if (!!retry) return [3 /*break*/, 4]; + _a = { + didScheduleRetry: false + }; + return [4 /*yield*/, getPlanItemById(id)]; + case 3: return [2 /*return*/, (_a.item = _b.sent(), + _a.message = '이슈 조치 이력을 저장했습니다. 재처리는 요청하지 않았습니다.', + _a)]; + case 4: + if (!shouldResumePlanDevelopmentFromIssueAction(currentRow.status, retry)) return [3 /*break*/, 6]; + return [4 /*yield*/, resumePlanDevelopmentFromRelease(id, actionNote)]; + case 5: + resumeResult = _b.sent(); + return [2 /*return*/, __assign(__assign({}, resumeResult), { didScheduleRetry: Boolean(resumeResult === null || resumeResult === void 0 ? void 0 : resumeResult.message) })]; + case 6: return [2 /*return*/, queuePlanRetryForCurrentStatus(id, currentRow.worker_status)]; + } + }); + }); +} +function cancelPlanRelease(id) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, releaseTarget, sourceWorkCountRow, sourceWorkCount, isReleaseMergeFailure, skippedRollbackBecauseNoSourceWork, targetRows, _a, targetIds, historyMessage, resultMessage, _i, targetIds_1, targetId, issueRow; + var _b, _c; + var _d, _e; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _f.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 2: + currentRow = _f.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + releaseTarget = (_d = currentRow.release_target) !== null && _d !== void 0 ? _d : 'release'; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .where({ plan_item_id: id }) + .count('id as count') + .first()]; + case 3: + sourceWorkCountRow = _f.sent(); + sourceWorkCount = Math.max(0, Number((_e = sourceWorkCountRow === null || sourceWorkCountRow === void 0 ? void 0 : sourceWorkCountRow.count) !== null && _e !== void 0 ? _e : 0) || 0); + isReleaseMergeFailure = currentRow.status === '작업완료' && currentRow.worker_status === 'release반영실패'; + skippedRollbackBecauseNoSourceWork = sourceWorkCount === 0 && + (currentRow.status === '릴리즈완료' || currentRow.worker_status === 'main반영실패'); + if (!isReleaseMergeFailure) return [3 /*break*/, 4]; + _a = []; + return [3 /*break*/, 6]; + case 4: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .select('*') + .where({ release_target: releaseTarget, status: '릴리즈완료' })]; + case 5: + _a = _f.sent(); + _f.label = 6; + case 6: + targetRows = _a; + targetIds = isReleaseMergeFailure + ? [id] + : __spreadArray([], new Set(targetRows.map(function (row) { return Number(row.id); }).concat(id)), true); + historyMessage = isReleaseMergeFailure + ? "release(".concat(releaseTarget, ") \uBC18\uC601 \uC2E4\uD328 \uC0C1\uD0DC\uC5D0\uC11C \uC791\uC5C5\uC744 \uCDE8\uC18C \uCC98\uB9AC\uD588\uC2B5\uB2C8\uB2E4.") + : skippedRollbackBecauseNoSourceWork + ? "\uC18C\uC2A4 \uC791\uC5C5 \uC99D\uC801\uC774 \uC5C6\uC5B4 release(".concat(releaseTarget, ") \uB864\uBC31 \uC5C6\uC774 \uC791\uC5C5\uC744 \uCDE8\uC18C \uCC98\uB9AC\uD588\uC2B5\uB2C8\uB2E4.") + : "release(".concat(releaseTarget, ") \uBC30\uD3EC \uB0B4\uC5ED\uC744 \uB864\uBC31\uD558\uACE0 \uC791\uC5C5\uC744 \uCDE8\uC18C \uCC98\uB9AC\uD588\uC2B5\uB2C8\uB2E4."); + resultMessage = isReleaseMergeFailure + ? "release(".concat(releaseTarget, ") \uBC18\uC601 \uC2E4\uD328 \uC0C1\uD0DC\uC5D0\uC11C \uC791\uC5C5\uCDE8\uC18C\uB85C \uC644\uB8CC \uCC98\uB9AC\uD588\uC2B5\uB2C8\uB2E4.") + : skippedRollbackBecauseNoSourceWork + ? "\uC18C\uC2A4 \uC791\uC5C5 \uC99D\uC801\uC774 \uC5C6\uC5B4 rollback \uC5C6\uC774 \uC791\uC5C5\uCDE8\uC18C\uB85C \uC644\uB8CC \uCC98\uB9AC\uD588\uC2B5\uB2C8\uB2E4." + : "release(".concat(releaseTarget, ") \uBC30\uD3EC \uB0B4\uC5ED\uC744 \uB864\uBC31\uD558\uACE0 \uC791\uC5C5\uCDE8\uC18C\uB85C \uC644\uB8CC \uCC98\uB9AC\uD588\uC2B5\uB2C8\uB2E4."); + if (!(targetIds.length === 0)) return [3 /*break*/, 8]; + _b = {}; + return [4 /*yield*/, getPlanItemById(id)]; + case 7: return [2 /*return*/, (_b.item = _f.sent(), + _b.message = '취소할 release 반영 이력이 없습니다.', + _b)]; + case 8: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereIn('id', targetIds) + .update({ + status: '완료', + worker_status: '작업취소', + last_error: null, + locked_by: null, + locked_at: null, + completed_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + })]; + case 9: + _f.sent(); + _i = 0, targetIds_1 = targetIds; + _f.label = 10; + case 10: + if (!(_i < targetIds_1.length)) return [3 /*break*/, 16]; + targetId = targetIds_1[_i]; + return [4 /*yield*/, createPlanActionHistory(targetId, '작업취소', historyMessage)]; + case 11: + _f.sent(); + return [4 /*yield*/, createPlanIssueHistory(targetId, '작업취소', historyMessage)]; + case 12: + issueRow = _f.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .where({ id: issueRow.id }) + .update({ + resolved: true, + resolved_at: client_js_1.db.fn.now(), + })]; + case 13: + _f.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(targetId)]; + case 14: + _f.sent(); + _f.label = 15; + case 15: + _i++; + return [3 /*break*/, 10]; + case 16: + _c = {}; + return [4 /*yield*/, getPlanItemById(id)]; + case 17: return [2 /*return*/, (_c.item = _f.sent(), + _c.message = resultMessage, + _c)]; + } + }); + }); +} +function createPlanActionHistory(planItemId, actionType, note) { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ACTION_TABLE) + .insert({ + plan_item_id: planItemId, + action_type: actionType, + note: note, + }) + .returning('*')]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function resolveAutomationIssueHistories(planItemId) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .where({ plan_item_id: planItemId, resolved: false }) + .whereIn('issue_tag', __spreadArray([], automationIssueTags, true)) + .update({ + resolved: true, + resolved_at: client_js_1.db.fn.now(), + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function listPlanActionHistories(planItemId) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [2 /*return*/, (0, client_js_1.db)(exports.PLAN_ACTION_TABLE) + .select('*') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc')]; + } + }); + }); +} +function createPlanSourceWorkHistory(planItemId, payload) { + return __awaiter(this, void 0, void 0, function () { + var rawChangedFiles, changedFiles, filteredChangedFiles, rows_3, existingChangedFilesList, rows; + var _a, _b, _c, _d, _e, _f; + return __generator(this, function (_g) { + switch (_g.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _g.sent(); + rawChangedFiles = (_a = payload.changedFiles) !== null && _a !== void 0 ? _a : []; + changedFiles = normalizeChangedFiles(rawChangedFiles); + filteredChangedFiles = changedFiles; + if (!changedFiles.some(function (file) { return WORKLOG_EVIDENCE_PATH_PATTERN.test(file); })) return [3 /*break*/, 3]; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .select('changed_files') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc')]; + case 2: + rows_3 = _g.sent(); + existingChangedFilesList = rows_3.map(function (row) { + var _a; + try { + return JSON.parse(String((_a = row.changed_files) !== null && _a !== void 0 ? _a : '[]')); + } + catch (_b) { + return []; + } + }); + filteredChangedFiles = filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList); + _g.label = 3; + case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .insert({ + plan_item_id: planItemId, + summary: payload.summary, + branch_name: payload.branchName, + commit_hash: (_b = payload.commitHash) !== null && _b !== void 0 ? _b : null, + preview_url: (_c = payload.previewUrl) !== null && _c !== void 0 ? _c : null, + changed_files: JSON.stringify(filteredChangedFiles), + command_log: (_d = payload.commandLog) !== null && _d !== void 0 ? _d : null, + diff_text: (_e = payload.diffText) !== null && _e !== void 0 ? _e : null, + source_files: JSON.stringify((_f = payload.sourceFiles) !== null && _f !== void 0 ? _f : []), + }) + .returning('*')]; + case 4: + rows = _g.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(planItemId)]; + case 5: + _g.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function createPlanLifecycleSourceWorkHistory(planItemId, summary, branchName, commandLog, previewUrl) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + return [2 /*return*/, createPlanSourceWorkHistory(planItemId, { + summary: summary, + branchName: branchName, + previewUrl: previewUrl, + changedFiles: [], + commandLog: commandLog !== null && commandLog !== void 0 ? commandLog : null, + diffText: null, + sourceFiles: [], + })]; + }); + }); +} +function listPlanSourceWorkHistories(planItemId) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [2 /*return*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .select('*') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc')]; + } + }); + }); +} +function getPlanSourceWorkHistory(planItemId, sourceWorkId) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [2 /*return*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .select('*') + .where({ + id: sourceWorkId, + plan_item_id: planItemId, + }) + .first()]; + } + }); + }); +} +function listLatestPlanSourceWorkMap(planItemIds) { + return __awaiter(this, void 0, void 0, function () { + var rows, latestMap, fallbackMap; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (planItemIds.length === 0) { + return [2 /*return*/, new Map()]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .select('*') + .whereIn('plan_item_id', planItemIds) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc')]; + case 1: + rows = _a.sent(); + latestMap = new Map(); + fallbackMap = new Map(); + rows.forEach(function (row) { + var planItemId = Number(row.plan_item_id); + var mappedRow = mapPlanSourceWorkRow(row); + var hasMeaningfulChanges = mappedRow.changedFiles.length > 0 || mappedRow.sourceFiles.length > 0; + if (!fallbackMap.has(planItemId)) { + fallbackMap.set(planItemId, mappedRow); + } + if (!latestMap.has(planItemId) && hasMeaningfulChanges) { + latestMap.set(planItemId, mappedRow); + } + }); + planItemIds.forEach(function (planItemId) { + if (!latestMap.has(planItemId) && fallbackMap.has(planItemId)) { + latestMap.set(planItemId, fallbackMap.get(planItemId)); + } + }); + return [2 /*return*/, latestMap]; + } + }); + }); +} +function listPlanReleaseReviewRowMap(planItemIds) { + return __awaiter(this, void 0, void 0, function () { + var rows, reviewMap; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (planItemIds.length === 0) { + return [2 /*return*/, new Map()]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_RELEASE_REVIEW_TABLE) + .select('*') + .whereIn('plan_item_id', planItemIds) + .orderBy('updated_at', 'desc') + .orderBy('id', 'desc')]; + case 1: + rows = _a.sent(); + reviewMap = new Map(); + rows.forEach(function (row) { + var planItemId = Number(row.plan_item_id); + if (!reviewMap.has(planItemId)) { + reviewMap.set(planItemId, row); + } + }); + return [2 /*return*/, reviewMap]; + } + }); + }); +} +function summarizePlanReviewText(value) { + return String(value !== null && value !== void 0 ? value : '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 220); +} +function inferReleaseReviewPageSelectionIds(changedFiles) { + var selectionIds = new Set(['page:plans:release-review', 'page:plans:release']); + changedFiles.forEach(function (file) { + var normalized = String(file !== null && file !== void 0 ? file : '').trim(); + if (!normalized) { + return; + } + if (normalized.startsWith('src/features/board/') || normalized.includes('/BoardPage.tsx')) { + selectionIds.add('page:plans:board'); + } + if (normalized.startsWith('src/features/history/')) { + selectionIds.add('page:plans:history'); + } + if (normalized.startsWith('src/components/') || normalized.startsWith('src/features/layout/')) { + selectionIds.add('page:apis:components'); + } + if (normalized.startsWith('src/widgets/')) { + selectionIds.add('page:apis:widgets'); + } + }); + return Array.from(selectionIds); +} +function inferReleaseReviewDocIds(changedFiles) { + return Array.from(new Set(changedFiles + .map(function (file) { + var normalized = String(file !== null && file !== void 0 ? file : '').trim(); + return normalized.startsWith('docs/') ? normalized.replace(/[^\w-]+/g, '-') : ''; + }) + .filter(Boolean))); +} +function toKebabCase(value) { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[_\s]+/g, '-') + .toLowerCase(); +} +function extractSampleTargetIds(sourceFiles, changedFiles, pathPrefix) { + var targetIds = new Set(); + (sourceFiles !== null && sourceFiles !== void 0 ? sourceFiles : []).forEach(function (sourceFile) { + var _a, _b, _c; + var normalizedPath = String((_a = sourceFile.path) !== null && _a !== void 0 ? _a : '').trim(); + if (!normalizedPath.startsWith(pathPrefix)) { + return; + } + var matches = String((_b = sourceFile.content) !== null && _b !== void 0 ? _b : '').matchAll(/componentId\s*:\s*['"]([^'"]+)['"]/g); + for (var _i = 0, matches_1 = matches; _i < matches_1.length; _i++) { + var match = matches_1[_i]; + var targetId = String((_c = match[1]) !== null && _c !== void 0 ? _c : '').trim(); + if (targetId) { + targetIds.add(targetId); + } + } + }); + changedFiles.forEach(function (file) { + var normalized = String(file !== null && file !== void 0 ? file : '').trim(); + if (!normalized.startsWith(pathPrefix)) { + return; + } + var relativePath = normalized.slice(pathPrefix.length); + var firstSegment = relativePath.split('/').filter(Boolean)[0]; + if (firstSegment) { + targetIds.add(toKebabCase(firstSegment)); + } + }); + return Array.from(targetIds); +} +function inferReleaseReviewComponentIds(sourceFiles, changedFiles) { + return extractSampleTargetIds(sourceFiles, changedFiles, 'src/components/'); +} +function inferReleaseReviewWidgetIds(sourceFiles, changedFiles) { + return extractSampleTargetIds(sourceFiles, changedFiles, 'src/widgets/'); +} +function buildReleaseReviewSummary(row, latestSourceWork, reviewRow) { + var _a, _b, _c, _d, _e, _f; + var metadata = mapPlanReleaseReviewMetadata((_a = reviewRow === null || reviewRow === void 0 ? void 0 : reviewRow.metadata) !== null && _a !== void 0 ? _a : null); + var candidateSummary = (_d = (_b = metadata.summary) !== null && _b !== void 0 ? _b : summarizePlanReviewText(String((_c = latestSourceWork === null || latestSourceWork === void 0 ? void 0 : latestSourceWork.summary) !== null && _c !== void 0 ? _c : ''))) !== null && _d !== void 0 ? _d : summarizePlanReviewText(String((_e = row.note) !== null && _e !== void 0 ? _e : '')); + return candidateSummary || summarizePlanReviewText(String((_f = row.note) !== null && _f !== void 0 ? _f : '')); +} +function upsertPlanReleaseReview(planItemId, payload, actor) { + return __awaiter(this, void 0, void 0, function () { + var currentItem, currentRow, currentMetadata, nextStatus, nextReviewNote, nextMetadata, hasReviewerTrace, nextRow, rows_4, rows; + var _a, _b, _c, _d, _e, _f, _g; + return __generator(this, function (_h) { + switch (_h.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _h.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: planItemId }).first()]; + case 2: + currentItem = _h.sent(); + if (!currentItem) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_RELEASE_REVIEW_TABLE).where({ plan_item_id: planItemId }).first()]; + case 3: + currentRow = _h.sent(); + currentMetadata = mapPlanReleaseReviewMetadata((_a = currentRow === null || currentRow === void 0 ? void 0 : currentRow.metadata) !== null && _a !== void 0 ? _a : null); + nextStatus = (_b = payload.status) !== null && _b !== void 0 ? _b : ((currentRow === null || currentRow === void 0 ? void 0 : currentRow.status) ? String(currentRow.status) : 'pending'); + nextReviewNote = (_c = payload.reviewNote) !== null && _c !== void 0 ? _c : String((_d = currentRow === null || currentRow === void 0 ? void 0 : currentRow.review_note) !== null && _d !== void 0 ? _d : ''); + nextMetadata = payload.metadata ? __assign(__assign({}, currentMetadata), payload.metadata) : currentMetadata; + hasReviewerTrace = nextStatus !== 'pending' || nextReviewNote.trim().length > 0; + nextRow = { + plan_item_id: planItemId, + status: nextStatus, + review_note: nextReviewNote, + checked_by_client_id: hasReviewerTrace ? ((_e = actor === null || actor === void 0 ? void 0 : actor.clientId) === null || _e === void 0 ? void 0 : _e.trim()) || null : null, + checked_by_nickname: hasReviewerTrace ? ((_f = actor === null || actor === void 0 ? void 0 : actor.nickname) === null || _f === void 0 ? void 0 : _f.trim()) || ((_g = actor === null || actor === void 0 ? void 0 : actor.clientId) === null || _g === void 0 ? void 0 : _g.trim()) || null : null, + checked_at: hasReviewerTrace ? client_js_1.db.fn.now() : null, + metadata: JSON.stringify(nextMetadata), + updated_at: client_js_1.db.fn.now(), + }; + if (!currentRow) return [3 /*break*/, 6]; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_RELEASE_REVIEW_TABLE) + .where({ plan_item_id: planItemId }) + .update(nextRow) + .returning('*')]; + case 4: + rows_4 = _h.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(planItemId)]; + case 5: + _h.sent(); + return [2 /*return*/, mapPlanReleaseReviewRow(rows_4[0], planItemId)]; + case 6: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_RELEASE_REVIEW_TABLE) + .insert(__assign(__assign({}, nextRow), { created_at: client_js_1.db.fn.now() })) + .returning('*')]; + case 7: + rows = _h.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(planItemId)]; + case 8: + _h.sent(); + return [2 /*return*/, mapPlanReleaseReviewRow(rows[0], planItemId)]; + } + }); + }); +} +function listPlanReleaseReviewBoardItems(options) { + return __awaiter(this, void 0, void 0, function () { + var rows, releaseRows, planItemIds, issueSummaryMap, latestSourceWorkMap, reviewRowMap; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc')]; + case 2: + rows = _a.sent(); + releaseRows = rows.filter(function (row) { + var _a; + var workerStatus = String((_a = row.worker_status) !== null && _a !== void 0 ? _a : ''); + return (row.status === '릴리즈완료' || + row.status === '완료' || + ['release반영중', 'main반영대기', 'main반영중', 'main반영실패', 'release반영완료'].includes(workerStatus)); + }); + planItemIds = releaseRows.map(function (row) { return Number(row.id); }); + return [4 /*yield*/, listPlanIssueSummaries(planItemIds)]; + case 3: + issueSummaryMap = _a.sent(); + return [4 /*yield*/, listLatestPlanSourceWorkMap(planItemIds)]; + case 4: + latestSourceWorkMap = _a.sent(); + return [4 /*yield*/, listPlanReleaseReviewRowMap(planItemIds)]; + case 5: + reviewRowMap = _a.sent(); + return [2 /*return*/, releaseRows.map(function (row) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j; + var planItemId = Number(row.id); + var planItem = mapPlanRow(row, __assign(__assign({}, ((_a = issueSummaryMap.get(planItemId)) !== null && _a !== void 0 ? _a : { issueTags: [], hasOpenIssues: false })), { maskNote: options === null || options === void 0 ? void 0 : options.maskNote, noteMasked: options === null || options === void 0 ? void 0 : options.maskNote, exposeConfiguredAutomationType: true })); + var latestSourceWork = (_b = latestSourceWorkMap.get(planItemId)) !== null && _b !== void 0 ? _b : null; + var reviewRow = reviewRowMap.get(planItemId); + var review = mapPlanReleaseReviewRow(reviewRow, planItemId); + var changedFiles = (_c = latestSourceWork === null || latestSourceWork === void 0 ? void 0 : latestSourceWork.changedFiles) !== null && _c !== void 0 ? _c : []; + var sourceFiles = (_d = latestSourceWork === null || latestSourceWork === void 0 ? void 0 : latestSourceWork.sourceFiles) !== null && _d !== void 0 ? _d : []; + var metadata = __assign(__assign({}, review.metadata), { summary: (_e = review.metadata.summary) !== null && _e !== void 0 ? _e : buildReleaseReviewSummary(row, latestSourceWork !== null && latestSourceWork !== void 0 ? latestSourceWork : undefined, reviewRow), pageSelectionIds: (_f = review.metadata.pageSelectionIds) !== null && _f !== void 0 ? _f : inferReleaseReviewPageSelectionIds(changedFiles), docIds: (_g = review.metadata.docIds) !== null && _g !== void 0 ? _g : inferReleaseReviewDocIds(changedFiles), componentIds: (_h = review.metadata.componentIds) !== null && _h !== void 0 ? _h : inferReleaseReviewComponentIds(sourceFiles, changedFiles), widgetIds: (_j = review.metadata.widgetIds) !== null && _j !== void 0 ? _j : inferReleaseReviewWidgetIds(sourceFiles, changedFiles) }); + return { + planItem: planItem, + review: __assign(__assign({}, review), { metadata: metadata }), + latestSourceWork: latestSourceWork, + }; + })]; + } + }); + }); +} +function createPlanIssueHistory(planItemId, issueTag, message) { + return __awaiter(this, void 0, void 0, function () { + var normalizedIssueTag, previousIssueWithAction, rows; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _b.sent(); + normalizedIssueTag = issueTag.startsWith('#') ? issueTag : "#".concat(issueTag); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .select('*') + .where({ + plan_item_id: planItemId, + issue_tag: normalizedIssueTag, + message: message, + }) + .whereNotNull('action_note') + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .first()]; + case 2: + previousIssueWithAction = _b.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .insert({ + plan_item_id: planItemId, + issue_tag: normalizedIssueTag, + message: message, + action_note: (_a = previousIssueWithAction === null || previousIssueWithAction === void 0 ? void 0 : previousIssueWithAction.action_note) !== null && _a !== void 0 ? _a : null, + }) + .returning('*')]; + case 3: + rows = _b.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function appendLatestIssueAction(planItemId_1, actionNote_1) { + return __awaiter(this, arguments, void 0, function (planItemId, actionNote, resolve) { + var issueRow, nextActionNote, rows; + if (resolve === void 0) { resolve = false; } + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .where({ plan_item_id: planItemId }) + .orderBy('resolved', 'asc') + .orderBy('created_at', 'desc') + .first()]; + case 2: + issueRow = _a.sent(); + if (!issueRow) { + throw new Error('기록할 이슈 이력이 없습니다.'); + } + nextActionNote = issueRow.action_note + ? "".concat(issueRow.action_note, "\n[").concat(new Date().toISOString(), "] ").concat(actionNote) + : "[".concat(new Date().toISOString(), "] ").concat(actionNote); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .where({ id: issueRow.id }) + .update({ + action_note: nextActionNote, + resolved: resolve ? true : issueRow.resolved, + resolved_at: resolve ? client_js_1.db.fn.now() : issueRow.resolved_at, + }) + .returning('*')]; + case 3: + rows = _a.sent(); + return [4 /*yield*/, createPlanActionHistory(planItemId, resolve ? '이슈해결조치' : '이슈조치', actionNote)]; + case 4: + _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function listPlanIssueHistories(planItemId) { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, ensurePlanFailureIssueHistory(planItemId)]; + case 2: + _a.sent(); + return [2 /*return*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .select('*') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc')]; + } + }); + }); +} +function listPlanIssueSummaries(planItemIds) { + return __awaiter(this, void 0, void 0, function () { + var _i, planItemIds_1, planItemId, rows, summaryMap; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + if (planItemIds.length === 0) { + return [2 /*return*/, new Map()]; + } + _i = 0, planItemIds_1 = planItemIds; + _a.label = 2; + case 2: + if (!(_i < planItemIds_1.length)) return [3 /*break*/, 5]; + planItemId = planItemIds_1[_i]; + return [4 /*yield*/, ensurePlanFailureIssueHistory(planItemId)]; + case 3: + _a.sent(); + _a.label = 4; + case 4: + _i++; + return [3 /*break*/, 2]; + case 5: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_ISSUE_TABLE) + .select('*') + .whereIn('plan_item_id', planItemIds) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc')]; + case 6: + rows = _a.sent(); + summaryMap = new Map(); + rows.forEach(function (row) { + var _a; + var planItemId = Number(row.plan_item_id); + var current = (_a = summaryMap.get(planItemId)) !== null && _a !== void 0 ? _a : { issueTags: [], hasOpenIssues: false }; + if (!row.resolved && typeof row.issue_tag === 'string' && !current.issueTags.includes(row.issue_tag)) { + current.issueTags.push(row.issue_tag); + } + if (!row.resolved) { + current.hasOpenIssues = true; + } + summaryMap.set(planItemId, current); + }); + return [2 /*return*/, summaryMap]; + } + }); + }); +} +function claimNextPlanForBranch(workerId) { + return __awaiter(this, void 0, void 0, function () { + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [2 /*return*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var row, assignedBranch, rows; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, trx(exports.PLAN_TABLE) + .select('*') + .where({ status: '등록' }) + .where(function (builder) { + builder.whereNull('worker_status').orWhere('worker_status', '대기'); + }) + .orderBy('created_at', 'asc') + .forUpdate() + .skipLocked() + .first()]; + case 1: + row = _b.sent(); + if (!row) { + return [2 /*return*/, null]; + } + assignedBranch = shouldUseLocalMainPlanMode(row.automation_type) + ? String((0, env_js_1.getEnv)().PLAN_MAIN_BRANCH) + : buildPlanBranchName(String(row.work_id), Number(row.id)); + return [4 /*yield*/, trx(exports.PLAN_TABLE) + .where({ id: row.id }) + .update({ + status: '작업중', + assigned_branch: assignedBranch, + worker_status: '브랜치생성중', + last_error: null, + locked_by: workerId, + locked_at: client_js_1.db.fn.now(), + started_at: (_a = row.started_at) !== null && _a !== void 0 ? _a : client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _b.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); })]; + } + }); + }); +} +function claimNextPlanForExecution(workerId) { + return __awaiter(this, void 0, void 0, function () { + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [2 /*return*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var row, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(exports.PLAN_TABLE) + .select('*') + .where({ status: '작업중', worker_status: '브랜치준비' }) + .whereNotNull('assigned_branch') + .orderBy('updated_at', 'asc') + .forUpdate() + .skipLocked() + .first()]; + case 1: + row = _a.sent(); + if (!row) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, trx(exports.PLAN_TABLE) + .where({ id: row.id }) + .update({ + worker_status: '자동작업중', + last_error: null, + locked_by: workerId, + locked_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); })]; + } + }); + }); +} +function claimNextPlanForMerge(workerId) { + return __awaiter(this, void 0, void 0, function () { + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [2 /*return*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var row, rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(exports.PLAN_TABLE) + .select('*') + .where({ status: '작업완료' }) + .whereNotNull('assigned_branch') + .where(function (builder) { + builder.whereNull('worker_status').orWhere('worker_status', 'release반영대기'); + }) + .orderBy('updated_at', 'asc') + .forUpdate() + .skipLocked() + .first()]; + case 1: + row = _a.sent(); + if (!row) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, trx(exports.PLAN_TABLE) + .where({ id: row.id }) + .update({ + worker_status: 'release반영중', + last_error: null, + locked_by: workerId, + locked_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); })]; + } + }); + }); +} +function claimNextPlanForMainMerge(workerId) { + return __awaiter(this, void 0, void 0, function () { + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [2 /*return*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + var candidateRows, row, pendingReleaseRow, batchRows, batchIds, rows; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, trx(exports.PLAN_TABLE) + .select('*') + .where({ status: '릴리즈완료', worker_status: 'main반영대기' }) + .whereNotNull('assigned_branch') + .orderBy('updated_at', 'asc') + .forUpdate()]; + case 1: + candidateRows = _b.sent(); + row = candidateRows[0]; + if (!row) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, trx(exports.PLAN_TABLE) + .select('id') + .where({ release_target: row.release_target, status: '작업완료' }) + .first()]; + case 2: + pendingReleaseRow = _b.sent(); + if (pendingReleaseRow) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, trx(exports.PLAN_TABLE) + .select('id') + .where({ status: '릴리즈완료', release_target: row.release_target }) + .whereIn('worker_status', ['main반영대기', 'main반영실패'])]; + case 3: + batchRows = _b.sent(); + batchIds = batchRows.map(function (candidate) { return Number(candidate.id); }).filter(Number.isFinite); + return [4 /*yield*/, trx(exports.PLAN_TABLE) + .whereIn('id', batchIds.length > 0 ? batchIds : [Number(row.id)]) + .update({ + worker_status: 'main반영중', + last_error: null, + locked_by: workerId, + locked_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 4: + rows = _b.sent(); + return [2 /*return*/, (_a = rows[0]) !== null && _a !== void 0 ? _a : null]; + } + }); + }); })]; + } + }); + }); +} +function markPlanBranchReady(id, workerId) { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .where({ locked_by: workerId }) + .update({ + worker_status: '브랜치준비', + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 1: + rows = _a.sent(); + if (!rows[0]) { + return [2 /*return*/, null]; + } + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function markPlanWorkCompleted(id, workerId, note) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, rows; + var _a, _b, _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 1: + currentRow = _d.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .where({ locked_by: workerId }) + .update({ + status: '작업완료', + jangsing_processing_required: true, + worker_status: 'release반영대기', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _d.sent(); + if (!rows[0]) { + return [2 /*return*/, null]; + } + if (!currentRow) return [3 /*break*/, 4]; + return [4 /*yield*/, createPlanLifecycleSourceWorkHistory(id, '자동 작업 완료로 release 반영 대기 상태로 전환했습니다.', (_b = (_a = currentRow.assigned_branch) !== null && _a !== void 0 ? _a : currentRow.release_target) !== null && _b !== void 0 ? _b : 'release', currentRow.assigned_branch + ? "\uD604\uC7AC \uC791\uC5C5 \uBE0C\uB79C\uCE58: ".concat(currentRow.assigned_branch) + : "release \uB300\uAE30 \uBE0C\uB79C\uCE58: ".concat((_c = currentRow.release_target) !== null && _c !== void 0 ? _c : 'release'))]; + case 3: + _d.sent(); + _d.label = 4; + case 4: return [4 /*yield*/, createPlanActionHistory(id, '작업완료', note !== null && note !== void 0 ? note : '자동 작업을 마치고 release 반영을 대기합니다.')]; + case 5: + _d.sent(); + return [4 /*yield*/, resolveAutomationIssueHistories(id)]; + case 6: + _d.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function markPlanReleaseMerged(id, workerId) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, autoDeployToMain, rows; + var _a, _b, _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 1: + currentRow = _d.sent(); + autoDeployToMain = Boolean((_a = currentRow === null || currentRow === void 0 ? void 0 : currentRow.auto_deploy_to_main) !== null && _a !== void 0 ? _a : true); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .where({ locked_by: workerId }) + .update({ + status: '릴리즈완료', + worker_status: autoDeployToMain ? 'main반영대기' : 'release반영완료', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _d.sent(); + if (!rows[0]) { + return [2 /*return*/, null]; + } + if (!currentRow) return [3 /*break*/, 4]; + return [4 /*yield*/, createPlanLifecycleSourceWorkHistory(id, 'release 브랜치 반영을 완료했습니다.', (_b = currentRow.release_target) !== null && _b !== void 0 ? _b : 'release', "release \uBE0C\uB79C\uCE58: ".concat((_c = currentRow.release_target) !== null && _c !== void 0 ? _c : 'release'))]; + case 3: + _d.sent(); + _d.label = 4; + case 4: return [4 /*yield*/, createPlanActionHistory(id, 'release반영완료', autoDeployToMain + ? 'release 브랜치 반영을 완료했고 main 자동 반영을 예약했습니다.' + : 'release 브랜치 반영을 완료했습니다.')]; + case 5: + _d.sent(); + return [4 /*yield*/, resolveAutomationIssueHistories(id)]; + case 6: + _d.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 7: + _d.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function markPlanMerged(id, workerId) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, batchRows, batchIds, targetRows, rows, _i, targetRows_1, row, planId; + var _a, _b, _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 1: + currentRow = _d.sent(); + if (!currentRow || currentRow.locked_by !== workerId) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .select('*') + .where({ status: '릴리즈완료', release_target: currentRow.release_target }) + .whereIn('worker_status', ['main반영대기', 'main반영중', 'main반영실패'])]; + case 2: + batchRows = _d.sent(); + batchIds = batchRows.map(function (row) { return Number(row.id); }).filter(Number.isFinite); + targetRows = batchRows.length > 0 ? batchRows : [currentRow]; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .whereIn('id', batchIds.length > 0 ? batchIds : [id]) + .update({ + status: '완료', + worker_status: 'main반영완료', + last_error: null, + locked_by: null, + locked_at: null, + merged_at: client_js_1.db.fn.now(), + completed_at: client_js_1.db.fn.now(), + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 3: + rows = _d.sent(); + _i = 0, targetRows_1 = targetRows; + _d.label = 4; + case 4: + if (!(_i < targetRows_1.length)) return [3 /*break*/, 10]; + row = targetRows_1[_i]; + planId = Number(row.id); + return [4 /*yield*/, createPlanLifecycleSourceWorkHistory(planId, "".concat((0, env_js_1.getEnv)().PLAN_MAIN_BRANCH, " \uBE0C\uB79C\uCE58 \uBC18\uC601\uC744 \uC644\uB8CC\uD588\uC2B5\uB2C8\uB2E4."), (0, env_js_1.getEnv)().PLAN_MAIN_BRANCH, "".concat((_a = row.release_target) !== null && _a !== void 0 ? _a : 'release', " -> ").concat((0, env_js_1.getEnv)().PLAN_MAIN_BRANCH))]; + case 5: + _d.sent(); + return [4 /*yield*/, createPlanActionHistory(planId, 'main반영완료', "".concat((_b = row.release_target) !== null && _b !== void 0 ? _b : 'release', " \uBE0C\uB79C\uCE58\uB97C ").concat((0, env_js_1.getEnv)().PLAN_MAIN_BRANCH, " \uBE0C\uB79C\uCE58\uC5D0 \uC77C\uAD04 \uBC18\uC601 \uC644\uB8CC\uD588\uC2B5\uB2C8\uB2E4."))]; + case 6: + _d.sent(); + return [4 /*yield*/, resolveAutomationIssueHistories(planId)]; + case 7: + _d.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(planId)]; + case 8: + _d.sent(); + _d.label = 9; + case 9: + _i++; + return [3 /*break*/, 4]; + case 10: return [2 /*return*/, { + mergedRow: (_c = rows[0]) !== null && _c !== void 0 ? _c : null, + notificationRows: targetRows, + }]; + } + }); + }); +} +function markPlanMainMergeFailure(releaseTarget, workerId, errorMessage) { + return __awaiter(this, void 0, void 0, function () { + var rows, _i, rows_5, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ status: '릴리즈완료', release_target: releaseTarget, locked_by: workerId }) + .whereIn('worker_status', ['main반영대기', 'main반영중']) + .update({ + worker_status: 'main반영실패', + last_error: errorMessage, + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _a.sent(); + _i = 0, rows_5 = rows; + _a.label = 3; + case 3: + if (!(_i < rows_5.length)) return [3 /*break*/, 7]; + row = rows_5[_i]; + return [4 /*yield*/, createPlanActionHistory(Number(row.id), 'main반영실패', "".concat(releaseTarget, " \uBE0C\uB79C\uCE58 \uAE30\uC900 main \uC77C\uAD04 \uBC18\uC601\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4."))]; + case 4: + _a.sent(); + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(Number(row.id))]; + case 5: + _a.sent(); + _a.label = 6; + case 6: + _i++; + return [3 /*break*/, 3]; + case 7: return [2 /*return*/, rows]; + } + }); + }); +} +function markPlanAutomationFailure(id, workerId, workerStatus, errorMessage) { + return __awaiter(this, void 0, void 0, function () { + var currentRow, nextStatus, rows, issueHistory; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 1: + currentRow = _a.sent(); + if (!currentRow) { + return [2 /*return*/, null]; + } + nextStatus = workerStatus === '브랜치실패' + ? '등록' + : workerStatus === '자동작업실패' + ? '작업중' + : workerStatus === 'main반영실패' + ? '릴리즈완료' + : '작업완료'; + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .where({ id: id }) + .where({ locked_by: workerId }) + .update({ + status: nextStatus, + worker_status: workerStatus, + last_error: errorMessage, + locked_by: null, + locked_at: null, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _a.sent(); + if (!rows[0]) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, createPlanIssueHistory(id, workerStatus, errorMessage)]; + case 3: + issueHistory = _a.sent(); + if (!(issueHistory === null || issueHistory === void 0 ? void 0 : issueHistory.action_note)) return [3 /*break*/, 5]; + return [4 /*yield*/, createPlanActionHistory(id, '오류재발', "\uB3D9\uC77C \uC624\uB958\uAC00 \uB2E4\uC2DC \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4.\n\uAE30\uC874 \uC870\uCE58\uB0B4\uC5ED:\n".concat(issueHistory.action_note))]; + case 4: + _a.sent(); + _a.label = 5; + case 5: return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 6: + _a.sent(); + return [2 /*return*/, rows[0]]; + } + }); + }); +} +function listPlanItems(status, options) { + return __awaiter(this, void 0, void 0, function () { + var buildListQuery, rows, missingSnapshotIds, planItemIds, issueSummaryMap, releaseReviewNoteMap; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + buildListQuery = function () { + var builder = (0, client_js_1.db)(exports.PLAN_TABLE).select('*').orderBy('id', 'desc'); + if (status) { + builder.where('status', status); + } + return builder; + }; + return [4 /*yield*/, buildListQuery()]; + case 2: + rows = _a.sent(); + missingSnapshotIds = rows + .filter(function (row) { return row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === ''; }) + .map(function (row) { return Number(row.id); }) + .filter(Number.isFinite); + if (!(missingSnapshotIds.length > 0)) return [3 /*break*/, 5]; + return [4 /*yield*/, Promise.all(missingSnapshotIds.map(function (planItemId) { return syncPlanAutomationUsageSnapshot(planItemId); }))]; + case 3: + _a.sent(); + return [4 /*yield*/, buildListQuery()]; + case 4: + rows = _a.sent(); + _a.label = 5; + case 5: + planItemIds = rows.map(function (row) { return Number(row.id); }); + return [4 /*yield*/, listPlanIssueSummaries(planItemIds)]; + case 6: + issueSummaryMap = _a.sent(); + return [4 /*yield*/, listPlanReleaseReviewNoteMap(planItemIds)]; + case 7: + releaseReviewNoteMap = _a.sent(); + return [2 /*return*/, rows.map(function (row) { + var _a, _b; + return mapPlanRow(row, __assign(__assign({}, ((_a = issueSummaryMap.get(Number(row.id))) !== null && _a !== void 0 ? _a : { issueTags: [], hasOpenIssues: false })), { maskNote: options === null || options === void 0 ? void 0 : options.maskNote, noteMasked: options === null || options === void 0 ? void 0 : options.maskNote, releaseReviewNote: (_b = releaseReviewNoteMap.get(Number(row.id))) !== null && _b !== void 0 ? _b : '', exposeConfiguredAutomationType: true })); + })]; + } + }); + }); +} +function getPlanItemById(id, options) { + return __awaiter(this, void 0, void 0, function () { + var row, issueSummaryMap, releaseReviewNoteMap; + var _a, _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _c.sent(); + return [4 /*yield*/, ensurePlanFailureIssueHistory(id)]; + case 2: + _c.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 3: + row = _c.sent(); + if (!row) { + return [2 /*return*/, null]; + } + if (!(row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '')) return [3 /*break*/, 6]; + return [4 /*yield*/, syncPlanAutomationUsageSnapshot(id)]; + case 4: + _c.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).where({ id: id }).first()]; + case 5: + row = _c.sent(); + _c.label = 6; + case 6: return [4 /*yield*/, listPlanIssueSummaries([id])]; + case 7: + issueSummaryMap = _c.sent(); + return [4 /*yield*/, listPlanReleaseReviewNoteMap([id])]; + case 8: + releaseReviewNoteMap = _c.sent(); + return [2 /*return*/, mapPlanRow(row, __assign(__assign({}, ((_a = issueSummaryMap.get(id)) !== null && _a !== void 0 ? _a : { issueTags: [], hasOpenIssues: false })), { maskNote: options === null || options === void 0 ? void 0 : options.maskNote, noteMasked: options === null || options === void 0 ? void 0 : options.maskNote, releaseReviewNote: (_b = releaseReviewNoteMap.get(id)) !== null && _b !== void 0 ? _b : '', exposeConfiguredAutomationType: true }))]; + } + }); + }); +} +function normalizePreviewLookupValue(value, stripSearch) { + if (stripSearch === void 0) { stripSearch = false; } + var trimmed = String(value !== null && value !== void 0 ? value : '').trim(); + if (!trimmed) { + return ''; + } + try { + var url = new URL(trimmed); + url.hash = ''; + if (stripSearch) { + url.search = ''; + } + return url.toString().replace(/\/+$/, ''); + } + catch (_a) { + var withoutHash = trimmed.replace(/#.*$/, ''); + var withoutSearch = stripSearch ? withoutHash.replace(/\?.*$/, '') : withoutHash; + return withoutSearch.replace(/\/+$/, ''); + } +} +function buildPreviewLookupCandidates(value) { + return Array.from(new Set([ + normalizePreviewLookupValue(value, false), + normalizePreviewLookupValue(value, true), + ].filter(Boolean))); +} +function findLatestPlanItem() { + return __awaiter(this, void 0, void 0, function () { + var row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE).select('id').orderBy('id', 'desc').first()]; + case 2: + row = _a.sent(); + if (!(row === null || row === void 0 ? void 0 : row.id)) { + return [2 /*return*/, null]; + } + return [2 /*return*/, getPlanItemById(Number(row.id))]; + } + }); + }); +} +function findPlanItemByWorkId(workId) { + return __awaiter(this, void 0, void 0, function () { + var normalizedWorkId, rows, matchedRow; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + normalizedWorkId = normalizePlanWorkId(workId); + if (normalizedWorkId === '작업ID') { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_TABLE) + .select('id', 'work_id') + .orderBy('id', 'desc')]; + case 2: + rows = _a.sent(); + matchedRow = rows.find(function (row) { var _a; return normalizePlanWorkId(String((_a = row.work_id) !== null && _a !== void 0 ? _a : '')) === normalizedWorkId; }); + if (!(matchedRow === null || matchedRow === void 0 ? void 0 : matchedRow.id)) { + return [2 /*return*/, null]; + } + return [2 /*return*/, getPlanItemById(Number(matchedRow.id))]; + } + }); + }); +} +function findPlanItemByPreviewUrl(previewUrl) { + return __awaiter(this, void 0, void 0, function () { + var lookupCandidates, rows, matchedRow; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensurePlanTable()]; + case 1: + _a.sent(); + lookupCandidates = new Set(buildPreviewLookupCandidates(previewUrl)); + if (lookupCandidates.size === 0) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.PLAN_SOURCE_WORK_TABLE) + .select('plan_item_id', 'preview_url') + .whereNotNull('preview_url') + .orderBy('created_at', 'desc') + .orderBy('id', 'desc')]; + case 2: + rows = _a.sent(); + matchedRow = rows.find(function (row) { var _a; return buildPreviewLookupCandidates(String((_a = row.preview_url) !== null && _a !== void 0 ? _a : '')).some(function (candidate) { return lookupCandidates.has(candidate); }); }); + if (!(matchedRow === null || matchedRow === void 0 ? void 0 : matchedRow.plan_item_id)) { + return [2 /*return*/, null]; + } + return [2 /*return*/, getPlanItemById(Number(matchedRow.plan_item_id))]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts index a891d62..1bf4bfe 100755 --- a/etc/servers/work-server/src/services/plan-service.ts +++ b/etc/servers/work-server/src/services/plan-service.ts @@ -1415,12 +1415,27 @@ export async function deletePlanItem(id: number) { export async function getBoardPostLinkedToPlanItem(planItemId: number) { const boardPostsTable = 'board_posts'; + const boardPostRequestsTable = 'board_post_requests'; const hasBoardPostsTable = await db.schema.hasTable(boardPostsTable); if (!hasBoardPostsTable) { return null; } + const hasBoardPostRequestsTable = await db.schema.hasTable(boardPostRequestsTable); + + if (hasBoardPostRequestsTable) { + const linkedRequestRow = await db(boardPostRequestsTable) + .join(boardPostsTable, `${boardPostRequestsTable}.board_post_id`, `${boardPostsTable}.id`) + .select(`${boardPostsTable}.id`, `${boardPostsTable}.title`) + .where(`${boardPostRequestsTable}.plan_item_id`, planItemId) + .first(); + + if (linkedRequestRow) { + return linkedRequestRow; + } + } + return db(boardPostsTable).select('id', 'title').where({ automation_plan_item_id: planItemId }).first(); } @@ -1456,6 +1471,7 @@ export async function markPlanAsDevelopmentComplete(id: number) { : `release 대기 브랜치: ${currentRow.release_target ?? 'release'}`, ); await createPlanActionHistory(id, '작업완료', '작업완료 처리'); + await resolveAutomationIssueHistories(id); await syncPlanAutomationUsageSnapshot(id); return rows[0]; @@ -1505,6 +1521,7 @@ export async function markPlanAsCompleted( } await createPlanActionHistory(id, '완료처리', note ?? '작업을 완료 처리했습니다.'); + await resolveAutomationIssueHistories(id); await syncPlanAutomationUsageSnapshot(id); return rows[0]; @@ -1844,7 +1861,15 @@ export async function cancelPlanRelease(id: number) { } const releaseTarget = currentRow.release_target ?? 'release'; + const sourceWorkCountRow = await db(PLAN_SOURCE_WORK_TABLE) + .where({ plan_item_id: id }) + .count<{ count: string }>('id as count') + .first(); + const sourceWorkCount = Math.max(0, Number(sourceWorkCountRow?.count ?? 0) || 0); const isReleaseMergeFailure = currentRow.status === '작업완료' && currentRow.worker_status === 'release반영실패'; + const skippedRollbackBecauseNoSourceWork = + sourceWorkCount === 0 && + (currentRow.status === '릴리즈완료' || currentRow.worker_status === 'main반영실패'); const targetRows = isReleaseMergeFailure ? [] : await db(PLAN_TABLE) @@ -1856,10 +1881,14 @@ export async function cancelPlanRelease(id: number) { : [...new Set(targetRows.map((row) => Number(row.id)).concat(id))]; const historyMessage = isReleaseMergeFailure ? `release(${releaseTarget}) 반영 실패 상태에서 작업을 취소 처리했습니다.` - : `release(${releaseTarget}) 배포 내역을 롤백하고 작업을 취소 처리했습니다.`; + : skippedRollbackBecauseNoSourceWork + ? `소스 작업 증적이 없어 release(${releaseTarget}) 롤백 없이 작업을 취소 처리했습니다.` + : `release(${releaseTarget}) 배포 내역을 롤백하고 작업을 취소 처리했습니다.`; const resultMessage = isReleaseMergeFailure ? `release(${releaseTarget}) 반영 실패 상태에서 작업취소로 완료 처리했습니다.` - : `release(${releaseTarget}) 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.`; + : skippedRollbackBecauseNoSourceWork + ? `소스 작업 증적이 없어 rollback 없이 작업취소로 완료 처리했습니다.` + : `release(${releaseTarget}) 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.`; if (targetIds.length === 0) { return { diff --git a/etc/servers/work-server/src/services/resource-manager-service.ts b/etc/servers/work-server/src/services/resource-manager-service.ts new file mode 100644 index 0000000..f255c3e --- /dev/null +++ b/etc/servers/work-server/src/services/resource-manager-service.ts @@ -0,0 +1,471 @@ +import { randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { accessSync, existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export type ResourceManagerEntryType = 'file' | 'directory'; + +export type ResourceManagerTreeNode = { + name: string; + path: string; + type: ResourceManagerEntryType; + extension: string | null; + size: number | null; + modifiedAt: string; + previewUrl: string | null; + children?: ResourceManagerTreeNode[]; +}; + +export type ResourceManagerTreeRoot = { + label: string; + rootPath: string; + tree: ResourceManagerTreeNode; +}; + +export type ResourceManagerDirectoryEntry = { + name: string; + path: string; + type: ResourceManagerEntryType; + extension: string | null; + size: number | null; + modifiedAt: string; + previewUrl: string | null; +}; + +export type ResourceManagerFileDetail = { + name: string; + path: string; + extension: string | null; + size: number; + modifiedAt: string; + mimeType: string; + previewUrl: string; + isTextEditable: boolean; + content: string | null; +}; + +const RESOURCE_MANAGER_ROOT_DIR = 'resource'; +const RESOURCE_MANAGER_ROOT_LABEL = 'resource'; + +const TEXT_FILE_EXTENSIONS = new Set([ + '.txt', + '.md', + '.markdown', + '.json', + '.yaml', + '.yml', + '.js', + '.jsx', + '.ts', + '.tsx', + '.css', + '.scss', + '.sass', + '.less', + '.html', + '.htm', + '.xml', + '.svg', + '.csv', + '.log', + '.env', + '.ini', + '.sh', + '.sql', + '.py', + '.java', + '.kt', + '.go', + '.rs', + '.c', + '.cc', + '.cpp', + '.h', + '.hpp', + '.diff', +]); + +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 '.txt': + case '.diff': + case '.log': + case '.csv': + case '.yaml': + case '.yml': + case '.xml': + return 'text/plain; charset=utf-8'; + case '.md': + case '.markdown': + return 'text/markdown; 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 '.pdf': + return 'application/pdf'; + default: + return 'application/octet-stream'; + } +} + +function isTextEditable(filePath: string) { + const extension = path.extname(filePath).toLowerCase(); + return TEXT_FILE_EXTENSIONS.has(extension); +} + +function sanitizeEntryName(name: string) { + const trimmed = name.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' '); + + if (!trimmed || trimmed === '.' || trimmed === '..') { + throw new Error('이름이 올바르지 않습니다.'); + } + + return trimmed; +} + +function normalizeRelativeTarget(relativePath: string | null | undefined) { + const trimmed = String(relativePath ?? '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); + + if (!trimmed) { + return ''; + } + + const normalized = path.posix.normalize(trimmed); + + if (normalized === '.' || normalized === '') { + return ''; + } + + if (normalized.startsWith('../') || normalized === '..' || normalized.includes('/../')) { + throw new Error('허용되지 않은 경로입니다.'); + } + + return normalized.replace(/^\/+/, ''); +} + +function resolveRepoRoot(candidateRootPath: string) { + const candidates = [ + candidateRootPath, + path.resolve(process.cwd(), '../../..'), + ]; + + for (const candidate of candidates) { + try { + accessSync(candidate); + return candidate; + } catch { + continue; + } + } + + return candidates[0]; +} + +export function resolveResourceManagerRoot(repoRootPath: string) { + return path.join(resolveRepoRoot(repoRootPath), RESOURCE_MANAGER_ROOT_DIR); +} + +function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: string) { + const rootPath = resolveResourceManagerRoot(repoRootPath); + const normalizedRelativePath = normalizeRelativeTarget(relativePath); + const absolutePath = path.resolve(rootPath, normalizedRelativePath); + + if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) { + throw new Error('허용되지 않은 경로입니다.'); + } + + return { + rootPath, + absolutePath, + relativePath: normalizedRelativePath, + }; +} + +function buildPreviewUrl(relativePath: string) { + const encodedPath = normalizeRelativeTarget(relativePath) + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); + + return `/api/resource-manager/preview/${encodedPath}`; +} + +async function buildTreeNode(absolutePath: string, relativePath: string): Promise { + const stats = await fs.stat(absolutePath); + const type: ResourceManagerEntryType = stats.isDirectory() ? 'directory' : 'file'; + const extension = type === 'file' ? path.extname(absolutePath).toLowerCase() || null : null; + + const node: ResourceManagerTreeNode = { + name: relativePath ? path.basename(relativePath) : RESOURCE_MANAGER_ROOT_LABEL, + path: normalizeRelativeTarget(relativePath), + type, + extension, + size: type === 'file' ? stats.size : null, + modifiedAt: stats.mtime.toISOString(), + previewUrl: type === 'file' ? buildPreviewUrl(relativePath) : null, + }; + + if (type === 'directory') { + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + const children = await Promise.all( + entries + .filter((entry) => !entry.name.startsWith('.')) + .sort((left, right) => { + if (left.isDirectory() && !right.isDirectory()) { + return -1; + } + + if (!left.isDirectory() && right.isDirectory()) { + return 1; + } + + return left.name.localeCompare(right.name, 'ko'); + }) + .map((entry) => + buildTreeNode(path.join(absolutePath, entry.name), path.posix.join(relativePath, entry.name)), + ), + ); + + node.children = children; + } + + return node; +} + +export async function ensureResourceManagerRoot(repoRootPath: string) { + const rootPath = resolveResourceManagerRoot(repoRootPath); + await fs.mkdir(rootPath, { recursive: true }); +} + +export async function getResourceManagerTree(repoRootPath: string) { + await ensureResourceManagerRoot(repoRootPath); + + return { + label: RESOURCE_MANAGER_ROOT_LABEL, + rootPath: RESOURCE_MANAGER_ROOT_DIR, + tree: await buildTreeNode(resolveResourceManagerRoot(repoRootPath), ''), + } satisfies ResourceManagerTreeRoot; +} + +export async function listResourceManagerDirectory(repoRootPath: string, directoryPath = '') { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, directoryPath); + const stats = await fs.stat(absolutePath); + + if (!stats.isDirectory()) { + throw new Error('디렉터리가 아닙니다.'); + } + + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + const items: ResourceManagerDirectoryEntry[] = await Promise.all( + entries + .filter((entry) => !entry.name.startsWith('.')) + .sort((left, right) => { + if (left.isDirectory() && !right.isDirectory()) { + return -1; + } + + if (!left.isDirectory() && right.isDirectory()) { + return 1; + } + + return left.name.localeCompare(right.name, 'ko'); + }) + .map(async (entry) => { + const entryRelativePath = path.posix.join(relativePath, entry.name); + const entryAbsolutePath = path.join(absolutePath, entry.name); + const entryStats = await fs.stat(entryAbsolutePath); + const type: ResourceManagerEntryType = entry.isDirectory() ? 'directory' : 'file'; + + return { + name: entry.name, + path: entryRelativePath, + type, + extension: type === 'file' ? path.extname(entry.name).toLowerCase() || null : null, + size: type === 'file' ? entryStats.size : null, + modifiedAt: entryStats.mtime.toISOString(), + previewUrl: type === 'file' ? buildPreviewUrl(entryRelativePath) : null, + }; + }), + ); + + return { + path: relativePath, + items, + }; +} + +export async function readResourceManagerFile(repoRootPath: string, filePath: string) { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, filePath); + const stats = await fs.stat(absolutePath); + + if (!stats.isFile()) { + throw new Error('파일이 아닙니다.'); + } + + const textEditable = isTextEditable(absolutePath); + + return { + name: path.basename(relativePath), + path: relativePath, + extension: path.extname(absolutePath).toLowerCase() || null, + size: stats.size, + modifiedAt: stats.mtime.toISOString(), + mimeType: resolveStaticContentType(absolutePath), + previewUrl: buildPreviewUrl(relativePath), + isTextEditable: textEditable, + content: textEditable ? await fs.readFile(absolutePath, 'utf8') : null, + } satisfies ResourceManagerFileDetail; +} + +export async function createResourceManagerDirectory(repoRootPath: string, parentPath: string, name: string) { + await ensureResourceManagerRoot(repoRootPath); + const safeName = sanitizeEntryName(name); + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName)); + await fs.mkdir(absolutePath, { recursive: false }); +} + +export async function createResourceManagerFile(repoRootPath: string, parentPath: string, name: string, content = '') { + await ensureResourceManagerRoot(repoRootPath); + const safeName = sanitizeEntryName(name); + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName)); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, 'utf8'); +} + +export async function saveResourceManagerFile(repoRootPath: string, filePath: string, content: string) { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, filePath); + await fs.writeFile(absolutePath, content, 'utf8'); +} + +export async function uploadResourceManagerFile( + repoRootPath: string, + parentPath: string, + fileName: string, + contentBase64: string, +) { + await ensureResourceManagerRoot(repoRootPath); + const safeFileName = sanitizeEntryName(fileName); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeFileName)); + const buffer = Buffer.from(contentBase64, 'base64'); + + if (!buffer.byteLength) { + throw new Error('업로드할 파일 내용을 찾지 못했습니다.'); + } + + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, buffer); + + return { + id: randomUUID(), + name: safeFileName, + path: relativePath, + previewUrl: buildPreviewUrl(relativePath), + size: buffer.byteLength, + }; +} + +async function resolveCopyMoveTarget( + repoRootPath: string, + sourcePath: string, + targetDirectoryPath: string, + nextName?: string | null, +) { + const sourceTarget = resolveResourceManagerTargetPath(repoRootPath, sourcePath); + const sourceStats = await fs.stat(sourceTarget.absolutePath); + const resolvedName = sanitizeEntryName(nextName?.trim() || path.basename(sourceTarget.relativePath)); + const targetTarget = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(targetDirectoryPath, resolvedName)); + + return { + sourceAbsolutePath: sourceTarget.absolutePath, + sourceRelativePath: sourceTarget.relativePath, + sourceStats, + targetAbsolutePath: targetTarget.absolutePath, + targetRelativePath: targetTarget.relativePath, + }; +} + +export async function copyResourceManagerItem( + repoRootPath: string, + sourcePath: string, + targetDirectoryPath: string, + nextName?: string | null, +) { + await ensureResourceManagerRoot(repoRootPath); + const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName); + await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true }); + await fs.cp(target.sourceAbsolutePath, target.targetAbsolutePath, { recursive: target.sourceStats.isDirectory(), force: false }); + + return { + path: target.targetRelativePath, + }; +} + +export async function moveResourceManagerItem( + repoRootPath: string, + sourcePath: string, + targetDirectoryPath: string, + nextName?: string | null, +) { + await ensureResourceManagerRoot(repoRootPath); + const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName); + await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true }); + await fs.rename(target.sourceAbsolutePath, target.targetAbsolutePath); + + return { + path: target.targetRelativePath, + }; +} + +export async function deleteResourceManagerItem(repoRootPath: string, targetPath: string) { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath); + + if (!relativePath) { + throw new Error('루트 폴더는 삭제할 수 없습니다.'); + } + + await fs.rm(absolutePath, { recursive: true, force: false }); +} + +export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) { + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath); + + if (!existsSync(absolutePath)) { + throw new Error('리소스를 찾을 수 없습니다.'); + } + + const stats = await fs.stat(absolutePath); + + if (!stats.isFile()) { + throw new Error('파일만 미리보기할 수 있습니다.'); + } + + return { + stream: createReadStream(absolutePath), + contentType: resolveStaticContentType(absolutePath), + }; +} diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts index 71ed6f0..271afce 100755 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -1384,8 +1384,12 @@ async function inspectBuild(definition: ServerDefinition): Promise { + assert.equal( + hasReservedRestartVerification( + 'test', + { + availability: 'online', + startedAt: '2026-05-05T23:59:40.000Z', + runningBuiltAt: '2026-05-06T00:00:05.000Z', + runningVersion: null, + buildRequired: false, + updateAvailable: false, + }, + reservationStartedAt, + ), + false, + ); + + assert.equal( + hasReservedRestartVerification( + 'test', + { + availability: 'online', + startedAt: '2026-05-06T00:00:03.000Z', + runningBuiltAt: null, + runningVersion: null, + buildRequired: false, + updateAvailable: false, + }, + reservationStartedAt, + ), + false, + ); + + assert.equal( + hasReservedRestartVerification( + 'test', + { + availability: 'online', + startedAt: '2026-05-06T00:00:03.000Z', + runningBuiltAt: '2026-05-06T00:00:05.000Z', + runningVersion: null, + buildRequired: false, + updateAvailable: false, + }, + reservationStartedAt, + ), + true, + ); +}); + +test('hasReservedRestartVerification keeps work-server restart pending until new runtime and build info are ready', () => { + assert.equal( + hasReservedRestartVerification( + 'work-server', + { + availability: 'online', + startedAt: '2026-05-06T00:00:03.000Z', + runningBuiltAt: '2026-05-06T00:00:04.000Z', + runningVersion: null, + buildRequired: false, + updateAvailable: false, + }, + reservationStartedAt, + ), + true, + ); + + assert.equal( + hasReservedRestartVerification( + 'work-server', + { + availability: 'online', + startedAt: '2026-05-06T00:00:03.000Z', + runningBuiltAt: null, + runningVersion: null, + buildRequired: false, + updateAvailable: false, + }, + reservationStartedAt, + ), + false, + ); + + assert.equal( + hasReservedRestartVerification( + 'work-server', + { + availability: 'online', + startedAt: '2026-05-06T00:00:03.000Z', + runningBuiltAt: '2026-05-06T00:00:04.000Z', + runningVersion: 'build-2', + buildRequired: false, + updateAvailable: true, + }, + reservationStartedAt, + ), + false, + ); +}); + +test('summarizeRestartReservationAutomationWork ignores pending requests that were not accepted into automation', () => { + const summary = summarizeRestartReservationAutomationWork([ + { + requestItems: [ + { + id: 1, + boardPostId: 1, + sequence: 1, + title: 'pending', + content: '', + planItemId: null, + automationReceivedAt: null, + workflowState: 'pending', + status: 'pending', + statusLabel: '미접수', + planStatus: null, + workerStatus: null, + lastError: null, + createdAt: '', + updatedAt: '', + }, + { + id: 2, + boardPostId: 1, + sequence: 2, + title: 'waiting', + content: '', + planItemId: 10, + automationReceivedAt: '2026-05-06T00:00:00.000Z', + workflowState: 'waiting', + status: 'waiting', + statusLabel: '선행 대기', + planStatus: null, + workerStatus: null, + lastError: null, + createdAt: '', + updatedAt: '', + }, + { + id: 3, + boardPostId: 1, + sequence: 3, + title: 'running', + content: '', + planItemId: 11, + automationReceivedAt: '2026-05-06T00:00:00.000Z', + workflowState: 'registered', + status: 'in_progress', + statusLabel: '진행중', + planStatus: '작업중', + workerStatus: '자동작업중', + lastError: null, + createdAt: '', + updatedAt: '', + }, + ], + }, + ]); + + assert.deepEqual(summary, { + running: 1, + queued: 1, + }); +}); + +test('summarizeRestartReservationCodexWork keeps the reservation requester counted while work is still running', () => { + const summary = summarizeRestartReservationCodexWork( + { + running: [ + { sessionId: 'session-a' }, + { sessionId: 'session-b' }, + ], + queued: [ + { sessionId: 'session-a' }, + { sessionId: 'session-c' }, + ], + }, + { + sessionClientIds: new Map([ + ['session-a', 'client-a'], + ['session-b', 'client-b'], + ['session-c', 'client-c'], + ]), + }, + ); + + assert.deepEqual(summary, { + running: 2, + queued: 2, + }); +}); + +test('summarizeRestartReservationCodexWork keeps the current client counted when no exclusion is provided', () => { + const summary = summarizeRestartReservationCodexWork( + { + running: [ + { sessionId: 'session-a' }, + { sessionId: 'session-b' }, + ], + queued: [ + { sessionId: 'session-a' }, + { sessionId: 'session-c' }, + ], + }, + { + sessionClientIds: new Map([ + ['session-a', 'client-a'], + ['session-b', 'client-b'], + ['session-c', 'client-c'], + ]), + }, + ); + + assert.deepEqual(summary, { + running: 2, + queued: 2, + }); +}); diff --git a/etc/servers/work-server/src/services/server-restart-reservation-service.ts b/etc/servers/work-server/src/services/server-restart-reservation-service.ts new file mode 100644 index 0000000..4e96ade --- /dev/null +++ b/etc/servers/work-server/src/services/server-restart-reservation-service.ts @@ -0,0 +1,694 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { db } from '../db/client.js'; +import { getAppConfigSnapshot } from './app-config-service.js'; +import { listBoardPosts, type BoardPostItem, type BoardPostRequestItem } from './board-service.js'; +import { getActiveChatService } from './chat-service.js'; +import { chatRuntimeService, type ChatRuntimeJobItem } from './chat-runtime-service.js'; +import { createNotificationMessage, deleteOlderNotificationMessagesBySource } from './notification-message-service.js'; +import { + listServerCommands, + restartServerCommand, + type ServerCommandSnapshot, +} from './server-command-service.js'; +import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js'; + +const SERVER_RESTART_RESERVATION_TABLE = 'server_restart_reservations'; +const SERVER_RESTART_RESERVATION_ROW_ID = 1; +const SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS = 10_000; +const ACTIVE_CLIENT_WINDOW_MS = 3 * 60 * 1000; +const TEST_TO_WORK_SERVER_DELAY_MS = 5_000; +const RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000; +const SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE = 'server-restart-reservation'; + +type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'completed' | 'cancelled' | 'failed'; +type RestartReservationTarget = 'all'; + +type RestartReservationWorkloadSummary = { + codexRunningCount: number; + codexQueuedCount: number; + automationRunningCount: number; + automationQueuedCount: number; +}; + +type RestartReservationRow = { + id: number; + enabled: boolean; + target: RestartReservationTarget; + status: RestartReservationStatus; + requested_at: string | null; + requested_by_client_id: string | null; + last_checked_at: string | null; + waiting_reason: string | null; + workload_summary_json: RestartReservationWorkloadSummary | string | null; + started_at: string | null; + completed_at: string | null; + cancelled_at: string | null; + last_error: string | null; + active_client_count: number | null; + notified_active_clients_at: string | null; + app_origin: string | null; + auto_execute_at: string | null; + auto_execute_delay_seconds: number | null; + updated_at: string | null; +}; + +export type ServerRestartReservationSnapshot = { + enabled: boolean; + target: RestartReservationTarget; + status: RestartReservationStatus; + requestedAt: string | null; + requestedByClientId: string | null; + lastCheckedAt: string | null; + nextCheckAt: string | null; + waitingReason: string | null; + workloadSummary: RestartReservationWorkloadSummary; + startedAt: string | null; + completedAt: string | null; + cancelledAt: string | null; + lastError: string | null; + activeClientCount: number; + notifiedActiveClientsAt: string | null; + appOrigin: string | null; + autoExecuteAt: string | null; + autoExecuteDelaySeconds: number; + updatedAt: string | null; +}; + +function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary { + return { + codexRunningCount: 0, + codexQueuedCount: 0, + automationRunningCount: 0, + automationQueuedCount: 0, + }; +} + +function hasAcceptedAutomationRequest(requestItem: Pick) { + return Boolean(requestItem.planItemId) || Boolean(requestItem.automationReceivedAt) || requestItem.workflowState !== 'pending'; +} + +export function summarizeRestartReservationAutomationWork( + posts: Array>, +) { + return posts.reduce( + (summary, item) => { + for (const requestItem of item.requestItems) { + if (requestItem.status === 'in_progress') { + summary.running += 1; + continue; + } + + if ( + (requestItem.status === 'queued' || requestItem.status === 'waiting') + && hasAcceptedAutomationRequest(requestItem) + ) { + summary.queued += 1; + } + } + + return summary; + }, + { running: 0, queued: 0 }, + ); +} + +export function summarizeRestartReservationCodexWork( + runtimeItems: { + running: Array>; + queued: Array>; + }, + options?: { + sessionClientIds?: Map; + }, +) { + void options?.sessionClientIds; + + return { + running: runtimeItems.running.length, + queued: runtimeItems.queued.length, + }; +} + +export async function getRestartReservationWorkloadSummary() { + const runtimeSnapshot = chatRuntimeService.getSnapshot(); + const runtimeSummary = summarizeRestartReservationCodexWork( + { + running: runtimeSnapshot.running, + queued: runtimeSnapshot.queued, + }, + { sessionClientIds: getActiveChatService()?.getSessionClientIdMap() ?? new Map() }, + ); + const automationSummary = await countPendingAutomationWork(); + + return { + codexRunningCount: runtimeSummary.running, + codexQueuedCount: runtimeSummary.queued, + automationRunningCount: automationSummary.running, + automationQueuedCount: automationSummary.queued, + } satisfies RestartReservationWorkloadSummary; +} + +function buildNextCheckAt(row: RestartReservationRow | null | undefined) { + if (!row?.enabled || row.status !== 'waiting') { + return null; + } + + const baseTimestamp = row.last_checked_at ?? row.requested_at ?? row.updated_at; + + if (!baseTimestamp) { + return null; + } + + const baseTime = Date.parse(baseTimestamp); + + if (!Number.isFinite(baseTime)) { + return null; + } + + return new Date(baseTime + SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS).toISOString(); +} + +function mapReservationRow(row: RestartReservationRow | null | undefined): ServerRestartReservationSnapshot { + const rawSummary = row?.workload_summary_json; + let workloadSummary = getDefaultWorkloadSummary(); + + if (typeof rawSummary === 'string') { + try { + const parsed = JSON.parse(rawSummary) as Partial; + workloadSummary = { + codexRunningCount: Number(parsed.codexRunningCount ?? 0), + codexQueuedCount: Number(parsed.codexQueuedCount ?? 0), + automationRunningCount: Number(parsed.automationRunningCount ?? 0), + automationQueuedCount: Number(parsed.automationQueuedCount ?? 0), + }; + } catch { + workloadSummary = getDefaultWorkloadSummary(); + } + } else if (rawSummary && typeof rawSummary === 'object') { + workloadSummary = { + codexRunningCount: Number(rawSummary.codexRunningCount ?? 0), + codexQueuedCount: Number(rawSummary.codexQueuedCount ?? 0), + automationRunningCount: Number(rawSummary.automationRunningCount ?? 0), + automationQueuedCount: Number(rawSummary.automationQueuedCount ?? 0), + }; + } + + return { + enabled: Boolean(row?.enabled), + target: row?.target === 'all' ? 'all' : 'all', + status: row?.status ?? 'idle', + requestedAt: row?.requested_at ?? null, + requestedByClientId: row?.requested_by_client_id ?? null, + lastCheckedAt: row?.last_checked_at ?? null, + nextCheckAt: buildNextCheckAt(row), + waitingReason: row?.waiting_reason ?? null, + workloadSummary, + startedAt: row?.started_at ?? null, + completedAt: row?.completed_at ?? null, + cancelledAt: row?.cancelled_at ?? null, + lastError: row?.last_error ?? null, + activeClientCount: Number(row?.active_client_count ?? 0), + notifiedActiveClientsAt: row?.notified_active_clients_at ?? null, + appOrigin: row?.app_origin ?? null, + autoExecuteAt: row?.auto_execute_at ?? null, + autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)), + updatedAt: row?.updated_at ?? null, + }; +} + +async function ensureServerRestartReservationTable() { + const hasTable = await db.schema.hasTable(SERVER_RESTART_RESERVATION_TABLE); + + if (!hasTable) { + await db.schema.createTable(SERVER_RESTART_RESERVATION_TABLE, (table) => { + table.integer('id').primary(); + table.boolean('enabled').notNullable().defaultTo(false); + table.string('target', 20).notNullable().defaultTo('all'); + table.string('status', 20).notNullable().defaultTo('idle'); + table.timestamp('requested_at', { useTz: true }).nullable(); + table.string('requested_by_client_id', 120).nullable(); + table.timestamp('last_checked_at', { useTz: true }).nullable(); + table.text('waiting_reason').nullable(); + table.jsonb('workload_summary_json').notNullable().defaultTo('{}'); + table.timestamp('started_at', { useTz: true }).nullable(); + table.timestamp('completed_at', { useTz: true }).nullable(); + table.timestamp('cancelled_at', { useTz: true }).nullable(); + table.text('last_error').nullable(); + table.integer('active_client_count').notNullable().defaultTo(0); + table.timestamp('notified_active_clients_at', { useTz: true }).nullable(); + table.string('app_origin', 255).nullable(); + table.timestamp('auto_execute_at', { useTz: true }).nullable(); + table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['app_origin', (table) => table.string('app_origin', 255).nullable()], + ['auto_execute_at', (table) => table.timestamp('auto_execute_at', { useTz: true }).nullable()], + ['auto_execute_delay_seconds', (table) => table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10)], + ]; + + for (const [columnName, addColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(SERVER_RESTART_RESERVATION_TABLE, columnName); + if (!hasColumn) { + await db.schema.alterTable(SERVER_RESTART_RESERVATION_TABLE, (table) => { + addColumn(table); + }); + } + } + + const existing = await db(SERVER_RESTART_RESERVATION_TABLE).where({ id: SERVER_RESTART_RESERVATION_ROW_ID }).first(); + + if (!existing) { + await db(SERVER_RESTART_RESERVATION_TABLE).insert({ + id: SERVER_RESTART_RESERVATION_ROW_ID, + enabled: false, + target: 'all', + status: 'idle', + workload_summary_json: getDefaultWorkloadSummary(), + active_client_count: 0, + updated_at: db.fn.now(), + }); + } +} + +async function readReservationRow() { + await ensureServerRestartReservationTable(); + return (await db(SERVER_RESTART_RESERVATION_TABLE) + .where({ id: SERVER_RESTART_RESERVATION_ROW_ID }) + .first()) as RestartReservationRow | undefined; +} + +async function updateReservationRow(patch: Record) { + await ensureServerRestartReservationTable(); + await db(SERVER_RESTART_RESERVATION_TABLE) + .where({ id: SERVER_RESTART_RESERVATION_ROW_ID }) + .update({ + ...patch, + updated_at: db.fn.now(), + }); + return readReservationRow(); +} + +async function countPendingAutomationWork() { + return summarizeRestartReservationAutomationWork(await listBoardPosts()); +} + +function buildWaitingReason(summary: RestartReservationWorkloadSummary) { + const reasons: string[] = []; + + const codexPending = summary.codexRunningCount + summary.codexQueuedCount; + const automationPending = summary.automationRunningCount + summary.automationQueuedCount; + + if (codexPending > 0) { + reasons.push(`Codex Live ${codexPending}건`); + } + + if (automationPending > 0) { + reasons.push(`자동화 ${automationPending}건`); + } + + return reasons.length > 0 ? `${reasons.join(', ')} 진행 중이라 재기동을 대기합니다.` : null; +} + +async function listActiveClients() { + await ensureVisitorHistoryTables(); + const visitors = await listVisitorClients(50); + const cutoffTime = Date.now() - ACTIVE_CLIENT_WINDOW_MS; + + return visitors.filter((item) => { + const lastVisitedAt = Date.parse(item.lastVisitedAt); + return Number.isFinite(lastVisitedAt) && lastVisitedAt >= cutoffTime; + }); +} + +function waitForDuration(durationMs: number) { + return new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); +} + +function resolveAutoExecuteDelaySeconds(rawDelaySeconds: number | null | undefined) { + if (!Number.isFinite(rawDelaySeconds)) { + return 10; + } + + return Math.min(300, Math.max(1, Math.round(rawDelaySeconds ?? 10))); +} + +function buildAutoExecuteAt(baseTimestamp: string | null | undefined, autoExecuteDelaySeconds: number) { + const baseTime = Date.parse(baseTimestamp ?? ''); + + if (!Number.isFinite(baseTime)) { + return null; + } + + return new Date(baseTime + autoExecuteDelaySeconds * 1000).toISOString(); +} + +async function resolveReservationAutoExecuteDelaySeconds(row: RestartReservationRow) { + if (Number.isFinite(row.auto_execute_delay_seconds)) { + return resolveAutoExecuteDelaySeconds(row.auto_execute_delay_seconds); + } + + const appConfig = await getAppConfigSnapshot(row.app_origin ?? undefined); + return resolveAutoExecuteDelaySeconds(appConfig.chat?.restartReservationCompletionDelaySeconds ?? 10); +} + +function isReservationAutoExecuteDue(row: RestartReservationRow) { + const autoExecuteTime = Date.parse(row.auto_execute_at ?? ''); + + if (!Number.isFinite(autoExecuteTime)) { + return false; + } + + return Date.now() >= autoExecuteTime; +} + +function isReservationCheckDue(row: RestartReservationRow) { + const baseTimestamp = row.last_checked_at ?? row.requested_at ?? row.updated_at; + + if (!baseTimestamp) { + return true; + } + + const baseTime = Date.parse(baseTimestamp); + + if (!Number.isFinite(baseTime)) { + return true; + } + + return Date.now() - baseTime >= SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS; +} + +function hasRestartStartedAfterReservation( + serverStartedAt: string | null | undefined, + reservationStartedAt: string | null | undefined, +) { + const serverStartedTime = Date.parse(serverStartedAt ?? ''); + const reservationStartedTime = Date.parse(reservationStartedAt ?? ''); + + if (!Number.isFinite(serverStartedTime) || !Number.isFinite(reservationStartedTime)) { + return false; + } + + return serverStartedTime + RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime; +} + +export function hasReservedRestartVerification( + key: 'test' | 'work-server', + server: Pick< + ServerCommandSnapshot, + 'availability' | 'startedAt' | 'runningBuiltAt' | 'runningVersion' | 'buildRequired' | 'updateAvailable' + > | null | undefined, + reservationStartedAt: string | null | undefined, +) { + if (!server || server.availability !== 'online' || !hasRestartStartedAfterReservation(server.startedAt, reservationStartedAt)) { + return false; + } + + if (key === 'test') { + return Boolean(server.runningBuiltAt) && !server.buildRequired; + } + + return Boolean(server.runningVersion ?? server.runningBuiltAt) && !server.buildRequired && !server.updateAvailable; +} + +async function finalizeReservedRestart(row: RestartReservationRow) { + const statuses = await listServerCommands(); + const testServer = statuses.find((item) => item.key === 'test') ?? null; + const workServer = statuses.find((item) => item.key === 'work-server') ?? null; + const testVerified = hasReservedRestartVerification('test', testServer, row.started_at); + const workVerified = hasReservedRestartVerification('work-server', workServer, row.started_at); + + if (!testVerified || !workVerified) { + const waitingTargets = [ + !testVerified ? 'TEST 서버' : null, + !workVerified ? 'WORK 서버' : null, + ].filter((value): value is string => Boolean(value)); + + await updateReservationRow({ + enabled: true, + status: 'executing', + last_checked_at: db.fn.now(), + waiting_reason: `${waitingTargets.join(' / ')} 새 런타임과 정상 기동을 확인하는 중입니다.`, + last_error: null, + }); + return mapReservationRow(row); + } + + const nextRow = await updateReservationRow({ + enabled: false, + status: 'completed', + completed_at: db.fn.now(), + waiting_reason: null, + workload_summary_json: getDefaultWorkloadSummary(), + last_error: null, + last_checked_at: db.fn.now(), + auto_execute_at: null, + }); + + return mapReservationRow(nextRow); +} + +async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) { + const activeClients = await listActiveClients(); + await updateReservationRow({ + enabled: true, + status: 'executing', + started_at: row.started_at ?? db.fn.now(), + last_checked_at: db.fn.now(), + waiting_reason: null, + active_client_count: activeClients.length, + last_error: null, + }); + + if (activeClients.length > 0) { + await createNotificationMessage({ + title: '예약된 재기동 시작', + body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 TEST / WORK 서버 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`, + category: 'system', + source: SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE, + priority: 'high', + metadata: { + previewText: `예약된 재기동 시작 · 활성 클라이언트 ${activeClients.length}건`, + linkUrl: '/?topMenu=plans', + linkLabel: '작업 화면 열기', + }, + }); + await deleteOlderNotificationMessagesBySource(SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE, 1); + + await updateReservationRow({ + notified_active_clients_at: db.fn.now(), + active_client_count: activeClients.length, + }); + } + + logger.info( + { + requestedAt: row.requested_at, + requestedByClientId: row.requested_by_client_id, + activeClientCount: activeClients.length, + }, + 'Executing reserved restart', + ); + + await restartServerCommand('test'); + await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS); + + await updateReservationRow({ + enabled: true, + status: 'executing', + waiting_reason: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.', + last_checked_at: db.fn.now(), + }); + + await restartServerCommand('work-server'); +} + +export async function getServerRestartReservation() { + return mapReservationRow(await readReservationRow()); +} + +export async function scheduleServerRestartReservation(options?: { + clientId?: string | null; + appOrigin?: string | null; + autoExecuteDelaySeconds?: number | null; +}) { + const autoExecuteDelaySeconds = resolveAutoExecuteDelaySeconds(options?.autoExecuteDelaySeconds); + const row = await updateReservationRow({ + enabled: true, + target: 'all', + status: 'waiting', + requested_at: db.fn.now(), + requested_by_client_id: options?.clientId?.trim() || null, + last_checked_at: null, + waiting_reason: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.', + workload_summary_json: getDefaultWorkloadSummary(), + started_at: null, + completed_at: null, + cancelled_at: null, + last_error: null, + active_client_count: 0, + notified_active_clients_at: null, + app_origin: options?.appOrigin?.trim() || null, + auto_execute_at: null, + auto_execute_delay_seconds: autoExecuteDelaySeconds, + }); + + return mapReservationRow(row); +} + +export async function cancelServerRestartReservation() { + const row = await updateReservationRow({ + enabled: false, + status: 'cancelled', + cancelled_at: db.fn.now(), + waiting_reason: null, + workload_summary_json: getDefaultWorkloadSummary(), + active_client_count: 0, + last_error: null, + auto_execute_at: null, + }); + + return mapReservationRow(row); +} + +export async function confirmServerRestartReservation(logger: FastifyBaseLogger) { + const row = await readReservationRow(); + + if (!row?.enabled) { + return mapReservationRow(row); + } + + if (row.status === 'executing' || row.status === 'completed') { + return mapReservationRow(row); + } + + if (row.status !== 'ready') { + const error = new Error('재기동 예약이 아직 실행 준비 상태가 아닙니다.'); + (error as Error & { statusCode?: number }).statusCode = 409; + throw error; + } + + const nextRow = await updateReservationRow({ + enabled: true, + status: 'executing', + started_at: db.fn.now(), + last_checked_at: db.fn.now(), + waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.', + last_error: null, + auto_execute_at: null, + }); + + if (!nextRow) { + throw new Error('재기동 예약 상태를 갱신하지 못했습니다.'); + } + + void executeReservedRestart(logger, nextRow).catch(async (error) => { + const message = error instanceof Error ? error.message : '예약된 재기동 처리에 실패했습니다.'; + logger.error({ err: error }, 'Confirmed reserved restart failed'); + await updateReservationRow({ + enabled: false, + status: 'failed', + last_error: message, + waiting_reason: null, + }).catch(() => undefined); + }); + + return mapReservationRow(nextRow); +} + +export class ServerRestartReservationWorker { + private timer: ReturnType | null = null; + private running = false; + + constructor(private readonly logger: FastifyBaseLogger) {} + + start() { + if (this.timer) { + return; + } + + void this.tick(); + this.timer = setInterval(() => { + void this.tick(); + }, SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS); + } + + async stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async tick() { + if (this.running) { + return; + } + + this.running = true; + + try { + const row = await readReservationRow(); + + if (!row?.enabled) { + return; + } + + if (row.status === 'executing' && row.started_at) { + await finalizeReservedRestart(row); + return; + } + + if (!isReservationCheckDue(row)) { + return; + } + + const workloadSummary = await getRestartReservationWorkloadSummary(); + const waitingReason = buildWaitingReason(workloadSummary); + + if (!waitingReason && row.status === 'ready' && isReservationAutoExecuteDue(row)) { + await confirmServerRestartReservation(this.logger); + return; + } + + const autoExecuteDelaySeconds = await resolveReservationAutoExecuteDelaySeconds(row); + const autoExecuteAt = buildAutoExecuteAt( + row.status === 'ready' && row.auto_execute_at ? row.auto_execute_at : new Date().toISOString(), + autoExecuteDelaySeconds, + ); + + await updateReservationRow({ + status: waitingReason ? 'waiting' : 'ready', + last_checked_at: db.fn.now(), + waiting_reason: waitingReason + ?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 TEST/WORK 서버 재기동을 자동 시작합니다.`, + workload_summary_json: workloadSummary, + auto_execute_at: waitingReason + ? null + : row.status === 'ready' && row.auto_execute_at + ? row.auto_execute_at + : autoExecuteAt, + auto_execute_delay_seconds: autoExecuteDelaySeconds, + }); + + if (waitingReason) { + return; + } + } catch (error) { + const message = error instanceof Error ? error.message : '예약된 재기동 처리에 실패했습니다.'; + this.logger.error({ err: error }, 'Reserved restart processing failed'); + await updateReservationRow({ + enabled: false, + status: 'failed', + last_error: message, + waiting_reason: null, + }).catch(() => undefined); + } finally { + this.running = false; + } + } +} diff --git a/etc/servers/work-server/src/services/stock-alert-service.js b/etc/servers/work-server/src/services/stock-alert-service.js new file mode 100644 index 0000000..2b012f2 --- /dev/null +++ b/etc/servers/work-server/src/services/stock-alert-service.js @@ -0,0 +1,1627 @@ +"use strict"; +var __assign = (this && this.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { + if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { + if (ar || !(i in from)) { + if (!ar) ar = Array.prototype.slice.call(from, 0, i); + ar[i] = from[i]; + } + } + return to.concat(ar || Array.prototype.slice.call(from)); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.STOCK_ALERT_TYPE_OPTIONS = exports.STOCK_ALERT_LAYOUT_NAME = exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = exports.STOCK_ALERT_TABLE = void 0; +exports.resolveVolumeRate5dFromHistory = resolveVolumeRate5dFromHistory; +exports.searchStockAlertCandidates = searchStockAlertCandidates; +exports.resolveLatestQuoteFromMeta = resolveLatestQuoteFromMeta; +exports.resolveLatestQuoteFromNaverRealtime = resolveLatestQuoteFromNaverRealtime; +exports.fetchQuotesByCodes = fetchQuotesByCodes; +exports.listStockAlerts = listStockAlerts; +exports.createStockAlert = createStockAlert; +exports.updateStockAlert = updateStockAlert; +exports.deleteStockAlert = deleteStockAlert; +exports.saveStockAlerts = saveStockAlerts; +exports.buildCurrentPriceStockAlertLines = buildCurrentPriceStockAlertLines; +exports.buildChangeRateThresholdStockAlertLines = buildChangeRateThresholdStockAlertLines; +exports.buildChangeRateAndVolumeSpikeStockAlertCandidates = buildChangeRateAndVolumeSpikeStockAlertCandidates; +exports.buildChangeRateAndVolumeSpikeStockAlertLines = buildChangeRateAndVolumeSpikeStockAlertLines; +exports.buildStockAlertNotificationIdentity = buildStockAlertNotificationIdentity; +exports.sendManagedStockAlertWebPush = sendManagedStockAlertWebPush; +exports.sendCurrentPriceStockAlertWebPush = sendCurrentPriceStockAlertWebPush; +exports.updateStockAlertLayoutFeatureDescription = updateStockAlertLayoutFeatureDescription; +var notification_service_js_1 = require("./notification-service.js"); +var client_js_1 = require("../db/client.js"); +exports.STOCK_ALERT_TABLE = 'stock_alerts'; +exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots'; +exports.STOCK_ALERT_LAYOUT_NAME = 'stock알림'; +exports.STOCK_ALERT_TYPE_OPTIONS = [ + { value: 'all', label: '전체' }, + { value: 'price', label: '현재가' }, + { value: 'top3', label: '등락폭이 큰 상위3종목' }, +]; +var STOCK_ALERT_LABEL_MAP = new Map(exports.STOCK_ALERT_TYPE_OPTIONS.map(function (option) { return [option.value, option.label]; })); +var STOCK_ALERT_VALUE_SET = new Set(['price', 'top3']); +var KRX_CORP_LIST_URL = 'https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13'; +var KRX_CORP_LIST_CACHE_TTL_MS = 1000 * 60 * 60 * 12; +var STOCK_ALERT_NOTIFICATION_TITLE = '현재 주가'; +var STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN = 'test.sm-home.cloud'; +var STOCK_ALERT_NOTIFICATION_SCOPE = 'schedule-2-stock-alert'; +var STOCK_ALERT_NOTIFICATION_TARGET_URL = "https://".concat(STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN, "/?topMenu=play&playMenu=layout"); +var KOREA_TIMEZONE = 'Asia/Seoul'; +var KOREA_PREOPEN_RESET_HOUR = 5; +var KOREA_REGULAR_OPEN_HOUR = 9; +var cachedKrxListedStocks = null; +function normalizeTimestamp(value) { + if (!value) { + return new Date().toISOString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + var parsed = Date.parse(value); + return Number.isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString(); +} +function normalizeAlertType(value) { + var normalized = value.trim().toLowerCase(); + if (!STOCK_ALERT_VALUE_SET.has(normalized)) { + throw new Error('알림유형은 현재가 또는 등락폭이 큰 상위3종목만 저장할 수 있습니다.'); + } + return normalized; +} +function normalizeStockCode(value) { + var digits = value.replace(/\D+/g, ''); + return digits.length === 6 ? digits : ''; +} +function isFiniteNumber(value) { + return typeof value === 'number' && Number.isFinite(value); +} +function parseLooseNumber(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value !== 'string') { + return null; + } + var normalized = value.replace(/[^0-9.-]+/g, ''); + if (!normalized) { + return null; + } + var parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} +function applySign(value, sign) { + if (value === null || sign === null || value === 0) { + return value; + } + return Math.abs(value) * sign; +} +function resolveNaverDirectionSign(compareToPreviousPrice) { + var _a, _b; + var direction = ((_a = compareToPreviousPrice === null || compareToPreviousPrice === void 0 ? void 0 : compareToPreviousPrice.name) === null || _a === void 0 ? void 0 : _a.trim().toUpperCase()) || ((_b = compareToPreviousPrice === null || compareToPreviousPrice === void 0 ? void 0 : compareToPreviousPrice.code) === null || _b === void 0 ? void 0 : _b.trim()); + if (direction === 'FALLING' || direction === '4' || direction === '5') { + return -1; + } + if (direction === 'RISING' || direction === '2') { + return 1; + } + return null; +} +function resolveSignedNaverChangeRate(rate, compareToPreviousClosePrice, compareToPreviousPrice) { + var signedChangeAmount = parseLooseNumber(compareToPreviousClosePrice); + if (signedChangeAmount !== null && signedChangeAmount !== 0) { + return applySign(rate, signedChangeAmount < 0 ? -1 : 1); + } + return applySign(rate, resolveNaverDirectionSign(compareToPreviousPrice)); +} +function resolveCapturedTimestampMs(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + var parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} +function extractKoreaHour(timestampMs) { + var formatted = new Intl.DateTimeFormat('en-GB', { + timeZone: KOREA_TIMEZONE, + hour: '2-digit', + hour12: false, + }).format(new Date(timestampMs)); + var hour = Number(formatted); + return Number.isFinite(hour) ? hour : null; +} +function isKoreaMorningResetWindow(timestampMs) { + if (!Number.isFinite(timestampMs)) { + return false; + } + var koreaHour = extractKoreaHour(timestampMs); + return koreaHour !== null && koreaHour >= KOREA_PREOPEN_RESET_HOUR && koreaHour < KOREA_REGULAR_OPEN_HOUR; +} +function getAlertTypeLabel(value) { + var _a; + return (_a = STOCK_ALERT_LABEL_MAP.get(value)) !== null && _a !== void 0 ? _a : value; +} +function average(values) { + if (!values.length) { + return null; + } + var sum = values.reduce(function (acc, value) { return acc + value; }, 0); + return sum / values.length; +} +function resolveVolumeRate5dFromHistory(currentVolume, historicalVolumes) { + var _a; + var normalizedCurrentVolume = isFiniteNumber(currentVolume) && currentVolume >= 0 ? currentVolume : null; + var normalizedVolumes = historicalVolumes.filter(function (value) { return isFiniteNumber(value) && value >= 0; }); + if (normalizedVolumes.length < 2) { + return null; + } + var latestVolume = (_a = normalizedCurrentVolume !== null && normalizedCurrentVolume !== void 0 ? normalizedCurrentVolume : normalizedVolumes[normalizedVolumes.length - 1]) !== null && _a !== void 0 ? _a : null; + var previousFiveAverage = average(normalizedVolumes.slice(-6, -1)); + if (latestVolume === null || previousFiveAverage === null || previousFiveAverage <= 0) { + return null; + } + return (latestVolume / previousFiveAverage) * 100; +} +function normalizeNonNegativeVolume(value) { + var parsed = parseLooseNumber(value); + if (!isFiniteNumber(parsed) || parsed < 0) { + return null; + } + return Math.round(parsed); +} +function calculateVolumeIncreasePercent(currentVolume, previousVolume) { + if (!isFiniteNumber(currentVolume) || !isFiniteNumber(previousVolume) || previousVolume <= 0 || currentVolume < previousVolume) { + return null; + } + return ((currentVolume - previousVolume) / previousVolume) * 100; +} +function normalizeStockAlertVolumeSnapshotRow(row) { + var _a; + return { + stockCode: normalizeStockCode(row.stock_code), + stockName: String((_a = row.stock_name) !== null && _a !== void 0 ? _a : '').trim() || normalizeStockCode(row.stock_code), + previousVolume: normalizeNonNegativeVolume(row.previous_volume), + currentVolume: normalizeNonNegativeVolume(row.current_volume), + volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent), + currentPrice: parseLooseNumber(row.current_price), + changeRate: parseLooseNumber(row.change_rate), + quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null, + createdAt: normalizeTimestamp(row.created_at), + updatedAt: normalizeTimestamp(row.updated_at), + }; +} +function buildStockAlertVolumeSnapshotRecord(item, currentVolume, previousSnapshot) { + var _a, _b; + var now = new Date().toISOString(); + var normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume); + var previousCurrentVolume = (_a = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && _a !== void 0 ? _a : null; + var shouldResetBaseline = normalizedCurrentVolume !== null && + previousCurrentVolume !== null && + normalizedCurrentVolume < previousCurrentVolume; + var comparisonBaseline = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume; + var volumeIncreasePercent = calculateVolumeIncreasePercent(normalizedCurrentVolume, comparisonBaseline); + var nextPreviousVolume = shouldResetBaseline + ? normalizedCurrentVolume + : previousCurrentVolume !== null + ? previousCurrentVolume + : normalizedCurrentVolume; + return { + stock_code: item.stockCode, + stock_name: item.stockName, + previous_volume: nextPreviousVolume, + current_volume: normalizedCurrentVolume, + volume_increase_percent: volumeIncreasePercent, + current_price: item.currentPrice, + change_rate: item.changeRate, + quoted_at: item.quotedAt, + created_at: (_b = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.createdAt) !== null && _b !== void 0 ? _b : now, + updated_at: now, + }; +} +function buildStockSymbols(stockCode) { + var normalizedCode = normalizeStockCode(stockCode); + if (!normalizedCode) { + return []; + } + return ["".concat(normalizedCode, ".KS"), "".concat(normalizedCode, ".KQ")]; +} +function extractStockCodeFromSymbol(symbol) { + var match = symbol.trim().match(/^(\d{6})\.(?:KS|KQ)$/i); + return match ? match[1] : ''; +} +function resolveMarketLabel(quote) { + var _a, _b, _c, _d, _e; + var symbol = (_b = (_a = quote.symbol) === null || _a === void 0 ? void 0 : _a.trim().toUpperCase()) !== null && _b !== void 0 ? _b : ''; + if (symbol.endsWith('.KS')) { + return 'KOSPI'; + } + if (symbol.endsWith('.KQ')) { + return 'KOSDAQ'; + } + var exchange = ((_c = quote.exchDisp) === null || _c === void 0 ? void 0 : _c.trim()) || ((_d = quote.exchange) === null || _d === void 0 ? void 0 : _d.trim()) || ((_e = quote.typeDisp) === null || _e === void 0 ? void 0 : _e.trim()); + if (!exchange) { + return '기타'; + } + if (/KOSDAQ/i.test(exchange)) { + return 'KOSDAQ'; + } + if (/KOSPI|KOSE/i.test(exchange)) { + return 'KOSPI'; + } + return exchange; +} +function ensureStockAlertTable() { + return __awaiter(this, void 0, void 0, function () { + var exists; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.STOCK_ALERT_TABLE)]; + case 1: + exists = _a.sent(); + if (exists) { + return [2 /*return*/]; + } + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.STOCK_ALERT_TABLE, function (table) { + table.text('id').primary(); + table.text('stock_code').notNullable(); + table.text('stock_name').notNullable(); + table.text('alert_type').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable(); + table.timestamp('updated_at', { useTz: true }).notNullable(); + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function ensureStockAlertVolumeSnapshotTable() { + return __awaiter(this, void 0, void 0, function () { + var exists; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE)]; + case 1: + exists = _a.sent(); + if (exists) { + return [2 /*return*/]; + } + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE, function (table) { + table.text('stock_code').primary(); + table.text('stock_name').notNullable(); + table.bigInteger('previous_volume').nullable(); + table.bigInteger('current_volume').nullable(); + table.decimal('volume_increase_percent', 10, 2).nullable(); + table.decimal('current_price', 14, 2).nullable(); + table.decimal('change_rate', 10, 4).nullable(); + table.timestamp('quoted_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable(); + table.timestamp('updated_at', { useTz: true }).notNullable(); + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function fetchJson(url, init) { + return __awaiter(this, void 0, void 0, function () { + var response; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, fetch(url, __assign(__assign({}, init), { headers: __assign({ accept: 'application/json', 'user-agent': 'ai-code-app/stock-alert' }, ((_a = init === null || init === void 0 ? void 0 : init.headers) !== null && _a !== void 0 ? _a : {})) }))]; + case 1: + response = _b.sent(); + if (!response.ok) { + throw new Error("\uC678\uBD80 \uC2DC\uC138 \uC751\uB2F5\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. (".concat(response.status, ")")); + } + return [2 /*return*/, response.json()]; + } + }); + }); +} +function fetchText(url, init) { + return __awaiter(this, void 0, void 0, function () { + var response; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, fetch(url, __assign(__assign({}, init), { headers: __assign({ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'user-agent': 'ai-code-app/stock-alert' }, ((_a = init === null || init === void 0 ? void 0 : init.headers) !== null && _a !== void 0 ? _a : {})) }))]; + case 1: + response = _b.sent(); + if (!response.ok) { + throw new Error("\uC678\uBD80 \uC885\uBAA9 \uAC80\uC0C9 \uC751\uB2F5\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. (".concat(response.status, ")")); + } + return [2 /*return*/, response.arrayBuffer()]; + } + }); + }); +} +function decodeEucKr(value) { + return new TextDecoder('euc-kr').decode(value); +} +function decodeHtmlEntities(value) { + return value + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/'/g, "'") + .replace(/"/gi, '"'); +} +function stripHtmlTags(value) { + return decodeHtmlEntities(value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); +} +function normalizeSearchKeyword(value) { + return value.trim().replace(/\s+/g, '').toLowerCase(); +} +function normalizeMarketLabel(value) { + var trimmedValue = value.trim(); + if (/코스닥/i.test(trimmedValue)) { + return 'KOSDAQ'; + } + if (/유가증권|코스피|유가/i.test(trimmedValue)) { + return 'KOSPI'; + } + return trimmedValue || '기타'; +} +function parseKrxListedStocks(html) { + var _a; + var rowMatches = (_a = html.match(//gi)) !== null && _a !== void 0 ? _a : []; + var items = []; + rowMatches.forEach(function (rowHtml) { + var _a, _b, _c, _d; + var cellMatches = (_a = rowHtml.match(//gi)) !== null && _a !== void 0 ? _a : []; + if (cellMatches.length < 3) { + return; + } + var stockName = stripHtmlTags((_b = cellMatches[0]) !== null && _b !== void 0 ? _b : ''); + var market = normalizeMarketLabel(stripHtmlTags((_c = cellMatches[1]) !== null && _c !== void 0 ? _c : '')); + var stockCode = normalizeStockCode(stripHtmlTags((_d = cellMatches[2]) !== null && _d !== void 0 ? _d : '')); + if (!stockCode || !stockName) { + return; + } + items.push({ + stockCode: stockCode, + stockName: stockName, + market: market, + }); + }); + return items; +} +function findKrxListedStockByCode(stockCode) { + return __awaiter(this, void 0, void 0, function () { + var normalizedCode, items; + var _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + normalizedCode = normalizeStockCode(stockCode); + if (!normalizedCode) { + return [2 /*return*/, null]; + } + return [4 /*yield*/, fetchKrxListedStocks()]; + case 1: + items = _b.sent(); + return [2 /*return*/, (_a = items.find(function (item) { return item.stockCode === normalizedCode; })) !== null && _a !== void 0 ? _a : null]; + } + }); + }); +} +function fetchKrxListedStocks() { + return __awaiter(this, void 0, void 0, function () { + var buffer, decodedHtml, items; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + if (cachedKrxListedStocks && cachedKrxListedStocks.expiresAt > Date.now()) { + return [2 /*return*/, cachedKrxListedStocks.items]; + } + return [4 /*yield*/, fetchText(KRX_CORP_LIST_URL)]; + case 1: + buffer = _a.sent(); + decodedHtml = decodeEucKr(buffer); + items = parseKrxListedStocks(decodedHtml); + cachedKrxListedStocks = { + expiresAt: Date.now() + KRX_CORP_LIST_CACHE_TTL_MS, + items: items, + }; + return [2 /*return*/, items]; + } + }); + }); +} +function searchKrxListedStocks(query_1) { + return __awaiter(this, arguments, void 0, function (query, limit) { + var normalizedKeyword, items, matchedItems; + if (limit === void 0) { limit = 20; } + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + normalizedKeyword = normalizeSearchKeyword(query); + if (!normalizedKeyword) { + return [2 /*return*/, []]; + } + return [4 /*yield*/, fetchKrxListedStocks()]; + case 1: + items = _a.sent(); + matchedItems = items.filter(function (item) { + var normalizedCode = item.stockCode.toLowerCase(); + var normalizedName = normalizeSearchKeyword(item.stockName); + return normalizedCode.includes(normalizedKeyword) || normalizedName.includes(normalizedKeyword); + }); + matchedItems.sort(function (left, right) { + var trimmedQuery = query.trim(); + var leftExactCode = left.stockCode === trimmedQuery ? 1 : 0; + var rightExactCode = right.stockCode === trimmedQuery ? 1 : 0; + if (leftExactCode !== rightExactCode) { + return rightExactCode - leftExactCode; + } + var leftExactName = normalizeSearchKeyword(left.stockName) === normalizedKeyword ? 1 : 0; + var rightExactName = normalizeSearchKeyword(right.stockName) === normalizedKeyword ? 1 : 0; + if (leftExactName !== rightExactName) { + return rightExactName - leftExactName; + } + var leftStartsWith = normalizeSearchKeyword(left.stockName).startsWith(normalizedKeyword) ? 1 : 0; + var rightStartsWith = normalizeSearchKeyword(right.stockName).startsWith(normalizedKeyword) ? 1 : 0; + if (leftStartsWith !== rightStartsWith) { + return rightStartsWith - leftStartsWith; + } + var leftLengthGap = Math.abs(left.stockName.length - trimmedQuery.length); + var rightLengthGap = Math.abs(right.stockName.length - trimmedQuery.length); + if (leftLengthGap !== rightLengthGap) { + return leftLengthGap - rightLengthGap; + } + return left.stockName.localeCompare(right.stockName, 'ko-KR'); + }); + return [2 /*return*/, matchedItems.slice(0, Math.max(1, Math.min(50, limit)))]; + } + }); + }); +} +function resolveStockIdentity(input) { + return __awaiter(this, void 0, void 0, function () { + var codeFromInput, trimmedName, krxMatch, quotes, quote, krxMatches, exactMatch, searchUrl, payload, matchedQuote, resolvedCode; + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k; + return __generator(this, function (_l) { + switch (_l.label) { + case 0: + codeFromInput = normalizeStockCode((_a = input.stockCode) !== null && _a !== void 0 ? _a : ''); + trimmedName = (_c = (_b = input.stockName) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : ''; + if (!codeFromInput) return [3 /*break*/, 3]; + return [4 /*yield*/, findKrxListedStockByCode(codeFromInput)]; + case 1: + krxMatch = _l.sent(); + if (krxMatch) { + return [2 /*return*/, { + stockCode: codeFromInput, + stockName: krxMatch.stockName, + }]; + } + if (trimmedName) { + return [2 /*return*/, { + stockCode: codeFromInput, + stockName: trimmedName, + }]; + } + return [4 /*yield*/, fetchQuotesByCodes([codeFromInput])]; + case 2: + quotes = _l.sent(); + quote = quotes.get(codeFromInput); + if (quote) { + return [2 /*return*/, { + stockCode: codeFromInput, + stockName: (_d = quote.stockName) !== null && _d !== void 0 ? _d : codeFromInput, + }]; + } + return [2 /*return*/, { + stockCode: codeFromInput, + stockName: codeFromInput, + }]; + case 3: + if (!trimmedName) { + throw new Error('종목명을 입력해 주세요.'); + } + return [4 /*yield*/, searchKrxListedStocks(trimmedName, 10)]; + case 4: + krxMatches = _l.sent(); + exactMatch = (_f = (_e = krxMatches.find(function (item) { return normalizeSearchKeyword(item.stockName) === normalizeSearchKeyword(trimmedName); })) !== null && _e !== void 0 ? _e : krxMatches[0]) !== null && _f !== void 0 ? _f : null; + if (exactMatch) { + return [2 /*return*/, { + stockCode: exactMatch.stockCode, + stockName: exactMatch.stockName, + }]; + } + searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); + searchUrl.searchParams.set('q', trimmedName); + searchUrl.searchParams.set('quotesCount', '10'); + searchUrl.searchParams.set('newsCount', '0'); + searchUrl.searchParams.set('lang', 'ko-KR'); + searchUrl.searchParams.set('region', 'KR'); + return [4 /*yield*/, fetchJson(searchUrl)]; + case 5: + payload = _l.sent(); + matchedQuote = (_h = (_g = payload.quotes) === null || _g === void 0 ? void 0 : _g.find(function (quote) { return typeof quote.symbol === 'string' && extractStockCodeFromSymbol(quote.symbol).length === 6; })) !== null && _h !== void 0 ? _h : null; + if (!(matchedQuote === null || matchedQuote === void 0 ? void 0 : matchedQuote.symbol)) { + throw new Error("\uC885\uBAA9\uBA85 \"".concat(trimmedName, "\"\uC5D0 \uD574\uB2F9\uD558\uB294 \uC885\uBAA9\uCF54\uB4DC\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.")); + } + resolvedCode = extractStockCodeFromSymbol(matchedQuote.symbol); + if (!resolvedCode) { + throw new Error("\uC885\uBAA9\uBA85 \"".concat(trimmedName, "\"\uC5D0 \uD574\uB2F9\uD558\uB294 \uC885\uBAA9\uCF54\uB4DC\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.")); + } + return [2 /*return*/, { + stockCode: resolvedCode, + stockName: ((_j = matchedQuote.shortname) === null || _j === void 0 ? void 0 : _j.trim()) || ((_k = matchedQuote.longname) === null || _k === void 0 ? void 0 : _k.trim()) || trimmedName, + }]; + } + }); + }); +} +function searchYahooStocks(query_1) { + return __awaiter(this, arguments, void 0, function (query, quotesCount) { + var trimmedQuery, searchUrl, payload, error_1; + var _a; + if (quotesCount === void 0) { quotesCount = 20; } + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + trimmedQuery = query.trim(); + if (!trimmedQuery) { + return [2 /*return*/, []]; + } + searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); + searchUrl.searchParams.set('q', trimmedQuery); + searchUrl.searchParams.set('quotesCount', String(Math.max(1, Math.min(50, quotesCount)))); + searchUrl.searchParams.set('newsCount', '0'); + searchUrl.searchParams.set('lang', 'ko-KR'); + searchUrl.searchParams.set('region', 'KR'); + _b.label = 1; + case 1: + _b.trys.push([1, 3, , 4]); + return [4 /*yield*/, fetchJson(searchUrl)]; + case 2: + payload = _b.sent(); + return [2 /*return*/, (_a = payload.quotes) !== null && _a !== void 0 ? _a : []]; + case 3: + error_1 = _b.sent(); + // Yahoo search rejects some non-code Korean queries with HTTP 400. + if (/[^\x00-\x7F]/.test(trimmedQuery)) { + return [2 /*return*/, []]; + } + throw error_1; + case 4: return [2 /*return*/]; + } + }); + }); +} +function searchStockAlertCandidates(query_1) { + return __awaiter(this, arguments, void 0, function (query, limit) { + var normalizedLimit, _a, krxItems, quotes, seenCodes, items, krxByCode; + if (limit === void 0) { limit = 20; } + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + normalizedLimit = Math.max(1, Math.min(50, limit)); + return [4 /*yield*/, Promise.all([ + searchKrxListedStocks(query, normalizedLimit), + searchYahooStocks(query, normalizedLimit * 2), + ])]; + case 1: + _a = _b.sent(), krxItems = _a[0], quotes = _a[1]; + seenCodes = new Set(); + items = __spreadArray([], krxItems, true); + krxByCode = new Map(krxItems.map(function (item) { return [item.stockCode, item]; })); + krxItems.forEach(function (item) { + seenCodes.add(item.stockCode); + }); + quotes.forEach(function (quote) { + var _a, _b, _c, _d; + if (!quote.symbol) { + return; + } + var stockCode = extractStockCodeFromSymbol(quote.symbol); + if (!stockCode || seenCodes.has(stockCode)) { + return; + } + var stockName = ((_a = quote.shortname) === null || _a === void 0 ? void 0 : _a.trim()) || ((_b = quote.longname) === null || _b === void 0 ? void 0 : _b.trim()) || stockCode; + var krxMatch = krxByCode.get(stockCode); + seenCodes.add(stockCode); + items.push({ + stockCode: stockCode, + stockName: (_c = krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.stockName) !== null && _c !== void 0 ? _c : stockName, + market: (_d = krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.market) !== null && _d !== void 0 ? _d : resolveMarketLabel(quote), + }); + }); + return [2 /*return*/, items.slice(0, normalizedLimit)]; + } + }); + }); +} +function ensureNoDuplicateStockCode(stockCode, currentId) { + return __awaiter(this, void 0, void 0, function () { + var normalizedCode, existing; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + normalizedCode = normalizeStockCode(stockCode); + if (!normalizedCode) { + throw new Error('종목코드를 확인할 수 없습니다.'); + } + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('id') + .where({ stock_code: normalizedCode }) + .modify(function (query) { + if (currentId === null || currentId === void 0 ? void 0 : currentId.trim()) { + query.whereNot('id', currentId.trim()); + } + }) + .first()]; + case 1: + existing = (_a.sent()); + if (existing === null || existing === void 0 ? void 0 : existing.id) { + throw new Error('이미 추가된 종목입니다.'); + } + return [2 /*return*/]; + } + }); + }); +} +function resolveLatestQuoteFromMeta(meta) { + var _a, _b, _c, _d; + var marketState = String((_a = meta.marketState) !== null && _a !== void 0 ? _a : '') + .trim() + .toUpperCase(); + var shouldPreferPremarket = ['PRE', 'PREPRE'].includes(marketState); + var shouldPreferPostmarket = ['POST', 'POSTPOST', 'CLOSED'].includes(marketState); + var preferredCandidate = shouldPreferPremarket + ? { + price: meta.preMarketPrice, + changeRate: meta.preMarketChangePercent, + time: meta.preMarketTime, + } + : shouldPreferPostmarket + ? { + price: meta.postMarketPrice, + changeRate: meta.postMarketChangePercent, + time: meta.postMarketTime, + } + : { + price: meta.regularMarketPrice, + changeRate: meta.regularMarketChangePercent, + time: meta.regularMarketTime, + }; + var quoteCandidates = [ + { + price: meta.regularMarketPrice, + changeRate: meta.regularMarketChangePercent, + time: meta.regularMarketTime, + }, + { + price: meta.preMarketPrice, + changeRate: meta.preMarketChangePercent, + time: meta.preMarketTime, + }, + { + price: meta.postMarketPrice, + changeRate: meta.postMarketChangePercent, + time: meta.postMarketTime, + }, + ]; + var latestCandidate = (_b = quoteCandidates + .flatMap(function (item) { + return isFiniteNumber(item.price) && isFiniteNumber(item.time) + ? [ + { + price: item.price, + changeRate: item.changeRate, + time: item.time, + }, + ] + : []; + }) + .sort(function (left, right) { return right.time - left.time; })[0]) !== null && _b !== void 0 ? _b : null; + var resolvedCandidate = isFiniteNumber(preferredCandidate.price) && isFiniteNumber(preferredCandidate.time) ? preferredCandidate : latestCandidate; + var currentPrice = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.price) + ? resolvedCandidate.price + : isFiniteNumber(meta.regularMarketPrice) + ? meta.regularMarketPrice + : null; + var quotedAt = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.time) + ? new Date(resolvedCandidate.time * 1000).toISOString() + : isFiniteNumber(meta.regularMarketTime) + ? new Date(meta.regularMarketTime * 1000).toISOString() + : null; + var previousClose = typeof meta.chartPreviousClose === 'number' + ? meta.chartPreviousClose + : typeof meta.previousClose === 'number' + ? meta.previousClose + : null; + var changeRate = isFiniteNumber(resolvedCandidate === null || resolvedCandidate === void 0 ? void 0 : resolvedCandidate.changeRate) + ? resolvedCandidate.changeRate + : currentPrice !== null && previousClose !== null && previousClose !== 0 + ? ((currentPrice - previousClose) / previousClose) * 100 + : null; + return { + currentPrice: currentPrice, + changeRate: changeRate, + volumeRate5d: null, + currentVolume: null, + quotedAt: quotedAt, + stockName: ((_c = meta.shortName) === null || _c === void 0 ? void 0 : _c.trim()) || ((_d = meta.longName) === null || _d === void 0 ? void 0 : _d.trim()) || null, + }; +} +function resolveLatestQuoteFromNaverRealtime(data, capturedAt) { + var _a, _b, _c, _d, _e; + var overMarketInfo = data.nxtOverMarketPriceInfo; + var overPrice = parseLooseNumber(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.overPrice); + var overChangeRate = resolveSignedNaverChangeRate(parseLooseNumber(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.fluctuationsRatio), overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.compareToPreviousClosePrice, overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.compareToPreviousPrice); + var overQuotedAt = ((_a = overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.localTradedAt) === null || _a === void 0 ? void 0 : _a.trim()) || null; + var hasExtendedSessionQuote = overPrice !== null && overQuotedAt; + var baseQuotedAt = typeof capturedAt === 'number' && Number.isFinite(capturedAt) + ? new Date(capturedAt).toISOString() + : typeof capturedAt === 'string' && capturedAt.trim() + ? new Date(capturedAt).toISOString() + : null; + var capturedTimestampMs = resolveCapturedTimestampMs(capturedAt); + var basePrice = isFiniteNumber(data.nv) ? data.nv : null; + var baseChangeRate = applySign(isFiniteNumber(data.cr) ? data.cr : null, resolveNaverDirectionSign(((_b = data.rf) === null || _b === void 0 ? void 0 : _b.trim()) ? { code: data.rf } : undefined)); + var previousClosePrice = isFiniteNumber(data.pcv) ? data.pcv : null; + var currentVolume = (_d = (_c = normalizeNonNegativeVolume(overMarketInfo === null || overMarketInfo === void 0 ? void 0 : overMarketInfo.accumulatedTradingVolume)) !== null && _c !== void 0 ? _c : normalizeNonNegativeVolume(data.accumulatedTradingVolume)) !== null && _d !== void 0 ? _d : normalizeNonNegativeVolume(data.aq); + var shouldResetToPreviousClose = !hasExtendedSessionQuote && + isKoreaMorningResetWindow(capturedTimestampMs) && + previousClosePrice !== null; + return { + currentPrice: hasExtendedSessionQuote ? overPrice : shouldResetToPreviousClose ? previousClosePrice : basePrice, + changeRate: hasExtendedSessionQuote ? overChangeRate : shouldResetToPreviousClose ? 0 : baseChangeRate, + volumeRate5d: null, + currentVolume: currentVolume, + quotedAt: hasExtendedSessionQuote ? overQuotedAt : baseQuotedAt, + stockName: ((_e = data.nm) === null || _e === void 0 ? void 0 : _e.trim()) || null, + }; +} +function choosePreferredQuote(primary, fallback) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j; + if ((primary === null || primary === void 0 ? void 0 : primary.currentPrice) === null && (fallback === null || fallback === void 0 ? void 0 : fallback.currentPrice) === null) { + return (_a = primary !== null && primary !== void 0 ? primary : fallback) !== null && _a !== void 0 ? _a : null; + } + if (!primary) { + return fallback; + } + if (!fallback) { + return primary; + } + var primaryQuotedAt = primary.quotedAt ? Date.parse(primary.quotedAt) : Number.NaN; + var fallbackQuotedAt = fallback.quotedAt ? Date.parse(fallback.quotedAt) : Number.NaN; + if (Number.isFinite(primaryQuotedAt) && Number.isFinite(fallbackQuotedAt) && fallbackQuotedAt > primaryQuotedAt) { + return __assign(__assign({}, fallback), { stockName: (_b = fallback.stockName) !== null && _b !== void 0 ? _b : primary.stockName, currentVolume: (_c = fallback.currentVolume) !== null && _c !== void 0 ? _c : primary.currentVolume }); + } + return __assign(__assign({}, primary), { stockName: (_d = primary.stockName) !== null && _d !== void 0 ? _d : fallback.stockName, currentPrice: (_e = primary.currentPrice) !== null && _e !== void 0 ? _e : fallback.currentPrice, changeRate: (_f = primary.changeRate) !== null && _f !== void 0 ? _f : fallback.changeRate, volumeRate5d: (_g = primary.volumeRate5d) !== null && _g !== void 0 ? _g : fallback.volumeRate5d, currentVolume: (_h = primary.currentVolume) !== null && _h !== void 0 ? _h : fallback.currentVolume, quotedAt: (_j = primary.quotedAt) !== null && _j !== void 0 ? _j : fallback.quotedAt }); +} +function fetchNaverRealtimeQuoteByCode(stockCode) { + return __awaiter(this, void 0, void 0, function () { + var quoteUrl, payload, data; + var _a, _b, _c, _d, _e; + return __generator(this, function (_f) { + switch (_f.label) { + case 0: + quoteUrl = new URL('https://polling.finance.naver.com/api/realtime'); + quoteUrl.searchParams.set('query', "SERVICE_ITEM:".concat(stockCode)); + return [4 /*yield*/, fetchJson(quoteUrl, { + headers: { + accept: '*/*', + }, + })]; + case 1: + payload = _f.sent(); + data = (_d = (_c = (_b = (_a = payload.result) === null || _a === void 0 ? void 0 : _a.areas) === null || _b === void 0 ? void 0 : _b.find(function (area) { return area.name === 'SERVICE_ITEM'; })) === null || _c === void 0 ? void 0 : _c.datas) === null || _d === void 0 ? void 0 : _d[0]; + if (!data) { + return [2 /*return*/, null]; + } + return [2 /*return*/, resolveLatestQuoteFromNaverRealtime(data, (_e = payload.result) === null || _e === void 0 ? void 0 : _e.time)]; + } + }); + }); +} +function fetchQuoteBySymbol(symbol) { + return __awaiter(this, void 0, void 0, function () { + var quoteUrl, payload, result, meta, quote, dailyVolumes; + var _a, _b, _c, _d, _e, _f, _g, _h; + return __generator(this, function (_j) { + switch (_j.label) { + case 0: + quoteUrl = new URL("https://query1.finance.yahoo.com/v8/finance/chart/".concat(symbol)); + quoteUrl.searchParams.set('range', '3mo'); + quoteUrl.searchParams.set('interval', '1d'); + quoteUrl.searchParams.set('includePrePost', 'true'); + quoteUrl.searchParams.set('lang', 'ko-KR'); + quoteUrl.searchParams.set('region', 'KR'); + return [4 /*yield*/, fetchJson(quoteUrl)]; + case 1: + payload = _j.sent(); + result = (_b = (_a = payload.chart) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b[0]; + meta = result === null || result === void 0 ? void 0 : result.meta; + if (!meta) { + return [2 /*return*/, null]; + } + quote = resolveLatestQuoteFromMeta(meta); + dailyVolumes = (_f = (_e = (_d = (_c = result === null || result === void 0 ? void 0 : result.indicators) === null || _c === void 0 ? void 0 : _c.quote) === null || _d === void 0 ? void 0 : _d[0]) === null || _e === void 0 ? void 0 : _e.volume) !== null && _f !== void 0 ? _f : []; + return [2 /*return*/, __assign(__assign({}, quote), { currentVolume: (_g = dailyVolumes[dailyVolumes.length - 1]) !== null && _g !== void 0 ? _g : null, volumeRate5d: resolveVolumeRate5dFromHistory((_h = dailyVolumes[dailyVolumes.length - 1]) !== null && _h !== void 0 ? _h : null, dailyVolumes) })]; + } + }); + }); +} +function fetchQuotesByCodes(stockCodes) { + return __awaiter(this, void 0, void 0, function () { + var normalizedCodes, quoteMap; + var _this = this; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: + normalizedCodes = Array.from(new Set(stockCodes + .map(function (value) { return normalizeStockCode(value); }) + .filter(Boolean))); + quoteMap = new Map(); + if (!normalizedCodes.length) { + return [2 /*return*/, quoteMap]; + } + return [4 /*yield*/, Promise.all(normalizedCodes.map(function (stockCode) { return __awaiter(_this, void 0, void 0, function () { + var preferredQuote, _a, symbols, _i, symbols_1, symbol, yahooQuote, _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: + preferredQuote = null; + _c.label = 1; + case 1: + _c.trys.push([1, 3, , 4]); + return [4 /*yield*/, fetchNaverRealtimeQuoteByCode(stockCode)]; + case 2: + preferredQuote = _c.sent(); + return [3 /*break*/, 4]; + case 3: + _a = _c.sent(); + return [3 /*break*/, 4]; + case 4: + symbols = buildStockSymbols(stockCode); + _i = 0, symbols_1 = symbols; + _c.label = 5; + case 5: + if (!(_i < symbols_1.length)) return [3 /*break*/, 10]; + symbol = symbols_1[_i]; + _c.label = 6; + case 6: + _c.trys.push([6, 8, , 9]); + return [4 /*yield*/, fetchQuoteBySymbol(symbol)]; + case 7: + yahooQuote = _c.sent(); + preferredQuote = choosePreferredQuote(preferredQuote, yahooQuote); + if (preferredQuote && + (preferredQuote.currentPrice !== null || preferredQuote.stockName) && + preferredQuote.volumeRate5d !== null) { + quoteMap.set(stockCode, preferredQuote); + return [2 /*return*/]; + } + return [3 /*break*/, 9]; + case 8: + _b = _c.sent(); + return [3 /*break*/, 9]; + case 9: + _i++; + return [3 /*break*/, 5]; + case 10: + if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName || preferredQuote.volumeRate5d !== null)) { + quoteMap.set(stockCode, preferredQuote); + } + return [2 /*return*/]; + } + }); + }); }))]; + case 1: + _a.sent(); + return [2 /*return*/, quoteMap]; + } + }); + }); +} +function listStockAlerts() { + return __awaiter(this, arguments, void 0, function (filterType) { + var rows, matchedCodes, quotes, krxItems, krxByCode, groupedItems; + if (filterType === void 0) { filterType = 'all'; } + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureStockAlertTable()]; + case 1: + _a.sent(); + rows = []; + if (!(filterType === 'all')) return [3 /*break*/, 3]; + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).select('*').orderBy('updated_at', 'desc')]; + case 2: + rows = (_a.sent()); + return [3 /*break*/, 6]; + case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('stock_code') + .where({ alert_type: normalizeAlertType(filterType) }) + .groupBy('stock_code')]; + case 4: + matchedCodes = (_a.sent()).map(function (row) { return row.stock_code; }); + if (!matchedCodes.length) { + return [2 /*return*/, []]; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('*') + .whereIn('stock_code', matchedCodes) + .orderBy('updated_at', 'desc')]; + case 5: + rows = (_a.sent()); + _a.label = 6; + case 6: return [4 /*yield*/, fetchQuotesByCodes(rows.map(function (row) { return row.stock_code; }))]; + case 7: + quotes = _a.sent(); + return [4 /*yield*/, fetchKrxListedStocks()]; + case 8: + krxItems = _a.sent(); + krxByCode = new Map(krxItems.map(function (item) { return [item.stockCode, item]; })); + groupedItems = new Map(); + rows.forEach(function (row) { + var _a, _b, _c, _d, _e; + var alertType = normalizeAlertType(row.alert_type); + var quote = quotes.get(row.stock_code); + var krxMatch = krxByCode.get(row.stock_code); + var existing = groupedItems.get(row.stock_code); + if (existing) { + if (!existing.alertTypes.includes(alertType)) { + existing.alertTypes.push(alertType); + existing.alertTypeLabels.push(getAlertTypeLabel(alertType)); + } + var updatedAtTime = Date.parse(normalizeTimestamp(row.updated_at)); + var existingUpdatedAtTime = Date.parse(existing.updatedAt); + if (Number.isFinite(updatedAtTime) && (!Number.isFinite(existingUpdatedAtTime) || updatedAtTime > existingUpdatedAtTime)) { + existing.updatedAt = normalizeTimestamp(row.updated_at); + } + return; + } + groupedItems.set(row.stock_code, { + id: row.stock_code, + stockCode: row.stock_code, + stockName: row.stock_name || (krxMatch === null || krxMatch === void 0 ? void 0 : krxMatch.stockName) || (quote === null || quote === void 0 ? void 0 : quote.stockName) || row.stock_code, + alertTypes: [alertType], + alertTypeLabels: [getAlertTypeLabel(alertType)], + currentPrice: (_a = quote === null || quote === void 0 ? void 0 : quote.currentPrice) !== null && _a !== void 0 ? _a : null, + changeRate: (_b = quote === null || quote === void 0 ? void 0 : quote.changeRate) !== null && _b !== void 0 ? _b : null, + volumeRate5d: (_c = quote === null || quote === void 0 ? void 0 : quote.volumeRate5d) !== null && _c !== void 0 ? _c : null, + currentVolume: (_d = quote === null || quote === void 0 ? void 0 : quote.currentVolume) !== null && _d !== void 0 ? _d : null, + quotedAt: (_e = quote === null || quote === void 0 ? void 0 : quote.quotedAt) !== null && _e !== void 0 ? _e : null, + createdAt: normalizeTimestamp(row.created_at), + updatedAt: normalizeTimestamp(row.updated_at), + }); + }); + return [2 /*return*/, Array.from(groupedItems.values()).sort(function (left, right) { return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); })]; + } + }); + }); +} +function createStockAlert(input) { + return __awaiter(this, void 0, void 0, function () { + var identity, alertTypes, existingRows, mergedAlertTypes, now, created; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureStockAlertTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, resolveStockIdentity(input)]; + case 2: + identity = _a.sent(); + alertTypes = Array.from(new Set(input.alertTypes.map(function (value) { return normalizeAlertType(value); }))); + if (!alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('*') + .where({ stock_code: identity.stockCode })]; + case 3: + existingRows = (_a.sent()); + if (existingRows.length) { + mergedAlertTypes = Array.from(new Set(__spreadArray(__spreadArray([], existingRows.map(function (row) { return normalizeAlertType(row.alert_type); }), true), alertTypes, true))); + return [2 /*return*/, updateStockAlert(identity.stockCode, { + stockCode: identity.stockCode, + stockName: identity.stockName, + alertTypes: mergedAlertTypes, + })]; + } + now = new Date().toISOString(); + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).insert(alertTypes.map(function (alertType) { return ({ + id: "stock-alert-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), + stock_code: identity.stockCode, + stock_name: identity.stockName, + alert_type: alertType, + created_at: now, + updated_at: now, + }); }))]; + case 4: + _a.sent(); + return [4 /*yield*/, listStockAlerts('all').then(function (rows) { return rows.filter(function (row) { return row.stockCode === identity.stockCode; }); })]; + case 5: + created = (_a.sent())[0]; + if (!created) { + throw new Error('저장된 종목 알림을 다시 불러오지 못했습니다.'); + } + return [2 /*return*/, created]; + } + }); + }); +} +function updateStockAlert(id, input) { + return __awaiter(this, void 0, void 0, function () { + var currentRows, existing, identity, updatedAt, alertTypes, updated; + var _this = this; + var _a, _b; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, ensureStockAlertTable()]; + case 1: + _c.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE) + .select('*') + .where(function (query) { + query.where({ stock_code: normalizeStockCode(id) || '__never__' }).orWhere({ id: id }); + })]; + case 2: + currentRows = (_c.sent()); + if (!currentRows.length) { + throw new Error('수정할 종목 알림을 찾을 수 없습니다.'); + } + existing = currentRows[0]; + return [4 /*yield*/, resolveStockIdentity({ + stockCode: (_a = input.stockCode) !== null && _a !== void 0 ? _a : existing === null || existing === void 0 ? void 0 : existing.stock_code, + stockName: (_b = input.stockName) !== null && _b !== void 0 ? _b : existing === null || existing === void 0 ? void 0 : existing.stock_name, + })]; + case 3: + identity = _c.sent(); + updatedAt = new Date().toISOString(); + alertTypes = Array.from(new Set(input.alertTypes.map(function (value) { return normalizeAlertType(value); }))); + if (!alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + if (!(identity.stockCode !== existing.stock_code)) return [3 /*break*/, 5]; + return [4 /*yield*/, ensureNoDuplicateStockCode(identity.stockCode)]; + case 4: + _c.sent(); + _c.label = 5; + case 5: return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, trx(exports.STOCK_ALERT_TABLE).where({ stock_code: existing.stock_code }).delete()]; + case 1: + _a.sent(); + return [4 /*yield*/, trx(exports.STOCK_ALERT_TABLE).insert(alertTypes.map(function (alertType) { + var _a, _b; + return ({ + id: "stock-alert-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), + stock_code: identity.stockCode, + stock_name: identity.stockName, + alert_type: alertType, + created_at: (_b = (_a = currentRows.find(function (row) { return row.alert_type === alertType; })) === null || _a === void 0 ? void 0 : _a.created_at) !== null && _b !== void 0 ? _b : updatedAt, + updated_at: updatedAt, + }); + }))]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); })]; + case 6: + _c.sent(); + return [4 /*yield*/, listStockAlerts('all').then(function (rows) { return rows.filter(function (row) { return row.stockCode === identity.stockCode; }); })]; + case 7: + updated = (_c.sent())[0]; + if (!updated) { + throw new Error('수정된 종목 알림을 다시 불러오지 못했습니다.'); + } + return [2 /*return*/, updated]; + } + }); + }); +} +function deleteStockAlert(id) { + return __awaiter(this, void 0, void 0, function () { + var normalizedCode, count, _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, ensureStockAlertTable()]; + case 1: + _b.sent(); + normalizedCode = normalizeStockCode(id); + if (!normalizedCode) return [3 /*break*/, 3]; + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ stock_code: normalizedCode }).delete()]; + case 2: + _a = _b.sent(); + return [3 /*break*/, 5]; + case 3: return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_TABLE).where({ id: id }).delete()]; + case 4: + _a = _b.sent(); + _b.label = 5; + case 5: + count = _a; + if (!count) { + throw new Error('삭제할 종목 알림을 찾을 수 없습니다.'); + } + return [2 /*return*/]; + } + }); + }); +} +function saveStockAlerts(items) { + return __awaiter(this, void 0, void 0, function () { + var seenCodes, _i, items_1, item, normalizedCode, savedItems, _a, items_2, item, trimmedId, savedItem, _b; + var _c, _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, ensureStockAlertTable()]; + case 1: + _e.sent(); + seenCodes = new Set(); + for (_i = 0, items_1 = items; _i < items_1.length; _i++) { + item = items_1[_i]; + normalizedCode = normalizeStockCode((_c = item.stockCode) !== null && _c !== void 0 ? _c : ''); + if (!normalizedCode) { + continue; + } + if (seenCodes.has(normalizedCode)) { + throw new Error('동일한 종목코드를 중복 저장할 수 없습니다.'); + } + if (!item.alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + seenCodes.add(normalizedCode); + } + savedItems = []; + _a = 0, items_2 = items; + _e.label = 2; + case 2: + if (!(_a < items_2.length)) return [3 /*break*/, 8]; + item = items_2[_a]; + trimmedId = (_d = item.id) === null || _d === void 0 ? void 0 : _d.trim(); + if (!trimmedId) return [3 /*break*/, 4]; + return [4 /*yield*/, updateStockAlert(trimmedId, item)]; + case 3: + _b = _e.sent(); + return [3 /*break*/, 6]; + case 4: return [4 /*yield*/, createStockAlert(item)]; + case 5: + _b = _e.sent(); + _e.label = 6; + case 6: + savedItem = _b; + savedItems.push(savedItem); + _e.label = 7; + case 7: + _a++; + return [3 /*break*/, 2]; + case 8: return [2 /*return*/, savedItems]; + } + }); + }); +} +function formatStockAlertPrice(value) { + if (!isFiniteNumber(value)) { + return '-'; + } + return "".concat(Math.round(value).toLocaleString('ko-KR'), "\u20A9"); +} +function formatStockAlertChangeRate(value) { + if (!isFiniteNumber(value)) { + return '(변동률 확인불가)'; + } + if (value > 0) { + return "(+".concat(value.toFixed(2), "% \u25B2)"); + } + if (value < 0) { + return "(".concat(value.toFixed(2), "% \u25BC)"); + } + return '(0.00% -)'; +} +function canBuildCurrentPriceStockAlertLine(item) { + return item.alertTypes.includes('price') && isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate); +} +function canBuildChangeThresholdStockAlertLine(item) { + return isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate); +} +function buildCurrentPriceStockAlertLines(items) { + return items + .filter(canBuildCurrentPriceStockAlertLine) + .map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); +} +function buildChangeRateThresholdStockAlertLines(items, thresholdPercent) { + return items + .filter(function (item) { var _a; return canBuildChangeThresholdStockAlertLine(item) && Math.abs((_a = item.changeRate) !== null && _a !== void 0 ? _a : 0) >= thresholdPercent; }) + .sort(function (left, right) { + var _a, _b; + var changeRateGap = Math.abs(((_a = right.changeRate) !== null && _a !== void 0 ? _a : 0)) - Math.abs(((_b = left.changeRate) !== null && _b !== void 0 ? _b : 0)); + if (changeRateGap !== 0) { + return changeRateGap; + } + return left.stockName.localeCompare(right.stockName, 'ko-KR'); + }) + .map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); +} +function listStockAlertVolumeSnapshots() { + return __awaiter(this, void 0, void 0, function () { + var rows; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureStockAlertVolumeSnapshotTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) + .select('*') + .orderBy('updated_at', 'desc')]; + case 2: + rows = (_a.sent()); + return [2 /*return*/, new Map(rows + .map(function (row) { return normalizeStockAlertVolumeSnapshotRow(row); }) + .filter(function (row) { return row.stockCode; }) + .map(function (row) { return [row.stockCode, row]; }))]; + } + }); + }); +} +function upsertStockAlertVolumeSnapshots(items, previousSnapshots) { + return __awaiter(this, void 0, void 0, function () { + var records; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureStockAlertVolumeSnapshotTable()]; + case 1: + _a.sent(); + if (!items.length) { + return [2 /*return*/]; + } + records = items.map(function (item) { var _a; return buildStockAlertVolumeSnapshotRecord(item, item.currentVolume, (_a = previousSnapshots.get(item.stockCode)) !== null && _a !== void 0 ? _a : null); }); + return [4 /*yield*/, (0, client_js_1.db)(exports.STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) + .insert(records) + .onConflict('stock_code') + .merge({ + stock_name: client_js_1.db.ref('excluded.stock_name'), + previous_volume: client_js_1.db.ref('excluded.previous_volume'), + current_volume: client_js_1.db.ref('excluded.current_volume'), + volume_increase_percent: client_js_1.db.ref('excluded.volume_increase_percent'), + current_price: client_js_1.db.ref('excluded.current_price'), + change_rate: client_js_1.db.ref('excluded.change_rate'), + quoted_at: client_js_1.db.ref('excluded.quoted_at'), + updated_at: client_js_1.db.ref('excluded.updated_at'), + })]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options) { + return items + .flatMap(function (item) { + var _a, _b; + if (!canBuildChangeThresholdStockAlertLine(item)) { + return []; + } + var previousSnapshot = previousSnapshots.get(item.stockCode); + var previousVolume = (_a = previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && _a !== void 0 ? _a : null; + var currentVolume = normalizeNonNegativeVolume(item.currentVolume); + var volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume); + if (volumeIncreasePercent === null || + Math.abs((_b = item.changeRate) !== null && _b !== void 0 ? _b : 0) < options.thresholdPercent || + volumeIncreasePercent < options.minVolumeIncreasePercent) { + return []; + } + return [ + { + stockCode: item.stockCode, + stockName: item.stockName, + currentPrice: item.currentPrice, + changeRate: item.changeRate, + currentVolume: currentVolume, + previousVolume: previousVolume, + volumeIncreasePercent: volumeIncreasePercent, + quotedAt: item.quotedAt, + }, + ]; + }) + .sort(function (left, right) { + var _a, _b, _c, _d; + var changeRateGap = Math.abs(((_a = right.changeRate) !== null && _a !== void 0 ? _a : 0)) - Math.abs(((_b = left.changeRate) !== null && _b !== void 0 ? _b : 0)); + if (changeRateGap !== 0) { + return changeRateGap; + } + var volumeGap = ((_c = right.volumeIncreasePercent) !== null && _c !== void 0 ? _c : 0) - ((_d = left.volumeIncreasePercent) !== null && _d !== void 0 ? _d : 0); + if (volumeGap !== 0) { + return volumeGap; + } + return left.stockName.localeCompare(right.stockName, 'ko-KR'); + }); +} +function buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, options) { + return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options).map(function (item) { return "".concat(item.stockName, " ").concat(formatStockAlertPrice(item.currentPrice), " ").concat(formatStockAlertChangeRate(item.changeRate)); }); +} +function createSkippedNotificationResult(reason) { + var skippedWebResult = { + ok: true, + skipped: true, + reason: reason, + sentCount: 0, + failedCount: 0, + invalidEndpoints: [], + }; + var skippedIosResult = { + ok: true, + skipped: true, + reason: reason, + sentCount: 0, + failedCount: 0, + invalidTokens: [], + }; + return { + ios: skippedIosResult, + web: skippedWebResult, + }; +} +function buildStockAlertNotificationIdentity(options) { + var modeKey = options.mode === 'price' + ? 'current-price' + : options.mode === 'change-threshold' + ? 'change-threshold' + : 'change-threshold-volume-spike'; + var legacyNotificationKey = "".concat(options.serviceKey, ":current-price"); + var legacyModeNotificationKey = "".concat(options.serviceKey, ":").concat(modeKey); + return { + threadId: "schedule-stock-alert:".concat(options.scheduleId), + notificationKey: "schedule-stock-alert:".concat(options.scheduleId), + notificationScope: "schedule-stock-alert:".concat(options.scheduleId), + notificationAliases: __spreadArray([], new Set([ + options.serviceKey, + legacyNotificationKey, + legacyModeNotificationKey, + options.scheduleId === 2 ? STOCK_ALERT_NOTIFICATION_SCOPE : '', + options.scheduleId === 2 ? "".concat(STOCK_ALERT_NOTIFICATION_SCOPE, ":current-price") : '', + ].filter(Boolean)), true), + }; +} +function sendManagedStockAlertWebPush(options) { + return __awaiter(this, void 0, void 0, function () { + var thresholdPercent, minVolumeIncreasePercent, items, previousSnapshots, _a, lines, hasRegisteredTargets, hasComparableVolumeBaseline, skippedReason, skippedResult, body, notificationIdentity, result; + var _b, _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: + thresholdPercent = Math.max(0, Number((_b = options.thresholdPercent) !== null && _b !== void 0 ? _b : 5)); + minVolumeIncreasePercent = Math.max(0, Number((_c = options.minVolumeIncreasePercent) !== null && _c !== void 0 ? _c : 300)); + return [4 /*yield*/, listStockAlerts(options.mode === 'price' ? 'price' : 'all')]; + case 1: + items = _d.sent(); + if (!(options.mode === 'change-threshold-volume-spike')) return [3 /*break*/, 3]; + return [4 /*yield*/, listStockAlertVolumeSnapshots()]; + case 2: + _a = _d.sent(); + return [3 /*break*/, 4]; + case 3: + _a = new Map(); + _d.label = 4; + case 4: + previousSnapshots = _a; + lines = options.mode === 'price' + ? buildCurrentPriceStockAlertLines(items) + : options.mode === 'change-threshold' + ? buildChangeRateThresholdStockAlertLines(items, thresholdPercent) + : buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, { + thresholdPercent: thresholdPercent, + minVolumeIncreasePercent: minVolumeIncreasePercent, + }); + hasRegisteredTargets = options.mode === 'price' ? items.some(function (item) { return item.alertTypes.includes('price'); }) : items.length > 0; + hasComparableVolumeBaseline = options.mode !== 'change-threshold-volume-spike' + ? false + : items.some(function (item) { + var previousSnapshot = previousSnapshots.get(item.stockCode); + return (previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== null && (previousSnapshot === null || previousSnapshot === void 0 ? void 0 : previousSnapshot.currentVolume) !== undefined; + }); + skippedReason = options.mode === 'price' + ? hasRegisteredTargets + ? '현재가 시세를 확인할 수 있는 종목이 없습니다.' + : '현재가로 등록된 종목이 없습니다.' + : options.mode === 'change-threshold' + ? hasRegisteredTargets + ? "".concat(thresholdPercent, "% \uC774\uC0C1 \uBCC0\uB3D9 \uC885\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.") + : '등록된 종목이 없습니다.' + : !hasRegisteredTargets + ? '등록된 종목이 없습니다.' + : !hasComparableVolumeBaseline + ? '\uC774\uC804 \uAC70\uB798\uB7C9 \uB610\uB294 5\uC601\uC5C5\uC77C \uD3C9\uADE0 \uAC70\uB798\uB7C9 \uBE44\uAD50 \uAE30\uC900\uC774 \uC5C6\uC5B4 \uC2A4\uB0C5\uC0F7\uB9CC \uAC31\uC2E0\uD588\uC2B5\uB2C8\uB2E4.' + : "\uB4F1\uB77D\uB960 ".concat(thresholdPercent, "% \uC774\uC0C1\uC774\uBA74\uC11C \uC9C1\uC804 \uAC70\uB798\uB7C9 \uC99D\uD3ED\uC218 \uB300\uBE44 \uC774\uBC88 \uAC70\uB798\uB7C9 \uC99D\uD3ED\uC774 ").concat(minVolumeIncreasePercent, "% \uC774\uC0C1\uC778 \uC885\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."); + skippedResult = createSkippedNotificationResult(skippedReason); + if (!(options.mode === 'change-threshold-volume-spike')) return [3 /*break*/, 6]; + return [4 /*yield*/, upsertStockAlertVolumeSnapshots(items, previousSnapshots)]; + case 5: + _d.sent(); + _d.label = 6; + case 6: + if (!lines.length) { + return [2 /*return*/, { + ok: true, + skipped: true, + reason: skippedReason, + title: options.title, + body: '', + itemCount: 0, + lines: [], + ios: skippedResult.ios, + web: skippedResult.web, + }]; + } + body = lines.join('\n'); + notificationIdentity = buildStockAlertNotificationIdentity(options); + return [4 /*yield*/, (0, notification_service_js_1.sendNotifications)({ + title: options.title, + body: body, + threadId: notificationIdentity.threadId, + targetAppDomains: [STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN], + data: { + category: 'stock-alert', + eventType: options.mode === 'price' + ? 'stock-alert-current-price' + : options.mode === 'change-threshold' + ? 'stock-alert-change-threshold' + : 'stock-alert-change-threshold-volume-spike', + notificationKey: notificationIdentity.notificationKey, + notificationScope: notificationIdentity.notificationScope, + notificationAliases: JSON.stringify(notificationIdentity.notificationAliases), + replaceExistingScope: 'true', + source: options.serviceKey, + targetUrl: STOCK_ALERT_NOTIFICATION_TARGET_URL, + }, + }, { + disableIos: true, + })]; + case 7: + result = _d.sent(); + return [2 /*return*/, __assign(__assign({}, result), { title: options.title, body: body, itemCount: lines.length, lines: lines })]; + } + }); + }); +} +function sendCurrentPriceStockAlertWebPush() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + return [2 /*return*/, sendManagedStockAlertWebPush({ + scheduleId: 2, + serviceKey: STOCK_ALERT_NOTIFICATION_SCOPE, + title: STOCK_ALERT_NOTIFICATION_TITLE, + mode: 'price', + })]; + }); + }); +} +function updateStockAlertLayoutFeatureDescription() { + return __awaiter(this, void 0, void 0, function () { + var layoutRecord, tree, changed, nextInteractions; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureStockAlertTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)('play_layouts').select('id', 'tree').where({ name: exports.STOCK_ALERT_LAYOUT_NAME }).first()]; + case 2: + layoutRecord = _a.sent(); + if (!layoutRecord || typeof layoutRecord !== 'object') { + return [2 /*return*/, false]; + } + tree = layoutRecord.tree; + if (!tree || !Array.isArray(tree.interactions)) { + return [2 /*return*/, false]; + } + changed = false; + nextInteractions = tree.interactions.map(function (interaction) { + var _a; + var title = (_a = interaction.title) === null || _a === void 0 ? void 0 : _a.trim(); + if (title === '그리드 기본정의') { + var nextDescription = [ + '## 그리드 필드를 아래로 정의하세요.', + ' - 종목명, 등락률, 현재가, 기준일시, 알림유형', + '## 숨긴필드', + ' - 종목코드', + '## DB관리 데이터', + ' - 종목코드, 알림유형', + '## 외부 API 및 가공 데이터', + ' - DB에 저장된 종목코드에 대한 현재가 등락률 기준일시를 표현', + ' - 현재가 데이터는 정규장이 아닌 가격도 모두 가져오도록 해주세요.', + '## 서비스 구현', + ' - 해당 그리드 데이터를 저장할 DB 및 서비스 API를 구현허고 연결하세요. CRUD', + ' - 알림유형은 한종목에 멀티로 저장되어야 합니다.', + '## 입력', + '알림유형의 경우 멀티선택 가능하게 해주세요.', + ].join('\n'); + var nextNotes = 'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량은 네이버 실시간 누적거래량을 현재값으로 받고, Yahoo Finance 일봉 최근 5영업일 평균 대비 비율(%)로 계산해 제공합니다.'; + if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) { + changed = true; + return __assign(__assign({}, interaction), { description: nextDescription, implementationNotes: nextNotes }); + } + } + if (title === '얼림유형 검색') { + var nextNotes = 'Primary Pane Select Input 값은 all, price, top3 코드로 유지하고 Secondary Pane 그리드 조회 파라미터 alertType 으로 바로 전달합니다.'; + if (interaction.implementationNotes !== nextNotes) { + changed = true; + return __assign(__assign({}, interaction), { implementationNotes: nextNotes }); + } + } + if (title === '행추가 기능') { + var nextNotes = 'GET /api/stock-alerts/search?query=... 로 종목명/종목코드를 조회하고, 선택한 종목코드·종목명·시장구분을 그리드 신규 행으로 매핑합니다. 동일 종목코드는 중복 추가를 막고, 선택 후 해당 행으로 포커스를 이동합니다.'; + if (interaction.implementationNotes !== nextNotes) { + changed = true; + return __assign(__assign({}, interaction), { implementationNotes: nextNotes }); + } + } + return interaction; + }); + if (!changed) { + return [2 /*return*/, false]; + } + return [4 /*yield*/, (0, client_js_1.db)('play_layouts') + .where({ id: layoutRecord.id }) + .update({ + tree: __assign(__assign({}, tree), { interactions: nextInteractions }), + })]; + case 3: + _a.sent(); + return [2 /*return*/, true]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/stock-alert-service.test.ts b/etc/servers/work-server/src/services/stock-alert-service.test.ts index 24a1673..7b63950 100644 --- a/etc/servers/work-server/src/services/stock-alert-service.test.ts +++ b/etc/servers/work-server/src/services/stock-alert-service.test.ts @@ -1,11 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildChangeRateAndVolumeSpikeStockAlertCandidates, buildChangeRateThresholdStockAlertLines, buildCurrentPriceStockAlertLines, buildStockAlertNotificationIdentity, resolveLatestQuoteFromMeta, resolveLatestQuoteFromNaverRealtime, + resolveVolumeRate5dFromHistory, type StockAlertItem, } from './stock-alert-service.js'; @@ -38,6 +40,8 @@ test('buildCurrentPriceStockAlertLines formats current-price stock alert lines', alertTypeLabels: ['현재가'], currentPrice: 210000, changeRate: 1.23, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -50,6 +54,8 @@ test('buildCurrentPriceStockAlertLines formats current-price stock alert lines', alertTypeLabels: ['현재가'], currentPrice: 198000, changeRate: -2.34, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -72,6 +78,8 @@ test('buildCurrentPriceStockAlertLines skips rows without resolved current-price alertTypeLabels: ['현재가'], currentPrice: 210000, changeRate: 1.23, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -84,6 +92,8 @@ test('buildCurrentPriceStockAlertLines skips rows without resolved current-price alertTypeLabels: ['현재가'], currentPrice: null, changeRate: null, + volumeRate5d: null, + currentVolume: null, quotedAt: null, createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -103,6 +113,8 @@ test('buildCurrentPriceStockAlertLines excludes stocks not registered for curren alertTypeLabels: ['현재가', '등락폭이 큰 상위3종목'], currentPrice: 210000, changeRate: 1.23, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -115,6 +127,8 @@ test('buildCurrentPriceStockAlertLines excludes stocks not registered for curren alertTypeLabels: ['등락폭이 큰 상위3종목'], currentPrice: 230000, changeRate: 3.45, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -134,6 +148,8 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th alertTypeLabels: ['등락폭이 큰 상위3종목'], currentPrice: 26500, changeRate: 11.11, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -146,6 +162,8 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th alertTypeLabels: ['현재가'], currentPrice: 210000, changeRate: 6.1, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -158,6 +176,8 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th alertTypeLabels: ['등락폭이 큰 상위3종목'], currentPrice: 137400, changeRate: -1.86, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -170,6 +190,8 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th alertTypeLabels: ['등락폭이 큰 상위3종목'], currentPrice: 230000, changeRate: -7.23, + volumeRate5d: null, + currentVolume: null, quotedAt: '2026-04-29T09:00:00.000Z', createdAt: '2026-04-29T09:00:00.000Z', updatedAt: '2026-04-29T09:00:00.000Z', @@ -183,6 +205,232 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th ]); }); +test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a 300%+ volume jump over the previous snapshot', () => { + const items: StockAlertItem[] = [ + { + id: '290550', + stockCode: '290550', + stockName: '디케이티', + alertTypes: ['top3'], + alertTypeLabels: ['등락폭이 큰 상위3종목'], + currentPrice: 26500, + changeRate: 11.11, + volumeRate5d: null, + currentVolume: 400000, + quotedAt: '2026-05-06T00:30:00.000Z', + createdAt: '2026-05-06T00:30:00.000Z', + updatedAt: '2026-05-06T00:30:00.000Z', + }, + { + id: '005930', + stockCode: '005930', + stockName: '삼성전자', + alertTypes: ['price'], + alertTypeLabels: ['현재가'], + currentPrice: 210000, + changeRate: 6.1, + volumeRate5d: null, + currentVolume: 390000, + quotedAt: '2026-05-06T00:30:00.000Z', + createdAt: '2026-05-06T00:30:00.000Z', + updatedAt: '2026-05-06T00:30:00.000Z', + }, + { + id: '035420', + stockCode: '035420', + stockName: 'NAVER', + alertTypes: ['top3'], + alertTypeLabels: ['등락폭이 큰 상위3종목'], + currentPrice: 230000, + changeRate: -7.23, + volumeRate5d: null, + currentVolume: 380000, + quotedAt: '2026-05-06T00:30:00.000Z', + createdAt: '2026-05-06T00:30:00.000Z', + updatedAt: '2026-05-06T00:30:00.000Z', + }, + ]; + + const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates( + items, + new Map([ + [ + '290550', + { + stockCode: '290550', + stockName: '디케이티', + previousVolume: 100000, + currentVolume: 100000, + volumeIncreasePercent: 0, + currentPrice: 25000, + changeRate: 3, + quotedAt: '2026-05-06T00:00:00.000Z', + createdAt: '2026-05-06T00:00:00.000Z', + updatedAt: '2026-05-06T00:00:00.000Z', + }, + ], + [ + '005930', + { + stockCode: '005930', + stockName: '삼성전자', + previousVolume: 130000, + currentVolume: 130000, + volumeIncreasePercent: 0, + currentPrice: 205000, + changeRate: 2, + quotedAt: '2026-05-06T00:00:00.000Z', + createdAt: '2026-05-06T00:00:00.000Z', + updatedAt: '2026-05-06T00:00:00.000Z', + }, + ], + [ + '035420', + { + stockCode: '035420', + stockName: 'NAVER', + previousVolume: 120000, + currentVolume: 120000, + volumeIncreasePercent: 0, + currentPrice: 240000, + changeRate: -3, + quotedAt: '2026-05-06T00:00:00.000Z', + createdAt: '2026-05-06T00:00:00.000Z', + updatedAt: '2026-05-06T00:00:00.000Z', + }, + ], + ]), + { + thresholdPercent: 3, + minVolumeIncreasePercent: 300, + }, + ); + + assert.deepEqual(candidates, [ + { + stockCode: '290550', + stockName: '디케이티', + currentPrice: 26500, + changeRate: 11.11, + currentVolume: 400000, + previousVolume: 100000, + volumeIncreasePercent: 300, + volumeAmplificationPercent: 300, + quotedAt: '2026-05-06T00:30:00.000Z', + }, + ]); +}); + +test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled amplification amount', () => { + const items: StockAlertItem[] = [ + { + id: '290550', + stockCode: '290550', + stockName: '디케이티', + alertTypes: ['top3'], + alertTypeLabels: ['등락폭이 큰 상위3종목'], + currentPrice: 26500, + changeRate: 11.11, + volumeRate5d: null, + currentVolume: 350000, + quotedAt: '2026-05-06T00:30:00.000Z', + createdAt: '2026-05-06T00:30:00.000Z', + updatedAt: '2026-05-06T00:30:00.000Z', + }, + ]; + + const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates( + items, + new Map([ + [ + '290550', + { + stockCode: '290550', + stockName: '디케이티', + previousVolume: 100000, + currentVolume: 200000, + volumeIncreasePercent: 100, + currentPrice: 25000, + changeRate: 3, + quotedAt: '2026-05-06T00:00:00.000Z', + createdAt: '2026-05-06T00:00:00.000Z', + updatedAt: '2026-05-06T00:00:00.000Z', + }, + ], + ]), + { + thresholdPercent: 3, + minVolumeIncreasePercent: 50, + }, + ); + + assert.deepEqual(candidates, [ + { + stockCode: '290550', + stockName: '디케이티', + currentPrice: 26500, + changeRate: 11.11, + currentVolume: 350000, + previousVolume: 200000, + volumeIncreasePercent: 75, + volumeAmplificationPercent: 50, + quotedAt: '2026-05-06T00:30:00.000Z', + }, + ]); +}); + +test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day average volume when no previous snapshot exists', () => { + const items: StockAlertItem[] = [ + { + id: '290550', + stockCode: '290550', + stockName: '디케이티', + alertTypes: ['top3'], + alertTypeLabels: ['등락폭이 큰 상위3종목'], + currentPrice: 26500, + changeRate: 11.11, + volumeRate5d: 400, + currentVolume: 400000, + quotedAt: '2026-05-06T00:30:00.000Z', + createdAt: '2026-05-06T00:30:00.000Z', + updatedAt: '2026-05-06T00:30:00.000Z', + }, + { + id: '035420', + stockCode: '035420', + stockName: 'NAVER', + alertTypes: ['top3'], + alertTypeLabels: ['등락폭이 큰 상위3종목'], + currentPrice: 230000, + changeRate: -7.23, + volumeRate5d: 250, + currentVolume: 380000, + quotedAt: '2026-05-06T00:30:00.000Z', + createdAt: '2026-05-06T00:30:00.000Z', + updatedAt: '2026-05-06T00:30:00.000Z', + }, + ]; + + const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(items, new Map(), { + thresholdPercent: 3, + minVolumeIncreasePercent: 300, + }); + + assert.deepEqual(candidates, [ + { + stockCode: '290550', + stockName: '디케이티', + currentPrice: 26500, + changeRate: 11.11, + currentVolume: 400000, + previousVolume: 100000, + volumeIncreasePercent: 300, + volumeAmplificationPercent: 300, + quotedAt: '2026-05-06T00:30:00.000Z', + }, + ]); +}); + test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => { assert.deepEqual( buildStockAlertNotificationIdentity({ @@ -220,6 +468,24 @@ test('buildStockAlertNotificationIdentity keeps one stable notification per sche ], }, ); + + assert.deepEqual( + buildStockAlertNotificationIdentity({ + scheduleId: 11, + serviceKey: 'schedule-11-stock-hight-v2-service', + mode: 'change-threshold-volume-spike', + }), + { + threadId: 'schedule-stock-alert:11', + notificationKey: 'schedule-stock-alert:11', + notificationScope: 'schedule-stock-alert:11', + notificationAliases: [ + 'schedule-11-stock-hight-v2-service', + 'schedule-11-stock-hight-v2-service:current-price', + 'schedule-11-stock-hight-v2-service:change-threshold-volume-spike', + ], + }, + ); }); test('resolveLatestQuoteFromNaverRealtime prefers extended-hours quote when available', () => { @@ -242,10 +508,32 @@ test('resolveLatestQuoteFromNaverRealtime prefers extended-hours quote when avai assert.equal(quote.currentPrice, 225500); assert.equal(quote.changeRate, 1.58); + assert.equal(quote.currentVolume, null); assert.equal(quote.stockName, '삼성전자'); assert.equal(quote.quotedAt, '2026-04-29T19:05:12.465411+09:00'); }); +test('resolveLatestQuoteFromNaverRealtime keeps accumulated trading volume for volume-rate calculation', () => { + const quote = resolveLatestQuoteFromNaverRealtime( + { + cd: '005930', + nm: '삼성전자', + nv: 224750, + cr: 0.55, + aq: '11,342,102', + }, + 1777521720275, + ); + + assert.equal(quote.currentVolume, 11342102); +}); + +test('resolveVolumeRate5dFromHistory prefers realtime current volume over the last daily candle', () => { + const rate = resolveVolumeRate5dFromHistory(11342102, [34525485, 19626666, 22870374, 18444490, 20363756, 11015729]); + + assert.equal(rate?.toFixed(2), '48.96'); +}); + test('resolveLatestQuoteFromNaverRealtime keeps after-hours quote even after session close', () => { const quote = resolveLatestQuoteFromNaverRealtime( { diff --git a/etc/servers/work-server/src/services/stock-alert-service.ts b/etc/servers/work-server/src/services/stock-alert-service.ts index a1590f2..4531bef 100644 --- a/etc/servers/work-server/src/services/stock-alert-service.ts +++ b/etc/servers/work-server/src/services/stock-alert-service.ts @@ -2,6 +2,7 @@ import { sendNotifications } from './notification-service.js'; import { db } from '../db/client.js'; export const STOCK_ALERT_TABLE = 'stock_alerts'; +export const STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots'; export const STOCK_ALERT_LAYOUT_NAME = 'stock알림'; export const STOCK_ALERT_TYPE_OPTIONS = [ @@ -22,9 +23,24 @@ export type StockAlertRow = { updated_at: string; }; +export type StockAlertVolumeSnapshotRow = { + stock_code: string; + stock_name: string; + previous_volume: number | string | null; + current_volume: number | string | null; + volume_increase_percent: number | string | null; + current_price: number | string | null; + change_rate: number | string | null; + quoted_at: string | null; + created_at: string; + updated_at: string; +}; + export type StockAlertQuote = { currentPrice: number | null; changeRate: number | null; + volumeRate5d: number | null; + currentVolume: number | null; quotedAt: string | null; stockName: string | null; }; @@ -37,11 +53,38 @@ export type StockAlertItem = { alertTypeLabels: string[]; currentPrice: number | null; changeRate: number | null; + volumeRate5d: number | null; + currentVolume: number | null; quotedAt: string | null; createdAt: string; updatedAt: string; }; +export type StockAlertVolumeSnapshot = { + stockCode: string; + stockName: string; + previousVolume: number | null; + currentVolume: number | null; + volumeIncreasePercent: number | null; + currentPrice: number | null; + changeRate: number | null; + quotedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type StockAlertVolumeSpikeCandidate = { + stockCode: string; + stockName: string; + currentPrice: number | null; + changeRate: number | null; + currentVolume: number | null; + previousVolume: number | null; + volumeIncreasePercent: number | null; + volumeAmplificationPercent: number | null; + quotedAt: string | null; +}; + type YahooQuoteRow = { symbol?: string; regularMarketPrice?: number; @@ -75,10 +118,13 @@ type NaverRealtimeRow = { ms?: string; tyn?: string; pcv?: number; + aq?: number | string; + accumulatedTradingVolume?: number | string; nxtOverMarketPriceInfo?: { tradingSessionType?: string; overMarketStatus?: string; overPrice?: string; + accumulatedTradingVolume?: string; fluctuationsRatio?: string; compareToPreviousClosePrice?: string; compareToPreviousPrice?: { @@ -251,6 +297,160 @@ function getAlertTypeLabel(value: StockAlertType) { return STOCK_ALERT_LABEL_MAP.get(value) ?? value; } +function average(values: number[]) { + if (!values.length) { + return null; + } + + const sum = values.reduce((acc, value) => acc + value, 0); + return sum / values.length; +} + +export function resolveVolumeRate5dFromHistory( + currentVolume: number | null | undefined, + historicalVolumes: Array, +) { + const normalizedCurrentVolume = isFiniteNumber(currentVolume) && currentVolume >= 0 ? currentVolume : null; + const normalizedVolumes = historicalVolumes.filter((value): value is number => isFiniteNumber(value) && value >= 0); + + if (normalizedVolumes.length < 2) { + return null; + } + + const latestVolume = normalizedCurrentVolume ?? normalizedVolumes[normalizedVolumes.length - 1] ?? null; + const previousFiveAverage = average(normalizedVolumes.slice(-6, -1)); + + if (latestVolume === null || previousFiveAverage === null || previousFiveAverage <= 0) { + return null; + } + + return (latestVolume / previousFiveAverage) * 100; +} + +function normalizeNonNegativeVolume(value: unknown) { + const parsed = parseLooseNumber(value); + + if (!isFiniteNumber(parsed) || parsed < 0) { + return null; + } + + return Math.round(parsed); +} + +function calculateVolumeIncreasePercent(currentVolume: number | null, previousVolume: number | null) { + if (!isFiniteNumber(currentVolume) || !isFiniteNumber(previousVolume) || previousVolume <= 0 || currentVolume < previousVolume) { + return null; + } + + return ((currentVolume - previousVolume) / previousVolume) * 100; +} + +function calculateVolumeAmplificationPercent( + currentVolume: number | null, + previousSnapshot: StockAlertVolumeSnapshot | null, + fallbackPercent: number | null, +) { + const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume); + const previousCurrentVolume = normalizeNonNegativeVolume(previousSnapshot?.currentVolume); + const previousBaselineVolume = normalizeNonNegativeVolume(previousSnapshot?.previousVolume); + + if ( + normalizedCurrentVolume === null + || previousCurrentVolume === null + || previousBaselineVolume === null + || previousCurrentVolume <= previousBaselineVolume + || normalizedCurrentVolume < previousCurrentVolume + ) { + return fallbackPercent; + } + + const previousRiseAmount = previousCurrentVolume - previousBaselineVolume; + const currentRiseAmount = normalizedCurrentVolume - previousCurrentVolume; + + if (previousRiseAmount <= 0) { + return fallbackPercent; + } + + return ((currentRiseAmount - previousRiseAmount) / previousRiseAmount) * 100; +} + +function deriveVolumeBaselineFromRate5d(item: StockAlertItem) { + const currentVolume = normalizeNonNegativeVolume(item.currentVolume); + const volumeRate5d = isFiniteNumber(item.volumeRate5d) ? item.volumeRate5d : null; + + if (currentVolume === null || volumeRate5d === null || volumeRate5d <= 0) { + return null; + } + + const baseline = currentVolume / (volumeRate5d / 100); + + if (!Number.isFinite(baseline) || baseline <= 0) { + return null; + } + + return Math.max(1, Math.round(baseline)); +} + +function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot: StockAlertVolumeSnapshot | null) { + const snapshotBaseline = previousSnapshot?.currentVolume ?? null; + + if (snapshotBaseline !== null) { + return snapshotBaseline; + } + + return deriveVolumeBaselineFromRate5d(item); +} + +function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow): StockAlertVolumeSnapshot { + return { + stockCode: normalizeStockCode(row.stock_code), + stockName: String(row.stock_name ?? '').trim() || normalizeStockCode(row.stock_code), + previousVolume: normalizeNonNegativeVolume(row.previous_volume), + currentVolume: normalizeNonNegativeVolume(row.current_volume), + volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent), + currentPrice: parseLooseNumber(row.current_price), + changeRate: parseLooseNumber(row.change_rate), + quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null, + createdAt: normalizeTimestamp(row.created_at), + updatedAt: normalizeTimestamp(row.updated_at), + }; +} + +function buildStockAlertVolumeSnapshotRecord( + item: StockAlertItem, + currentVolume: number | null, + previousSnapshot: StockAlertVolumeSnapshot | null, +) { + const now = new Date().toISOString(); + const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume); + const previousCurrentVolume = previousSnapshot?.currentVolume ?? null; + const shouldResetBaseline = + normalizedCurrentVolume !== null && + previousCurrentVolume !== null && + normalizedCurrentVolume < previousCurrentVolume; + const comparisonBaseline = shouldResetBaseline ? normalizedCurrentVolume : previousCurrentVolume; + const volumeIncreasePercent = calculateVolumeIncreasePercent(normalizedCurrentVolume, comparisonBaseline); + const nextPreviousVolume = + shouldResetBaseline + ? normalizedCurrentVolume + : previousCurrentVolume !== null + ? previousCurrentVolume + : normalizedCurrentVolume; + + return { + stock_code: item.stockCode, + stock_name: item.stockName, + previous_volume: nextPreviousVolume, + current_volume: normalizedCurrentVolume, + volume_increase_percent: volumeIncreasePercent, + current_price: item.currentPrice, + change_rate: item.changeRate, + quoted_at: item.quotedAt, + created_at: previousSnapshot?.createdAt ?? now, + updated_at: now, + } satisfies Omit & { quoted_at: string | null }; +} + function buildStockSymbols(stockCode: string) { const normalizedCode = normalizeStockCode(stockCode); @@ -311,6 +511,27 @@ async function ensureStockAlertTable() { }); } +async function ensureStockAlertVolumeSnapshotTable() { + const exists = await db.schema.hasTable(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE); + + if (exists) { + return; + } + + await db.schema.createTable(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE, (table) => { + table.text('stock_code').primary(); + table.text('stock_name').notNullable(); + table.bigInteger('previous_volume').nullable(); + table.bigInteger('current_volume').nullable(); + table.decimal('volume_increase_percent', 10, 2).nullable(); + table.decimal('current_price', 14, 2).nullable(); + table.decimal('change_rate', 10, 4).nullable(); + table.timestamp('quoted_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable(); + table.timestamp('updated_at', { useTz: true }).notNullable(); + }); +} + async function fetchJson(url: URL, init?: RequestInit) { const response = await fetch(url, { ...init, @@ -751,6 +972,8 @@ export function resolveLatestQuoteFromMeta(meta: { return { currentPrice, changeRate, + volumeRate5d: null, + currentVolume: null, quotedAt, stockName: meta.shortName?.trim() || meta.longName?.trim() || null, } satisfies StockAlertQuote; @@ -779,6 +1002,10 @@ export function resolveLatestQuoteFromNaverRealtime(data: NaverRealtimeRow, capt resolveNaverDirectionSign(data.rf?.trim() ? { code: data.rf } : undefined), ); const previousClosePrice = isFiniteNumber(data.pcv) ? data.pcv : null; + const currentVolume = + normalizeNonNegativeVolume(overMarketInfo?.accumulatedTradingVolume) ?? + normalizeNonNegativeVolume(data.accumulatedTradingVolume) ?? + normalizeNonNegativeVolume(data.aq); const shouldResetToPreviousClose = !hasExtendedSessionQuote && isKoreaMorningResetWindow(capturedTimestampMs) && @@ -787,6 +1014,8 @@ export function resolveLatestQuoteFromNaverRealtime(data: NaverRealtimeRow, capt return { currentPrice: hasExtendedSessionQuote ? overPrice : shouldResetToPreviousClose ? previousClosePrice : basePrice, changeRate: hasExtendedSessionQuote ? overChangeRate : shouldResetToPreviousClose ? 0 : baseChangeRate, + volumeRate5d: null, + currentVolume, quotedAt: hasExtendedSessionQuote ? overQuotedAt : baseQuotedAt, stockName: data.nm?.trim() || null, } satisfies StockAlertQuote; @@ -812,6 +1041,7 @@ function choosePreferredQuote(primary: StockAlertQuote | null, fallback: StockAl return { ...fallback, stockName: fallback.stockName ?? primary.stockName, + currentVolume: fallback.currentVolume ?? primary.currentVolume, } satisfies StockAlertQuote; } @@ -820,6 +1050,8 @@ function choosePreferredQuote(primary: StockAlertQuote | null, fallback: StockAl stockName: primary.stockName ?? fallback.stockName, currentPrice: primary.currentPrice ?? fallback.currentPrice, changeRate: primary.changeRate ?? fallback.changeRate, + volumeRate5d: primary.volumeRate5d ?? fallback.volumeRate5d, + currentVolume: primary.currentVolume ?? fallback.currentVolume, quotedAt: primary.quotedAt ?? fallback.quotedAt, } satisfies StockAlertQuote; } @@ -852,8 +1084,8 @@ async function fetchNaverRealtimeQuoteByCode(stockCode: string) { async function fetchQuoteBySymbol(symbol: string) { const quoteUrl = new URL(`https://query1.finance.yahoo.com/v8/finance/chart/${symbol}`); - quoteUrl.searchParams.set('range', '1d'); - quoteUrl.searchParams.set('interval', '1m'); + quoteUrl.searchParams.set('range', '3mo'); + quoteUrl.searchParams.set('interval', '1d'); quoteUrl.searchParams.set('includePrePost', 'true'); quoteUrl.searchParams.set('lang', 'ko-KR'); quoteUrl.searchParams.set('region', 'KR'); @@ -861,6 +1093,7 @@ async function fetchQuoteBySymbol(symbol: string) { const payload = await fetchJson<{ chart?: { result?: Array<{ + timestamp?: number[]; meta?: { regularMarketPrice?: number; regularMarketTime?: number; @@ -877,16 +1110,30 @@ async function fetchQuoteBySymbol(symbol: string) { longName?: string; chartPreviousClose?: number; }; + indicators?: { + quote?: Array<{ + volume?: Array; + close?: Array; + }>; + }; }>; }; }>(quoteUrl); - const meta = payload.chart?.result?.[0]?.meta; + const result = payload.chart?.result?.[0]; + const meta = result?.meta; if (!meta) { return null; } - return resolveLatestQuoteFromMeta(meta); + const quote = resolveLatestQuoteFromMeta(meta); + const dailyVolumes = result?.indicators?.quote?.[0]?.volume ?? []; + + return { + ...quote, + currentVolume: dailyVolumes[dailyVolumes.length - 1] ?? null, + volumeRate5d: resolveVolumeRate5dFromHistory(dailyVolumes[dailyVolumes.length - 1] ?? null, dailyVolumes), + } satisfies StockAlertQuote; } export async function fetchQuotesByCodes(stockCodes: string[]) { @@ -913,11 +1160,6 @@ export async function fetchQuotesByCodes(stockCodes: string[]) { // Ignore realtime provider failures and fall back to the next source. } - if (preferredQuote && preferredQuote.currentPrice !== null) { - quoteMap.set(stockCode, preferredQuote); - return; - } - const symbols = buildStockSymbols(stockCode); for (const symbol of symbols) { @@ -925,7 +1167,11 @@ export async function fetchQuotesByCodes(stockCodes: string[]) { const yahooQuote = await fetchQuoteBySymbol(symbol); preferredQuote = choosePreferredQuote(preferredQuote, yahooQuote); - if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName)) { + if ( + preferredQuote && + (preferredQuote.currentPrice !== null || preferredQuote.stockName) && + preferredQuote.volumeRate5d !== null + ) { quoteMap.set(stockCode, preferredQuote); return; } @@ -933,6 +1179,10 @@ export async function fetchQuotesByCodes(stockCodes: string[]) { // Ignore per-symbol failures and try the next market suffix. } } + + if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName || preferredQuote.volumeRate5d !== null)) { + quoteMap.set(stockCode, preferredQuote); + } }), ); @@ -999,6 +1249,8 @@ export async function listStockAlerts(filterType: StockAlertFilterType = 'all') alertTypeLabels: [getAlertTypeLabel(alertType)], currentPrice: quote?.currentPrice ?? null, changeRate: quote?.changeRate ?? null, + volumeRate5d: quote?.volumeRate5d ?? null, + currentVolume: quote?.currentVolume ?? null, quotedAt: quote?.quotedAt ?? null, createdAt: normalizeTimestamp(row.created_at), updatedAt: normalizeTimestamp(row.updated_at), @@ -1016,14 +1268,29 @@ export async function createStockAlert(input: { await ensureStockAlertTable(); const identity = await resolveStockIdentity(input); - const now = new Date().toISOString(); const alertTypes = Array.from(new Set(input.alertTypes.map((value) => normalizeAlertType(value)))); if (!alertTypes.length) { throw new Error('알림유형을 하나 이상 선택해 주세요.'); } - await ensureNoDuplicateStockCode(identity.stockCode); + const existingRows = (await db(STOCK_ALERT_TABLE) + .select('*') + .where({ stock_code: identity.stockCode })) as StockAlertRow[]; + + if (existingRows.length) { + const mergedAlertTypes = Array.from( + new Set([...existingRows.map((row) => normalizeAlertType(row.alert_type)), ...alertTypes]), + ); + + return updateStockAlert(identity.stockCode, { + stockCode: identity.stockCode, + stockName: identity.stockName, + alertTypes: mergedAlertTypes, + }); + } + + const now = new Date().toISOString(); await db(STOCK_ALERT_TABLE).insert( alertTypes.map((alertType) => ({ @@ -1212,6 +1479,126 @@ export function buildChangeRateThresholdStockAlertLines(items: StockAlertItem[], .map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`); } +async function listStockAlertVolumeSnapshots() { + await ensureStockAlertVolumeSnapshotTable(); + + const rows = (await db(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) + .select('*') + .orderBy('updated_at', 'desc')) as StockAlertVolumeSnapshotRow[]; + + return new Map( + rows + .map((row) => normalizeStockAlertVolumeSnapshotRow(row)) + .filter((row) => row.stockCode) + .map((row) => [row.stockCode, row] as const), + ); +} + +async function upsertStockAlertVolumeSnapshots( + items: StockAlertItem[], + previousSnapshots: Map, +) { + await ensureStockAlertVolumeSnapshotTable(); + + if (!items.length) { + return; + } + + const records = items.map((item) => + buildStockAlertVolumeSnapshotRecord(item, item.currentVolume, previousSnapshots.get(item.stockCode) ?? null), + ); + + await db(STOCK_ALERT_VOLUME_SNAPSHOT_TABLE) + .insert(records) + .onConflict('stock_code') + .merge({ + stock_name: db.ref('excluded.stock_name'), + previous_volume: db.ref('excluded.previous_volume'), + current_volume: db.ref('excluded.current_volume'), + volume_increase_percent: db.ref('excluded.volume_increase_percent'), + current_price: db.ref('excluded.current_price'), + change_rate: db.ref('excluded.change_rate'), + quoted_at: db.ref('excluded.quoted_at'), + updated_at: db.ref('excluded.updated_at'), + }); +} + +export function buildChangeRateAndVolumeSpikeStockAlertCandidates( + items: StockAlertItem[], + previousSnapshots: Map, + options: { + thresholdPercent: number; + minVolumeIncreasePercent: number; + }, +) { + return items + .flatMap((item) => { + if (!canBuildChangeThresholdStockAlertLine(item)) { + return []; + } + + const previousSnapshot = previousSnapshots.get(item.stockCode); + const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null); + const currentVolume = normalizeNonNegativeVolume(item.currentVolume); + const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume); + const volumeAmplificationPercent = calculateVolumeAmplificationPercent( + currentVolume, + previousSnapshot ?? null, + volumeIncreasePercent, + ); + + if ( + volumeAmplificationPercent === null || + Math.abs(item.changeRate ?? 0) < options.thresholdPercent || + volumeAmplificationPercent < options.minVolumeIncreasePercent + ) { + return []; + } + + return [ + { + stockCode: item.stockCode, + stockName: item.stockName, + currentPrice: item.currentPrice, + changeRate: item.changeRate, + currentVolume, + previousVolume, + volumeIncreasePercent, + volumeAmplificationPercent, + quotedAt: item.quotedAt, + } satisfies StockAlertVolumeSpikeCandidate, + ]; + }) + .sort((left, right) => { + const changeRateGap = Math.abs((right.changeRate ?? 0)) - Math.abs((left.changeRate ?? 0)); + + if (changeRateGap !== 0) { + return changeRateGap; + } + + const volumeGap = (right.volumeAmplificationPercent ?? 0) - (left.volumeAmplificationPercent ?? 0); + + if (volumeGap !== 0) { + return volumeGap; + } + + return left.stockName.localeCompare(right.stockName, 'ko-KR'); + }); +} + +export function buildChangeRateAndVolumeSpikeStockAlertLines( + items: StockAlertItem[], + previousSnapshots: Map, + options: { + thresholdPercent: number; + minVolumeIncreasePercent: number; + }, +) { + return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options).map( + (item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`, + ); +} + function createSkippedNotificationResult(reason: string) { const skippedWebResult = { ok: true, @@ -1239,9 +1626,14 @@ function createSkippedNotificationResult(reason: string) { export function buildStockAlertNotificationIdentity(options: { scheduleId: number; serviceKey: string; - mode: 'price' | 'change-threshold'; + mode: 'price' | 'change-threshold' | 'change-threshold-volume-spike'; }) { - const modeKey = options.mode === 'price' ? 'current-price' : 'change-threshold'; + const modeKey = + options.mode === 'price' + ? 'current-price' + : options.mode === 'change-threshold' + ? 'change-threshold' + : 'change-threshold-volume-spike'; const legacyNotificationKey = `${options.serviceKey}:current-price`; const legacyModeNotificationKey = `${options.serviceKey}:${modeKey}`; @@ -1263,25 +1655,52 @@ export async function sendManagedStockAlertWebPush(options: { scheduleId: number; serviceKey: string; title: string; - mode: 'price' | 'change-threshold'; + mode: 'price' | 'change-threshold' | 'change-threshold-volume-spike'; thresholdPercent?: number; + minVolumeIncreasePercent?: number; }) { + const thresholdPercent = Math.max(0, Number(options.thresholdPercent ?? 5)); + const minVolumeIncreasePercent = Math.max(0, Number(options.minVolumeIncreasePercent ?? 300)); const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all'); - const lines = options.mode === 'price' - ? buildCurrentPriceStockAlertLines(items) - : buildChangeRateThresholdStockAlertLines(items, Math.max(0, Number(options.thresholdPercent ?? 5))); - const hasRegisteredTargets = options.mode === 'price' - ? items.some((item) => item.alertTypes.includes('price')) - : items.length > 0; - const skippedReason = options.mode === 'price' - ? hasRegisteredTargets - ? '현재가 시세를 확인할 수 있는 종목이 없습니다.' - : '현재가로 등록된 종목이 없습니다.' - : hasRegisteredTargets - ? `${Math.max(0, Number(options.thresholdPercent ?? 5))}% 이상 변동 종목이 없습니다.` - : '등록된 종목이 없습니다.'; + const previousSnapshots = + options.mode === 'change-threshold-volume-spike' ? await listStockAlertVolumeSnapshots() : new Map(); + const lines = + options.mode === 'price' + ? buildCurrentPriceStockAlertLines(items) + : options.mode === 'change-threshold' + ? buildChangeRateThresholdStockAlertLines(items, thresholdPercent) + : buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, { + thresholdPercent, + minVolumeIncreasePercent, + }); + const hasRegisteredTargets = options.mode === 'price' ? items.some((item) => item.alertTypes.includes('price')) : items.length > 0; + const hasComparableVolumeBaseline = + options.mode !== 'change-threshold-volume-spike' + ? false + : items.some((item) => { + const previousSnapshot = previousSnapshots.get(item.stockCode); + return resolveComparableVolumeBaseline(item, previousSnapshot ?? null) !== null; + }); + const skippedReason = + options.mode === 'price' + ? hasRegisteredTargets + ? '현재가 시세를 확인할 수 있는 종목이 없습니다.' + : '현재가로 등록된 종목이 없습니다.' + : options.mode === 'change-threshold' + ? hasRegisteredTargets + ? `${thresholdPercent}% 이상 변동 종목이 없습니다.` + : '등록된 종목이 없습니다.' + : !hasRegisteredTargets + ? '등록된 종목이 없습니다.' + : !hasComparableVolumeBaseline + ? '이전 거래량 또는 5영업일 평균 거래량 비교 기준이 없어 스냅샷만 갱신했습니다.' + : `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 직전 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`; const skippedResult = createSkippedNotificationResult(skippedReason); + if (options.mode === 'change-threshold-volume-spike') { + await upsertStockAlertVolumeSnapshots(items, previousSnapshots); + } + if (!lines.length) { return { ok: true, @@ -1306,7 +1725,12 @@ export async function sendManagedStockAlertWebPush(options: { targetAppDomains: [STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN], data: { category: 'stock-alert', - eventType: options.mode === 'price' ? 'stock-alert-current-price' : 'stock-alert-change-threshold', + eventType: + options.mode === 'price' + ? 'stock-alert-current-price' + : options.mode === 'change-threshold' + ? 'stock-alert-change-threshold' + : 'stock-alert-change-threshold-volume-spike', notificationKey: notificationIdentity.notificationKey, notificationScope: notificationIdentity.notificationScope, notificationAliases: JSON.stringify(notificationIdentity.notificationAliases), @@ -1369,13 +1793,30 @@ export async function updateStockAlertLayoutFeatureDescription() { const title = interaction.title?.trim(); if (title === '그리드 기본정의') { + const nextDescription = [ + '## 그리드 필드를 아래로 정의하세요.', + ' - 종목명, 등락률, 현재가, 기준일시, 알림유형', + '## 숨긴필드', + ' - 종목코드', + '## DB관리 데이터', + ' - 종목코드, 알림유형', + '## 외부 API 및 가공 데이터', + ' - DB에 저장된 종목코드에 대한 현재가 등락률 기준일시를 표현', + ' - 현재가 데이터는 정규장이 아닌 가격도 모두 가져오도록 해주세요.', + '## 서비스 구현', + ' - 해당 그리드 데이터를 저장할 DB 및 서비스 API를 구현허고 연결하세요. CRUD', + ' - 알림유형은 한종목에 멀티로 저장되어야 합니다.', + '## 입력', + '알림유형의 경우 멀티선택 가능하게 해주세요.', + ].join('\n'); const nextNotes = - 'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다.'; + 'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량은 네이버 실시간 누적거래량을 현재값으로 받고, Yahoo Finance 일봉 최근 5영업일 평균 대비 비율(%)로 계산해 제공합니다.'; - if (interaction.implementationNotes !== nextNotes) { + if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) { changed = true; return { ...interaction, + description: nextDescription, implementationNotes: nextNotes, }; } diff --git a/etc/servers/work-server/src/services/visitor-history-service.js b/etc/servers/work-server/src/services/visitor-history-service.js new file mode 100644 index 0000000..983928d --- /dev/null +++ b/etc/servers/work-server/src/services/visitor-history-service.js @@ -0,0 +1,476 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updateVisitorNicknameSchema = exports.visitorHistoryQuerySchema = exports.listVisitorClientsQuerySchema = exports.trackVisitSchema = exports.VISITOR_HISTORY_TABLE = exports.VISITOR_CLIENT_TABLE = void 0; +exports.ensureVisitorHistoryTables = ensureVisitorHistoryTables; +exports.getVisitorClientByClientId = getVisitorClientByClientId; +exports.listVisitorClients = listVisitorClients; +exports.listVisitorHistories = listVisitorHistories; +exports.updateVisitorNickname = updateVisitorNickname; +exports.trackVisit = trackVisit; +var zod_1 = require("zod"); +var client_js_1 = require("../db/client.js"); +exports.VISITOR_CLIENT_TABLE = 'visitor_clients'; +exports.VISITOR_HISTORY_TABLE = 'visitor_visit_histories'; +exports.trackVisitSchema = zod_1.z.object({ + clientId: zod_1.z.string().trim().min(1).max(120), + url: zod_1.z.string().trim().min(1).max(2000), + eventType: zod_1.z.string().trim().min(1).max(80).default('page_view'), + userAgent: zod_1.z.string().trim().max(1000).optional().nullable(), +}); +exports.listVisitorClientsQuerySchema = zod_1.z.object({ + limit: zod_1.z.coerce.number().int().min(1).max(500).optional(), + search: zod_1.z.string().trim().max(120).optional(), + clientId: zod_1.z.string().trim().max(120).optional(), + nickname: zod_1.z.string().trim().max(80).optional(), + path: zod_1.z.string().trim().max(500).optional(), + visitedFrom: zod_1.z.string().trim().max(40).optional(), + visitedTo: zod_1.z.string().trim().max(40).optional(), +}); +exports.visitorHistoryQuerySchema = zod_1.z.object({ + limit: zod_1.z.coerce.number().int().min(1).max(500).optional(), +}); +exports.updateVisitorNicknameSchema = zod_1.z.object({ + nickname: zod_1.z.string().trim().min(1).max(80), +}); +function mapVisitorClientRow(row) { + var _a, _b, _c, _d, _e, _f, _g; + return { + clientId: String((_a = row.client_id) !== null && _a !== void 0 ? _a : ''), + nickname: String((_b = row.nickname) !== null && _b !== void 0 ? _b : ''), + firstVisitedAt: String((_c = row.first_visited_at) !== null && _c !== void 0 ? _c : ''), + lastVisitedAt: String((_d = row.last_visited_at) !== null && _d !== void 0 ? _d : ''), + visitCount: Number((_e = row.visit_count) !== null && _e !== void 0 ? _e : 0), + lastVisitedUrl: row.last_visited_url ? String(row.last_visited_url) : null, + lastUserAgent: row.last_user_agent ? String(row.last_user_agent) : null, + lastIp: row.last_ip ? String(row.last_ip) : null, + createdAt: String((_f = row.created_at) !== null && _f !== void 0 ? _f : ''), + updatedAt: String((_g = row.updated_at) !== null && _g !== void 0 ? _g : ''), + }; +} +function mapVisitorHistoryRow(row) { + var _a, _b, _c, _d, _e; + return { + id: Number((_a = row.id) !== null && _a !== void 0 ? _a : 0), + clientId: String((_b = row.client_id) !== null && _b !== void 0 ? _b : ''), + visitedAt: String((_c = row.visited_at) !== null && _c !== void 0 ? _c : ''), + url: String((_d = row.url) !== null && _d !== void 0 ? _d : ''), + eventType: String((_e = row.event_type) !== null && _e !== void 0 ? _e : 'page_view'), + userAgent: row.user_agent ? String(row.user_agent) : null, + ip: row.ip ? String(row.ip) : null, + }; +} +function normalizeDateBoundary(value, boundary) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + return boundary === 'start' ? "".concat(value, " 00:00:00") : "".concat(value, " 23:59:59.999"); +} +function ensureVisitorClientTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.VISITOR_CLIENT_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.VISITOR_CLIENT_TABLE, function (table) { + table.increments('id').primary(); + table.string('client_id', 120).notNullable().unique(); + table.string('nickname', 80).notNullable(); + table.timestamp('first_visited_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('last_visited_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.integer('visit_count').notNullable().defaultTo(1); + table.string('last_visited_url', 2000).nullable(); + table.string('last_user_agent', 1000).nullable(); + table.string('last_ip', 120).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['client_id', function (table) { return table.string('client_id', 120).notNullable().unique(); }], + ['nickname', function (table) { return table.string('nickname', 80).notNullable().defaultTo('방문자_0001'); }], + ['first_visited_at', function (table) { return table.timestamp('first_visited_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['last_visited_at', function (table) { return table.timestamp('last_visited_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['visit_count', function (table) { return table.integer('visit_count').notNullable().defaultTo(1); }], + ['last_visited_url', function (table) { return table.string('last_visited_url', 2000).nullable(); }], + ['last_user_agent', function (table) { return table.string('last_user_agent', 1000).nullable(); }], + ['last_ip', function (table) { return table.string('last_ip', 120).nullable(); }], + ['created_at', function (table) { return table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ]; + _loop_1 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.VISITOR_CLIENT_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.VISITOR_CLIENT_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_1 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7]; + _a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_1(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function ensureVisitorHistoryTable() { + return __awaiter(this, void 0, void 0, function () { + var hasTable, requiredColumns, _loop_2, _i, requiredColumns_2, _a, columnName, createColumn; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(exports.VISITOR_HISTORY_TABLE)]; + case 1: + hasTable = _b.sent(); + if (!!hasTable) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.createTable(exports.VISITOR_HISTORY_TABLE, function (table) { + table.increments('id').primary(); + table.string('client_id', 120).notNullable().index(); + table.timestamp('visited_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); + table.string('url', 2000).notNullable(); + table.string('event_type', 80).notNullable().defaultTo('page_view'); + table.string('user_agent', 1000).nullable(); + table.string('ip', 120).nullable(); + })]; + case 2: + _b.sent(); + return [2 /*return*/]; + case 3: + requiredColumns = [ + ['client_id', function (table) { return table.string('client_id', 120).notNullable().index(); }], + ['visited_at', function (table) { return table.timestamp('visited_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }], + ['url', function (table) { return table.string('url', 2000).notNullable().defaultTo(''); }], + ['event_type', function (table) { return table.string('event_type', 80).notNullable().defaultTo('page_view'); }], + ['user_agent', function (table) { return table.string('user_agent', 1000).nullable(); }], + ['ip', function (table) { return table.string('ip', 120).nullable(); }], + ]; + _loop_2 = function (columnName, createColumn) { + var hasColumn; + return __generator(this, function (_c) { + switch (_c.label) { + case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(exports.VISITOR_HISTORY_TABLE, columnName)]; + case 1: + hasColumn = _c.sent(); + if (!!hasColumn) return [3 /*break*/, 3]; + return [4 /*yield*/, client_js_1.db.schema.alterTable(exports.VISITOR_HISTORY_TABLE, function (table) { + createColumn(table); + })]; + case 2: + _c.sent(); + _c.label = 3; + case 3: return [2 /*return*/]; + } + }); + }; + _i = 0, requiredColumns_2 = requiredColumns; + _b.label = 4; + case 4: + if (!(_i < requiredColumns_2.length)) return [3 /*break*/, 7]; + _a = requiredColumns_2[_i], columnName = _a[0], createColumn = _a[1]; + return [5 /*yield**/, _loop_2(columnName, createColumn)]; + case 5: + _b.sent(); + _b.label = 6; + case 6: + _i++; + return [3 /*break*/, 4]; + case 7: return [2 /*return*/]; + } + }); + }); +} +function ensureVisitorHistoryTables() { + return __awaiter(this, void 0, void 0, function () { + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureVisitorClientTable()]; + case 1: + _a.sent(); + return [4 /*yield*/, ensureVisitorHistoryTable()]; + case 2: + _a.sent(); + return [2 /*return*/]; + } + }); + }); +} +function generateAutoNickname() { + return __awaiter(this, void 0, void 0, function () { + var result, nextNumber; + var _a, _b, _c; + return __generator(this, function (_d) { + switch (_d.label) { + case 0: return [4 /*yield*/, client_js_1.db.raw("\n select coalesce(max(cast(substring(nickname from '[0-9]+$') as integer)), 0) + 1 as next_nickname_number\n from ".concat(exports.VISITOR_CLIENT_TABLE, "\n where nickname like '\uBC29\uBB38\uC790\\_%'\n "))]; + case 1: + result = _d.sent(); + nextNumber = Number((_c = (_b = (_a = result.rows) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.next_nickname_number) !== null && _c !== void 0 ? _c : 1); + return [2 /*return*/, "\uBC29\uBB38\uC790_".concat(String(nextNumber).padStart(4, '0'))]; + } + }); + }); +} +function getVisitorClientByClientId(clientId) { + return __awaiter(this, void 0, void 0, function () { + var row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureVisitorHistoryTables()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.VISITOR_CLIENT_TABLE).where({ client_id: clientId }).first()]; + case 2: + row = _a.sent(); + return [2 /*return*/, row ? mapVisitorClientRow(row) : null]; + } + }); + }); +} +function listVisitorClients() { + return __awaiter(this, arguments, void 0, function (limit, filters) { + var query, normalizedSearch, normalizedClientId, normalizedNickname, normalizedPath, normalizedVisitedFrom, normalizedVisitedTo, rows; + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m; + if (limit === void 0) { limit = 100; } + if (filters === void 0) { filters = {}; } + return __generator(this, function (_o) { + switch (_o.label) { + case 0: return [4 /*yield*/, ensureVisitorHistoryTables()]; + case 1: + _o.sent(); + query = (0, client_js_1.db)(exports.VISITOR_CLIENT_TABLE) + .select('*') + .orderBy('last_visited_at', 'desc') + .limit(Math.min(Math.max(Math.trunc(limit), 1), 500)); + normalizedSearch = (_b = (_a = filters.search) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ''; + if (normalizedSearch) { + query.where(function (builder) { + builder.whereILike('client_id', "%".concat(normalizedSearch, "%")).orWhereILike('nickname', "%".concat(normalizedSearch, "%")); + }); + } + normalizedClientId = (_d = (_c = filters.clientId) === null || _c === void 0 ? void 0 : _c.trim()) !== null && _d !== void 0 ? _d : ''; + if (normalizedClientId) { + query.whereILike('client_id', "%".concat(normalizedClientId, "%")); + } + normalizedNickname = (_f = (_e = filters.nickname) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : ''; + if (normalizedNickname) { + query.whereILike('nickname', "%".concat(normalizedNickname, "%")); + } + normalizedPath = (_h = (_g = filters.path) === null || _g === void 0 ? void 0 : _g.trim()) !== null && _h !== void 0 ? _h : ''; + normalizedVisitedFrom = (_k = (_j = filters.visitedFrom) === null || _j === void 0 ? void 0 : _j.trim()) !== null && _k !== void 0 ? _k : ''; + normalizedVisitedTo = (_m = (_l = filters.visitedTo) === null || _l === void 0 ? void 0 : _l.trim()) !== null && _m !== void 0 ? _m : ''; + if (normalizedPath || normalizedVisitedFrom || normalizedVisitedTo) { + query.whereIn('client_id', (0, client_js_1.db)(exports.VISITOR_HISTORY_TABLE) + .select('client_id') + .modify(function (historyQuery) { + if (normalizedPath) { + historyQuery.whereILike('url', "%".concat(normalizedPath, "%")); + } + if (normalizedVisitedFrom) { + historyQuery.where('visited_at', '>=', normalizeDateBoundary(normalizedVisitedFrom, 'start')); + } + if (normalizedVisitedTo) { + historyQuery.where('visited_at', '<=', normalizeDateBoundary(normalizedVisitedTo, 'end')); + } + })); + } + return [4 /*yield*/, query]; + case 2: + rows = _o.sent(); + return [2 /*return*/, rows.map(function (row) { return mapVisitorClientRow(row); })]; + } + }); + }); +} +function listVisitorHistories(clientId_1) { + return __awaiter(this, arguments, void 0, function (clientId, limit) { + var rows; + if (limit === void 0) { limit = 100; } + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureVisitorHistoryTables()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.VISITOR_HISTORY_TABLE) + .select('*') + .where({ client_id: clientId }) + .orderBy('visited_at', 'desc') + .limit(Math.min(Math.max(Math.trunc(limit), 1), 500))]; + case 2: + rows = _a.sent(); + return [2 /*return*/, rows.map(function (row) { return mapVisitorHistoryRow(row); })]; + } + }); + }); +} +function updateVisitorNickname(clientId, nickname) { + return __awaiter(this, void 0, void 0, function () { + var rows, row; + return __generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, ensureVisitorHistoryTables()]; + case 1: + _a.sent(); + return [4 /*yield*/, (0, client_js_1.db)(exports.VISITOR_CLIENT_TABLE) + .where({ client_id: clientId }) + .update({ + nickname: nickname, + updated_at: client_js_1.db.fn.now(), + }) + .returning('*')]; + case 2: + rows = _a.sent(); + row = rows[0]; + return [2 /*return*/, row ? mapVisitorClientRow(row) : null]; + } + }); + }); +} +function trackVisit(payload, ip) { + return __awaiter(this, void 0, void 0, function () { + var parsedPayload, normalizedIp, normalizedUserAgent, existing, _a, _b, error_1, dbError; + var _c; + var _d; + return __generator(this, function (_e) { + switch (_e.label) { + case 0: return [4 /*yield*/, ensureVisitorHistoryTables()]; + case 1: + _e.sent(); + parsedPayload = exports.trackVisitSchema.parse(payload); + normalizedIp = String(ip !== null && ip !== void 0 ? ip : '').trim() || null; + normalizedUserAgent = ((_d = parsedPayload.userAgent) === null || _d === void 0 ? void 0 : _d.trim()) || null; + return [4 /*yield*/, (0, client_js_1.db)(exports.VISITOR_CLIENT_TABLE) + .select('*') + .where({ client_id: parsedPayload.clientId }) + .first()]; + case 2: + existing = _e.sent(); + if (!existing) return [3 /*break*/, 4]; + return [4 /*yield*/, (0, client_js_1.db)(exports.VISITOR_CLIENT_TABLE) + .where({ client_id: parsedPayload.clientId }) + .update({ + last_visited_at: client_js_1.db.fn.now(), + visit_count: client_js_1.db.raw('coalesce(visit_count, 0) + 1'), + last_visited_url: parsedPayload.url, + last_user_agent: normalizedUserAgent, + last_ip: normalizedIp, + updated_at: client_js_1.db.fn.now(), + })]; + case 3: + _e.sent(); + return [3 /*break*/, 9]; + case 4: + _e.trys.push([4, 7, , 9]); + _b = (_a = (0, client_js_1.db)(exports.VISITOR_CLIENT_TABLE)).insert; + _c = { + client_id: parsedPayload.clientId + }; + return [4 /*yield*/, generateAutoNickname()]; + case 5: + // 최초 방문만 자동 닉네임을 발급하고 이후에는 사용자가 수정할 수 있습니다. + return [4 /*yield*/, _b.apply(_a, [(_c.nickname = _e.sent(), + _c.first_visited_at = client_js_1.db.fn.now(), + _c.last_visited_at = client_js_1.db.fn.now(), + _c.visit_count = 1, + _c.last_visited_url = parsedPayload.url, + _c.last_user_agent = normalizedUserAgent, + _c.last_ip = normalizedIp, + _c.created_at = client_js_1.db.fn.now(), + _c.updated_at = client_js_1.db.fn.now(), + _c)])]; + case 6: + // 최초 방문만 자동 닉네임을 발급하고 이후에는 사용자가 수정할 수 있습니다. + _e.sent(); + return [3 /*break*/, 9]; + case 7: + error_1 = _e.sent(); + dbError = error_1; + if (dbError.code !== '23505') { + throw error_1; + } + return [4 /*yield*/, (0, client_js_1.db)(exports.VISITOR_CLIENT_TABLE) + .where({ client_id: parsedPayload.clientId }) + .update({ + last_visited_at: client_js_1.db.fn.now(), + visit_count: client_js_1.db.raw('coalesce(visit_count, 0) + 1'), + last_visited_url: parsedPayload.url, + last_user_agent: normalizedUserAgent, + last_ip: normalizedIp, + updated_at: client_js_1.db.fn.now(), + })]; + case 8: + _e.sent(); + return [3 /*break*/, 9]; + case 9: return [4 /*yield*/, (0, client_js_1.db)(exports.VISITOR_HISTORY_TABLE).insert({ + client_id: parsedPayload.clientId, + visited_at: client_js_1.db.fn.now(), + url: parsedPayload.url, + event_type: parsedPayload.eventType, + user_agent: normalizedUserAgent, + ip: normalizedIp, + })]; + case 10: + _e.sent(); + return [2 /*return*/, getVisitorClientByClientId(parsedPayload.clientId)]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/services/work-server-build-service.test.ts b/etc/servers/work-server/src/services/work-server-build-service.test.ts new file mode 100644 index 0000000..26fcd00 --- /dev/null +++ b/etc/servers/work-server/src/services/work-server-build-service.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveWorkServerBuildInfoFilePaths } from './work-server-build-service.js'; + +test('resolveWorkServerBuildInfoFilePaths includes configured dist and mirrored main-project fallback paths', () => { + const candidates = resolveWorkServerBuildInfoFilePaths({ + workServerRootPath: '/workspace/auto_codex/repo/etc/servers/work-server', + mainProjectRoot: '/workspace/main-project', + configuredDistDir: '/tmp/work-server-dist', + }); + + assert.deepEqual(candidates, [ + '/tmp/work-server-dist/build-info.json', + '/workspace/auto_codex/repo/etc/servers/work-server/dist/build-info.json', + '/workspace/main-project/etc/servers/work-server/dist/build-info.json', + ]); +}); + +test('resolveWorkServerBuildInfoFilePaths deduplicates identical configured dist paths', () => { + const candidates = resolveWorkServerBuildInfoFilePaths({ + workServerRootPath: '/workspace/main-project/etc/servers/work-server', + mainProjectRoot: '/workspace/main-project', + configuredDistDir: 'dist', + }); + + assert.deepEqual(candidates, ['/workspace/main-project/etc/servers/work-server/dist/build-info.json']); +}); diff --git a/etc/servers/work-server/src/services/work-server-build-service.ts b/etc/servers/work-server/src/services/work-server-build-service.ts index 92d02ab..f4b0732 100755 --- a/etc/servers/work-server/src/services/work-server-build-service.ts +++ b/etc/servers/work-server/src/services/work-server-build-service.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { env } from '../config/env.js'; export type WorkServerBuildInfo = { version: string; @@ -16,13 +17,55 @@ export type WorkServerSourceChangeInfo = { const MODULE_DIR_PATH = path.dirname(fileURLToPath(import.meta.url)); const WORK_SERVER_ROOT_PATH = path.resolve(MODULE_DIR_PATH, '..', '..'); -const BUILD_INFO_FILE_PATH = path.join(WORK_SERVER_ROOT_PATH, 'dist', 'build-info.json'); -const SOURCE_TARGET_PATHS = [ - path.join(WORK_SERVER_ROOT_PATH, 'src'), - path.join(WORK_SERVER_ROOT_PATH, 'scripts'), - path.join(WORK_SERVER_ROOT_PATH, 'package.json'), - path.join(WORK_SERVER_ROOT_PATH, 'tsconfig.json'), -] as const; +const SOURCE_TARGET_PATH_NAMES = ['src', 'scripts', 'package.json', 'tsconfig.json'] as const; + +function normalizeRootPath(value: string | null | undefined) { + const normalized = String(value ?? '').trim(); + return normalized ? path.resolve(normalized) : null; +} + +function resolveSourceTargetRoots() { + const roots = [WORK_SERVER_ROOT_PATH]; + const mainProjectRoot = normalizeRootPath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT); + + if (mainProjectRoot) { + const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server'); + + if (!roots.includes(mirroredWorkServerRoot)) { + roots.push(mirroredWorkServerRoot); + } + } + + return roots; +} + +function resolveBuildInfoDirectoryPath(rootPath: string, configuredDistDir: string) { + return path.resolve(rootPath, configuredDistDir); +} + +export function resolveWorkServerBuildInfoFilePaths(options?: { + workServerRootPath?: string; + mainProjectRoot?: string | null; + configuredDistDir?: string | null; +}) { + const workServerRootPath = path.resolve(options?.workServerRootPath ?? WORK_SERVER_ROOT_PATH); + const configuredDistDir = String(options?.configuredDistDir ?? env.WORK_SERVER_DIST_DIR ?? 'dist').trim() || 'dist'; + const mainProjectRoot = normalizeRootPath( + options?.mainProjectRoot ?? env.SERVER_COMMAND_MAIN_PROJECT_ROOT ?? env.SERVER_COMMAND_PROJECT_ROOT, + ); + const candidates = [ + path.join(resolveBuildInfoDirectoryPath(workServerRootPath, configuredDistDir), 'build-info.json'), + path.join(workServerRootPath, 'dist', 'build-info.json'), + ]; + + if (mainProjectRoot) { + const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server'); + candidates.push(path.join(resolveBuildInfoDirectoryPath(mirroredWorkServerRoot, configuredDistDir), 'build-info.json')); + candidates.push(path.join(mirroredWorkServerRoot, 'dist', 'build-info.json')); + } + + return candidates.filter((candidate, index, array) => array.indexOf(candidate) === index); +} function normalizeBuildInfo(value: unknown): WorkServerBuildInfo | null { if (!value || typeof value !== 'object') { @@ -65,27 +108,43 @@ function readBuildInfoFromDiskSync(filePath: string) { } export async function readLatestWorkServerBuildInfo() { - try { - return normalizeBuildInfo(JSON.parse(await readFile(BUILD_INFO_FILE_PATH, 'utf8'))); - } catch { - return null; + let latestBuildInfo: WorkServerBuildInfo | null = null; + + for (const candidatePath of resolveWorkServerBuildInfoFilePaths()) { + try { + const buildInfo = normalizeBuildInfo(JSON.parse(await readFile(candidatePath, 'utf8'))); + + if (!buildInfo) { + continue; + } + + if (!latestBuildInfo || buildInfo.builtAt > latestBuildInfo.builtAt) { + latestBuildInfo = buildInfo; + } + } catch { + continue; + } } + + return latestBuildInfo; } -const runtimeWorkServerBuildInfo = readBuildInfoFromDiskSync(BUILD_INFO_FILE_PATH); +const runtimeWorkServerBuildInfo = readBuildInfoFromDiskSync( + path.join(resolveBuildInfoDirectoryPath(WORK_SERVER_ROOT_PATH, env.WORK_SERVER_DIST_DIR), 'build-info.json'), +); export function getRuntimeWorkServerBuildInfo() { return runtimeWorkServerBuildInfo; } -async function findLatestSourceChangeInPath(targetPath: string): Promise { +async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise { try { const stats = await fs.promises.stat(targetPath); if (stats.isFile()) { return { changedAt: stats.mtime.toISOString(), - path: path.relative(WORK_SERVER_ROOT_PATH, targetPath) || path.basename(targetPath), + path: path.relative(rootPath, targetPath) || path.basename(targetPath), }; } @@ -102,7 +161,7 @@ async function findLatestSourceChangeInPath(targetPath: string): Promise latest.changedAt) { - latest = candidate; + if (!latest || candidate.changedAt > latest.changedAt) { + latest = candidate; + } } } diff --git a/etc/servers/work-server/src/services/worklog-automation-utils.js b/etc/servers/work-server/src/services/worklog-automation-utils.js new file mode 100644 index 0000000..70c61e7 --- /dev/null +++ b/etc/servers/work-server/src/services/worklog-automation-utils.js @@ -0,0 +1,193 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __generator = (this && this.__generator) || function (thisArg, body) { + var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); + return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; + function verb(n) { return function (v) { return step([n, v]); }; } + function step(op) { + if (f) throw new TypeError("Generator is already executing."); + while (g && (g = 0, op[0] && (_ = 0)), _) try { + if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; + if (y = 0, t) op = [op[0] & 2, t.value]; + switch (op[0]) { + case 0: case 1: t = op; break; + case 4: _.label++; return { value: op[1], done: false }; + case 5: _.label++; y = op[1]; op = [0]; continue; + case 7: op = _.ops.pop(); _.trys.pop(); continue; + default: + if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } + if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } + if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } + if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } + if (t[2]) _.ops.pop(); + _.trys.pop(); continue; + } + op = body.call(thisArg, _); + } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } + if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; + } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_DAILY_CREATE_TIME = void 0; +exports.isValidDailyCreateTime = isValidDailyCreateTime; +exports.normalizeDailyCreateTime = normalizeDailyCreateTime; +exports.getKstNowParts = getKstNowParts; +exports.isWorklogCreationDue = isWorklogCreationDue; +exports.renderWorklogTemplate = renderWorklogTemplate; +exports.upgradeLegacyWorklogContent = upgradeLegacyWorklogContent; +exports.ensureDailyWorklogFile = ensureDailyWorklogFile; +var promises_1 = require("node:fs/promises"); +var node_path_1 = require("node:path"); +var KST_TIME_ZONE = 'Asia/Seoul'; +exports.DEFAULT_DAILY_CREATE_TIME = '18:00'; +var DEFAULT_WORKLOG_TEMPLATE = "# YYYY-MM-DD \uC791\uC5C5\uC77C\uC9C0\n\n## \uC624\uB298 \uC791\uC5C5\n\n- \n\n## \uC774\uC288 \uBC0F \uD574\uACB0\n\n- \n\n## \uACB0\uC815 \uC0AC\uD56D\n\n- \n\n## \uC0C1\uC138 \uC791\uC5C5 \uB0B4\uC5ED\n\n- \n- \uC774 \uC139\uC158\uC5D0\uB294 \uD30C\uC77C \uBAA9\uB85D, \uACBD\uB85C \uB098\uC5F4, raw diff\uB97C \uC9C1\uC811 \uD480\uC5B4\uC4F0\uC9C0 \uB9D0\uACE0 \uC791\uC5C5 \uD750\uB984\uACFC \uD310\uB2E8\uB9CC \uC815\uB9AC\n- \uD30C\uC77C \uBAA9\uB85D\uC740 \uBCC0\uACBD/\uC2E0\uADDC \uD30C\uC77C \uC139\uC158\uC5D0, raw diff\uB294 \uC18C\uC2A4 \uC139\uC158\uC5D0\uB9CC \uAE30\uB85D\n\n## \uC2A4\uD06C\uB9B0\uC0F7\n\n- \uC804\uCCB4 \uD654\uBA74 \uC2A4\uD06C\uB9B0\uC0F7 1\uC7A5\uC740 \uD544\uC218\n- \uC704\uC82F/\uCEF4\uD3EC\uB10C\uD2B8 \uB2E8\uC704 \uBD80\uBD84 \uC2A4\uD06C\uB9B0\uC0F7\uC740 \uD544\uC694\uD55C \uB9CC\uD07C \uCD94\uAC00\n- \uC800\uC7A5\uC18C \uAE30\uC900 \uC5F0\uACB0\uB41C \uC2A4\uD06C\uB9B0\uC0F7\uC774 \uC5C6\uC73C\uBA74 \uC791\uC5C5 \uC885\uB8CC \uC804 \uBC18\uB4DC\uC2DC \uCC44\uC6C0\n\n## \uC18C\uC2A4\n\n### \uD30C\uC77C 1: `path/to/file.tsx`\n\n- \uBCC0\uACBD \uB610\uB294 \uC2E0\uADDC \uCD94\uAC00 \uBAA9\uC801\uACFC \uD575\uC2EC \uB0B4\uC6A9\uC744 \uD55C \uC904\uB85C \uC815\uB9AC\n- `\uC0C1\uC138 \uC791\uC5C5 \uB0B4\uC5ED`\uC5D0\uB294 \uD30C\uC77C \uBAA9\uB85D\uC774\uB098 raw diff\uB97C \uB2E4\uC2DC \uC4F0\uC9C0 \uC54A\uC74C\n- `\uC18C\uC2A4` \uD0ED\uC5D0\uC11C Codex preview \uC2A4\uD0C0\uC77C\uC758 `\uC804\uCCB4\uC18C\uC2A4 / raw diff` \uC804\uD658\uC744 \uC81C\uACF5\uD558\uBBC0\uB85C \uC5EC\uAE30\uC5D0\uB294 \uD30C\uC77C\uBCC4 raw diff \uC704\uC8FC\uB85C \uB0A8\uAE40\n\n```diff\n# \uC774 \uD30C\uC77C\uC758 raw diff\n- before\n+ after\n```\n\n### \uD30C\uC77C 2: `path/to/another-file.ts`\n\n- \uD544\uC694 \uC5C6\uC73C\uBA74 \uC774 \uC139\uC158\uC740 \uC0AD\uC81C\n\n## \uC2E4\uD589 \uCEE4\uB9E8\uB4DC\n\n```bash\n```\n\n## \uBCC0\uACBD/\uC2E0\uADDC \uD30C\uC77C\n\n- \n"; +var OBSOLETE_WORKLOG_SECTION_TITLES = new Set([ + '커밋 목록', + '변경 요약', + '변경 통계', + '라인 통계', +]); +function extractKstParts(date) { + var _a, _b, _c, _d, _e; + var parts = new Intl.DateTimeFormat('en-CA', { + timeZone: KST_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + var values = Object.fromEntries(parts.filter(function (part) { return part.type !== 'literal'; }).map(function (part) { return [part.type, part.value]; })); + return { + year: Number((_a = values.year) !== null && _a !== void 0 ? _a : '0'), + month: Number((_b = values.month) !== null && _b !== void 0 ? _b : '0'), + day: Number((_c = values.day) !== null && _c !== void 0 ? _c : '0'), + hour: Number((_d = values.hour) !== null && _d !== void 0 ? _d : '0'), + minute: Number((_e = values.minute) !== null && _e !== void 0 ? _e : '0'), + }; +} +function isValidDailyCreateTime(value) { + return typeof value === 'string' && /^\d{2}:\d{2}$/.test(value); +} +function normalizeDailyCreateTime(value) { + return isValidDailyCreateTime(value) ? value : exports.DEFAULT_DAILY_CREATE_TIME; +} +function getKstNowParts(date) { + if (date === void 0) { date = new Date(); } + var _a = extractKstParts(date), year = _a.year, month = _a.month, day = _a.day, hour = _a.hour, minute = _a.minute; + return { + dateKey: "".concat(String(year).padStart(4, '0'), "-").concat(String(month).padStart(2, '0'), "-").concat(String(day).padStart(2, '0')), + minutesOfDay: hour * 60 + minute, + }; +} +function isWorklogCreationDue(now, dailyCreateTime, alreadyExecutedToday) { + if (alreadyExecutedToday) { + return false; + } + var _a = normalizeDailyCreateTime(dailyCreateTime).split(':').map(function (value) { return Number(value); }), hours = _a[0], minutes = _a[1]; + var nowParts = getKstNowParts(now); + return nowParts.minutesOfDay >= (hours * 60 + minutes); +} +function renderWorklogTemplate(template, dateKey) { + return template.replaceAll('YYYY-MM-DD', dateKey); +} +function normalizeLevelTwoHeading(line) { + var _a, _b; + var match = line.match(/^##\s+([^\n]+)$/m); + if (!match) { + return ''; + } + return (_b = (_a = match[1]) === null || _a === void 0 ? void 0 : _a.replace(/\s+\(.*\)\s*$/u, '').trim()) !== null && _b !== void 0 ? _b : ''; +} +function stripObsoleteWorklogSections(content) { + var sections = content.split(/\n(?=##\s+)/); + var preservedSections = sections.filter(function (section) { + var normalizedHeading = normalizeLevelTwoHeading(section); + return !OBSOLETE_WORKLOG_SECTION_TITLES.has(normalizedHeading); + }); + return "".concat(preservedSections.join('\n\n').replace(/\n{3,}/g, '\n\n').trimEnd(), "\n"); +} +var LEGACY_SOURCE_SECTION = "## \uC18C\uC2A4\n\n- \uC694\uC57D \uC124\uBA85\n- `path/to/file.tsx`: \uBCC0\uACBD \uB610\uB294 \uC2E0\uADDC \uCD94\uAC00 \uBAA9\uC801\uACFC \uD575\uC2EC \uB0B4\uC6A9\uC744 \uD55C \uC904\uB85C \uC815\uB9AC\n\n```diff\n# \uD575\uC2EC diff\uB97C 1~3\uAC1C \uBE14\uB85D\uC73C\uB85C \uAE30\uB85D\n- before\n+ after\n```"; +var FILE_SCOPED_SOURCE_SECTION = "## \uC18C\uC2A4\n\n### \uD30C\uC77C 1: `path/to/file.tsx`\n\n- \uBCC0\uACBD \uB610\uB294 \uC2E0\uADDC \uCD94\uAC00 \uBAA9\uC801\uACFC \uD575\uC2EC \uB0B4\uC6A9\uC744 \uD55C \uC904\uB85C \uC815\uB9AC\n- `\uC0C1\uC138 \uC791\uC5C5 \uB0B4\uC5ED`\uC5D0\uB294 \uD30C\uC77C \uBAA9\uB85D\uC774\uB098 raw diff\uB97C \uB2E4\uC2DC \uC4F0\uC9C0 \uC54A\uC74C\n- `\uC18C\uC2A4` \uD0ED\uC5D0\uC11C Codex preview \uC2A4\uD0C0\uC77C\uC758 `\uC804\uCCB4\uC18C\uC2A4 / raw diff` \uC804\uD658\uC744 \uC81C\uACF5\uD558\uBBC0\uB85C \uC5EC\uAE30\uC5D0\uB294 \uD30C\uC77C\uBCC4 raw diff \uC704\uC8FC\uB85C \uB0A8\uAE40\n\n```diff\n# \uC774 \uD30C\uC77C\uC758 raw diff\n- before\n+ after\n```\n\n### \uD30C\uC77C 2: `path/to/another-file.ts`\n\n- \uD544\uC694 \uC5C6\uC73C\uBA74 \uC774 \uC139\uC158\uC740 \uC0AD\uC81C"; +function upgradeLegacyWorklogContent(content) { + var upgradedContent = content; + if (upgradedContent.includes(LEGACY_SOURCE_SECTION)) { + upgradedContent = upgradedContent.replace(LEGACY_SOURCE_SECTION, FILE_SCOPED_SOURCE_SECTION); + } + upgradedContent = upgradedContent + .replace('## 상세 작업 내역\n\n- ', "## \uC0C1\uC138 \uC791\uC5C5 \uB0B4\uC5ED\n\n- \n- \uC774 \uC139\uC158\uC5D0\uB294 \uD30C\uC77C \uBAA9\uB85D, \uACBD\uB85C \uB098\uC5F4, raw diff\uB97C \uC9C1\uC811 \uD480\uC5B4\uC4F0\uC9C0 \uB9D0\uACE0 \uC791\uC5C5 \uD750\uB984\uACFC \uD310\uB2E8\uB9CC \uC815\uB9AC\n- \uD30C\uC77C \uBAA9\uB85D\uC740 `## \uBCC0\uACBD/\uC2E0\uADDC \uD30C\uC77C`, raw diff\uB294 `## \uC18C\uC2A4`\uC5D0\uC11C\uB9CC \uAE30\uB85D") + .replace('## 스크린샷\n\n- 저장소 기준 연결된 스크린샷 없음', "## \uC2A4\uD06C\uB9B0\uC0F7\n\n- \uC804\uCCB4 \uD654\uBA74 \uC2A4\uD06C\uB9B0\uC0F7 1\uC7A5\uC740 \uD544\uC218\n- \uC704\uC82F/\uCEF4\uD3EC\uB10C\uD2B8 \uB2E8\uC704 \uBD80\uBD84 \uC2A4\uD06C\uB9B0\uC0F7\uC740 \uD544\uC694\uD55C \uB9CC\uD07C \uCD94\uAC00\n- \uC800\uC7A5\uC18C \uAE30\uC900 \uC5F0\uACB0\uB41C \uC2A4\uD06C\uB9B0\uC0F7\uC774 \uC5C6\uC73C\uBA74 \uC791\uC5C5 \uC885\uB8CC \uC804 \uBC18\uB4DC\uC2DC \uCC44\uC6C0") + .replaceAll('# 이 파일의 핵심 diff', '# 이 파일의 raw diff') + .replaceAll('`작업일지` 탭에는 중복 파일 목록을 다시 쓰지 않아도 됨', '`상세 작업 내역`에는 파일 목록이나 raw diff를 다시 쓰지 않음') + .replaceAll('`소스` 탭에서 전체소스/diff를 전환해 보므로 여기에는 파일별 raw diff 위주로 남김', '`소스` 탭에서 Codex preview 스타일의 `전체소스 / raw diff` 전환을 제공하므로 여기에는 파일별 raw diff 위주로 남김'); + return stripObsoleteWorklogSections(upgradedContent); +} +function readWorklogTemplate(repoPath) { + return __awaiter(this, void 0, void 0, function () { + var templatePath, _a; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + templatePath = node_path_1.default.join(repoPath, 'docs', 'templates', 'worklog-template.md'); + _b.label = 1; + case 1: + _b.trys.push([1, 3, , 4]); + return [4 /*yield*/, (0, promises_1.readFile)(templatePath, 'utf8')]; + case 2: return [2 /*return*/, _b.sent()]; + case 3: + _a = _b.sent(); + return [2 /*return*/, DEFAULT_WORKLOG_TEMPLATE]; + case 4: return [2 /*return*/]; + } + }); + }); +} +function ensureDailyWorklogFile(repoPath, dateKey) { + return __awaiter(this, void 0, void 0, function () { + var worklogPath, existingContent, upgradedContent, _a, template; + return __generator(this, function (_b) { + switch (_b.label) { + case 0: + worklogPath = node_path_1.default.join(repoPath, 'docs', 'worklogs', "".concat(dateKey, ".md")); + _b.label = 1; + case 1: + _b.trys.push([1, 6, , 10]); + return [4 /*yield*/, (0, promises_1.access)(worklogPath)]; + case 2: + _b.sent(); + return [4 /*yield*/, (0, promises_1.readFile)(worklogPath, 'utf8')]; + case 3: + existingContent = _b.sent(); + upgradedContent = upgradeLegacyWorklogContent(existingContent); + if (!(upgradedContent !== existingContent)) return [3 /*break*/, 5]; + return [4 /*yield*/, (0, promises_1.writeFile)(worklogPath, upgradedContent, 'utf8')]; + case 4: + _b.sent(); + _b.label = 5; + case 5: return [2 /*return*/, worklogPath]; + case 6: + _a = _b.sent(); + return [4 /*yield*/, readWorklogTemplate(repoPath)]; + case 7: + template = _b.sent(); + return [4 /*yield*/, (0, promises_1.mkdir)(node_path_1.default.dirname(worklogPath), { recursive: true })]; + case 8: + _b.sent(); + return [4 /*yield*/, (0, promises_1.writeFile)(worklogPath, renderWorklogTemplate(template, dateKey), 'utf8')]; + case 9: + _b.sent(); + return [2 /*return*/, worklogPath]; + case 10: return [2 /*return*/]; + } + }); + }); +} diff --git a/etc/servers/work-server/src/workers/plan-worker.ts b/etc/servers/work-server/src/workers/plan-worker.ts index 6b796db..dba59b8 100755 --- a/etc/servers/work-server/src/workers/plan-worker.ts +++ b/etc/servers/work-server/src/workers/plan-worker.ts @@ -42,6 +42,7 @@ import { pullMainProjectBranch, pushBranch, } from '../services/git-service.js'; +import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js'; import { registerDuePlanScheduledTasks } from '../services/plan-schedule-service.js'; const STREAM_CAPTURE_LIMIT = 256 * 1024; @@ -972,6 +973,7 @@ export class PlanWorker { `브랜치 생성 실패\n${message}`, 'branch-failed', ); + await progressBoardPostAutomationByPlanResult(planId, 'failed'); this.logger.error({ planId, err: error }, 'Plan branch creation failed'); } } @@ -1008,6 +1010,7 @@ export class PlanWorker { '로컬 main 직접 작업이 이미 반영되어 별도 release 반영 없이 완료 처리했습니다.', 'work-local-main-complete', ); + await progressBoardPostAutomationByPlanResult(planId, 'completed'); this.logger.info({ planId, branch: assignedBranch, releaseTarget }, 'Plan completed in local main mode without release merge'); return; } @@ -1036,6 +1039,7 @@ export class PlanWorker { `${releaseTarget} 브랜치 반영이 완료되었습니다.`, 'release-merged', ); + await progressBoardPostAutomationByPlanResult(planId, 'completed'); } this.logger.info( { planId, branch: assignedBranch, releaseTarget }, @@ -1068,6 +1072,7 @@ export class PlanWorker { `release 반영 실패\n${message}`, 'release-failed', ); + await progressBoardPostAutomationByPlanResult(planId, 'failed'); this.logger.error({ planId, err: error }, 'Plan release merge failed'); } } @@ -1105,6 +1110,7 @@ export class PlanWorker { '로컬 main 직접 작업이 이미 반영되어 별도 main merge 없이 완료 처리했습니다.', 'work-local-main-complete', ); + await progressBoardPostAutomationByPlanResult(planId, 'completed'); this.logger.info({ planId, branch: assignedBranch, releaseTarget }, 'Plan completed in local main mode without main merge'); return; } @@ -1140,6 +1146,7 @@ export class PlanWorker { `${env.PLAN_MAIN_BRANCH} 브랜치 반영 후 메인 프로젝트 pull까지 완료되었습니다.`, 'main-merged', ); + await progressBoardPostAutomationByPlanResult(notificationPlanId, 'completed'); } this.logger.info( { @@ -1180,6 +1187,7 @@ export class PlanWorker { `main 일괄 반영 실패\n${message}`, 'main-failed', ); + await progressBoardPostAutomationByPlanResult(planId, 'failed'); this.logger.error({ planId, err: error }, 'Plan main merge failed'); } } @@ -1262,6 +1270,7 @@ export class PlanWorker { : this.buildExecutionCompletedBody(autoDeployToMain), 'work-completed', ); + await progressBoardPostAutomationByPlanResult(planId, 'completed'); this.logger.info({ planId }, 'Plan Codex execution completed'); } catch (error) { const rawMessage = error instanceof Error ? error.message : '자동 작업 실행에 실패했습니다.'; @@ -1291,6 +1300,7 @@ export class PlanWorker { `자동 작업 실패\n${message}`, 'work-failed', ); + await progressBoardPostAutomationByPlanResult(planId, 'failed'); this.logger.error({ planId, err: error }, 'Plan Codex execution failed'); } } diff --git a/package.json b/package.json index d263e35..5194f48 100755 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "plan:codex:once": "node scripts/run-plan-codex-once.mjs", "server-command:runner": "node scripts/run-server-command-runner.mjs", "build:app": "tsc -b && vite build --outDir app-dist", - "build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist", + "build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true VITE_DISABLE_PWA=true vite build --outDir /tmp/ai-code-test-app-dist", "build:lib": "tsc -p tsconfig.lib.json", "build": "npm run build:lib && npm run build:app", "prepublishOnly": "npm run build:lib", diff --git a/resource/prod/.gitkeep b/resource/prod/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/resource/prod/.gitkeep @@ -0,0 +1 @@ + diff --git a/resource/prod/clipboard-20260506-214618-1.html b/resource/prod/clipboard-20260506-214618-1.html new file mode 100644 index 0000000..ca2a761 --- /dev/null +++ b/resource/prod/clipboard-20260506-214618-1.html @@ -0,0 +1 @@ +/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f \ No newline at end of file diff --git a/resource/prod/clipboard-20260506-214618-2.txt b/resource/prod/clipboard-20260506-214618-2.txt new file mode 100644 index 0000000..6691f8d --- /dev/null +++ b/resource/prod/clipboard-20260506-214618-2.txt @@ -0,0 +1 @@ +/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f \ No newline at end of file diff --git a/resource/release/.gitkeep b/resource/release/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/resource/release/.gitkeep @@ -0,0 +1 @@ + diff --git a/resource/release/IMG_9111.PNG b/resource/release/IMG_9111.PNG new file mode 100644 index 0000000..4c47c56 Binary files /dev/null and b/resource/release/IMG_9111.PNG differ diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index 5b3f3e3..80bd7e0 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -1,6 +1,6 @@ import { createServer } from 'node:http'; import { execFile, spawn } from 'node:child_process'; -import { access, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { access, chmod, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -50,8 +50,29 @@ const CODEX_HOME_RUNTIME_PATHS = [ 'models_cache.json', 'version.json', ]; +const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777; const activeCodexExecutions = new Map(); +async function ensureWorldWritableDirectory(absolutePath) { + await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE }); + await chmod(absolutePath, CHAT_SESSION_RESOURCE_DIR_MODE).catch(() => {}); +} + +async function ensureWritableChatSessionDirectories(repoPath, sessionId) { + const sessionRoot = path.join(repoPath, 'public', '.codex_chat', sessionId); + const resourceRoot = path.join(sessionRoot, 'resource'); + const uploadRoot = path.join(resourceRoot, 'uploads'); + + await ensureWorldWritableDirectory(sessionRoot); + await ensureWorldWritableDirectory(resourceRoot); + await ensureWorldWritableDirectory(uploadRoot); + + return { + resourceRoot, + uploadRoot, + }; +} + const commandDefinitions = { test: { label: 'TEST', @@ -539,8 +560,7 @@ async function runCodexLiveExecution(payload, response) { } await validateCodexExecutionRuntime(repoPath, codexBin); - await mkdir(resourceDir, { recursive: true }); - await mkdir(uploadDir, { recursive: true }); + await ensureWritableChatSessionDirectories(repoPath, sessionId); const tempDir = await mkdtemp(path.join(tmpdir(), 'command-runner-codex-')); const writableCodexHome = await prepareWritableCodexHome(tempDir); diff --git a/scripts/serve-app-dist.mjs b/scripts/serve-app-dist.mjs index 9c4a919..9a6da02 100755 --- a/scripts/serve-app-dist.mjs +++ b/scripts/serve-app-dist.mjs @@ -23,6 +23,20 @@ const mimeTypes = { '.woff2': 'font/woff2', }; +function resolveCacheControl(resolvedPath, extension) { + const normalizedPath = resolvedPath.replace(/\\/g, '/'); + + if (extension === '.html') { + return 'no-cache'; + } + + if (normalizedPath.endsWith('/sw.js') || extension === '.webmanifest') { + return 'no-store, no-cache, max-age=0, must-revalidate'; + } + + return 'public, max-age=31536000, immutable'; +} + function looksLikeStaticAsset(requestedPath) { const normalizedPath = requestedPath.split('?')[0] ?? requestedPath; const extension = extname(normalizedPath); @@ -135,7 +149,7 @@ const server = createServer(async (request, response) => { response.writeHead(200, { 'Content-Type': contentType, - 'Cache-Control': extension === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable', + 'Cache-Control': resolveCacheControl(resolvedPath, extension), }); createReadStream(resolvedPath).pipe(response); diff --git a/src/App.tsx b/src/App.tsx index 4f9039b..4f94b6f 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { getOrCreateClientId } from './app/main/clientIdentity'; import { reportClientError } from './app/main/errorLogApi'; import { AppShell } from './app/main'; import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay'; import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal'; +import { bindViewportCssVars } from './app/main/viewportCssVars'; import { reportVisitorPageView } from './features/history/api'; import { useAppStore } from './store'; @@ -39,6 +40,8 @@ function App() { const lastTrackedPageIdRef = useRef(null); const [showInitialLoading, setShowInitialLoading] = useState(true); + useLayoutEffect(() => bindViewportCssVars(), []); + useEffect(() => { if (typeof window === 'undefined') { return undefined; diff --git a/src/app/main/AppShell.tsx b/src/app/main/AppShell.tsx index 806fa9a..80460f7 100755 --- a/src/app/main/AppShell.tsx +++ b/src/app/main/AppShell.tsx @@ -5,18 +5,20 @@ import { ChatPage } from './pages/ChatPage'; import { DocsPage } from './pages/DocsPage'; import { PlansPage } from './pages/PlansPage'; import { PlayPage } from './pages/PlayPage'; -import { buildDocsPath, buildPlansPath } from './routes'; +import { buildChatPath, buildDocsPath } from './routes'; export function AppShell() { return ( }> - } /> + } /> } /> } /> } /> } /> } /> + } /> + } /> } /> } /> diff --git a/src/app/main/ChatDefaultContextManagementPage.tsx b/src/app/main/ChatDefaultContextManagementPage.tsx new file mode 100644 index 0000000..0fe37d2 --- /dev/null +++ b/src/app/main/ChatDefaultContextManagementPage.tsx @@ -0,0 +1,426 @@ +import { + ArrowsAltOutlined, + DeleteOutlined, + EditOutlined, + PlusOutlined, + SaveOutlined, + ShrinkOutlined, + UnorderedListOutlined, +} from '@ant-design/icons'; +import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { MarkdownPreviewContent } from '../../components/markdownPreview'; +import { + deleteChatDefaultContext, + pruneChatRoomContextSettings, + pruneChatTypeDefaultSelections, + upsertChatDefaultContext, + useChatContextSettingsRegistry, + type ChatDefaultContextRecord, +} from './chatContextSettingsAccess'; +import { useTokenAccess } from './tokenAccess'; +import './ChatTypeManagementPage.css'; + +const { Text, Title } = Typography; + +type ChatDefaultContextFormValue = { + id?: string; + title: string; + content: string; + enabled: boolean; +}; + +const EMPTY_FORM_VALUE: ChatDefaultContextFormValue = { + title: '', + content: '', + enabled: true, +}; + +function toFormValue(record: ChatDefaultContextRecord | null): ChatDefaultContextFormValue { + if (!record) { + return EMPTY_FORM_VALUE; + } + + return { + id: record.id, + title: record.title, + content: record.content, + enabled: record.enabled, + }; +} + +export function ChatDefaultContextManagementPage() { + const { hasAccess } = useTokenAccess(); + const { + defaultContexts, + chatTypeDefaults, + roomContexts, + errorMessage: contextSettingsErrorMessage, + setStore, + } = useChatContextSettingsRegistry(); + const [selectedContextId, setSelectedContextId] = useState(defaultContexts[0]?.id ?? null); + const [isMobileViewport, setIsMobileViewport] = useState(false); + const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); + const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); + const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none'); + const [isCreating, setIsCreating] = useState(false); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); + const [form] = Form.useForm(); + + const selectedContext = useMemo( + () => defaultContexts.find((item) => item.id === selectedContextId) ?? null, + [defaultContexts, selectedContextId], + ); + + useEffect(() => { + if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) { + return; + } + + setSelectedContextId(defaultContexts[0]?.id ?? null); + }, [defaultContexts, selectedContextId]); + + useEffect(() => { + if (detailMode !== 'detail') { + return; + } + + form.resetFields(); + form.setFieldsValue(toFormValue(isCreating ? null : selectedContext)); + }, [detailMode, form, isCreating, selectedContext]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const mediaQuery = window.matchMedia('(max-width: 960px)'); + const update = () => { + setIsMobileViewport(mediaQuery.matches); + if (!mediaQuery.matches) { + setMobileView('edit'); + } + }; + + update(); + mediaQuery.addEventListener('change', update); + + return () => { + mediaQuery.removeEventListener('change', update); + }; + }, []); + + const openCreateForm = () => { + setIsCreating(true); + setSelectedContextId(null); + setDetailMode('detail'); + setMaximizedPane('none'); + form.resetFields(); + form.setFieldsValue(EMPTY_FORM_VALUE); + }; + + const openDetail = (contextId: string) => { + setIsCreating(false); + setSelectedContextId(contextId); + setDetailMode('detail'); + setMaximizedPane('none'); + }; + + const closeDetail = () => { + setIsCreating(false); + setDetailMode('list'); + setMaximizedPane('none'); + }; + + const handleDelete = async () => { + if (!selectedContext) { + return; + } + + if (!window.confirm(`"${selectedContext.title}" 기본 유형을 삭제할까요?`)) { + return; + } + + const nextDefaultContexts = deleteChatDefaultContext(defaultContexts, selectedContext.id); + const nextChatTypeDefaults = pruneChatTypeDefaultSelections(chatTypeDefaults, selectedContext.id); + const nextRoomContexts = pruneChatRoomContextSettings(roomContexts, selectedContext.id); + + await setStore({ + defaultContexts: nextDefaultContexts, + chatTypeDefaults: nextChatTypeDefaults, + roomContexts: nextRoomContexts, + }); + setSelectedContextId(nextDefaultContexts[0]?.id ?? null); + setIsCreating(false); + setDetailMode('list'); + form.resetFields(); + form.setFieldsValue(EMPTY_FORM_VALUE); + }; + + if (!hasAccess) { + return ( + + + + ); + } + + return ( +
+ {detailMode === 'list' ? ( + } onClick={openCreateForm}> + 신규 기본 유형 + + } + > +
+ {saveErrorMessage ? : null} + {contextSettingsErrorMessage ? : null} +
+ 등록 기본 유형 + {`${defaultContexts.length}건`} +
+ {defaultContexts.length > 0 ? ( + ( + { + openDetail(item.id); + }} + actions={[ +
+
+ ) : ( + + + + + )} +
+
+
+
+ 입력 +
+ + + +
+
+
+
+ 미리보기 +
+
+ prev.content !== next.content}> + {({ getFieldValue }) => { + const content = String(getFieldValue('content') ?? '').trim(); + + return content ? ( + + ) : ( + + ); + }} + +
+
+
+
+ + + + + + + )} + + ); +} diff --git a/src/app/main/ChatRuntimeBridgeV2.tsx b/src/app/main/ChatRuntimeBridgeV2.tsx index 603dfbe..fe9d0f6 100644 --- a/src/app/main/ChatRuntimeBridgeV2.tsx +++ b/src/app/main/ChatRuntimeBridgeV2.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { useAppStore } from '../../store'; -import { getChatClientSessionId } from './mainChatPanel'; import { chatConnectionGateway, chatGateway } from './chatV2'; import type { ChatMessage, ChatViewContext } from './mainChatPanel/types'; @@ -17,8 +17,26 @@ function isStandaloneDisplayMode() { export function ChatRuntimeBridgeV2() { const { currentPage, focusedComponentId } = useAppStore(); - const [sessionId] = useState(() => getChatClientSessionId()); + const location = useLocation(); const [, setMessages] = useState([]); + const sessionId = useMemo(() => { + if (typeof window === 'undefined') { + return ''; + } + + if (currentPage.topMenu !== 'chat') { + return ''; + } + + const currentUrl = new URL(window.location.href); + const pathname = currentUrl.pathname.replace(/\/+$/, '') || '/'; + + if (pathname !== '/chat/live') { + return ''; + } + + return currentUrl.searchParams.get('sessionId')?.trim() || ''; + }, [currentPage.topMenu, location.pathname, location.search]); const currentContext: ChatViewContext = useMemo( () => ({ diff --git a/src/app/main/ChatTypeManagementPage.css b/src/app/main/ChatTypeManagementPage.css index 8dd5184..481e4aa 100755 --- a/src/app/main/ChatTypeManagementPage.css +++ b/src/app/main/ChatTypeManagementPage.css @@ -7,10 +7,6 @@ overflow: hidden; } -.chat-type-management-page--detail { - container-type: inline-size; -} - .chat-type-management-page .ant-card, .chat-type-management-page .ant-card-body, .chat-type-management-page__card { @@ -61,6 +57,7 @@ flex: 1; min-height: 0; overflow: auto; + padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); } .chat-type-management-page__editor-form { @@ -81,7 +78,11 @@ flex-direction: column; gap: 4px; overflow: auto; - padding: 0 0 8px; + padding: 0 0 calc(10px + env(safe-area-inset-bottom, 0px)); +} + +.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) { + overflow: hidden; } .chat-type-management-page__editor-form .ant-form-item { @@ -128,6 +129,55 @@ margin-bottom: 0; } +.chat-type-management-page__default-context-field { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(248, 250, 252, 0.82); + border: 1px solid #e2e8f0; +} + +.chat-type-management-page__default-context-field--hidden { + display: none; +} + +.chat-type-management-page__default-context-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.chat-type-management-page__default-context-options { + width: 100%; +} + +.chat-type-management-page__default-context-space { + width: 100%; +} + +.chat-type-management-page__default-context-option { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid #e5e7eb; +} + +.chat-type-management-page__default-context-option-copy { + padding-left: 24px; +} + +.chat-type-management-page__default-context-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + .chat-type-management-page__markdown-field { width: 100%; flex: 1; @@ -190,6 +240,7 @@ .chat-type-management-page__markdown-grid--maximized { grid-template-columns: minmax(0, 1fr); + min-height: min(720px, calc(100dvh - 236px)); } .chat-type-management-page__markdown-pane { @@ -236,13 +287,13 @@ .chat-type-management-page__markdown-textarea { height: 100% !important; - min-height: 360px; + min-height: clamp(360px, calc(100dvh - 360px), 720px); resize: none; } .chat-type-management-page__markdown-textarea textarea { height: 100% !important; - min-height: 360px; + min-height: clamp(360px, calc(100dvh - 360px), 720px); overflow: auto !important; resize: none; } @@ -325,12 +376,17 @@ .chat-type-management-page--pane-maximized .chat-type-management-page__editor, .chat-type-management-page--pane-maximized .chat-type-management-page__editor-form, +.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll, .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field, .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor, .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid { flex: 1; } +.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll { + overflow: hidden; +} + .chat-type-management-page--pane-maximized .chat-type-management-page__field-label { display: none; } @@ -414,6 +470,10 @@ align-items: start; } + .chat-type-management-page__default-context-header { + flex-direction: column; + } + .chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content { justify-content: flex-end; } @@ -421,6 +481,8 @@ .chat-type-management-page__markdown-grid { grid-template-columns: minmax(0, 1fr); gap: 8px; + height: 100%; + min-height: 0; overflow: hidden; } @@ -465,6 +527,110 @@ overflow: auto !important; } + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-pane + .ant-form-item, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-preview { + min-height: clamp(220px, calc(100dvh - 560px), 320px); + } + + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-field, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-editor, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-grid, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-pane, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-preview { + height: auto; + overflow: visible; + } + + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-pane + .ant-form-item, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-pane + .ant-form-item-control, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-pane + .ant-form-item-control-input, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-pane + .ant-form-item-control-input-content { + flex: none; + height: auto; + } + + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-textarea, + .chat-type-management-page:not(.chat-type-management-page--pane-maximized) + .chat-type-management-page__markdown-textarea textarea { + height: auto !important; + min-height: clamp(320px, calc(100dvh - 430px), 520px) !important; + } + + .chat-type-management-page--pane-maximized { + height: calc(100dvh - 52px); + max-height: calc(100dvh - 52px); + } + + .chat-type-management-page--pane-maximized .ant-card-head { + min-height: 44px; + } + + .chat-type-management-page--pane-maximized .ant-card-head-title, + .chat-type-management-page--pane-maximized .ant-card-extra { + padding-top: 4px; + padding-bottom: 4px; + } + + .chat-type-management-page--pane-maximized .ant-card-body { + padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px)); + } + + .chat-type-management-page--pane-maximized .chat-type-management-page__card, + .chat-type-management-page--pane-maximized .chat-type-management-page__editor, + .chat-type-management-page--pane-maximized .chat-type-management-page__editor-form, + .chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll, + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field, + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor, + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid, + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane, + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview, + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea, + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea { + min-height: 0; + } + + .chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll { + gap: 0; + padding-bottom: 2px; + } + + .chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar { + display: none; + } + + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid { + height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px)); + min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px)); + max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px)); + } + + .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane { + gap: 4px; + } + + .chat-type-management-page--pane-maximized + .chat-type-management-page__markdown-pane + .ant-form-item-control-input-content { + padding-bottom: 2px; + } + .chat-type-management-page__markdown-preview { padding: 8px 10px; } diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx index 4ac3551..2a6297a 100755 --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -20,6 +20,11 @@ import { type ChatPermissionRole, type ChatTypeRecord, } from './chatTypeAccess'; +import { + resolveChatTypeDefaultContextIds, + upsertChatTypeDefaultContextSelection, + useChatContextSettingsRegistry, +} from './chatContextSettingsAccess'; import { useTokenAccess } from './tokenAccess'; import './ChatTypeManagementPage.css'; @@ -57,6 +62,13 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue { export function ChatTypeManagementPage() { const { hasAccess } = useTokenAccess(); const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry(); + const { + defaultContexts, + chatTypeDefaults, + roomContexts, + errorMessage: contextSettingsErrorMessage, + setStore, + } = useChatContextSettingsRegistry(); const [selectedChatTypeId, setSelectedChatTypeId] = useState(chatTypes[0]?.id ?? null); const [isMobileViewport, setIsMobileViewport] = useState(false); const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); @@ -65,6 +77,7 @@ export function ChatTypeManagementPage() { const [isCreating, setIsCreating] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(''); + const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState([]); const [form] = Form.useForm(); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); const isPaneMaximized = maximizedPane !== 'none'; @@ -89,7 +102,14 @@ export function ChatTypeManagementPage() { form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType)); - }, [detailMode, form, isCreating, selectedChatType]); + setSelectedDefaultContextIds( + isCreating + ? [] + : resolveChatTypeDefaultContextIds(chatTypeDefaults, selectedChatType?.id).filter((contextId) => + defaultContexts.some((context) => context.id === contextId && context.enabled), + ), + ); + }, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType]); useEffect(() => { if (detailMode !== 'detail') { @@ -268,6 +288,7 @@ export function ChatTypeManagementPage() { >
{errorMessage ? : null} + {contextSettingsErrorMessage ? : null} {saveErrorMessage ? : null}
등록 컨텍스트 @@ -279,6 +300,9 @@ export function ChatTypeManagementPage() { dataSource={chatTypes} renderItem={(item) => { const isCurrentUserAllowed = canUseChatType(item, userRoles); + const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id) + .map((contextId) => defaultContexts.find((context) => context.id === contextId)) + .filter((context): context is NonNullable => Boolean(context)); const itemClassName = item.id === selectedChatTypeId ? 'chat-type-management-page__item chat-type-management-page__item--active' @@ -322,6 +346,11 @@ export function ChatTypeManagementPage() { {item.permissions.map((permission) => ( {CHAT_PERMISSION_ROLE_LABELS[permission]} ))} + {linkedDefaultContexts.map((context) => ( + + {context.title} + + ))}
@@ -341,6 +370,7 @@ export function ChatTypeManagementPage() { >
{errorMessage ? : null} + {contextSettingsErrorMessage ? : null} {saveErrorMessage ? : null}
item.id === values.id || item.name === values.name); + const nextChatTypeDefaults = savedChatType + ? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds) + : chatTypeDefaults; + await setStore({ + defaultContexts, + chatTypeDefaults: nextChatTypeDefaults, + roomContexts, + }); setIsCreating(false); setSelectedChatTypeId(savedChatType?.id ?? null); setDetailMode('detail'); @@ -400,6 +438,60 @@ export function ChatTypeManagementPage() {
+
+
+ 기본 유형 연결 + 여러 개를 선택하면 채팅 요청마다 함께 참조됩니다. +
+ {defaultContexts.filter((context) => context.enabled).length > 0 ? ( + <> + { + setSelectedDefaultContextIds( + checkedValues + .map((value) => String(value).trim()) + .filter((value) => defaultContexts.some((context) => context.id === value && context.enabled)), + ); + }} + > + + {defaultContexts + .filter((context) => context.enabled) + .map((context) => ( + + ))} + + + {selectedDefaultContextIds.length > 0 ? ( +
+ {selectedDefaultContextIds.map((contextId) => { + const context = defaultContexts.find((item) => item.id === contextId); + + return context ? ( + + {context.title} + + ) : null; + })} +
+ ) : null} + + ) : ( + + )} +
기본 문맥 설명 diff --git a/src/app/main/MainChatPanel.css b/src/app/main/MainChatPanel.css index 5dff55f..24920cf 100755 --- a/src/app/main/MainChatPanel.css +++ b/src/app/main/MainChatPanel.css @@ -3,8 +3,8 @@ display: flex; flex-direction: column; overflow: hidden; - height: calc(100dvh - 128px); - max-height: calc(100dvh - 128px); + height: 100%; + max-height: 100%; background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 30%), radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.12), transparent 34%), @@ -186,6 +186,147 @@ border-radius: 16px; } +.app-chat-panel__action-group { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(148, 163, 184, 0.22); + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08); +} + +.app-chat-panel__action-group .ant-btn { + border-radius: 999px; +} + +.app-chat-panel__action-group--mobile { + justify-content: flex-end; + min-width: 0; + margin-left: auto; +} + +.app-chat-panel__mobile-actions { + display: inline-flex; + align-items: center; +} + +.app-chat-panel__context-drawer { + display: flex; + flex: 1; + flex-direction: column; + gap: 16px; + height: 100%; + min-height: 0; +} + +.app-chat-panel__context-drawer-tabs { + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.app-chat-panel__context-drawer-tabs .ant-tabs-nav { + flex: 0 0 auto; +} + +.app-chat-panel__context-drawer-tabs .ant-tabs-content-holder, +.app-chat-panel__context-drawer-tabs .ant-tabs-content { + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-active { + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-hidden { + display: none; +} + +.app-chat-panel__context-drawer-section { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 10px; + padding: 14px; + border-radius: 18px; + background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.app-chat-panel__context-drawer-section--editor { + flex: 1; + height: 100%; + min-height: 0; +} + +.app-chat-panel__context-drawer-section-head { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 4px; +} + +.app-chat-panel__context-drawer-space, +.app-chat-panel__context-drawer-radio, +.app-chat-panel__context-drawer-checkbox { + width: 100%; +} + +.app-chat-panel__context-drawer-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(226, 232, 240, 0.96); +} + +.app-chat-panel__context-drawer-card--readonly { + gap: 6px; +} + +.app-chat-panel__context-drawer-card-copy { + padding-left: 24px; +} + +.app-chat-panel__context-drawer-card-title { + font-weight: 600; +} + +.app-chat-panel__context-drawer-textarea-shell { + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + min-height: 220px; +} + +.app-chat-panel__context-drawer-textarea { + height: 100%; + flex: 1 1 auto; + min-height: 220px; + resize: none; +} + +.app-chat-panel__context-drawer-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .app-chat-panel__error-layout { position: relative; display: flex; @@ -661,15 +802,15 @@ @media (max-width: 1080px) { .app-chat-panel { position: static; - height: calc(100dvh - 112px); - max-height: calc(100dvh - 112px); + height: 100%; + max-height: 100%; } } @media (max-width: 768px) { .app-chat-panel { - height: calc(100dvh - 76px); - max-height: calc(100dvh - 76px); + height: 100%; + max-height: 100%; border-radius: 0; } @@ -749,4 +890,44 @@ align-items: stretch; flex-direction: column; } + + .app-chat-panel__action-group { + gap: 4px; + padding: 3px; + } + + .app-chat-panel__action-group--mobile { + gap: 6px; + padding: 3px; + } + + .app-chat-panel__context-drawer { + flex: 1; + gap: 12px; + } + + .app-chat-panel__context-drawer-tabs .ant-tabs-nav { + margin-bottom: 12px; + } + + .app-chat-panel__context-drawer-tabs .ant-tabs-tab { + padding: 8px 0; + } + + .app-chat-panel__context-drawer-section { + padding: 12px; + border-radius: 16px; + } + + .app-chat-panel__context-drawer-section--editor { + flex: 1; + } + + .app-chat-panel__context-drawer-textarea-shell { + min-height: 0; + } + + .app-chat-panel__context-drawer-textarea { + min-height: 320px; + } } diff --git a/src/app/main/MainChatPanel.hotfix.css b/src/app/main/MainChatPanel.hotfix.css index 55b3ddd..f6befbe 100644 --- a/src/app/main/MainChatPanel.hotfix.css +++ b/src/app/main/MainChatPanel.hotfix.css @@ -39,7 +39,7 @@ .app-chat-panel--tablet-app .app-chat-panel__conversation-empty, .app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list { width: 100%; - min-width: 100%; + min-width: 0; max-width: 100%; box-sizing: border-box; } @@ -245,6 +245,12 @@ gap: 6px; } +.app-chat-panel__conversation-section-items { + display: flex; + flex-direction: column; + gap: 6px; +} + .app-chat-panel__conversation-section-header { display: flex; align-items: center; @@ -253,6 +259,25 @@ padding: 2px 2px 0; } +.app-chat-panel__conversation-section--reorderable { + position: relative; + transition: transform 0.18s ease; +} + +.app-chat-panel__conversation-section-header--reorderable, +.app-chat-panel__conversation-section-toggle--reorderable { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +.app-chat-panel__conversation-section-header-main { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + .app-chat-panel__conversation-section-header--muted { margin-top: 6px; } @@ -316,6 +341,120 @@ color: #475569; } +.app-chat-panel__conversation-section-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + padding: 10px 12px; + border: 0; + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12); + color: #0f172a; + text-align: left; +} + +.app-chat-panel__conversation-section-mobile-header { + display: flex; + align-items: center; + gap: 8px; +} + +.app-chat-panel__conversation-section-mobile-header .app-chat-panel__conversation-section-toggle { + flex: 1 1 auto; + min-width: 0; +} + +.app-chat-panel__conversation-section-toggle-main { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.app-chat-panel__conversation-section-toggle-actions, +.app-chat-panel__conversation-section-move-controls { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.app-chat-panel__conversation-section-move-controls { + flex-shrink: 0; +} + +.app-chat-panel__conversation-section-move-activator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 999px; + background: rgba(226, 232, 240, 0.9); + color: #475569; +} + +.app-chat-panel__conversation-section-move-activator.is-active { + background: rgba(191, 219, 254, 0.95); + color: #1d4ed8; +} + +.app-chat-panel__conversation-section-move-button { + min-width: 0; + padding: 5px 8px; + border: 0; + border-radius: 999px; + background: rgba(226, 232, 240, 0.9); + color: #334155; + font-size: 11px; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + +.app-chat-panel__conversation-section-move-button:disabled { + background: rgba(226, 232, 240, 0.52); + color: rgba(100, 116, 139, 0.72); +} + +.app-chat-panel__conversation-section-move-button:not(:disabled):active { + background: rgba(191, 219, 254, 0.95); + color: #1d4ed8; +} + +.app-chat-panel__conversation-section-toggle-icon, +.app-chat-panel__conversation-section-toggle-caret { + display: inline-flex; + align-items: center; + justify-content: center; + color: #64748b; +} + +.app-chat-panel__conversation-section-toggle.is-open { + box-shadow: + inset 0 0 0 1px rgba(59, 130, 246, 0.18), + 0 10px 24px rgba(59, 130, 246, 0.08); +} + +.app-chat-panel__conversation-section-toggle--processing .app-chat-panel__conversation-section-toggle-icon, +.app-chat-panel__conversation-section-toggle--processing .app-chat-panel__conversation-section-title { + color: #b45309; +} + +.app-chat-panel__conversation-section-toggle--failed .app-chat-panel__conversation-section-toggle-icon, +.app-chat-panel__conversation-section-toggle--failed .app-chat-panel__conversation-section-title { + color: #b91c1c; +} + +.app-chat-panel__conversation-section-toggle--unread .app-chat-panel__conversation-section-toggle-icon, +.app-chat-panel__conversation-section-toggle--unread .app-chat-panel__conversation-section-title { + color: #1d4ed8; +} + .app-chat-panel__conversation-item { position: relative; display: flex; @@ -649,16 +788,52 @@ box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.18); } -.app-chat-panel__conversation-item-delete.ant-btn { +.app-chat-panel__conversation-item-flag--section { + color: #475569; + background: rgba(226, 232, 240, 0.76); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14); +} + +.app-chat-panel__conversation-item-flag--request { + color: #0f766e; + background: rgba(204, 251, 241, 0.96); + box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.16); +} + +.app-chat-panel__conversation-item-actions { + display: flex; + flex-direction: column; align-self: stretch; + justify-content: center; + gap: 2px; + padding: 4px 4px 4px 0; +} + +.app-chat-panel__conversation-item-folder.ant-btn, +.app-chat-panel__conversation-item-delete.ant-btn { flex-shrink: 0; width: 28px; min-width: 28px; - height: auto; - margin-right: 4px; + height: 28px; color: #94a3b8; } +.app-chat-panel__conversation-item-delete.ant-btn { + margin-right: 0; +} + +.app-chat-panel__general-section-modal { + display: flex; + flex-direction: column; + gap: 12px; +} + +.app-chat-panel__general-section-presets { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .app-chat-panel__conversation-main { display: flex; flex: 1 1 0%; @@ -948,6 +1123,46 @@ text-align: left; } +.app-chat-panel__resource-chip-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} + +.app-chat-panel__resource-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: 0 0 22px; + border-radius: 8px; + background: rgba(226, 232, 240, 0.9); + color: #1e293b; + font-size: 12px; +} + +.app-chat-panel__resource-chip-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-chat-panel__resource-chip-meta { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.08); + color: #334155; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .app-chat-panel__title-input { width: min(240px, 48vw); } @@ -1246,6 +1461,32 @@ } } +@media (min-width: 820px) and (max-width: 1366px) { + .app-chat-panel--ipad-readable .app-chat-message__body, + .app-chat-panel--ipad-readable .app-chat-message__body.ant-typography, + .app-chat-panel--ipad-readable .app-chat-message__block, + .app-chat-panel--ipad-readable .app-chat-message__block .ant-typography, + .app-chat-panel--ipad-readable .app-chat-message__block span, + .app-chat-panel--ipad-readable .app-chat-message__block a { + font-size: 22px !important; + line-height: 1.6; + } +} + +@media (min-width: 768px) and (pointer: fine) { + .app-chat-panel .app-chat-message__body, + .app-chat-panel .app-chat-message__body.ant-typography { + font-size: 19px !important; + } +} + +@media (min-width: 768px) and (max-width: 1366px) and (pointer: fine) { + .app-chat-panel .app-chat-message__body, + .app-chat-panel .app-chat-message__body.ant-typography { + font-size: 22px !important; + } +} + .app-chat-panel__conversation-header { display: flex; align-items: center; @@ -1634,11 +1875,15 @@ gap: 8px; width: 100%; max-width: none; - padding: 8px 0 10px; - border: 1px solid rgba(148, 163, 184, 0.22); + padding: 8px 1px 12px; + border: 0; + border-radius: 16px; background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); - overflow: hidden; + box-shadow: + inset 0 0 0 1px rgba(148, 163, 184, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.65); + box-sizing: border-box; + overflow: clip; height: auto; } @@ -1682,9 +1927,12 @@ } .app-chat-preview-card--collapsed .app-chat-preview-card__header { - border: 1px solid rgba(148, 163, 184, 0.22); + border: 0; + border-radius: 16px; background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); + box-shadow: + inset 0 0 0 1px rgba(148, 163, 184, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.65); } .app-chat-preview-card__meta { @@ -1884,8 +2132,9 @@ display: flex; min-height: 0; border-top: 1px solid rgba(148, 163, 184, 0.18); - padding-top: 8px; + padding: 8px 0 1px; width: 100%; + box-sizing: border-box; } .app-chat-preview-card--fullscreen { @@ -1969,6 +2218,8 @@ .app-chat-panel__preview-rich { width: 100%; min-width: 0; + padding-bottom: 1px; + box-sizing: border-box; } .app-chat-panel__preview-rich .previewer-ui__editor, @@ -2030,6 +2281,66 @@ min-width: 0; } +.app-chat-panel__preview-table { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; +} + +.app-chat-panel__preview-table-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; +} + +.app-chat-panel__preview-table-scroll { + overflow: auto; + max-height: min(420px, 70vh); + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 18px; + background: rgba(255, 255, 255, 0.92); +} + +.app-chat-panel__preview-table-grid { + width: 100%; + min-width: max-content; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; + line-height: 1.5; +} + +.app-chat-panel__preview-table-grid th, +.app-chat-panel__preview-table-grid td { + padding: 10px 12px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(226, 232, 240, 0.92); + white-space: pre-wrap; + word-break: break-word; +} + +.app-chat-panel__preview-table-grid th { + position: sticky; + top: 0; + z-index: 1; + background: #eff6ff; + color: #1e3a8a; + font-weight: 700; +} + +.app-chat-panel__preview-table-grid tbody tr:nth-child(even) td { + background: rgba(248, 250, 252, 0.82); +} + +.app-chat-panel__preview-table-grid tbody tr:last-child td { + border-bottom: 0; +} + .app-chat-message__preview-image, .app-chat-message__preview-video, .app-chat-message__preview-frame { @@ -2923,6 +3234,7 @@ .app-chat-panel__conversation-list, .app-chat-panel__conversation-main { height: 100%; + min-width: 0; overflow: hidden; } @@ -2949,6 +3261,17 @@ -webkit-overflow-scrolling: touch; } + .app-chat-panel input, + .app-chat-panel textarea, + .app-chat-panel .ant-input, + .app-chat-panel .ant-input-affix-wrapper input, + .app-chat-panel .ant-select-selection-item, + .app-chat-panel .ant-select-selection-placeholder, + .app-chat-panel .ant-select-selector, + .app-chat-panel .ant-input-textarea textarea.ant-input { + font-size: 16px !important; + } + .app-chat-panel__messages, .app-chat-panel__composer, .app-chat-panel__resource-strip { @@ -2977,15 +3300,21 @@ .app-chat-panel__messages, .app-chat-panel__preview-stage, .app-chat-panel__resource-strip { + width: 100%; + min-width: 0; padding-left: 12px; padding-right: 12px; + box-sizing: border-box; } .app-chat-panel__composer { + width: 100%; + min-width: 0; padding-left: 10px; padding-right: 10px; padding-top: 4px; padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px)); + box-sizing: border-box; } .app-chat-panel__composer textarea.ant-input { @@ -2993,6 +3322,7 @@ min-height: clamp(104px, 16dvh, 136px); padding-top: 8px; padding-bottom: 8px; + line-height: 1.5; } .app-chat-panel__composer-input-shell { diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 22f426f..78facfe 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -2,12 +2,18 @@ import { AppstoreOutlined, BellFilled, BellOutlined, + CaretDownOutlined, + CaretRightOutlined, CloseOutlined, + ClockCircleOutlined, + ControlOutlined, CopyOutlined, DownloadOutlined, EditOutlined, EyeOutlined, ExclamationCircleOutlined, + FolderOpenOutlined, + FolderOutlined, FullscreenExitOutlined, FullscreenOutlined, MessageOutlined, @@ -16,8 +22,9 @@ import { ReloadOutlined, SearchOutlined, DeleteOutlined, + WarningOutlined, } from '@ant-design/icons'; -import { Alert, Button, Card, Empty, Input, Modal, Radio, Space, Tag, Typography, message } from 'antd'; +import { Alert, Button, Card, Checkbox, Drawer, Empty, Input, Modal, Radio, Space, Tabs, Tag, Tooltip, Typography, message } from 'antd'; import type { InputRef } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import { @@ -42,10 +49,22 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController'; import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody'; import { triggerResourceDownload } from './mainChatPanel/downloadUtils'; -import { shouldSkipForegroundResyncAfterExternalLink } from './mainChatPanel/linkNavigation'; import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems'; import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; -import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; +import { + canUseChatType, + resolveCurrentChatPermissionRoles, + upsertChatType, + useChatTypeRegistry, + type ChatTypeRecord, +} from './chatTypeAccess'; +import { + resolveChatRoomContextSettings, + resolveChatTypeDefaultContextIds, + upsertChatRoomContextSettings, + useChatContextSettingsRegistry, + type ChatDefaultContextRecord, +} from './chatContextSettingsAccess'; import { renderModalWithEnterConfirm } from './modalKeyboard'; import { createNotificationMessage } from './notificationApi'; import { useTokenAccess } from './tokenAccess'; @@ -58,7 +77,10 @@ import { createChatMessage, createLocalMessage, ErrorLogViewer, + getStoredChatSessionLastTypeId, + isMissingRequestMessage, isPreparingChatReplyText, + setStoredChatSessionLastTypeId, sortChatConversationSummaries, upsertChatMessage, useErrorLogs, @@ -81,8 +103,9 @@ import './MainChatPanel.css'; import './MainChatPanel.hotfix.css'; const { Text } = Typography; -const ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS = 5000; - +const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 2000; +const CHAT_GENERAL_SECTION_STORAGE_KEY = 'codex-live-general-section-registry'; +const CHAT_GENERAL_SECTION_ORDER_STORAGE_KEY = 'codex-live-general-section-order'; type ChatTypeOption = { value: string; label: string; @@ -101,6 +124,7 @@ type PendingChatRequest = { requestId: string; text: string; mode: 'queue' | 'direct'; + omitPromptHistory?: boolean; chatTypeId: string; chatTypeLabel: string; chatTypeDescription: string; @@ -124,13 +148,9 @@ type ImportedCodexDraftRequest = { sendMode: 'queue' | 'direct'; }; -type PendingFreshConversationSendRequest = { - targetSessionId: string; - text: string; - sendMode: 'queue' | 'direct'; - chatTypeId: string; - chatTypeLabel: string; - chatTypeDescription: string; +type CreateConversationOptions = { + chatTypeOverride?: CreateConversationTarget | null; + persist?: boolean; }; const CHAT_MAX_RETRY_ATTEMPTS = 5; @@ -165,6 +185,27 @@ function isStandaloneDisplayMode() { ); } +function isIpadLikeChatViewport() { + if (typeof window === 'undefined') { + return false; + } + + const width = window.innerWidth; + const height = window.innerHeight; + const shortestSide = Math.min(width, height); + const longestSide = Math.max(width, height); + const hasTouchPoints = navigator.maxTouchPoints > 0; + const isAppleTabletUserAgent = + /iPad/i.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && hasTouchPoints); + + if (!hasTouchPoints) { + return false; + } + + return isAppleTabletUserAgent && shortestSide >= 820 && longestSide <= 1366; +} + function isRestartRequiredResponseText(text: string) { const normalized = String(text ?? '') .replace(/\s+/g, ' ') @@ -323,6 +364,82 @@ function createConversationPreviewText(text: string) { return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } +function buildOptimisticConversationSummary(args: { + sessionId: string; + title: string; + chatType: CreateConversationTarget | null; + contextDescription: string | null; + createdAt: string; + isDraftOnly?: boolean; +}): ChatConversationSummary { + return { + sessionId: args.sessionId, + clientId: null, + isDraftOnly: args.isDraftOnly === true, + title: args.title, + chatTypeId: args.chatType?.id ?? null, + lastChatTypeId: args.chatType?.id ?? null, + generalSectionName: null, + contextLabel: args.chatType?.name ?? null, + contextDescription: args.contextDescription, + notifyOffline: true, + hasUnreadResponse: false, + currentRequestId: null, + currentJobStatus: null, + currentJobMessage: null, + currentQueueSize: 0, + currentStatusUpdatedAt: null, + lastRequestPreview: '', + lastMessagePreview: '', + lastResponsePreview: '', + createdAt: args.createdAt, + updatedAt: args.createdAt, + lastMessageAt: null, + }; +} + +function normalizeConversationContextDescription(value: string | null | undefined) { + const normalized = value?.trim() ?? ''; + + if (!normalized) { + return null; + } + + return normalized.slice(0, CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH); +} + +function composeConversationContextDescription( + baseDescription: string | null | undefined, + defaultContexts: ChatDefaultContextRecord[], + customContextTitle?: string | null, + customContextContent?: string | null, +) { + const sections = [baseDescription?.trim() ?? ''].filter(Boolean); + + for (const context of defaultContexts) { + const normalizedContent = context.content.trim(); + + if (!normalizedContent) { + continue; + } + + sections.push(`## 기본 유형 · ${context.title}\n${normalizedContent}`); + } + + const normalizedCustomTitle = customContextTitle?.trim() ?? ''; + const normalizedCustomContent = customContextContent?.trim() ?? ''; + + if (normalizedCustomTitle || normalizedCustomContent) { + sections.push( + [`## 채팅방 전용 Context${normalizedCustomTitle ? ` · ${normalizedCustomTitle}` : ''}`, normalizedCustomContent] + .filter(Boolean) + .join('\n'), + ); + } + + return normalizeConversationContextDescription(sections.join('\n\n')); +} + type PreviewTextMatch = { node: Text; start: number; @@ -594,6 +711,347 @@ function resolveConversationListPreviewText(preview: string) { return normalized; } +function trimConversationRequestBadgeLabel(label: string, maxLength = 18) { + const normalized = label.replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return ''; + } + + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; +} + +function compactConversationBadgeLabel(label: string | null | undefined, maxWords = 2) { + const normalized = (label ?? '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return null; + } + + const words = normalized.split(' ').filter(Boolean); + return trimConversationRequestBadgeLabel(words.slice(0, maxWords).join(' ')); +} + +function isConversationBadgeMetaRequest(text: string) { + const normalized = text.replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return false; + } + + return ( + /((최근|촤근)?\s*작업|최근작업|촤근작업).*(뱃지|badge|라벨)/iu.test(normalized) || + /(요청내역|이전\s*요청|마지막\s*요청|두\s*단어|두단어|문맥을\s*읽어서|문맥\s*읽어서)/u.test(normalized) + ); +} + +function normalizeConversationBadgeToken(token: string) { + return token + .replace(/^[^0-9A-Za-z가-힣/_-]+|[^0-9A-Za-z가-힣/_-]+$/gu, '') + .replace(/[()[\]{}"'`~!@#$%^&*+=|\\:;,.<>?…]+/g, '') + .trim(); +} + +function canonicalizeConversationBadgeToken(token: string) { + const normalized = normalizeConversationBadgeToken(token) + .replace(/^(최근작업|촤근작업)$/iu, '작업') + .replace(/^(대화방|대화)$/u, '채팅') + .replace(/^(badge|라벨)$/iu, '뱃지') + .replace(/^(웹소켓|ws\/chat)$/iu, '소켓') + .replace(/^(업데이투|업뎃|업데이트)$/iu, '업데이트'); + + if (/(codex|cdex)?\s*cli/iu.test(normalized)) { + return 'CLI'; + } + + if (/^release\/test$/iu.test(normalized)) { + return '설정'; + } + + if (/^(이전세션|새세션|세션들)$/u.test(normalized)) { + return '세션'; + } + + return normalized + .replace(/(입니다|이에요|예요|합니다|해요|해주세요|해주세여|됐나요|되나요|맞나요|인가요|인가|입니다만|입니다요)$/u, '') + .replace(/(하기|하기를|하기랑|하기와|하거나|하면서|되어서|되게|되도록)$/u, '') + .replace(/(으로|로|과|와|을|를|은|는|이|가|도|만|랑|이라도|이라던지|라던지|에서|에게|께|마다|부터|까지)$/u, '') + .trim(); +} + +const CONVERSATION_BADGE_ACTION_RULES = [ + { label: '재기동', pattern: /(재기동|재시작|재부팅)/iu }, + { label: '업데이트', pattern: /(업데이트|update|업데이투|업뎃)/iu }, + { label: '분리', pattern: /(분리|구분)/iu }, + { label: '수정', pattern: /(수정|고쳐|고치|개선|반영|복구|정리|보완|조정|변경|맞춰|맞추|손봐|손보)/iu }, + { label: '추가', pattern: /(추가|노출|표시|표현|붙여|붙이|삽입|넣어)/iu }, + { label: '생성', pattern: /(생성|만들어|만들기|신규)/iu }, + { label: '시작', pattern: /(시작|개시)/iu }, + { label: '실행', pattern: /(실행|호출|등록|설치|빌드)/iu }, + { label: '확인', pattern: /(확인|점검|검증|테스트|조회|체크)/iu }, + { label: '문의', pattern: /(문의|질문|왜|어떻게|맞나요|되나요|안보이나요|이상한데|\?)/iu }, +] as const; + +function isConversationBadgeStopToken(token: string) { + return /^(최근|촤근|작업|요청|요청내역|이전요청|마지막요청|문맥|읽어서|문의|답변|참조|이유|왜|안보이나요|안보임|일반|처리|대화방|대화|현재|실제|통해서|맞나요|이상한데|개선하세여|수정하는지|생성하는지|실행인지|항목을|어떤|아떤|조합|두개|두단어|단어조합|요청문|업데이투|이후|다시|원인|방식|맞는지)$/iu.test( + token, + ); +} + +function collectConversationBadgeMeaningfulTokens(text: string) { + return text + .replace(/[()[\]{}"'`~!@#$%^&*+=|\\:;,.<>?]+/g, ' ') + .split(/\s+/) + .map((token) => canonicalizeConversationBadgeToken(token)) + .filter((token) => token.length > 1 && !isConversationBadgeStopToken(token)); +} + +function findConversationBadgeActionLabel(...texts: Array) { + const hasQuestionLikeText = texts.some((text) => /(문의|질문|왜|어떻게|맞나요|되나요|안보이나요|이상한데|\?)/iu.test(text ?? '')); + + const strongActionLabels = new Set(['재기동', '업데이트', '분리', '수정', '추가', '생성', '시작', '실행']); + + for (const text of texts) { + const normalized = text?.trim(); + if (!normalized) { + continue; + } + + for (const rule of CONVERSATION_BADGE_ACTION_RULES) { + if (strongActionLabels.has(rule.label) && rule.pattern.test(normalized)) { + return rule.label; + } + } + } + + if (hasQuestionLikeText) { + return '문의'; + } + + for (const text of texts) { + const normalized = text?.trim(); + if (!normalized) { + continue; + } + + for (const rule of CONVERSATION_BADGE_ACTION_RULES) { + if (!strongActionLabels.has(rule.label) && rule.label !== '문의' && rule.pattern.test(normalized)) { + return rule.label; + } + } + } + + return null; +} + +function normalizeConversationBadgeTargetToken(token: string) { + if (/^(뱃지|채팅|목록|세션|cbt|소켓|설정|요일|기간|토큰|프록시|캡처|화면|버튼|미리보기|preview|연결|cli)$/iu.test(token)) { + return token.toUpperCase() === 'CLI' ? 'CLI' : token; + } + + if (/^release\/test$/iu.test(token)) { + return '설정'; + } + + if (/^(이어서|계속|새로|최근작업|요청문|단어조합|명령|응답|내용|항목|항목문의)$/u.test(token)) { + return ''; + } + + return token; +} + +function findConversationBadgeTargetLabel(...texts: Array) { + const weightedTokens = new Map(); + let tokenIndex = 0; + + texts.forEach((text, sourceIndex) => { + const weight = Math.max(1, texts.length - sourceIndex); + const tokens = collectConversationBadgeMeaningfulTokens(text ?? '') + .map((token) => normalizeConversationBadgeTargetToken(token)) + .filter((token) => { + if (!token) { + return false; + } + + return !CONVERSATION_BADGE_ACTION_RULES.some((rule) => rule.label === token); + }); + + tokens.forEach((token) => { + const current = weightedTokens.get(token); + if (current) { + current.score += weight; + return; + } + + weightedTokens.set(token, { score: weight, firstIndex: tokenIndex }); + tokenIndex += 1; + }); + }); + + const rankedTokens = Array.from(weightedTokens.entries()) + .sort((left, right) => { + const scoreDiff = right[1].score - left[1].score; + if (scoreDiff !== 0) { + return scoreDiff; + } + + return left[1].firstIndex - right[1].firstIndex; + }) + .map(([token]) => token); + + const strongPairRules = [ + { pattern: /(최근\s*작업|최근작업|촤근작업).*(뱃지|badge|라벨)/iu, label: '뱃지' }, + { pattern: /(뱃지|badge|라벨)/iu, label: '뱃지' }, + { pattern: /(채팅|대화).*(목록|리스트)/iu, label: '채팅 목록' }, + { pattern: /(최근\s*5개방|최근\s*대화방\s*5개)/u, label: '5개방' }, + { pattern: /((codex|cdex)\s*cli|cli)/iu, label: 'CLI' }, + { pattern: /(소켓|웹소켓|ws\/chat)/iu, label: '소켓' }, + { pattern: /(세션|이전세션|새세션)/u, label: '세션' }, + { pattern: /(cbt)/iu, label: 'CBT' }, + { pattern: /(release\/test|설정)/iu, label: '설정' }, + ] as const; + + const joinedText = texts + .map((text) => text?.trim()) + .filter(Boolean) + .join(' '); + + for (const rule of strongPairRules) { + if (rule.pattern.test(joinedText)) { + return trimConversationRequestBadgeLabel(rule.label); + } + } + + if (rankedTokens.length >= 2) { + return trimConversationRequestBadgeLabel(rankedTokens.slice(0, 2).join(' ')); + } + + return rankedTokens[0] ? trimConversationRequestBadgeLabel(rankedTokens[0]) : null; +} + +function buildConversationBadgeSourceTexts( + item: Pick< + ChatConversationSummary, + 'title' | 'contextLabel' | 'contextDescription' | 'lastMessagePreview' | 'lastRequestPreview' | 'lastResponsePreview' + >, + runtimeSummary?: string | null, +) { + const requestText = item.lastRequestPreview.trim(); + const responseText = item.lastResponsePreview.trim(); + const messageText = item.lastMessagePreview.trim(); + const contextLabel = item.contextLabel?.trim() || ''; + const contextDescription = item.contextDescription?.trim() || ''; + const titleText = item.title?.trim() || ''; + const runtimeText = runtimeSummary?.trim() || ''; + const isMetaRequest = isConversationBadgeMetaRequest(requestText); + + return isMetaRequest + ? [runtimeText, responseText, messageText, contextDescription, titleText, contextLabel] + : [runtimeText, responseText, requestText, messageText, contextDescription, titleText, contextLabel]; +} + +function inferConversationRequestBadgeLabel( + item: Pick< + ChatConversationSummary, + 'title' | 'contextLabel' | 'contextDescription' | 'lastMessagePreview' | 'lastRequestPreview' | 'lastResponsePreview' + >, + runtimeSummary?: string | null, +) { + const labelSourceTexts = buildConversationBadgeSourceTexts(item, runtimeSummary); + const requestText = item.lastRequestPreview.trim(); + const responseText = item.lastResponsePreview.trim(); + const messageText = item.lastMessagePreview.trim(); + const contextLabel = item.contextLabel?.trim() || ''; + const contextDescription = item.contextDescription?.trim() || ''; + const titleText = item.title?.trim() || ''; + const actionLabel = findConversationBadgeActionLabel(...labelSourceTexts); + const targetLabel = findConversationBadgeTargetLabel(...labelSourceTexts); + + if (targetLabel && actionLabel) { + const targetWords = targetLabel.split(/\s+/).filter(Boolean); + if (targetWords.includes(actionLabel)) { + return trimConversationRequestBadgeLabel(targetLabel); + } + + const primaryTarget = targetWords[targetWords.length - 1] || targetLabel; + return trimConversationRequestBadgeLabel(`${primaryTarget} ${actionLabel}`); + } + + if (targetLabel) { + return trimConversationRequestBadgeLabel(targetLabel); + } + + if (actionLabel) { + const fallbackLabel = compactConversationBadgeLabel( + contextDescription || titleText || contextLabel || requestText || responseText || messageText, + 1, + ); + return fallbackLabel ? trimConversationRequestBadgeLabel(`${fallbackLabel} ${actionLabel}`) : actionLabel; + } + + return compactConversationBadgeLabel( + contextDescription || titleText || contextLabel || requestText || responseText || messageText, + 2, + ); +} + +function buildConversationRequestBadgeLabel( + item: Pick< + ChatConversationSummary, + | 'sessionId' + | 'title' + | 'currentJobStatus' + | 'contextLabel' + | 'contextDescription' + | 'lastMessagePreview' + | 'lastRequestPreview' + | 'lastResponsePreview' + >, + runtimeSnapshot: ChatRuntimeSnapshot | null, +) { + if (!runtimeSnapshot) { + return inferConversationRequestBadgeLabel(item); + } + + const latestRuntimeItem = [...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent] + .filter((runtimeItem) => runtimeItem.sessionId === item.sessionId) + .sort((left, right) => { + const leftTime = toRuntimeStatusTime( + left.status === 'running' + ? left.startedAt ?? left.enqueuedAt + : left.status === 'queued' + ? left.enqueuedAt + : left.lastUpdatedAt, + ); + const rightTime = toRuntimeStatusTime( + right.status === 'running' + ? right.startedAt ?? right.enqueuedAt + : right.status === 'queued' + ? right.enqueuedAt + : right.lastUpdatedAt, + ); + + return rightTime - leftTime; + })[0]; + + const runtimeSummary = latestRuntimeItem?.summary?.trim() || ''; + const inferredLabel = inferConversationRequestBadgeLabel(item, runtimeSummary); + + if (item.currentJobStatus === 'queued' || item.currentJobStatus === 'started') { + return inferredLabel || '작업중'; + } + + if (inferredLabel) { + return inferredLabel; + } + + return runtimeSummary ? compactConversationBadgeLabel(runtimeSummary, 3) : null; +} + function compareConversationItemsByLatestChat(left: ChatConversationSummary, right: ChatConversationSummary) { const timeDiff = getConversationLatestActivityTime(right) - getConversationLatestActivityTime(left); @@ -604,19 +1062,36 @@ function compareConversationItemsByLatestChat(left: ChatConversationSummary, rig return left.sessionId.localeCompare(right.sessionId); } -function getConversationLatestActivityTime(item: ChatConversationSummary) { - const timestamps = [item.lastMessageAt, item.updatedAt, item.createdAt]; - let latestTime = 0; - - timestamps.forEach((timestamp) => { - const parsedTime = timestamp ? new Date(timestamp).getTime() : 0; - - if (Number.isFinite(parsedTime) && parsedTime > latestTime) { - latestTime = parsedTime; +function mergeConversationItemsPreservingTransientState( + nextItems: ChatConversationSummary[], + previousItems: ChatConversationSummary[], +) { + const nextSessionIds = new Set(nextItems.map((item) => item.sessionId)); + const preservedItems = previousItems.filter((item) => { + if (!item.sessionId || nextSessionIds.has(item.sessionId)) { + return false; } + + return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started'; }); - return latestTime; + return sortChatConversationSummaries([...preservedItems, ...nextItems]); +} + +function getConversationLatestActivityTime(item: ChatConversationSummary) { + const lastMessageTime = item.lastMessageAt ? new Date(item.lastMessageAt).getTime() : 0; + + if (Number.isFinite(lastMessageTime) && lastMessageTime > 0) { + return lastMessageTime; + } + + const createdAtTime = item.createdAt ? new Date(item.createdAt).getTime() : 0; + if (Number.isFinite(createdAtTime) && createdAtTime > 0) { + return createdAtTime; + } + + const updatedAtTime = item.updatedAt ? new Date(item.updatedAt).getTime() : 0; + return Number.isFinite(updatedAtTime) ? updatedAtTime : 0; } function getLatestConversationPreviewMessage(messages: ChatMessage[]) { @@ -721,6 +1196,45 @@ function buildConversationTitleFromRequestText(requestText: string, fallbackTitl return createConversationPreviewText(requestText) || fallbackTitle || '새 대화'; } +function mergeConversationRequestPreservingContent( + previousItem: ChatConversationRequest | null | undefined, + nextItem: ChatConversationRequest, +) { + if (!previousItem) { + return nextItem; + } + + const nextUserText = nextItem.userText.trim() || previousItem.userText.trim(); + const nextResponseText = nextItem.responseText.trim() || previousItem.responseText.trim(); + const nextStatusMessage = nextItem.statusMessage?.trim() || previousItem.statusMessage?.trim() || null; + + return { + ...nextItem, + statusMessage: nextStatusMessage, + userMessageId: nextItem.userMessageId ?? previousItem.userMessageId, + userText: nextUserText, + responseMessageId: nextItem.responseMessageId ?? previousItem.responseMessageId, + responseText: nextResponseText, + hasResponse: nextItem.hasResponse || previousItem.hasResponse || nextResponseText.length > 0, + answeredAt: nextItem.answeredAt ?? previousItem.answeredAt, + terminalAt: nextItem.terminalAt ?? previousItem.terminalAt, + }; +} + +function mergeConversationRequestsPreservingContent( + previousItems: ChatConversationRequest[], + nextItems: ChatConversationRequest[], + sessionId: string, +) { + const previousByRequestId = new Map( + previousItems + .filter((item) => item.sessionId === sessionId) + .map((item) => [item.requestId, item] as const), + ); + + return nextItems.map((item) => mergeConversationRequestPreservingContent(previousByRequestId.get(item.requestId), item)); +} + function buildMessageSyncKey(messages: ChatMessage[]) { if (messages.length === 0) { return '0'; @@ -838,6 +1352,10 @@ function mapSystemStatusMessage(text: string) { return null; } + if (!normalized.includes('\n') && normalized.length <= 72) { + return normalized; + } + return null; } @@ -846,8 +1364,14 @@ function mapJobStatusLabel(item: Pick) { +function resolveConversationProcessingReferenceTime( + item: Pick, + runtimeSnapshot: ChatRuntimeSnapshot | null, +) { + if (item.currentJobStatus === 'started') { + const runningItem = runtimeSnapshot?.running.find( + (candidate) => + candidate.sessionId === item.sessionId && + (!item.currentRequestId || candidate.requestId === item.currentRequestId), + ); + + return runningItem?.startedAt || item.currentStatusUpdatedAt || null; + } + + if (item.currentJobStatus === 'queued') { + const queuedItem = runtimeSnapshot?.queued.find( + (candidate) => + candidate.sessionId === item.sessionId && + (!item.currentRequestId || candidate.requestId === item.currentRequestId), + ); + + return queuedItem?.enqueuedAt || item.currentStatusUpdatedAt || null; + } + + return null; +} + +function formatConversationProcessingElapsed(referenceTime: string | null | undefined, now = Date.now()) { + if (!referenceTime) { + return null; + } + + const startedAt = new Date(referenceTime).getTime(); + + if (!Number.isFinite(startedAt)) { + return null; + } + + const elapsedMs = Math.max(0, now - startedAt); + const totalMinutes = Math.floor(elapsedMs / 60000); + + if (totalMinutes < 1) { + return '소요 1분 미만'; + } + + if (totalMinutes < 60) { + return `소요 ${totalMinutes}분`; + } + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (minutes === 0) { + return `소요 ${hours}시간`; + } + + return `소요 ${hours}시간 ${minutes}분`; +} + +function buildConversationJobStatusLabel( + item: Pick< + ChatConversationSummary, + 'sessionId' | 'currentRequestId' | 'currentJobStatus' | 'currentJobMessage' | 'currentQueueSize' | 'currentStatusUpdatedAt' + >, + runtimeSnapshot: ChatRuntimeSnapshot | null, + now = Date.now(), +) { + const baseLabel = mapJobStatusLabel(item); + + if (!baseLabel || (item.currentJobStatus !== 'queued' && item.currentJobStatus !== 'started')) { + return baseLabel; + } + + const elapsedLabel = formatConversationProcessingElapsed( + resolveConversationProcessingReferenceTime(item, runtimeSnapshot), + now, + ); + + return elapsedLabel ? `${baseLabel} · ${elapsedLabel}` : baseLabel; +} + +function isConversationProcessing( + item: Pick, +) { return item.currentJobStatus === 'queued' || item.currentJobStatus === 'started'; } @@ -877,6 +1484,346 @@ function isConversationFailed(item: Pick; + } + + try { + const raw = window.localStorage.getItem(CHAT_GENERAL_SECTION_STORAGE_KEY); + + if (!raw) { + return {} as Record; + } + + const parsed = JSON.parse(raw) as Record; + + return Object.entries(parsed).reduce>((result, [sessionId, sectionName]) => { + const normalizedSessionId = sessionId.trim(); + const normalizedSectionName = normalizeGeneralSectionName(sectionName as string | null | undefined); + + if (normalizedSessionId && normalizedSectionName) { + result[normalizedSessionId] = normalizedSectionName; + } + + return result; + }, {}); + } catch { + return {} as Record; + } +} + +function writeStoredGeneralSectionName(sessionId: string, sectionName: string | null) { + if (typeof window === 'undefined') { + return; + } + + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return; + } + + const nextMap = readStoredGeneralSectionNameMap(); + const normalizedSectionName = normalizeGeneralSectionName(sectionName); + + if (normalizedSectionName) { + nextMap[normalizedSessionId] = normalizedSectionName; + } else { + delete nextMap[normalizedSessionId]; + } + + window.localStorage.setItem(CHAT_GENERAL_SECTION_STORAGE_KEY, JSON.stringify(nextMap)); +} + +function buildGeneralSectionKey(name: string | null) { + return name ? `general:${name}` : 'general'; +} + +const RECENT_CONVERSATION_SECTION_KEY = 'recent'; + +function isGeneralConversationSectionKey(sectionKey: string) { + return sectionKey === 'general' || sectionKey.startsWith('general:'); +} + +function isReorderableConversationSectionKey(sectionKey: string) { + return sectionKey === RECENT_CONVERSATION_SECTION_KEY || isGeneralConversationSectionKey(sectionKey); +} + +function readStoredGeneralSectionOrder() { + if (typeof window === 'undefined') { + return [] as string[]; + } + + try { + const raw = window.localStorage.getItem(CHAT_GENERAL_SECTION_ORDER_STORAGE_KEY); + + if (!raw) { + return [] as string[]; + } + + const parsed = JSON.parse(raw); + + if (!Array.isArray(parsed)) { + return [] as string[]; + } + + return parsed.reduce((result, value) => { + const normalizedKey = String(value ?? '').trim(); + + if (isReorderableConversationSectionKey(normalizedKey) && !result.includes(normalizedKey)) { + result.push(normalizedKey); + } + + return result; + }, []); + } catch { + return [] as string[]; + } +} + +function writeStoredGeneralSectionOrder(sectionKeys: string[]) { + if (typeof window === 'undefined') { + return; + } + + const normalizedKeys = sectionKeys.reduce((result, value) => { + const normalizedKey = String(value ?? '').trim(); + + if (isReorderableConversationSectionKey(normalizedKey) && !result.includes(normalizedKey)) { + result.push(normalizedKey); + } + + return result; + }, []); + + window.localStorage.setItem(CHAT_GENERAL_SECTION_ORDER_STORAGE_KEY, JSON.stringify(normalizedKeys)); +} + +function normalizeGeneralSectionOrder(sectionKeys: string[], storedOrder: string[]) { + const normalizedStoredOrder = storedOrder.filter((key) => sectionKeys.includes(key)); + const missingKeys = sectionKeys.filter((key) => !normalizedStoredOrder.includes(key)); + return [...normalizedStoredOrder, ...missingKeys]; +} + +function reorderGeneralConversationSectionsToIndex(sectionKeys: string[], activeKey: string, targetIndex: number) { + const activeIndex = sectionKeys.indexOf(activeKey); + + if (activeIndex < 0) { + return sectionKeys; + } + + const boundedTargetIndex = Math.max(0, Math.min(targetIndex, sectionKeys.length - 1)); + + if (activeIndex === boundedTargetIndex) { + return sectionKeys; + } + + const nextKeys = [...sectionKeys]; + const [movedKey] = nextKeys.splice(activeIndex, 1); + nextKeys.splice(boundedTargetIndex, 0, movedKey); + return nextKeys; +} + +function getDefaultMobileSectionOpenValue(sectionKey: string) { + if (sectionKey === 'processing' || sectionKey === 'failed' || sectionKey === 'unread') { + return true; + } + + return false; +} + +type ConversationListSectionKey = string; + +type ConversationListSection = { + key: ConversationListSectionKey; + title: string; + tone: 'processing' | 'failed' | 'unread' | 'muted'; + items: ChatConversationSummary[]; + generalSectionName?: string | null; + defaultOpen: boolean; + isReorderable?: boolean; +}; + +const DEFAULT_MOBILE_SECTION_OPEN_STATE: Record = { + processing: true, + failed: true, + unread: true, + general: false, +}; + +function toRuntimeStatusTime(value: string | null | undefined) { + if (!value) { + return 0; + } + + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function mergeConversationSummaryPreservingChatType( + previousItem: ChatConversationSummary | null | undefined, + nextItem: ChatConversationSummary, +) { + if (!previousItem) { + return nextItem; + } + + const nextChatTypeId = nextItem.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null; + const nextLastChatTypeId = + nextItem.lastChatTypeId?.trim() || + nextChatTypeId || + previousItem.lastChatTypeId?.trim() || + previousItem.chatTypeId?.trim() || + null; + + return { + ...nextItem, + chatTypeId: nextChatTypeId, + lastChatTypeId: nextLastChatTypeId, + generalSectionName: normalizeGeneralSectionName(nextItem.generalSectionName) ?? normalizeGeneralSectionName(previousItem.generalSectionName), + contextLabel: nextItem.contextLabel?.trim() || previousItem.contextLabel?.trim() || null, + contextDescription: nextItem.contextDescription?.trim() || previousItem.contextDescription?.trim() || null, + lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '', + lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(), + lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '', + }; +} + +function applyRuntimeSnapshotToConversationItems( + items: ChatConversationSummary[], + snapshot: ChatRuntimeSnapshot | null, +) { + if (!snapshot || items.length === 0) { + return items; + } + + const runningBySession = new Map(snapshot.running.map((item) => [item.sessionId, item] as const)); + const queuedBySession = new Map(snapshot.queued.map((item) => [item.sessionId, item] as const)); + const queuedCountBySession = snapshot.queued.reduce>((result, item) => { + result.set(item.sessionId, (result.get(item.sessionId) ?? 0) + 1); + return result; + }, new Map()); + const recentBySession = snapshot.recent.reduce< + Map + >((result, item) => { + const current = result.get(item.sessionId); + + if (!current || toRuntimeStatusTime(item.lastUpdatedAt) >= toRuntimeStatusTime(current.lastUpdatedAt)) { + result.set(item.sessionId, item); + } + + return result; + }, new Map()); + + let hasChanges = false; + const nextItems = items.map((item) => { + const runningItem = runningBySession.get(item.sessionId); + + if (runningItem) { + const nextStatusUpdatedAt = runningItem.startedAt || snapshot.generatedAt; + const nextItem = + item.currentRequestId === runningItem.requestId && + item.currentJobStatus === 'started' && + item.currentQueueSize === 0 && + item.currentStatusUpdatedAt === nextStatusUpdatedAt + ? item + : { + ...item, + currentRequestId: runningItem.requestId, + currentJobStatus: 'started' as const, + currentJobMessage: null, + currentQueueSize: 0, + currentStatusUpdatedAt: nextStatusUpdatedAt, + }; + + if (nextItem !== item) { + hasChanges = true; + } + + return nextItem; + } + + const queuedItem = queuedBySession.get(item.sessionId); + + if (queuedItem) { + const nextQueueSize = queuedCountBySession.get(item.sessionId) ?? 0; + const nextStatusUpdatedAt = queuedItem.enqueuedAt || snapshot.generatedAt; + const nextItem = + item.currentRequestId === queuedItem.requestId && + item.currentJobStatus === 'queued' && + item.currentQueueSize === nextQueueSize && + item.currentStatusUpdatedAt === nextStatusUpdatedAt + ? item + : { + ...item, + currentRequestId: queuedItem.requestId, + currentJobStatus: 'queued' as const, + currentJobMessage: null, + currentQueueSize: nextQueueSize, + currentStatusUpdatedAt: nextStatusUpdatedAt, + }; + + if (nextItem !== item) { + hasChanges = true; + } + + return nextItem; + } + + const recentItem = recentBySession.get(item.sessionId); + + if (recentItem?.terminalStatus === 'failed') { + const nextStatusUpdatedAt = recentItem.lastUpdatedAt || snapshot.generatedAt; + const nextItem = + item.currentRequestId === recentItem.requestId && + item.currentJobStatus === 'failed' && + item.currentQueueSize === 0 && + item.currentStatusUpdatedAt === nextStatusUpdatedAt + ? item + : { + ...item, + currentRequestId: recentItem.requestId, + currentJobStatus: 'failed' as const, + currentJobMessage: null, + currentQueueSize: 0, + currentStatusUpdatedAt: nextStatusUpdatedAt, + }; + + if (nextItem !== item) { + hasChanges = true; + } + + return nextItem; + } + + if (!item.currentJobStatus && !item.currentRequestId && item.currentQueueSize === 0) { + return item; + } + + hasChanges = true; + return { + ...item, + currentRequestId: null, + currentJobStatus: null, + currentJobMessage: null, + currentQueueSize: 0, + currentStatusUpdatedAt: recentItem?.lastUpdatedAt || snapshot.generatedAt, + }; + }); + + if (!hasChanges) { + return items; + } + + return nextItems; +} + function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) { if (!snapshot) { return null; @@ -909,7 +1856,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const { currentPage, focusedComponentId } = useAppStore(); const appConfig = useAppConfig(); const { hasAccess } = useTokenAccess(); - const { chatTypes } = useChatTypeRegistry(); + const { chatTypes, setChatTypes } = useChatTypeRegistry(); + const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore } = + useChatContextSettingsRegistry(); const [draft, setDraft] = useState(''); const [composerAttachments, setComposerAttachments] = useState([]); const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false); @@ -935,6 +1884,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const [selectedChatTypeId, setSelectedChatTypeId] = useState(availableChatTypes[0]?.id ?? null); const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null; const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false; + const [isContextDrawerOpen, setIsContextDrawerOpen] = useState(false); + const [contextDrawerTabKey, setContextDrawerTabKey] = useState('chat-type'); + const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState(null); + const [editingChatTypeDescription, setEditingChatTypeDescription] = useState(''); + const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState([]); + const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState(''); + const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState(''); + const [mobileConversationSectionOpen, setMobileConversationSectionOpen] = + useState>(DEFAULT_MOBILE_SECTION_OPEN_STATE); const [isCreateConversationModalOpen, setIsCreateConversationModalOpen] = useState(false); const [createConversationChatTypeId, setCreateConversationChatTypeId] = useState( availableChatTypes[0]?.id ?? null, @@ -951,6 +1909,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const [editingConversationTitle, setEditingConversationTitle] = useState(''); const [isEditingConversationTitle, setIsEditingConversationTitle] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false); + const [isMobileActionGroupOpen, setIsMobileActionGroupOpen] = useState(false); const [messages, setMessages] = useState([]); const [activeView, setActiveView] = useState(() => { if (requestedChatView) { @@ -962,6 +1921,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const [copiedMessageId, setCopiedMessageId] = useState(null); const [requestItemsState, setRequestItemsState] = useState([]); const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null); + const [editingGeneralSectionSessionId, setEditingGeneralSectionSessionId] = useState(null); + const [editingGeneralSectionName, setEditingGeneralSectionName] = useState(''); + const [savingGeneralSectionSessionId, setSavingGeneralSectionSessionId] = useState(null); + const [generalSectionOrder, setGeneralSectionOrder] = useState(() => readStoredGeneralSectionOrder()); + const [activeGeneralSectionMoveControlsKey, setActiveGeneralSectionMoveControlsKey] = useState(null); const [isConversationContentLoading, setIsConversationContentLoading] = useState(true); const [conversationLoadingLabel, setConversationLoadingLabel] = useState('대화 내용을 불러오는 중입니다.'); const [conversationRoomReloadKey, setConversationRoomReloadKey] = useState(0); @@ -978,11 +1942,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const [renamingConversationSessionId, setRenamingConversationSessionId] = useState(null); const [queuedImportedDraft, setQueuedImportedDraft] = useState(''); const [pendingImportedDraftRequest, setPendingImportedDraftRequest] = useState(null); - const [pendingFreshConversationSendRequest, setPendingFreshConversationSendRequest] = - useState(null); const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false); const [messageApi, messageContextHolder] = message.useMessage(); const [pendingContextConfirm, setPendingContextConfirm] = useState(null); + const [conversationProcessingNow, setConversationProcessingNow] = useState(() => Date.now()); const viewportRef = useRef(null); const composerRef = useRef(null); const previewFindInputRef = useRef(null); @@ -991,10 +1954,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const previewSearchMatchIndexRef = useRef(-1); const previewSearchKeyRef = useRef(''); const activeConversationResyncPromiseRef = useRef | null>(null); + const conversationDetailSyncTokenRef = useRef(0); const previousPreviewModalOpenRef = useRef(false); const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false); const titleClusterRef = useRef(null); + const mobileActionGroupRef = useRef(null); const copyFeedbackTimerRef = useRef(null); + const generalSectionOrderRef = useRef(generalSectionOrder); const pendingRequestsRef = useRef([]); const messagesRef = useRef(messages); const requestItemsRef = useRef(requestItemsState); @@ -1005,6 +1971,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const lastConversationForegroundResyncAtRef = useRef(0); const handledRequestedSessionIdRef = useRef(''); const syncedSelectedChatTypeSessionIdRef = useRef(null); + const chatTypeSelectionIntentSessionIdRef = useRef(null); const isClosingConversationRef = useRef(false); const notifiedTerminalJobKeysRef = useRef([]); const notifiedRestartRequirementKeysRef = useRef([]); @@ -1066,8 +2033,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setConversationSearch, } = useConversationListController({ requestedSessionId: requestedSessionId ?? '', + enabled: activeView === 'chat', }); const conversationItemsRef = useRef(conversationItems); + useEffect(() => { + setConversationItems((previous) => { + const storedSectionNameMap = readStoredGeneralSectionNameMap(); + let didChange = false; + const nextItems = previous.map((item) => { + const storedGeneralSectionName = normalizeGeneralSectionName(storedSectionNameMap[item.sessionId]); + const currentGeneralSectionName = normalizeGeneralSectionName(item.generalSectionName); + + if (!storedGeneralSectionName || storedGeneralSectionName === currentGeneralSectionName) { + return item; + } + + didChange = true; + return { + ...item, + generalSectionName: storedGeneralSectionName, + }; + }); + + return didChange ? nextItems : previous; + }); + }, [setConversationItems]); const { runtimeSnapshot, runtimeJobDetail, @@ -1077,6 +2067,27 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = activeSessionId, isDeferringAuxiliaryChatRequests, }); + const hasProcessingConversation = useMemo( + () => conversationItems.some((item) => isConversationProcessing(item)), + [conversationItems], + ); + + useEffect(() => { + setConversationProcessingNow(Date.now()); + + if (!hasProcessingConversation) { + return; + } + + const intervalId = window.setInterval(() => { + setConversationProcessingNow(Date.now()); + }, 30000); + + return () => { + window.clearInterval(intervalId); + }; + }, [hasProcessingConversation]); + const handleToggleConversationNotification = async () => { if (!activeConversation) { return; @@ -1104,38 +2115,111 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setNotificationToggleSessionId((current) => (current === sessionId ? null : current)); } }; - const handleCreateConversation = async (chatTypeOverride?: CreateConversationTarget | null) => { + const openGeneralSectionEditor = (item: ChatConversationSummary) => { + setEditingGeneralSectionSessionId(item.sessionId); + setEditingGeneralSectionName(normalizeGeneralSectionName(item.generalSectionName) ?? ''); + }; + const handleSaveGeneralSection = async () => { + const targetSessionId = editingGeneralSectionSessionId; + + if (!targetSessionId) { + return; + } + + const nextGeneralSectionName = normalizeGeneralSectionName(editingGeneralSectionName); + const currentConversation = conversationItemsRef.current.find((item) => item.sessionId === targetSessionId) ?? null; + + if (!currentConversation) { + setEditingGeneralSectionSessionId(null); + setEditingGeneralSectionName(''); + return; + } + + const previousGeneralSectionName = normalizeGeneralSectionName(currentConversation.generalSectionName); + + if (previousGeneralSectionName === nextGeneralSectionName) { + setEditingGeneralSectionSessionId(null); + setEditingGeneralSectionName(''); + return; + } + + setSavingGeneralSectionSessionId(targetSessionId); + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === targetSessionId + ? { + ...entry, + generalSectionName: nextGeneralSectionName, + } + : entry, + ), + ); + + try { + const item = await chatGateway.updateConversation(targetSessionId, { + generalSectionName: nextGeneralSectionName, + }); + writeStoredGeneralSectionName(targetSessionId, nextGeneralSectionName); + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === item.sessionId + ? { + ...mergeConversationSummaryPreservingChatType(entry, item), + generalSectionName: nextGeneralSectionName, + } + : entry, + ), + ); + setEditingGeneralSectionSessionId(null); + setEditingGeneralSectionName(''); + messageApi.success(nextGeneralSectionName ? `일반 보관 섹션을 "${nextGeneralSectionName}"로 저장했습니다.` : '기본 일반 섹션으로 되돌렸습니다.'); + } catch (error) { + writeStoredGeneralSectionName(targetSessionId, previousGeneralSectionName); + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === targetSessionId + ? { + ...entry, + generalSectionName: previousGeneralSectionName, + } + : entry, + ), + ); + messageApi.error(error instanceof Error ? error.message : '일반 보관 섹션 저장에 실패했습니다.'); + } finally { + setSavingGeneralSectionSessionId((current) => (current === targetSessionId ? null : current)); + } + }; + const handleCreateConversation = async ({ + chatTypeOverride = null, + persist = true, + }: CreateConversationOptions = {}) => { const sessionId = createConversationSessionId(); const now = new Date().toISOString(); const nextConversationChatType = chatTypeOverride ?? (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null)); const nextConversationTitle = resolveConversationDefaultTitle(nextConversationChatType); - const optimisticItem: ChatConversationSummary = { + const nextConversationDescription = normalizeConversationContextDescription( + resolveComposedChatTypeDescription(nextConversationChatType, { sessionId }), + ); + const optimisticItem = buildOptimisticConversationSummary({ sessionId, - clientId: null, title: nextConversationTitle, - chatTypeId: nextConversationChatType?.id ?? null, - lastChatTypeId: nextConversationChatType?.id ?? null, - contextLabel: nextConversationChatType?.name ?? null, - contextDescription: nextConversationChatType?.description ?? null, - notifyOffline: true, - hasUnreadResponse: false, - currentRequestId: null, - currentJobStatus: null, - currentJobMessage: null, - currentQueueSize: 0, - currentStatusUpdatedAt: null, - lastMessagePreview: '', + chatType: nextConversationChatType, + contextDescription: nextConversationDescription, createdAt: now, - updatedAt: now, - lastMessageAt: null, - }; + isDraftOnly: !persist, + }); setConversationItems((previous) => sortChatConversationSummaries([optimisticItem, ...previous.filter((entry) => entry.sessionId !== sessionId)]), ); openConversationSession(sessionId); + if (!persist) { + return sessionId; + } + try { const item = await chatGateway.createConversation({ sessionId, @@ -1143,7 +2227,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = chatTypeId: nextConversationChatType?.id ?? null, lastChatTypeId: nextConversationChatType?.id ?? null, contextLabel: nextConversationChatType?.name, - contextDescription: nextConversationChatType?.description, + contextDescription: nextConversationDescription ?? undefined, notifyOffline: true, }); @@ -1190,7 +2274,112 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setIsCreateConversationModalOpen(false); setSelectedChatTypeId(selectedCreateConversationChatType.id); - await handleCreateConversation(selectedCreateConversationChatType); + await handleCreateConversation({ + chatTypeOverride: selectedCreateConversationChatType, + persist: false, + }); + }; + const openContextDrawer = () => { + if (!activeConversation) { + return; + } + + setIsMobileActionGroupOpen(false); + const nextChatTypeId = effectiveChatType?.id ?? activeConversation.chatTypeId?.trim() ?? activeConversation.lastChatTypeId?.trim() ?? null; + const nextChatType = + chatTypes.find((item) => item.id === nextChatTypeId) ?? + availableChatTypes.find((item) => item.id === nextChatTypeId) ?? + effectiveChatType ?? + null; + setEditingRoomChatTypeId(nextChatTypeId); + setEditingChatTypeDescription(nextChatType?.description ?? ''); + setEditingRoomDefaultContextIds(effectiveDefaultContextIds); + setEditingRoomCustomContextTitle(effectiveRoomCustomContextTitle); + setEditingRoomCustomContextContent(effectiveRoomCustomContextContent); + setContextDrawerTabKey('chat-type'); + setIsContextDrawerOpen(true); + }; + const handleSaveContextDrawer = async () => { + if (!activeConversation) { + setIsContextDrawerOpen(false); + return; + } + + let nextChatType = + chatTypes.find((item) => item.id === editingRoomChatTypeId) ?? + effectiveRegisteredChatType ?? + null; + + if (!nextChatType) { + messageApi.warning('채팅유형을 먼저 선택하세요.'); + return; + } + + try { + const normalizedChatTypeDescription = editingChatTypeDescription.trim(); + + if (nextChatType.description !== normalizedChatTypeDescription) { + const targetChatTypeId = nextChatType.id; + const nextChatTypes = upsertChatType(chatTypes, { + id: targetChatTypeId, + name: nextChatType.name, + description: normalizedChatTypeDescription, + permissions: nextChatType.permissions, + enabled: nextChatType.enabled, + }); + const savedChatTypes = await setChatTypes(nextChatTypes); + nextChatType = savedChatTypes.find((item) => item.id === targetChatTypeId) ?? nextChatType; + } + + const resolvedChatType = nextChatType; + + const nextRoomContexts = upsertChatRoomContextSettings(roomContexts, { + sessionId: activeConversation.sessionId, + defaultContextIds: editingRoomDefaultContextIds, + customContextTitle: editingRoomCustomContextTitle, + customContextContent: editingRoomCustomContextContent, + }); + const nextDescription = normalizeConversationContextDescription( + resolveComposedChatTypeDescription(resolvedChatType, { + sessionId: activeConversation.sessionId, + defaultContextIds: editingRoomDefaultContextIds, + customContextTitle: editingRoomCustomContextTitle, + customContextContent: editingRoomCustomContextContent, + }), + ); + + void setChatContextSettingsStore({ + defaultContexts, + chatTypeDefaults, + roomContexts: nextRoomContexts, + }).catch(() => {}); + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === activeConversation.sessionId + ? { + ...entry, + chatTypeId: resolvedChatType.id, + lastChatTypeId: resolvedChatType.id, + contextLabel: resolvedChatType.name, + contextDescription: nextDescription, + } + : entry, + ), + ); + setSelectedChatTypeId(resolvedChatType.id); + + const item = await chatGateway.updateConversation(activeConversation.sessionId, { + chatTypeId: resolvedChatType.id, + lastChatTypeId: resolvedChatType.id, + contextLabel: resolvedChatType.name, + contextDescription: nextDescription, + }); + setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry))); + setStoredChatSessionLastTypeId(activeConversation.sessionId, resolvedChatType.id); + setIsContextDrawerOpen(false); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : '채팅방 Context 저장에 실패했습니다.'); + } }; const upsertRequestItem = (request: ChatConversationRequest) => { setRequestItems((previous) => { @@ -1203,10 +2392,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } const nextItems = [...previous]; - nextItems[existingIndex] = { - ...nextItems[existingIndex], - ...request, - }; + nextItems[existingIndex] = mergeConversationRequestPreservingContent(nextItems[existingIndex], request); return nextItems; }); }; @@ -1236,14 +2422,34 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const syncConversationDetailIntoState = useCallback( (sessionId: string, detail: ChatConversationDetailResponse) => { setConversationItems((previous) => { - const exists = previous.some((item) => item.sessionId === sessionId); + const previousItem = previous.find((item) => item.sessionId === sessionId) ?? null; + const mergedMessages = detail.messages; + const fallbackPreview = + detail.item.lastMessagePreview.trim() || + createConversationPreviewText(getLatestConversationPreviewMessage(mergedMessages)?.text ?? '') || + ''; + const fallbackResponsePreview = + detail.item.lastResponsePreview.trim() || + createConversationPreviewText( + [...mergedMessages] + .reverse() + .find((message) => message.author === 'codex' && !isPreparingChatReplyText(message.text)) + ?.text ?? '', + ) || + ''; + const mergedDetailItem = mergeConversationSummaryPreservingChatType(previousItem, { + ...detail.item, + lastMessagePreview: fallbackPreview, + lastResponsePreview: fallbackResponsePreview, + }); + const exists = previousItem != null; if (!exists) { - return sortChatConversationSummaries([detail.item, ...previous]); + return sortChatConversationSummaries([mergedDetailItem, ...previous]); } return sortChatConversationSummaries( - previous.map((item) => (item.sessionId === sessionId ? detail.item : item)), + previous.map((item) => (item.sessionId === sessionId ? mergedDetailItem : item)), ); }); @@ -1251,7 +2457,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setRequestItems((previous) => { const preserved = previous.filter((item) => item.sessionId !== sessionId); - return [...preserved, ...detail.requests]; + return [...preserved, ...mergeConversationRequestsPreservingContent(previous, detail.requests, sessionId)]; }); if (sessionId === activeSessionId) { @@ -1279,13 +2485,20 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } + const syncToken = ++conversationDetailSyncTokenRef.current; const activeSessionRequestCount = requestItemsRef.current.filter( (item) => item.sessionId === normalizedSessionId, ).length; - const detailLimit = + const activeSessionVisibleRequestCount = normalizedSessionId === activeSessionId - ? Math.max(20, messagesRef.current.length || 0, activeSessionRequestCount || 0) - : Math.max(20, activeSessionRequestCount || 0); + ? requestItemsRef.current.filter( + (item) => item.sessionId === normalizedSessionId && item.status !== 'removed', + ).length + : 0; + const detailLimit = Math.min( + 60, + Math.max(20, activeSessionRequestCount || 0, activeSessionVisibleRequestCount || 0), + ); for (const delayMs of CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS) { if (delayMs > 0) { @@ -1298,6 +2511,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const detail = await chatGateway.getConversationDetail(normalizedSessionId, { limit: detailLimit, }); + + if (syncToken !== conversationDetailSyncTokenRef.current) { + return; + } + syncConversationDetailIntoState(normalizedSessionId, detail); if (doesConversationDetailSatisfyTerminalRequest(detail, options?.ensureTerminalRequest)) { @@ -1343,6 +2561,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } }, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]); + const handleRefreshActiveConversation = useCallback(() => { + void reloadConversationItems(); + + if (activeSessionId.trim()) { + void resyncActiveConversationDetail(); + } + }, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]); + const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => { const sessionId = eventSessionId.trim() || activeSessionId; const existingRequest = @@ -1507,6 +2733,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ? buildConversationTitleFromRequestText(incomingMessage.text, item.title) : item.title, lastMessagePreview: createConversationPreviewText(incomingMessage.text), + lastResponsePreview: + incomingMessage.author === 'codex' && !isPreparingChatReplyText(incomingMessage.text) + ? createConversationPreviewText(incomingMessage.text) + : item.lastResponsePreview, lastMessageAt: responseTimestamp, updatedAt: responseTimestamp, hasUnreadResponse: @@ -1596,12 +2826,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } }; const previewItems = useMemo( - () => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))), + () => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message) && !isMissingRequestMessage(message))), [messages], ); const isTabletAppLayout = isMobileViewport; + const [isIpadLikeViewport, setIsIpadLikeViewport] = useState(() => isIpadLikeChatViewport()); const chatMessages = useMemo( - () => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)), + () => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message) || isMissingRequestMessage(message)), [messages], ); const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]); @@ -1609,6 +2840,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = () => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null, [activeSessionId, conversationItems], ); + const activeConversationHasLocalActivity = + chatMessages.length > 0 || requestItems.some((item) => item.sessionId === activeSessionId); const persistedActiveChatTypeId = activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null; const effectiveChatType = useMemo(() => { @@ -1639,7 +2872,51 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const isEffectiveChatTypeAllowed = effectiveRegisteredChatType ? canUseChatType(effectiveRegisteredChatType, userRoles) : false; - const isChatTypeSelectionLocked = Boolean(activeSessionId); + const enabledDefaultContexts = useMemo( + () => defaultContexts.filter((context) => context.enabled), + [defaultContexts], + ); + const activeRoomContextSettings = useMemo( + () => resolveChatRoomContextSettings(roomContexts, activeSessionId), + [activeSessionId, roomContexts], + ); + const effectiveDefaultContextIds = useMemo(() => { + if (activeRoomContextSettings) { + return activeRoomContextSettings.defaultContextIds; + } + + return resolveChatTypeDefaultContextIds(chatTypeDefaults, effectiveChatTypeId); + }, [activeRoomContextSettings, chatTypeDefaults, effectiveChatTypeId]); + const effectiveDefaultContexts = useMemo( + () => + effectiveDefaultContextIds + .map((contextId) => defaultContexts.find((context) => context.id === contextId && context.enabled)) + .filter((context): context is ChatDefaultContextRecord => Boolean(context)), + [defaultContexts, effectiveDefaultContextIds], + ); + const effectiveRoomCustomContextTitle = activeRoomContextSettings?.customContextTitle?.trim() ?? ''; + const effectiveRoomCustomContextContent = activeRoomContextSettings?.customContextContent?.trim() ?? ''; + const contextDrawerChatType = + chatTypes.find((item) => item.id === editingRoomChatTypeId) ?? + availableChatTypes.find((item) => item.id === editingRoomChatTypeId) ?? + null; + const effectiveChatTypeDescription = useMemo( + () => + composeConversationContextDescription( + effectiveChatType?.description ?? activeConversation?.contextDescription ?? '', + effectiveDefaultContexts, + effectiveRoomCustomContextTitle, + effectiveRoomCustomContextContent, + ) ?? '', + [ + activeConversation?.contextDescription, + effectiveChatType?.description, + effectiveDefaultContexts, + effectiveRoomCustomContextContent, + effectiveRoomCustomContextTitle, + ], + ); + const isChatTypeSelectionLocked = true; const currentContext: ChatViewContext = { pageId: currentPage.id, pageTitle: currentPage.title, @@ -1651,8 +2928,45 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', chatTypeId: effectiveChatType?.id ?? null, chatTypeLabel: effectiveChatType?.name ?? '', - chatTypeDescription: effectiveChatType?.description ?? '', + chatTypeDescription: effectiveChatTypeDescription, }; + const resolveComposedChatTypeDescription = useCallback( + ( + chatType: CreateConversationTarget | ChatTypeRecord | { id: string; name: string; description: string } | null, + options?: { + sessionId?: string | null; + defaultContextIds?: string[] | null; + customContextTitle?: string | null; + customContextContent?: string | null; + }, + ) => { + if (!chatType) { + return ''; + } + + const roomOverrides = + options?.sessionId && !options?.defaultContextIds && !options?.customContextTitle && !options?.customContextContent + ? resolveChatRoomContextSettings(roomContexts, options.sessionId) + : null; + const nextDefaultContextIds = + options?.defaultContextIds ?? + roomOverrides?.defaultContextIds ?? + resolveChatTypeDefaultContextIds(chatTypeDefaults, chatType.id); + const nextDefaultContexts = nextDefaultContextIds + .map((contextId) => defaultContexts.find((context) => context.id === contextId && context.enabled)) + .filter((context): context is ChatDefaultContextRecord => Boolean(context)); + + return ( + composeConversationContextDescription( + chatType.description, + nextDefaultContexts, + options?.customContextTitle ?? roomOverrides?.customContextTitle, + options?.customContextContent ?? roomOverrides?.customContextContent, + ) ?? '' + ); + }, + [chatTypeDefaults, defaultContexts, roomContexts], + ); const { socketRef, connectionState } = chatConnectionGateway.useConnection({ sessionId: activeSessionId, currentContext, @@ -1694,6 +3008,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = .map((item) => item.requestId), ); }, [activeSessionId, runtimeSnapshot]); + useEffect(() => { + if (!runtimeSnapshot) { + return; + } + + setConversationItems((previous) => applyRuntimeSnapshotToConversationItems(previous, runtimeSnapshot)); + }, [runtimeSnapshot, setConversationItems]); const activeQueuedComposerRequests = useMemo( () => { const queuedItems = requestItems @@ -1751,33 +3072,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = mapSystemStatusMessage, isActivityLogMessage, }); - useEffect(() => { - if (activeView !== 'chat' || !activeSessionId.trim()) { - return; - } - - if (typeof window === 'undefined') { - return; - } - - const runSilentResync = () => { - if (document.visibilityState !== 'visible') { - return; - } - - void resyncActiveConversationDetail(); - }; - - runSilentResync(); - - const intervalId = window.setInterval(runSilentResync, ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS); - - return () => { - window.clearInterval(intervalId); - }; - }, [activeSessionId, activeView, resyncActiveConversationDetail]); const { loadOlderMessages } = useConversationRoomController({ activeSessionId, + activeConversationIsDraftOnly: activeConversation?.isDraftOnly === true, + activeConversationHasLocalActivity, oldestLoadedMessageId, reloadKey: conversationRoomReloadKey, shouldForceStickToBottomOnNextLoadRef, @@ -1824,35 +3122,163 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } return sortedItems.filter((item) => - [item.title, item.sessionId, item.lastMessagePreview, item.contextDescription] + [item.title, item.sessionId, item.generalSectionName, item.lastMessagePreview, item.contextDescription] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(keyword)), ); }, [conversationItems, conversationSearch]); - const unreadConversationItems = useMemo( - () => filteredConversationItems.filter((item) => item.hasUnreadResponse && !isConversationFailed(item)), - [filteredConversationItems], + useEffect(() => { + generalSectionOrderRef.current = generalSectionOrder; + }, [generalSectionOrder]); + const conversationSections = useMemo(() => { + const groupedItems: Record = { + processing: [], + failed: [], + unread: [], + }; + const generalGroups = new Map(); + const recentConversationItems = filteredConversationItems.filter((item) => { + if (isConversationProcessing(item)) { + return false; + } + + return !item.hasUnreadResponse; + }).slice(0, 5); + + filteredConversationItems.forEach((item) => { + if (isConversationProcessing(item)) { + groupedItems.processing.push(item); + return; + } + + if (isConversationFailed(item)) { + groupedItems.failed.push(item); + return; + } + + if (item.hasUnreadResponse) { + groupedItems.unread.push(item); + return; + } + + const generalSectionName = normalizeGeneralSectionName(item.generalSectionName); + const generalKey = buildGeneralSectionKey(generalSectionName); + const existingGroup = generalGroups.get(generalKey); + + if (existingGroup) { + existingGroup.items.push(item); + return; + } + + generalGroups.set(generalKey, { + generalSectionName, + items: [item], + }); + }); + + const generalSections: ConversationListSection[] = Array.from(generalGroups.entries()).map(([key, group]) => ({ + key, + title: group.generalSectionName ? `일반 · ${group.generalSectionName}` : '일반', + tone: 'muted' as const, + items: group.items, + generalSectionName: group.generalSectionName, + defaultOpen: false, + isReorderable: true, + })); + const reorderableSections: ConversationListSection[] = [ + { + key: RECENT_CONVERSATION_SECTION_KEY, + title: '최근 대화방 5개', + tone: 'muted', + items: recentConversationItems, + defaultOpen: true, + isReorderable: true, + }, + ...generalSections, + ]; + const normalizedGeneralSectionOrder = normalizeGeneralSectionOrder( + reorderableSections.map((section) => section.key), + generalSectionOrder, + ); + const generalSectionMap = new Map(reorderableSections.map((section) => [section.key, section])); + + return [ + { key: 'processing', title: '처리 중', tone: 'processing', items: groupedItems.processing, defaultOpen: true }, + { key: 'failed', title: '오류', tone: 'failed', items: groupedItems.failed, defaultOpen: true }, + { key: 'unread', title: '답변 도착', tone: 'unread', items: groupedItems.unread, defaultOpen: true }, + ...normalizedGeneralSectionOrder + .map((key) => generalSectionMap.get(key)) + .filter((section): section is ConversationListSection => Boolean(section)), + ].filter((section): section is ConversationListSection => Boolean(section) && section.items.length > 0); + }, [filteredConversationItems, generalSectionOrder]); + useEffect(() => { + const availableGeneralSectionKeys = conversationSections + .filter((section) => section.isReorderable) + .map((section) => section.key); + const normalizedOrder = normalizeGeneralSectionOrder(availableGeneralSectionKeys, generalSectionOrderRef.current); + + if ( + normalizedOrder.length !== generalSectionOrderRef.current.length || + normalizedOrder.some((key, index) => key !== generalSectionOrderRef.current[index]) + ) { + generalSectionOrderRef.current = normalizedOrder; + setGeneralSectionOrder(normalizedOrder); + writeStoredGeneralSectionOrder(normalizedOrder); + } + }, [conversationSections]); + const reorderableGeneralSectionKeys = useMemo( + () => conversationSections.filter((section) => section.isReorderable).map((section) => section.key), + [conversationSections], ); - const failedConversationItems = useMemo( - () => filteredConversationItems.filter((item) => isConversationFailed(item)), - [filteredConversationItems], - ); - const processingConversationItems = useMemo( - () => - filteredConversationItems.filter( - (item) => isConversationProcessing(item) && !item.hasUnreadResponse && !isConversationFailed(item), - ), - [filteredConversationItems], - ); - const generalConversationItems = useMemo( - () => - filteredConversationItems.filter( - (item) => !item.hasUnreadResponse && !isConversationProcessing(item) && !isConversationFailed(item), - ), - [filteredConversationItems], + const toggleGeneralSectionMoveControls = useCallback((sectionKey: string) => { + setActiveGeneralSectionMoveControlsKey((previous) => (previous === sectionKey ? null : sectionKey)); + }, []); + const moveGeneralConversationSection = useCallback( + (sectionKey: string, direction: 'up' | 'down') => { + const currentOrder = normalizeGeneralSectionOrder(reorderableGeneralSectionKeys, generalSectionOrderRef.current); + const currentIndex = currentOrder.indexOf(sectionKey); + + if (currentIndex < 0) { + return; + } + + const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + + if (targetIndex < 0 || targetIndex >= currentOrder.length) { + return; + } + + const nextOrder = reorderGeneralConversationSectionsToIndex(currentOrder, sectionKey, targetIndex); + generalSectionOrderRef.current = nextOrder; + setGeneralSectionOrder(nextOrder); + writeStoredGeneralSectionOrder(nextOrder); + }, + [reorderableGeneralSectionKeys], ); + useEffect(() => { + if (!activeGeneralSectionMoveControlsKey) { + return; + } + + if (!reorderableGeneralSectionKeys.includes(activeGeneralSectionMoveControlsKey)) { + setActiveGeneralSectionMoveControlsKey(null); + } + }, [activeGeneralSectionMoveControlsKey, reorderableGeneralSectionKeys]); const pendingDeleteConversation = conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null; + const editingGeneralSectionConversation = + conversationItems.find((item) => item.sessionId === editingGeneralSectionSessionId) ?? null; + const availableGeneralSectionNames = useMemo( + () => + Array.from( + new Set( + conversationItems + .map((item) => normalizeGeneralSectionName(item.generalSectionName)) + .filter((value): value is string => Boolean(value)), + ), + ), + [conversationItems], + ); useEffect(() => { if (!pendingContextConfirm && !pendingDeleteConversation) { return undefined; @@ -2027,10 +3453,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = if (previousPreviewModalOpenRef.current && !isPreviewModalOpen) { previousPreviewModalOpenRef.current = false; - void reloadConversationItems(); - void resyncActiveConversationDetail(); } - }, [isPreviewModalOpen, reloadConversationItems, resyncActiveConversationDetail]); + }, [isPreviewModalOpen]); useEffect(() => { resetActivePreviewSearchState(); @@ -2162,13 +3586,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const renderConversationListItem = ( item: ChatConversationSummary, - section: 'failed' | 'processing' | 'unread' | 'general' = 'general', + sectionKey = 'general', ) => { const isUnread = item.hasUnreadResponse; const isProcessing = isConversationProcessing(item); const isFailed = isConversationFailed(item); - const isUnreadSection = section === 'unread'; - const isFailedSection = section === 'failed'; + const generalSectionName = normalizeGeneralSectionName(item.generalSectionName); + const isUnreadSection = sectionKey === 'unread'; + const isFailedSection = sectionKey === 'failed'; + const isGeneralSection = sectionKey.startsWith('general'); + const requestBadgeLabel = buildConversationRequestBadgeLabel(item, runtimeSnapshot); + const jobStatusLabel = item.currentJobStatus + ? buildConversationJobStatusLabel(item, runtimeSnapshot, conversationProcessingNow) + : ''; return (
-
+
+ ); + }; + + const renderConversationListSection = (section: ConversationListSection) => { + const sectionOrderIndex = reorderableGeneralSectionKeys.indexOf(section.key); + const canMoveSectionUp = section.isReorderable && sectionOrderIndex > 0; + const canMoveSectionDown = + section.isReorderable && sectionOrderIndex >= 0 && sectionOrderIndex < reorderableGeneralSectionKeys.length - 1; + const isMoveControlsActive = activeGeneralSectionMoveControlsKey === section.key; + + return ( +
+ {isMobileViewport ? ( + <> +
+ + {section.isReorderable ? ( + + + {isMoveControlsActive ? ( + <> + + + + ) : null} + + ) : null} +
+ {(mobileConversationSectionOpen[section.key] ?? section.defaultOpen) ? ( +
+ {section.items.map((item) => renderConversationListItem(item, section.key))} +
+ ) : null} + + ) : ( + <> +
{ + if (isMoveControlsActive) { + setActiveGeneralSectionMoveControlsKey(null); + } + }} + > + + {section.title} + {section.items.length} + + {section.isReorderable ? ( + + + {isMoveControlsActive ? ( + <> + + + + ) : null} + + ) : null} +
+ {section.items.map((item) => renderConversationListItem(item, section.key))} + + )}
); }; @@ -2368,7 +4005,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = currentJobMessage: null, currentQueueSize: 0, currentStatusUpdatedAt: null, + lastRequestPreview: '', lastMessagePreview: '', + lastResponsePreview: '', createdAt: now, updatedAt: now, lastMessageAt: null, @@ -2430,6 +4069,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }; }, [isTitleClusterOpen]); + useEffect(() => { + if (!isMobileActionGroupOpen) { + return; + } + + const handleWindowPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node)) { + return; + } + + if (mobileActionGroupRef.current?.contains(event.target)) { + return; + } + + setIsMobileActionGroupOpen(false); + }; + + window.addEventListener('pointerdown', handleWindowPointerDown); + + return () => { + window.removeEventListener('pointerdown', handleWindowPointerDown); + }; + }, [isMobileActionGroupOpen]); + + useEffect(() => { + if (!isMobileViewport) { + setIsMobileActionGroupOpen(false); + } + }, [isMobileViewport]); + + useEffect(() => { + setIsMobileActionGroupOpen(false); + }, [activeSessionId]); + useEffect(() => { clearLegacyChatMessageStorage(); }, []); @@ -2515,6 +4188,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = type: 'message:send', payload: { text: request.text, + omitPromptHistory: request.omitPromptHistory === true, chatTypeId: request.chatTypeId, chatTypeLabel: request.chatTypeLabel, chatTypeDescription: request.chatTypeDescription, @@ -2629,16 +4303,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }; }, []); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const syncIpadLikeViewport = () => { + setIsIpadLikeViewport(isIpadLikeChatViewport()); + }; + + syncIpadLikeViewport(); + window.addEventListener('resize', syncIpadLikeViewport); + window.addEventListener('orientationchange', syncIpadLikeViewport); + + return () => { + window.removeEventListener('resize', syncIpadLikeViewport); + window.removeEventListener('orientationchange', syncIpadLikeViewport); + }; + }, []); + useEffect(() => { if (activeSessionId) { if (!activeConversation) { return; } + const persistedChatTypeId = + activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null; + + if (persistedChatTypeId) { + setStoredChatSessionLastTypeId(activeSessionId, persistedChatTypeId); + } + if (syncedSelectedChatTypeSessionIdRef.current !== activeSessionId) { syncedSelectedChatTypeSessionIdRef.current = activeSessionId; - const persistedChatTypeId = - activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null; if (persistedChatTypeId) { if (selectedChatTypeId !== persistedChatTypeId) { @@ -2646,6 +4344,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } return; } + + const storedChatTypeId = getStoredChatSessionLastTypeId(activeSessionId); + + if (storedChatTypeId && selectedChatTypeId !== storedChatTypeId) { + setSelectedChatTypeId(storedChatTypeId); + return; + } } } else { syncedSelectedChatTypeSessionIdRef.current = null; @@ -2673,10 +4378,24 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null; const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null; - if (currentChatTypeId === selectedChatTypeId && currentLastChatTypeId === selectedChatTypeId) { + const hasPersistedConversationChatType = Boolean(currentChatTypeId || currentLastChatTypeId); + const hasExplicitSelectionIntent = chatTypeSelectionIntentSessionIdRef.current === activeSessionId; + + if (!hasPersistedConversationChatType && !hasExplicitSelectionIntent) { return; } + if (currentChatTypeId === selectedChatTypeId && currentLastChatTypeId === selectedChatTypeId) { + if (hasExplicitSelectionIntent) { + chatTypeSelectionIntentSessionIdRef.current = null; + } + return; + } + + const nextContextDescription = normalizeConversationContextDescription( + resolveComposedChatTypeDescription(selectedChatType, { sessionId: activeSessionId }), + ); + setConversationItems((previous) => previous.map((entry) => entry.sessionId === activeSessionId @@ -2685,7 +4404,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = chatTypeId: selectedChatTypeId, lastChatTypeId: selectedChatTypeId, contextLabel: selectedChatType.name, - contextDescription: selectedChatType.description, + contextDescription: nextContextDescription, } : entry, ), @@ -2695,12 +4414,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = chatTypeId: selectedChatTypeId, lastChatTypeId: selectedChatTypeId, contextLabel: selectedChatType.name, - contextDescription: selectedChatType.description, + contextDescription: nextContextDescription, }).then((item) => { + setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId); + if (chatTypeSelectionIntentSessionIdRef.current === activeSessionId) { + chatTypeSelectionIntentSessionIdRef.current = null; + } setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)), ); }).catch((error: unknown) => { + if (chatTypeSelectionIntentSessionIdRef.current === activeSessionId) { + chatTypeSelectionIntentSessionIdRef.current = null; + } messageApi.error(error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.'); setConversationItems((previous) => previous.map((entry) => @@ -2725,6 +4451,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = activeSessionId, isChatTypeSelectionLocked, messageApi, + resolveComposedChatTypeDescription, selectedChatType, selectedChatTypeId, setConversationItems, @@ -2829,6 +4556,29 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = requestedSessionId, ]); + useEffect(() => { + if (!isMobileViewport) { + return; + } + + setMobileConversationSectionOpen((previous) => { + const nextState = { ...DEFAULT_MOBILE_SECTION_OPEN_STATE, ...previous }; + conversationSections.forEach((section) => { + if (!(section.key in nextState)) { + nextState[section.key] = section.defaultOpen; + } + }); + const activeSectionKey = + conversationSections.find((section) => section.items.some((item) => item.sessionId === activeSessionId))?.key ?? null; + + if (activeSectionKey) { + nextState[activeSectionKey] = true; + } + + return nextState; + }); + }, [activeSessionId, conversationSections, isMobileViewport]); + useEffect(() => { if (requestedSessionId) { return; @@ -2872,7 +4622,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = if (!activeSessionId.trim()) { void chatGateway.listConversations().then((items) => { if (!isCancelled) { - setConversationItems(sortChatConversationSummaries(items)); + setConversationItems((previous) => mergeConversationItemsPreservingTransientState(items, previous)); } }).catch(() => { // 재연결 직후 목록 재조회 실패는 현재 목록 상태를 유지한다. @@ -2894,48 +4644,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } shouldRestoreConversationAfterReconnectRef.current = false; - resyncConversationEntryState(); if (activeSessionId.trim()) { setConversationRoomReloadKey((previous) => previous + 1); } - }, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests, resyncConversationEntryState]); - - useEffect(() => { - const handleFocus = () => { - if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) { - return; - } - resyncConversationEntryState(); - }; - const handlePageShow = () => { - if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) { - return; - } - resyncConversationEntryState(); - }; - const handleVisibilityChange = () => { - if (document.visibilityState !== 'visible') { - return; - } - - if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) { - return; - } - - resyncConversationEntryState(); - }; - - window.addEventListener('focus', handleFocus); - window.addEventListener('pageshow', handlePageShow); - document.addEventListener('visibilitychange', handleVisibilityChange); - - return () => { - window.removeEventListener('focus', handleFocus); - window.removeEventListener('pageshow', handlePageShow); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; - }, [connectionState, resyncConversationEntryState]); + }, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests]); useEffect(() => { if (connectionState !== 'disconnected') { @@ -2965,13 +4678,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } const latestMessage = getLatestConversationPreviewMessage(messages); + const latestResponseMessage = + [...messages].reverse().find((message) => message.author === 'codex' && !isPreparingChatReplyText(message.text)) ?? null; const nextTitle = buildConversationTitleFromMessages(messages, item.title); const nextPreview = latestMessage ? createConversationPreviewText(latestMessage.text) : item.lastMessagePreview; const nextLastMessageAt = latestMessage?.timestamp?.trim() || item.lastMessageAt; + const nextResponsePreview = latestResponseMessage?.text?.trim() + ? createConversationPreviewText(latestResponseMessage.text) + : item.lastResponsePreview; if ( item.title === nextTitle && item.lastMessagePreview === nextPreview && + item.lastResponsePreview === nextResponsePreview && item.lastMessageAt === nextLastMessageAt ) { return item; @@ -2981,6 +4700,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ...item, title: nextTitle, lastMessagePreview: nextPreview, + lastResponsePreview: nextResponsePreview, lastMessageAt: nextLastMessageAt, }; }), @@ -3063,8 +4783,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const { executeSendMessage, handleComposerFilesPicked, - handleSend, - handleSendImmediate, + sendMessage, } = useConversationComposerController({ activeSessionId, appConfigChat: appConfig.chat, @@ -3075,7 +4794,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ? { id: effectiveChatType.id, name: effectiveChatType.name, - description: effectiveChatType.description, + description: effectiveChatTypeDescription, } : null, socketRef, @@ -3104,31 +4823,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = scrollViewportToBottom, }); - useEffect(() => { - if (!pendingFreshConversationSendRequest) { - return; - } - - if (pendingContextConfirm) { - return; - } - - if (activeSessionId !== pendingFreshConversationSendRequest.targetSessionId) { - return; - } - - executeSendMessage({ - mode: pendingFreshConversationSendRequest.sendMode, - text: pendingFreshConversationSendRequest.text, - chatTypeId: pendingFreshConversationSendRequest.chatTypeId, - chatTypeLabel: pendingFreshConversationSendRequest.chatTypeLabel, - chatTypeDescription: pendingFreshConversationSendRequest.chatTypeDescription, - includedContextCount: 0, - omittedContextCount: 0, - }); - setPendingFreshConversationSendRequest(null); - }, [activeSessionId, executeSendMessage, pendingContextConfirm, pendingFreshConversationSendRequest]); - useEffect(() => { if (!pendingImportedDraftRequest?.autoSend) { return; @@ -3157,13 +4851,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = isCreatingImportedDraftConversationRef.current = true; setSelectedChatTypeId(importChatType.id); - void handleCreateConversation(importChatType).finally(() => { + void handleCreateConversation({ + chatTypeOverride: importChatType, + persist: false, + }).finally(() => { isCreatingImportedDraftConversationRef.current = false; }); return; } - if (!effectiveChatType) { + if (!effectiveChatType) { return; } @@ -3174,7 +4871,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = text: pendingImportedDraftRequest.text, chatTypeId: effectiveChatType.id, chatTypeLabel: effectiveChatType.name, - chatTypeDescription: effectiveChatType.description, + chatTypeDescription: effectiveChatTypeDescription, includedContextCount: 0, omittedContextCount: 0, }); @@ -3182,6 +4879,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = activeSessionId, availableChatTypes, effectiveChatType, + effectiveChatTypeDescription, executeSendMessage, handleCreateConversation, pendingContextConfirm, @@ -3193,7 +4891,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ]); const handleSendWithoutPreviousContext = useCallback( - async (mode: 'queue' | 'direct') => { + (mode: 'queue' | 'direct', draftText?: string) => { if (isComposerAttachmentUploading) { return; } @@ -3204,10 +4902,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ? { id: selectedChatType.id, name: selectedChatType.name, - description: selectedChatType.description, + description: resolveComposedChatTypeDescription(selectedChatType), } : (availableChatTypes[0] ?? null)); - const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim(); + const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim(); if (!trimmed) { return; @@ -3221,54 +4919,67 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } - const createdSessionId = await handleCreateConversation(nextConversationChatType); - if (!createdSessionId) { + if (!activeSessionId.trim()) { + setMessages((previous) => [ + ...previous.slice(-39), + createLocalMessage('활성 대화방이 없어서 문맥 없이 보내기를 실행할 수 없습니다. 먼저 보낼 대화방을 열어 주세요.'), + ]); return; } - setPendingFreshConversationSendRequest({ - targetSessionId: createdSessionId, + executeSendMessage({ + mode, text: trimmed, - sendMode: mode, chatTypeId: nextConversationChatType.id, chatTypeLabel: nextConversationChatType.name, - chatTypeDescription: nextConversationChatType.description, + chatTypeDescription: resolveComposedChatTypeDescription(nextConversationChatType, { sessionId: activeSessionId }), + includedContextCount: 0, + omittedContextCount: 0, + omitPromptHistory: true, }); - setDraft(''); - setComposerAttachments([]); }, [ + activeSessionId, availableChatTypes, buildOutgoingMessageText, composerAttachments, createLocalMessage, draft, effectiveChatType, - handleCreateConversation, + executeSendMessage, isComposerAttachmentUploading, isSelectedChatTypeAllowed, + resolveComposedChatTypeDescription, selectedChatType, setMessages, ], ); - const handleComposerSend = useCallback(() => { - if (isSendWithoutContextEnabled) { - void handleSendWithoutPreviousContext('queue'); - return; - } + const handleComposerSend = useCallback( + (draftText?: string) => { + if (isSendWithoutContextEnabled) { + setIsSendWithoutContextEnabled(false); + void handleSendWithoutPreviousContext('queue', draftText); + return; + } - handleSend(); - }, [handleSend, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]); + sendMessage({ mode: 'queue', draftText }); + }, + [handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage], + ); - const handleComposerSendImmediate = useCallback(() => { - if (isSendWithoutContextEnabled) { - void handleSendWithoutPreviousContext('direct'); - return; - } + const handleComposerSendImmediate = useCallback( + (draftText?: string) => { + if (isSendWithoutContextEnabled) { + setIsSendWithoutContextEnabled(false); + void handleSendWithoutPreviousContext('direct', draftText); + return; + } - handleSendImmediate(); - }, [handleSendImmediate, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]); + sendMessage({ mode: 'direct', draftText }); + }, + [handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage], + ); const handleCopyMessage = async (message: ChatMessage) => { await copyText(message.text); @@ -3284,6 +4995,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, 2000); }; + const handleCloseConversationPane = useCallback(() => { + setIsMobileActionGroupOpen(false); + isClosingConversationRef.current = true; + handledRequestedSessionIdRef.current = ''; + replaceChatSessionInUrl(''); + setIsConversationPaneClosed(true); + + if (isMobileViewport) { + setIsMobileConversationView(false); + } + }, [isMobileViewport, replaceChatSessionInUrl]); + if (activeView === 'errors' && !hasAccess) { return ( + {activeConversation ? ( +
+ + ) : ( +
+ +
+ ) + ) : null} + {!activeConversation ? ( + + + } + > +
+ +
+ 현재 채팅유형 + 다른 유형을 고르는 대신, 이 방이 쓰는 기본 문맥 내용을 바로 확인하고 수정합니다. +
+ {contextDrawerChatType ? ( + <> +
+ {contextDrawerChatType.name} + + 저장하면 이 채팅유형을 사용하는 다른 방의 기본 설명도 함께 갱신됩니다. + +
+
+ { + setEditingChatTypeDescription(event.target.value); + }} + /> +
+ + ) : ( + + )} +
+ ), + }, + { + key: 'room-context', + label: '방 전용', + children: ( +
+
+ 채팅방 전용 Context + 이 방에서만 추가로 참조되는 Markdown 문맥입니다. +
+ { + setEditingRoomCustomContextTitle(event.target.value); + }} + /> +
+ { + setEditingRoomCustomContextContent(event.target.value); + }} + /> +
+
+ ), + }, + { + key: 'default-contexts', + label: '기본 유형', + children: ( +
+
+ 적용 기본 유형 + 여러 개를 함께 참조할 수 있습니다. +
+ {enabledDefaultContexts.length > 0 ? ( + { + setEditingRoomDefaultContextIds( + checkedValues + .map((value) => String(value).trim()) + .filter((value) => enabledDefaultContexts.some((context) => context.id === value)), + ); + }} + > + + {enabledDefaultContexts.map((context) => ( + + ))} + + + ) : ( + + )} +
+ ), + }, + { + key: 'summary', + label: '미리보기', + children: ( +
+
+ 현재 적용 미리보기 + 저장 후 이후 요청부터 반영됩니다. +
+
+ {contextDrawerChatType?.name ?? activeConversation?.contextLabel ?? '채팅 유형 미선택'} + {editingRoomDefaultContextIds.map((contextId) => { + const context = enabledDefaultContexts.find((item) => item.id === contextId); + + return context ? ( + + {context.title} + + ) : null; + })} +
+
+ ), + }, + ]} + /> + + + )} + { + setEditingGeneralSectionSessionId(null); + setEditingGeneralSectionName(''); + }} + onOk={async () => { + await handleSaveGeneralSection(); + }} + > +
+ + 처리 중, 오류, 답변 도착 섹션에서는 그대로 보이고, 작업이 끝나면 여기서 지정한 일반 섹션으로 돌아갑니다. + + { + setEditingGeneralSectionName(event.target.value); + }} + onPressEnter={() => { + void handleSaveGeneralSection(); + }} + /> + {availableGeneralSectionNames.length > 0 ? ( +
+ + {availableGeneralSectionNames.map((name) => ( + + ))} +
+ ) : null} +
+
; } + if (selectionId === 'page:chat:resources') { + return ; + } + if (selectionId === 'page:chat:manage') { return ; } + if (selectionId === 'page:chat:manage-defaults') { + return ; + } + if (selectionId === 'page:play:layout') { return ; } @@ -217,7 +228,13 @@ export function MainContent({ return ( { handleFocusCapture(event.target); }} diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index f280d6b..bcc4c47 100755 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -29,12 +29,18 @@ import { Space, Typography, } from 'antd'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { fetchPlanItems } from '../../features/planBoard/api'; import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters'; -import { fetchServerCommands, restartServerCommand } from '../../features/serverCommand/api'; -import type { ServerCommandItem } from '../../features/serverCommand/types'; +import { + cancelServerRestartReservation, + fetchServerCommands, + fetchServerRestartReservation, + restartServerCommand, + scheduleServerRestartReservation, +} from '../../features/serverCommand/api'; +import type { ServerCommandItem, ServerRestartReservation } from '../../features/serverCommand/types'; import { DEFAULT_APP_CONFIG, saveAutomationNotificationPreferenceToServer, @@ -86,11 +92,19 @@ const APP_SETTINGS_CATEGORIES = [ ] as const; const APP_SETTINGS_SECTIONS: Array<{ - value: 'chatSettings' | 'planDefaults' | 'planCost' | 'worklogAutomation' | 'automationNotifications' | 'gestureShortcuts'; + value: + | 'chatSettings' + | 'automationRuntime' + | 'planDefaults' + | 'planCost' + | 'worklogAutomation' + | 'automationNotifications' + | 'gestureShortcuts'; label: string; category: (typeof APP_SETTINGS_CATEGORIES)[number]['value']; }> = [ { value: 'chatSettings', label: '채팅 문맥 설정', category: 'workspace' }, + { value: 'automationRuntime', label: '자동접수 / 주기', category: 'automation' }, { value: 'planDefaults', label: '자동화 기본값', category: 'automation' }, { value: 'planCost', label: '비용 표시', category: 'automation' }, { value: 'worklogAutomation', label: '업무일지 자동화 설정', category: 'automation' }, @@ -115,6 +129,43 @@ type InlineFeedback = { tone: FeedbackTone; message: string; }; +type RestartProgressState = { + targetLabel: string; + stepLabel: string; + detail: string; + cancelPending: boolean; + cancellable: boolean; +}; +type RestartReservationOverlayStep = { + label: string; + status: 'done' | 'active' | 'pending'; +}; +type RestartReservationOverlayState = { + title: string; + statusText: string; + detail: string; + steps: RestartReservationOverlayStep[]; +}; + +type RestartCompletionAction = 'reload' | 'reset-client-state' | 'custom'; +type RestartCompletionConfirmOptions = { + title: string; + targetLabel: string; + action: RestartCompletionAction; + autoActionSeconds?: number; + okText?: string; + cancelText?: string; + promptText?: string; + countdownText?: (remainingSeconds: number, actionLabel: string) => string; + customAction?: () => Promise; + onCancel?: () => void; + onActionError?: (message: string) => void; +}; +const SERVER_RESTART_CACHE_BUST_PARAM = '__serverRestartedAt'; +const RESERVED_RESTART_WORK_SERVER_DELAY_MS = 5_000; +const RESERVED_RESTART_VERIFICATION_INTERVAL_MS = 2_000; +const RESERVED_RESTART_VERIFICATION_TIMEOUT_MS = 90_000; +const RESERVED_RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000; function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) { return ( @@ -122,7 +173,8 @@ function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) left.maxContextChars === right.maxContextChars && left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds && left.codexLiveIdleTimeoutSeconds === right.codexLiveIdleTimeoutSeconds && - left.receiveRoomNotifications === right.receiveRoomNotifications + left.receiveRoomNotifications === right.receiveRoomNotifications && + left.restartReservationCompletionDelaySeconds === right.restartReservationCompletionDelaySeconds ); } @@ -149,6 +201,10 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c changedLabels.push('채팅방 알림 수신'); } + if (saved.restartReservationCompletionDelaySeconds !== draft.restartReservationCompletionDelaySeconds) { + changedLabels.push('재기동 예약 자동 실행 대기 시간'); + } + return changedLabels; } @@ -273,6 +329,74 @@ function formatPlanCostTimeMultiplierLabel(planCost: AppConfig['planCost']) { return `${PLAN_COST_TIME_UNIT_LABELS[planCost.timeCostUnit]}당 ${planCost.hourlyCostMultiplierPercent}%`; } +function areAutomationRuntimeSettingsEqual(left: AppConfig['automation'], right: AppConfig['automation']) { + return ( + left.autoRefreshEnabled === right.autoRefreshEnabled && + left.autoRefreshIntervalSeconds === right.autoRefreshIntervalSeconds && + left.autoReceiveScheduleType === right.autoReceiveScheduleType && + left.autoReceiveIntervalSeconds === right.autoReceiveIntervalSeconds && + left.autoReceiveDailyTime === right.autoReceiveDailyTime && + left.autoReceiveWeeklyDay === right.autoReceiveWeeklyDay && + left.autoReceiveWeeklyTime === right.autoReceiveWeeklyTime + ); +} + +function getAutomationRuntimeDiffLabels(left: AppConfig['automation'], right: AppConfig['automation']) { + const changedLabels: string[] = []; + + if (left.autoRefreshEnabled !== right.autoRefreshEnabled) { + changedLabels.push('자동화 워커 실행'); + } + + if (left.autoRefreshIntervalSeconds !== right.autoRefreshIntervalSeconds) { + changedLabels.push('워커 새로고침 주기'); + } + + if (left.autoReceiveScheduleType !== right.autoReceiveScheduleType) { + changedLabels.push('자동접수 스케줄 방식'); + } + + if (left.autoReceiveIntervalSeconds !== right.autoReceiveIntervalSeconds) { + changedLabels.push('자동접수 간격'); + } + + if (left.autoReceiveDailyTime !== right.autoReceiveDailyTime) { + changedLabels.push('매일 자동접수 시각'); + } + + if (left.autoReceiveWeeklyDay !== right.autoReceiveWeeklyDay) { + changedLabels.push('주간 자동접수 요일'); + } + + if (left.autoReceiveWeeklyTime !== right.autoReceiveWeeklyTime) { + changedLabels.push('주간 자동접수 시각'); + } + + return changedLabels; +} + +function formatAutomationRuntimeSchedule(automation: AppConfig['automation']) { + if (automation.autoReceiveScheduleType === 'daily') { + return `매일 ${automation.autoReceiveDailyTime}`; + } + + if (automation.autoReceiveScheduleType === 'weekly') { + const weeklyDayLabels: Record = { + mon: '월요일', + tue: '화요일', + wed: '수요일', + thu: '목요일', + fri: '금요일', + sat: '토요일', + sun: '일요일', + }; + + return `${weeklyDayLabels[automation.autoReceiveWeeklyDay]} ${automation.autoReceiveWeeklyTime}`; + } + + return `${automation.autoReceiveIntervalSeconds}초 간격`; +} + function getPlanCostThresholdPreview(planCost: AppConfig['planCost']) { const attentionThreshold = planCost.baseCostPerMillionTokens * Math.max(0.1, planCost.attentionCostThresholdMultiplier); const warningThreshold = @@ -502,6 +626,255 @@ function formatDateTimeLabel(value: string | null) { }).format(parsed); } +function getKstDateKey(date: Date) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +} + +function formatTimeLabel(value: string | null) { + if (!value) { + return '-'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('ko-KR', { + timeZone: 'Asia/Seoul', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(parsed); +} + +function formatMonthDayLabel(value: string | null) { + if (!value) { + return '-'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('ko-KR', { + timeZone: 'Asia/Seoul', + month: 'numeric', + day: 'numeric', + }).format(parsed); +} + +function formatCompactHistoryLabel(value: string | null, nowTimestamp: number) { + if (!value) { + return null; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return getKstDateKey(parsed) === getKstDateKey(new Date(nowTimestamp)) + ? formatTimeLabel(value) + : formatMonthDayLabel(value); +} + +function formatRelativeSecondsLabel(targetAt: string | null, nowTimestamp: number) { + if (!targetAt) { + return null; + } + + const targetTimestamp = Date.parse(targetAt); + + if (!Number.isFinite(targetTimestamp)) { + return null; + } + + const diffMs = targetTimestamp - nowTimestamp; + + if (diffMs <= 0) { + return '곧 확인'; + } + + const diffSeconds = Math.max(1, Math.ceil(diffMs / 1000)); + return `${diffSeconds}초 후`; +} + +function formatRemainingSecondsLabel(targetTimestamp: number | null, nowTimestamp: number) { + if (!targetTimestamp || !Number.isFinite(targetTimestamp)) { + return null; + } + + const diffMs = targetTimestamp - nowTimestamp; + + if (diffMs <= 0) { + return '곧'; + } + + return `${Math.max(1, Math.ceil(diffMs / 1000))}초`; +} + +function buildCacheBustedReloadUrl() { + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.set(SERVER_RESTART_CACHE_BUST_PARAM, `${Date.now()}`); + return nextUrl.toString(); +} + +function getServerRestartReservationTimingLabel( + reservation: ServerRestartReservation | null, + nowTimestamp: number, +) { + if (!reservation) { + return null; + } + + if (reservation.status === 'waiting' && reservation.nextCheckAt) { + const relativeLabel = formatRelativeSecondsLabel(reservation.nextCheckAt, nowTimestamp); + return relativeLabel ? `확인 ${relativeLabel}` : '확인 대기'; + } + + if (reservation.status === 'ready') { + const relativeLabel = formatRelativeSecondsLabel(reservation.autoExecuteAt, nowTimestamp); + return relativeLabel ? `자동 실행 ${relativeLabel}` : '자동 실행 준비'; + } + + if (reservation.status === 'executing' && reservation.startedAt) { + const workServerRestartAt = Date.parse(reservation.startedAt) + RESERVED_RESTART_WORK_SERVER_DELAY_MS; + const remainingLabel = formatRemainingSecondsLabel(workServerRestartAt, nowTimestamp); + return remainingLabel ? `${remainingLabel} 남음` : '곧 완료'; + } + + if (reservation.status === 'completed' && reservation.completedAt) { + const completedLabel = formatCompactHistoryLabel(reservation.completedAt, nowTimestamp); + return completedLabel ? `완료 ${completedLabel}` : '완료'; + } + + if (reservation.status === 'cancelled' && reservation.cancelledAt) { + const cancelledLabel = formatCompactHistoryLabel(reservation.cancelledAt, nowTimestamp); + return cancelledLabel ? `취소 ${cancelledLabel}` : '취소'; + } + + if (reservation.requestedAt) { + const requestedLabel = formatCompactHistoryLabel(reservation.requestedAt, nowTimestamp); + return requestedLabel ? `예약 ${requestedLabel}` : '예약'; + } + + return null; +} + +function getServerRestartReservationPendingSummary( + reservation: ServerRestartReservation | null, + nowTimestamp: number, +) { + if (!reservation) { + return null; + } + + const codexPending = + (reservation.workloadSummary.codexRunningCount ?? 0) + (reservation.workloadSummary.codexQueuedCount ?? 0); + const automationPending = + (reservation.workloadSummary.automationRunningCount ?? 0) + (reservation.workloadSummary.automationQueuedCount ?? 0); + const totalPending = codexPending + automationPending; + + if (reservation.status === 'waiting') { + const relativeLabel = formatRelativeSecondsLabel(reservation.nextCheckAt, nowTimestamp); + + if (totalPending > 0) { + return `작업 ${totalPending}건 · 완료 뒤 10초`; + } + + return relativeLabel ? `재확인 ${relativeLabel}` : '재기동 대기'; + } + + if (reservation.status === 'ready') { + const relativeLabel = formatRelativeSecondsLabel(reservation.autoExecuteAt, nowTimestamp); + return relativeLabel + ? `${relativeLabel} 뒤 자동 실행` + : `${reservation.autoExecuteDelaySeconds}초 뒤 자동 실행`; + } + + if (reservation.status === 'executing') { + return reservation.activeClientCount > 0 ? `알림 ${reservation.activeClientCount}건 전송` : null; + } + + return null; +} + +function getServerRestartReservationOverlayState( + reservation: ServerRestartReservation | null, + nowTimestamp: number, + reloadPending: boolean, +): RestartReservationOverlayState | null { + if (!reservation) { + return null; + } + + if (reservation.status === 'executing') { + const startedTimestamp = reservation.startedAt ? Date.parse(reservation.startedAt) : Number.NaN; + const workServerRestartAt = Number.isFinite(startedTimestamp) + ? startedTimestamp + RESERVED_RESTART_WORK_SERVER_DELAY_MS + : null; + const beforeWorkServerRestart = + workServerRestartAt !== null && Number.isFinite(workServerRestartAt) ? nowTimestamp < workServerRestartAt : false; + const remainingLabel = formatRemainingSecondsLabel(workServerRestartAt, nowTimestamp); + const statusText = beforeWorkServerRestart && remainingLabel ? `${remainingLabel} 뒤 다음 단계` : '곧 마무리'; + const detail = beforeWorkServerRestart + ? 'TEST 서버를 먼저 재기동하고, 이어서 WORK 서버 재기동을 준비합니다.' + : 'WORK 서버 재기동 뒤 정상 기동을 확인하는 중입니다.'; + + return { + title: beforeWorkServerRestart ? 'TEST 서버 재기동 중' : 'WORK 서버 재기동 준비 중', + statusText, + detail, + steps: [ + { label: '예약 확인', status: 'done' }, + { label: 'TEST 재기동', status: beforeWorkServerRestart ? 'active' : 'done' }, + { label: 'WORK 재기동', status: beforeWorkServerRestart ? 'pending' : 'active' }, + { label: '새로고침', status: 'pending' }, + ], + }; + } + + if (reservation.status === 'ready') { + return { + title: '재기동 예약 자동 실행 대기', + statusText: '자동 재기동 대기 중', + detail: '진행 중인 작업이 모두 끝났습니다. 설정된 대기 시간이 지나면 TEST 서버부터 순서대로 재기동합니다.', + steps: [ + { label: '자동 실행 대기', status: 'active' }, + { label: 'TEST 재기동', status: 'pending' }, + { label: 'WORK 재기동', status: 'pending' }, + { label: '새로고침', status: 'pending' }, + ], + }; + } + + if (reservation.status === 'completed' && reloadPending) { + return { + title: '재기동 완료 처리 중', + statusText: '정상 기동 최종 확인', + detail: 'TEST/WORK 서버 새 런타임을 다시 확인한 뒤 브라우저 상태를 정리하고 최신 화면으로 연결합니다.', + steps: [ + { label: '예약 확인', status: 'done' }, + { label: 'TEST 재기동', status: 'done' }, + { label: 'WORK 재기동', status: 'done' }, + { label: '새로고침', status: 'active' }, + ], + }; + } + + return null; +} + function getServerVersionStatusClassName(item: ServerCommandItem | null) { if (!item) { return 'app-header__server-version-indicator--unknown'; @@ -522,6 +895,22 @@ function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) { return formatDateTimeLabel(item?.latestSourceChangeAt ?? null); } +function getServerLastSourceChangedHint(item: ServerCommandItem | null) { + if (!item) { + return null; + } + + if (item.latestSourceChangePath?.trim()) { + return `최근 경로: ${item.latestSourceChangePath.trim()}`; + } + + if (!item.latestSourceChangeAt && item.updateSummary?.trim()) { + return item.updateSummary.trim(); + } + + return null; +} + function getServerVersionStatusTitle(item: ServerCommandItem | null, label: string) { if (!item) { return `${label} 최신 버전 확인 전`; @@ -538,6 +927,161 @@ function getServerVersionStatusTitle(item: ServerCommandItem | null, label: stri return `${label} 최신 버전`; } +function getServerRestartFollowupMessage(item: ServerCommandItem, label: string) { + if (item.buildRequired) { + return `${label}는 다시 떠 있어도 빨간 점이 유지될 수 있습니다. 현재 소스가 최신 빌드보다 새로워 재기동만으로는 해소되지 않습니다.`; + } + + if (item.updateAvailable) { + return `${label}는 다시 떠 있어도 반영 대기 상태라 점 상태가 바로 바뀌지 않을 수 있습니다.`; + } + + return null; +} + +function hasServerRuntimeChanged(previous: ServerCommandItem | null, next: ServerCommandItem | null) { + if (!previous || !next) { + return Boolean(next); + } + + return ( + previous.startedAt !== next.startedAt || + previous.runningBuiltAt !== next.runningBuiltAt || + previous.runningVersion !== next.runningVersion || + previous.composeDetails !== next.composeDetails + ); +} + +function hasServerUpdateStateImproved(previous: ServerCommandItem | null, next: ServerCommandItem | null) { + if (!previous || !next) { + return Boolean(next) && !next.buildRequired && !next.updateAvailable; + } + + const previousNeedsUpdate = previous.buildRequired || previous.updateAvailable; + const nextNeedsUpdate = next.buildRequired || next.updateAvailable; + + return previousNeedsUpdate && !nextNeedsUpdate; +} + +function hasServerRestartBeenVerified( + key: 'test' | 'prod' | 'work-server', + previous: ServerCommandItem | null, + next: ServerCommandItem | null, +) { + if (!next || next.availability !== 'online') { + return false; + } + + if (hasServerRuntimeChanged(previous, next) || hasServerUpdateStateImproved(previous, next)) { + return true; + } + + if (key === 'test') { + return Boolean(next.runningBuiltAt) && !next.buildRequired; + } + + if (key === 'prod') { + return Boolean(next.runningBuiltAt) && !next.updateAvailable; + } + + if (key === 'work-server') { + return Boolean(next.runningVersion ?? next.runningBuiltAt) && !next.buildRequired && !next.updateAvailable; + } + + return false; +} + +function hasReservedRestartStartedAfterReservation( + serverStartedAt: string | null | undefined, + reservationStartedAt: string | null | undefined, +) { + const serverStartedTime = Date.parse(serverStartedAt ?? ''); + const reservationStartedTime = Date.parse(reservationStartedAt ?? ''); + + if (!Number.isFinite(serverStartedTime) || !Number.isFinite(reservationStartedTime)) { + return false; + } + + return serverStartedTime + RESERVED_RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime; +} + +function hasReservedRestartServerBeenVerified( + key: 'test' | 'work-server', + item: ServerCommandItem | null, + reservationStartedAt: string | null | undefined, +) { + if ( + !item || + item.availability !== 'online' || + !hasReservedRestartStartedAfterReservation(item.startedAt, reservationStartedAt) + ) { + return false; + } + + if (key === 'test') { + return Boolean(item.runningBuiltAt) && !item.buildRequired; + } + + return Boolean(item.runningVersion ?? item.runningBuiltAt) && !item.buildRequired && !item.updateAvailable; +} + +function shouldResetClientStateAfterRestart( + key: 'test' | 'prod' | 'work-server', + previous: ServerCommandItem | null, + next: ServerCommandItem | null, +) { + if (key !== 'test' || !next || next.availability !== 'online' || next.buildRequired) { + return false; + } + + return ( + hasServerUpdateStateImproved(previous, next) || + previous?.runningBuiltAt !== next.runningBuiltAt || + previous?.latestBuiltAt !== next.latestBuiltAt + ); +} + +function getRestartCompletionActionLabel(action: RestartCompletionAction) { + if (action === 'reset-client-state') { + return '캐시 초기화 후 새로고침'; + } + + if (action === 'custom') { + return '재기동'; + } + + return '새로고침'; +} + +function getServerRestartTargetLabel(key: 'test' | 'prod' | 'work-server' | 'all') { + switch (key) { + case 'test': + return 'TEST 서버'; + case 'prod': + return 'PROD 컨테이너'; + case 'work-server': + return 'WORK 서버'; + default: + return 'TEST 서버와 WORK 서버'; + } +} + +function getServerAvailabilityStatusLabel(item: ServerCommandItem | null) { + if (!item) { + return '상태 응답 대기 중'; + } + + if (item.availability === 'online') { + return '정상 응답 확인'; + } + + if (item.availability === 'degraded') { + return '부분 응답 상태'; + } + + return '오프라인 상태'; +} + function getClientNotificationPermission(): ClientNotificationPermissionState { if ( typeof window === 'undefined' || @@ -950,6 +1494,18 @@ export function MainHeader({ const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null); const [serverRestartFeedback, setServerRestartFeedback] = useState(null); const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState(null); + const [serverRestartReservation, setServerRestartReservation] = useState(null); + const [serverRestartReservationLoading, setServerRestartReservationLoading] = useState(false); + const [serverRestartReservationFeedback, setServerRestartReservationFeedback] = useState(null); + const [serverRestartReservationReloadPending, setServerRestartReservationReloadPending] = useState(false); + const [serverRestartReservationNowTimestamp, setServerRestartReservationNowTimestamp] = useState(() => Date.now()); + const [restartProgress, setRestartProgress] = useState(null); + const restartProgressTaskIdRef = useRef(0); + const restartProgressCancelledRef = useRef(false); + const restartProgressAbortRef = useRef(null); + const serverRestartReservationCompletedBootstrapRef = useRef(false); + const handledServerRestartReservationCompletedAtRef = useRef(null); + const serverRestartReservationReloadTaskIdRef = useRef(0); const { registeredToken, hasAccess } = useTokenAccess(); const appConfig = useAppConfig(); const [appConfigDraft, setAppConfigDraft] = useState(appConfig); @@ -1002,6 +1558,67 @@ export function MainHeader({ : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태'; + const serverRestartReservationStatusClassName = + serverRestartReservation?.enabled + ? serverRestartReservation.status === 'failed' + ? 'app-header__status-dot--inactive' + : 'app-header__status-dot--progress' + : 'app-header__status-dot--inactive'; + const serverRestartReservationStatusLabel = + !serverRestartReservation || (!serverRestartReservation.enabled && serverRestartReservation.status === 'idle') + ? '대기 중지' + : serverRestartReservation.status === 'waiting' + ? '대기 중' + : serverRestartReservation.status === 'ready' + ? '자동 실행 대기' + : serverRestartReservation.status === 'executing' + ? '실행 중' + : serverRestartReservation.status === 'completed' + ? '최근 실행 완료' + : serverRestartReservation.status === 'cancelled' + ? '예약 취소됨' + : '실패'; + const serverRestartReservationItemLabel = '재기동 예약'; + const serverRestartReservationTimingLabel = getServerRestartReservationTimingLabel( + serverRestartReservation, + serverRestartReservationNowTimestamp, + ); + const serverRestartReservationPendingSummary = getServerRestartReservationPendingSummary( + serverRestartReservation, + serverRestartReservationNowTimestamp, + ); + const serverRestartReservationItemMetaParts = + serverRestartReservation?.status === 'waiting' || serverRestartReservation?.status === 'ready' + ? [serverRestartReservationStatusLabel, serverRestartReservationPendingSummary] + : [serverRestartReservationStatusLabel, serverRestartReservationTimingLabel]; + const serverRestartReservationItemMeta = serverRestartReservationItemMetaParts + .filter((value): value is string => Boolean(value)) + .join(' · '); + const serverRestartReservationStatusDescription = + serverRestartReservation?.status === 'waiting' + ? serverRestartReservationPendingSummary + : serverRestartReservation?.status === 'ready' + ? serverRestartReservationPendingSummary + : serverRestartReservation?.status === 'executing' + ? serverRestartReservationTimingLabel + : serverRestartReservation?.status === 'completed' + ? serverRestartReservationTimingLabel + : serverRestartReservation?.status === 'cancelled' + ? serverRestartReservationTimingLabel + : serverRestartReservation?.status === 'failed' + ? '실패' + : null; + const serverRestartReservationItemDescription = + serverRestartReservationStatusDescription + ?? serverRestartReservationFeedback?.message + ?? serverRestartReservation?.waitingReason + ?? serverRestartReservation?.lastError + ?? serverRestartReservationItemMeta; + const serverRestartReservationOverlayState = getServerRestartReservationOverlayState( + serverRestartReservation, + serverRestartReservationNowTimestamp, + serverRestartReservationReloadPending, + ); const headerTopMenuOptions = hasAccess ? [ { @@ -1099,7 +1716,9 @@ export function MainHeader({ appConfig.worklogAutomation, appConfigDraft.worklogAutomation, ); + const automationRuntimeSettingsDirty = !areAutomationRuntimeSettingsEqual(appConfig.automation, appConfigDraft.automation); const chatSettingsDiffLabels = getChatSettingsDiffLabels(appConfig.chat, appConfigDraft.chat); + const automationRuntimeDiffLabels = getAutomationRuntimeDiffLabels(appConfig.automation, appConfigDraft.automation); const planDefaultSettingsDirty = !arePlanDefaultSettingsEqual(appConfig.planDefaults, appConfigDraft.planDefaults); const planDefaultDiffLabels = getPlanDefaultDiffLabels(appConfig.planDefaults, appConfigDraft.planDefaults); const planCostSettingsDirty = !arePlanCostSettingsEqual(appConfig.planCost, appConfigDraft.planCost); @@ -1283,10 +1902,12 @@ export function MainHeader({ if (!hasAccess) { setTestServerStatus(null); setWorkServerStatus(null); + setServerRestartReservation(null); return; } void refreshUpdateTargets(true); + void refreshServerRestartReservation(true); }, [hasAccess]); useEffect(() => { @@ -1295,8 +1916,143 @@ export function MainHeader({ } void refreshUpdateTargets(true); + void refreshServerRestartReservation(true); }, [activeSettingsModal, hasAccess, settingsModalOpen]); + useEffect(() => { + if (!hasAccess) { + return; + } + + void refreshServerRestartReservation(true); + + const timer = window.setInterval(() => { + void refreshServerRestartReservation(true); + }, serverRestartReservation?.enabled ? 2_000 : 5_000); + + return () => { + window.clearInterval(timer); + }; + }, [hasAccess, serverRestartReservation?.enabled]); + + useEffect(() => { + if (!serverRestartReservation) { + return; + } + + if ( + serverRestartReservation.status !== 'waiting' + && serverRestartReservation.status !== 'ready' + && serverRestartReservation.status !== 'executing' + ) { + return; + } + + const timer = window.setInterval(() => { + setServerRestartReservationNowTimestamp(Date.now()); + }, 1_000); + + return () => { + window.clearInterval(timer); + }; + }, [serverRestartReservation]); + + useEffect(() => { + if (!serverRestartReservation) { + setServerRestartReservationReloadPending(false); + serverRestartReservationReloadTaskIdRef.current += 1; + return; + } + + if (serverRestartReservation.status !== 'completed') { + setServerRestartReservationReloadPending(false); + serverRestartReservationReloadTaskIdRef.current += 1; + } + + if (!serverRestartReservationCompletedBootstrapRef.current) { + serverRestartReservationCompletedBootstrapRef.current = true; + handledServerRestartReservationCompletedAtRef.current = serverRestartReservation.completedAt; + return; + } + + if ( + serverRestartReservation.status !== 'completed' + || !serverRestartReservation.completedAt + || handledServerRestartReservationCompletedAtRef.current === serverRestartReservation.completedAt + ) { + return; + } + + handledServerRestartReservationCompletedAtRef.current = serverRestartReservation.completedAt; + setServerRestartReservationReloadPending(true); + setServerRestartReservationFeedback({ + tone: 'info', + message: '예약된 재기동 완료 신호를 받았습니다. TEST/WORK 서버 새 런타임을 최종 확인한 뒤 화면을 새로고침합니다.', + }); + + const taskId = serverRestartReservationReloadTaskIdRef.current + 1; + serverRestartReservationReloadTaskIdRef.current = taskId; + + void (async () => { + const deadline = Date.now() + RESERVED_RESTART_VERIFICATION_TIMEOUT_MS; + + while (serverRestartReservationReloadTaskIdRef.current === taskId && Date.now() <= deadline) { + try { + const statuses = await refreshServerStatuses(); + const testVerified = hasReservedRestartServerBeenVerified('test', statuses.test, serverRestartReservation.startedAt); + const workVerified = hasReservedRestartServerBeenVerified( + 'work-server', + statuses['work-server'], + serverRestartReservation.startedAt, + ); + + if (testVerified && workVerified) { + setServerRestartReservationFeedback({ + tone: 'info', + message: '예약된 재기동 뒤 TEST/WORK 서버 새 런타임을 확인했습니다. 캐시를 정리한 뒤 화면을 새로고침합니다.', + }); + try { + await executeRestartCompletionAction('reset-client-state'); + } catch (error) { + const message = + error instanceof Error ? error.message : '재기동 뒤 브라우저 상태를 정리하지 못했습니다.'; + setServerRestartReservationReloadPending(false); + setServerRestartReservationFeedback({ + tone: 'error', + message, + }); + } + return; + } + + const pendingTargets = [ + !testVerified ? 'TEST' : null, + !workVerified ? 'WORK' : null, + ].filter((value): value is string => Boolean(value)); + setServerRestartReservationFeedback({ + tone: 'info', + message: `${pendingTargets.join('/')} 서버 새 런타임 확인 전이라 새로고침을 보류합니다.`, + }); + } catch { + setServerRestartReservationFeedback({ + tone: 'info', + message: '재기동 뒤 서버 응답을 다시 확인하는 중입니다. 확인 전까지 새로고침을 보류합니다.', + }); + } + + await waitForDuration(RESERVED_RESTART_VERIFICATION_INTERVAL_MS); + } + + if (serverRestartReservationReloadTaskIdRef.current === taskId) { + setServerRestartReservationReloadPending(false); + setServerRestartReservationFeedback({ + tone: 'warning', + message: '재기동 완료 뒤 새 런타임 최종 확인이 지연되어 자동 새로고침을 보류했습니다.', + }); + } + })(); + }, [serverRestartReservation]); + const ensureClientNotificationPermission = async () => { const currentPermission = getClientNotificationPermission(); setClientNotificationPermission(currentPermission); @@ -1571,6 +2327,43 @@ export function MainHeader({ } satisfies Record<'test' | 'prod' | 'work-server', ServerCommandItem | null>; }; + const refreshServerRestartReservation = async (silent = false) => { + if (!hasAccess) { + setServerRestartReservation(null); + if (!silent) { + setServerRestartReservationFeedback({ tone: 'warning', message: '재기동 예약은 권한 토큰 등록 후 사용할 수 있습니다.' }); + } + return null; + } + + if (!silent) { + setServerRestartReservationLoading(true); + } + + try { + const nextReservation = await fetchServerRestartReservation(); + setServerRestartReservation(nextReservation); + + if (!silent) { + setServerRestartReservationFeedback(null); + } + + return nextReservation; + } catch (error) { + if (!silent) { + setServerRestartReservationFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '재기동 예약 상태를 불러오지 못했습니다.', + }); + } + return null; + } finally { + if (!silent) { + setServerRestartReservationLoading(false); + } + } + }; + const refreshUpdateTargets = async (silent = false) => { if (!hasAccess) { setTestServerStatus(null); @@ -1605,33 +2398,85 @@ export function MainHeader({ } }; - const waitForServerRestart = async (key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null) => { + const waitForServerRestart = async ( + key: 'test' | 'prod' | 'work-server', + baseline: ServerCommandItem | null, + progressTaskId: number, + ) => { for (let attempt = 0; attempt < 16; attempt += 1) { + if (isRestartProgressCancelled(progressTaskId)) { + return { + ok: false, + cancelled: true, + item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus, + }; + } + + updateRestartProgress( + progressTaskId, + '서버 부팅 대기 중', + `현재 진행상태: ${attempt + 1}/16회 대기 중입니다. 재기동 후 응답이 돌아올 때까지 잠시 기다립니다.`, + ); await waitForDuration(2500); try { + if (isRestartProgressCancelled(progressTaskId)) { + return { + ok: false, + cancelled: true, + item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus, + }; + } + + updateRestartProgress( + progressTaskId, + '서버 상태 확인 중', + `현재 진행상태: ${attempt + 1}/16회 상태 조회 중입니다. 실제 부팅 완료 응답을 확인하고 있습니다.`, + ); const nextStatuses = await refreshServerStatuses(); const nextStatus = nextStatuses[key]; if (!nextStatus) { + updateRestartProgress( + progressTaskId, + '서버 상태 재확인 중', + `현재 진행상태: ${attempt + 1}/16회 확인 결과 응답 정보가 없어 다시 확인합니다.`, + ); continue; } - const restarted = - baseline == null || - nextStatus.startedAt !== baseline.startedAt || - nextStatus.checkedAt !== baseline.checkedAt; + const restarted = hasServerRestartBeenVerified(key, baseline, nextStatus); - if (nextStatus.availability === 'online' && restarted) { - return { ok: true, item: nextStatus }; + if (restarted) { + updateRestartProgress( + progressTaskId, + '부팅 완료 확인', + `현재 진행상태: ${attempt + 1}/16회 확인에서 정상 응답을 확인했습니다.`, + { cancellable: false }, + ); + return { ok: true, cancelled: false, item: nextStatus }; } + + const statusLabel = getServerAvailabilityStatusLabel(nextStatus); + const checkedAtLabel = formatDateTimeLabel(nextStatus.checkedAt); + updateRestartProgress( + progressTaskId, + '서버 상태 재확인 중', + `현재 진행상태: ${attempt + 1}/16회 확인 결과 ${statusLabel}${checkedAtLabel ? ` (${checkedAtLabel})` : ''}. 실제 재기동/최신 빌드 반영을 계속 확인합니다.`, + ); } catch { // 서버 재기동 중에는 일시적으로 조회가 실패할 수 있습니다. + updateRestartProgress( + progressTaskId, + '서버 응답 재시도 중', + `현재 진행상태: ${attempt + 1}/16회 확인에서 응답이 불안정해 다시 확인합니다.`, + ); } } return { ok: false, + cancelled: false, item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus, }; }; @@ -1661,7 +2506,7 @@ export function MainHeader({ : '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.', }); window.setTimeout(() => { - window.location.replace(window.location.href); + window.location.replace(buildCacheBustedReloadUrl()); }, 700); } catch (error) { setClientResetFeedback({ @@ -1673,14 +2518,133 @@ export function MainHeader({ } }; + const executeRestartCompletionAction = async (action: RestartCompletionAction) => { + if (action === 'reset-client-state') { + await resetNonAuthClientState(); + window.location.replace(buildCacheBustedReloadUrl()); + return; + } + + if (action === 'custom') { + return; + } + + window.location.replace(buildCacheBustedReloadUrl()); + }; + + const openRestartCompletionConfirm = (options: RestartCompletionConfirmOptions) => { + const { + title, + targetLabel, + action, + autoActionSeconds = 4, + okText, + cancelText, + promptText, + countdownText, + customAction, + onCancel, + onActionError, + } = options; + const actionLabel = getRestartCompletionActionLabel(action); + let remainingSeconds = Math.max(1, Math.round(autoActionSeconds)); + let settled = false; + let countdownTimerId: number | null = null; + let autoActionTimerId: number | null = null; + + const buildContent = (seconds: number) => ( + + {promptText ?? `실제로 ${targetLabel} 부팅 완료까지 확인했습니다. 지금 ${actionLabel.toLowerCase()}할까요?`} + + {countdownText?.(seconds, actionLabel) ?? `${seconds}초 동안 반응이 없으면 자동으로 ${actionLabel.toLowerCase()}합니다.`} + + + ); + + const cleanupTimers = () => { + if (countdownTimerId !== null) { + window.clearInterval(countdownTimerId); + } + + if (autoActionTimerId !== null) { + window.clearTimeout(autoActionTimerId); + } + }; + + const runAction = async () => { + if (settled) { + return; + } + + settled = true; + cleanupTimers(); + try { + if (action === 'custom') { + await customAction?.(); + return; + } + + await executeRestartCompletionAction(action); + } catch (error) { + const message = + error instanceof Error + ? error.message + : `${targetLabel} 재기동 후 ${actionLabel.toLowerCase()}를 진행하지 못했습니다.`; + onActionError?.(message); + setServerRestartFeedback({ + tone: 'error', + message, + }); + } + }; + + const confirmModal = modalApi.confirm({ + title, + content: buildContent(remainingSeconds), + okText: okText ?? actionLabel, + cancelText: cancelText ?? '나중에', + autoFocusButton: 'ok', + modalRender: renderModalWithEnterConfirm, + onOk: () => runAction(), + onCancel: () => { + settled = true; + cleanupTimers(); + onCancel?.(); + }, + }); + + countdownTimerId = window.setInterval(() => { + if (settled) { + cleanupTimers(); + return; + } + + remainingSeconds = Math.max(0, remainingSeconds - 1); + confirmModal.update({ + content: buildContent(remainingSeconds), + }); + }, 1000); + + autoActionTimerId = window.setTimeout(() => { + if (settled) { + return; + } + + confirmModal.destroy(); + void runAction(); + }, remainingSeconds * 1000); + }; + const restartServerWithVerification = async ( key: 'test' | 'prod' | 'work-server', busyKey: 'test' | 'prod' | 'work-server' | 'all', + progressTaskId: number, ) => { const baseline = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus; - const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버'; + const targetLabel = getServerRestartTargetLabel(key); - const result = await restartServerCommand(key); + updateRestartProgress(progressTaskId, '재기동 요청 전송 중', `${targetLabel} 재기동 명령을 전달하고 있습니다.`); + const result = await restartServerCommand(key, { signal: restartProgressAbortRef.current?.signal }); if (key === 'test') { setTestServerStatus(result.item); @@ -1699,21 +2663,37 @@ export function MainHeader({ }); setServerRestartingKey(busyKey); - const verified = await waitForServerRestart(key, baseline); + updateRestartProgress( + progressTaskId, + '재기동 요청 완료', + `${targetLabel} 재기동 요청이 접수되었습니다. 실제 부팅 완료를 확인할 때까지 기다립니다.`, + ); + const verified = await waitForServerRestart(key, baseline, progressTaskId); + + if (verified.cancelled || isRestartProgressCancelled(progressTaskId)) { + setServerRestartFeedback({ + tone: 'warning', + message: `${targetLabel} 재기동 대기를 취소했습니다. 서버 재기동 요청은 이미 접수됐을 수 있으니 상태를 다시 확인해 주세요.`, + }); + return null; + } if (!verified.ok || !verified.item || verified.item.availability !== 'online') { setServerRestartFeedback({ tone: 'error', message: `${targetLabel} 재기동 후 정상 응답을 확인하지 못했습니다. 상태를 다시 확인해 주세요.`, }); - return false; + return null; } + const followupMessage = getServerRestartFollowupMessage(verified.item, targetLabel); setServerRestartFeedback({ - tone: 'success', - message: `${targetLabel} 재기동 성공을 확인했습니다. 확인 시각 ${formatDateTimeLabel(verified.item.checkedAt)}`, + tone: followupMessage ? 'warning' : 'success', + message: followupMessage + ? `${targetLabel} 재기동 성공을 확인했습니다. ${followupMessage} 확인 시각 ${formatDateTimeLabel(verified.item.checkedAt)}` + : `${targetLabel} 재기동 성공을 확인했습니다. 확인 시각 ${formatDateTimeLabel(verified.item.checkedAt)}`, }); - return true; + return verified.item; }; const handleRestartSingleServer = async (key: 'test' | 'prod' | 'work-server') => { @@ -1721,20 +2701,54 @@ export function MainHeader({ return false; } + const targetLabel = getServerRestartTargetLabel(key); + const baselineStatus = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus; + const progressTaskId = beginRestartProgress(targetLabel); setServerRestartCopyFeedback(null); setServerRestartFeedback(null); setServerRestartingKey(key); try { - return await restartServerWithVerification(key, key); + const restartedItem = await restartServerWithVerification(key, key, progressTaskId); + + if (!restartedItem || isRestartProgressCancelled(progressTaskId)) { + closeRestartProgress(); + return false; + } + + const completionAction = shouldResetClientStateAfterRestart(key, baselineStatus, restartedItem) + ? 'reset-client-state' + : 'reload'; + + updateRestartProgress( + progressTaskId, + '재기동 확인 완료', + `${targetLabel} 부팅 완료를 확인했습니다. ${getRestartCompletionActionLabel(completionAction)} 여부를 선택해 주세요.`, + { cancellable: false }, + ); + closeRestartProgress(); + openRestartCompletionConfirm({ + title: `${targetLabel} 재기동 완료`, + targetLabel, + action: completionAction, + }); + return true; } catch (error) { - const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버'; + if (error instanceof DOMException && error.name === 'AbortError') { + setServerRestartFeedback({ + tone: 'warning', + message: `${targetLabel} 재기동 대기를 취소했습니다. 서버 재기동 요청은 이미 접수됐을 수 있으니 상태를 다시 확인해 주세요.`, + }); + return false; + } + setServerRestartFeedback({ tone: 'error', message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`, }); return false; } finally { + closeRestartProgress(); setServerRestartingKey(null); } }; @@ -1763,37 +2777,132 @@ export function MainHeader({ return; } + const testBaselineStatus = testServerStatus; + const progressTaskId = beginRestartProgress(getServerRestartTargetLabel('all')); setServerRestartCopyFeedback(null); setServerRestartFeedback(null); setServerRestartingKey('all'); try { - const testOk = await restartServerWithVerification('test', 'all'); + const testRestartedItem = await restartServerWithVerification('test', 'all', progressTaskId); - if (!testOk) { + if (!testRestartedItem) { + closeRestartProgress(); return; } - const workServerOk = await restartServerWithVerification('work-server', 'all'); + updateRestartProgress( + progressTaskId, + '다음 서버 진행 중', + 'TEST 서버 부팅 확인이 끝났습니다. 이어서 WORK 서버 재기동을 진행합니다.', + ); + const workServerRestartedItem = await restartServerWithVerification('work-server', 'all', progressTaskId); - if (!workServerOk) { + if (!workServerRestartedItem) { + closeRestartProgress(); return; } + const completionAction = shouldResetClientStateAfterRestart('test', testBaselineStatus, testRestartedItem) + ? 'reset-client-state' + : 'reload'; + setServerRestartFeedback({ tone: 'success', message: 'TEST 서버와 WORK 서버 모두 재기동 성공을 확인했습니다.', }); + updateRestartProgress( + progressTaskId, + '전체 재기동 확인 완료', + `TEST 서버와 WORK 서버 모두 실제 부팅 완료를 확인했습니다. ${getRestartCompletionActionLabel(completionAction)} 여부를 선택해 주세요.`, + { cancellable: false }, + ); + closeRestartProgress(); + openRestartCompletionConfirm({ + title: '전체 재기동 완료', + targetLabel: 'TEST 서버와 WORK 서버', + action: completionAction, + }); } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + setServerRestartFeedback({ + tone: 'warning', + message: '전체 재기동 대기를 취소했습니다. 이미 접수된 서버 재기동은 계속될 수 있으니 상태를 다시 확인해 주세요.', + }); + return; + } + setServerRestartFeedback({ tone: 'error', message: error instanceof Error ? error.message : '서버 재기동에 실패했습니다.', }); } finally { + closeRestartProgress(); setServerRestartingKey(null); } }; + const handleScheduleServerRestartReservation = async () => { + if (!hasAccess || serverRestartReservationLoading || serverRestartingKey) { + return; + } + + setServerRestartReservationLoading(true); + setServerRestartReservationFeedback(null); + + try { + const nextReservation = await scheduleServerRestartReservation({ + autoExecuteDelaySeconds: appConfig.chat.restartReservationCompletionDelaySeconds, + }); + setServerRestartReservation(nextReservation); + setServerRestartReservationFeedback({ + tone: 'success', + message: `전체 재기동 예약을 등록했습니다. 작업이 모두 끝나면 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 뒤 자동 실행합니다.`, + }); + } catch (error) { + setServerRestartReservationFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.', + }); + } finally { + setServerRestartReservationLoading(false); + } + }; + + const handleCancelServerRestartReservation = async () => { + if (!hasAccess || serverRestartReservationLoading) { + return; + } + + setServerRestartReservationLoading(true); + setServerRestartReservationFeedback(null); + + try { + const nextReservation = await cancelServerRestartReservation(); + setServerRestartReservation(nextReservation); + setServerRestartReservationFeedback({ + tone: 'success', + message: '재기동 예약을 취소했습니다.', + }); + } catch (error) { + setServerRestartReservationFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '재기동 예약 취소에 실패했습니다.', + }); + } finally { + setServerRestartReservationLoading(false); + } + }; + + const handleToggleServerRestartReservation = async () => { + if (serverRestartReservation?.enabled) { + await handleCancelServerRestartReservation(); + return; + } + + await handleScheduleServerRestartReservation(); + }; + const openSettingsModal = ( modal: SettingsModalKey, nextAppSettingsSection: AppSettingsSectionKey = 'planDefaults', @@ -1873,6 +2982,13 @@ export function MainHeader({ }); }, []); + useEffect( + () => () => { + restartProgressAbortRef.current?.abort(); + }, + [], + ); + useEffect(() => { if (!hasAccess || !settingsOpen) { setPlanShortcutCounts({ @@ -1950,6 +3066,84 @@ export function MainHeader({ ); }; + const beginRestartProgress = (targetLabel: string) => { + restartProgressTaskIdRef.current += 1; + restartProgressCancelledRef.current = false; + restartProgressAbortRef.current = new AbortController(); + setRestartProgress({ + targetLabel, + stepLabel: '재기동 요청 전송 중', + detail: '서버 명령 API에 재기동을 요청하고 있습니다.', + cancelPending: false, + cancellable: true, + }); + return restartProgressTaskIdRef.current; + }; + + const updateRestartProgress = ( + taskId: number, + stepLabel: string, + detail: string, + options?: { cancellable?: boolean; cancelPending?: boolean }, + ) => { + if (restartProgressTaskIdRef.current !== taskId) { + return; + } + + setRestartProgress((current) => { + if (!current) { + return current; + } + + return { + ...current, + stepLabel, + detail, + cancellable: options?.cancellable ?? current.cancellable, + cancelPending: options?.cancelPending ?? current.cancelPending, + }; + }); + }; + + const isRestartProgressCancelled = (taskId: number) => + restartProgressTaskIdRef.current !== taskId || restartProgressCancelledRef.current; + + const closeRestartProgress = () => { + setRestartProgress(null); + restartProgressAbortRef.current = null; + restartProgressCancelledRef.current = false; + }; + + const requestCancelRestartProgress = () => { + if (!restartProgress || restartProgress.cancelPending || !restartProgress.cancellable) { + return; + } + + modalApi.confirm({ + title: '재기동 대기를 취소할까요?', + content: '이미 접수된 서버 재기동 자체는 계속될 수 있습니다. 현재 화면의 대기와 마지막 새로고침만 중단합니다.', + okText: '대기 취소', + cancelText: '계속 진행', + autoFocusButton: 'cancel', + modalRender: renderModalWithEnterConfirm, + onOk: () => { + restartProgressCancelledRef.current = true; + restartProgressAbortRef.current?.abort(); + setRestartProgress((current) => + current + ? { + ...current, + stepLabel: '취소 처리 중', + detail: '진행 중인 재기동 확인과 마지막 새로고침 안내를 중단하고 있습니다.', + cancelPending: true, + cancellable: false, + } + : current, + ); + }, + }); + }; + const handleRegisterToken = () => { const trimmedToken = tokenInput.trim(); @@ -2184,8 +3378,8 @@ export function MainHeader({ message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'} description={ chatSettingsDirty - ? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}` - : `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태` + ? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}, 재기동 예약 자동 실행 대기 ${appConfigDraft.chat.restartReservationCompletionDelaySeconds}초` + : `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태, 재기동 예약 자동 실행 대기 시간은 ${appConfig.chat.restartReservationCompletionDelaySeconds}초` } /> @@ -2299,6 +3493,31 @@ export function MainHeader({ }} /> + +
+ 재기동 예약 자동 실행 대기 시간(초) + + 예약 작업이 끝나면 자동화 확인 없이 이 시간 뒤 TEST/WORK 서버 재기동을 자동으로 시작합니다. + + { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + restartReservationCompletionDelaySeconds: + typeof value === 'number' && Number.isFinite(value) + ? Math.min(300, Math.max(1, Math.round(value))) + : DEFAULT_APP_CONFIG.chat.restartReservationCompletionDelaySeconds, + }, + })); + }} + /> +
); @@ -2388,6 +3607,187 @@ export function MainHeader({ ); + const automationRuntimePanel = ( + + + + {renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)} + { + updateAppConfigDraft((current) => ({ + ...current, + automation: { + ...current.automation, + autoRefreshEnabled: event.target.checked, + }, + })); + }} + > + 자동화 워커 실행 유지 + + + 워커 새로고침 주기(초) + { + updateAppConfigDraft((current) => ({ + ...current, + automation: { + ...current.automation, + autoRefreshIntervalSeconds: + typeof value === 'number' && Number.isFinite(value) + ? Math.min(3600, Math.max(1, Math.round(value))) + : DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds, + }, + })); + }} + /> + Plan worker가 대기열, 자동접수, 후속 단계를 다시 확인하는 간격입니다. + + + 자동접수 스케줄 + { + updateAppConfigDraft((current) => ({ + ...current, + automation: { + ...current.automation, + autoReceiveDailyTime: event.target.value || DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime, + }, + })); + }} + /> + 매일 한 번 자동접수를 시작할 시각입니다. + + ) : null} + {appConfigDraft.automation.autoReceiveScheduleType === 'weekly' ? ( + <> + { + updateAppConfigDraft((current) => ({ + ...current, + automation: { + ...current.automation, + autoReceiveWeeklyTime: event.target.value || DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime, + }, + })); + }} + /> + 선택한 요일과 시각에 자동접수를 시작합니다. + + ) : null} + + + + + + + ); + const planCostPanel = ( 업데이트 + ); return ( <> {modalContextHolder} + {serverRestartReservationOverlayState ? ( +
+
+ RESTART RESERVATION + {serverRestartReservationOverlayState.title} +
+ + {serverRestartReservationOverlayState.statusText} +
+

{serverRestartReservationOverlayState.detail}

+
+ {serverRestartReservationOverlayState.steps.map((step) => ( +
+
+ ))} +
+
+
+ ) : null}
@@ -3227,11 +4668,13 @@ export function MainHeader({ />