chore: sync local workspace changes
This commit is contained in:
@@ -54,6 +54,12 @@
|
||||
* 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다
|
||||
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
|
||||
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
|
||||
* 채팅 답변에서 링크 카드는 외부 공개 링크에만 사용하고 `[[link-card:제목|URL|버튼라벨]]` 형식을 정확히 지킨다
|
||||
* 세션 리소스, 내부 문서, 코드, 로그, 테이블 파일처럼 `/api/chat/resources/...`, `/.codex_chat/...`, `/public/.codex_chat/...` 아래 내부 리소스에는 링크 카드를 사용하지 않는다
|
||||
* 링크 카드 URL 안에 구분자 `|`를 `%7C`로 인코딩하거나 `https:/api/...`처럼 잘못 줄여 쓰지 않는다
|
||||
* 모바일 캡처 결과나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 형식으로 제공한다
|
||||
* 모바일 캡처 결과를 설명하는 본문 옆에 링크 카드를 덧붙이더라도, 같은 리소스의 `[[preview:URL]]` 표시는 생략하지 않는다
|
||||
* 내부 문서성 리소스는 일반 경로나 자동 프리뷰 가능한 리소스 URL로 제공하고, 표 형태 확인이 필요하면 preview 컴포넌트에서 바로 열 수 있는 형식을 우선 사용한다
|
||||
* 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
|
||||
* 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다
|
||||
|
||||
|
||||
11
README.md
11
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`도 함께 점검합니다.
|
||||
|
||||
## 운영 메모
|
||||
|
||||
|
||||
@@ -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` 참고
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
`code/value` 데이터를 받아 여러 항목을 체크박스 형태로 선택하고, 실제 값은 `code[]`로 유지하는 다중 선택 combo 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
변경 파일의 전체 소스와 raw diff를 codex 스타일 아코디언으로 함께 보여주는 공통 preview 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -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 샘플 위젯
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
Plan/Board 계열 화면에서 반복되는 산출물 카드, 링크, 미리보기 진입 UI를 공통 스트립으로 정리하는 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 지원 타입
|
||||
|
||||
- `image`
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
Ant Design `Input`을 기반으로 하되, 타이핑 중에는 내부 상태만 변경하고 `Enter` 또는 `blur` 시점에만 외부 `onChange`를 호출하는 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
`[input][button][readonly input]` 형태로 검색어 입력, 버튼 액션, 선택 결과 표시를 한 줄에서 처리하는 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
다양한 데이터를 공통 카드 형태로 미리보기할 수 있는 previewer 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 지원 타입
|
||||
|
||||
- `text`
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
Plan, Board, History 화면에서 공통으로 사용할 수 있는 단계형 진행 표시 컴포넌트입니다.
|
||||
현재 단계, 완료 단계, 실패 단계, 다음 대기 단계를 한 번에 보여주며 가로/세로 배치와 compact 모드를 지원합니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
문서, API, 컴포넌트, 위젯을 키워드로 빠르게 찾고 바로 이동할 수 있는 통합 검색 모달입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트와 위젯에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트와 위젯은 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트와 위젯은 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- `AutoComplete` 기반 추천 드롭다운
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
`code/value` 데이터를 받아 실제 값은 `code`로 유지하고, 드롭다운 표시와 검색은 `value` 기준으로 처리하는 select combo 입력 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
상태 값을 간단한 UI 배지로 표현하는 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
여러 단계를 순서대로 표시하고 현재 진행 위치를 강조하는 stepper 컴포넌트입니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 구현 위치
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
부모 영역 안에서 이동 가능한 모달 스타일 윈도우를 제공합니다.
|
||||
|
||||
## 공통 설계 원칙
|
||||
|
||||
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
|
||||
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
|
||||
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
|
||||
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 헤더 작업줄 드래그 이동
|
||||
|
||||
71
docs/features/work-request-board.md
Normal file
71
docs/features/work-request-board.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 작업 요청 게시판
|
||||
|
||||
## 목적
|
||||
|
||||
작업 요청 게시판은 게시글 1건 안에 여러 하위 요청을 묶어 등록하고, 각 하위 요청이 자동화 작업으로 어떻게 접수되고 완료되는지 추적하는 화면입니다.
|
||||
|
||||
## 핵심 구조
|
||||
|
||||
- 게시글 1건은 공통 제목, 공통 메모, 공통 첨부 파일을 가집니다.
|
||||
- 게시글 안에는 하위 요청을 1건 이상 둘 수 있습니다.
|
||||
- 각 하위 요청은 별도 Plan 항목으로 등록될 수 있고, 상태도 개별로 추적됩니다.
|
||||
- 공통 메모와 첨부 파일 경로는 하위 요청별 자동화 메모에 함께 포함됩니다.
|
||||
|
||||
## 데이터 모델
|
||||
|
||||
- 게시글 테이블: `board_posts`
|
||||
- 하위 요청 테이블: `board_post_requests`
|
||||
- 주요 서버 구현:
|
||||
- 라우트: `etc/servers/work-server/src/routes/board.ts`
|
||||
- 서비스: `etc/servers/work-server/src/services/board-service.ts`
|
||||
- 주요 프런트 구현:
|
||||
- 화면: `src/features/board/BoardPage.tsx`
|
||||
- API 클라이언트: `src/features/board/api.ts`
|
||||
- 타입: `src/features/board/types.ts`
|
||||
|
||||
## 실행 방식
|
||||
|
||||
하위 요청 등록 방식은 게시글 단위로 선택합니다.
|
||||
|
||||
- `all_at_once`: 접수 가능한 하위 요청을 한 번에 모두 Plan으로 등록합니다.
|
||||
- `after_previous_finished`: 앞 요청이 성공/실패와 무관하게 종료되면 다음 요청을 등록합니다.
|
||||
- `after_previous_success`: 앞 요청이 성공으로 완료된 경우에만 다음 요청을 등록합니다. 실패하면 뒤 요청은 `blocked` 상태로 남습니다.
|
||||
|
||||
## 상태 추적
|
||||
|
||||
각 하위 요청은 아래 정보를 기준으로 상태를 계산합니다.
|
||||
|
||||
- 게시판 워크플로 상태: `pending`, `waiting`, `registered`, `completed`, `failed`, `blocked`
|
||||
- 연결된 Plan 상태: `status`, `workerStatus`, `lastError`
|
||||
|
||||
화면에서는 이를 바탕으로 다음처럼 보여줍니다.
|
||||
|
||||
- `미접수`: 아직 Plan 등록 전
|
||||
- `선행 대기`: 순차 실행에서 앞 요청 완료를 기다리는 상태
|
||||
- `대기열`: Plan 등록은 됐지만 아직 본격 처리 전
|
||||
- `진행중`: worker가 작업 중
|
||||
- `완료`: Plan 완료 반영
|
||||
- `실패`: worker 실패 또는 오류 기록 존재
|
||||
- `차단`: 성공 의존 순차 모드에서 앞 요청 실패로 후속 요청 중단
|
||||
|
||||
## 화면 동작
|
||||
|
||||
- 목록 화면에서 게시글별로 `완료 x/y`, 실패 수, 진행 수를 요약해 보여줍니다.
|
||||
- 상세 화면에서 하위 요청을 추가, 삭제, 순서 변경할 수 있습니다.
|
||||
- 자동화 접수 후에는 게시글과 하위 요청 편집이 잠기고 읽기 전용으로 전환됩니다.
|
||||
- 하위 요청별로 연결된 Plan 링크를 바로 열 수 있습니다.
|
||||
- 여러 게시글을 선택해 일괄 접수할 수 있지만, 실제 순차 흐름은 각 게시글의 실행 옵션을 따릅니다.
|
||||
|
||||
## 자동화 연동
|
||||
|
||||
- 접수 시 `receiveBoardPostAutomation()`이 하위 요청별 Plan을 생성합니다.
|
||||
- Plan worker가 완료/실패를 기록하면 `progressBoardPostAutomationByPlanResult()`가 다음 하위 요청 등록 여부를 결정합니다.
|
||||
- 레거시 호환용 `board_posts.automation_plan_item_id`, `automation_received_at`도 첫 접수 요청 기준으로 함께 동기화합니다.
|
||||
|
||||
## 검증 포인트
|
||||
|
||||
- 새 게시글 저장 시 하위 요청이 1건 이상 생성되는지 확인
|
||||
- 실행 옵션별로 다음 요청 등록 시점이 의도대로 달라지는지 확인
|
||||
- 앞 요청 실패 시 `after_previous_success` 모드에서 후속 요청이 `차단`으로 남는지 확인
|
||||
- 자동화 접수 후 편집/삭제가 막히는지 확인
|
||||
- 하위 요청별 Plan 링크가 올바른 항목으로 연결되는지 확인
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,7 +45,7 @@ prepare_runtime() {
|
||||
|
||||
start_child() {
|
||||
log "starting server process"
|
||||
node dist/server.js &
|
||||
npm run start &
|
||||
CHILD_PID=$!
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
112
etc/servers/work-server/src/config/env.js
Normal file
112
etc/servers/work-server/src/config/env.js
Normal file
@@ -0,0 +1,112 @@
|
||||
"use strict";
|
||||
var __assign = (this && this.__assign) || function () {
|
||||
__assign = Object.assign || function(t) {
|
||||
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
||||
s = arguments[i];
|
||||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
||||
t[p] = s[p];
|
||||
}
|
||||
return t;
|
||||
};
|
||||
return __assign.apply(this, arguments);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = void 0;
|
||||
exports.getEnv = getEnv;
|
||||
var node_path_1 = require("node:path");
|
||||
var dotenv_1 = require("dotenv");
|
||||
var zod_1 = require("zod");
|
||||
dotenv_1.default.config({ override: true, quiet: true });
|
||||
var envSchema = zod_1.z.object({
|
||||
PORT: zod_1.z.coerce.number().default(3100),
|
||||
APP_TIME_ZONE: zod_1.z.string().default('Asia/Seoul'),
|
||||
DB_TIME_ZONE: zod_1.z.string().default('Asia/Seoul'),
|
||||
DB_CLIENT: zod_1.z.string().default('pg'),
|
||||
DB_HOST: zod_1.z.string().default('localhost'),
|
||||
DB_PORT: zod_1.z.coerce.number().default(5432),
|
||||
DB_NAME: zod_1.z.string().default('work_db'),
|
||||
DB_USER: zod_1.z.string().default('work_user'),
|
||||
DB_PASSWORD: zod_1.z.string().default('change-me'),
|
||||
DB_SSL: zod_1.z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform(function (value) { return value === 'true'; }),
|
||||
PLAN_WORKER_ENABLED: zod_1.z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform(function (value) { return value === 'true'; }),
|
||||
PLAN_WORKER_INTERVAL_MS: zod_1.z.coerce.number().default(10000),
|
||||
PLAN_WORKER_ID: zod_1.z.string().optional(),
|
||||
PLAN_GIT_REPO_PATH: zod_1.z.string().default('/workspace/repo'),
|
||||
PLAN_MAIN_PROJECT_REPO_PATH: zod_1.z.string().optional(),
|
||||
PLAN_RELEASE_BRANCH: zod_1.z.string().default('release'),
|
||||
PLAN_MAIN_BRANCH: zod_1.z.string().default('main'),
|
||||
PLAN_GIT_USER_NAME: zod_1.z.string().default('how2ice'),
|
||||
PLAN_GIT_USER_EMAIL: zod_1.z.string().default('how2ice@naver.com'),
|
||||
PLAN_CODEX_RUNNER_PATH: zod_1.z.string().default('/workspace/repo-scripts/run-plan-codex-once.mjs'),
|
||||
PLAN_CODEX_ENABLED: zod_1.z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform(function (value) { return value === 'true'; }),
|
||||
PLAN_LOCAL_MAIN_MODE: zod_1.z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform(function (value) { return value === 'true'; }),
|
||||
PLAN_CODEX_BIN: zod_1.z.string().default('codex'),
|
||||
PLAN_CODEX_TEMPLATE_HOME: zod_1.z.string().optional(),
|
||||
PLAN_PREVIEW_BASE_URL: zod_1.z.string().optional(),
|
||||
PLAN_PREVIEW_URL_TEMPLATE: zod_1.z.string().optional(),
|
||||
IOS_NOTIFICATION_ENABLED: zod_1.z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform(function (value) { return value === 'true'; }),
|
||||
WEB_PUSH_ENABLED: zod_1.z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform(function (value) { return value === 'true'; }),
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY: zod_1.z.string().optional(),
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY: zod_1.z.string().optional(),
|
||||
WEB_PUSH_SUBJECT: zod_1.z.string().default('mailto:how2ice@naver.com'),
|
||||
APNS_KEY_ID: zod_1.z.string().optional(),
|
||||
APNS_TEAM_ID: zod_1.z.string().optional(),
|
||||
APNS_BUNDLE_ID: zod_1.z.string().optional(),
|
||||
APNS_PRIVATE_KEY: zod_1.z.string().optional(),
|
||||
APNS_PRIVATE_KEY_PATH: zod_1.z.string().optional(),
|
||||
APNS_PRODUCTION: zod_1.z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform(function (value) { return value === 'true'; }),
|
||||
SERVER_COMMAND_ACCESS_TOKEN: zod_1.z.string().default('usr_7f3a9c2d8e1b4a6f'),
|
||||
SERVER_COMMAND_API_BASE_URL: zod_1.z.string().optional(),
|
||||
SERVER_COMMAND_API_ACCESS_TOKEN: zod_1.z.string().optional(),
|
||||
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: zod_1.z.string().default('/api/server-commands/{key}/actions/restart'),
|
||||
SERVER_COMMAND_PROJECT_ROOT: zod_1.z.string().default(node_path_1.default.resolve(process.cwd(), '../../..')),
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT: zod_1.z.string().default('/workspace/main-project'),
|
||||
SERVER_COMMAND_TEST_URL: zod_1.z.string().default('https://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();
|
||||
@@ -80,6 +80,7 @@ const envSchema = z.object({
|
||||
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
||||
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),
|
||||
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
|
||||
WORK_SERVER_DIST_DIR: z.string().default('dist'),
|
||||
});
|
||||
|
||||
function parseEnv() {
|
||||
|
||||
37
etc/servers/work-server/src/db/client.js
Normal file
37
etc/servers/work-server/src/db/client.js
Normal file
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.db = void 0;
|
||||
var knex_1 = require("knex");
|
||||
var env_js_1 = require("../config/env.js");
|
||||
exports.db = (0, knex_1.default)({
|
||||
client: env_js_1.env.DB_CLIENT,
|
||||
connection: {
|
||||
host: env_js_1.env.DB_HOST,
|
||||
port: env_js_1.env.DB_PORT,
|
||||
database: env_js_1.env.DB_NAME,
|
||||
user: env_js_1.env.DB_USER,
|
||||
password: env_js_1.env.DB_PASSWORD,
|
||||
ssl: env_js_1.env.DB_SSL ? { rejectUnauthorized: false } : false,
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 10,
|
||||
afterCreate: function (connection, done) {
|
||||
var _a;
|
||||
var clientName = String((_a = env_js_1.env.DB_CLIENT) !== null && _a !== void 0 ? _a : '').toLowerCase();
|
||||
if (clientName === 'pg' || clientName === 'postgres' || clientName === 'postgresql') {
|
||||
connection.query("SET TIME ZONE '".concat(env_js_1.env.DB_TIME_ZONE, "'"), function (error) {
|
||||
done(error, connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (clientName === 'mysql' || clientName === 'mysql2') {
|
||||
connection.query('SET time_zone = "+09:00"', function (error) {
|
||||
done(error, connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
done(null, connection);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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<string, string | string[] | undefined> }) {
|
||||
const rawAppOrigin = request.headers['x-app-origin'];
|
||||
const appOrigin = Array.isArray(rawAppOrigin) ? rawAppOrigin[0] : rawAppOrigin;
|
||||
|
||||
if (appOrigin?.trim()) {
|
||||
return appOrigin.trim();
|
||||
}
|
||||
|
||||
const rawOrigin = request.headers.origin;
|
||||
const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
|
||||
return origin?.trim() ?? '';
|
||||
}
|
||||
|
||||
function getRequestAppDomain(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const rawAppDomain = request.headers['x-app-domain'];
|
||||
const appDomain = Array.isArray(rawAppDomain) ? rawAppDomain[0] : rawAppDomain;
|
||||
|
||||
if (appDomain?.trim()) {
|
||||
return appDomain.trim();
|
||||
}
|
||||
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
|
||||
if (!appOrigin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(appOrigin).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async () => {
|
||||
const config = await getAppConfig();
|
||||
app.get('/api/app-config', async (request) => {
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const config = await 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<string, unknown>);
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const appDomain = getRequestAppDomain(request);
|
||||
const savedConfig = await upsertAppConfig(config as Record<string, unknown>, appOrigin, appDomain);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
1256
etc/servers/work-server/src/routes/plan.js
Normal file
1256
etc/servers/work-server/src/routes/plan.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
239
etc/servers/work-server/src/routes/resource-manager.ts
Normal file
239
etc/servers/work-server/src/routes/resource-manager.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
copyResourceManagerItem,
|
||||
createResourceManagerDirectory,
|
||||
createResourceManagerFile,
|
||||
deleteResourceManagerItem,
|
||||
ensureResourceManagerRoot,
|
||||
getResourceManagerTree,
|
||||
listResourceManagerDirectory,
|
||||
moveResourceManagerItem,
|
||||
openResourceManagerPreviewStream,
|
||||
readResourceManagerFile,
|
||||
saveResourceManagerFile,
|
||||
uploadResourceManagerFile,
|
||||
} from '../services/resource-manager-service.js';
|
||||
|
||||
const queryPathSchema = z.object({
|
||||
path: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const createDirectoryBodySchema = z.object({
|
||||
parentPath: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().min(1).max(255),
|
||||
});
|
||||
|
||||
const createFileBodySchema = z.object({
|
||||
parentPath: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().min(1).max(255),
|
||||
content: z.string().optional().default(''),
|
||||
});
|
||||
|
||||
const saveFileBodySchema = z.object({
|
||||
path: z.string().trim().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
const uploadFileBodySchema = z.object({
|
||||
parentPath: z.string().trim().optional().default(''),
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
contentBase64: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
const copyMoveBodySchema = z.object({
|
||||
path: z.string().trim().min(1),
|
||||
targetDirectoryPath: z.string().trim().optional().default(''),
|
||||
nextName: z.string().trim().max(255).optional().nullable(),
|
||||
});
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply, tokenOverride?: string | null) {
|
||||
if ((tokenOverride ?? getRequestAccessToken(request)) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
reply.status(403);
|
||||
void reply.send({
|
||||
message: '권한 토큰이 필요합니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveRepoRootPath() {
|
||||
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
|
||||
}
|
||||
|
||||
export async function registerResourceManagerRoutes(app: FastifyInstance) {
|
||||
app.get('/api/resource-manager/tree', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRootPath = resolveRepoRootPath();
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getResourceManagerTree(repoRootPath),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/resource-manager/directory', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = queryPathSchema.parse(request.query ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await listResourceManagerDirectory(resolveRepoRootPath(), query.path),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/resource-manager/file', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = queryPathSchema.extend({
|
||||
path: z.string().trim().min(1),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await readResourceManagerFile(resolveRepoRootPath(), query.path),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/resource-manager/preview/*', async (request, reply) => {
|
||||
const query = z.object({
|
||||
token: z.string().trim().optional(),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
if (!ensureAuthorized(request, reply, query.token ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
const preview = await openResourceManagerPreviewStream(resolveRepoRootPath(), decodeURIComponent(wildcard));
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.type(preview.contentType);
|
||||
return reply.send(preview.stream);
|
||||
});
|
||||
|
||||
app.post('/api/resource-manager/directories', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = createDirectoryBodySchema.parse(request.body ?? {});
|
||||
await createResourceManagerDirectory(resolveRepoRootPath(), payload.parentPath, payload.name);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/resource-manager/files', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = createFileBodySchema.parse(request.body ?? {});
|
||||
await createResourceManagerFile(resolveRepoRootPath(), payload.parentPath, payload.name, payload.content);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/resource-manager/files/content', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = saveFileBodySchema.parse(request.body ?? {});
|
||||
await saveResourceManagerFile(resolveRepoRootPath(), payload.path, payload.content);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/resource-manager/files/upload', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = uploadFileBodySchema.parse(request.body ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await uploadResourceManagerFile(
|
||||
resolveRepoRootPath(),
|
||||
payload.parentPath,
|
||||
payload.fileName,
|
||||
payload.contentBase64,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/resource-manager/items/copy', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = copyMoveBodySchema.parse(request.body ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await copyResourceManagerItem(
|
||||
resolveRepoRootPath(),
|
||||
payload.path,
|
||||
payload.targetDirectoryPath,
|
||||
payload.nextName,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/resource-manager/items/move', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = copyMoveBodySchema.parse(request.body ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await moveResourceManagerItem(
|
||||
resolveRepoRootPath(),
|
||||
payload.path,
|
||||
payload.targetDirectoryPath,
|
||||
payload.nextName,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/resource-manager/items', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = queryPathSchema.extend({
|
||||
path: z.string().trim().min(1),
|
||||
}).parse(request.query ?? {});
|
||||
await deleteResourceManagerItem(resolveRepoRootPath(), query.path);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> | 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();
|
||||
|
||||
449
etc/servers/work-server/src/services/app-config-service.js
Normal file
449
etc/servers/work-server/src/services/app-config-service.js
Normal file
@@ -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];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/<chat-session-id>/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/<chat-session-id>/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/<chat-session-id>/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<string, unknown>,
|
||||
override: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
nextConfig: Record<string, unknown>,
|
||||
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<string, unknown>;
|
||||
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<ChatDefaultContextRecord>;
|
||||
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<string, ChatDefaultContextRecord>();
|
||||
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<string, ChatTypeDefaultContextSelection>();
|
||||
const sourceItems = Array.isArray(items) ? items : [];
|
||||
|
||||
sourceItems.forEach((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = item as Partial<ChatTypeDefaultContextSelection>;
|
||||
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<string, ChatRoomContextSettings>();
|
||||
const sourceItems = Array.isArray(items) ? items : [];
|
||||
|
||||
sourceItems.forEach((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = item as Partial<ChatRoomContextSettings>;
|
||||
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<ChatTypeRecord, 'name'>) {
|
||||
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<AppConfigSnapshot> {
|
||||
return normalizeAppConfigSnapshot(await getAppConfig());
|
||||
export async function getAppConfigSnapshot(appOrigin?: string | null): Promise<AppConfigSnapshot> {
|
||||
return normalizeAppConfigSnapshot(await getAppConfig(appOrigin));
|
||||
}
|
||||
|
||||
export async function upsertAppConfig(config: Record<string, unknown>) {
|
||||
export async function upsertAppConfig(
|
||||
config: Record<string, unknown>,
|
||||
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<string, unknown>) {
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"use strict";
|
||||
var __assign = (this && this.__assign) || function () {
|
||||
__assign = Object.assign || function(t) {
|
||||
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
||||
s = arguments[i];
|
||||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
||||
t[p] = s[p];
|
||||
}
|
||||
return t;
|
||||
};
|
||||
return __assign.apply(this, arguments);
|
||||
};
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
||||
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
||||
if (ar || !(i in from)) {
|
||||
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
||||
ar[i] = from[i];
|
||||
}
|
||||
}
|
||||
return to.concat(ar || Array.prototype.slice.call(from));
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_AUTOMATION_CONTEXTS = void 0;
|
||||
exports.sanitizeAutomationContexts = sanitizeAutomationContexts;
|
||||
exports.getAutomationContextsConfig = getAutomationContextsConfig;
|
||||
exports.upsertAutomationContextsConfig = upsertAutomationContextsConfig;
|
||||
exports.normalizeAutomationContextSelection = normalizeAutomationContextSelection;
|
||||
exports.resolveAutomationContexts = resolveAutomationContexts;
|
||||
var client_js_1 = require("../db/client.js");
|
||||
var AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
|
||||
exports.DEFAULT_AUTOMATION_CONTEXTS = [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content: '## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content: '## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
function normalizeText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
function normalizeEnabled(value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
var normalizedValue = value.trim().toLowerCase();
|
||||
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return value !== false;
|
||||
}
|
||||
function buildContextTitleKey(value) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
function compareContextUpdatedAt(left, right) {
|
||||
var leftTime = Date.parse(left.updatedAt);
|
||||
var rightTime = Date.parse(right.updatedAt);
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function normalizeAutomationContext(record) {
|
||||
var title = normalizeText(record.title);
|
||||
var content = normalizeText(record.content);
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
var rawId = normalizeText(record.id);
|
||||
var normalizedId = rawId || "automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: title || 'Context',
|
||||
content: content,
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
defaultSelected: normalizeEnabled(record.defaultSelected),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
function sanitizeAutomationContexts(items) {
|
||||
var byId = new Map();
|
||||
var bySemanticKey = new Map();
|
||||
(items !== null && items !== void 0 ? items : [])
|
||||
.map(function (item) { return normalizeAutomationContext(item); })
|
||||
.filter(function (item) { return Boolean(item); })
|
||||
.forEach(function (item) {
|
||||
var currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
|
||||
var item = _a[_i];
|
||||
var semanticKey = buildContextTitleKey(item.title);
|
||||
var current = bySemanticKey.get(semanticKey);
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
var values = Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); });
|
||||
return values.length > 0 ? values : exports.DEFAULT_AUTOMATION_CONTEXTS;
|
||||
}
|
||||
function ensureAutomationContextsTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var hasTable, requiredColumns, _loop_1, _i, requiredColumns_1, _a, columnName, createColumn;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE)];
|
||||
case 1:
|
||||
hasTable = _b.sent();
|
||||
if (!!hasTable) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, function (table) {
|
||||
table.string('id').primary();
|
||||
table.string('title').notNullable();
|
||||
table.text('content').notNullable().defaultTo('');
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.boolean('default_selected').notNullable().defaultTo(false);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now());
|
||||
})];
|
||||
case 2:
|
||||
_b.sent();
|
||||
return [2 /*return*/];
|
||||
case 3:
|
||||
requiredColumns = [
|
||||
['title', function (table) { return table.string('title').notNullable().defaultTo(''); }],
|
||||
['content', function (table) { return table.text('content').notNullable().defaultTo(''); }],
|
||||
['enabled', function (table) { return table.boolean('enabled').notNullable().defaultTo(true); }],
|
||||
['default_selected', function (table) { return table.boolean('default_selected').notNullable().defaultTo(false); }],
|
||||
['updated_at', function (table) { return table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(client_js_1.db.fn.now()); }],
|
||||
];
|
||||
_loop_1 = function (columnName, createColumn) {
|
||||
var hasColumn;
|
||||
return __generator(this, function (_c) {
|
||||
switch (_c.label) {
|
||||
case 0: return [4 /*yield*/, client_js_1.db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName)];
|
||||
case 1:
|
||||
hasColumn = _c.sent();
|
||||
if (!!hasColumn) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, client_js_1.db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, function (table) {
|
||||
createColumn(table);
|
||||
})];
|
||||
case 2:
|
||||
_c.sent();
|
||||
_c.label = 3;
|
||||
case 3: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
};
|
||||
_i = 0, requiredColumns_1 = requiredColumns;
|
||||
_b.label = 4;
|
||||
case 4:
|
||||
if (!(_i < requiredColumns_1.length)) return [3 /*break*/, 7];
|
||||
_a = requiredColumns_1[_i], columnName = _a[0], createColumn = _a[1];
|
||||
return [5 /*yield**/, _loop_1(columnName, createColumn)];
|
||||
case 5:
|
||||
_b.sent();
|
||||
_b.label = 6;
|
||||
case 6:
|
||||
_i++;
|
||||
return [3 /*break*/, 4];
|
||||
case 7: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function parseContextsFromLegacyValue(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
catch (_a) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function toAutomationContextRecord(row) {
|
||||
return normalizeAutomationContext({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
title: typeof row.title === 'string' ? row.title : undefined,
|
||||
content: typeof row.content === 'string' ? row.content : undefined,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
defaultSelected: normalizeEnabled(row.default_selected),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
function replaceAutomationContextsInTable(items) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var nextItems;
|
||||
var _this = this;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ensureAutomationContextsTable()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
nextItems = sanitizeAutomationContexts(items);
|
||||
return [4 /*yield*/, client_js_1.db.transaction(function (trx) { return __awaiter(_this, void 0, void 0, function () {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, trx(AUTOMATION_CONTEXTS_TABLE).del()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, trx(AUTOMATION_CONTEXTS_TABLE).insert(nextItems.map(function (item) { return ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
enabled: item.enabled,
|
||||
default_selected: item.defaultSelected,
|
||||
updated_at: item.updatedAt,
|
||||
}); }))];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
}); })];
|
||||
case 2:
|
||||
_a.sent();
|
||||
return [2 /*return*/, nextItems];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function seedAutomationContextsFromLegacySources() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var seededItems, hasAutomationTypesTable, rows, _i, rows_1, row;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
seededItems = __spreadArray([], exports.DEFAULT_AUTOMATION_CONTEXTS, true);
|
||||
return [4 /*yield*/, client_js_1.db.schema.hasTable('automation_types')];
|
||||
case 1:
|
||||
hasAutomationTypesTable = _a.sent();
|
||||
if (!hasAutomationTypesTable) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, (0, client_js_1.db)('automation_types').select('contexts_json')];
|
||||
case 2:
|
||||
rows = _a.sent();
|
||||
for (_i = 0, rows_1 = rows; _i < rows_1.length; _i++) {
|
||||
row = rows_1[_i];
|
||||
seededItems.push.apply(seededItems, parseContextsFromLegacyValue(row.contexts_json));
|
||||
}
|
||||
_a.label = 3;
|
||||
case 3: return [2 /*return*/, replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems))];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function isSameAutomationContextList(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
return left.every(function (item, index) {
|
||||
var target = right[index];
|
||||
return (target &&
|
||||
item.id === target.id &&
|
||||
item.title === target.title &&
|
||||
item.content === target.content &&
|
||||
item.enabled === target.enabled &&
|
||||
item.defaultSelected === target.defaultSelected &&
|
||||
item.updatedAt === target.updatedAt);
|
||||
});
|
||||
}
|
||||
function mergeDefaultAutomationContexts(items) {
|
||||
var byId = new Map(items.map(function (item) { return [item.id, item]; }));
|
||||
for (var _i = 0, DEFAULT_AUTOMATION_CONTEXTS_1 = exports.DEFAULT_AUTOMATION_CONTEXTS; _i < DEFAULT_AUTOMATION_CONTEXTS_1.length; _i++) {
|
||||
var defaultItem = DEFAULT_AUTOMATION_CONTEXTS_1[_i];
|
||||
var existingItem = byId.get(defaultItem.id);
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
byId.set(defaultItem.id, __assign(__assign({}, existingItem), { title: defaultItem.title, content: existingItem.content || defaultItem.content }));
|
||||
}
|
||||
return sanitizeAutomationContexts(Array.from(byId.values()));
|
||||
}
|
||||
function readAutomationContextsFromTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var rows, savedItems;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, ensureAutomationContextsTable()];
|
||||
case 1:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, (0, client_js_1.db)(AUTOMATION_CONTEXTS_TABLE)
|
||||
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
|
||||
.orderBy('title', 'asc')];
|
||||
case 2:
|
||||
rows = _a.sent();
|
||||
savedItems = rows
|
||||
.map(function (row) { return toAutomationContextRecord(row); })
|
||||
.filter(function (item) { return Boolean(item); });
|
||||
return [2 /*return*/, sanitizeAutomationContexts(savedItems)];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function getAutomationContextsConfig() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var savedContexts, mergedContexts;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0: return [4 /*yield*/, readAutomationContextsFromTable()];
|
||||
case 1:
|
||||
savedContexts = _a.sent();
|
||||
if (savedContexts.length === 0 || savedContexts === exports.DEFAULT_AUTOMATION_CONTEXTS) {
|
||||
return [2 /*return*/, seedAutomationContextsFromLegacySources()];
|
||||
}
|
||||
mergedContexts = mergeDefaultAutomationContexts(savedContexts);
|
||||
if (!!isSameAutomationContextList(savedContexts, mergedContexts)) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, replaceAutomationContextsInTable(mergedContexts)];
|
||||
case 2:
|
||||
_a.sent();
|
||||
_a.label = 3;
|
||||
case 3: return [2 /*return*/, mergedContexts];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function upsertAutomationContextsConfig(items) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var nextContexts;
|
||||
return __generator(this, function (_a) {
|
||||
nextContexts = mergeDefaultAutomationContexts(sanitizeAutomationContexts(Array.isArray(items) ? items : []));
|
||||
return [2 /*return*/, replaceAutomationContextsInTable(nextContexts)];
|
||||
});
|
||||
});
|
||||
}
|
||||
function normalizeAutomationContextSelection(value) {
|
||||
var rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map(function (item) { return item.trim(); })
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return __spreadArray([], new Set(rawValues.map(function (item) { return normalizeText(String(item)); }).filter(Boolean)), true);
|
||||
}
|
||||
function resolveAutomationContexts(contexts, selectedContextIds) {
|
||||
var normalizedContexts = sanitizeAutomationContexts(contexts);
|
||||
var requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
||||
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
||||
return [];
|
||||
}
|
||||
if (requestedIds.length === 0) {
|
||||
return normalizedContexts.filter(function (item) { return item.enabled && item.defaultSelected; });
|
||||
}
|
||||
var requestedIdSet = new Set(requestedIds);
|
||||
return normalizedContexts.filter(function (item) { return requestedIdSet.has(item.id); });
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use strict";
|
||||
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
||||
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
||||
function verb(n) { return function (v) { return step([n, v]); }; }
|
||||
function step(op) {
|
||||
if (f) throw new TypeError("Generator is already executing.");
|
||||
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
||||
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
||||
if (y = 0, t) op = [op[0] & 2, t.value];
|
||||
switch (op[0]) {
|
||||
case 0: case 1: t = op; break;
|
||||
case 4: _.label++; return { value: op[1], done: false };
|
||||
case 5: _.label++; y = op[1]; op = [0]; continue;
|
||||
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
||||
default:
|
||||
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
||||
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
||||
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
||||
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
||||
if (t[2]) _.ops.pop();
|
||||
_.trys.pop(); continue;
|
||||
}
|
||||
op = body.call(thisArg, _);
|
||||
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
||||
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
||||
}
|
||||
};
|
||||
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
||||
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
|
||||
if (ar || !(i in from)) {
|
||||
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
|
||||
ar[i] = from[i];
|
||||
}
|
||||
}
|
||||
return to.concat(ar || Array.prototype.slice.call(from));
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.stringifyAutomationContextIds = stringifyAutomationContextIds;
|
||||
exports.parseAutomationContextIds = parseAutomationContextIds;
|
||||
exports.buildAutomationContextMarkdown = buildAutomationContextMarkdown;
|
||||
exports.buildAutomationNoteSections = buildAutomationNoteSections;
|
||||
exports.ensureSchedulePromptSnapshot = ensureSchedulePromptSnapshot;
|
||||
var node_path_1 = require("node:path");
|
||||
var promises_1 = require("node:fs/promises");
|
||||
var env_js_1 = require("../config/env.js");
|
||||
var automation_context_config_service_js_1 = require("./automation-context-config-service.js");
|
||||
function stringifyAutomationContextIds(value) {
|
||||
return JSON.stringify((0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(value));
|
||||
}
|
||||
function parseAutomationContextIds(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(value);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
var trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(trimmed);
|
||||
return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(parsed);
|
||||
}
|
||||
catch (_a) {
|
||||
return (0, automation_context_config_service_js_1.normalizeAutomationContextSelection)(trimmed);
|
||||
}
|
||||
}
|
||||
function buildAutomationContextMarkdown(contexts, selectedContextIds) {
|
||||
var resolvedContexts = (0, automation_context_config_service_js_1.resolveAutomationContexts)(contexts, selectedContextIds);
|
||||
if (resolvedContexts.length === 0) {
|
||||
return '선택된 자동화 Context 없음';
|
||||
}
|
||||
return resolvedContexts
|
||||
.map(function (item) { return ["### ".concat(item.title), item.content.trim() || '(내용 없음)'].join('\n'); })
|
||||
.join('\n\n');
|
||||
}
|
||||
function buildAutomationNoteSections(options) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var availableContexts, _a, lines;
|
||||
var _b, _c, _d, _e, _f, _g;
|
||||
return __generator(this, function (_h) {
|
||||
switch (_h.label) {
|
||||
case 0:
|
||||
if (!((_b = options.availableContexts) !== null && _b !== void 0)) return [3 /*break*/, 1];
|
||||
_a = _b;
|
||||
return [3 /*break*/, 3];
|
||||
case 1: return [4 /*yield*/, (0, automation_context_config_service_js_1.getAutomationContextsConfig)()];
|
||||
case 2:
|
||||
_a = (_h.sent());
|
||||
_h.label = 3;
|
||||
case 3:
|
||||
availableContexts = _a;
|
||||
lines = __spreadArray(__spreadArray(__spreadArray([
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
((_c = options.title) === null || _c === void 0 ? void 0 : _c.trim()) ? "- \uAC8C\uC2DC\uD310 \uC81C\uBAA9: ".concat(options.title.trim()) : null,
|
||||
"- \uBA54\uBAA8 \uCD9C\uCC98: ".concat(options.sourceLabel),
|
||||
((_e = (_d = options.automationType) === null || _d === void 0 ? void 0 : _d.name) === null || _e === void 0 ? void 0 : _e.trim()) ? "- \uC120\uD0DD \uC790\uB3D9\uD654 \uC720\uD615: ".concat(options.automationType.name.trim()) : null,
|
||||
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.',
|
||||
'',
|
||||
'## 자동화 Context',
|
||||
buildAutomationContextMarkdown(availableContexts, options.selectedContextIds),
|
||||
''
|
||||
], ((_f = options.extraSections) !== null && _f !== void 0 ? _f : []), true), [
|
||||
'## 요청 본문',
|
||||
options.requestContent.trim()
|
||||
], false), (((_g = options.attachments) === null || _g === void 0 ? void 0 : _g.length) ? __spreadArray(['', '## 첨부 파일'], options.attachments, true) : []), true);
|
||||
return [2 /*return*/, lines.filter(function (line) { return line !== null && line !== undefined; }).join('\n')];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function extractRequestedPaths(note) {
|
||||
var _a;
|
||||
var matches = (_a = note.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g)) !== null && _a !== void 0 ? _a : [];
|
||||
return __spreadArray([], new Set(matches.map(function (item) { return item.replace(/\\/g, '/'); })), true);
|
||||
}
|
||||
function tryReadFile(filePath) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var _a;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0:
|
||||
_b.trys.push([0, 2, , 3]);
|
||||
return [4 /*yield*/, (0, promises_1.readFile)(filePath, 'utf8')];
|
||||
case 1: return [2 /*return*/, _b.sent()];
|
||||
case 2:
|
||||
_a = _b.sent();
|
||||
return [2 /*return*/, null];
|
||||
case 3: return [2 /*return*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function limitText(value, maxChars) {
|
||||
if (maxChars === void 0) { maxChars = 12000; }
|
||||
var normalized = value.trim();
|
||||
return normalized.length <= maxChars ? normalized : "".concat(normalized.slice(0, maxChars).trimEnd(), "\n\n...");
|
||||
}
|
||||
function getScheduleRepoRoot() {
|
||||
var env = (0, env_js_1.getEnv)();
|
||||
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
|
||||
}
|
||||
function ensureSchedulePromptSnapshot(options) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var repoRoot, scheduleDir, requestPath, contextPath, manifestPath, requestedPaths, candidatePaths, uniqueRelativePaths, references, _i, uniqueRelativePaths_1, relativePath, absolutePath, content, requestMarkdown, contextMarkdown, relativeDir;
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
repoRoot = getScheduleRepoRoot();
|
||||
scheduleDir = node_path_1.default.join(repoRoot, '.auto_codex', 'schedule', String(options.scheduleId));
|
||||
return [4 /*yield*/, (0, promises_1.mkdir)(scheduleDir, { recursive: true })];
|
||||
case 1:
|
||||
_a.sent();
|
||||
requestPath = node_path_1.default.join(scheduleDir, 'request.md');
|
||||
contextPath = node_path_1.default.join(scheduleDir, 'context.md');
|
||||
manifestPath = node_path_1.default.join(scheduleDir, 'manifest.json');
|
||||
requestedPaths = extractRequestedPaths(options.note);
|
||||
candidatePaths = __spreadArray([
|
||||
'AGENTS.md',
|
||||
'docs/README.md'
|
||||
], requestedPaths.filter(function (item) { return !item.startsWith('http://') && !item.startsWith('https://'); }), true);
|
||||
uniqueRelativePaths = __spreadArray([], new Set(candidatePaths), true);
|
||||
references = [];
|
||||
_i = 0, uniqueRelativePaths_1 = uniqueRelativePaths;
|
||||
_a.label = 2;
|
||||
case 2:
|
||||
if (!(_i < uniqueRelativePaths_1.length)) return [3 /*break*/, 5];
|
||||
relativePath = uniqueRelativePaths_1[_i];
|
||||
absolutePath = node_path_1.default.resolve(repoRoot, relativePath);
|
||||
return [4 /*yield*/, tryReadFile(absolutePath)];
|
||||
case 3:
|
||||
content = _a.sent();
|
||||
if (!content) {
|
||||
return [3 /*break*/, 4];
|
||||
}
|
||||
references.push("## ".concat(relativePath, "\n\n```\n").concat(limitText(content), "\n```"));
|
||||
_a.label = 4;
|
||||
case 4:
|
||||
_i++;
|
||||
return [3 /*break*/, 2];
|
||||
case 5:
|
||||
requestMarkdown = [
|
||||
'# 스케줄 요청 원문',
|
||||
'',
|
||||
"- \uC2A4\uCF00\uC904 ID: ".concat(options.scheduleId),
|
||||
"- \uC791\uC5C5 ID: ".concat(options.workId),
|
||||
'',
|
||||
'## 원본 메모',
|
||||
options.note.trim() || '(비어 있음)',
|
||||
].join('\n');
|
||||
contextMarkdown = __spreadArray([
|
||||
'# 스케줄 전용 참조',
|
||||
'',
|
||||
'- 최초 활성화 시점에 읽은 요청/문서/소스 일부를 이 디렉터리 아래로 정리했습니다.',
|
||||
'- 이후 자동화 실행은 우선 이 디렉터리의 Markdown 문서를 참조하고, 원본 소스 재탐색은 꼭 필요할 때만 제한적으로 수행합니다.',
|
||||
''
|
||||
], (references.length > 0 ? references : ['## 참조 문서', '별도로 추출된 문서가 없습니다. request.md를 우선 참조합니다.']), true).join('\n\n');
|
||||
return [4 /*yield*/, (0, promises_1.writeFile)(requestPath, "".concat(requestMarkdown, "\n"), 'utf8')];
|
||||
case 6:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, (0, promises_1.writeFile)(contextPath, "".concat(contextMarkdown, "\n"), 'utf8')];
|
||||
case 7:
|
||||
_a.sent();
|
||||
return [4 /*yield*/, (0, promises_1.writeFile)(manifestPath, JSON.stringify({
|
||||
scheduleId: options.scheduleId,
|
||||
workId: options.workId,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
forceRefresh: Boolean(options.forceRefresh),
|
||||
sourcePaths: uniqueRelativePaths,
|
||||
}, null, 2), 'utf8')];
|
||||
case 8:
|
||||
_a.sent();
|
||||
relativeDir = node_path_1.default.relative(repoRoot, scheduleDir).replace(/\\/g, '/');
|
||||
return [2 /*return*/, {
|
||||
directory: relativeDir,
|
||||
requestPath: "".concat(relativeDir, "/request.md"),
|
||||
contextPath: "".concat(relativeDir, "/context.md"),
|
||||
manifestPath: "".concat(relativeDir, "/manifest.json"),
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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); });
|
||||
}
|
||||
1261
etc/servers/work-server/src/services/board-service.js
Normal file
1261
etc/servers/work-server/src/services/board-service.js
Normal file
File diff suppressed because it is too large
Load Diff
872
etc/servers/work-server/src/services/board-service.ts
Executable file → Normal file
872
etc/servers/work-server/src/services/board-service.ts
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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<string, { text: string; createdAt: string | null }>();
|
||||
const requestRowsBySession = new Map<string, Array<{ text: string; createdAt: string | null }>>();
|
||||
const completedSessionIds = new Set<string>();
|
||||
|
||||
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<string, { text: string; createdAt: string | null }>();
|
||||
}
|
||||
|
||||
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<string, { text: string; createdAt: string | null }>();
|
||||
|
||||
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<typeof conversatio
|
||||
title: parsed.title?.trim() || '새 대화',
|
||||
chat_type_id: parsed.chatTypeId?.trim() || null,
|
||||
last_chat_type_id: parsed.lastChatTypeId?.trim() || parsed.chatTypeId?.trim() || null,
|
||||
general_section_name: parsed.generalSectionName?.trim() || null,
|
||||
context_label: parsed.contextLabel?.trim() || null,
|
||||
context_description: parsed.contextDescription?.trim() || null,
|
||||
notify_offline: notifyOffline,
|
||||
@@ -1064,6 +1241,7 @@ export async function updateChatConversationContext(
|
||||
clientId?: string | null;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
generalSectionName?: string | null;
|
||||
contextLabel?: string | null;
|
||||
contextDescription?: string | null;
|
||||
notifyOffline?: boolean | null;
|
||||
@@ -1089,6 +1267,7 @@ export async function updateChatConversationContext(
|
||||
client_id: normalizedClientId || current.client_id || null,
|
||||
chat_type_id: nextChatTypeId,
|
||||
last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null,
|
||||
general_section_name: resolveNextConversationContextValue(current.general_section_name, payload.generalSectionName),
|
||||
context_label: resolveNextConversationContextValue(current.context_label, requestedContextLabel),
|
||||
context_description: resolveNextConversationContextValue(current.context_description, requestedContextDescription),
|
||||
notify_offline:
|
||||
@@ -1235,12 +1414,33 @@ export async function listChatConversations(
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length > 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<RecoverableChatConversationRequestItem[]> {
|
||||
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: {
|
||||
|
||||
@@ -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가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
|
||||
'',
|
||||
'<!-- codex-live:auto:start -->',
|
||||
'## 자동 갱신 문맥',
|
||||
'- 오래된 본문',
|
||||
'<!-- codex-live:auto:end -->',
|
||||
"이전 응답 조각'; @@ +export async function ensureChatSessionReferenceResource(args: { ... }) {",
|
||||
'<!-- codex-live:auto:end -->',
|
||||
'',
|
||||
'## 수동 메모',
|
||||
'- 유지 메모',
|
||||
'',
|
||||
].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(/<!-- codex-live:auto:start -->/g) ?? []).length, 1);
|
||||
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:end -->/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');
|
||||
|
||||
@@ -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 = '<!-- codex-live:auto:start -->';
|
||||
const CHAT_SESSION_REFERENCE_AUTO_END = '<!-- codex-live: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<string, string>();
|
||||
|
||||
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<string, typeof recoverableRequests>();
|
||||
|
||||
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<ChatContext> | 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<typeof setInterval> | 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<typeof setTimeout> | 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),
|
||||
);
|
||||
|
||||
37
etc/servers/work-server/src/services/chat-type-defaults.js
Normal file
37
etc/servers/work-server/src/services/chat-type-defaults.js
Normal file
@@ -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/<chat-session-id>/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/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
47
etc/servers/work-server/src/services/chat-type-defaults.ts
Normal file
47
etc/servers/work-server/src/services/chat-type-defaults.ts
Normal file
@@ -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/<chat-session-id>/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/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
@@ -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 = '<!-- error-log-plan-work-id:';
|
||||
function normalizeDateBoundary(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
var date = value instanceof Date ? value : new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
function formatIsoTimestamp(value) {
|
||||
var date = normalizeDateBoundary(value);
|
||||
return date ? date.toISOString() : String(value !== null && value !== void 0 ? value : '');
|
||||
}
|
||||
function normalizeRequestPathGroup(requestPath) {
|
||||
var normalized = String(requestPath !== null && requestPath !== void 0 ? requestPath : '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\?.*$/u, '')
|
||||
.replace(/\/\d+(?=\/|$)/gu, '/:id')
|
||||
.replace(/[0-9a-f]{8,}(?=\/|$)/giu, ':id');
|
||||
if (!normalized) {
|
||||
return 'unknown';
|
||||
}
|
||||
var segments = normalized
|
||||
.split('/')
|
||||
.map(function (segment) { return segment.trim(); })
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
return segments.length > 0 ? "/".concat(segments.join('/')) : normalized;
|
||||
}
|
||||
function buildErrorLogPlanFingerprint(log) {
|
||||
var _a, _b;
|
||||
return (0, node_crypto_1.createHash)('sha1')
|
||||
.update([
|
||||
String((_a = log.source) !== null && _a !== void 0 ? _a : '').trim().toLowerCase(),
|
||||
String((_b = log.errorType) !== null && _b !== void 0 ? _b : '').trim().toLowerCase(),
|
||||
log.statusCode == null ? '' : String(log.statusCode),
|
||||
].join('||'))
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
}
|
||||
function buildErrorLogPlanWorkId(log) {
|
||||
return "error-fix-".concat(buildErrorLogPlanFingerprint(log));
|
||||
}
|
||||
function buildErrorLogPlanCandidates(logs) {
|
||||
var _a, _b, _c, _d, _e, _f;
|
||||
var grouped = new Map();
|
||||
for (var _i = 0, logs_1 = logs; _i < logs_1.length; _i++) {
|
||||
var log = logs_1[_i];
|
||||
var fingerprint = (0, node_crypto_1.createHash)('sha1')
|
||||
.update([
|
||||
String((_a = log.source) !== null && _a !== void 0 ? _a : '').trim().toLowerCase(),
|
||||
String((_b = log.errorType) !== null && _b !== void 0 ? _b : '').trim().toLowerCase(),
|
||||
String((_c = log.errorName) !== null && _c !== void 0 ? _c : '').trim().toLowerCase(),
|
||||
log.statusCode == null ? '' : String(log.statusCode),
|
||||
normalizeRequestPathGroup(log.requestPath),
|
||||
].join('||'))
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
var createdAtMs = (_e = (_d = normalizeDateBoundary(log.createdAt)) === null || _d === void 0 ? void 0 : _d.getTime()) !== null && _e !== void 0 ? _e : 0;
|
||||
var existing = grouped.get(fingerprint);
|
||||
var requestPathGroup = normalizeRequestPathGroup(log.requestPath);
|
||||
var errorMessage = String((_f = log.errorMessage) !== null && _f !== void 0 ? _f : '').trim();
|
||||
if (!existing) {
|
||||
grouped.set(fingerprint, {
|
||||
sample: log,
|
||||
count: 1,
|
||||
firstCreatedAt: log.createdAt,
|
||||
firstTimeMs: createdAtMs,
|
||||
lastCreatedAt: log.createdAt,
|
||||
lastTimeMs: createdAtMs,
|
||||
requestPathGroup: requestPathGroup,
|
||||
requestPaths: log.requestPath ? new Set([String(log.requestPath).trim()]) : new Set(),
|
||||
errorMessages: errorMessage ? new Set([errorMessage]) : new Set(),
|
||||
errorNames: log.errorName ? new Set([String(log.errorName).trim()]) : new Set(),
|
||||
sampleLogIds: log.id != null ? [Number(log.id)] : [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.count += 1;
|
||||
if (log.requestPath) {
|
||||
existing.requestPaths.add(String(log.requestPath).trim());
|
||||
}
|
||||
if (errorMessage) {
|
||||
existing.errorMessages.add(errorMessage);
|
||||
}
|
||||
if (log.errorName) {
|
||||
existing.errorNames.add(String(log.errorName).trim());
|
||||
}
|
||||
if (log.id != null && existing.sampleLogIds.length < 5) {
|
||||
existing.sampleLogIds.push(Number(log.id));
|
||||
}
|
||||
if (createdAtMs <= existing.firstTimeMs) {
|
||||
existing.firstCreatedAt = log.createdAt;
|
||||
existing.firstTimeMs = createdAtMs;
|
||||
}
|
||||
if (createdAtMs >= existing.lastTimeMs) {
|
||||
existing.sample = log;
|
||||
existing.lastCreatedAt = log.createdAt;
|
||||
existing.lastTimeMs = createdAtMs;
|
||||
}
|
||||
}
|
||||
return __spreadArray([], grouped.entries(), true).map(function (_a) {
|
||||
var _b, _c, _d;
|
||||
var fingerprint = _a[0], entry = _a[1];
|
||||
return ({
|
||||
fingerprint: fingerprint,
|
||||
workId: buildErrorLogPlanWorkId(entry.sample),
|
||||
source: String((_b = entry.sample.source) !== null && _b !== void 0 ? _b : ''),
|
||||
sourceLabel: entry.sample.sourceLabel ? String(entry.sample.sourceLabel) : null,
|
||||
errorType: String((_c = entry.sample.errorType) !== null && _c !== void 0 ? _c : ''),
|
||||
errorName: entry.sample.errorName ? String(entry.sample.errorName) : null,
|
||||
errorMessage: String((_d = entry.sample.errorMessage) !== null && _d !== void 0 ? _d : ''),
|
||||
requestPath: entry.sample.requestPath ? String(entry.sample.requestPath) : null,
|
||||
requestPathGroup: entry.requestPathGroup,
|
||||
requestPaths: __spreadArray([], entry.requestPaths, true).filter(Boolean).slice(0, 5),
|
||||
statusCode: entry.sample.statusCode == null ? null : Number(entry.sample.statusCode),
|
||||
count: entry.count,
|
||||
firstCreatedAt: entry.firstCreatedAt,
|
||||
lastCreatedAt: entry.lastCreatedAt,
|
||||
sampleLogId: entry.sample.id == null ? null : Number(entry.sample.id),
|
||||
sampleLogIds: entry.sampleLogIds,
|
||||
errorNames: __spreadArray([], entry.errorNames, true).filter(Boolean).slice(0, 5),
|
||||
representativeMessages: __spreadArray([], entry.errorMessages, true).filter(Boolean).slice(0, 5),
|
||||
});
|
||||
})
|
||||
.sort(function (left, right) {
|
||||
var _a, _b, _c, _d, _e, _f;
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
var leftLastTime = (_b = (_a = normalizeDateBoundary(left.lastCreatedAt)) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : 0;
|
||||
var rightLastTime = (_d = (_c = normalizeDateBoundary(right.lastCreatedAt)) === null || _c === void 0 ? void 0 : _c.getTime()) !== null && _d !== void 0 ? _d : 0;
|
||||
if (rightLastTime !== leftLastTime) {
|
||||
return rightLastTime - leftLastTime;
|
||||
}
|
||||
return Number((_e = right.sampleLogId) !== null && _e !== void 0 ? _e : 0) - Number((_f = left.sampleLogId) !== null && _f !== void 0 ? _f : 0);
|
||||
});
|
||||
}
|
||||
function mergeErrorLogPlanCandidateBucket(bucket, bucketIndex) {
|
||||
var sortedBucket = __spreadArray([], bucket, true).sort(function (left, right) {
|
||||
var _a, _b;
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
return String((_a = left.workId) !== null && _a !== void 0 ? _a : '').localeCompare(String((_b = right.workId) !== null && _b !== void 0 ? _b : ''));
|
||||
});
|
||||
var representative = sortedBucket[0];
|
||||
var uniqueFingerprints = __spreadArray([], new Set(sortedBucket.map(function (candidate) { return candidate.fingerprint; }).filter(Boolean)), true).sort();
|
||||
var mergedFingerprint = (0, node_crypto_1.createHash)('sha1')
|
||||
.update(uniqueFingerprints.join('||'))
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
var firstCreatedAt = sortedBucket
|
||||
.map(function (candidate) { var _a, _b; return (_b = (_a = normalizeDateBoundary(candidate.firstCreatedAt)) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Number.POSITIVE_INFINITY; })
|
||||
.reduce(function (min, value) { return Math.min(min, value); }, Number.POSITIVE_INFINITY);
|
||||
var lastCreatedAt = sortedBucket
|
||||
.map(function (candidate) { var _a, _b; return (_b = (_a = normalizeDateBoundary(candidate.lastCreatedAt)) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : 0; })
|
||||
.reduce(function (max, value) { return Math.max(max, value); }, 0);
|
||||
var requestPaths = __spreadArray([], new Set(sortedBucket.flatMap(function (candidate) { var _a; return (_a = candidate.requestPaths) !== null && _a !== void 0 ? _a : []; }).filter(Boolean)), true).slice(0, 8);
|
||||
var representativeMessages = __spreadArray([], new Set(sortedBucket.flatMap(function (candidate) { var _a; return (_a = candidate.representativeMessages) !== null && _a !== void 0 ? _a : []; }).filter(Boolean)), true).slice(0, 8);
|
||||
var errorNames = __spreadArray([], new Set(sortedBucket.flatMap(function (candidate) { var _a; return (_a = candidate.errorNames) !== null && _a !== void 0 ? _a : []; }).filter(Boolean)), true).slice(0, 8);
|
||||
var groupedScopes = sortedBucket
|
||||
.slice(0, 8)
|
||||
.map(function (candidate) {
|
||||
var parts = [candidate.sourceLabel || candidate.source, candidate.errorType];
|
||||
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
||||
parts.push(candidate.requestPathGroup);
|
||||
}
|
||||
return parts.filter(Boolean).join(' / ');
|
||||
})
|
||||
.filter(Boolean);
|
||||
return {
|
||||
fingerprint: mergedFingerprint,
|
||||
workId: "error-fix-bundle-".concat(mergedFingerprint),
|
||||
source: representative.source,
|
||||
sourceLabel: representative.sourceLabel,
|
||||
errorType: sortedBucket.length > 1 ? '다중 에러 묶음' : representative.errorType,
|
||||
errorName: representative.errorName,
|
||||
errorMessage: representative.errorMessage,
|
||||
requestPath: representative.requestPath,
|
||||
requestPathGroup: representative.requestPathGroup,
|
||||
requestPaths: requestPaths,
|
||||
statusCode: representative.statusCode,
|
||||
count: sortedBucket.reduce(function (sum, candidate) { var _a; return sum + Number((_a = candidate.count) !== null && _a !== void 0 ? _a : 0); }, 0),
|
||||
firstCreatedAt: Number.isFinite(firstCreatedAt) ? new Date(firstCreatedAt).toISOString() : representative.firstCreatedAt,
|
||||
lastCreatedAt: lastCreatedAt > 0 ? new Date(lastCreatedAt).toISOString() : representative.lastCreatedAt,
|
||||
sampleLogId: representative.sampleLogId,
|
||||
sampleLogIds: __spreadArray([], new Set(sortedBucket.flatMap(function (candidate) { var _a; return (_a = candidate.sampleLogIds) !== null && _a !== void 0 ? _a : []; }).filter(function (value) { return value != null; })), true).slice(0, 8),
|
||||
errorNames: errorNames,
|
||||
representativeMessages: representativeMessages,
|
||||
groupedScopes: groupedScopes,
|
||||
groupedCandidateCount: sortedBucket.length,
|
||||
};
|
||||
}
|
||||
function coalesceErrorLogPlanCandidates(candidates, maxGroups) {
|
||||
if (maxGroups === void 0) { maxGroups = DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT; }
|
||||
var sortedCandidates = __spreadArray([], candidates, true).sort(function (left, right) {
|
||||
var _a, _b;
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
return String((_a = left.workId) !== null && _a !== void 0 ? _a : '').localeCompare(String((_b = right.workId) !== null && _b !== void 0 ? _b : ''));
|
||||
});
|
||||
if (sortedCandidates.length <= maxGroups) {
|
||||
return sortedCandidates;
|
||||
}
|
||||
var bucketSize = Math.ceil(sortedCandidates.length / maxGroups);
|
||||
var merged = [];
|
||||
for (var index = 0; index < sortedCandidates.length; index += bucketSize) {
|
||||
var bucket = sortedCandidates.slice(index, index + bucketSize);
|
||||
if (bucket.length === 0) {
|
||||
continue;
|
||||
}
|
||||
merged.push(mergeErrorLogPlanCandidateBucket(bucket, merged.length + 1));
|
||||
}
|
||||
return merged.slice(0, maxGroups);
|
||||
}
|
||||
function filterLogsWithinRange(logs, rangeStart, rangeEnd) {
|
||||
var startTime = rangeStart.getTime();
|
||||
var endTime = rangeEnd.getTime();
|
||||
return logs.filter(function (log) {
|
||||
var _a;
|
||||
var createdAtMs = (_a = normalizeDateBoundary(log.createdAt)) === null || _a === void 0 ? void 0 : _a.getTime();
|
||||
return createdAtMs != null && createdAtMs >= startTime && createdAtMs <= endTime;
|
||||
});
|
||||
}
|
||||
function formatErrorLogPlanNote(candidate, rangeStart, rangeEnd) {
|
||||
var _a;
|
||||
var lines = [
|
||||
"\uC870\uD68C \uAD6C\uAC04: ".concat(formatIsoTimestamp(rangeStart), " ~ ").concat(formatIsoTimestamp(rangeEnd)),
|
||||
"\uBC1C\uC0DD \uAC74\uC218: ".concat(candidate.count, "\uAC74"),
|
||||
"\uCD5C\uADFC \uBC1C\uC0DD: ".concat(formatIsoTimestamp(candidate.lastCreatedAt)),
|
||||
"\uCD5C\uCD08 \uBC1C\uC0DD: ".concat(formatIsoTimestamp(candidate.firstCreatedAt)),
|
||||
"\uC5D0\uB7EC \uC720\uD615: ".concat(candidate.errorType),
|
||||
];
|
||||
if (candidate.errorName) {
|
||||
lines.push("\uC5D0\uB7EC \uC774\uB984: ".concat(candidate.errorName));
|
||||
}
|
||||
if (candidate.sourceLabel || candidate.source) {
|
||||
lines.push("\uBC1C\uC0DD \uC704\uCE58: ".concat(candidate.sourceLabel || candidate.source));
|
||||
}
|
||||
if (candidate.groupedCandidateCount && candidate.groupedCandidateCount > 1) {
|
||||
lines.push("\uBB36\uC778 \uC5D0\uB7EC \uADF8\uB8F9: ".concat(candidate.groupedCandidateCount, "\uAC1C"));
|
||||
}
|
||||
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
||||
lines.push("\uC8FC\uC694 \uACBD\uB85C \uADF8\uB8F9: ".concat(candidate.requestPathGroup));
|
||||
}
|
||||
if (Array.isArray(candidate.requestPaths) && candidate.requestPaths.length > 0) {
|
||||
lines.push("\uB300\uD45C \uACBD\uB85C: ".concat(candidate.requestPaths.join(', ')));
|
||||
}
|
||||
if (candidate.statusCode != null) {
|
||||
lines.push("\uC0C1\uD0DC \uCF54\uB4DC: ".concat(candidate.statusCode));
|
||||
}
|
||||
if (Array.isArray(candidate.representativeMessages) && candidate.representativeMessages.length > 0) {
|
||||
lines.push('대표 메시지:');
|
||||
lines.push.apply(lines, candidate.representativeMessages.map(function (message, index) { return "".concat(index + 1, ". ").concat(message); }));
|
||||
}
|
||||
else {
|
||||
lines.push("\uB300\uD45C \uBA54\uC2DC\uC9C0: ".concat(String((_a = candidate.errorMessage) !== null && _a !== void 0 ? _a : '').trim()));
|
||||
}
|
||||
if (Array.isArray(candidate.sampleLogIds) && candidate.sampleLogIds.length > 0) {
|
||||
lines.push("\uB300\uD45C \uB85C\uADF8 ID: ".concat(candidate.sampleLogIds.join(', ')));
|
||||
}
|
||||
else {
|
||||
lines.push("\uB300\uD45C \uB85C\uADF8 ID: ".concat(candidate.sampleLogId));
|
||||
}
|
||||
if (Array.isArray(candidate.groupedScopes) && candidate.groupedScopes.length > 0) {
|
||||
lines.push('묶인 에러 범위:');
|
||||
lines.push.apply(lines, candidate.groupedScopes.map(function (scope, index) { return "".concat(index + 1, ". ").concat(scope); }));
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('처리 요청:');
|
||||
lines.push('1. 재현 경로와 영향 범위를 확인합니다.');
|
||||
lines.push('2. 수정이 필요한 경우 별도 Plan으로 소스 작업을 진행합니다.');
|
||||
lines.push('3. 테스트와 재발 방지 필요 여부를 검토합니다.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
function buildBoardPostMarker(workId) {
|
||||
return "".concat(ERROR_LOG_BOARD_POST_MARKER_PREFIX).concat(workId, " -->");
|
||||
}
|
||||
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,
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -440,6 +440,8 @@ export async function registerErrorLogBoardPosts(args?: {
|
||||
attachments: [],
|
||||
automationType: 'none',
|
||||
automationContextIds: [],
|
||||
requestExecutionMode: 'all_at_once',
|
||||
requestItems: [],
|
||||
});
|
||||
|
||||
createdPosts.push({
|
||||
|
||||
257
etc/servers/work-server/src/services/error-log-service.js
Normal file
257
etc/servers/work-server/src/services/error-log-service.js
Normal file
@@ -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;
|
||||
}
|
||||
526
etc/servers/work-server/src/services/git-service.js
Normal file
526
etc/servers/work-server/src/services/git-service.js
Normal file
@@ -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*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
221
etc/servers/work-server/src/services/managed-schedule-service.js
Normal file
221
etc/servers/work-server/src/services/managed-schedule-service.js
Normal file
@@ -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, '');
|
||||
}
|
||||
@@ -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<ManagedScheduleServiceResult>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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<typeof notifica
|
||||
return mapNotificationMessageRow(row);
|
||||
}
|
||||
|
||||
export async function deleteOlderNotificationMessagesBySource(source: string, keepLatestCount = 1) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const normalizedSource = source.trim();
|
||||
|
||||
if (!normalizedSource) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rows = await db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.select('id', 'created_at')
|
||||
.where({ source: normalizedSource });
|
||||
const idsToDelete = selectNotificationMessageIdsToDelete(
|
||||
rows.map((row) => ({
|
||||
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<typeof notificationMessageReadPayloadSchema>,
|
||||
|
||||
1352
etc/servers/work-server/src/services/notification-service.js
Normal file
1352
etc/servers/work-server/src/services/notification-service.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
})];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
10
etc/servers/work-server/src/services/plan-retry-policy.js
Normal file
10
etc/servers/work-server/src/services/plan-retry-policy.js
Normal file
@@ -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);
|
||||
}
|
||||
1788
etc/servers/work-server/src/services/plan-schedule-service.js
Normal file
1788
etc/servers/work-server/src/services/plan-schedule-service.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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' }]);
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, now: Date) {
|
||||
}
|
||||
|
||||
function isWithinRepeatWindow(row: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<typeof createPlanScheduledTaskSchema>) {
|
||||
@@ -901,6 +1274,8 @@ export async function createPlanScheduledTask(payload: z.infer<typeof createPlan
|
||||
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit);
|
||||
const automationType = await resolveAutomationType(payload.automationType);
|
||||
const executionMode = normalizeScheduleExecutionMode(payload.executionMode);
|
||||
const repeatWindows = normalizeScheduleTimeWindows(payload.repeatWindows);
|
||||
const firstRepeatWindow = repeatWindows[0] ?? null;
|
||||
const shouldAcknowledgeManagedServiceRefreshOnNextRun =
|
||||
executionMode === 'managed-service' && Boolean(payload.recreateManagedServiceOnNextSave);
|
||||
|
||||
@@ -926,8 +1301,11 @@ export async function createPlanScheduledTask(payload: z.infer<typeof createPlan
|
||||
repeat_interval_seconds: toRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
|
||||
repeat_interval_minutes: toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
|
||||
daily_run_time: normalizeDailyRunTime(payload.dailyRunTime),
|
||||
repeat_window_start_time: normalizeOptionalTimeOfDay(payload.repeatWindowStartTime),
|
||||
repeat_window_end_time: normalizeOptionalTimeOfDay(payload.repeatWindowEndTime),
|
||||
schedule_weekdays_json: stringifyScheduleWeekdays(payload.scheduleWeekdays),
|
||||
schedule_date_ranges_json: stringifyScheduleDateRanges(payload.scheduleDateRanges),
|
||||
repeat_windows_json: stringifyScheduleTimeWindows(repeatWindows),
|
||||
repeat_window_start_time: firstRepeatWindow?.startTime ?? null,
|
||||
repeat_window_end_time: firstRepeatWindow?.endTime ?? null,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
@@ -979,6 +1357,10 @@ export async function updatePlanScheduledTask(id: number, payload: z.infer<typeo
|
||||
const automationType = await resolveAutomationType(
|
||||
payload.automationType ?? currentRow.automation_type_id ?? currentRow.automation_type,
|
||||
);
|
||||
const repeatWindows = normalizeScheduleTimeWindows(
|
||||
payload.repeatWindows ?? resolveScheduleTimeWindows(currentRow),
|
||||
);
|
||||
const firstRepeatWindow = repeatWindows[0] ?? null;
|
||||
const shouldRecreateManagedService =
|
||||
executionMode === 'managed-service' &&
|
||||
(normalizeBoolean(payload.recreateManagedServiceOnNextSave, false)
|
||||
@@ -1020,12 +1402,15 @@ export async function updatePlanScheduledTask(id: number, payload: z.infer<typeo
|
||||
repeat_interval_seconds: toRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
|
||||
repeat_interval_minutes: toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
|
||||
daily_run_time: normalizeDailyRunTime(payload.dailyRunTime ?? currentRow.daily_run_time),
|
||||
repeat_window_start_time: normalizeOptionalTimeOfDay(
|
||||
payload.repeatWindowStartTime ?? currentRow.repeat_window_start_time,
|
||||
schedule_weekdays_json: stringifyScheduleWeekdays(
|
||||
payload.scheduleWeekdays ?? parseScheduleWeekdays(currentRow.schedule_weekdays_json),
|
||||
),
|
||||
repeat_window_end_time: normalizeOptionalTimeOfDay(
|
||||
payload.repeatWindowEndTime ?? currentRow.repeat_window_end_time,
|
||||
schedule_date_ranges_json: stringifyScheduleDateRanges(
|
||||
payload.scheduleDateRanges ?? parseScheduleDateRanges(currentRow.schedule_date_ranges_json),
|
||||
),
|
||||
repeat_windows_json: stringifyScheduleTimeWindows(repeatWindows),
|
||||
repeat_window_start_time: firstRepeatWindow?.startTime ?? null,
|
||||
repeat_window_end_time: firstRepeatWindow?.endTime ?? null,
|
||||
context_snapshot_generated_at:
|
||||
payload.enabled !== undefined && Boolean(payload.enabled) !== Boolean(currentRow.enabled ?? true)
|
||||
? null
|
||||
@@ -1265,6 +1650,8 @@ async function registerPlanScheduledTaskRow(row: Record<string, unknown>, 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)
|
||||
|
||||
3482
etc/servers/work-server/src/services/plan-service.js
Normal file
3482
etc/servers/work-server/src/services/plan-service.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
471
etc/servers/work-server/src/services/resource-manager-service.ts
Normal file
471
etc/servers/work-server/src/services/resource-manager-service.ts
Normal file
@@ -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<ResourceManagerTreeNode> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1384,8 +1384,12 @@ async function inspectBuild(definition: ServerDefinition): Promise<BuildInspecti
|
||||
: runningBuild && latestBuild
|
||||
? '실행 중인 work-server가 최신 빌드입니다.'
|
||||
: latestBuild
|
||||
? '최신 빌드는 준비되어 있지만 실행 중 버전 정보를 읽지 못했습니다.'
|
||||
: '아직 확인된 work-server 빌드 정보가 없습니다.',
|
||||
? latestSourceChangedAt
|
||||
? '최신 빌드는 준비되어 있지만 실행 중 버전 정보를 읽지 못했습니다.'
|
||||
: '최신 빌드는 준비되어 있지만 워크서버 소스 수정일을 읽지 못했습니다. /app 또는 /workspace/main-project의 work-server 소스 경로를 확인해 주세요.'
|
||||
: latestSourceChangedAt
|
||||
? '아직 확인된 work-server 빌드 정보가 없습니다.'
|
||||
: '워크서버 소스 수정일과 빌드 정보를 읽지 못했습니다. /app 또는 /workspace/main-project의 work-server 소스 경로를 확인해 주세요.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
hasReservedRestartVerification,
|
||||
summarizeRestartReservationAutomationWork,
|
||||
summarizeRestartReservationCodexWork,
|
||||
} from './server-restart-reservation-service.js';
|
||||
|
||||
const reservationStartedAt = '2026-05-06T00:00:00.000Z';
|
||||
|
||||
test('hasReservedRestartVerification keeps test restart pending until a new startedAt and build timestamp exist', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -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<BoardPostRequestItem, 'planItemId' | 'automationReceivedAt' | 'workflowState'>) {
|
||||
return Boolean(requestItem.planItemId) || Boolean(requestItem.automationReceivedAt) || requestItem.workflowState !== 'pending';
|
||||
}
|
||||
|
||||
export function summarizeRestartReservationAutomationWork(
|
||||
posts: Array<Pick<BoardPostItem, 'requestItems'>>,
|
||||
) {
|
||||
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<Pick<ChatRuntimeJobItem, 'sessionId'>>;
|
||||
queued: Array<Pick<ChatRuntimeJobItem, 'sessionId'>>;
|
||||
},
|
||||
options?: {
|
||||
sessionClientIds?: Map<string, string | null>;
|
||||
},
|
||||
) {
|
||||
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<string, string | null>() },
|
||||
);
|
||||
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<RestartReservationWorkloadSummary>;
|
||||
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<string, unknown>) {
|
||||
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<void>((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<typeof setInterval> | 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1627
etc/servers/work-server/src/services/stock-alert-service.js
Normal file
1627
etc/servers/work-server/src/services/stock-alert-service.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<number | null | undefined>,
|
||||
) {
|
||||
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<StockAlertVolumeSnapshotRow, 'quoted_at'> & { 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<T>(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<number | null>;
|
||||
close?: Array<number | null>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>(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<StockAlertRow>(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<StockAlertVolumeSnapshotRow>(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<string, StockAlertVolumeSnapshot>,
|
||||
) {
|
||||
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<string, StockAlertVolumeSnapshot>,
|
||||
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<string, StockAlertVolumeSnapshot>,
|
||||
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<string, StockAlertVolumeSnapshot>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
476
etc/servers/work-server/src/services/visitor-history-service.js
Normal file
476
etc/servers/work-server/src/services/visitor-history-service.js
Normal file
@@ -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)];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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<WorkServerSourceChangeInfo | null> {
|
||||
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<WorkServerSourceChangeInfo | null> {
|
||||
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<WorkSer
|
||||
}
|
||||
|
||||
const childPath = path.join(targetPath, entry.name);
|
||||
const childLatest = await findLatestSourceChangeInPath(childPath);
|
||||
const childLatest = await findLatestSourceChangeInPath(rootPath, childPath);
|
||||
|
||||
if (!childLatest) {
|
||||
continue;
|
||||
@@ -122,15 +181,17 @@ async function findLatestSourceChangeInPath(targetPath: string): Promise<WorkSer
|
||||
export async function readLatestWorkServerSourceChange() {
|
||||
let latest: WorkServerSourceChangeInfo | null = null;
|
||||
|
||||
for (const targetPath of SOURCE_TARGET_PATHS) {
|
||||
const candidate = await findLatestSourceChangeInPath(targetPath);
|
||||
for (const rootPath of resolveSourceTargetRoots()) {
|
||||
for (const targetName of SOURCE_TARGET_PATH_NAMES) {
|
||||
const candidate = await findLatestSourceChangeInPath(rootPath, path.join(rootPath, targetName));
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latest || candidate.changedAt > latest.changedAt) {
|
||||
latest = candidate;
|
||||
if (!latest || candidate.changedAt > latest.changedAt) {
|
||||
latest = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
193
etc/servers/work-server/src/services/worklog-automation-utils.js
Normal file
193
etc/servers/work-server/src/services/worklog-automation-utils.js
Normal file
@@ -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*/];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
resource/prod/.gitkeep
Normal file
1
resource/prod/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
resource/prod/clipboard-20260506-214618-1.html
Normal file
1
resource/prod/clipboard-20260506-214618-1.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><body dir="ltr"><span style="caret-color: rgb(24, 34, 48); color: rgb(24, 34, 48); font-family: "SUIT Variable", "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, sans-serif; font-size: medium; font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; white-space: pre; widows: 2; word-spacing: 0px; -webkit-tap-highlight-color: rgba(26, 26, 26, 0.3); -webkit-text-size-adjust: auto; -webkit-text-stroke-width: 0px; text-decoration: none; display: inline !important; float: none;">/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f</span></body>
|
||||
1
resource/prod/clipboard-20260506-214618-2.txt
Normal file
1
resource/prod/clipboard-20260506-214618-2.txt
Normal file
@@ -0,0 +1 @@
|
||||
/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f
|
||||
1
resource/release/.gitkeep
Normal file
1
resource/release/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
resource/release/IMG_9111.PNG
Normal file
BIN
resource/release/IMG_9111.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [showInitialLoading, setShowInitialLoading] = useState(true);
|
||||
|
||||
useLayoutEffect(() => bindViewportCssVars(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<Navigate to={buildPlansPath('all')} replace />} />
|
||||
<Route index element={<Navigate to={buildChatPath('live')} replace />} />
|
||||
<Route path="docs/:folder" element={<DocsPage />} />
|
||||
<Route path="apis/:section" element={<ApisPage />} />
|
||||
<Route path="plans/:section" element={<PlansPage />} />
|
||||
<Route path="chat/:section" element={<ChatPage />} />
|
||||
<Route path="play/layout" element={<PlayPage />} />
|
||||
<Route path="play/test" element={<PlayPage />} />
|
||||
<Route path="play/cbt" element={<PlayPage />} />
|
||||
<Route path="play/layout-record/:layoutId" element={<PlayPage />} />
|
||||
<Route path="*" element={<Navigate to={buildDocsPath()} replace />} />
|
||||
</Route>
|
||||
|
||||
426
src/app/main/ChatDefaultContextManagementPage.tsx
Normal file
426
src/app/main/ChatDefaultContextManagementPage.tsx
Normal file
@@ -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<string | null>(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<ChatDefaultContextFormValue>();
|
||||
|
||||
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 (
|
||||
<Card title="기본 유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 기본 유형을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page--pane-maximized' : ''
|
||||
}`}
|
||||
>
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="기본 유형 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 기본 유형
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 기본 유형</Title>
|
||||
<Text type="secondary">{`${defaultContexts.length}건`}</Text>
|
||||
</div>
|
||||
{defaultContexts.length > 0 ? (
|
||||
<List
|
||||
dataSource={defaultContexts}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={
|
||||
item.id === selectedContextId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item'
|
||||
}
|
||||
onClick={() => {
|
||||
openDetail(item.id);
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(item.id);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.title}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 기본 유형이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '기본 유형 등록' : '기본 유형 상세'}
|
||||
className={`chat-type-management-page__card${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__card--pane-maximized' : ''
|
||||
}`}
|
||||
extra={
|
||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
<Button shape="circle" icon={<PlusOutlined />} aria-label="새 입력" onClick={openCreateForm} />
|
||||
{!isCreating && selectedContext ? (
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="삭제"
|
||||
onClick={() => {
|
||||
void handleDelete();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={EMPTY_FORM_VALUE}
|
||||
onFinish={async (values) => {
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const nextDefaultContexts = upsertChatDefaultContext(defaultContexts, values);
|
||||
const savedContext = nextDefaultContexts.find((item) => item.id === values.id || item.title === values.title) ?? null;
|
||||
|
||||
await setStore({
|
||||
defaultContexts: nextDefaultContexts,
|
||||
chatTypeDefaults,
|
||||
roomContexts,
|
||||
});
|
||||
setIsCreating(false);
|
||||
setSelectedContextId(savedContext?.id ?? null);
|
||||
setDetailMode('detail');
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '기본 유형 저장에 실패했습니다.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="chat-type-management-page__editor-scroll">
|
||||
<div className={`chat-type-management-page__meta-grid${maximizedPane !== 'none' ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="기본 유형명"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '기본 유형명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 모바일 검증 공통 규칙" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
기본 유형 본문
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">입력</Text>
|
||||
</div>
|
||||
<Form.Item name="content" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={'## 적용 기준\n- 기본 유형의 공통 규칙을 Markdown으로 정의하세요.'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.content !== next.content}>
|
||||
{({ getFieldValue }) => {
|
||||
const content = String(getFieldValue('content') ?? '').trim();
|
||||
|
||||
return content ? (
|
||||
<MarkdownPreviewContent content={content} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 기본 유형 본문이 없습니다." />
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ChatMessage[]>([]);
|
||||
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(
|
||||
() => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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<string[]>([]);
|
||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||
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() {
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 컨텍스트</Title>
|
||||
@@ -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<typeof context> => 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) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
))}
|
||||
{linkedDefaultContexts.map((context) => (
|
||||
<Tag key={`${item.id}-${context.id}`} color="gold">
|
||||
{context.title}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
@@ -341,6 +370,7 @@ export function ChatTypeManagementPage() {
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
|
||||
<Form
|
||||
@@ -356,6 +386,14 @@ export function ChatTypeManagementPage() {
|
||||
try {
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
const savedChatType = savedChatTypes.find((item) => 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() {
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className={`chat-type-management-page__default-context-field${isPaneMaximized ? ' chat-type-management-page__default-context-field--hidden' : ''}`}>
|
||||
<div className="chat-type-management-page__default-context-header">
|
||||
<Text strong>기본 유형 연결</Text>
|
||||
<Text type="secondary">여러 개를 선택하면 채팅 요청마다 함께 참조됩니다.</Text>
|
||||
</div>
|
||||
{defaultContexts.filter((context) => context.enabled).length > 0 ? (
|
||||
<>
|
||||
<Checkbox.Group
|
||||
className="chat-type-management-page__default-context-options"
|
||||
value={selectedDefaultContextIds}
|
||||
onChange={(checkedValues) => {
|
||||
setSelectedDefaultContextIds(
|
||||
checkedValues
|
||||
.map((value) => String(value).trim())
|
||||
.filter((value) => defaultContexts.some((context) => context.id === value && context.enabled)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={10} className="chat-type-management-page__default-context-space">
|
||||
{defaultContexts
|
||||
.filter((context) => context.enabled)
|
||||
.map((context) => (
|
||||
<label key={context.id} className="chat-type-management-page__default-context-option">
|
||||
<Checkbox value={context.id}>{context.title}</Checkbox>
|
||||
<Text type="secondary" className="chat-type-management-page__default-context-option-copy">
|
||||
{context.content.split('\n')[0]?.replace(/^#+\s*/, '') || '설명 없음'}
|
||||
</Text>
|
||||
</label>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
{selectedDefaultContextIds.length > 0 ? (
|
||||
<div className="chat-type-management-page__default-context-preview">
|
||||
{selectedDefaultContextIds.map((contextId) => {
|
||||
const context = defaultContexts.find((item) => item.id === contextId);
|
||||
|
||||
return context ? (
|
||||
<Tag key={`preview-${context.id}`} color="gold">
|
||||
{context.title}
|
||||
</Tag>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="등록된 기본 유형이 없습니다."
|
||||
description="채팅 관리 > 기본 유형 관리에서 Markdown 스타일 기본 유형을 먼저 등록하세요."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
기본 문맥 설명
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ import { useAppStore } from '../../store';
|
||||
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
|
||||
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
|
||||
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
|
||||
import { ChatDefaultContextManagementPage } from './ChatDefaultContextManagementPage';
|
||||
import { ResourceManagementPage } from './ResourceManagementPage';
|
||||
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
||||
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
||||
import { MainChatPanel } from './MainChatPanel';
|
||||
@@ -30,6 +32,7 @@ const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
export function MainContent({
|
||||
contentExpanded,
|
||||
sidebarOverlayActive = false,
|
||||
onToggleContentExpanded,
|
||||
children,
|
||||
}: MainContentProps) {
|
||||
@@ -204,10 +207,18 @@ export function MainContent({
|
||||
return <ChatSourceChangesPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:resources') {
|
||||
return <ResourceManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:manage') {
|
||||
return <ChatTypeManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:manage-defaults') {
|
||||
return <ChatDefaultContextManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:play:layout') {
|
||||
return <LayoutPlaygroundView />;
|
||||
}
|
||||
@@ -217,7 +228,13 @@ export function MainContent({
|
||||
|
||||
return (
|
||||
<Content
|
||||
className={contentExpanded ? 'app-main-content app-main-content--expanded' : 'app-main-content'}
|
||||
className={[
|
||||
'app-main-content',
|
||||
contentExpanded ? 'app-main-content--expanded' : '',
|
||||
sidebarOverlayActive ? 'app-main-content--under-sidebar-overlay' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClickCapture={(event) => {
|
||||
handleFocusCapture(event.target);
|
||||
}}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,17 @@
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--app-viewport-height);
|
||||
min-height: var(--app-viewport-height);
|
||||
max-height: var(--app-viewport-height);
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-shell:has(.app-chat-panel) {
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.app-main-panel--play-saved) {
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.app-chat-panel) > .ant-layout {
|
||||
.app-shell__body.ant-layout {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: calc(100dvh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.app-main-panel--play-saved) > .ant-layout {
|
||||
min-height: 0;
|
||||
height: calc(100dvh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -200,6 +186,7 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 132px;
|
||||
max-width: min(100%, 240px);
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
@@ -304,6 +291,130 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header__settings-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.app-header__settings-meta {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(59, 130, 246, 0.2), transparent 28%),
|
||||
linear-gradient(135deg, rgba(2, 6, 23, 0.84), rgba(15, 23, 42, 0.92));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-card {
|
||||
width: min(100%, 420px);
|
||||
padding: 24px 22px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.2);
|
||||
border-radius: 26px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.94), rgba(2, 6, 23, 0.98)),
|
||||
rgba(15, 23, 42, 0.96);
|
||||
box-shadow:
|
||||
0 26px 60px rgba(15, 23, 42, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(147, 197, 253, 0.88);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-title {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
color: #f8fafc;
|
||||
font-size: clamp(22px, 4vw, 28px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
color: #bfdbfe;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-detail {
|
||||
margin: 0 0 16px;
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-steps {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.52);
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-step-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-step--done {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-step--active {
|
||||
color: #f8fafc;
|
||||
border-color: rgba(96, 165, 250, 0.28);
|
||||
background: rgba(30, 41, 59, 0.86);
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.app-header__restart-overlay-step--pending {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.app-header__settings-group-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -432,6 +543,9 @@
|
||||
}
|
||||
|
||||
.app-sider.ant-layout-sider {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
@@ -444,7 +558,7 @@
|
||||
min-width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
flex: 0 0 100vw !important;
|
||||
height: calc(100vh - 72px);
|
||||
height: calc(var(--app-viewport-height) - 72px);
|
||||
border-right: 0;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
transition: none !important;
|
||||
@@ -479,36 +593,32 @@
|
||||
.app-main-content.ant-layout-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: calc(100dvh - 60px);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content:has(.app-chat-panel) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
.app-main-content--under-sidebar-overlay.ant-layout-content {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-main-content--expanded.ant-layout-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-height: var(--app-viewport-height);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.app-main-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -518,7 +628,7 @@
|
||||
|
||||
.app-main-panel--play-saved {
|
||||
height: 100%;
|
||||
min-height: calc(100dvh - 60px);
|
||||
min-height: calc(var(--app-viewport-height) - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -535,18 +645,21 @@
|
||||
}
|
||||
|
||||
.app-main-content--expanded.ant-layout-content:has(.app-main-panel--play-saved) {
|
||||
min-height: calc(100dvh - 60px);
|
||||
min-height: calc(var(--app-viewport-height) - 60px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.app-chat-panel) {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.app-chat-panel) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -557,10 +670,16 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.chat-type-management-page) {
|
||||
@@ -569,13 +688,124 @@
|
||||
padding: 4px 12px 12px;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.board-page),
|
||||
.app-main-panel:has(.history-page),
|
||||
.app-main-panel:has(.chat-source-changes-page),
|
||||
.app-main-panel:has(.docs-page) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.board-page),
|
||||
.app-main-layout:has(.history-page),
|
||||
.app-main-layout:has(.chat-source-changes-page),
|
||||
.app-main-layout:has(.docs-page) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 4px 12px 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.docs-page) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.docs-page {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.docs-page__card,
|
||||
.docs-page__card.ant-card,
|
||||
.docs-page__card.ant-card .ant-card-body {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.docs-page__card.ant-card {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.docs-page__card.ant-card .ant-card-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.docs-page__scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 0 0 calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.docs-page__stack {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.resource-management-page) {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.resource-management-page) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 4px 12px 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.plan-board-page),
|
||||
.app-main-panel:has(.plan-schedule-page),
|
||||
.app-main-panel:has(.release-review-page),
|
||||
.app-main-panel:has(.server-command-page),
|
||||
.app-main-panel:has(.test-play-app),
|
||||
.app-main-panel:has(.layout-playground__editor-card) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.plan-board-page),
|
||||
.app-main-layout:has(.plan-schedule-page),
|
||||
.app-main-layout:has(.release-review-page),
|
||||
.app-main-layout:has(.server-command-page),
|
||||
.app-main-layout:has(.test-play-app),
|
||||
.app-main-layout:has(.layout-playground__editor-card) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-panel--play:has(.test-play-app),
|
||||
.app-main-panel--play:has(.layout-playground__editor-card) {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100dvh;
|
||||
height: var(--app-viewport-height);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
html:has(.chat-type-management-page),
|
||||
@@ -595,12 +825,16 @@
|
||||
.app-shell,
|
||||
.app-main-content.ant-layout-content {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.app-main-panel,
|
||||
.app-main-layout {
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.app-chat-panel),
|
||||
@@ -609,12 +843,128 @@
|
||||
}
|
||||
|
||||
.app-shell:has(.chat-type-management-page),
|
||||
.app-shell:has(.chat-type-management-page) > .ant-layout {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: var(--app-viewport-height);
|
||||
min-height: var(--app-viewport-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content:has(.chat-type-management-page),
|
||||
.app-main-panel:has(.chat-type-management-page),
|
||||
.app-main-layout:has(.chat-type-management-page),
|
||||
.chat-type-management-page,
|
||||
.chat-type-management-page__card {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content:has(.chat-type-management-page),
|
||||
.app-main-panel:has(.chat-type-management-page),
|
||||
.app-main-layout:has(.chat-type-management-page) {
|
||||
height: calc(var(--app-viewport-height) - 52px);
|
||||
min-height: calc(var(--app-viewport-height) - 52px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.resource-management-page),
|
||||
.app-shell:has(.resource-management-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.resource-management-page),
|
||||
.app-main-panel:has(.resource-management-page),
|
||||
.app-main-layout:has(.resource-management-page) {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-shell:has(.resource-management-page),
|
||||
.app-shell:has(.resource-management-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.resource-management-page),
|
||||
.app-main-panel:has(.resource-management-page),
|
||||
.app-main-layout:has(.resource-management-page) {
|
||||
height: calc(var(--app-viewport-height) - 52px);
|
||||
min-height: calc(var(--app-viewport-height) - 52px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.chat-type-management-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.chat-type-management-page),
|
||||
.app-main-panel:has(.chat-type-management-page),
|
||||
.app-main-layout:has(.chat-type-management-page) {
|
||||
height: calc(100dvh - 52px);
|
||||
min-height: calc(100dvh - 52px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-shell:has(.board-page),
|
||||
.app-shell:has(.board-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.board-page),
|
||||
.app-main-panel:has(.board-page),
|
||||
.app-main-layout:has(.board-page),
|
||||
.app-shell:has(.history-page),
|
||||
.app-shell:has(.history-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.history-page),
|
||||
.app-main-panel:has(.history-page),
|
||||
.app-main-layout:has(.history-page),
|
||||
.app-shell:has(.chat-source-changes-page),
|
||||
.app-shell:has(.chat-source-changes-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.chat-source-changes-page),
|
||||
.app-main-panel:has(.chat-source-changes-page),
|
||||
.app-main-layout:has(.chat-source-changes-page),
|
||||
.app-shell:has(.docs-page),
|
||||
.app-shell:has(.docs-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.docs-page),
|
||||
.app-main-panel:has(.docs-page),
|
||||
.app-main-layout:has(.docs-page) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-shell:has(.docs-page),
|
||||
.app-shell:has(.docs-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.docs-page),
|
||||
.app-main-panel:has(.docs-page) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.docs-page) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.plan-board-page),
|
||||
.app-shell:has(.plan-board-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.plan-board-page),
|
||||
.app-main-panel:has(.plan-board-page),
|
||||
.app-main-layout:has(.plan-board-page),
|
||||
.app-shell:has(.plan-schedule-page),
|
||||
.app-shell:has(.plan-schedule-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.plan-schedule-page),
|
||||
.app-main-panel:has(.plan-schedule-page),
|
||||
.app-main-layout:has(.plan-schedule-page),
|
||||
.app-shell:has(.release-review-page),
|
||||
.app-shell:has(.release-review-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.release-review-page),
|
||||
.app-main-panel:has(.release-review-page),
|
||||
.app-main-layout:has(.release-review-page),
|
||||
.app-shell:has(.server-command-page),
|
||||
.app-shell:has(.server-command-page) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.server-command-page),
|
||||
.app-main-panel:has(.server-command-page),
|
||||
.app-main-layout:has(.server-command-page),
|
||||
.app-shell:has(.test-play-app),
|
||||
.app-shell:has(.test-play-app) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.test-play-app),
|
||||
.app-main-panel:has(.test-play-app),
|
||||
.app-main-layout:has(.test-play-app),
|
||||
.app-shell:has(.layout-playground__editor-card),
|
||||
.app-shell:has(.layout-playground__editor-card) > .ant-layout,
|
||||
.app-main-content.ant-layout-content:has(.layout-playground__editor-card),
|
||||
.app-main-panel:has(.layout-playground__editor-card),
|
||||
.app-main-layout:has(.layout-playground__editor-card) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -623,8 +973,8 @@
|
||||
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),
|
||||
.app-main-layout:has(.app-main-panel--play-saved),
|
||||
.app-main-panel--play-saved {
|
||||
height: calc(100dvh - 52px);
|
||||
min-height: calc(100dvh - 52px);
|
||||
height: calc(var(--app-viewport-height) - 52px);
|
||||
min-height: calc(var(--app-viewport-height) - 52px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -718,7 +1068,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100dvh - 92px);
|
||||
min-height: calc(var(--app-viewport-height) - 92px);
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
}
|
||||
@@ -806,14 +1156,14 @@
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-main-panel:has(.app-chat-panel) {
|
||||
height: calc(100dvh - 60px);
|
||||
min-height: calc(100dvh - 60px);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.app-chat-panel) {
|
||||
height: calc(100dvh - 60px);
|
||||
min-height: calc(100dvh - 60px);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
@@ -821,10 +1171,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell:has(.app-chat-panel) > .ant-layout {
|
||||
height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 6px 10px;
|
||||
height: 52px;
|
||||
@@ -874,7 +1220,7 @@
|
||||
.app-sider--mobile.ant-layout-sider {
|
||||
position: fixed;
|
||||
inset: 52px 0 0;
|
||||
height: calc(100vh - 52px);
|
||||
height: calc(var(--app-viewport-height) - 52px);
|
||||
}
|
||||
|
||||
.app-sider--mobile-inline.ant-layout-sider {
|
||||
@@ -884,21 +1230,33 @@
|
||||
|
||||
.app-main-content.ant-layout-content {
|
||||
padding: 0;
|
||||
min-height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.app-main-layout {
|
||||
min-height: calc(100dvh - 52px);
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
.app-main-layout,
|
||||
.app-main-layout:has(.chat-type-management-page),
|
||||
.app-main-layout:has(.docs-page),
|
||||
.app-main-layout:has(.resource-management-page),
|
||||
.app-main-layout:has(.board-page),
|
||||
.app-main-layout:has(.history-page),
|
||||
.app-main-layout:has(.chat-source-changes-page),
|
||||
.app-main-layout:has(.plan-board-page),
|
||||
.app-main-layout:has(.plan-schedule-page),
|
||||
.app-main-layout:has(.release-review-page),
|
||||
.app-main-layout:has(.server-command-page),
|
||||
.app-main-layout:has(.test-play-app),
|
||||
.app-main-layout:has(.layout-playground__editor-card),
|
||||
.app-main-layout:has(.app-main-panel--play-saved) {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-main-panel--play-saved {
|
||||
min-height: calc(100dvh - 52px);
|
||||
min-height: calc(var(--app-viewport-height) - 52px);
|
||||
}
|
||||
|
||||
.app-main-layout:has(.app-main-panel--play-saved) {
|
||||
min-height: calc(100dvh - 52px);
|
||||
min-height: calc(var(--app-viewport-height) - 52px);
|
||||
}
|
||||
|
||||
.app-main-layout:has(.chat-type-management-page) {
|
||||
@@ -906,12 +1264,17 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.docs-page) {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-main-window-layer {
|
||||
inset: 8px;
|
||||
}
|
||||
|
||||
.app-main-window-layer__stage {
|
||||
min-height: calc(100dvh - 68px);
|
||||
min-height: calc(var(--app-viewport-height) - 68px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
@@ -935,8 +1298,28 @@
|
||||
padding: 10px 16px 16px;
|
||||
}
|
||||
|
||||
.docs-page__scroll {
|
||||
padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.app-main-panel:has(.app-chat-panel) {
|
||||
height: calc(100dvh - 76px);
|
||||
min-height: calc(100dvh - 76px);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.plan-board-page),
|
||||
.app-main-layout:has(.plan-schedule-page),
|
||||
.app-main-layout:has(.release-review-page),
|
||||
.app-main-layout:has(.server-command-page),
|
||||
.app-main-layout:has(.test-play-app),
|
||||
.app-main-layout:has(.layout-playground__editor-card) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.test-play-app),
|
||||
.app-main-panel:has(.layout-playground__editor-card) {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function MainSidebar({
|
||||
: [...(docsMenuItems ?? []), ...(apiMenuItems ?? [])]
|
||||
: effectiveTopMenu === 'play'
|
||||
? [...(playMenuItems ?? [])]
|
||||
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])];
|
||||
: [...(chatMenuItems ?? []), ...(planMenuItems ?? [])];
|
||||
const rootKeys = sidebarItems.flatMap((item) =>
|
||||
item && typeof item === 'object' && 'key' in item && typeof item.key === 'string' ? [item.key] : [],
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user