Compare commits
3 Commits
fix/circul
...
54535e7b5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 54535e7b5e | |||
| bc6904d348 | |||
| fc0d831f57 |
@@ -4,6 +4,19 @@
|
||||
- 4자매 운영 상태를 실시간으로 보여준다
|
||||
- 프로젝트/Sprint/Hotfix/QA/Deploy 흐름을 시각화한다
|
||||
- `main`이 항상 배포 가능 상태라는 원칙을 UI와 운영에 함께 반영한다
|
||||
- 오피스 화면과 운영 패널이 분리되지 않고 하나의 관제 경험으로 이어지게 만든다
|
||||
|
||||
## 저장소 / Git 기준
|
||||
- Repo: `hanarang-dashboard`
|
||||
- Git URL: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
|
||||
- 기본 브랜치: `main`
|
||||
- 현재 오피스 구현 기준 경로:
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/components/office/OfficeScene.tsx`
|
||||
- `frontend/components/office/ContextPanel.tsx`
|
||||
- `frontend/components/office/ChatWorkspace.tsx`
|
||||
- `frontend/components/office/PipelinePanel.tsx`
|
||||
- `frontend/components/office/ServerHealthPanel.tsx`
|
||||
|
||||
## 현재 표준 구조
|
||||
- 루트: `README.md`, `ARCHITECTURE.md`
|
||||
@@ -15,25 +28,37 @@
|
||||
- `deploy/`
|
||||
- 장기 문서: `docs/`
|
||||
|
||||
## 활성 작업
|
||||
- **SPRINT-016**: 4자매 Isometric Office Dashboard 재기획
|
||||
## 현재 상태
|
||||
- **SPRINT-016**: `/office` 화면과 핵심 컴포넌트가 `main`에 반영됨
|
||||
- **다음 활성 작업**: **SPRINT-017** 오피스 대시보드 운영 안정화와 모바일 완성
|
||||
|
||||
## 이번 Sprint 핵심 요구사항
|
||||
1. 4자매와 17개 서브에이전트, 총 21개 에이전트를 오피스 은유로 재설계
|
||||
2. 4자매 독립 Gateway WebSocket 기준 실시간 상태 모델 정리
|
||||
3. 고정 좌석 4자매 + 동적 이동 서브에이전트 + 회의실/연결선 구조 정의
|
||||
4. direct chat / workflow panel / server health를 오피스 화면과 통합
|
||||
5. PRD와 `.plans/` 구조를 새 제품 방향 기준으로 정리
|
||||
## SPRINT-017 핵심 요구사항
|
||||
1. `/office` 화면을 Desktop, Tablet, Mobile에서 같은 운영 의미로 읽히게 만든다
|
||||
2. `live / snapshot / fallback` 상태 구분을 UI와 API 문서에서 같은 규칙으로 고정한다
|
||||
3. direct chat, pipeline, server health 패널의 실패/빈 상태/권한 상태를 문서화하고 정리한다
|
||||
4. WebSocket + polling reconciliation 규칙을 운영 기준으로 확정한다
|
||||
5. QA와 deploy preflight가 재현 가능한 체크리스트로 이어지게 만든다
|
||||
|
||||
## 이행 전략
|
||||
- 코드에서는 구 구조와 신 구조를 일정 기간 동시 지원
|
||||
- 문서는 신 구조를 기준으로 선반영
|
||||
- 코드에서는 이미 반영된 `/office` 구현을 기준으로 안정화 Sprint를 잡는다
|
||||
- 문서는 실제 `main` 구현 경로를 기준으로만 갱신한다
|
||||
- 신규 QA 문서는 `.plans/qa/` 기준
|
||||
- 신규 Hotfix 문서는 `.plans/hotfix/` 기준
|
||||
- SPRINT-017 문서는 SPRINT-016의 비전 문서를 덮어쓰지 않고, 운영 안정화 기준을 덧붙이는 방식으로 간다
|
||||
|
||||
## 문서 맵
|
||||
- 구조 기준: `../ARCHITECTURE.md`
|
||||
- 제품 PRD: `../docs/product-specs/openclaw-office-dashboard-prd.md`
|
||||
- 디자인 인덱스: `./design/index.md`
|
||||
- Sprint 계획: `./sprints/SPRINT-016.md`
|
||||
- PRD: `../docs/product-specs/openclaw-office-dashboard-prd.md`
|
||||
- Sprint 016 비전: `./sprints/SPRINT-016.md`
|
||||
- Sprint 017 실행 계획: `./sprints/SPRINT-017.md`
|
||||
- API / 실시간 모델: `./design/api-design.md`
|
||||
- 오피스 씬 UI 기준: `./design/ui/office-dashboard-design.md`
|
||||
- 오피스 채팅 UI 기준: `./design/ui/office-chat-design.md`
|
||||
- 배포 플로우: `./deploy/main-release-flow.md`
|
||||
|
||||
## 교차 참조 규칙
|
||||
- Sprint 문서는 반드시 관련 design 문서를 링크한다
|
||||
- design 문서는 실제 Git 구현 경로를 함께 적는다
|
||||
- QA / deploy 문서는 대상 Sprint 문서와 구현 commit을 함께 남긴다
|
||||
- 여기까지가 SPRINT-017 기준 scope야.
|
||||
|
||||
@@ -3,36 +3,137 @@
|
||||
## 공통 규칙
|
||||
- Base URL: `https://hanarang-api.nabomhalang.co.kr`
|
||||
- 응답 형식: JSON
|
||||
- 인증: MVP에서는 인증 없음 (내부망 전용). 추후 JWT 추가 가능.
|
||||
- 에러 형식: `{ "statusCode": 400, "message": "...", "error": "Bad Request" }`
|
||||
- WS Namespace: `/ws`
|
||||
- 기본 에러 형식: `{ "statusCode": 400, "message": "...", "error": "Bad Request" }`
|
||||
- SPRINT-017 기준으로 오피스 화면은 `REST snapshot + WebSocket push` 혼합 모델을 사용한다.
|
||||
|
||||
## Sisters (자매 상태)
|
||||
## 인증 규칙
|
||||
- 읽기 전용 상태 조회 API는 현재 공개 조회가 가능한 엔드포인트가 섞여 있어.
|
||||
- direct chat (`POST /api/sisters/:name/chat`) 은 JWT 필수야.
|
||||
- WebSocket 연결도 JWT 필수야. 토큰이 없거나 잘못되면 서버가 연결을 끊어.
|
||||
- 그래서 `/office`는 **읽기와 쓰기의 권한 상태를 분리해서** 다뤄야 해.
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/sisters` | 4자매 상태 목록 (SSH로 실시간 조회) |
|
||||
| GET | `/api/sisters/:name` | 자매 상세 (설정 + 상태) |
|
||||
| GET | `/api/sisters/:name/config` | openclaw.json 내용 |
|
||||
| GET | `/api/sisters/:name/sessions` | 최근 세션 목록 |
|
||||
| GET | `/api/sisters/:name/subagents` | 서브에이전트 사용 현황 |
|
||||
| POST | `/api/sisters/:name/restart` | Gateway 재시작 (관리자) |
|
||||
| POST | `/api/sisters/:name/reset` | 세션 리셋 (관리자) |
|
||||
## 오피스 대시보드 핵심 소스
|
||||
- Git 구현 기준:
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `backend/src/sisters/sisters.controller.ts`
|
||||
- `backend/src/events/events.gateway.ts`
|
||||
- `backend/src/events/events.scheduler.ts`
|
||||
- 관련 Sprint: `../sprints/SPRINT-017.md`
|
||||
- 관련 UI 문서:
|
||||
- `./ui/office-dashboard-design.md`
|
||||
- `./ui/office-chat-design.md`
|
||||
|
||||
### GET `/api/sisters`
|
||||
## 상태 모델
|
||||
### Data Mode
|
||||
| mode | 의미 | UI 원칙 |
|
||||
|---|---|---|
|
||||
| `live` | WebSocket 또는 최신 runtime 기준으로 실시간성이 유지되는 상태 | 가장 신뢰도 높은 상태로 표시 |
|
||||
| `snapshot` | REST polling 기준 최신 스냅샷 | live보다 약한 상태로 표시 |
|
||||
| `fallback` | runtime 또는 status 조회 실패 시 보여주는 보정 데이터 | 추정치임을 숨기지 않음 |
|
||||
|
||||
### Agent State
|
||||
| state | 의미 |
|
||||
|---|---|
|
||||
| `idle` | 대기 중 |
|
||||
| `thinking` | 작업 준비 / 추론 중 |
|
||||
| `tool_calling` | 외부 작업/도구 호출 중 |
|
||||
| `speaking` | 응답 생성 또는 대화 중 |
|
||||
| `error` | 연결 또는 런타임 이상 |
|
||||
|
||||
## Sisters (오피스 화면 기준)
|
||||
|
||||
| Method | Endpoint | 인증 | 설명 |
|
||||
|--------|----------|------|------|
|
||||
| GET | `/api/sisters` | 없음 | 4자매 상태 목록 |
|
||||
| GET | `/api/sisters/runtime` | 없음 | 4자매 runtime 스냅샷 |
|
||||
| GET | `/api/sisters/:name/runtime` | 없음 | 개별 자매 runtime |
|
||||
| GET | `/api/sisters/:name/system` | 없음 | 개별 자매 시스템 정보 |
|
||||
| GET | `/api/sisters/:name/avatar` | 없음 | 자매 아바타 이미지 |
|
||||
| GET | `/api/sisters/:name/config` | 없음 | openclaw 설정 조회 |
|
||||
| GET | `/api/sisters/:name/sessions` | 없음 | 최근 세션 목록 |
|
||||
| GET | `/api/sisters/:name/subagents` | 없음 | 서브에이전트 목록/현황 |
|
||||
| GET | `/api/sisters/:name/activity` | 없음 | 최근 활동 로그 |
|
||||
| POST | `/api/sisters/:name/chat` | JWT 필요 | direct chat 전송 |
|
||||
|
||||
### GET `/api/sisters/runtime`
|
||||
오피스 메인 화면의 상단 상태와 최근 메시지, 서브에이전트 상태를 구성하는 runtime source야.
|
||||
|
||||
예시 필드:
|
||||
```json
|
||||
// Response 200
|
||||
[
|
||||
{
|
||||
"name": "harang",
|
||||
"displayName": "하랑이",
|
||||
"ip": "10.10.10.112",
|
||||
"status": "online",
|
||||
"lastSeen": "2026-04-04T01:45:00Z",
|
||||
"role": "Orchestrator"
|
||||
"gatewayConnected": true,
|
||||
"mainState": "thinking",
|
||||
"currentTask": "SPRINT-017 scope 잠금",
|
||||
"activeSessionLabel": "main",
|
||||
"activeSessionUpdatedAt": 1775640000000,
|
||||
"controlSessionKey": "agent:harang:main",
|
||||
"recentMessages": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"role": "assistant",
|
||||
"content": "scope 정리 중",
|
||||
"ts": "2026-04-08T09:20:00Z"
|
||||
}
|
||||
],
|
||||
"subagents": [
|
||||
{
|
||||
"name": "prd-writer",
|
||||
"state": "tool_calling",
|
||||
"updatedAt": 1775640000000,
|
||||
"currentTask": "SPRINT-017 작성",
|
||||
"sessionLabel": "main"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### POST `/api/sisters/:name/chat`
|
||||
```json
|
||||
// Request
|
||||
{ "message": "SPRINT-017 scope 확인해" }
|
||||
```
|
||||
|
||||
```json
|
||||
// Response 200 example
|
||||
{
|
||||
"ok": true,
|
||||
"queued": true,
|
||||
"sessionKey": "agent:harang:main"
|
||||
}
|
||||
```
|
||||
|
||||
### Chat 실패 처리 원칙
|
||||
- JWT 없음 → 입력창 비활성화 또는 전송 실패 이유 명시
|
||||
- timeout → 전송은 재시도 가능 상태로 남김
|
||||
- 최근 메시지 없음 → empty state 문구 사용
|
||||
- tool 메시지와 assistant 메시지는 같은 bubble로 합치지 않음
|
||||
|
||||
## WebSocket
|
||||
|
||||
### 연결
|
||||
- Namespace: `/ws`
|
||||
- 인증 방식:
|
||||
- `handshake.auth.token`
|
||||
- 또는 `Authorization: Bearer <token>`
|
||||
- 토큰 없음/검증 실패 시 disconnect
|
||||
|
||||
### 서버 이벤트
|
||||
| Event | Payload | 설명 |
|
||||
|---|---|---|
|
||||
| `pong` | `{ ts }` | ping 응답 |
|
||||
| `sisters:update` | `{ sisters, ts }` | 4자매 상태 push |
|
||||
| `activity:new` | `{ item, ts }` | 새 활동 로그 push |
|
||||
|
||||
### 운영 규칙
|
||||
- WS는 가장 강한 source야.
|
||||
- WS가 끊겨도 마지막 성공 시각을 보존해 stale 여부를 판단해야 해.
|
||||
- scheduler polling 값이 더 오래된 경우 live 값을 덮어쓰면 안 돼.
|
||||
- SPRINT-017에서는 reconnect / stale / snapshot downgrade 규칙을 문서와 QA 기준으로 잠근다.
|
||||
|
||||
## Projects (프로젝트)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
@@ -69,3 +170,9 @@
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/health` | 서버 상태 확인 |
|
||||
|
||||
## SPRINT-017 문서 기준 정리
|
||||
- 오피스 화면은 읽기 API와 쓰기 API 권한을 분리해서 다룬다
|
||||
- `live / snapshot / fallback`은 API 문서, UI 문서, QA 문서에서 같은 의미로 쓴다
|
||||
- direct chat, WS disconnect, stale 상태는 정상 흐름만큼 중요하게 검증한다
|
||||
- 여기까지가 API 기준 scope야.
|
||||
|
||||
@@ -21,8 +21,20 @@
|
||||
## 레퍼런스
|
||||
- `references/07-master-dashboard-v3-reference.md`
|
||||
|
||||
## Sprint 016에서 반드시 반영할 화면
|
||||
- 오피스 메인: 4자매 고정 좌석 + 서브에이전트 동적 이동
|
||||
- 오피스 채팅: 자매 선택 direct chat + 현재 상태 결합
|
||||
- 운영 패널: active workflow / sprint / review loop / deploy gate / server health
|
||||
- 모바일: 오피스 씬 단순화 + 세로 흐름 재배치
|
||||
## 현재 우선 문서
|
||||
- Sprint 실행 기준: `../sprints/SPRINT-017.md`
|
||||
- 제품 비전: `../../docs/product-specs/openclaw-office-dashboard-prd.md`
|
||||
- 실행 개요: `../OVERVIEW.md`
|
||||
|
||||
## SPRINT-017에서 반드시 잠글 것
|
||||
- 오피스 메인: Desktop / Tablet / Mobile 정보 우선순위
|
||||
- 오피스 채팅: JWT 필요, 전송 실패, empty state 처리
|
||||
- 운영 패널: active workflow / sprint / review loop / deploy gate / server health 상태 톤 통일
|
||||
- 실시간 모델: `live / snapshot / fallback` 판정 규칙
|
||||
- WS + polling reconciliation: stale / reconnect / downgrade 기준
|
||||
|
||||
## Git 기준 확인 경로
|
||||
- `/office` entry: `frontend/app/office/page.tsx`
|
||||
- scene: `frontend/components/office/OfficeScene.tsx`
|
||||
- chat: `frontend/components/office/ChatWorkspace.tsx`
|
||||
- ws gateway: `backend/src/events/events.gateway.ts`
|
||||
|
||||
178
.plans/sprints/SPRINT-017.md
Normal file
178
.plans/sprints/SPRINT-017.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# SPRINT-017: 오피스 대시보드 운영 안정화와 모바일 완성
|
||||
|
||||
## 목표
|
||||
`/office`를 데모 화면이 아니라 운영자가 실제로 믿고 쓰는 관제 화면으로 고정한다. 이번 Sprint의 중심은 새 기능 추가가 아니라, 이미 `main`에 들어온 오피스 대시보드를 **실시간성, 반응형, 실패 처리, QA 기준**까지 포함해 운영 가능 상태로 만드는 거야.
|
||||
|
||||
## 배경
|
||||
SPRINT-016에서 아래는 이미 확보됐어.
|
||||
- 오피스 PRD와 핵심 IA
|
||||
- `frontend/app/office/page.tsx` 진입 경로
|
||||
- `OfficeScene`, `ContextPanel`, `ChatWorkspace`, `PipelinePanel`, `ServerHealthPanel` 구현
|
||||
- release preflight 문서와 기본 빌드 통과 근거
|
||||
|
||||
하지만 여기서 멈추면 안 돼.
|
||||
- live 데이터가 약해질 때 무엇을 보여줄지
|
||||
- 모바일에서 어떤 정보만 남기고 무엇을 접을지
|
||||
- direct chat이 권한/JWT/오류 상황에서 어떻게 반응할지
|
||||
- WebSocket과 polling snapshot이 충돌할 때 어떤 값이 우선인지
|
||||
이 기준이 문서와 QA에서 아직 완전히 잠기지 않았어.
|
||||
|
||||
## 참고 문서
|
||||
- PRD: `docs/product-specs/openclaw-office-dashboard-prd.md`
|
||||
- 실행 개요: `.plans/OVERVIEW.md`
|
||||
- 이전 Sprint: `.plans/sprints/SPRINT-016.md`
|
||||
- UI 기준: `.plans/design/ui/office-dashboard-design.md`
|
||||
- 채팅 기준: `.plans/design/ui/office-chat-design.md`
|
||||
- API 기준: `.plans/design/api-design.md`
|
||||
- 배포/검증: `.plans/qa/SPRINT-016-release-preflight.md`
|
||||
|
||||
## Git 링크
|
||||
- Repo: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard
|
||||
- `/office` entry: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard/src/branch/main/frontend/app/office/page.tsx
|
||||
- office components: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard/src/branch/main/frontend/components/office
|
||||
- sisters controller: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard/src/branch/main/backend/src/sisters/sisters.controller.ts
|
||||
- websocket gateway: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard/src/branch/main/backend/src/events/events.gateway.ts
|
||||
|
||||
## Sprint 범위
|
||||
- `/office` 운영 안정화
|
||||
- Desktop / Tablet / Mobile 정보 우선순위 재정리
|
||||
- live / snapshot / fallback 판정 규칙 고정
|
||||
- direct chat 실패/권한/빈 상태 처리 강화
|
||||
- WebSocket reconnect / polling reconciliation 기준 문서화
|
||||
- QA 재현 기준과 deploy gate 정리
|
||||
|
||||
## 이번 Sprint에서 하지 않는 것
|
||||
- 새로운 오피스 씬 세계관 추가
|
||||
- 3D 전환
|
||||
- 음성/영상 채널 추가
|
||||
- 별도 모바일 앱 설계
|
||||
- Gateway 프로토콜 자체 신규 설계
|
||||
|
||||
## 완료 기준
|
||||
- Desktop / Tablet / Mobile에서 `/office` 핵심 판단 정보가 모두 남아 있음
|
||||
- `live / snapshot / fallback` 판정이 UI와 API 문서에서 같은 의미로 쓰임
|
||||
- direct chat, pipeline, health 패널의 오류/권한/빈 상태가 정의되고 QA 가능함
|
||||
- WS 끊김, JWT 없음, 데이터 지연 상황에서 운영자가 왜 이런 화면이 보이는지 설명 가능함
|
||||
- QA 체크와 deploy preflight가 SPRINT-017 기준으로 이어짐
|
||||
|
||||
## 태스크
|
||||
|
||||
### TASK-100: SPRINT-017 scope 확정과 문서 교차 참조 정리
|
||||
- **담당:** 하랑이
|
||||
- **상태:** pending
|
||||
- **산출물:**
|
||||
- `.plans/OVERVIEW.md` 갱신
|
||||
- `.plans/sprints/SPRINT-017.md`
|
||||
- `.plans/design/api-design.md` 갱신
|
||||
- **설명:** SPRINT-016 비전 문서와 실제 `main` 구현 사이의 차이를 정리하고, 이번 Sprint의 안정화 scope를 문서 레벨에서 잠근다.
|
||||
- **done_when:**
|
||||
- SPRINT-017 문서가 실제 Git 구현 경로를 링크함
|
||||
- 관련 문서 간 교차 참조가 누락 없이 연결됨
|
||||
- active sprint가 OVERVIEW에서 SPRINT-017로 표시됨
|
||||
|
||||
### TASK-101: live / snapshot / fallback 상태 모델 고정
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/components/office/OfficeScene.tsx`
|
||||
- `backend/src/sisters/sisters.controller.ts`
|
||||
- `backend/src/events/events.gateway.ts`
|
||||
- **설명:** 오피스 화면이 어떤 데이터 출처를 보고 있는지 명확히 드러내고, WS 데이터와 REST snapshot이 섞일 때 우선순위를 고정한다.
|
||||
- **done_when:**
|
||||
- 화면 상에서 현재 데이터 모드가 `live`, `snapshot`, `fallback` 중 하나로 읽힘
|
||||
- WS 끊김 후 snapshot으로 내려가는 기준 시간이 정리됨
|
||||
- fallback 진입 조건이 문서와 코드 주석/상수 기준으로 일치함
|
||||
|
||||
### TASK-102: Office Scene 반응형 정보 우선순위 재정의
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/components/office/OfficeScene.tsx`
|
||||
- `frontend/components/office/ContextPanel.tsx`
|
||||
- `frontend/components/office/PipelinePanel.tsx`
|
||||
- `frontend/components/office/ServerHealthPanel.tsx`
|
||||
- **설명:** 모바일에서는 전체 씬을 그대로 축소하지 말고, 운영 의미가 유지되는 최소 단위로 재배치한다.
|
||||
- **done_when:**
|
||||
- Desktop(≥1280), Tablet(768~1279), Mobile(<768) 기준 레이아웃이 정의됨
|
||||
- Mobile에서 4자매 상태, 현재 focus, health summary가 첫 화면 안에 들어옴
|
||||
- 고정 높이/overflow 때문에 주요 패널이 잘리지 않음
|
||||
|
||||
### TASK-103: direct chat 권한/오류/빈 상태 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/components/office/ChatWorkspace.tsx`
|
||||
- `backend/src/sisters/sisters.controller.ts`
|
||||
- **설명:** JWT 없음, 전송 실패, 응답 지연, 최근 메시지 없음 같은 운영 상황을 조용히 숨기지 않고 정직하게 보여준다.
|
||||
- **done_when:**
|
||||
- JWT 없음 상태에서 전송 불가 이유가 사용자에게 표시됨
|
||||
- 전송 실패/timeout 시 재시도 또는 안내 문구가 있음
|
||||
- 최근 대화가 없을 때 빈 상태 문구가 존재함
|
||||
- tool 메시지와 assistant 메시지가 구분돼 보임
|
||||
|
||||
### TASK-104: WebSocket reconnect와 polling reconciliation 운영 규칙 정리
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `backend/src/events/events.gateway.ts`
|
||||
- `backend/src/events/events.scheduler.ts`
|
||||
- `frontend/lib/useSocket.ts`
|
||||
- `frontend/app/office/page.tsx`
|
||||
- **설명:** 지금 구조는 WS broadcast와 주기적 snapshot이 함께 가고 있어. 이 둘의 충돌/지연 시 동작 원리를 문서와 구현에서 함께 잠가야 해.
|
||||
- **done_when:**
|
||||
- reconnect backoff 또는 재연결 전략이 문서화됨
|
||||
- 마지막 성공 수신 시각을 기준으로 stale 상태를 표시할 수 있음
|
||||
- snapshot이 WS보다 오래된 데이터를 덮어쓰지 않게 기준이 정리됨
|
||||
|
||||
### TASK-105: 오피스 패널 loading / empty / stale UX 통일
|
||||
- **담당:** 나랑이
|
||||
- **상태:** pending
|
||||
- **주요 파일:**
|
||||
- `frontend/app/office/page.tsx`
|
||||
- `frontend/components/office/*`
|
||||
- **설명:** 씬, 채팅, 파이프라인, 서버 헬스가 각자 다른 실패 톤으로 반응하면 운영자가 혼란스러워져. 상태 톤을 하나로 맞춘다.
|
||||
- **done_when:**
|
||||
- loading, empty, stale, error 네 상태가 전 패널에서 같은 규칙으로 보임
|
||||
- fallback 데이터 사용 시 라벨 또는 보조 문구가 있음
|
||||
- 데이터 없음과 오류를 같은 문구로 처리하지 않음
|
||||
|
||||
### TASK-106: QA 매트릭스 작성 및 실브라우저 검증
|
||||
- **담당:** 다랑이
|
||||
- **상태:** pending
|
||||
- **산출물:**
|
||||
- `.plans/qa/SPRINT-017-review-1.md`
|
||||
- 필요 시 `.plans/qa/SPRINT-017-review-2.md`
|
||||
- **설명:** 이번 Sprint의 QA는 단순 UI 확인이 아니라 운영 실패 시나리오까지 포함해야 해.
|
||||
- **done_when:**
|
||||
- Desktop / Tablet / Mobile 실브라우저 검증 기록이 있음
|
||||
- WS disconnect, JWT 없음, 데이터 없음, 긴 task 텍스트 케이스가 포함됨
|
||||
- blocker / warning / follow-up이 분리 기록됨
|
||||
|
||||
### TASK-107: 배포 전 운영 준비사항 잠금
|
||||
- **담당:** 이랑이
|
||||
- **상태:** pending
|
||||
- **산출물:**
|
||||
- `.plans/qa/SPRINT-017-release-preflight.md`
|
||||
- 필요 시 `.plans/deploy/deploy-plan.md` 보강
|
||||
- **설명:** `/office`는 WS와 env 의존성이 있어서 코드만 통과해도 운영 배포에서 무너질 수 있어. 배포 전 체크를 문서로 잠근다.
|
||||
- **done_when:**
|
||||
- `NEXT_PUBLIC_API_URL`, 필요 시 `NEXT_PUBLIC_WS_URL`, `JWT_SECRET`, `CORS_ORIGINS`, WS proxy 체크가 문서화됨
|
||||
- build/test/lint 외 운영 env 검증 항목이 분리됨
|
||||
- deploy gate 통과 조건이 문서에 명시됨
|
||||
|
||||
## 검증 시나리오
|
||||
1. 브라우저 첫 진입 시 `/office`에서 4자매 상태와 현재 focus를 5초 안에 설명할 수 있어야 해.
|
||||
2. WS 연결이 끊겨도 snapshot 또는 fallback 전환이 조용히 숨겨지지 않아야 해.
|
||||
3. 모바일에서 씬, pipeline, health, chat 중 무엇이 우선인지 일관돼야 해.
|
||||
4. direct chat이 실패했을 때 무응답처럼 보이지 않아야 해.
|
||||
5. QA 문서와 deploy preflight가 같은 기준 용어를 써야 해.
|
||||
|
||||
## 핸드오프 메모
|
||||
- 하랑이는 용어와 상태 모델을 잠그는 역할이야.
|
||||
- 나랑이는 `/office`를 운영 화면으로 만드는 역할이야.
|
||||
- 다랑이는 브라우저와 실패 시나리오를 실제로 깨보는 역할이야.
|
||||
- 이랑이는 env, WS proxy, deploy gate를 잠그는 역할이야.
|
||||
|
||||
여기까지가 scope야.
|
||||
128
backend/src/auth/auth-cookies.ts
Normal file
128
backend/src/auth/auth-cookies.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { randomBytes, timingSafeEqual } from 'crypto';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
|
||||
export const ACCESS_TOKEN_COOKIE = 'hanarang_access_token';
|
||||
export const REFRESH_TOKEN_COOKIE = 'hanarang_refresh_token';
|
||||
export const CSRF_TOKEN_COOKIE = 'hanarang_csrf_token';
|
||||
|
||||
const ACCESS_TOKEN_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
const REFRESH_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function isHttpsRequest(req?: Request): boolean {
|
||||
const forwardedProto = req?.headers['x-forwarded-proto'];
|
||||
const protocol = Array.isArray(forwardedProto)
|
||||
? forwardedProto[0]
|
||||
: forwardedProto;
|
||||
|
||||
return (
|
||||
process.env.NODE_ENV === 'production' ||
|
||||
req?.secure === true ||
|
||||
protocol === 'https'
|
||||
);
|
||||
}
|
||||
|
||||
function baseCookieOptions(req?: Request): CookieOptions {
|
||||
return {
|
||||
path: '/',
|
||||
secure: isHttpsRequest(req),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCookieHeader(
|
||||
header?: string | string[],
|
||||
): Record<string, string> {
|
||||
const raw = Array.isArray(header) ? header.join(';') : header;
|
||||
if (!raw) return {};
|
||||
|
||||
return raw
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.reduce<Record<string, string>>((cookies, part) => {
|
||||
const eqIndex = part.indexOf('=');
|
||||
if (eqIndex === -1) return cookies;
|
||||
|
||||
const key = decodeURIComponent(part.slice(0, eqIndex).trim());
|
||||
const value = decodeURIComponent(part.slice(eqIndex + 1).trim());
|
||||
cookies[key] = value;
|
||||
return cookies;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function getCookieValue(
|
||||
req: Pick<Request, 'headers'>,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
return parseCookieHeader(req.headers.cookie)[name];
|
||||
}
|
||||
|
||||
export function generateCsrfToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export function hasValidCsrfToken(req: Request): boolean {
|
||||
const cookieToken = getCookieValue(req, CSRF_TOKEN_COOKIE);
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
const requestToken = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
||||
|
||||
if (!cookieToken || !requestToken) return false;
|
||||
|
||||
const cookieBuffer = Buffer.from(cookieToken);
|
||||
const requestBuffer = Buffer.from(requestToken);
|
||||
|
||||
if (cookieBuffer.length !== requestBuffer.length) return false;
|
||||
|
||||
try {
|
||||
return timingSafeEqual(cookieBuffer, requestBuffer);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthCookies(
|
||||
res: Response,
|
||||
req: Request,
|
||||
tokens: { accessToken: string; refreshToken: string },
|
||||
csrfToken = generateCsrfToken(),
|
||||
) {
|
||||
res.cookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
|
||||
...baseCookieOptions(req),
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ACCESS_TOKEN_MAX_AGE_MS,
|
||||
});
|
||||
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
|
||||
...baseCookieOptions(req),
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: REFRESH_TOKEN_MAX_AGE_MS,
|
||||
});
|
||||
|
||||
res.cookie(CSRF_TOKEN_COOKIE, csrfToken, {
|
||||
...baseCookieOptions(req),
|
||||
httpOnly: false,
|
||||
sameSite: 'strict',
|
||||
maxAge: REFRESH_TOKEN_MAX_AGE_MS,
|
||||
});
|
||||
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
export function clearAuthCookies(res: Response, req?: Request) {
|
||||
res.clearCookie(ACCESS_TOKEN_COOKIE, {
|
||||
...baseCookieOptions(req),
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, {
|
||||
...baseCookieOptions(req),
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
res.clearCookie(CSRF_TOKEN_COOKIE, {
|
||||
...baseCookieOptions(req),
|
||||
httpOnly: false,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,30 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
BadRequestException,
|
||||
Body,
|
||||
Headers,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
Request,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import { AuthService, LoginDto } from './auth.service';
|
||||
import { JwtGuard } from './jwt.guard';
|
||||
import type {
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { AuthService, LoginDto } from './auth.service';
|
||||
import {
|
||||
getCookieValue,
|
||||
hasValidCsrfToken,
|
||||
setAuthCookies,
|
||||
clearAuthCookies,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
} from './auth-cookies';
|
||||
import { JwtGuard } from './jwt.guard';
|
||||
|
||||
interface AuthenticatedRequest extends ExpressRequest {
|
||||
user: { userId: number };
|
||||
@@ -21,17 +34,58 @@ interface AuthenticatedRequest extends ExpressRequest {
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Req() req: ExpressRequest,
|
||||
@Res({ passthrough: true }) res: ExpressResponse,
|
||||
) {
|
||||
const tokens = await this.authService.login(dto);
|
||||
setAuthCookies(res, req, tokens);
|
||||
|
||||
return {
|
||||
username: tokens.username,
|
||||
role: tokens.role,
|
||||
};
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@Post('refresh')
|
||||
refresh(@Headers('x-refresh-token') token: string) {
|
||||
async refresh(
|
||||
@Req() req: ExpressRequest,
|
||||
@Res({ passthrough: true }) res: ExpressResponse,
|
||||
) {
|
||||
const cookieToken = getCookieValue(req, REFRESH_TOKEN_COOKIE);
|
||||
const headerToken = req.headers['x-refresh-token'];
|
||||
const token = cookieToken ?? (Array.isArray(headerToken) ? headerToken[0] : headerToken);
|
||||
|
||||
if (!token) throw new BadRequestException('Refresh token required');
|
||||
return this.authService.refresh(token);
|
||||
if (cookieToken && !hasValidCsrfToken(req)) {
|
||||
throw new UnauthorizedException('Invalid CSRF token');
|
||||
}
|
||||
|
||||
const tokens = await this.authService.refresh(token);
|
||||
setAuthCookies(res, req, tokens);
|
||||
|
||||
return {
|
||||
username: tokens.username,
|
||||
role: tokens.role,
|
||||
};
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Post('logout')
|
||||
logout(
|
||||
@Req() req: ExpressRequest,
|
||||
@Res({ passthrough: true }) res: ExpressResponse,
|
||||
) {
|
||||
clearAuthCookies(res, req);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ACCESS_TOKEN_COOKIE, getCookieValue } from './auth-cookies';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
@@ -9,13 +11,21 @@ export interface JwtPayload {
|
||||
role: string;
|
||||
}
|
||||
|
||||
function cookieTokenExtractor(req?: Request): string | null {
|
||||
if (!req) return null;
|
||||
return getCookieValue(req, ACCESS_TOKEN_COOKIE) ?? null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(config: ConfigService) {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) throw new Error('JWT_SECRET not set');
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
cookieTokenExtractor,
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
]),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { ACCESS_TOKEN_COOKIE, parseCookieHeader } from '../auth/auth-cookies';
|
||||
|
||||
interface SocketUserPayload {
|
||||
username?: string;
|
||||
@@ -50,12 +51,17 @@ export class EventsGateway
|
||||
|
||||
handleConnection(client: SocketWithUser) {
|
||||
// JWT 인증 필수 — 토큰 없거나 유효하지 않으면 disconnect
|
||||
const authHeader = client.handshake.headers.authorization;
|
||||
const tokenFromHeader = Array.isArray(authHeader)
|
||||
? authHeader[0]?.replace('Bearer ', '')
|
||||
: authHeader?.replace('Bearer ', '');
|
||||
const tokenFromCookie = parseCookieHeader(client.handshake.headers.cookie)[
|
||||
ACCESS_TOKEN_COOKIE
|
||||
];
|
||||
const token =
|
||||
(client.handshake.auth?.token as string) ??
|
||||
(client.handshake.headers.authorization as string)?.replace(
|
||||
'Bearer ',
|
||||
'',
|
||||
);
|
||||
(client.handshake.auth?.token as string | undefined) ??
|
||||
tokenFromHeader ??
|
||||
tokenFromCookie;
|
||||
|
||||
if (!token) {
|
||||
this.logger.debug(`WS rejected (no token): ${client.id}`);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
import {
|
||||
ACCESS_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
getCookieValue,
|
||||
hasValidCsrfToken,
|
||||
} from './auth/auth-cookies';
|
||||
|
||||
function getAllowedOrigins() {
|
||||
return (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
|
||||
@@ -10,10 +17,40 @@ function getAllowedOrigins() {
|
||||
.filter((origin): origin is string => origin.length > 0);
|
||||
}
|
||||
|
||||
function shouldBypassCsrf(req: Request): boolean {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method.toUpperCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.path === '/api/auth/login') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasAuthorizationHeader = Boolean(req.headers.authorization);
|
||||
if (hasAuthorizationHeader) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasAuthCookie = Boolean(
|
||||
getCookieValue(req, ACCESS_TOKEN_COOKIE) ||
|
||||
getCookieValue(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
|
||||
return !hasAuthCookie;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.use(helmet());
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (shouldBypassCsrf(req) || hasValidCsrfToken(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({ message: 'Invalid CSRF token' });
|
||||
});
|
||||
|
||||
const allowedOrigins = getAllowedOrigins();
|
||||
|
||||
@@ -29,6 +66,12 @@ async function bootstrap() {
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'X-CSRF-Token',
|
||||
'X-Refresh-Token',
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SshService } from './ssh.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
type SisterName = 'harang' | 'narang' | 'darang' | 'erang';
|
||||
|
||||
@@ -20,6 +20,15 @@ const SISTER_DESCRIPTIONS: Record<SisterName, string> = {
|
||||
erang: '인프라와 배포. 서버 관리, merge, 프로덕션 배포를 담당한다.',
|
||||
};
|
||||
|
||||
function summarizeText(value?: string | null, maxLength = 120): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
const compact = value.replace(/\s+/g, ' ').trim();
|
||||
if (!compact) return null;
|
||||
if (compact.length <= maxLength) return compact;
|
||||
return `${compact.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SisterDetailService {
|
||||
private readonly logger = new Logger(SisterDetailService.name);
|
||||
@@ -39,21 +48,36 @@ export class SisterDetailService {
|
||||
sister.ip,
|
||||
sister.user,
|
||||
sshKeyPath,
|
||||
'WORKSPACE=~/.hermes/workspace; [ -d "$WORKSPACE" ] || WORKSPACE=~/.openclaw/workspace; cat "$WORKSPACE"/SOUL.md 2>/dev/null | head -60; echo "---AGENTS---"; cat "$WORKSPACE"/AGENTS.md 2>/dev/null | head -40',
|
||||
'WORKSPACE=~/.hermes/workspace; [ -d "$WORKSPACE" ] || WORKSPACE=~/.openclaw/workspace; for FILE in SOUL.md AGENTS.md; do if [ -f "$WORKSPACE/$FILE" ]; then printf "%s|present|%s\n" "$FILE" "$(wc -l < "$WORKSPACE/$FILE" 2>/dev/null || echo 0)"; else printf "%s|missing|0\n" "$FILE"; fi; done',
|
||||
);
|
||||
|
||||
const files = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const [file, status, lineCount] = line.split('|');
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
lineCount: Number.parseInt(lineCount ?? '0', 10) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
role: SISTER_ROLES[name],
|
||||
description: SISTER_DESCRIPTIONS[name],
|
||||
raw: result.stdout || '(설정 파일 없음)',
|
||||
files,
|
||||
summary: `${files.filter((file) => file.status === 'present').length}/${files.length} protected config files detected`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name,
|
||||
role: SISTER_ROLES[name],
|
||||
description: SISTER_DESCRIPTIONS[name],
|
||||
raw: '(SSH 연결 불가)',
|
||||
files: [],
|
||||
summary: '(SSH 연결 불가)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -70,21 +94,34 @@ export class SisterDetailService {
|
||||
'SESSION_DIR=~/.hermes/agents/main/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/agents/main/sessions; ls -lt "$SESSION_DIR"/ 2>/dev/null | head -20 || echo ""',
|
||||
);
|
||||
|
||||
const lines = result.stdout
|
||||
const sessions = result.stdout
|
||||
.split('\n')
|
||||
.filter((l) => l && !l.startsWith('total'));
|
||||
return lines
|
||||
.filter((line) => line && !line.startsWith('total'))
|
||||
.map((line) => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const label = parts[parts.length - 1] ?? '';
|
||||
const modified = parts.slice(5, 8).join(' ');
|
||||
const size = parts[4] ?? '0';
|
||||
|
||||
return {
|
||||
name: parts[parts.length - 1] ?? '',
|
||||
modified: parts.slice(5, 8).join(' '),
|
||||
size: parts[4] ?? '0',
|
||||
id: label,
|
||||
label,
|
||||
status: [modified, size !== '0' ? `${size}B` : null]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(' · '),
|
||||
};
|
||||
})
|
||||
.filter((s) => s.name && s.name !== '');
|
||||
.filter((session) => session.label.length > 0);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
total: sessions.length,
|
||||
};
|
||||
} catch {
|
||||
return [];
|
||||
return {
|
||||
sessions: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,15 +137,25 @@ export class SisterDetailService {
|
||||
'AGENT_DIR=~/.hermes/workspace/agents; [ -d "$AGENT_DIR" ] || AGENT_DIR=~/.openclaw/workspace/agents; ls "$AGENT_DIR"/ 2>/dev/null || echo ""',
|
||||
);
|
||||
|
||||
const agents = result.stdout
|
||||
const items = result.stdout
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().endsWith('.md'));
|
||||
return agents.map((a) => ({
|
||||
name: a.trim().replace('.md', ''),
|
||||
file: a.trim(),
|
||||
}));
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.endsWith('.md'))
|
||||
.map((file) => ({
|
||||
id: file.replace('.md', ''),
|
||||
label: file.replace('.md', ''),
|
||||
file,
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
};
|
||||
} catch {
|
||||
return [];
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +169,16 @@ export class SisterDetailService {
|
||||
}),
|
||||
this.prisma.activityLog.count({ where: { sisterId: sister.id } }),
|
||||
]);
|
||||
return { items: logs, total };
|
||||
|
||||
return {
|
||||
items: logs.map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
detail: summarizeText(log.detail),
|
||||
createdAt: log.createdAt,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async getOrgData() {
|
||||
@@ -157,8 +213,8 @@ export class SisterDetailService {
|
||||
}
|
||||
|
||||
private getKeyPath(): string {
|
||||
const p = this.config.get<string>('SSH_KEY_PATH');
|
||||
if (!p) throw new Error('SSH_KEY_PATH is not set');
|
||||
return p;
|
||||
const path = this.config.get<string>('SSH_KEY_PATH');
|
||||
if (!path) throw new Error('SSH_KEY_PATH is not set');
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { SistersService } from './sisters.service';
|
||||
import { SisterDetailService } from './sister-detail.service';
|
||||
import { JwtGuard } from '../auth/jwt.guard';
|
||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||
import type { SisterName } from '../common/sister-name.pipe';
|
||||
import { AvatarService } from './avatar.service';
|
||||
import { JwtGuard } from '../auth/jwt.guard';
|
||||
import { SisterDetailService } from './sister-detail.service';
|
||||
import { SistersService } from './sisters.service';
|
||||
|
||||
@Controller('api/sisters')
|
||||
@UseGuards(JwtGuard, RoleGuard)
|
||||
@Roles('admin', 'viewer')
|
||||
export class SistersController {
|
||||
constructor(
|
||||
private readonly sistersService: SistersService,
|
||||
@@ -30,7 +33,17 @@ export class SistersController {
|
||||
|
||||
@Get()
|
||||
async getSistersStatus() {
|
||||
return this.sistersService.getAllSistersStatus();
|
||||
const sisters = await this.sistersService.getAllSistersStatus();
|
||||
|
||||
return sisters.map(({ id, name, lxcId, role, status, lastSeen, currentTask }) => ({
|
||||
id,
|
||||
name,
|
||||
lxcId,
|
||||
role,
|
||||
status,
|
||||
lastSeen,
|
||||
currentTask,
|
||||
}));
|
||||
}
|
||||
|
||||
@Get(':name/runtime')
|
||||
@@ -49,7 +62,15 @@ export class SistersController {
|
||||
|
||||
@Get(':name/system')
|
||||
async getSystemInfo(@Param('name', SisterNamePipe) name: SisterName) {
|
||||
return this.sistersService.getSystemInfo(name);
|
||||
const system = await this.sistersService.getSystemInfo(name);
|
||||
if (!system) return null;
|
||||
|
||||
return {
|
||||
uptime: system.uptime,
|
||||
cpu: system.cpu,
|
||||
memory: system.memory,
|
||||
disk: system.disk,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':name/avatar')
|
||||
@@ -64,25 +85,29 @@ export class SistersController {
|
||||
|
||||
const avatar = await this.avatarService.getAvatar(sister);
|
||||
res.setHeader('Content-Type', avatar.contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
return res.send(avatar.data);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get(':name/config')
|
||||
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
|
||||
return this.sisterDetail.getSisterConfig(name);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get(':name/sessions')
|
||||
async getSisterSessions(@Param('name', SisterNamePipe) name: SisterName) {
|
||||
return this.sisterDetail.getSisterSessions(name);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get(':name/subagents')
|
||||
async getSisterSubagents(@Param('name', SisterNamePipe) name: SisterName) {
|
||||
return this.sisterDetail.getSisterSubagents(name);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get(':name/activity')
|
||||
async getSisterActivity(@Param('name', SisterNamePipe) name: SisterName) {
|
||||
return this.sisterDetail.getSisterActivityLog(name);
|
||||
|
||||
@@ -381,12 +381,21 @@ export default function OfficePage() {
|
||||
};
|
||||
|
||||
void boot();
|
||||
|
||||
// Polling fallback — only when socket is disconnected
|
||||
// Socket handles live updates; polling is a disconnected fallback
|
||||
return () => {};
|
||||
}, [fetchAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected) return; // socket is live, no polling needed
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void fetchAll();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchAll]);
|
||||
}, [connected, fetchAll]);
|
||||
|
||||
const hasSisterSnapshot = sisters.length > 0;
|
||||
const sisterDataMode: 'live' | 'snapshot' | 'fallback' = connected ? 'live' : hasSisterSnapshot ? 'snapshot' : 'fallback';
|
||||
|
||||
@@ -433,7 +433,9 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
|
||||
const [activeSister, setActiveSister] = useState<SisterName>(initialSister);
|
||||
const [allMessages, setAllMessages] = useState<Partial<Record<SisterName, ChatMessage[]>>>({});
|
||||
const [runtimeBySister, setRuntimeBySister] = useState<Partial<Record<SisterName, RuntimeSnapshot>>>({});
|
||||
const [input, setInput] = useState('');
|
||||
// Per-sister draft — prevents input leaking across tabs
|
||||
const [drafts, setDrafts] = useState<Partial<Record<SisterName, string>>>({});
|
||||
const input = drafts[activeSister] ?? '';
|
||||
const [sending, setSending] = useState(false);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -488,7 +490,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setMessages(activeSister, (prev) => mergeMessages(prev, [{
|
||||
id: `sys-${Date.now()}`,
|
||||
id: `sys-${crypto.randomUUID()}`,
|
||||
role: 'assistant',
|
||||
content: '로그인이 풀린 것 같아. 다시 로그인한 뒤 시도해줘.',
|
||||
ts: new Date().toISOString(),
|
||||
@@ -497,14 +499,14 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
id: `user-${crypto.randomUUID()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages(activeSister, (prev) => mergeMessages(prev, [userMessage]));
|
||||
setInput('');
|
||||
setDrafts((prev) => ({ ...prev, [activeSister]: '' }));
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
@@ -529,7 +531,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
|
||||
const reply = String(data?.reply || '').trim();
|
||||
if (reply) {
|
||||
setMessages(activeSister, (prev) => mergeMessages(prev, [{
|
||||
id: `assistant-${Date.now()}`,
|
||||
id: `assistant-${crypto.randomUUID()}`,
|
||||
role: 'assistant',
|
||||
content: reply,
|
||||
ts: new Date().toISOString(),
|
||||
@@ -540,7 +542,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '채팅 전송 중 오류가 발생했어.';
|
||||
setMessages(activeSister, (prev) => mergeMessages(prev, [{
|
||||
id: `error-${Date.now()}`,
|
||||
id: `error-${crypto.randomUUID()}`,
|
||||
role: 'assistant',
|
||||
content: `전송 실패: ${message}`,
|
||||
ts: new Date().toISOString(),
|
||||
@@ -614,7 +616,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
|
||||
<MessageInput
|
||||
placeholder={`${SISTER_DISPLAY[activeSister]}에게 지시해...`}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onChange={(e) => setDrafts((prev) => ({ ...prev, [activeSister]: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
@@ -181,7 +181,14 @@ function SisterCircle({
|
||||
const clipId = `avatar-clip-${name}`;
|
||||
|
||||
return (
|
||||
<g onClick={onClick} style={{ cursor: 'pointer' }}>
|
||||
<g
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${name} — state: ${state}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
|
||||
style={{ cursor: 'pointer', outline: 'none' }}
|
||||
>
|
||||
{selected && (
|
||||
<circle
|
||||
cx={cx}
|
||||
@@ -264,7 +271,14 @@ function SubagentCircle({
|
||||
const color = STATE_COLORS[state];
|
||||
|
||||
return (
|
||||
<g onClick={onClick} style={{ cursor: 'pointer' }}>
|
||||
<g
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`subagent ${label} — state: ${state}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
|
||||
style={{ cursor: 'pointer', outline: 'none' }}
|
||||
>
|
||||
{motion && (
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { API_URL } from './config';
|
||||
import { withSessionRequest } from './csrf';
|
||||
|
||||
interface AuthUser {
|
||||
userId: number;
|
||||
@@ -31,38 +32,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
const token = localStorage.getItem('hanarang_access_token');
|
||||
if (!token) { setLoading(false); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const res = await fetch(`${API_URL}/api/auth/me`, withSessionRequest());
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser({ userId: data.id, username: data.username, role: data.role });
|
||||
} else if (res.status === 401) {
|
||||
// access token 만료 → refresh 시도
|
||||
const refreshToken = localStorage.getItem('hanarang_refresh_token');
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const rRes = await fetch(`${API_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-refresh-token': refreshToken },
|
||||
});
|
||||
if (rRes.ok) {
|
||||
const d = await rRes.json();
|
||||
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||
if (d.refreshToken) localStorage.setItem('hanarang_refresh_token', d.refreshToken);
|
||||
setUser({ userId: 0, username: d.username, role: d.role });
|
||||
} else {
|
||||
localStorage.removeItem('hanarang_access_token');
|
||||
localStorage.removeItem('hanarang_refresh_token');
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
} else {
|
||||
localStorage.removeItem('hanarang_access_token');
|
||||
}
|
||||
// access token expired → try refresh via cookie
|
||||
try {
|
||||
const rRes = await fetch(`${API_URL}/api/auth/refresh`, withSessionRequest({ method: 'POST' }, { csrf: true }));
|
||||
if (rRes.ok) {
|
||||
const d = await rRes.json();
|
||||
setUser({ userId: 0, username: d.username, role: d.role });
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
@@ -74,11 +57,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => { loadUser(); }, [loadUser]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const res = await fetch(`${API_URL}/api/auth/login`, {
|
||||
const res = await fetch(`${API_URL}/api/auth/login`, withSessionRequest({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}, { csrf: true }));
|
||||
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
@@ -86,27 +69,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const d = await res.json();
|
||||
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||
if (d.refreshToken) {
|
||||
localStorage.setItem('hanarang_refresh_token', d.refreshToken);
|
||||
}
|
||||
// me API로 실제 userId 가져오기
|
||||
// Tokens are set as HttpOnly cookies by the server
|
||||
setUser({ userId: 0, username: d.username, role: d.role });
|
||||
|
||||
// Fetch actual userId from /me
|
||||
try {
|
||||
const meRes = await fetch(`${API_URL}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${d.accessToken}` },
|
||||
});
|
||||
const meRes = await fetch(`${API_URL}/api/auth/me`, withSessionRequest());
|
||||
if (meRes.ok) {
|
||||
const me = await meRes.json();
|
||||
setUser({ userId: me.id, username: me.username, role: me.role });
|
||||
return;
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
setUser({ userId: 0, username: d.username, role: d.role });
|
||||
} catch { /* fallback — already set basic info */ }
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('hanarang_access_token');
|
||||
localStorage.removeItem('hanarang_refresh_token');
|
||||
const logout = async () => {
|
||||
try {
|
||||
await fetch(`${API_URL}/api/auth/logout`, withSessionRequest({ method: 'POST' }, { csrf: true }));
|
||||
} catch { /* silent */ }
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
|
||||
46
frontend/lib/csrf.ts
Normal file
46
frontend/lib/csrf.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const CSRF_COOKIE_NAME = 'hanarang_csrf_token';
|
||||
|
||||
export function getCookieValue(name: string): string {
|
||||
if (typeof document === 'undefined') return '';
|
||||
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find((item) => item.startsWith(`${encodeURIComponent(name)}=`));
|
||||
|
||||
if (!match) return '';
|
||||
|
||||
return decodeURIComponent(match.split('=').slice(1).join('='));
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string {
|
||||
return getCookieValue(CSRF_COOKIE_NAME);
|
||||
}
|
||||
|
||||
export function needsCsrf(method?: string): boolean {
|
||||
const normalized = (method ?? 'GET').toUpperCase();
|
||||
return !['GET', 'HEAD', 'OPTIONS'].includes(normalized);
|
||||
}
|
||||
|
||||
export function withCsrfHeaders(headers?: HeadersInit): Headers {
|
||||
const nextHeaders = new Headers(headers);
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
if (csrfToken) {
|
||||
nextHeaders.set('x-csrf-token', csrfToken);
|
||||
}
|
||||
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
export function withSessionRequest(
|
||||
init: RequestInit = {},
|
||||
options: { csrf?: boolean } = {},
|
||||
): RequestInit {
|
||||
const useCsrf = options.csrf ?? needsCsrf(init.method);
|
||||
|
||||
return {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: useCsrf ? withCsrfHeaders(init.headers) : new Headers(init.headers),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user