chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

View File

@@ -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를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다

View File

@@ -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`도 함께 점검합니다.
## 운영 메모

View File

@@ -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` 참고

View File

@@ -4,6 +4,13 @@
`code/value` 데이터를 받아 여러 항목을 체크박스 형태로 선택하고, 실제 값은 `code[]`로 유지하는 다중 선택 combo 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조
```text

View File

@@ -4,6 +4,13 @@
변경 파일의 전체 소스와 raw diff를 codex 스타일 아코디언으로 함께 보여주는 공통 preview 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조
```text

View File

@@ -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 샘플 위젯

View File

@@ -4,6 +4,13 @@
Plan/Board 계열 화면에서 반복되는 산출물 카드, 링크, 미리보기 진입 UI를 공통 스트립으로 정리하는 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 지원 타입
- `image`

View File

@@ -4,6 +4,13 @@
Ant Design `Input`을 기반으로 하되, 타이핑 중에는 내부 상태만 변경하고 `Enter` 또는 `blur` 시점에만 외부 `onChange`를 호출하는 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조
```text

View File

@@ -4,6 +4,13 @@
`[input][button][readonly input]` 형태로 검색어 입력, 버튼 액션, 선택 결과 표시를 한 줄에서 처리하는 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조
```text

View File

@@ -4,6 +4,13 @@
다양한 데이터를 공통 카드 형태로 미리보기할 수 있는 previewer 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 지원 타입
- `text`

View File

@@ -5,6 +5,13 @@
Plan, Board, History 화면에서 공통으로 사용할 수 있는 단계형 진행 표시 컴포넌트입니다.
현재 단계, 완료 단계, 실패 단계, 다음 대기 단계를 한 번에 보여주며 가로/세로 배치와 compact 모드를 지원합니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조
```text

View File

@@ -4,6 +4,13 @@
문서, API, 컴포넌트, 위젯을 키워드로 빠르게 찾고 바로 이동할 수 있는 통합 검색 모달입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트와 위젯에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트와 위젯은 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트와 위젯은 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 특징
- `AutoComplete` 기반 추천 드롭다운

View File

@@ -4,6 +4,13 @@
`code/value` 데이터를 받아 실제 값은 `code`로 유지하고, 드롭다운 표시와 검색은 `value` 기준으로 처리하는 select combo 입력 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조
```text

View File

@@ -4,6 +4,13 @@
상태 값을 간단한 UI 배지로 표현하는 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 폴더 구조
```text

View File

@@ -4,6 +4,13 @@
여러 단계를 순서대로 표시하고 현재 진행 위치를 강조하는 stepper 컴포넌트입니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 구현 위치
```text

View File

@@ -4,6 +4,13 @@
부모 영역 안에서 이동 가능한 모달 스타일 윈도우를 제공합니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출이나 화면 전용 로직을 직접 넣지 않습니다.
- 컴포넌트는 최대한 멍청하게 설계하고, 직관적인 props를 받아 직관적인 UI 동작만 수행합니다.
- 기능 처리와 비즈니스 로직은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 여러 곳에서 재사용되므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서만 보완합니다.
## 특징
- 헤더 작업줄 드래그 이동

View 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 링크가 올바른 항목으로 연결되는지 확인

View File

@@ -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:

View File

@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "npm run build && npm run start",
"build": "tsc -p tsconfig.json && node ./scripts/write-build-info.mjs",
"start": "node dist/server.js",
"build": "sh -c 'tsc -p tsconfig.json --outDir \"${WORK_SERVER_DIST_DIR:-dist}\" && node ./scripts/write-build-info.mjs'",
"start": "sh -c 'node \"${WORK_SERVER_DIST_DIR:-dist}/server.js\"'",
"backfill:chat-request-links": "node --import tsx ./scripts/backfill-chat-request-links.ts",
"backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts",
"test": "node --import tsx --test src/**/*.test.ts"

View File

