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
CAPTURE_BASE_URL=https://test.sm-home.cloud/
CAPTURE_REGISTERED_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
VITE_ALLOWED_REGISTRATION_TOKEN=usr_7f3a9c2d8e1b4a6f
PHOTOPRISM_PORT=2342
PHOTOPRISM_ORIGINALS_SOURCE=/mnt/usb/photos
PHOTOPRISM_SITE_URL=https://photo.sm-home.cloud/

View File

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

View File

@@ -7,7 +7,8 @@ React + Vite + TypeScript 기반의 문서/샘플 허브 애플리케이션입
- 현재 저장소는 당분간 **로컬 전용 작업 모드**로 사용합니다.
- Git 원격 동기화, 브랜치 운영, 자동 병합/자동화는 잠시 중지한 상태로 간주합니다.
- 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/...` 아래를 사용합니다.
## 시작하기

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,15 @@ npm run server-command:runner
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `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`는 기본 검증 대상으로 삼지 않습니다.
@@ -69,14 +77,15 @@ npm run server-command:runner
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
현재 로컬 운영 모드에서는 아래 자동 브랜치 흐름을 기본 동작으로 강제하지 않습니다. 필요 시 사용자가 별도로 요청한 경우에만 사용합니다.
현재 운영 기준에서 자동화 작업메모는 아래 브랜치 흐름을 기본 동작으로 사용합니다.
- `등록` 상태: worker가 읽어서 `feature/plan-{id}-{workId}` 브랜치 생성 시도
- `등록` 상태: worker가 읽어서 신규 `feature/plan-{id}-{workId}` 브랜치 생성
- 성공 시: `작업중`, `브랜치준비`
- 실패 시: `이슈`, 최근 오류 기록
- `개발완료` 상태: 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 { shouldNotifyPlanRestart } from './plan-notification-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', () => {
assert.equal(shouldTriggerRetryFromActionNote('누락된 거 다시 고쳐서 테스트해 줘'), true);
@@ -52,3 +52,9 @@ test('buildPlanNotificationData uses stable task key per plan', () => {
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}`;
}
export function shouldUseLocalMainPlanMode(automationType: unknown) {
const env = getEnv();
return Boolean(env.PLAN_LOCAL_MAIN_MODE) && normalizePlanAutomationType(automationType) !== 'auto_worker';
}
export function mapPlanRow(
row: Record<string, unknown>,
options?: PlanRowOptions,
@@ -2399,7 +2404,6 @@ export async function listPlanIssueSummaries(planItemIds: number[]) {
export async function claimNextPlanForBranch(workerId: string) {
await ensurePlanTable();
const env = getEnv();
return db.transaction(async (trx) => {
const row = await trx(PLAN_TABLE)
@@ -2417,7 +2421,9 @@ export async function claimNextPlanForBranch(workerId: string) {
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)
.where({ id: row.id })
.update({

View File

@@ -30,6 +30,7 @@ import {
markPlanReleaseMerged,
markPlanMerged,
markPlanWorkCompleted,
shouldUseLocalMainPlanMode,
upsertAutoPlanItem,
} from '../services/plan-service.js';
import {
@@ -200,6 +201,10 @@ export class PlanWorker {
return Boolean(getEnv().PLAN_LOCAL_MAIN_MODE);
}
private shouldUseLocalMainModeForPlan(item: { automationType?: unknown }) {
return shouldUseLocalMainPlanMode(item.automationType);
}
start() {
const env = getEnv();
@@ -613,8 +618,10 @@ export class PlanWorker {
planId: number,
workId: string,
note: unknown,
automationType: unknown,
) {
const env = getEnv();
const useLocalMainMode = shouldUseLocalMainPlanMode(automationType);
const runCodexCommandAttempt = async (attempt: number) =>
await new Promise<string>((resolve, reject) => {
let settled = false;
@@ -629,7 +636,7 @@ export class PlanWorker {
stdio: ['ignore', 'pipe', 'pipe'],
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_ACCESS_TOKEN: ERROR_LOG_VIEW_TOKEN,
PLAN_ITEM_ID: String(planId),
@@ -637,7 +644,7 @@ export class PlanWorker {
PLAN_CODEX_TEMPLATE_HOME: env.PLAN_CODEX_TEMPLATE_HOME,
PLAN_GIT_USER_NAME: env.PLAN_GIT_USER_NAME,
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',
},
});
@@ -907,7 +914,7 @@ export class PlanWorker {
const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH);
try {
if (!this.isLocalMainMode()) {
if (!this.shouldUseLocalMainModeForPlan(item)) {
await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH);
await ensureBranchExists(
{
@@ -926,8 +933,8 @@ export class PlanWorker {
return;
}
this.logger.info(
{ planId, branch: assignedBranch, localMainMode: this.isLocalMainMode() },
this.isLocalMainMode() ? 'Plan local main execution prepared' : 'Plan branch created',
{ planId, branch: assignedBranch, localMainMode: this.shouldUseLocalMainModeForPlan(item) },
this.shouldUseLocalMainModeForPlan(item) ? 'Plan local main execution prepared' : 'Plan branch created',
);
} catch (error) {
const message = error instanceof Error ? error.message : '브랜치 생성에 실패했습니다.';
@@ -975,7 +982,7 @@ export class PlanWorker {
const autoDeployToMain = Boolean(item.autoDeployToMain ?? true);
try {
if (this.isLocalMainMode()) {
if (this.shouldUseLocalMainModeForPlan(item)) {
const completedRow = await markPlanAsCompleted(
planId,
'로컬 main 직접 작업 모드에서 release/main 반영 단계를 건너뛰고 완료 처리했습니다.',
@@ -1071,7 +1078,7 @@ export class PlanWorker {
const planLabel = formatPlanNotificationLabel(workId, planId);
try {
if (this.isLocalMainMode()) {
if (this.shouldUseLocalMainModeForPlan(item)) {
const completedRow = await markPlanAsCompleted(
planId,
'로컬 main 직접 작업 모드에서 main 반영 단계를 건너뛰고 완료 처리했습니다.',
@@ -1201,7 +1208,7 @@ export class PlanWorker {
'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 항목이 없습니다.')) {
throw new Error('자동 작업 대상 Plan 항목을 찾지 못했습니다. 상태 전환 로직을 확인해 주세요.');
@@ -1229,7 +1236,7 @@ export class PlanWorker {
return;
}
const finalCompletedRow = this.isLocalMainMode()
const finalCompletedRow = this.shouldUseLocalMainModeForPlan(item)
? await markPlanAsCompleted(planId, '로컬 main 직접 작업으로 자동 작업을 완료했습니다.')
: await markPlanWorkCompleted(planId, this.workerId, '자동 작업을 완료했습니다.');
if (!finalCompletedRow) {
@@ -1240,7 +1247,9 @@ export class PlanWorker {
planId,
workId,
planLabel,
this.isLocalMainMode() ? '자동 작업이 로컬 main 작업본에 직접 반영되어 완료되었습니다.' : this.buildExecutionCompletedBody(autoDeployToMain),
this.shouldUseLocalMainModeForPlan(item)
? '자동 작업이 로컬 main 작업본에 직접 반영되어 완료되었습니다.'
: this.buildExecutionCompletedBody(autoDeployToMain),
'work-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 { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd();
const componentId = process.argv[2];
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) {
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 browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
const context = await createCaptureContext(browser, {
viewport: {
width: 1600,
height: 1200,
},
deviceScaleFactor: 2,
});
const page = await context.newPage();
try {
await ensureDirectory(screenshotDir);
@@ -59,5 +61,6 @@ try {
console.log(`Saved: ${screenshotPath}`);
console.log(`Linked in: ${worklogPath}`);
} finally {
await context.close();
await browser.close();
}

View File

@@ -1,5 +1,6 @@
import process from 'node:process';
import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const FEATURE_CAPTURE_PRESETS = {
@@ -56,7 +57,7 @@ const FEATURE_CAPTURE_PRESETS = {
const cwd = process.cwd();
const presetKey = process.argv[2];
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)) {
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 page = await browser.newPage({
const context = await createCaptureContext(browser, {
viewport: { width: 1600, height: 1200 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
try {
await ensureDirectory(screenshotDir);
@@ -113,5 +115,6 @@ try {
console.log(`Saved: ${screenshotPath}`);
console.log(`Linked in: ${worklogPath}`);
} finally {
await context.close();
await browser.close();
}

View File

@@ -1,10 +1,11 @@
import process from 'node:process';
import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd();
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 { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
cwd,
@@ -13,10 +14,11 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
});
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
const context = await createCaptureContext(browser, {
viewport: { width: 1600, height: 1200 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
try {
await ensureDirectory(screenshotDir);
@@ -41,5 +43,6 @@ try {
console.log(`Saved: ${screenshotPath}`);
console.log(`Linked in: ${worklogPath}`);
} finally {
await context.close();
await browser.close();
}

View File

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

View File

@@ -1,10 +1,11 @@
import process from 'node:process';
import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd();
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 { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
cwd,
@@ -13,7 +14,7 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
});
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
const context = await createCaptureContext(browser, {
viewport: { width: 430, height: 932 },
isMobile: true,
hasTouch: true,

View File

@@ -1,10 +1,11 @@
import process from 'node:process';
import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-utils.mjs';
import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs';
const cwd = process.cwd();
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 { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({
cwd,
@@ -13,7 +14,7 @@ const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolv
});
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
const context = await createCaptureContext(browser, {
viewport: { width: 430, height: 932 },
isMobile: true,
hasTouch: true,

View File

@@ -1,10 +1,8 @@
import process from 'node:process';
import { chromium } from 'playwright';
import { createCaptureContext, getCaptureRuntimeConfig } from './capture-auth-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 = {
automation: {
screenshotFileName: 'settings-app.png',
@@ -19,7 +17,7 @@ const SETTINGS_CAPTURE_PRESETS = {
const cwd = process.cwd();
const presetKey = process.argv[2];
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)) {
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 context = await browser.newContext({
const context = await createCaptureContext(browser, {
viewport: { width: 1600, height: 1200 },
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();
try {

View File

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

View File

@@ -2149,6 +2149,22 @@
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 {
margin: 0;
font-size: 11px;

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import {
ThunderboltOutlined,
UpOutlined,
} 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 {
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> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = '';
@@ -750,6 +760,7 @@ export function ChatConversationView({
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
@@ -808,6 +819,23 @@ export function ChatConversationView({
return [...ordered, ...orphanActivityMessages];
}, [visibleMessages]);
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(() => {
if (typeof document === 'undefined') {
@@ -1150,8 +1178,19 @@ export function ChatConversationView({
{isResourceStripOpen ? (
<div className="app-chat-panel__resource-strip">
{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
key={item.id}
type="button"
@@ -1163,8 +1202,9 @@ export function ChatConversationView({
<span>{item.label}</span>
<span>{item.kind}</span>
</button>
))}
</div>
))}
</div>
</>
) : (
<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_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) {
return value?.trim() ?? '';

View File

@@ -992,6 +992,12 @@ button,
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 {
width: 100%;
min-width: 0;
@@ -1628,6 +1634,16 @@ button,
}
@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 {
gap: 12px;
}

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

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