diff --git a/apps/web/e2e/learner.spec.ts b/apps/web/e2e/learner.spec.ts index e707cb4..5d3eefe 100644 --- a/apps/web/e2e/learner.spec.ts +++ b/apps/web/e2e/learner.spec.ts @@ -21,6 +21,10 @@ async function signInAsLearnerEmail( }, }); expect(res.ok(), await res.text()).toBeTruthy(); + const consent = await page.request.post("/api/auth/consent", { + data: { accepted: true }, + }); + expect(consent.ok(), await consent.text()).toBeTruthy(); } async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 57116b7..137fec3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -197,10 +197,7 @@ export type SessionEndResponse = ApiSchema<"SessionEndResponse">; export type CrisisResource = ApiSchema<"CrisisResourceResponse">; export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">; -export interface LearnerSessionsResponse { - source: string; - sessions: LearnerSessionSummary[]; -} +export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">; export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">; @@ -224,16 +221,7 @@ export type ReviewTechnique = ApiSchema<"ReviewTechnique">; export type ReviewNonverbalEvent = ApiSchema<"ReviewNonverbalEvent">; export type ReviewNote = ApiSchema<"ReviewNote">; -export interface ReviewTurn { - id: string; - ts: string; - speaker: "learner" | "client"; - who: string; - text: string; - techniques: ReviewTechnique[]; - nonverbal: ReviewNonverbalEvent[]; - note?: ReviewNote | null; -} +export type ReviewTurn = ApiSchema<"ReviewTurn">; export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">; export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">; @@ -241,56 +229,10 @@ export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">; export type ReviewPoint = ApiSchema<"ReviewPoint">; export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">; -export interface ReviewWorksheetItem { - key: string; - label: string; - value?: string | null; - evidence: ReviewWorksheetEvidence[]; - confidence: "none" | "low" | "medium"; - emptyReason?: string | null; -} - -export interface ReviewWorksheetSection { - key: string; - title: string; - items: ReviewWorksheetItem[]; -} - -export interface ReviewCaseWorksheet { - status: "empty" | "draft_from_transcript"; - generatedBy: string; - sections: ReviewWorksheetSection[]; - limitations: string[]; -} - -export interface SessionReviewResponse { - session_id: string; - client: ReviewClient; - date: string; - durationLabel: string; - durationSeconds: number; - reachedPhase: string; - sessionSignal: string; - supervisorState: string; - supervisorName: string; - summary: string; - phases: ReviewPhaseSegment[]; - phaseAxis: string[]; - valenceAxis: string[]; - clientValence: ReviewValencePoint[]; - counselorBaseline: ReviewValencePoint[]; - turns: ReviewTurn[]; - rubric: ReviewRubricRow[]; - goodMoments: ReviewPoint[]; - growthPoints: ReviewPoint[]; - caseWorksheet?: ReviewCaseWorksheet | null; - nextLine?: string | null; - clientFeedback?: string | null; - audioUrl?: string | null; - pdfExportUrl?: string | null; - degraded: boolean; - reviewReady: boolean; -} +export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">; +export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection">; +export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">; +export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">; /* ===================================================================== SSE 헬퍼 — POST /sessions/{id}/stream @@ -528,53 +470,13 @@ export const adminUsersApi = { }), }; -export interface TeacherSessionSummary { - session_id: string; - learner_id: string; - learner_label: string; - persona_code: string; - persona_name: string; - session_no: number; - status: "active" | "ended" | string; - stage: string; - turn_count: number; - learner_turn_count: number; - client_turn_count: number; - started_at: string; - ended_at: string | null; -} +export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">; export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">; export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">; -export interface TeacherLearnerGrowth { - learner_id: string; - learner_label: string; - sessions: number; - ended_sessions: number; - latest_at: string; - first_score: number | null; - latest_score: number | null; - score_delta: number | null; - avg_score: number | null; - avg_rapport: number | null; - trend: "up" | "down" | "flat" | "insufficient" | string; - top_techniques: string[]; - points: TeacherGrowthPoint[]; -} - -export interface TeacherDashboardResponse { - source: string; - cohort_label: string; - total_learners: number; - active_sessions: number; - ended_sessions: number; - safety_alerts: TeacherSafetyAlert[]; - learner_growth: TeacherLearnerGrowth[]; - pending_reviews: TeacherSessionSummary[]; - recent_sessions: TeacherSessionSummary[]; - message: string; -} +export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">; +export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">; export const teacherApi = { dashboard: () => api.get("/teacher/dashboard"), diff --git a/apps/web/src/pages/LearnerHome.tsx b/apps/web/src/pages/LearnerHome.tsx index b08320e..b875c81 100644 --- a/apps/web/src/pages/LearnerHome.tsx +++ b/apps/web/src/pages/LearnerHome.tsx @@ -117,7 +117,7 @@ export default function LearnerHome() { } if (sessionResult.status === "fulfilled") { - setSessions(sessionResult.value.sessions); + setSessions(sessionResult.value.sessions ?? []); setSessionLoadState("ready"); } else { console.warn("[learner-home] failed to load sessions", sessionResult.reason); @@ -438,7 +438,7 @@ const LH_CSS = ` gap:clamp(12px,1.6vw,20px); padding:clamp(14px,2vw,28px); background:var(--bg-app); - overflow:hidden; + overflow:clip; } .lh-root::before{ content:""; diff --git a/apps/web/src/pages/Professor.tsx b/apps/web/src/pages/Professor.tsx index 84262b5..05d5e17 100644 --- a/apps/web/src/pages/Professor.tsx +++ b/apps/web/src/pages/Professor.tsx @@ -291,7 +291,7 @@ export default function Professor() { () => [ { label: "검토 대기", - value: dashboard?.pending_reviews.length ?? 0, + value: dashboard?.pending_reviews?.length ?? 0, hint: "종료 회기", icon: "review" as const, }, @@ -303,13 +303,13 @@ export default function Professor() { }, { label: "최근 세션", - value: dashboard?.recent_sessions.length ?? 0, + value: dashboard?.recent_sessions?.length ?? 0, hint: "종료 · 진행", icon: "play" as const, }, { label: "위기 알림", - value: dashboard?.safety_alerts.length ?? 0, + value: dashboard?.safety_alerts?.length ?? 0, hint: "109 확인", icon: "alert" as const, }, @@ -328,12 +328,14 @@ export default function Professor() { ], [dashboard, personaReviews.length], ); - const pendingCount = dashboard?.pending_reviews.length ?? 0; + const pendingCount = dashboard?.pending_reviews?.length ?? 0; const hasPending = pendingCount > 0; const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0); const personaReviewCount = personaReviews.length; const safetyAlerts = dashboard?.safety_alerts ?? []; const learnerGrowth = dashboard?.learner_growth ?? []; + const pendingReviews = dashboard?.pending_reviews ?? []; + const recentSessions = dashboard?.recent_sessions ?? []; return ( @@ -630,13 +632,13 @@ export default function Professor() { 검토 대기

종료된 실제 회기

- {dashboard?.pending_reviews.length ?? 0}건 + {pendingReviews.length}건 - {dashboard && dashboard.pending_reviews.length > 0 ? ( + {pendingReviews.length > 0 ? (
- {dashboard.pending_reviews.map((session) => ( + {pendingReviews.map((session) => (
@@ -669,11 +671,11 @@ export default function Professor() { 최근 세션

최근 회기 기록

- {dashboard?.recent_sessions.length ?? 0}건 + {recentSessions.length}건 - {dashboard && dashboard.recent_sessions.length > 0 ? ( + {recentSessions.length > 0 ? (
- {dashboard.recent_sessions.map((session) => ( + {recentSessions.map((session) => (
- {formatDateTime(session.ended_at)} + {formatDateTime(session.ended_at ?? null)}
))} @@ -732,7 +734,9 @@ export default function Professor() { } function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) { - const recentPoints = learner.points.slice(-3).reverse(); + const points = learner.points ?? []; + const topTechniques = learner.top_techniques ?? []; + const recentPoints = points.slice(-3).reverse(); return (
@@ -763,14 +767,14 @@ function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
- {learner.points.map((point) => ( + {points.map((point) => ( ))}
- {learner.top_techniques.length > 0 ? ( - learner.top_techniques.map((tag) => {tag}) + {topTechniques.length > 0 ? ( + topTechniques.map((tag) => {tag}) ) : ( 기법 태그 부족 )} @@ -1092,6 +1096,9 @@ const PF_CSS = ` background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface))); } .pf-growth-list{ + max-height:min(540px,52vh); + overflow:auto; + scrollbar-gutter:stable; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; diff --git a/apps/web/src/pages/SessionReview.tsx b/apps/web/src/pages/SessionReview.tsx index 8de8fa2..145bd0e 100644 --- a/apps/web/src/pages/SessionReview.tsx +++ b/apps/web/src/pages/SessionReview.tsx @@ -138,6 +138,7 @@ function CaseWorksheetCard({ onJump: (id: string) => void; }) { const sections = worksheet?.sections ?? []; + const limitations = worksheet?.limitations ?? []; return ( 사례개념화 워크시트 @@ -147,8 +148,8 @@ function CaseWorksheetCard({

{section.title}

- {section.items.map((item) => { - const evidence = item.evidence[0]; + {(section.items ?? []).map((item) => { + const evidence = item.evidence?.[0]; return (
@@ -180,9 +181,9 @@ function CaseWorksheetCard({ /> )}
- {(worksheet?.limitations ?? []).length > 0 ? ( + {limitations.length > 0 ? (
- {worksheet!.limitations.slice(0, 2).map((item) => ( + {limitations.slice(0, 2).map((item) => ( {item} ))}
@@ -242,8 +243,9 @@ export default function SessionReview() { function jumpToTurn(id: string) { if (!data) return; + const turns = data.turns ?? []; setActiveTurn(id); - if (learnerOnly && data.turns.find((turn) => turn.id === id)?.speaker === "client") { + if (learnerOnly && turns.find((turn) => turn.id === id)?.speaker === "client") { setLearnerOnly(false); } requestAnimationFrame(() => { @@ -283,12 +285,21 @@ export default function SessionReview() { ); } - const hasValence = data.clientValence.length > 0 || data.counselorBaseline.length > 0; + const turns = data.turns ?? []; + const phases = data.phases ?? []; + const phaseAxis = data.phaseAxis ?? []; + const valenceAxis = data.valenceAxis ?? []; + const clientValence = data.clientValence ?? []; + const counselorBaseline = data.counselorBaseline ?? []; + const rubric = data.rubric ?? []; + const goodMoments = data.goodMoments ?? []; + const growthPoints = data.growthPoints ?? []; + const hasValence = clientValence.length > 0 || counselorBaseline.length > 0; const canOpenAudio = Boolean(data.audioUrl); const canExportPdf = Boolean(data.pdfExportUrl); - const hasTranscript = data.turns.length > 0; + const hasTranscript = turns.length > 0; const reviewReadiness = hasTranscript - ? `${data.turns.length}개 발화 기반` + ? `${turns.length}개 발화 기반` : "축어록 저장 후 생성"; return ( @@ -384,9 +395,9 @@ export default function SessionReview() {
{hasValence ? ( ) : ( 회기 흐름
- {data.phases.length > 0 ? ( + {phases.length > 0 ? ( <>
- {data.phases.map((phase, index) => ( + {phases.map((phase, index) => (
- {data.phaseAxis.map((label, index) => ( + {phaseAxis.map((label, index) => ( {label} ))}
@@ -441,7 +452,7 @@ export default function SessionReview() { className="sr-chip-toggle" aria-pressed={!learnerOnly} onClick={() => setLearnerOnly(false)} - disabled={data.turns.length === 0} + disabled={turns.length === 0} > 전체 @@ -450,7 +461,7 @@ export default function SessionReview() { className="sr-chip-toggle" aria-pressed={learnerOnly} onClick={() => setLearnerOnly(true)} - disabled={data.turns.length === 0} + disabled={turns.length === 0} > 내 발화만 @@ -483,7 +494,7 @@ export default function SessionReview() {
{turn.who} - {turn.techniques.map((tech, i) => ( + {(turn.techniques ?? []).map((tech, i) => ( ))} {(turn.nonverbal ?? []).map((event, i) => ( @@ -520,8 +531,8 @@ export default function SessionReview() { 기법 사용 분포
- {data.rubric.length > 0 ? ( - data.rubric.map((row) => ( + {rubric.length > 0 ? ( + rubric.map((row) => (
{row.name} @@ -557,12 +568,12 @@ export default function SessionReview() { 좋았던 순간
- {data.goodMoments.length > 0 ? ( - data.goodMoments.map((point, i) => ( + {goodMoments.length > 0 ? ( + goodMoments.map((point, i) => ( )) @@ -578,12 +589,12 @@ export default function SessionReview() { 다음 회기 개선점
- {data.growthPoints.length > 0 ? ( - data.growthPoints.slice(0, 3).map((point, i) => ( + {growthPoints.length > 0 ? ( + growthPoints.slice(0, 3).map((point, i) => ( )) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index bcf18cf..21b2752 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # Vignette Handoff -> Updated: 2026-06-27 18:05 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. +> Updated: 2026-06-27 18:25 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. ## 현재 상태 @@ -21,7 +21,7 @@ - X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다. - H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속. - RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed. -- 계약 SSOT 2차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. 검증: `npm run check:api-types`, `npm run typecheck`. +- 계약 SSOT 3차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf, learner sessions, session review/worksheet aggregate, teacher dashboard aggregate 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. CI 연결과 SessionDetail/SessionStart 계열 stage enum 정리는 후속. 검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, learner/session-review/teacher/layout focused E2E 32 passed. - 턴 런타임 리팩터 2차: `turn_runtime.finalize_completed_turn`으로 REST submit/stream/voice WS의 `record_completed_turn` + `record_safety_event` 호출쌍을 공통화했다. gateway `/v1/stream` route 레벨에서 `token/done/error` SSE 프레임 contract tests를 추가했다. 검증: focused backend 40 passed. - Phase3 artifact checker 강화: `scripts/check-phase3-artifacts.py`가 CSV enum, KPI report 필수 field, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 검증한다. `app/test_phase3_artifact_checker.py` 5 tests 추가. 실제 파일럿 evidence와 steward/legal/IAA gate는 그대로 외부 의존이다. - 세션 종료 UX/theme SSOT 정리: 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체하고, Topbar/Settings theme 토글을 `lib/theme.ts` 단일 경로로 통합했다. 저장값이 없으면 dark를 기본으로 두고 앱 부팅 시 theme를 먼저 적용해 초기 flash를 줄인다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts` 15 passed. @@ -30,10 +30,10 @@ - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. - 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다. - 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다. -- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-27 17:25 KST 재확인: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, 공개 로그인 버튼 클릭 시 `accounts.google.com` OAuth URL로 이동. +- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-27 18:25 KST 재확인: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google start 302 to `accounts.google.com` + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`. - `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료. - 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다. -- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 `scripts/start-tailscale-runtime.ps1` 재기동 후 `login` 200, `/api/health` dev/db/engine true, direct Google start → `local_oauth_unavailable`, dev-login → `/learn` + `/api/auth/me` 200으로 재검증됐다. +- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 `scripts/start-tailscale-runtime.ps1` 재기동 후 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200으로 재검증됐다. - `scripts/dev-up.ps1 -NoGateway -NoWeb`는 이제 gateway/web stale 정리를 건너뛰고 지정 `-ApiPort`의 API만 재기동한다. 검증 당시 8000 API-only 재기동 후 8001/8010/5173/9099/20241 listener가 보존됐다. - Docker `vignette-dev-db`는 실행 중이고 DB는 accepting connections다. 단, 기존 컨테이너라 healthcheck가 없고 `POSTGRES_USER=vignette` 기반이다. `vignette_app` role은 NOBYPASSRLS로 존재하지만 현재 API startup DDL이 owner 권한을 요구하므로 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행한다. - 공개 런타임 재기동 스크립트: diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 43d0e0b..4e19a8a 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -636,8 +636,8 @@
현재

app/turn_runtime.py를 추가해 load_owned_session, record_completed_turn, append_completed_turn, update_session_state, record_safety_event를 공통화했다. 이번 패스에서 finalize_completed_turn을 추가해 record_completed_turn + record_safety_event 호출쌍을 REST submit/stream과 voice WS 라우트에서 제거했다. stream fast-loop 평가 누락, voice 세션 recall cache, visible_to/evaluator turn 분리는 보존.

검증

pytest app/test_session_turn_persistence.py app/test_voice_ws.py engine_gateway/test_gateway_model.py app/test_phase3_artifact_checker.py app/test_dataset_export.py -q 40 passed.

잔여

더 큰 finalize_turn/SessionRepository 경계, 장기적으로는 세션 저장소 인터페이스 SSOT화가 남아 있다.

- -
현재

apps/api/scripts/export-openapi.pyapps/web/scripts/generate-api-types.mjs로 FastAPI OpenAPI를 apps/web/src/lib/api.gen.ts에 생성한다. npm run check:api-types가 재생성 누락을 잡고, TurnResponse/SessionEndResponse는 생성 타입 alias를 사용한다. 이번 패스에서 MeResponse, AuthConfigResponse, ConsentResponse, PersonaSummary, PersonaReviewSummary, admin/user/engine DTO, review leaf DTO, CrisisResourceResponse, teacher safety/growth leaf DTO를 ApiSchema<...> alias로 전환했다. generated optional/default 차이는 UI form state와 notification default helper로 흡수했다.

검증

npm run check:api-types, npm run typecheck passed. Leaf DTO residual check에서 전환 대상은 ApiSchema alias로 확인.

잔여

aggregate 응답(SessionReviewResponse, TeacherDashboardResponse, worksheet/review 배열 등)은 generated 배열 optional 차이를 UI fallback으로 먼저 흡수한 뒤 전환한다. 실제 CI 워크플로에 npm run check:api-types를 연결하는 작업도 남아 있다.

+ +
현재

apps/api/scripts/export-openapi.pyapps/web/scripts/generate-api-types.mjs로 FastAPI OpenAPI를 apps/web/src/lib/api.gen.ts에 생성한다. npm run check:api-types가 재생성 누락을 잡는다. MeResponse, AuthConfigResponse, ConsentResponse, PersonaSummary, PersonaReviewSummary, admin/user/engine DTO, review leaf DTO, CrisisResourceResponse, teacher safety/growth leaf DTO에 이어 이번 패스에서 LearnerSessionsResponse, SessionReviewResponse, worksheet aggregate, TeacherDashboardResponse, TeacherSessionSummary, TeacherLearnerGrowthApiSchema<...> alias로 전환했다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다.

검증

npm run check:api-types, npm run typecheck, npm run build passed. 영향 범위 E2E: learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts 32 passed.

잔여

실제 CI 워크플로에 npm run check:api-types를 연결하는 작업은 남아 있다. SessionDetail/SessionStart 계열은 backend stage enum/guard를 먼저 정리한 뒤 생성 타입 alias로 전환한다.

@@ -774,7 +774,7 @@
DONE
레이아웃 감사팀 / 전면 레이아웃 개선

서브에이전트 전담으로 1차 overflow/clipping 수리 후 2차 전면 재설계를 진행했고, 이번 라운드에서 7개 권장 너비 시각 수용까지 닫았다. 세션, 학습자 홈/리뷰, 교수 콘솔, 관리자, 설정 화면을 작업 화면 기준으로 재배치했고, 과한 빈칸·튀어나감·버튼/텍스트 잘림·모바일 테이블 사용성을 줄였다. 엄격한 시각 게이트 + 화면별 적대적 시각 재검수에서 7개 화면 모두 accept(critical/major 0, 회귀 0)를 받아 DONE으로 닫는다.

산출물

docs/ops/layout-redesign-handoff-2026-06-26.md, page-by-page redesign patches, apps/web/e2e/layout-visual-gate.spec.ts

검증

npm run typecheck OK, 엄격 시각 게이트 7 passed, layout focused E2E desktop/mobile 54 passed, 적대적 시각 재검수 7/7 accept·회귀 0.

DONE
엄격한 레이아웃 시각 게이트 구축 · 병렬 수정 · 적대적 재검수

핸드오프가 요구한 7개 권장 너비(390/720/861/900/1024/1280/1440) 시각 수용을 자동 게이트로 고정했다. 7개 재설계 화면을 각 너비에서 렌더링해 가로 overflow 0, 컨트롤 하드클립/텍스트클립 0을 강제하고 화면당 7장 총 49장 풀페이지 스크린샷을 남긴다. 화면별 1에이전트 병렬 시각 리뷰로 49장 직접 판독 → 6개 병렬 수정 에이전트(파일 비중첩)가 결함 수정 → 화면별 적대적 재검수로 해소·무회귀 확정.

산출물

apps/web/e2e/layout-visual-gate.spec.ts, node_modules/.tmp/layout-gate/*.png 49장, 2× workflow(review/verify)·6× fix agent

검증

게이트 7 passed(재스크린샷). 해소된 핵심 결함: 학습자홈 1280/1440 3열 가운데 컬럼 붕괴(critical → 2열 분기 상향 + word-break:keep-all), 교수 'API 404' raw 배너 제거, 리뷰 빈상태 위계 역전, 관리자 스켈레톤, 설정 와이드 좌측 데드존 제거, 세션 모바일 44px 터치타깃. 적대적 재검수 7/7 accept, regression 0.

DONE
레이아웃 cosmetic minor 폴리시 일괄 처리

적대적 재검수 잔여 cosmetic minor를 5개 병렬 폴리시 에이전트로 처리했다. 학습자홈 헬퍼문구 고아 글자, 세션 1024 일시정지 아이콘 정리 + 보내기 버튼 대비 강화, 설정 2x2 칩 행 높이 균일, 교수 검토 큐 카드 갭 제거, 관리자 2열 높이 동기화를 적용했다.

산출물

page-by-page polish patches(LearnerHome/Session/Settings/Professor/Admin)

검증

npm run typecheck OK, 시각 게이트 7 passed, 레이아웃 E2E desktop/mobile 54 passed, session-layout 8/8 — 회귀 0.

-
DONE
세션 종료 UX와 theme SSOT

세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 lib/theme.ts 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다.

산출물

Session.tsx, session.css, session-review.css, lib/theme.ts, settings/useTheme.ts, session-layout.spec.ts

검증

npm run typecheck, npm run build, strict layout gate 7 passed, session-layout desktop/mobile 8 passed, settings desktop/mobile 12 passed, session-review desktop/mobile 2 passed.

+
DONE
세션 종료 UX와 다크 테마 SSOT

세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 lib/theme.ts 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다. API 기본 preference system은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따르며, 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재를 dark 작업면으로 맞췄다.

산출물

Session.tsx, session.css, session-review.css, Settings.tsx, settings.css, lib/theme.ts, settings/useTheme.ts, session-layout.spec.ts

검증

npm run typecheck, npm run build, npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts e2e/settings.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 30 passed. 추가 시각 감사: 7개 주요 페이지 × PC/태블릿/모바일 캡처에서 theme=dark, horizontal overflow 0, large bright surfaces 0.

DONE
잔여(비차단) — 공용 셸 단일 항목 → 처리

축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 components/shell/shell.css에서 처리했다. .vg-nav border-right 제거 후 .vg-shell__body 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 --nav-cur로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.

판정

구현 완료. 검증: npm run typecheck PASS + vite build PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.

검증 실행 상세 표

요약하지 않고 실행 범위, 명령, 결과를 행 단위로 남긴다.

@@ -783,6 +783,7 @@ Web typechecknpm run typecheckPassed API typegen SSOTnpm run check:api-typesPassed; FastAPI OpenAPI → src/lib/api.gen.ts stale check + Contract SSOT aggregate DTOnpm run typecheck / npm run build / learner + session-review + teacher + layout gatePassed; learner sessions, session review/worksheet, teacher dashboard aggregates now use generated ApiSchema aliases with UI array fallback. Focused E2E 32 passed. Backend pytestpython -m pytest app/ -q130 passed X2 evaluator model routingpython -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q23 passed; fast/deep evaluator model override and blank-default routing covered C1 case worksheetpython -m pytest app/test_session_turn_persistence.py -q / session-review.spec.ts14 passed; session review exposes transcript-grounded caseWorksheet and UI evidence jump @@ -825,12 +826,12 @@ Public loginauth.spec.ts --grep public login1 passed Public runtime scriptsstart/watch/install-public-runtime*.ps1parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live api-vnet.18ka.net; add future domains explicitly with -AdditionalPublicHealthUrls. Public API healthhttps://api-vignette.chanpaca.net/healthprod, db true, engine true - Public/local/Tailnet login recoveryhttps://vignette.chanpaca.net/login / http://localhost:5173/login / https://alpaca-home.taile93291.ts.net/login2026-06-27 17:25 KST 재확인: 모바일 502 시각의 cloudflared 로그는 127.0.0.1:8001 origin refused였고 현재 public login 200, public API health prod/db/engine true. Public login button click reaches accounts.google.com with HttpOnly state cookie. Tailnet 403은 stale Vite allowedHosts 문제였고 start-tailscale-runtime.ps1 재기동 후 login 200 + /api/health dev/db/engine true + dev-login /learn 진입 + /api/auth/me 200. local/Tailnet Google direct → local_oauth_unavailable. + Public/local/Tailnet login recoveryhttps://vignette.chanpaca.net/login / http://localhost:5173/login / https://alpaca-home.taile93291.ts.net/login2026-06-27 18:25 KST 재확인: public login 200, public API health prod/db/engine true, public Google start 302 to accounts.google.com with HttpOnly state cookie, provider callback error → /login?oauth=access_denied. Tailnet 403은 stale Vite allowedHosts 문제였고 start-tailscale-runtime.ps1 재기동 후 Tailnet login 200 + /api/health dev/db/engine true + /api/auth/config 200. Docker vignette-dev-db accepting connections, 기존 컨테이너 healthcheck 없음. Local 5175 loginPLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.tsdesktop/mobile passed Learner/readiness E2Elearner.spec.ts + readiness.spec.ts desktop/mobile14 passed Learner screenshotslearn-empty-desktop.png / learn-empty-mobile-compact.pngdocument overflow 0, empty history visible Session layout/turnsession-layout.spec.ts desktop/mobile8 passed - Session end + theme UXnpm run typecheck, npm run build, layout-visual-gate, session-layout, settings, session-reviewtypecheck/build passed; strict layout gate 7 passed; session-layout desktop/mobile 8 passed; settings desktop/mobile 12 passed; session-review desktop/mobile 2 passed + Session end + dark theme UXnpm run typecheck, npm run build, layout-visual-gate, session-layout, settings, session-reviewtypecheck/build passed; Playwright 묶음 30 passed(layout visual gate 7, session-layout desktop/mobile 8, settings desktop/mobile+single-run 13, session-review desktop/mobile 2). 7개 주요 페이지 × PC/태블릿/모바일 수동 캡처 감사에서 theme=dark, horizontal overflow 0, large bright surfaces 0. Voice/session focusedvoice + voice-success + session-layout15 passed Layout redesign handoffdocs/ops/layout-redesign-handoff-2026-06-26.mdsubagent scopes, files, verification, remaining visual review recorded Layout redesign focused E2Enpx playwright test e2e/learner.spec.ts e2e/session-layout.spec.ts e2e/session-review.spec.ts e2e/admin.spec.ts e2e/settings.spec.ts e2e/teacher.spec.ts --project=chromium-desktop --project=chromium-mobile54 passed after isolated Admin tablet flake rerun diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index 09c03f6..e0b5c58 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -373,7 +373,9 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom). - SSE: `openSessionStream(sessionId, text, handlers)` — `fetch` 스트림을 직접 라인 파싱해 `token | done | safety | ping | error` 이벤트를 콜백으로 전달. - 도메인 헬퍼: `sessionApi`(list/get/start/turn/end/review/stream), `personaApi`, `personaReviewApi`, - `adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 응답 타입은 백엔드 라우트의 pydantic 모델 미러. + `adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 주요 응답 타입은 FastAPI OpenAPI에서 생성한 + `src/lib/api.gen.ts`의 `ApiSchema<...>` alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서 + 빈 배열 fallback으로 흡수한다. - dev 환경: `vite.config`가 `/api` → `http://127.0.0.1:8000` 프록시(`/api` 프리픽스 제거). 배포 호스트(`vignette.chanpaca.net`, `*.pages.dev`)에서는 `api-vignette.chanpaca.net`을 직접 가리킨다. diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 3e9a6ec..feec6fe 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -65,7 +65,7 @@ - P1 서연 음성 아트 PoC: Higgs v3 무참조 synthetic seed + 5개 정서/속도 변주(mp3/wav) 생성. 실존 reference voice 미사용. `/voice/ws`에는 dev-only sample TTS provider로 연결했고 live text_turn smoke에서 `p1-sample-poc` binary 20 chunks/78,573 bytes 수신 확인. 이 provider는 TTS override만 담당한다(마이크/STT는 OpenAI 키 필요). 프로덕션 탑재는 라이선스/권리 결정 전 금지. - 부수: RLS smoke 스크립트 cleanup RLS 컨텍스트 버그 수정, `vignette_app`(NOBYPASSRLS) dev 역할 프로비저닝. - 평가 SSOP 2차: `TurnRecord.evaluation`을 `app.feedback_scores`/`turn_technique`/`turn_client_state`/`supervisor_comment`/`alternative_utterance`로 정규화 적재·복원하고, submit/stream/voice 저장 경로를 `app.turn_runtime`으로 공통화했다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. `app.turns` insert는 `RETURNING id` 확인으로 silent success 제거. **검증: `pytest app/test_evaluation_persistence.py -q` 6 passed, 관련 회귀 45 passed, `pytest app/` 119 passed, `pytest engine_gateway/` 7 passed.** -- 계약 SSOT 2차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 유지한다. `TurnResponse`/`SessionEndResponse`에 이어 `MeResponse`, `AuthConfigResponse`, `ConsentResponse`, `PersonaSummary`, `PersonaReviewSummary`, admin/user/engine DTO, review leaf DTO, `CrisisResourceResponse`, teacher safety/growth leaf DTO를 생성 타입 alias로 전환했다. generated optional/default 차이는 UI form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. **검증: `npm run check:api-types`, `npm run typecheck`.** +- 계약 SSOT 3차: FastAPI OpenAPI를 `apps/web/src/lib/api.gen.ts`로 생성하는 `npm run generate:api-types`와 stale 체크용 `npm run check:api-types`를 유지한다. `TurnResponse`/`SessionEndResponse`에 이어 `MeResponse`, `AuthConfigResponse`, `ConsentResponse`, `PersonaSummary`, `PersonaReviewSummary`, admin/user/engine DTO, review leaf DTO, `CrisisResourceResponse`, teacher safety/growth leaf DTO, `LearnerSessionsResponse`, `SessionReviewResponse`, worksheet aggregate, `TeacherDashboardResponse`, `TeacherSessionSummary`, `TeacherLearnerGrowth`를 생성 타입 alias로 전환했다. generated optional/default 차이는 UI form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. CI 연결과 SessionDetail/SessionStart 계열의 stage enum 정리는 후속. **검증: `npm run check:api-types`, `npm run typecheck`, `npm run build`, `learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts` 32 passed.** - 위기게이트 109 1차: 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 `crisis_resource(109)`와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `app.safety_events` detail 적재와 교수자 대시보드 안전 알림 큐까지 연결했다. 안전 이벤트 DB insert payload도 회귀화했다. 임상 문안, 실시간 push/메일, 지역 자원 확장은 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q` 20 passed, `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed.** - 교수자 성장 추적 1차: `teacher_dashboard`가 정규화된 턴 평가를 기반으로 학습자별 적절성·라포·기술 사용 추이를 집계하고, 교수 콘솔에 최근 회기/항목별 성장 카드와 안전 알림 큐를 함께 표시한다. **검증: `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.** - 페르소나 저작 CRUD 1차: teacher/admin 전용 `POST/GET/PUT /personas/drafts` 경로를 추가해 draft 생성·조회·편집·검수요청을 열고, 교수 콘솔에 JSON 초안 작성/불러오기/수정 UI를 연결했다. 승인된 카드만 학습자 catalog에 노출하는 기존 경계는 유지한다. P4~P7 실제 임상 콘텐츠 입력은 후속. **검증: `pytest app/test_persona_review.py -q` 19 passed, `pytest app/` 119 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`.** @@ -78,5 +78,5 @@ - H4 LLM call audit 1차: 상담 생성(generate/stream)·fast-loop 평가·deep-loop 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않고 감사 실패는 상담 루프를 막지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다(session `2460d56c-a9cb-4a40-a175-9575d510a5e9`). 공개 Google OAuth 실제 `/turn` proof는 별도 B2 항목에 남긴다. **검증: `pytest app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py -q` 27 passed, `pytest app/ -q` 119 passed.** - H4 동의 게이트 1차: `app_user.consent_at`를 런타임 테이블 readiness와 `SessionUser`/`Principal`에 포함하고, learner 전용 `POST /auth/consent`·`DELETE /auth/consent`를 추가했다. 동의가 없는 learner는 `POST /sessions`와 voice dev persona 시작이 `consent_required`로 막히고, 프론트 세션 시작 전 동의 체크 UI가 먼저 저장하도록 안내한다. E2E dev-login helper는 세션 테스트 전에 동의를 seed한다. 한국어 이름/기관 NER, guardian/legal 서명 동의서 evidence, 공개 Google OAuth 실제 `/turn` proof는 후속. **검증: `pytest app/ -q` 125 passed, `pytest app/test_auth_providers.py app/test_session_turn_persistence.py app/test_persona_review.py app/test_voice_ws.py -q` 64 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, local `/sessions` no-consent 403 + `/auth/consent` 200.** - RAG warm 동시성 안정화: layout/session E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 여러 번 지연 로드되며 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py`에서 embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py`의 `_warm_rag_caches`를 semaphore 1개로 제한해 warm task가 상담 요청 경로를 막지 않도록 했다. **검증: `pytest app/ -q` 125 passed, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts --project=chromium-single-run --project=chromium-desktop --workers=1` 11 passed.** -- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. 2026-06-27 모바일 502는 cloudflared 로그의 `127.0.0.1:8001` origin refused와 일치했다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. **검증: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, local/Tailnet Google direct는 각 origin의 `/login?oauth=local_oauth_unavailable`, `https://alpaca-home.taile93291.ts.net/login` 200 + `/api/health` dev/db/engine true, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.** +- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. 2026-06-27 모바일 502는 cloudflared 로그의 `127.0.0.1:8001` origin refused와 일치했다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. 2026-06-27 18:25 KST에 stale Vite `allowedHosts`로 생긴 Tailnet 403을 `scripts/start-tailscale-runtime.ps1` 재기동으로 복구했다. **검증: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, `https://alpaca-home.taile93291.ts.net/login` 200 + `/api/health` dev/db/engine true + `/api/auth/config` 200, local/Tailnet Google direct는 `local_oauth_unavailable`, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.** - Docker dev DB 안전장치: `scripts/dev-up.ps1`가 `vignette-dev-db` healthcheck/pg_isready/role safety를 점검하고, 새 컨테이너 생성 시 `vignette_owner`와 앱 role을 분리해 healthcheck를 붙인다. `infra\.env` compose 필수값 누락도 사전 경고한다. `scripts/dev-down.ps1`은 기본 DB 보존, `-Db` 지정 시 컨테이너 중지로 정리했다. **검증: 현재 Docker DB accepting connections, `vignette_app:false:false`, 기존 컨테이너 healthcheck 없음. 추가 확인 결과 기존 DB는 `POSTGRES_USER=vignette` 기반이고 API startup DDL이 owner 권한을 요구해 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행해야 한다.** diff --git a/docs/ops/tailscale-vnet-runtime-2026-06-27.md b/docs/ops/tailscale-vnet-runtime-2026-06-27.md index a0081d5..b16bbf0 100644 --- a/docs/ops/tailscale-vnet-runtime-2026-06-27.md +++ b/docs/ops/tailscale-vnet-runtime-2026-06-27.md @@ -26,6 +26,7 @@ - Tailnet API `127.0.0.1:8010` - web `127.0.0.1:5173` - 2026-06-27 17:00 KST 재확인: Tailnet `GET /login`이 `403 Blocked request. This host ... is not allowed`를 반환하던 원인은 Vite가 stale env로 떠서 `VITE_ALLOWED_HOSTS=alpaca-home.taile93291.ts.net`를 반영하지 못한 상태였다. `scripts/start-tailscale-runtime.ps1 -ApiPort 8010 -WebPort 5173 -SkipServeUpdate` 재기동 후 `GET /login` 200, `GET /api/health` dev/db/engine true, direct Google start 302 `local_oauth_unavailable`로 복구 확인. +- 2026-06-27 18:25 KST 재확인: 같은 stale Vite `allowedHosts` 증상이 재발해 `scripts/start-tailscale-runtime.ps1 -ApiPort 8010 -WebPort 5173`로 스택을 재기동했다. 현재 `https://alpaca-home.taile93291.ts.net/login` 200, `/api/health` 200(`dev`, `db:true`, `engine:true`), `/api/auth/config` 200이다. 함께 확인한 공개 경로도 `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` 200(`prod`, `db:true`, `engine:true`)이다. ## 공개 vnet.18ka.net 상태