@@ -45,7 +45,7 @@ prepare_runtime() {
start_child() {
log "starting server process"
node dist/server.js &
npm run start &
CHILD_PID=$!
}

View File

@@ -3,8 +3,11 @@ import path from 'node:path';
const projectRoot = process.cwd();
const packageJsonPath = path.join(projectRoot, 'package.json');
const distDirectoryPath = path.join(projectRoot, 'dist');
const configuredDistPath = process.env.WORK_SERVER_DIST_DIR?.trim() || 'dist';
const distDirectoryPath = path.resolve(projectRoot, configuredDistPath);
const buildInfoPath = path.join(distDirectoryPath, 'build-info.json');
const distNodeModulesPath = path.join(distDirectoryPath, 'node_modules');
const projectNodeModulesPath = path.join(projectRoot, 'node_modules');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const builtAt = new Date().toISOString();
@@ -18,4 +21,23 @@ const buildInfo = {
await fs.mkdir(distDirectoryPath, { recursive: true });
await fs.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2));
try {
const existingNodeModulesLink = await fs.lstat(distNodeModulesPath);
if (existingNodeModulesLink.isSymbolicLink()) {
await fs.unlink(distNodeModulesPath);
}
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
try {
await fs.symlink(projectNodeModulesPath, distNodeModulesPath, 'dir');
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error;
}
}
console.log(`work-server build info written to ${buildInfoPath}`);

View File

@@ -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);

View 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();

View File

@@ -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() {

View 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);
},
},
});

View File

@@ -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,

View File

@@ -109,7 +109,7 @@ export async function registerBoardRoutes(app: FastifyInstance) {
return {
ok: true,
item: result.item,
planItemId: result.planItemId,
planItemIds: result.planItemIds,
alreadyReceived: result.alreadyReceived,
};
});

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -64,10 +64,12 @@ import {
listPlanScheduledTasks,
mapPlanScheduledTaskRow,
registerPlanScheduledTaskNow,
syncManagedServiceGenerationCompletion,
updatePlanScheduledTask,
updatePlanScheduledTaskSchema,
} from '../services/plan-schedule-service.js';
import { getVisitorClientByClientId } from '../services/visitor-history-service.js';
import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js';
const completeActionSchema = z.object({
note: z.string().trim().min(1).optional(),
@@ -164,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,

View 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,
};
});
}

View File

@@ -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(),
};
});
}

View File

@@ -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();

View 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];
}
});
});
}

View File

@@ -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);
});

View File

@@ -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;
}

View File

@@ -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); });
}

View File

@@ -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"),
}];
}
});
});
}

View File

@@ -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); });
}

File diff suppressed because it is too large Load Diff

872
etc/servers/work-server/src/services/board-service.ts Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}

View File

@@ -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({

View File

@@ -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: {

View File

@@ -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');

View File

@@ -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),
);

View 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',
},
];

View 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',
},
];

View File

@@ -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,
}];
}
});
});
}

View File

@@ -440,6 +440,8 @@ export async function registerErrorLogBoardPosts(args?: {
attachments: [],
automationType: 'none',
automationContextIds: [],
requestExecutionMode: 'all_at_once',
requestItems: [],
});
createdPosts.push({

View 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;
}

View 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*/];
}
});
});
}

View 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, '');
}

View File

@@ -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>;
}

View File

@@ -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);
}

View File

@@ -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];
}
});
});
}

View File

@@ -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]);
});

View File

@@ -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>,

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}

View File

@@ -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),
})];
}
});
});
}

View 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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' }]);
});

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View 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),
};
}

View File

@@ -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 소스 경로를 확인해 주세요.',
};
}

View File

@@ -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,
});
});

View File

@@ -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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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(
{

View File

@@ -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,
};
}

View 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)];
}
});
});
}

View File

@@ -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']);
});

View File

@@ -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;
}
}
}

View 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*/];
}
});
});
}

View File

@@ -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');
}
}

View File

@@ -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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
<!DOCTYPE html><body dir="ltr"><span style="caret-color: rgb(24, 34, 48); color: rgb(24, 34, 48); font-family: &quot;SUIT Variable&quot;, &quot;Pretendard Variable&quot;, 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>

View File

@@ -0,0 +1 @@
/api/resource-manager/preview/test/IMG_9111.PNG?token=usr_7f3a9c2d8e1b4a6f

View File

@@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>

View 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>
);
}

View File

@@ -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(
() => ({

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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