chore: update plan automation and chat status UI

This commit is contained in:
2026-04-23 20:27:40 +09:00
parent 6e863feafd
commit 346d4c2208
26 changed files with 317 additions and 74 deletions

View File

@@ -1,4 +1,7 @@
NODE_VERSION=22.22.2 NODE_VERSION=22.22.2
CAPTURE_BASE_URL=https://test.sm-home.cloud/
CAPTURE_REGISTERED_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
VITE_ALLOWED_REGISTRATION_TOKEN=usr_7f3a9c2d8e1b4a6f
PHOTOPRISM_PORT=2342 PHOTOPRISM_PORT=2342
PHOTOPRISM_ORIGINALS_SOURCE=/mnt/usb/photos PHOTOPRISM_ORIGINALS_SOURCE=/mnt/usb/photos
PHOTOPRISM_SITE_URL=https://photo.sm-home.cloud/ PHOTOPRISM_SITE_URL=https://photo.sm-home.cloud/

View File

@@ -8,13 +8,16 @@
### Codex / AI 기본 규칙 ### Codex / AI 기본 규칙
* 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선, 작업 메모 반영을 요청하면 **현재 로컬 `main`에서 바로 작업**한다 * 사용자가 구현, 수정, 실행, 설정 변경, 문서 수정, 채팅 응답 개선을 요청하면 **현재 로컬 `main`에서 바로 작업**한다
* 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다 * 브라우저 확인, 화면 검증, 접속 테스트, 기본 작업 도메인은 **`https://test.sm-home.cloud/` 하나만 기준으로 사용**한다
* 브라우저 스크린샷/캡처 검증은 기본적으로 **루트 `.env``CAPTURE_BASE_URL=https://test.sm-home.cloud/`와 등록 토큰(`CAPTURE_REGISTERED_ACCESS_TOKEN`, `VITE_ALLOWED_REGISTRATION_TOKEN`)을 사용해 토큰 등록 상태에서 진행**한다
* 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다 * 별도로 운영 중인 `4173` 포트의 `ai-code-app-preview` 컨테이너는 **채팅 전용 테스트 서버가 아니라 현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 해석한다
* `test.sm-home.cloud` nginx 프록시는 **화면 `/``5174` 앱 테스트 서버로 보내고, `/api/``/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다 * `test.sm-home.cloud` nginx 프록시는 **화면 `/``5174` 앱 테스트 서버로 보내고, `/api/``/ws/chat`은 항상 `127.0.0.1:3100` work-server로 유지**한다
* `test.sm-home.cloud``/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다 * `test.sm-home.cloud``/api`, `/ws/chat`, `/.codex_chat`, `/public/.codex_chat` 프록시 대상은 사용자가 명시적으로 요청하지 않는 한 임의로 변경하지 않는다
* 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다 * 별도 지시가 없으면 `sm-home.cloud`, `rel.sm-home.cloud` 같은 다른 외부 도메인은 작업 기준으로 삼지 않는다
* `채팅`, `작업 메모`, `작업메모`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다 * `채팅`, `메모 반영`, `문서 반영` 요청은 기본적으로 **브랜치 생성 없이 main 직접 수정**으로 해석한다
* `자동화 작업메모`, `자동화 메모`, 자동화 접수된 작업메모는 **항상 신규 `feature/*` 브랜치를 생성해 작업**하고, 이후 `release` 반영과 `main` 일괄반영까지 진행한다
* 자동화 작업메모의 `main` 일괄반영이 끝나면 **프로젝트 루트에서 최신 `main``pull --ff-only`로 동기화**한다
* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다 * `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` 같은 Git 관리 작업은 기본적으로 수행하지 않는다
* 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다 * 사용자가 Git 작업을 **명시적으로 요청한 경우에만** 필요한 명령을 최소 범위로 수행한다
* 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다 * 원격 저장소 연결 복구, 브랜치 전략 복구, release/main 동기화, 자동 merge 같은 작업을 자동으로 시도하지 않는다
@@ -27,7 +30,7 @@
* 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다 * 사용자가 `git`, `브랜치`, `원격`, `push`, `pull`, `merge`, `commit`를 명시적으로 언급할 때만 Git 작업으로 해석한다
* 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다 * 사용자가 `Plan 등록`, `Plan 게시판 등록`, `게시판 등록`, `계획 등록`이라고 말하면 **Plan 게시판 항목 생성** 의미로 해석한다
* 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다 * 사용자가 `자동화 등록`, `자동화 접수`, `자동화 실행`, `자동화 돌려줘`라고 말하면 **자동화 API 등록 또는 실제 자동화 수행** 의미로 해석한다
* 사용자가 `자동화 메모`라고만 말하면 게시판 메모인지 자동화 접수 메모인지 문맥을 먼저 확인한다 * 사용자가 `자동화 메모`라고만 말하면 기본적으로 **자동화 접수 작업메모**로 해석하고, 신규 `feature/*` 브랜치 생성부터 `release` 반영, `main` 일괄반영, 프로젝트 루트 `pull`까지 포함한 흐름을 적용한다
* 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다 * 사용자가 `Plan 게시판에 등록만`, `자동화 없이`, `구현하지 말고 계획만 등록`, `게시판만`처럼 표현하면 **자동화 실행 없이 Plan 항목만 등록**한다
* 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다 * 사용자가 `자동화해줘`, `구현해줘`, `작업 진행해줘`, `실행해줘`처럼 실제 수행을 명시한 경우에만 자동화 또는 코드 작업으로 해석한다
* 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다 * 요청이 모호하면 자동화 실행을 바로 진행하지 말고, 우선 **Plan 등록**으로 안전 해석하거나 짧게 재확인한다
@@ -45,31 +48,33 @@
## Codex Live / 채팅 / 작업 메모 규칙 ## Codex Live / 채팅 / 작업 메모 규칙
* `Codex Live`, 일반 채팅, 작업 메모 반영 요청은 모두 현재 프로젝트의 로컬 `main`을 기준으로 처리한다 * `Codex Live`, 일반 채팅 요청은 현재 프로젝트의 로컬 `main`을 기준으로 처리한다
* 자동화 작업메모 반영 요청은 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull`** 순서를 기본으로 처리한다
* 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다 * 외부 도메인 기준 동작 확인이 필요하면 기본 대상은 항상 `https://test.sm-home.cloud/`로 본다
* `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다 * `https://test.sm-home.cloud/`에서 채팅/API 문제가 보이면 먼저 프런트 코드보다 **nginx의 `/api`와 `/ws/chat` 프록시가 `3100`을 가리키는지** 확인한다
* 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다 * 채팅에서 나온 수정 요청도 별도 브랜치 생성 없이 바로 파일 수정으로 이어질 수 있다
* 작업 메모는 기록 목적이든 실제 수정 지시든 우선 `main` 기준 로컬 작업으로 연결한다 * 일반 작업 메모는 기록 목적이면 메모로 유지하고, 실제 수정 지시라도 별도 자동화 접수가 아니면 `main` 기준 로컬 작업으로 연결한다
* 채팅과 작업 메모는 Git flow를 강제하지 않고, 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다 * 자동화 작업메모는 Git flow를 기본으로 적용하며, 일반 채팅/수동 작업은 필요한 경우에만 사용자가 별도로 Git 단계를 요청한다
* 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다 * 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 `public/.codex_chat/<chat-session-id>/resource/` 아래 세션 전용 경로를 기준으로 사용한다
* 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다 * 채팅 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/` 아래 경로를 기준으로 사용한다
* 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다 * 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다
* 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다
--- ---
## Plan / 자동화 메모 ## Plan / 자동화 메모
* 기존 문서에 남아 있는 `feature`, `hotfix`, `release` 흐름은 **현재 로컬 모드에서는 기본 규칙으로 사용하지 않는다** * 일반 수동 작업에는 여전히 로컬 `main` 직접 수정 원칙을 유지한다
* Plan 게시판과 자동화 관련 기능 설명은 UI/상태 설명으로만 참고하고, 실제 Git 브랜치 운영 규칙으로 자동 적용하지 않는 * 다만 자동화 작업메모와 Plan 자동화에는 기존 `feature -> release -> main -> 프로젝트 루트 pull` 흐름을 기본 규칙으로 다시 적용
* 사용자가 나중에 브랜치 전략 복구를 명시적으로 요청하면 그때 별도 문서 갱신 후 다시 적용한 * `hotfix/*` 흐름은 기존 예외 규칙을 유지하고, 자동화 대상이 아닌 일반 요청에는 자동 적용하지 않는
--- ---
## 한 줄 요약 ## 한 줄 요약
👉 지금은 로컬 `main`에서 바로 수정한다 👉 일반 채팅/수동 요청은 로컬 `main`에서 바로 수정한다
👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다 👉 외부 확인 기본 도메인은 `https://test.sm-home.cloud/` 하나다
👉 채팅이든 작업 메모든 기본 해석은 `main` 직접 작업이 👉 자동화 작업메모`feature -> release -> main -> 프로젝트 루트 pull` 흐름을 탄
👉 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다 👉 일반 Git 작업은 사용자가 명시적으로 요청할 때만 최소 범위로 한다
--- ---

View File

@@ -7,7 +7,8 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입
- 현재 저장소는 당분간 **로컬 전용 작업 모드**로 사용합니다. - 현재 저장소는 당분간 **로컬 전용 작업 모드**로 사용합니다.
- Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다. - Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다.
- Codex나 자동화 도구는 기본적으로 Git 작업 없이 **현재 프로젝트 루트의 `main` 작업본을 바로 수정**합니다. - Codex나 자동화 도구는 기본적으로 Git 작업 없이 **현재 프로젝트 루트의 `main` 작업본을 바로 수정**합니다.
- `채팅`, `Codex Live`, `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다. - `채팅`, `Codex Live`, 일반 `작업메모`, `메모 반영` 요청도 같은 기준으로 해석합니다.
- 단, 자동화 접수된 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름을 사용합니다.
- 채팅 리소스와 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래를 사용합니다. - 채팅 리소스와 첨부 파일은 `public/.codex_chat/<chat-session-id>/resource/...` 기준으로 제공하며, 업로드 파일은 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래를 사용합니다.
## 시작하기 ## 시작하기

View File

@@ -6,7 +6,8 @@
- 현재 저장소는 당분간 로컬 전용으로 운영합니다. - 현재 저장소는 당분간 로컬 전용으로 운영합니다.
- 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다. - 문서 정리나 Codex 작업 시 Git 원격 동기화, 브랜치 운영, 자동 merge 흐름은 기본 전제로 사용하지 않습니다.
- `Codex Live`, 일반 채팅, 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다. - `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**하는 방식으로 처리합니다.
- 자동화 작업메모는 예외적으로 **신규 `feature/*` 브랜치 생성 -> `release` 반영 -> `main` 일괄반영 -> 프로젝트 루트 `pull --ff-only`** 흐름으로 처리합니다.
- Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다. - Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
## 1. 작업일지 ## 1. 작업일지
@@ -149,7 +150,7 @@ src/components
- `Plan` 기능은 `src/features/planBoard`에서 관리 - `Plan` 기능은 `src/features/planBoard`에서 관리
- 현재 앱에는 목록/상세 보드, release 검수, 차트, 스케줄 화면이 함께 포함됨 - 현재 앱에는 목록/상세 보드, release 검수, 차트, 스케줄 화면이 함께 포함됨
- 기본 상태는 `등록`, `작업중`, `작업완료`, `릴리즈완료`, `완료` - 기본 상태는 `등록`, `작업중`, `작업완료`, `릴리즈완료`, `완료`
- 자동화 진행은 `workerStatus`로 별도 관리하며 브랜치 준비, 자동 작업, release/main 반영 상태를 표현 - 자동화 진행은 `workerStatus`로 별도 관리하며 브랜치 준비, 자동 작업, release/main 반영, 프로젝트 루트 pull 완료 상태를 포함한 흐름을 표현
- `작업시작` 이후에는 원본 요청(`작업 ID`, 원본 메모`)을 수정하지 않고 조치 이력으로 누적 기록 - `작업시작` 이후에는 원본 요청(`작업 ID`, 원본 메모`)을 수정하지 않고 조치 이력으로 누적 기록
- 권한 토큰이 없으면 조회 중심으로 동작하며 민감 메모와 소스 작업 일부는 제한 또는 마스킹 - 권한 토큰이 없으면 조회 중심으로 동작하며 민감 메모와 소스 작업 일부는 제한 또는 마스킹
- 관련 기능 문서는 `docs/features/plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고 - 관련 기능 문서는 `docs/features/plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` 참고

View File

@@ -4,6 +4,8 @@
Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다. Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다.
현재 운영 규칙에서 자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치에서 시작하며, `release` 반영과 `main` 일괄반영, 프로젝트 루트 `pull --ff-only`까지 이어집니다. 반면 `Codex Live`나 일반 수동 요청은 여전히 로컬 `main` 직접 수정 기준을 유지합니다.
## 구현 위치 ## 구현 위치
- 화면 진입: `src/app/main/MainContent.tsx` - 화면 진입: `src/app/main/MainContent.tsx`
@@ -114,6 +116,8 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수
Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다. Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다.
자동화 작업메모가 `main` 반영 단계까지 끝나면 worker는 `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`를 수행해 실제 작업본을 최신 `main`으로 맞춥니다.
## 차트 집계 방식 ## 차트 집계 방식
`charts.tsx`는 Plan 전체 목록을 주기적으로 다시 불러와 최근 성과를 집계합니다. `charts.tsx`는 Plan 전체 목록을 주기적으로 다시 불러와 최근 성과를 집계합니다.

View File

@@ -49,7 +49,7 @@
2. `작업시작` 또는 자동화에 따라 브랜치 준비와 작업이 진행됩니다. 2. `작업시작` 또는 자동화에 따라 브랜치 준비와 작업이 진행됩니다.
3. 작업이 완료되면 release 반영 상태와 main 반영 대기 여부가 추적됩니다. 3. 작업이 완료되면 release 반영 상태와 main 반영 대기 여부가 추적됩니다.
4. release 검수 화면에서 샘플/위젯/변경 파일을 확인하고 검수 상태를 갱신합니다. 4. release 검수 화면에서 샘플/위젯/변경 파일을 확인하고 검수 상태를 갱신합니다.
5. main 반영이 끝나면 최종 완료 흐름으로 정리됩니다. 5. main 반영이 끝나면 프로젝트 루트 `pull --ff-only`까지 수행한 뒤 최종 완료 흐름으로 정리됩니다.
## 목록 기능 ## 목록 기능
@@ -88,6 +88,8 @@
- `작업취소` - `작업취소`
- `main 일괄 반영 요청` - `main 일괄 반영 요청`
자동화 작업메모(`auto_worker`)는 항상 신규 `feature/*` 브랜치로 시작하며, `release` 반영과 `main` 일괄 반영 뒤 프로젝트 루트 동기화까지 포함합니다.
세부 액션은 `/plan/items/:id/actions/:action` API로 실행됩니다. 세부 액션은 `/plan/items/:id/actions/:action` API로 실행됩니다.
## 자동 새로고침과 알림 ## 자동 새로고침과 알림

View File

@@ -57,7 +57,15 @@ npm run server-command:runner
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다. 소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다.
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 작업메모 반영 요청 모두 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. 별도 브랜치 생성이나 `release -> main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다. 현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다.
단, 자동화 작업메모(`auto_worker`)는 예외적으로 아래 Git 흐름을 기본 동작으로 사용합니다.
- 신규 `feature/*` 브랜치 생성
- 자동 작업 수행
- `release` 브랜치 반영
- `main` 일괄반영
- `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud``rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다. 브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud``rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
@@ -69,14 +77,15 @@ npm run server-command:runner
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다. `Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
현재 로컬 운영 모드에서는 아래 자동 브랜치 흐름을 기본 동작으로 강제하지 않습니다. 필요 시 사용자가 별도로 요청한 경우에만 사용합니다. 현재 운영 기준에서 자동화 작업메모는 아래 브랜치 흐름을 기본 동작으로 사용합니다.
- `등록` 상태: worker가 읽어서 `feature/plan-{id}-{workId}` 브랜치 생성 시도 - `등록` 상태: worker가 읽어서 신규 `feature/plan-{id}-{workId}` 브랜치 생성
- 성공 시: `작업중`, `브랜치준비` - 성공 시: `작업중`, `브랜치준비`
- 실패 시: `이슈`, 최근 오류 기록 - 실패 시: `이슈`, 최근 오류 기록
- `개발완료` 상태: worker가 `release` 브랜치 병합 시도 - `개발완료` 상태: worker가 `release` 브랜치 병합 시도
- 병합 성공 시: `완료` - 병합 성공 시: `완료`
- 병합 실패 시: `이슈` - 병합 실패 시: `이슈`
- `main` 반영 성공 시: `PLAN_MAIN_PROJECT_REPO_PATH`에서 `main` 브랜치 `pull --ff-only`까지 수행
안전 조건: 안전 조건:

View File

@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { buildPlanNotificationData } from './plan-notification-service.js'; import { buildPlanNotificationData } from './plan-notification-service.js';
import { shouldNotifyPlanRestart } from './plan-notification-policy.js'; import { shouldNotifyPlanRestart } from './plan-notification-policy.js';
import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js'; import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js';
import { issueActionSchema } from './plan-service.js'; import { issueActionSchema, shouldUseLocalMainPlanMode } from './plan-service.js';
test('shouldTriggerRetryFromActionNote detects missing-fix and verification follow-up requests', () => { test('shouldTriggerRetryFromActionNote detects missing-fix and verification follow-up requests', () => {
assert.equal(shouldTriggerRetryFromActionNote('누락된 거 다시 고쳐서 테스트해 줘'), true); assert.equal(shouldTriggerRetryFromActionNote('누락된 거 다시 고쳐서 테스트해 줘'), true);
@@ -52,3 +52,9 @@ test('buildPlanNotificationData uses stable task key per plan', () => {
notificationKey: 'plan:17', notificationKey: 'plan:17',
}); });
}); });
test('shouldUseLocalMainPlanMode keeps auto_worker on branch workflow', () => {
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
assert.equal(shouldUseLocalMainPlanMode('auto_worker'), false);
assert.equal(shouldUseLocalMainPlanMode('none'), true);
});

View File

@@ -268,6 +268,11 @@ export function buildPlanBranchName(workId: string, id: number) {
return `${prefix}/plan-${id}-${token}`; return `${prefix}/plan-${id}-${token}`;
} }
export function shouldUseLocalMainPlanMode(automationType: unknown) {
const env = getEnv();
return Boolean(env.PLAN_LOCAL_MAIN_MODE) && normalizePlanAutomationType(automationType) !== 'auto_worker';
}
export function mapPlanRow( export function mapPlanRow(
row: Record<string, unknown>, row: Record<string, unknown>,
options?: PlanRowOptions, options?: PlanRowOptions,
@@ -2399,7 +2404,6 @@ export async function listPlanIssueSummaries(planItemIds: number[]) {
export async function claimNextPlanForBranch(workerId: string) { export async function claimNextPlanForBranch(workerId: string) {
await ensurePlanTable(); await ensurePlanTable();
const env = getEnv();
return db.transaction(async (trx) => { return db.transaction(async (trx) => {
const row = await trx(PLAN_TABLE) const row = await trx(PLAN_TABLE)
@@ -2417,7 +2421,9 @@ export async function claimNextPlanForBranch(workerId: string) {
return null; return null;
} }
const assignedBranch = env.PLAN_LOCAL_MAIN_MODE ? env.PLAN_MAIN_BRANCH : buildPlanBranchName(String(row.work_id), Number(row.id)); const assignedBranch = shouldUseLocalMainPlanMode(row.automation_type)
? String(getEnv().PLAN_MAIN_BRANCH)
: buildPlanBranchName(String(row.work_id), Number(row.id));
const rows = await trx(PLAN_TABLE) const rows = await trx(PLAN_TABLE)
.where({ id: row.id }) .where({ id: row.id })
.update({ .update({

View File

@@ -30,6 +30,7 @@ import {
markPlanReleaseMerged, markPlanReleaseMerged,
markPlanMerged, markPlanMerged,
markPlanWorkCompleted, markPlanWorkCompleted,
shouldUseLocalMainPlanMode,
upsertAutoPlanItem, upsertAutoPlanItem,
} from '../services/plan-service.js'; } from '../services/plan-service.js';
import { import {
@@ -200,6 +201,10 @@ export class PlanWorker {
return Boolean(getEnv().PLAN_LOCAL_MAIN_MODE); return Boolean(getEnv().PLAN_LOCAL_MAIN_MODE);
} }
private shouldUseLocalMainModeForPlan(item: { automationType?: unknown }) {
return shouldUseLocalMainPlanMode(item.automationType);
}
start() { start() {
const env = getEnv(); const env = getEnv();
@@ -613,8 +618,10 @@ export class PlanWorker {
planId: number, planId: number,
workId: string, workId: string,
note: unknown, note: unknown,
automationType: unknown,
) { ) {
const env = getEnv(); const env = getEnv();
const useLocalMainMode = shouldUseLocalMainPlanMode(automationType);
const runCodexCommandAttempt = async (attempt: number) => const runCodexCommandAttempt = async (attempt: number) =>
await new Promise<string>((resolve, reject) => { await new Promise<string>((resolve, reject) => {
let settled = false; let settled = false;
@@ -629,7 +636,7 @@ export class PlanWorker {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
PLAN_REPO_PATH: env.PLAN_LOCAL_MAIN_MODE ? env.PLAN_MAIN_PROJECT_REPO_PATH : env.PLAN_GIT_REPO_PATH, PLAN_REPO_PATH: useLocalMainMode ? env.PLAN_MAIN_PROJECT_REPO_PATH : env.PLAN_GIT_REPO_PATH,
PLAN_API_BASE_URL: 'http://127.0.0.1:3100/api', PLAN_API_BASE_URL: 'http://127.0.0.1:3100/api',
PLAN_ACCESS_TOKEN: ERROR_LOG_VIEW_TOKEN, PLAN_ACCESS_TOKEN: ERROR_LOG_VIEW_TOKEN,
PLAN_ITEM_ID: String(planId), PLAN_ITEM_ID: String(planId),
@@ -637,7 +644,7 @@ export class PlanWorker {
PLAN_CODEX_TEMPLATE_HOME: env.PLAN_CODEX_TEMPLATE_HOME, PLAN_CODEX_TEMPLATE_HOME: env.PLAN_CODEX_TEMPLATE_HOME,
PLAN_GIT_USER_NAME: env.PLAN_GIT_USER_NAME, PLAN_GIT_USER_NAME: env.PLAN_GIT_USER_NAME,
PLAN_GIT_USER_EMAIL: env.PLAN_GIT_USER_EMAIL, PLAN_GIT_USER_EMAIL: env.PLAN_GIT_USER_EMAIL,
PLAN_LOCAL_MAIN_MODE: env.PLAN_LOCAL_MAIN_MODE ? 'true' : 'false', PLAN_LOCAL_MAIN_MODE: useLocalMainMode ? 'true' : 'false',
PLAN_SKIP_WORK_COMPLETE: 'true', PLAN_SKIP_WORK_COMPLETE: 'true',
}, },
}); });
@@ -907,7 +914,7 @@ export class PlanWorker {
const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH); const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH);
try { try {
if (!this.isLocalMainMode()) { if (!this.shouldUseLocalMainModeForPlan(item)) {
await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH); await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH);
await ensureBranchExists( await ensureBranchExists(
{ {
@@ -926,8 +933,8 @@ export class PlanWorker {
return; return;
} }
this.logger.info( this.logger.info(
{ planId, branch: assignedBranch, localMainMode: this.isLocalMainMode() }, { planId, branch: assignedBranch, localMainMode: this.shouldUseLocalMainModeForPlan(item) },
this.isLocalMainMode() ? 'Plan local main execution prepared' : 'Plan branch created', this.shouldUseLocalMainModeForPlan(item) ? 'Plan local main execution prepared' : 'Plan branch created',
); );
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : '브랜치 생성에 실패했습니다.'; const message = error instanceof Error ? error.message : '브랜치 생성에 실패했습니다.';
@@ -975,7 +982,7 @@ export class PlanWorker {
const autoDeployToMain = Boolean(item.autoDeployToMain ?? true); const autoDeployToMain = Boolean(item.autoDeployToMain ?? true);
try { try {
if (this.isLocalMainMode()) { if (this.shouldUseLocalMainModeForPlan(item)) {
const completedRow = await markPlanAsCompleted( const completedRow = await markPlanAsCompleted(
planId, planId,
'로컬 main 직접 작업 모드에서 release/main 반영 단계를 건너뛰고 완료 처리했습니다.', '로컬 main 직접 작업 모드에서 release/main 반영 단계를 건너뛰고 완료 처리했습니다.',
@@ -1071,7 +1078,7 @@ export class PlanWorker {
const planLabel = formatPlanNotificationLabel(workId, planId); const planLabel = formatPlanNotificationLabel(workId, planId);
try { try {
if (this.isLocalMainMode()) { if (this.shouldUseLocalMainModeForPlan(item)) {
const completedRow = await markPlanAsCompleted( const completedRow = await markPlanAsCompleted(
planId, planId,
'로컬 main 직접 작업 모드에서 main 반영 단계를 건너뛰고 완료 처리했습니다.', '로컬 main 직접 작업 모드에서 main 반영 단계를 건너뛰고 완료 처리했습니다.',
@@ -1201,7 +1208,7 @@ export class PlanWorker {
'work-started', 'work-started',
); );
const output = await this.runCodexCommandWithProgressNotifications(planId, workId, item.note); const output = await this.runCodexCommandWithProgressNotifications(planId, workId, item.note, item.automationType);
if (output.includes('처리할 Plan 항목이 없습니다.')) { if (output.includes('처리할 Plan 항목이 없습니다.')) {
throw new Error('자동 작업 대상 Plan 항목을 찾지 못했습니다. 상태 전환 로직을 확인해 주세요.'); throw new Error('자동 작업 대상 Plan 항목을 찾지 못했습니다. 상태 전환 로직을 확인해 주세요.');
@@ -1229,7 +1236,7 @@ export class PlanWorker {
return; return;
} }
const finalCompletedRow = this.isLocalMainMode() const finalCompletedRow = this.shouldUseLocalMainModeForPlan(item)
? await markPlanAsCompleted(planId, '로컬 main 직접 작업으로 자동 작업을 완료했습니다.') ? await markPlanAsCompleted(planId, '로컬 main 직접 작업으로 자동 작업을 완료했습니다.')
: await markPlanWorkCompleted(planId, this.workerId, '자동 작업을 완료했습니다.'); : await markPlanWorkCompleted(planId, this.workerId, '자동 작업을 완료했습니다.');
if (!finalCompletedRow) { if (!finalCompletedRow) {
@@ -1240,7 +1247,9 @@ export class PlanWorker {
planId, planId,
workId, workId,
planLabel, planLabel,
this.isLocalMainMode() ? '자동 작업이 로컬 main 작업본에 직접 반영되어 완료되었습니다.' : this.buildExecutionCompletedBody(autoDeployToMain), this.shouldUseLocalMainModeForPlan(item)
? '자동 작업이 로컬 main 작업본에 직접 반영되어 완료되었습니다.'
: this.buildExecutionCompletedBody(autoDeployToMain),
'work-completed', 'work-completed',
); );
this.logger.info({ planId }, 'Plan Codex execution completed'); this.logger.info({ planId }, 'Plan Codex execution completed');

View File

@@ -0,0 +1,94 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const DEFAULT_CAPTURE_STORAGE_KEY = 'work-app.token-access.registered-token';
const DEFAULT_CAPTURE_BASE_URL = 'https://test.sm-home.cloud/';
function stripWrappingQuotes(value) {
if (!value) {
return value;
}
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
function applyEnvFile(filePath) {
if (!fs.existsSync(filePath)) {
return;
}
const content = fs.readFileSync(filePath, 'utf8');
for (const rawLine of content.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const value = stripWrappingQuotes(line.slice(separatorIndex + 1).trim());
if (!key || process.env[key] !== undefined) {
continue;
}
process.env[key] = value;
}
}
function loadCaptureEnv(cwd = process.cwd()) {
applyEnvFile(path.join(cwd, '.env'));
applyEnvFile(path.join(cwd, 'etc/servers/work-server/.env'));
}
export function getCaptureRuntimeConfig(cwd = process.cwd()) {
loadCaptureEnv(cwd);
return {
baseUrl:
process.env.CAPTURE_BASE_URL?.trim() ||
process.env.SERVER_COMMAND_TEST_URL?.trim() ||
DEFAULT_CAPTURE_BASE_URL,
tokenAccessStorageKey:
process.env.CAPTURE_REGISTERED_ACCESS_STORAGE_KEY?.trim() || DEFAULT_CAPTURE_STORAGE_KEY,
registeredAccessToken:
process.env.CAPTURE_REGISTERED_ACCESS_TOKEN?.trim() ||
process.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() ||
process.env.SERVER_COMMAND_ACCESS_TOKEN?.trim() ||
'',
};
}
export async function createCaptureContext(browser, options = {}) {
const { tokenAccessStorageKey, registeredAccessToken } = getCaptureRuntimeConfig();
const context = await browser.newContext(options);
if (registeredAccessToken) {
await context.addInitScript(
({ accessToken, storageKey }) => {
window.localStorage.setItem(storageKey, accessToken);
},
{
accessToken: registeredAccessToken,
storageKey: tokenAccessStorageKey,
},
);
}
return context;
}

View File

@@ -1,11 +1,12 @@
import process from 'node:process'; import process from 'node:process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd(); const cwd = process.cwd();
const componentId = process.argv[2]; const componentId = process.argv[2];
const captureDate = process.argv[3] ?? getKstDate(); const captureDate = process.argv[3] ?? getKstDate();
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; const { baseUrl } = getCaptureRuntimeConfig(cwd);
if (!componentId) { if (!componentId) {
console.error('Usage: node scripts/capture-component-screenshot.mjs <component-id> [YYYY-MM-DD]'); console.error('Usage: node scripts/capture-component-screenshot.mjs <component-id> [YYYY-MM-DD]');
@@ -21,13 +22,14 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
const targetSelector = `#component-sample-${componentId}`; const targetSelector = `#component-sample-${componentId}`;
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ const context = await createCaptureContext(browser, {
viewport: { viewport: {
width: 1600, width: 1600,
height: 1200, height: 1200,
}, },
deviceScaleFactor: 2, deviceScaleFactor: 2,
}); });
const page = await context.newPage();
try { try {
await ensureDirectory(screenshotDir); await ensureDirectory(screenshotDir);
@@ -59,5 +61,6 @@ try {
console.log(`Saved: ${screenshotPath}`); console.log(`Saved: ${screenshotPath}`);
console.log(`Linked in: ${worklogPath}`); console.log(`Linked in: ${worklogPath}`);
} finally { } finally {
await context.close();
await browser.close(); await browser.close();
} }

View File

@@ -1,5 +1,6 @@
import process from 'node:process'; import process from 'node:process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const FEATURE_CAPTURE_PRESETS = { const FEATURE_CAPTURE_PRESETS = {
@@ -56,7 +57,7 @@ const FEATURE_CAPTURE_PRESETS = {
const cwd = process.cwd(); const cwd = process.cwd();
const presetKey = process.argv[2]; const presetKey = process.argv[2];
const captureDate = process.argv[3] ?? getKstDate(); const captureDate = process.argv[3] ?? getKstDate();
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; const { baseUrl } = getCaptureRuntimeConfig(cwd);
if (!presetKey || !(presetKey in FEATURE_CAPTURE_PRESETS)) { if (!presetKey || !(presetKey in FEATURE_CAPTURE_PRESETS)) {
console.error(`Usage: node scripts/capture-feature-screenshot.mjs <${Object.keys(FEATURE_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`); console.error(`Usage: node scripts/capture-feature-screenshot.mjs <${Object.keys(FEATURE_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`);
@@ -71,10 +72,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
}); });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ const context = await createCaptureContext(browser, {
viewport: { width: 1600, height: 1200 }, viewport: { width: 1600, height: 1200 },
deviceScaleFactor: 2, deviceScaleFactor: 2,
}); });
const page = await context.newPage();
try { try {
await ensureDirectory(screenshotDir); await ensureDirectory(screenshotDir);
@@ -113,5 +115,6 @@ try {
console.log(`Saved: ${screenshotPath}`); console.log(`Saved: ${screenshotPath}`);
console.log(`Linked in: ${worklogPath}`); console.log(`Linked in: ${worklogPath}`);
} finally { } finally {
await context.close();
await browser.close(); await browser.close();
} }

View File

@@ -1,10 +1,11 @@
import process from 'node:process'; import process from 'node:process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd(); const cwd = process.cwd();
const captureDate = process.argv[2] ?? getKstDate(); const captureDate = process.argv[2] ?? getKstDate();
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; const { baseUrl } = getCaptureRuntimeConfig(cwd);
const screenshotFileName = 'main-content-fullscreen-toggle.png'; const screenshotFileName = 'main-content-fullscreen-toggle.png';
const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
cwd, cwd,
@@ -13,10 +14,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
}); });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ const context = await createCaptureContext(browser, {
viewport: { width: 1600, height: 1200 }, viewport: { width: 1600, height: 1200 },
deviceScaleFactor: 2, deviceScaleFactor: 2,
}); });
const page = await context.newPage();
try { try {
await ensureDirectory(screenshotDir); await ensureDirectory(screenshotDir);
@@ -41,5 +43,6 @@ try {
console.log(`Saved: ${screenshotPath}`); console.log(`Saved: ${screenshotPath}`);
console.log(`Linked in: ${worklogPath}`); console.log(`Linked in: ${worklogPath}`);
} finally { } finally {
await context.close();
await browser.close(); await browser.close();
} }

View File

@@ -1,11 +1,12 @@
import process from 'node:process'; import process from 'node:process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd(); const cwd = process.cwd();
const menuGroup = process.argv[2] ?? 'docs'; const menuGroup = process.argv[2] ?? 'docs';
const captureDate = process.argv[3] ?? getKstDate(); const captureDate = process.argv[3] ?? getKstDate();
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; const { baseUrl } = getCaptureRuntimeConfig(cwd);
const supportedMenuGroups = new Set(['docs', 'plans']); const supportedMenuGroups = new Set(['docs', 'plans']);
@@ -22,10 +23,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
}); });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ const context = await createCaptureContext(browser, {
viewport: { width: 1600, height: 1200 }, viewport: { width: 1600, height: 1200 },
deviceScaleFactor: 2, deviceScaleFactor: 2,
}); });
const page = await context.newPage();
try { try {
await ensureDirectory(screenshotDir); await ensureDirectory(screenshotDir);
@@ -57,5 +59,6 @@ try {
console.log(`Saved: ${screenshotPath}`); console.log(`Saved: ${screenshotPath}`);
console.log(`Linked in: ${worklogPath}`); console.log(`Linked in: ${worklogPath}`);
} finally { } finally {
await context.close();
await browser.close(); await browser.close();
} }

View File

@@ -1,10 +1,11 @@
import process from 'node:process'; import process from 'node:process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd(); const cwd = process.cwd();
const captureDate = process.argv[2] ?? getKstDate(); const captureDate = process.argv[2] ?? getKstDate();
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; const { baseUrl } = getCaptureRuntimeConfig(cwd);
const screenshotFileName = 'plan-board-mobile-memo-detail.png'; const screenshotFileName = 'plan-board-mobile-memo-detail.png';
const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
cwd, cwd,
@@ -13,7 +14,7 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
}); });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ const context = await createCaptureContext(browser, {
viewport: { width: 430, height: 932 }, viewport: { width: 430, height: 932 },
isMobile: true, isMobile: true,
hasTouch: true, hasTouch: true,

View File

@@ -1,10 +1,11 @@
import process from 'node:process'; import process from 'node:process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd(); const cwd = process.cwd();
const captureDate = process.argv[2] ?? getKstDate(); const captureDate = process.argv[2] ?? getKstDate();
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; const { baseUrl } = getCaptureRuntimeConfig(cwd);
const screenshotFileName = 'search-command.png'; const screenshotFileName = 'search-command.png';
const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
cwd, cwd,
@@ -13,7 +14,7 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
}); });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ const context = await createCaptureContext(browser, {
viewport: { width: 430, height: 932 }, viewport: { width: 430, height: 932 },
isMobile: true, isMobile: true,
hasTouch: true, hasTouch: true,

View File

@@ -1,10 +1,8 @@
import process from 'node:process'; import process from 'node:process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const ACCESS_TOKEN = 'usr_7f3a9c2d8e1b4a6f';
const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
const SETTINGS_CAPTURE_PRESETS = { const SETTINGS_CAPTURE_PRESETS = {
automation: { automation: {
screenshotFileName: 'settings-app.png', screenshotFileName: 'settings-app.png',
@@ -19,7 +17,7 @@ const SETTINGS_CAPTURE_PRESETS = {
const cwd = process.cwd(); const cwd = process.cwd();
const presetKey = process.argv[2]; const presetKey = process.argv[2];
const captureDate = process.argv[3] ?? getKstDate(); const captureDate = process.argv[3] ?? getKstDate();
const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:4173'; const { baseUrl } = getCaptureRuntimeConfig(cwd);
if (!presetKey || !(presetKey in SETTINGS_CAPTURE_PRESETS)) { if (!presetKey || !(presetKey in SETTINGS_CAPTURE_PRESETS)) {
console.error(`Usage: node scripts/capture-settings-screenshot.mjs <${Object.keys(SETTINGS_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`); console.error(`Usage: node scripts/capture-settings-screenshot.mjs <${Object.keys(SETTINGS_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`);
@@ -34,21 +32,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
}); });
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ const context = await createCaptureContext(browser, {
viewport: { width: 1600, height: 1200 }, viewport: { width: 1600, height: 1200 },
deviceScaleFactor: 2, deviceScaleFactor: 2,
}); });
await context.addInitScript(
({ tokenAccessStorageKey, accessToken }) => {
window.localStorage.setItem(tokenAccessStorageKey, accessToken);
},
{
tokenAccessStorageKey: TOKEN_ACCESS_STORAGE_KEY,
accessToken: ACCESS_TOKEN,
},
);
const page = await context.newPage(); const page = await context.newPage();
try { try {

View File

@@ -676,7 +676,9 @@ export function ChatSourceChangesPage() {
> >
<Space direction="vertical" size={6} style={{ width: '100%' }}> <Space direction="vertical" size={6} style={{ width: '100%' }}>
<Space size={8} wrap> <Space size={8} wrap>
<Text strong>{entry.conversationTitle}</Text> <Text strong className="chat-source-changes-page__list-title">
{entry.conversationTitle}
</Text>
<Tag color={entry.status === 'failed' ? 'error' : 'blue'}>{entry.status}</Tag> <Tag color={entry.status === 'failed' ? 'error' : 'blue'}>{entry.status}</Tag>
<Tag color={entry.currentSourceStatus === 'applied' ? 'cyan' : 'default'}> <Tag color={entry.currentSourceStatus === 'applied' ? 'cyan' : 'default'}>
{entry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'} {entry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'}
@@ -702,7 +704,7 @@ export function ChatSourceChangesPage() {
{selectedEntry ? ( {selectedEntry ? (
<Space direction="vertical" size={16} className="chat-source-changes-page__detail"> <Space direction="vertical" size={16} className="chat-source-changes-page__detail">
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Title level={5} style={{ margin: 0 }}> <Title level={5} className="chat-source-changes-page__detail-title">
{selectedEntry.requestTitle} {selectedEntry.requestTitle}
</Title> </Title>
<Text type="secondary"> <Text type="secondary">

View File

@@ -2149,6 +2149,22 @@
overflow: auto; overflow: auto;
} }
.app-chat-panel__resource-strip-filter {
display: flex;
align-items: center;
min-width: 0;
color: #334155;
font-size: 11px;
line-height: 1.4;
}
.app-chat-panel__resource-strip-filter .ant-checkbox-wrapper {
width: 100%;
margin-inline-start: 0;
font-size: inherit;
color: inherit;
}
.app-chat-panel__resource-strip-empty.ant-typography { .app-chat-panel__resource-strip-empty.ant-typography {
margin: 0; margin: 0;
font-size: 11px; font-size: 11px;

View File

@@ -484,12 +484,18 @@ function formatDateTimeLabel(value: string | null) {
function getServerVersionStatusClassName(item: ServerCommandItem | null) { function getServerVersionStatusClassName(item: ServerCommandItem | null) {
if (!item) { if (!item) {
return 'app-header__server-version-indicator--stale'; return 'app-header__server-version-indicator--unknown';
} }
return item.buildRequired || item.updateAvailable if (item.buildRequired) {
? 'app-header__server-version-indicator--stale' return 'app-header__server-version-indicator--build-required';
: 'app-header__server-version-indicator--latest'; }
if (item.updateAvailable) {
return 'app-header__server-version-indicator--update-available';
}
return 'app-header__server-version-indicator--latest';
} }
function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) { function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) {
@@ -501,8 +507,12 @@ function getServerVersionStatusTitle(item: ServerCommandItem | null, label: stri
return `${label} 최신 버전 확인 전`; return `${label} 최신 버전 확인 전`;
} }
if (item.buildRequired || item.updateAvailable) { if (item.buildRequired) {
return `${label} 최신 버전 아님`; return `${label} 커밋 미반영 상태`;
}
if (item.updateAvailable) {
return `${label} 운영 반영 대기 상태`;
} }
return `${label} 최신 버전`; return `${label} 최신 버전`;

View File

@@ -228,7 +228,15 @@
background: #2563eb; background: #2563eb;
} }
.app-header__server-version-indicator--stale { .app-header__server-version-indicator--unknown {
background: #94a3b8;
}
.app-header__server-version-indicator--update-available {
background: #f59e0b;
}
.app-header__server-version-indicator--build-required {
background: #dc2626; background: #dc2626;
} }

View File

@@ -17,7 +17,7 @@ import {
ThunderboltOutlined, ThunderboltOutlined,
UpOutlined, UpOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Alert, Button, Input, Select, Spin, message } from 'antd'; import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { TextAreaRef } from 'antd/es/input/TextArea';
import { import {
useEffect, useEffect,
@@ -159,6 +159,16 @@ function buildInlinePreviewLabel(url: string) {
} }
} }
function buildPreviewFileName(item: PreviewOption) {
try {
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
return fileName || item.label.trim() || item.url;
} catch {
return item.label.trim() || item.url;
}
}
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> { async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = ''; let responseMessage = '';
@@ -750,6 +760,7 @@ export function ChatConversationView({
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]); const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]); const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
const [showBusyOverlay, setShowBusyOverlay] = useState(false); const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>()); const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>()); const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
@@ -808,6 +819,23 @@ export function ChatConversationView({
return [...ordered, ...orphanActivityMessages]; return [...ordered, ...orphanActivityMessages];
}, [visibleMessages]); }, [visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]); const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
const visiblePreviewItems = useMemo(() => {
if (!showLatestResourceOnly) {
return previewItems;
}
const seenFileNames = new Set<string>();
return previewItems.filter((item) => {
const fileName = buildPreviewFileName(item);
if (seenFileNames.has(fileName)) {
return false;
}
seenFileNames.add(fileName);
return true;
});
}, [previewItems, showLatestResourceOnly]);
useEffect(() => { useEffect(() => {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@@ -1150,8 +1178,19 @@ export function ChatConversationView({
{isResourceStripOpen ? ( {isResourceStripOpen ? (
<div className="app-chat-panel__resource-strip"> <div className="app-chat-panel__resource-strip">
{previewItems.length > 0 ? ( {previewItems.length > 0 ? (
<div className="app-chat-panel__resource-strip-list"> <>
{previewItems.map((item) => ( <label className="app-chat-panel__resource-strip-filter">
<Checkbox
checked={showLatestResourceOnly}
onChange={(event) => {
setShowLatestResourceOnly(event.target.checked);
}}
>
</Checkbox>
</label>
<div className="app-chat-panel__resource-strip-list">
{visiblePreviewItems.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@@ -1163,8 +1202,9 @@ export function ChatConversationView({
<span>{item.label}</span> <span>{item.label}</span>
<span>{item.kind}</span> <span>{item.kind}</span>
</button> </button>
))} ))}
</div> </div>
</>
) : ( ) : (
<span className="app-chat-panel__resource-strip-empty"> <span className="app-chat-panel__resource-strip-empty">
. .

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
export const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token'; export const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
export const TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed'; export const TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
export const ALLOWED_REGISTRATION_TOKEN = 'usr_7f3a9c2d8e1b4a6f'; export const ALLOWED_REGISTRATION_TOKEN =
import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() || 'usr_7f3a9c2d8e1b4a6f';
function normalizeToken(value: string | null | undefined) { function normalizeToken(value: string | null | undefined) {
return value?.trim() ?? ''; return value?.trim() ?? '';

View File

@@ -992,6 +992,12 @@ button,
border-color: rgba(22, 93, 255, 0.22); border-color: rgba(22, 93, 255, 0.22);
} }
.chat-source-changes-page__list-title.ant-typography,
.chat-source-changes-page__detail-title.ant-typography {
margin: 0;
word-break: break-word;
}
.chat-source-changes-page__detail { .chat-source-changes-page__detail {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -1628,6 +1634,16 @@ button,
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.chat-source-changes-page__list-title.ant-typography {
font-size: 13px;
line-height: 1.45;
}
.chat-source-changes-page__detail-title.ant-typography {
font-size: 16px;
line-height: 1.35;
}
.release-review-page__grid { .release-review-page__grid {
gap: 12px; gap: 12px;
} }

8
src/vite-env.d.ts vendored
View File

@@ -1,2 +1,10 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" /> /// <reference types="vite-plugin-pwa/client" />
interface ImportMetaEnv {
readonly VITE_ALLOWED_REGISTRATION_TOKEN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}