diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 239520b..4357ddc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,6 +32,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 + - name: Setup Python + if: matrix.language == 'python' + uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88555e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg +*.egg-info/ +dist/ +build/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.whl +.installed.cfg + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment variables +.env +.env.* +!.env.example + +# Node +node_modules/ +npm-debug.log* + +# Distribution +*.tar.gz +*.zip diff --git a/backend/auth.py b/backend/auth.py index 3e4a6bf..deabddf 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -2,7 +2,7 @@ import os import bcrypt import secrets -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional @@ -10,29 +10,29 @@ from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer +logger = logging.getLogger(__name__) + def _resolve_secret_key() -> tuple[str, bool]: configured = str(os.getenv("SECRET_KEY") or "").strip() if configured: return configured, False - fallback_path = Path( - os.getenv( - "SECRET_KEY_FILE", - str(Path(os.getenv("TEMP") or "/tmp") / "codeai_jwt_secret.key"), - ) + configured_file = str(os.getenv("SECRET_KEY_FILE") or "").strip() + if configured_file: + fallback_path = Path(configured_file).expanduser() + try: + if fallback_path.exists() and fallback_path.is_file(): + cached_secret = fallback_path.read_text(encoding="utf-8").strip() + if cached_secret: + return cached_secret, True + except Exception: + pass + + logger.error( + "SECRET_KEY/SECRET_KEY_FILE is not configured; generating ephemeral runtime secret that invalidates tokens on restart." ) - try: - fallback_path.parent.mkdir(parents=True, exist_ok=True) - if fallback_path.exists(): - cached_secret = fallback_path.read_text(encoding="utf-8").strip() - if cached_secret: - return cached_secret, True - generated_secret = secrets.token_urlsafe(48) - fallback_path.write_text(generated_secret, encoding="utf-8") - return generated_secret, True - except Exception: - return secrets.token_urlsafe(48), True + return secrets.token_urlsafe(48), True SECRET_KEY, SECRET_KEY_IS_RUNTIME_FALLBACK = _resolve_secret_key() @@ -42,7 +42,6 @@ def _resolve_secret_key() -> tuple[str, bool]: ) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") -logger = logging.getLogger(__name__) def get_password_hash(password: str) -> str: @@ -68,7 +67,7 @@ def create_access_token( ) -> str: to_encode = data.copy() if not no_expiry: - expire = datetime.utcnow() + ( + expire = datetime.now(timezone.utc) + ( expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) ) to_encode.update({"exp": expire}) diff --git a/backend/auth_router.py b/backend/auth_router.py index 0fb4767..1ae751d 100644 --- a/backend/auth_router.py +++ b/backend/auth_router.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from secrets import compare_digest, randbelow, token_urlsafe import base64 import os @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from typing import Optional, cast -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, ConfigDict, EmailStr from sqlalchemy.orm import Session from webauthn import ( generate_authentication_options, @@ -82,8 +82,7 @@ class UserResponse(BaseModel): business_registration_number: Optional[str] = None representative_name: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class Token(BaseModel): @@ -254,7 +253,7 @@ class PasswordRecoveryResetResponse(BaseModel): def _issue_recovery_token(prefix: str) -> tuple[str, datetime]: - expires_at = datetime.utcnow() + timedelta(minutes=10) + expires_at = datetime.now(timezone.utc) + timedelta(minutes=10) return f"{prefix}_{token_urlsafe(24)}", expires_at @@ -273,7 +272,7 @@ def _normalize_password_recovery_scope(scope: str) -> str: def _purge_expired_password_recovery_sessions() -> None: - now = datetime.utcnow() + now = datetime.now(timezone.utc) expired_tokens = [ session_token for session_token, session_state in _password_recovery_store.items() @@ -380,7 +379,7 @@ def start_passkey_registration( "user_id": int(user.id), "challenge": _to_base64url(options.challenge), "device_label": str(payload.device_label or "이 기기 패스키").strip() or "이 기기 패스키", - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), "user_handle": user_handle, "rp_id": rp_id, "expected_origin": expected_origin, @@ -403,7 +402,7 @@ def finish_passkey_registration( raise HTTPException(status_code=404, detail="패스키 등록 세션을 찾을 수 없습니다") expires_at = state.get("expires_at") - if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow(): + if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc): _passkey_registration_store.pop(payload.registration_token, None) raise HTTPException(status_code=410, detail="패스키 등록 세션이 만료되었습니다") @@ -430,7 +429,7 @@ def finish_passkey_registration( user.passkey_public_key = _to_base64url(verification.credential_public_key) user.passkey_device_label = str(state.get("device_label") or "이 기기 패스키") user.passkey_sign_count = int(verification.sign_count) - user.passkey_registered_at = datetime.utcnow() + user.passkey_registered_at = datetime.now(timezone.utc) db.add(user) db.commit() _passkey_registration_store.pop(payload.registration_token, None) @@ -465,7 +464,7 @@ def start_passkey_login( ) _passkey_login_store[str(user.email)] = { "challenge": _to_base64url(options.challenge), - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), "credential_id": str(user.passkey_credential_id), "rp_id": rp_id, "expected_origin": expected_origin, @@ -492,7 +491,7 @@ def finish_passkey_login( raise HTTPException(status_code=404, detail="패스키 로그인 세션을 찾을 수 없습니다") expires_at = state.get("expires_at") - if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow(): + if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc): _passkey_login_store.pop(str(user.email), None) raise HTTPException(status_code=410, detail="패스키 로그인 세션이 만료되었습니다") @@ -581,7 +580,7 @@ def verify_password_recovery_identity(payload: PasswordRecoveryVerifyIdentityReq raise HTTPException(status_code=404, detail="복구 세션을 찾을 수 없습니다") expires_at = session_state.get("expires_at") - if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow(): + if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc): _password_recovery_store.pop(payload.recovery_session_token, None) raise HTTPException(status_code=410, detail="복구 세션이 만료되었습니다") @@ -638,7 +637,7 @@ def reset_password_via_recovery( raise HTTPException(status_code=403, detail="본인확인 검증이 완료되지 않았습니다") reset_expires_at = session_state.get("reset_expires_at") - if not isinstance(reset_expires_at, datetime) or reset_expires_at <= datetime.utcnow(): + if not isinstance(reset_expires_at, datetime) or reset_expires_at <= datetime.now(timezone.utc): _password_recovery_store.pop(session_token, None) raise HTTPException(status_code=410, detail="재설정 토큰이 만료되었습니다") diff --git a/backend/llm/orchestrator.py b/backend/llm/orchestrator.py index 0bf7c48..55d5a0f 100644 --- a/backend/llm/orchestrator.py +++ b/backend/llm/orchestrator.py @@ -2684,6 +2684,7 @@ class OrchestrationAcceptedResponse(BaseModel): _ORCHESTRATION_PROGRESS_STORE: Dict[str, Dict[str, Any]] = {} +_ORCHESTRATION_PROGRESS_FILE_LOCK = threading.Lock() def _runtime_progress_root() -> Path: @@ -2694,9 +2695,8 @@ def _runtime_progress_root() -> Path: return progress_root -def _orchestration_progress_path(run_id: str) -> Path: - safe_run_id = re.sub(r"[^a-zA-Z0-9_.-]+", "-", str(run_id or "unknown")).strip("-") or "unknown" - return _runtime_progress_root() / f"{safe_run_id}.json" +def _orchestration_progress_store_path() -> Path: + return _runtime_progress_root() / "progress_store.json" def _build_progress_poll_url(run_id: str) -> str: @@ -2712,8 +2712,23 @@ def _save_orchestration_progress(run_id: str, payload: Dict[str, Any]) -> Dict[s normalized["run_id"] = str(run_id or normalized.get("run_id") or "") normalized.setdefault("updated_at", datetime.utcnow().isoformat() + "Z") _ORCHESTRATION_PROGRESS_STORE[normalized["run_id"]] = normalized - progress_path = _orchestration_progress_path(normalized["run_id"]) - progress_path.write_text(json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8") + progress_path = _orchestration_progress_store_path() + with _ORCHESTRATION_PROGRESS_FILE_LOCK: + persisted_payload: Dict[str, Any] = {} + try: + if progress_path.exists() and progress_path.is_file(): + existing_payload = json.loads(progress_path.read_text(encoding="utf-8")) + if isinstance(existing_payload, dict): + persisted_payload = dict(existing_payload) + except Exception: + logger.warning( + "Failed to read orchestration progress store from %s before write", + str(progress_path), + exc_info=True, + ) + persisted_payload = {} + persisted_payload[normalized["run_id"]] = normalized + progress_path.write_text(json.dumps(persisted_payload, ensure_ascii=False, indent=2), encoding="utf-8") return normalized @@ -2721,14 +2736,18 @@ def _load_orchestration_progress(run_id: str) -> Dict[str, Any]: cached = _ORCHESTRATION_PROGRESS_STORE.get(str(run_id or "")) if isinstance(cached, dict) and cached: return dict(cached) - progress_path = _orchestration_progress_path(run_id) + progress_path = _orchestration_progress_store_path() try: - if progress_path.exists() and progress_path.is_file(): - payload = json.loads(progress_path.read_text(encoding="utf-8")) - if isinstance(payload, dict): - _ORCHESTRATION_PROGRESS_STORE[str(run_id or "")] = dict(payload) - return dict(payload) + with _ORCHESTRATION_PROGRESS_FILE_LOCK: + if progress_path.exists() and progress_path.is_file(): + payload = json.loads(progress_path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + stored = payload.get(str(run_id or "")) + if isinstance(stored, dict): + _ORCHESTRATION_PROGRESS_STORE[str(run_id or "")] = dict(stored) + return dict(stored) except Exception: + logger.error("Failed to load orchestration progress for run_id=%s", str(run_id or ""), exc_info=True) return {} return {} diff --git a/backend/main.py b/backend/main.py index 65c89cc..516b298 100644 --- a/backend/main.py +++ b/backend/main.py @@ -723,6 +723,24 @@ def _relative_percent(numerator: float, denominator: float) -> Optional[float]: return round((numerator / denominator) * 100, 1) +_SAFE_DIAGNOSTIC_ERROR_CODES = { + "cpu_load_unavailable", + "gpu_runtime_unavailable", + "memory_snapshot_unavailable", + "queue_runtime_unavailable", +} + + +def _sanitize_diagnostic_error(raw_error: Any, fallback: str) -> Optional[str]: + if raw_error is None: + return None + if isinstance(raw_error, str): + normalized = raw_error.strip().lower() + if normalized in _SAFE_DIAGNOSTIC_ERROR_CODES: + return normalized + return fallback + + def _linux_memory_snapshot() -> Optional[Dict[str, Any]]: meminfo_path = "/proc/meminfo" if not os.path.exists(meminfo_path): @@ -739,7 +757,12 @@ def _linux_memory_snapshot() -> Optional[Dict[str, Any]]: if number.isdigit(): values[key.strip()] = int(number) except Exception as exc: - return {"error": str(exc)} + return { + "error": _sanitize_diagnostic_error( + exc, + "memory_snapshot_unavailable", + ) + } total_kb = values.get("MemTotal", 0) available_kb = values.get("MemAvailable", values.get("MemFree", 0)) @@ -761,7 +784,12 @@ def _windows_memory_snapshot() -> Optional[Dict[str, Any]]: if not kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): return None except Exception as exc: - return {"error": str(exc)} + return { + "error": _sanitize_diagnostic_error( + exc, + "memory_snapshot_unavailable", + ) + } total_bytes = int(status.ullTotalPhys) available_bytes = int(status.ullAvailPhys) @@ -778,12 +806,18 @@ def _memory_snapshot() -> Dict[str, Any]: snapshot = _linux_memory_snapshot() if snapshot is None and os.name == "nt": snapshot = _windows_memory_snapshot() - if not snapshot: - return { + if not snapshot or snapshot.get("error"): + payload: Dict[str, Any] = { "available": False, "state": "warning", "note": "메모리 사용량을 수집하지 못했습니다.", } + if snapshot: + payload["error"] = _sanitize_diagnostic_error( + snapshot.get("error"), + "memory_snapshot_unavailable", + ) + return payload usage_percent = snapshot.get("usage_percent") critical_percent = max( @@ -868,7 +902,7 @@ def _cpu_snapshot() -> Dict[str, Any]: usage_percent: Optional[float] = None note = "CPU 부하가 정상 범위입니다." state = "ok" - error_message = "" + error_code: Optional[str] = None warning_percent = min( SAFE_COMPUTE_USAGE_LIMIT_PERCENT, int(os.getenv("RUNTIME_CPU_WARNING_PERCENT", str(SAFE_MEMORY_OCCUPANCY_LIMIT_PERCENT)) or SAFE_MEMORY_OCCUPANCY_LIMIT_PERCENT), @@ -883,7 +917,7 @@ def _cpu_snapshot() -> Dict[str, Any]: getloadavg = cast(Any, getattr(os, "getloadavg")) load_1m = round(float(getloadavg()[0]), 2) except Exception as exc: - error_message = str(exc) + error_code = _sanitize_diagnostic_error(exc, "cpu_load_unavailable") if load_1m is not None and cpu_count > 0: load_ratio_percent = _relative_percent(load_1m, cpu_count) @@ -911,29 +945,28 @@ def _cpu_snapshot() -> Dict[str, Any]: "load_ratio_percent": load_ratio_percent, "usage_percent": usage_percent, } - if error_message: - payload["error"] = error_message + if error_code: + payload["error"] = error_code return payload def _gpu_snapshot() -> Dict[str, Any]: gpu_runtime = get_gpu_runtime_info() + gpu_runtime_data = gpu_runtime if isinstance(gpu_runtime, dict) else {} + gpu_error = _sanitize_diagnostic_error( + gpu_runtime_data.get("error"), + "gpu_runtime_unavailable", + ) devices = ( - gpu_runtime.get("devices", []) - if isinstance(gpu_runtime, dict) - else [] + gpu_runtime_data.get("devices", []) ) - if not gpu_runtime.get("available"): + if not gpu_runtime_data.get("available"): return { "available": False, "state": "warning", "note": "GPU 런타임이 감지되지 않았습니다. CPU fallback 또는 드라이버 상태를 확인하세요.", "devices": [], - "error": ( - gpu_runtime.get("error") - if isinstance(gpu_runtime, dict) - else None - ), + "error": gpu_error or "gpu_runtime_unavailable", } peak_usage = 0.0 @@ -1031,6 +1064,8 @@ def _runtime_health_payload() -> Dict[str, Any]: from backend.marketplace.router import get_ad_queue_runtime_status queue_runtime = get_ad_queue_runtime_status() except Exception as exc: + logger.exception("Failed to load ad queue runtime status") + safe_queue_error = "queue_runtime_unavailable" queue_runtime = { "redis_queue": { "available": False, @@ -1038,7 +1073,7 @@ def _runtime_health_payload() -> Dict[str, Any]: "note": "Redis queue 진단을 로드하지 못했습니다.", "connection_id": "redis:video_render_queue", "queue_name": "video_render_queue", - "error": str(exc), + "error": safe_queue_error, }, "ad_worker": { "available": False, @@ -1047,7 +1082,7 @@ def _runtime_health_payload() -> Dict[str, Any]: "connection_id": "redis:video_render_queue", "queue_name": "video_render_queue", "worker_id": "ad-render-worker-001", - "error": str(exc), + "error": safe_queue_error, }, } redis_queue = queue_runtime.get("redis_queue", {}) diff --git a/backend/marketplace/router.py b/backend/marketplace/router.py index fc322c7..55cda54 100644 --- a/backend/marketplace/router.py +++ b/backend/marketplace/router.py @@ -595,15 +595,20 @@ def _persist_progress(*, percent: int, step: str, state: str, message: str) -> N ), ) except Exception as exc: + logger.exception( + "Marketplace feature orchestrate stream failed run_id=%s", + request.run_id, + ) + public_error_message = "라이브뷰 실행 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요." local_metadata["popup_state"] = "failed" local_metadata["last_event"] = "failed" - local_metadata["error"] = str(exc) + local_metadata["error"] = public_error_message local_metadata["updated_at"] = _utc_now_iso() - _persist_progress(percent=100, step="failed", state="failed", message=str(exc)) + _persist_progress(percent=100, step="failed", state="failed", message=public_error_message) local_stage_run = _set_feature_metadata(local_stage_run, local_metadata) - local_stage_run = _apply_feature_popup_state(local_stage_run, "failed", str(exc)) + local_stage_run = _apply_feature_popup_state(local_stage_run, "failed", public_error_message) save_stage_run(local_stage_run) - yield _build_feature_sse_event("failed", {"run_id": request.run_id, "state": "failed", "message": str(exc)}) + yield _build_feature_sse_event("failed", {"run_id": request.run_id, "state": "failed", "message": public_error_message}) yield _build_feature_sse_event( "progress", _build_feature_progress_payload( @@ -611,7 +616,7 @@ def _persist_progress(*, percent: int, step: str, state: str, message: str) -> N percent=100, step="failed", state="failed", - message=str(exc), + message=public_error_message, ), ) @@ -1054,9 +1059,10 @@ def get_ad_queue_runtime_status() -> Dict[str, Dict[str, Any]]: if redis_client is not None: try: queue_depth = int(redis_client.llen(VIDEO_RENDER_QUEUE_NAME)) - except RedisError as exc: + except RedisError: + logger.exception("Failed to read Redis queue depth for health diagnostics") redis_available = False - redis_error = str(exc) + redis_error = "redis_queue_unavailable" with _ad_worker_lock: started_at = _ad_worker_runtime.get("started_at") diff --git a/docs/overall-design-change-pr-report-2026-05-08.md b/docs/overall-design-change-pr-report-2026-05-08.md new file mode 100644 index 0000000..232e4c3 --- /dev/null +++ b/docs/overall-design-change-pr-report-2026-05-08.md @@ -0,0 +1,182 @@ +# 전체 설계변경 요약 및 PR 본문 초안 + +> Note: This document is bilingual by design. The repository-wide design summary is written in Korean for the primary working context, and the PR body draft is written in English so it can be pasted directly into GitHub pull request fields. + +## 문서 목적 +- 현재 저장소 문서 기준으로 전체 설계변경 내용을 한 번에 검토할 수 있도록 정리한다. +- 바로 복사해 사용할 수 있는 실제 PR 본문 초안을 남긴다. +- 설계변경 요약과 PR 설명의 근거 문서를 함께 묶어 추적 가능하게 유지한다. + +## 근거 문서 +- `README.md` +- `docs/orchestrator-multigenerator-upgrade-status.md` +- `docs/final_readiness_checklist.md` +- `docs/admin-dashboard-ui-ux-browser-blueprint.md` +- `docs/admin-dashboard-section-linkage-checklist.md` +- `gpu-llm-server/reports/pr-body-2026-04-27.md` + +--- + +## 1. 전체 설계변경 요약 + +### 1-1. 플랫폼 운영 구조 재정렬 +- 공개 메인 앱과 관리자 앱의 운영 기준을 분리했다. +- 공개 메인 앱은 `frontend`, 관리자 앱은 `frontend/frontend` 기준으로 정렬했다. +- 운영 진입 경로를 `marketplace`, `admin`, `/api/llm/ws` 중심으로 고정했다. +- 운영 판정은 단순 구현 여부가 아니라 운영 경로 실검증 기준으로 묶었다. + +### 1-2. 오케스트레이터 및 멀티 생성기 계약 단일화 +- Python 산출물 서비스 구조를 `app/services/__init__.py`, `app/services/runtime_service.py` 패키지 기준으로 통일했다. +- 레거시 `app/services.py` 단일 파일 기준과 신규 패키지 기준이 동시에 유지되지 않도록 계약을 정렬했다. +- 템플릿, 검증기, 체크리스트, capability 진단 규칙이 같은 서비스 패키지 기준을 보도록 재정리했다. + +### 1-3. 생성 직후 hard gate 검증 체계 강화 +- 생성 직후 결과물 폴더에서 의존성 설치, 단독 기동, 핵심 API 호출, 테스트, ZIP 재현 검증까지 한 흐름으로 묶었다. +- readiness checklist, semantic gate, completion gate, packaging audit, output audit의 연결 구조를 강화했다. +- 산출물 문서와 운영 증거가 분리되지 않도록 `final_readiness_checklist.md` 중심의 판정 체계를 유지했다. + +### 1-4. capability evidence 및 self-run 추적 강화 +- 관리자 capability 진단에 summary/detail 분리와 evidence bundle 해석을 반영했다. +- `completion_gate_ok`, `self_run_status`, `failure_tags`, `target_file_ids`, `evidence_digest` 등 추적 요약을 노출하도록 정리했다. +- self-run terminal state, `applied_to_source`, runtime artifact, operational evidence를 같은 흐름에서 확인할 수 있게 했다. + +### 1-5. 운영 경로 실검증 기준 고정 +- 운영 경로 검증 대상을 `admin`, `marketplace`, websocket, system settings, workspace self-run record까지 포함해 정리했다. +- 로컬 성능 및 검증 기준은 `localhost` 대신 `127.0.0.1:8000` 또는 운영 도메인 기준으로 고정했다. +- 완료 판정은 운영 경로 실검증과 readiness evidence가 함께 닫혀야만 가능하도록 유지했다. + +### 1-6. 관리자 대시보드 UI/UX 구조 재설계 +- 관리자 대시보드를 중앙 오케스트레이터 허브 중심 구조로 재배치했다. +- 상단 바, 히어로 액션, 중앙 런처 허브, 양측 레일, 오버레이 창형 섹션 구조를 연결했다. +- 인라인 접기 카드 위주 화면에서 운영자가 실제 제어에 집중할 수 있는 실행형 패널 구조로 전환했다. + +### 1-7. 신규 생성 프로그램의 운영형 기본 규칙 확대 +- 새로 생성되는 프로그램에도 운영형 설정, 보안 파일, 상태 클라이언트, 최소 코드량, self-configurable 검증 규칙을 공통 적용하는 방향으로 확장했다. +- 단순 스캐폴드가 아니라 운영 준비도와 검증 문서까지 포함하는 생성기 구조를 목표 상태로 정리했다. + +--- + +## 2. 실제 PR 제목 제안 + +### 추천 제목 +`오케스트레이터·멀티 생성기·운영 검증 체계 전면 정렬 및 관리자 UI 구조 재설계` + +### 대안 제목 +- `생성기 계약 단일화와 hard gate 검증 체계 정렬, 관리자 허브 UI 재설계` +- `운영형 오케스트레이터 증거 체계 정렬 및 admin/marketplace 검증 구조 고도화` + +--- + +## 3. 실제 PR 본문 초안 + +## Summary + +This PR consolidates the repository-wide design changes into a single operational baseline. It aligns the public/admin runtime structure, unifies the generator contract around the `app/services/` package layout, strengthens post-generation hard-gate validation, and reorganizes the admin dashboard into an operator-centric orchestration hub. It also ties readiness evidence, self-run traces, and operational verification into a single reviewable flow. + +## Why + +- The generator contract, validation rules, and documentation needed to follow the same service-package standard. +- Completion status needed to be grounded in real operational evidence instead of partial implementation signals. +- Post-generation validation needed to verify dependency install, standalone boot, core API health, tests, and ZIP reproduction as one closed gate. +- The admin dashboard needed to shift from scattered inline sections to a workflow-centered control hub that exposes actionable evidence. + +## Scope Of Changes + +### 1. Runtime / Platform Structure +- Reaffirmed split operation between the public main app and the admin app. +- Kept operational routing focused on `marketplace`, `admin`, and `/api/llm/ws`. +- Synchronized runtime interpretation with the documented production entry points. + +### 2. Generator Contract Unification +- Standardized Python service outputs on: + - `app/services/__init__.py` + - `app/services/runtime_service.py` +- Removed contract ambiguity between legacy single-file service references and package-based service structure. +- Kept templates, validators, checklists, and capability diagnostics aligned to the same package contract. + +### 3. Hard-Gate Validation Baseline +- Strengthened the closed validation path executed immediately after generation: + - dependency installation + - standalone boot + - core API smoke + - test execution + - ZIP reproduction verification +- Preserved semantic gate, completion gate, packaging audit, and readiness checklist linkage. +- Kept `final_readiness_checklist.md` as the central review artifact for closure. + +### 4. Capability Evidence / Self-Run Traceability +- Expanded capability summary/detail separation and evidence bundle interpretation. +- Surfaced evidence-oriented fields such as: + - `completion_gate_ok` + - `self_run_status` + - `failure_tags` + - `target_file_ids` + - `evidence_digest` +- Connected self-run terminal status and `applied_to_source` evidence to the admin-facing review flow. + +### 5. Operational Verification Standards +- Kept operational verification centered on real production paths, including admin, marketplace, websocket, system-settings, and workspace self-run record flows. +- Kept local verification baselines on `127.0.0.1:8000` or the production domain rather than `localhost`. +- Maintained the rule that completion status requires operational evidence, not just code presence. + +### 6. Admin Dashboard UX Redesign +- Reorganized the admin screen around a central orchestration hub. +- Connected top actions, hero actions, launcher tiles, inline surfaces, and modal/overlay sections into a more operator-focused UI. +- Shifted away from dense inline foldable cards toward a clearer action-and-control workflow. + +### 7. Operational-Grade Output Defaults +- Extended generator expectations so newly produced applications follow operational-grade defaults rather than bare scaffolds. +- Preserved expectations for security/runtime/status components and stronger output quality gates. + +## Validation Basis + +- Operational readiness and completion status are documented in `docs/final_readiness_checklist.md`. +- Orchestrator and multi-generator alignment details are documented in `docs/orchestrator-multigenerator-upgrade-status.md`. +- Admin dashboard UX restructuring basis is documented in: + - `docs/admin-dashboard-ui-ux-browser-blueprint.md` + - `docs/admin-dashboard-section-linkage-checklist.md` +- Documentation-only baseline validation for this update: + - `npm --prefix frontend/frontend run test` + +## Risks + +- Tightening contract alignment can expose stale references in secondary documents or auxiliary diagnostic paths. +- Evidence-first completion criteria can downgrade previously tolerated partial states into explicit blockers. +- Admin dashboard workflow changes may alter operator navigation expectations until the new hub pattern is fully internalized. + +## Rollback Strategy + +- Roll back documentation and PR narrative independently if wording or scope grouping needs refinement. +- If a runtime/design interpretation needs to be reverted, restore the corresponding baseline in the status/readiness documents first so the repository does not present mismatched closure criteria. +- Preserve the service-package contract and evidence-based completion rule unless a repository-wide alternative standard is intentionally adopted. + +## Reviewer Focus + +- Verify that generator, validator, checklist, and capability documentation all describe the same `app/services/` package contract. +- Check that hard-gate validation is represented as a closed operational path rather than a partial quality signal. +- Review whether the admin dashboard redesign description accurately matches the current launcher-hub and overlay-window structure. +- Confirm that completion claims stay anchored to documented operational evidence. + +## Notes For Release / Reporting + +- This PR body is intended as a consolidated reporting layer for the current repository baseline. +- It is suitable for follow-up release notes, readiness reviews, or status reports that need one narrative covering generator structure, operational verification, and admin UI direction. + +--- + +## 4. 짧은 PR 본문 버전 + +## Summary +- 공개/관리자 운영 구조를 재정렬하고 생성기 계약을 `app/services/` 패키지 기준으로 단일화했다. +- 생성 직후 hard gate 검증, readiness checklist, self-run evidence, operational verification 흐름을 같은 판정 체계로 묶었다. +- 관리자 화면을 중앙 오케스트레이터 허브 기반 구조로 정리해 운영 제어와 증거 확인 흐름을 강화했다. + +## Validation +- `docs/final_readiness_checklist.md` +- `docs/orchestrator-multigenerator-upgrade-status.md` +- `npm --prefix frontend/frontend run test` + +## Reviewer Focus +- 서비스 패키지 계약 정합성 +- hard gate 및 readiness evidence 표현 정확성 +- 관리자 허브 UI 설명과 실제 구조의 일치 여부 diff --git a/frontend/frontend/hooks/use-feature-orchestrator.ts b/frontend/frontend/hooks/use-feature-orchestrator.ts index ad39af5..7cb8b72 100644 --- a/frontend/frontend/hooks/use-feature-orchestrator.ts +++ b/frontend/frontend/hooks/use-feature-orchestrator.ts @@ -358,7 +358,7 @@ function buildDefaultCatalogItem(featureId: string): FeatureCatalogItem { const meta = FEATURE_EXPERIENCE_META[featureId] || FEATURE_EXPERIENCE_META['ai-sheet']; return { feature_id: featureId, - title: meta.popupKicker.replace('AI ', 'AI '), + title: meta.popupKicker, summary: meta.launcherSummary, popup_mode: preset.contextTags[1] || meta.outputKind, status: 'enabled', @@ -958,4 +958,4 @@ export function useFeatureOrchestrator() { progressSnapshot, progressHistory, }; -} \ No newline at end of file +} diff --git a/gpu-llm-server/custom-server/server.py b/gpu-llm-server/custom-server/server.py index 606bccf..1cb3cf0 100644 --- a/gpu-llm-server/custom-server/server.py +++ b/gpu-llm-server/custom-server/server.py @@ -176,7 +176,7 @@ def load_model(): logger.error(f"Failed to load model: {e}") model = None tokenizer = None - model_load_error = str(e) + model_load_error = "model_load_failed" logger.warning("Server will stay up without a loaded model.") diff --git a/pyproject.toml b/pyproject.toml index 8ceecb9..1bd5e2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "httpx>=0.28,<1.0", "requests>=2.32,<3.0", "redis>=5.2,<6.0", - "Pillow>=10.4,<12.0", + "Pillow>=12.2,<13.0", "torch>=2.6,<3.0", "qdrant-client>=1.12,<2.0", "grpcio>=1.71,<2.0", diff --git a/tests/test_auth_router_security.py b/tests/test_auth_router_security.py index 5b861c5..245042d 100644 --- a/tests/test_auth_router_security.py +++ b/tests/test_auth_router_security.py @@ -1,7 +1,7 @@ import importlib import sys import types -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest @@ -50,6 +50,39 @@ class _StubUser: fake_models.User = _StubUser + # Stub webauthn if not installed so auth_router can be imported without it + if "webauthn" not in sys.modules: + _stub_webauthn = types.ModuleType("webauthn") + for _name in ( + "generate_authentication_options", + "generate_registration_options", + "options_to_json", + "verify_authentication_response", + "verify_registration_response", + ): + setattr(_stub_webauthn, _name, None) + + _stub_structs = types.ModuleType("webauthn.helpers.structs") + for _name in ( + "PublicKeyCredentialType", + "AuthenticatorAssertionResponse", + "AuthenticatorAttestationResponse", + "AuthenticatorSelectionCriteria", + "PublicKeyCredentialDescriptor", + "RegistrationCredential", + "AuthenticationCredential", + "UserVerificationRequirement", + "ResidentKeyRequirement", + ): + setattr(_stub_structs, _name, None) + + _stub_helpers = types.ModuleType("webauthn.helpers") + _stub_helpers.structs = _stub_structs + + monkeypatch.setitem(sys.modules, "webauthn", _stub_webauthn) + monkeypatch.setitem(sys.modules, "webauthn.helpers", _stub_helpers) + monkeypatch.setitem(sys.modules, "webauthn.helpers.structs", _stub_structs) + monkeypatch.setitem(sys.modules, "backend.database", fake_database) monkeypatch.setitem(sys.modules, "backend.models", fake_models) return importlib.import_module("backend.auth_router") @@ -96,7 +129,7 @@ def test_password_recovery_verify_identity_limits_failed_attempts(monkeypatch): "verified": False, "verification_code": "654321", "verification_attempts": 0, - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), } payload = auth_router.PasswordRecoveryVerifyIdentityRequest( recovery_session_token=recovery_session_token, @@ -123,8 +156,8 @@ def test_reset_password_requires_verified_identity(monkeypatch): "scope": "admin", "verified": False, "reset_token": "reset_token", - "reset_expires_at": datetime.utcnow() + timedelta(minutes=5), - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "reset_expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), } with pytest.raises(HTTPException) as exc_info: @@ -151,8 +184,8 @@ def test_reset_password_updates_hash_and_clears_session(monkeypatch): "verified": True, "identity_session_token": "identity-proof", "reset_token": "reset_token", - "reset_expires_at": datetime.utcnow() + timedelta(minutes=5), - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "reset_expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), } response = auth_router.reset_password_via_recovery( diff --git a/tests/test_health_diagnostics_sanitization.py b/tests/test_health_diagnostics_sanitization.py new file mode 100644 index 0000000..2eaadc0 --- /dev/null +++ b/tests/test_health_diagnostics_sanitization.py @@ -0,0 +1,105 @@ +import ast +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, cast + + +MAIN_PATH = Path(__file__).resolve().parent.parent / "backend" / "main.py" +SAFE_DIAGNOSTIC_ERROR_CODES = { + "cpu_load_unavailable", + "gpu_runtime_unavailable", + "memory_snapshot_unavailable", + "queue_runtime_unavailable", +} + + +def _load_functions(*names: str, extra_globals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + tree = ast.parse(MAIN_PATH.read_text(encoding="utf-8-sig"), filename=str(MAIN_PATH)) + selected = [ + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name in names + ] + namespace: Dict[str, Any] = { + "Any": Any, + "Dict": Dict, + "List": List, + "Optional": Optional, + "Path": Path, + "cast": cast, + "os": os, + } + if extra_globals: + namespace.update(extra_globals) + exec(compile(ast.Module(body=selected, type_ignores=[]), str(MAIN_PATH), "exec"), namespace) + return namespace + + +def test_sanitize_diagnostic_error_redacts_exception_text(): + namespace = _load_functions( + "_sanitize_diagnostic_error", + extra_globals={ + "_SAFE_DIAGNOSTIC_ERROR_CODES": SAFE_DIAGNOSTIC_ERROR_CODES, + }, + ) + + sanitize = namespace["_sanitize_diagnostic_error"] + + assert sanitize(PermissionError("cannot open /proc/meminfo"), "memory_snapshot_unavailable") == "memory_snapshot_unavailable" + assert sanitize("gpu_runtime_unavailable", "memory_snapshot_unavailable") == "gpu_runtime_unavailable" + assert sanitize(" GPU_Runtime_Unavailable ", "memory_snapshot_unavailable") == "gpu_runtime_unavailable" + assert sanitize(None, "memory_snapshot_unavailable") is None + + +def test_memory_snapshot_error_becomes_warning_payload(): + namespace = _load_functions( + "_sanitize_diagnostic_error", + "_memory_snapshot", + extra_globals={ + "_SAFE_DIAGNOSTIC_ERROR_CODES": SAFE_DIAGNOSTIC_ERROR_CODES, + "_linux_memory_snapshot": lambda: {"error": "permission denied: /proc/meminfo"}, + "_windows_memory_snapshot": lambda: None, + "SAFE_COMPUTE_USAGE_LIMIT_PERCENT": 90, + "SAFE_MEMORY_OCCUPANCY_LIMIT_PERCENT": 75, + }, + ) + + payload = namespace["_memory_snapshot"]() + + assert payload["available"] is False + assert payload["state"] == "warning" + assert payload["error"] == "memory_snapshot_unavailable" + assert "/proc/meminfo" not in payload["error"] + + +def test_cpu_and_gpu_snapshots_expose_only_safe_error_codes(monkeypatch): + namespace = _load_functions( + "_sanitize_diagnostic_error", + "_cpu_snapshot", + "_gpu_snapshot", + extra_globals={ + "_SAFE_DIAGNOSTIC_ERROR_CODES": SAFE_DIAGNOSTIC_ERROR_CODES, + "SAFE_COMPUTE_USAGE_LIMIT_PERCENT": 90, + "SAFE_MEMORY_OCCUPANCY_LIMIT_PERCENT": 75, + "_relative_percent": lambda numerator, denominator: round((numerator / denominator) * 100, 1) if denominator > 0 else None, + "_linux_cpu_usage_percent": lambda: None, + "get_gpu_runtime_info": lambda: { + "available": False, + "error": "driver init failed for /dev/nvidia0", + "devices": [], + }, + }, + ) + + def _raise_loadavg_error(): + raise OSError("cannot read /proc/loadavg") + + monkeypatch.setattr(os, "getloadavg", _raise_loadavg_error) + + cpu_payload = namespace["_cpu_snapshot"]() + gpu_payload = namespace["_gpu_snapshot"]() + + assert cpu_payload["error"] == "cpu_load_unavailable" + assert "/proc/loadavg" not in cpu_payload["error"] + assert gpu_payload["error"] == "gpu_runtime_unavailable" + assert "/dev/nvidia0" not in gpu_payload["error"]