From f97e7fadac9be5234d8dff2d53698e10d3eae976 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 12 Aug 2026 16:33:59 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A3=BC=EA=B8=B0=20=ED=9A=8C=EA=B8=B0=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=EA=B3=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8F=AD=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/e2e/full-sweep-auth.spec.ts | 33 ++++++++ apps/web/src/components/auth/auth-shell.css | 4 +- apps/web/src/pages/login/login.css | 4 +- docs/HANDOFF.md | 12 ++- docs/TODO.md | 4 +- docs/dev_dashboard.html | 6 +- docs/guides/testing.md | 16 ++-- docs/ops/backlog-2026-06-26.md | 2 +- scripts/run-periodic-learner-e2e.py | 83 +++++++++++++++++++++ scripts/test_run_periodic_learner_e2e.py | 69 +++++++++++++++++ 10 files changed, 216 insertions(+), 17 deletions(-) diff --git a/apps/web/e2e/full-sweep-auth.spec.ts b/apps/web/e2e/full-sweep-auth.spec.ts index d6cb58a..5dea285 100644 --- a/apps/web/e2e/full-sweep-auth.spec.ts +++ b/apps/web/e2e/full-sweep-auth.spec.ts @@ -115,6 +115,39 @@ test.describe("full sweep auth", () => { await useRealApi(page); }); + // 검증 checklist: auth-login-mobile-scrollport + test("login root follows the narrow document scrollport without horizontal overflow", async ({ + page, + }) => { + await page.route(ME_PATTERN, async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "not_authenticated" }), + }); + }); + await fulfillConfig(page, READY_AUTH_CONFIG); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await expect(page.locator(".lg-root")).toBeVisible(); + await expectNoHorizontalOverflow(page); + + const widths = await page.evaluate(() => { + const documentRoot = document.documentElement; + const loginRoot = document.querySelector(".lg-root"); + if (!loginRoot) throw new Error("login root missing"); + return { + documentClientWidth: documentRoot.clientWidth, + documentScrollWidth: documentRoot.scrollWidth, + loginRight: loginRoot.getBoundingClientRect().right, + loginWidth: loginRoot.getBoundingClientRect().width, + }; + }); + expect(widths.documentScrollWidth).toBeLessThanOrEqual(widths.documentClientWidth + 1); + expect(widths.loginRight).toBeLessThanOrEqual(widths.documentClientWidth + 1); + expect(widths.loginWidth).toBeLessThanOrEqual(widths.documentClientWidth + 1); + }); + // 검증 checklist: auth-login-generic-google-button test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({ page, diff --git a/apps/web/src/components/auth/auth-shell.css b/apps/web/src/components/auth/auth-shell.css index 549a005..1f2b08e 100644 --- a/apps/web/src/components/auth/auth-shell.css +++ b/apps/web/src/components/auth/auth-shell.css @@ -1,8 +1,8 @@ .vg-auth-shell { position: relative; isolation: isolate; - inline-size: 100%; - max-inline-size: none; + inline-size: auto; + max-inline-size: 100%; min-block-size: 100vh; min-block-size: 100dvh; overflow-x: clip; diff --git a/apps/web/src/pages/login/login.css b/apps/web/src/pages/login/login.css index 6b6313f..46ed150 100644 --- a/apps/web/src/pages/login/login.css +++ b/apps/web/src/pages/login/login.css @@ -1,7 +1,7 @@ .lg-root { min-height: 100dvh; - width: 100%; - max-width: none; + width: auto; + max-width: 100%; position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 500px); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 35d51d7..a670206 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -50,6 +50,14 @@ G8 배포 source 기준선은 branch `master`, HEAD `61a41d1f08239b5f9e99cfae42a listener owner와 generation readiness를 강제하고 loopback contract **8/8**을 통과했다. 실제 9100 listener, NAS 설정·컨테이너·DB는 건드리지 않았다. 다음 mutation은 소유자 승인 뒤 단일 owner가 check-only→actual 1회→ NAS health/auth/OpenAPI/assets/실회기 재검증 순서로만 수행한다. +- 마지막 full disposable GREEN은 clean `64e1b221…`의 `periodic-learner-e2e-20260812-150209.json`·SHA-256 + `a7b2f758…8071`이다. detached-clean `94666192…` 확인은 전용 engine ready 뒤 disposable DB init health에서 + 브라우저 전에 FAILED했고 receipt `periodic-learner-e2e-20260812-155929.json`·SHA-256 `1bd95e01…52a8`에 + public/NAS/active DB 접촉 0과 cleanup 0을 남겼다. 같은 heartbeat에서는 재시도하지 않았다. +- 현재 공유 worktree에는 이 실패의 cleanup 전 `compose ps`·db/api 로그를 secret/email/UUID/query-safe tail로 + 영수증에 고정하는 runner 보강과, 실제 NAS 390px에서 남은 10px 로그인 overflow를 block auto-size로 고치는 + scoped source가 있다. contract 14/14, 학생 route-fixture desktop/mobile 76/76, auth 2/2, dashboard 10/10, + type/API contract와 SSOT checker는 GREEN이지만 아직 commit/deploy/full run 전이다. - 마지막 완료 작업은 G8 source HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`의 candidate 112/112와 actual NAS-origin 112/112다. API/Web `d5021950…e4b1`/`9796c092…4b36`, health ok·db/engine true·auth 401·OpenAPI 126, fresh dump `36ec8748…24db8`을 확인했다. @@ -697,7 +705,7 @@ node.exe .\node_modules\@playwright\test\cli.js test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1 --reporter=line ``` -전체 Playwright inventory는 627 tests / 45 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를 +전체 Playwright inventory는 629 tests / 45 files다. 실행 환경/API/DB를 정확히 맞추지 않고 fixture failure를 제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다. 주기 실회기 검증은 `scripts/run-periodic-learner-e2e.py`가 소유한다. NAS 112건 중 실 API/DB는 22건이고 @@ -706,7 +714,7 @@ route fixture는 90건이므로 전체 숫자를 실제 회기 폐루프로 부 검증한 뒤 sentinel resource·PID·listener·temp 잔여 0을 영수증으로 남긴다. 공개 8001/55432/9099와 NAS는 금지다. 최초 세 full run은 SSE body replay, durable turn 전 종료, 비종료 SSE `response.finished()` 대기를 차례로 찾아 모두 fail-closed했다. 4차 clean run은 reload 뒤 persisted episode를 `학습자만 실행`으로 표시하는 제품 결함을 검출했다. -5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 7차 clean `aa81af29…`는 same-learner SSE·review·G4/G5 `0→1→1`, 별도 zero-state returned desktop/mobile 4/4, route mock 0과 exact cleanup 0으로 첫 전체 GREEN receipt `periodic-learner-e2e-20260810-020117.json`·SHA-256 `bae963de…f32e1a`를 만들었다. 일곱 run 모두 public/NAS/active DB 접촉 0이며 cleanup 7/7 GREEN이다. 기존 hourly heartbeat는 중복 없이 갱신해 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀐 경우에만 preflight 뒤 full runner를 최대 1회 실행하고, 실패 시 자동 재시도하지 않는다. +5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 7차 clean `aa81af29…`는 첫 전체 GREEN을 만들었고 최신 full GREEN은 clean `64e1b221…`의 `periodic-learner-e2e-20260812-150209.json`·SHA-256 `a7b2f758…8071`이다. current `94666192…` 단일 run은 disposable DB init health에서 브라우저 전에 FAILED했으며 receipt `periodic-learner-e2e-20260812-155929.json`·SHA-256 `1bd95e01…52a8`은 public/NAS/active DB 접촉 0·cleanup 0을 증명한다. 당시 cleanup 전 로그가 없어 exact init 원인은 미확정이다. runner는 이제 `compose ps`와 db/api log tail을 secret/PII-safe로 영수증에 남기고 unit 14/14를 통과한다. current source는 새 clean commit 뒤 single full run GREEN 전까지 완료로 부르지 않는다. heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀐 경우에만 preflight 뒤 full runner를 최대 1회 실행하고 실패 시 자동 재시도하지 않는다. ## 9. 핵심 변경 파일 diff --git a/docs/TODO.md b/docs/TODO.md index ea0074b..cda9834 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -161,12 +161,12 @@ > 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다. > 매시간 `vignette-e2e` heartbeat 자동화는 ACTIVE다. 공개/NAS 화면과 health는 읽기 전용으로 보여주고, > 소스 material milestone에서만 전용 disposable 회기 E2E를 실행한다. runner의 clean HEAD/tree·로컬 npipe Docker· -> loopback 동적 포트·sentinel·exact cleanup 계약과 unit 12/12는 완료됐다. 최초 세 full run은 각각 SSE body 재읽기, +> loopback 동적 포트·sentinel·exact cleanup 계약과 unit 14/14는 완료됐다. 최초 세 full run은 각각 SSE body 재읽기, > durable turn 전 종료, 비종료 SSE의 `response.finished()` 대기라는 harness 결함을 fail-closed로 잡았다. 4차 clean run은 > 6.3분 안에 동일 학습자 SSE·review ready·G4 원장 0→1까지 통과한 뒤, full reload가 persisted episode를 화면에 hydrate하지 > 못해 이미 기록된 상태를 `학습자만 실행`으로 되돌리는 제품 결함을 잡았다. 네 번 모두 public/NAS/active DB 접촉 0·자원 잔여 0이다. > 현재 UI는 현재 session+prescription의 persisted episode를 `현재 원장 상태`로 복원하며 route fixture desktop/mobile 6/6을 통과했다. -> 5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 보정 후 7차 clean `aa81af29…`는 same-learner SSE·review·G4/G5 `0→1→1`, 별도 zero-state returned desktop/mobile 4/4, route mock 0과 exact cleanup 0으로 첫 **전체 GREEN receipt** `periodic-learner-e2e-20260810-020117.json`을 만들었다. public/NAS/active DB 접촉은 0이다. 기존 hourly heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀐 경우에만 preflight 뒤 full runner를 최대 1회 실행하며 실패 시 같은 실행에서 재시도하지 않는다. +> 5·6차 clean run은 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 fail-closed했다. 보정 후 7차 clean `aa81af29…`는 same-learner SSE·review·G4/G5 `0→1→1`, 별도 zero-state returned desktop/mobile 4/4, route mock 0과 exact cleanup 0으로 첫 전체 GREEN을 만들었다. 최신 full GREEN은 clean `64e1b221…`의 `periodic-learner-e2e-20260812-150209.json`·SHA-256 `a7b2f758…8071`이다. current `94666192…` 단일 run은 disposable DB init health에서 브라우저 전에 FAILED했지만 public/NAS/active DB 접촉 0·exact cleanup 0을 유지했다. cleanup 전에 진단 로그를 보존하지 못한 공백은 secret/PII-safe `compose ps`·db/api tail receipt와 unit 14/14로 닫았다. current source는 clean commit 뒤 single full run이 GREEN이기 전까지 완료로 부르지 않으며, 실패한 heartbeat에서는 재시도하지 않는다. > 증거: [배포 증거](./ops/nas-preview-deployment-evidence-2026-08-07.md), [기계 판독 증거](./ops/evidence/nas-preview-current-deploy-2026-08-07.json), [브라우저 증거](./ops/evidence/nas-preview-live-turn-2026-08-07.png). 이는 G7 물리 마이크·독립 사람 평가 증거를 대체하지 않는다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·모델 탐색·설정 저장 경로는 구현 완료. 남은 게이트는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 뒤 같은 프롬프트의 live 응답·계량·오류 표면화를 비교하는 것이다. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 86905b3..52a6549 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -301,7 +301,7 @@
project pulse

지금 위치: G0~G6 · G8 증거 DONE. NAS 엔진 복구 · G7 external proof가 남았다.

-

현재: 공개 앱·주기 실회기는 GREEN이지만 NAS 프리뷰 API는 health 3회 타임아웃으로 DEGRADED다. 다음: 승인된 source-pinned engine relay 복구와 NAS-origin 재검증, 이후 G7 명시 동의 mic·human pack·checker exit 0.

+

현재: 공개 앱은 GREEN이다. 주기 실회기 current 확인은 DB init에서 fail-closed(cleanup 0), NAS는 health timeout이다. 다음: clean single run → 승인 relay → G7 mic·human proof.

운영 복구·watchdog·배포 상세 보기

2026-08-12 현재 인시던트: 공개 API는 health ok·db/engine true·OpenAPI 126·auth 401과 local voice를 유지한다. NAS 프리뷰는 앞선 3/3 degraded·db=true·engine=false에서 15:24 KST local health 3회 타임아웃으로 악화됐고 정적 로그인 화면만 계속 보인다. NAS API의 고정 ENGINE_URL=http://192.168.0.223:9100 대상 listener가 사라졌으며 공개 엔진은 별도 loopback 127.0.0.1:9099에서 정상이다. G8 clean-head candidate 112/112·실제 origin 112/112와 rollback receipt는 완료 증거로 보존하지만, 현 NAS runtime은 relay를 source/secret/task에 결속해 복구하고 health 3/3·auth·OpenAPI·assets·실제 회기를 다시 통과할 때까지 DEGRADED다. 자동 재시작·포트 노출·배포는 실행하지 않았다. G7은 별도로 명시 동의 mic·동시 high-water·human evidence가 남는다.

@@ -679,6 +679,8 @@

200차 적용(2026-08-12): actual NAS 로그인 320/390 읽기 전용 관측에서 조작면·boot·console은 정상이지만 비-overlay 세로 scrollbar 환경의 100vw 두 선언이 10px 가로 넘침을 만드는 P1을 확인했다. auth/login shell을 scrollbar를 제외한 컨테이너 기준 100%로 바꾸고 320px stable-scrollbar OAuth fixture를 고정했다. 인증 desktop/mobile 2/2, 자기주도+G1·G4·G5·G7 56/56, 대시보드 10/10, G7 runner·checker·human/API 계약 110/110, type/API contract·Ruff·SSOT checker를 통과했다. 물리 soak standalone도 canonical 공통 3,000초를 보존하도록 production 기본·최소를 3,120초로 통일해 3,000초 요청을 장치 열거 전에 차단한다. clean HEAD 64e1b221…a4e·tree 2c687bac…30bf의 full disposable runner는 489.6초에 GREEN이었고 receipt periodic-learner-e2e-20260812-150209.json·SHA-256 a7b2f758…8071에서 same-learner SSE·review·G4/G5 0→1→1, returned desktop/mobile 4/4, route mock 0, public·NAS·active DB 접촉 0과 container·volume·network·listener·temp 잔여 0을 확인했다. 이 소스는 아직 공개/NAS에 배포하지 않았다.

201차 관측(2026-08-12): 읽기 전용 현재-state 대조에서 공개 앱은 health ok·db/engine true·OpenAPI 126·auth 401과 local_whisper/small·melotts/melotts-korean을 유지했지만, NAS 프리뷰는 3회 연속 status=degraded·db=true·engine=false였다. NAS의 DB·Web·proxy는 running이고 API는 restart 0인 채 unhealthy였다. 원인은 API의 고정 ENGINE_URL=http://192.168.0.223:9100 대상 listener 부재로 좁혔다. 현재 공개 엔진은 별도 loopback 127.0.0.1:9099에만 있어 공개 서비스는 영향받지 않는다. 과거 G8 clean-head NAS-origin 112/112와 실제 rollback receipt는 완료 증거로 보존하되, 현 NAS runtime은 DEGRADED · REVALIDATION으로 표시한다. 별도 승인 전에는 LAN 포트 노출·Scheduled Task 생성·컨테이너 재시작·배포를 하지 않는다. source-pinned·secret-bound relay를 복구한 뒤 health 3/3·auth 401·OpenAPI 126·assets·실제 NAS-origin 회기 smoke를 다시 통과해야 current GREEN으로 돌아간다.

202차 적용(2026-08-12): NAS relay 복구 스크립트를 기본 loopback·detached-clean HEAD/tree·launcher/Python/env SHA·exact consumer URL·명시 LAN 승인·외부 state/receipt로 fail-closed했다. wildcard·기존 listener 재사용·source 내부 또는 reparse-point 외부 경로를 거부하고, liveness 뒤 실제 provider /ready?force=true를 한 번만 호출한다. readiness/receipt 실패는 이번 실행이 소유한 PID/listener만 정리한다. Windows PowerShell parse, contract 8/8, Ruff·compile·diff-check를 통과했고 public 9099 listener는 1, NAS 9100 listener는 0을 유지했다. 같은 시각 public은 health ok·db/engine true·OpenAPI 126·auth 401·local voice exact였지만 NAS health는 3회 타임아웃했다. 실제 LAN relay·NAS/container/DB mutation은 소유자 승인 전 실행하지 않는다.

+

203차 관측·보강(2026-08-12): detached-clean 94666192…의 periodic full run은 전용 engine ready를 통과한 뒤 app.ci_regression_dag_node가 준비되지 않은 disposable PostgreSQL health에서 실패해 브라우저 단계 전에 종료됐다. receipt periodic-learner-e2e-20260812-155929.json·SHA-256 1bd95e01…52a8은 FAILED이며 public·NAS·active DB 접촉 0과 container·volume·network·listener·temp 잔여 0을 확인했다. stack source는 마지막 GREEN 64e1b221…과 동일하지만 cleanup 전에 DB/API 로그를 영수증에 남기지 않아 정확한 환경/init 원인은 미확정이다. 같은 heartbeat에서 재시도하지 않았다. runner는 이제 cleanup 전에 compose ps와 db/api 로그 tail을 캡처하고 secret·email·UUID·민감 query를 제거하며 raw SHA만 결속한다. contract 14/14·Ruff·compile을 통과했다.

+

204차 적용(2026-08-12): actual NAS 로그인 390×844에서 innerWidth=390, document usable width 380, login root width 390으로 10px 가로 넘침이 계속 재현돼 200차의 100vw→100% 보정만으로는 부족함을 확인했다. auth/login 최상위 shell을 block auto-size와 max-inline-size:100%로 바꿔 classic scrollbar를 usable scrollport에서 이중 계산하지 않게 했다. route-mocked 학생 자기주도·G1~G5·G7 desktop/mobile 76/76, 인증 2/2, 대시보드 10/10, type/API contract·SSOT checker를 통과했다. 이 수정은 아직 공개/NAS에 배포하지 않았고, 현 NAS는 health 타임아웃·실제 화면 overflow 10px인 DEGRADED 상태다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -1089,7 +1091,7 @@ Web typechecknpm run typecheckPassed Design SSOT / auth visualnpm run check:design-ssot / npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line / npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=lineSSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed. - Full Playwright E2E baselinenpm run e2e:parallel / npm run e2e:single-run / npm run e2e:list2026-08-09 현재 수집은 627 tests / 45 files다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다. + Full Playwright E2E baselinenpm run e2e:parallel / npm run e2e:single-run / npm run e2e:list2026-08-12 현재 수집은 629 tests / 45 files다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다. Refactor governance P1~P8ruff check app / pytest -q app / pytest -q engine_gateway / npm run typecheck / npm run check:api-types / npm run check:design-ssot / npm run check:dead-code / npm run check:duplication / npm run build / npm audit --audit-level=high / full PlaywrightBackend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 ops/refactor-governance-2026-07-15.md. API typegen SSOTnpm run check:api-typesPassed; FastAPI OpenAPI → src/lib/api.gen.ts stale check Outcome & Alliance OS G0py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q / scripts/check-measurement-ledger.sql / measurement·API contract checks / web typecheck / DB-backed session-persistence focused E2E 3종G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료. diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 56c74c1..b2d9383 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타 | API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | -| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 627 tests / 45 files · 현 작업트리 전체 GREEN 미검증 | +| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 현재 수집 629 tests / 45 files · 현 작업트리 전체 GREEN 미검증 | 핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.** **E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다. @@ -271,8 +271,8 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API ### 3.6 실측 테스트 개수 (현재) -2026-08-09 `npx playwright test --list` 기준 **현재 수집 627 tests / 45 files**다. -이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 627개 완주는 아직 증거가 없으며, +2026-08-12 `npx playwright test --list` 기준 **현재 수집 629 tests / 45 files**다. +이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 629개 완주는 아직 증거가 없으며, 과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다. NAS-origin 112건은 전부 실 API/DB 회기가 아니다. release receipt는 `session-layout`·`session-persistence` @@ -284,9 +284,13 @@ NAS-origin 112건은 전부 실 API/DB 회기가 아니다. release receipt는 ` persisted episode를 UI에 hydrate하지 못하는 제품 결함을 각각 fail-closed로 검출했다. 5·6차는 핵심 동일 학습자 SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 검출했다. 보정 후 7차 clean `aa81af29…`는 same-learner SSE·review·G4/G5 `0→1→1`, 별도 zero-state returned desktop/mobile 4/4, route mock 0, -exact cleanup 0으로 첫 전체 GREEN receipt `periodic-learner-e2e-20260810-020117.json`을 만들었다. 보호 runtime 접촉은 0이다. -hourly heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 preflight 뒤 full runner를 최대 1회 -실행하며, 실패 시 같은 heartbeat에서 재시도하지 않는다. +exact cleanup 0으로 첫 전체 GREEN receipt를 만들었다. 최신 full GREEN은 clean `64e1b221…`의 +`periodic-learner-e2e-20260812-150209.json`·SHA-256 `a7b2f758…8071`이다. current `94666192…` run은 +disposable DB init health에서 브라우저 전에 FAILED했지만 보호 runtime 접촉 0·exact cleanup 0을 유지했다. +당시 cleanup 전에 DB/API 로그를 보존하지 않아 exact 원인은 미확정이다. runner는 이제 실패 시 cleanup 전에 +`compose ps`와 db/api log tail을 수집하고 secret·email·UUID·민감 query를 제거한 뒤 raw SHA와 함께 receipt에 결속한다. +contract 14/14를 통과했다. hourly heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 +preflight 뒤 full runner를 최대 1회 실행하며, 실패 시 같은 heartbeat에서 재시도하지 않는다. - **병렬 시나리오**: 166 tests (desktop 83 + mobile 83) - **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등) diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 04ebc67..8c88604 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -98,7 +98,7 @@ 무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다. 격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와 review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증` - heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여준다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 12/12는 완료됐다. 최초 세 full run은 하네스, 4차는 reload ledger hydration, 5차는 fixture 공유, 6차는 mobile success-state locator를 fail-closed했다. 7차 clean `aa81af29…`는 same-learner SSE·review·G4/G5 `0→1→1`, 별도 zero-state returned desktop/mobile 4/4, route mock 0과 cleanup 0으로 첫 전체 GREEN receipt `periodic-learner-e2e-20260810-020117.json`을 만들었다. 일곱 번 모두 public/NAS/active DB 접촉 0·자원 잔여 0이다. heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 preflight 뒤 full runner를 최대 1회 실행하고 실패 시 자동 재시도하지 않는다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md), + heartbeat(automation id `vignette-e2e`)는 ACTIVE이며 공개/NAS 화면·health는 읽기 전용으로 보여준다. NAS 112건은 실 API/DB 22와 route fixture 90으로 영수증에서 분리한다. 동일 학습자 SSE→review→G4/G5 actual 폐루프 runner의 clean HEAD/tree·보호 포트 거부·sentinel·exact cleanup과 unit 14/14는 완료됐다. 최초 세 full run은 하네스, 4차는 reload ledger hydration, 5차는 fixture 공유, 6차는 mobile success-state locator를 fail-closed했다. 7차 clean `aa81af29…` 이후 최신 full GREEN은 clean `64e1b221…`의 `periodic-learner-e2e-20260812-150209.json`·SHA-256 `a7b2f758…8071`이다. current `94666192…` 확인은 disposable DB init health에서 브라우저 전에 FAILED했지만 public/NAS/active DB 접촉 0·cleanup 0을 유지했다. 당시 cleanup 전 로그 부재 때문에 exact 원인은 미확정이며 runner는 이제 secret/PII-safe `compose ps`·db/api tail을 실패 영수증에 고정한다. current source는 새 clean commit 뒤 single full run GREEN 전까지 완료로 부르지 않는다. heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 preflight 뒤 full runner를 최대 1회 실행하고 실패 시 자동 재시도하지 않는다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. 2026-08-07 SHA `6030a677af7e87cbfabc422b553d108d53414fd3c446548734a13b036d35c611`의 localhost 108/108과 평문 origin UUID 24건 실패는 역사 기준선으로 보존한다. 실제 receipt-bound rollback은 같은 프리뷰에서 별도 helper로 두 번 실행해 종료했다([런북](./nas-preview-g8-rollback-proof-runbook.md), [기계 판독 증거](./evidence/nas-preview-g8-actual-rollback-2026-08-07.json)). 현재는 source HEAD/tree `61a41d1f…6af`/`87dec55d…3b77`의 active archive `4d15d055…119d4d`, exact API/Web `d5021950…e4b1`/`9796c092…4b36`이 실행 중이다. candidate 112/112와 실제 NAS-origin 112/112, health ok·db/engine true·auth 401·OpenAPI 126을 통과했고 fresh dump `36ec8748…24db8` 1,097,100 bytes·TOC 1,738/TABLE DATA 129와 previous `6030a677…c611`을 보존했다. 증거: [배포 증거](./nas-preview-deployment-evidence-2026-08-07.md), [브라우저 증거](./evidence/nas-preview-live-turn-2026-08-07.png). 위 health GREEN은 배포 당시 증거다. 2026-08-12 current NAS는 고정 LAN engine relay listener 상실로 degraded였고 15:24 KST local health 요청도 3회 타임아웃했다. source-pinned relay launcher와 loopback contract 8/8은 완료했지만 실제 9100 시작·NAS diff --git a/scripts/run-periodic-learner-e2e.py b/scripts/run-periodic-learner-e2e.py index b3131dd..0674447 100644 --- a/scripts/run-periodic-learner-e2e.py +++ b/scripts/run-periodic-learner-e2e.py @@ -103,6 +103,17 @@ CRITICAL_UNTRACKED_SUFFIXES = frozenset( ) PROJECT_RE = re.compile(r"^vignette-periodic-[0-9a-f]{8}-[a-z0-9-]{6,32}$") HEX_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +SENSITIVE_ENV_KEY_RE = re.compile( + r"(?:PASSWORD|SECRET|TOKEN|API_KEY)$|^ENGINE_GATEWAY_SHARED_SECRET$" +) +DIAGNOSTIC_TAIL_CHARS = 12_000 +EMAIL_RE = re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b") +UUID_RE = re.compile( + r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b" +) +SENSITIVE_QUERY_RE = re.compile( + r"(?i)([?&](?:code|email|token|secret|session|state)=)[^&\s]+" +) class GateError(RuntimeError): @@ -362,6 +373,27 @@ def _safe_error(exc: BaseException) -> str: return str(exc).replace(str(REPO_ROOT), "")[-1600:] +def _sanitize_diagnostic_text(text: str, environ: Mapping[str, str]) -> str: + sanitized = text.replace(str(REPO_ROOT), "") + sensitive_values = sorted( + { + value + for key, value in environ.items() + if value and SENSITIVE_ENV_KEY_RE.search(key) + }, + key=len, + reverse=True, + ) + for value in sensitive_values: + sanitized = sanitized.replace(value, "") + sanitized = EMAIL_RE.sub("", sanitized) + sanitized = UUID_RE.sub("", sanitized) + sanitized = SENSITIVE_QUERY_RE.sub(r"\1", sanitized) + if any(value in sanitized for value in sensitive_values): + raise GateError("failure diagnostics retained a runtime secret") + return sanitized[-DIAGNOSTIC_TAIL_CHARS:] + + def assert_safe_value(label: str, value: str) -> None: normalized = value.strip().lower().replace("\\", "/") if any(marker.lower().replace("\\", "/") in normalized for marker in FORBIDDEN_MARKERS): @@ -1056,6 +1088,46 @@ class PeriodicRunner: proof["errors"].append(_safe_error(exc)) return proof + def _capture_failure_diagnostics( + self, stack_env: Mapping[str, str] + ) -> dict[str, Any]: + if not self.state.stack_attempted: + return {"captured": False, "reason": "stack_not_attempted"} + diagnostics: dict[str, Any] = { + "captured": True, + "secret_value_emitted": False, + "commands": {}, + } + commands = ( + ( + "compose_ps", + self._compose_argv("ps", "--format", "json"), + ), + ( + "compose_logs", + self._compose_argv( + "logs", + "--no-color", + "--timestamps", + "--tail", + "240", + "db", + "api", + ), + ), + ) + for stage, argv in commands: + result = self._run(stage, argv, timeout=90, check=False) + combined = "\n".join(part for part in (result.stdout, result.stderr) if part) + sanitized = _sanitize_diagnostic_text(combined, stack_env) + diagnostics["commands"][stage] = { + "exit_code": result.returncode, + "duration_seconds": round(result.duration_seconds, 3), + "raw_sha256": hashlib.sha256(combined.encode("utf-8")).hexdigest(), + "sanitized_tail": sanitized, + } + return diagnostics + @staticmethod def _cleanup_green(cleanup: Mapping[str, Any]) -> bool: return ( @@ -1116,6 +1188,7 @@ class PeriodicRunner: status = "FAILED" error = "" cleanup: dict[str, Any] = {} + stack_env: dict[str, str] = {} try: self.state.source = self.source_identity(require_clean=True) self.state.runtime = build_runtime_identity(self.state.source) @@ -1329,6 +1402,16 @@ class PeriodicRunner: status = "GREEN" except BaseException as exc: error = _safe_error(exc) + if self.state.stack_attempted: + try: + self.state.proof["failure_diagnostics"] = ( + self._capture_failure_diagnostics(stack_env) + ) + except BaseException as diagnostic_exc: + self.state.proof["failure_diagnostics"] = { + "captured": False, + "reason": _safe_error(diagnostic_exc), + } finally: cleanup = self._cleanup() if not self._cleanup_green(cleanup): diff --git a/scripts/test_run_periodic_learner_e2e.py b/scripts/test_run_periodic_learner_e2e.py index e944ae7..256fedc 100644 --- a/scripts/test_run_periodic_learner_e2e.py +++ b/scripts/test_run_periodic_learner_e2e.py @@ -183,6 +183,75 @@ def config(receipt_path: Path) -> runner.RunnerConfig: class SafetyContractTests(unittest.TestCase): + def test_failure_diagnostics_redact_known_secrets_and_precede_cleanup(self) -> None: + class DiagnosticFake(FakeController): + def run(self, stage, argv, **kwargs): + result = super().run(stage, argv, **kwargs) + if stage == "compose_ps": + return runner.CommandResult( + 0, + '[{"Service":"db","Health":"unhealthy"}]', + "", + 0.02, + ) + if stage == "compose_logs": + return runner.CommandResult( + 0, + "db init failed with owner-secret-value for learner@hs.ac.kr", + ( + "api waiting for engine-secret-value at " + "123e4567-e89b-42d3-a456-426614174000?token=visible" + ), + 0.03, + ) + return result + + fake = DiagnosticFake() + with tempfile.TemporaryDirectory() as raw: + periodic = runner.PeriodicRunner(config(Path(raw) / "receipt.json"), fake) + periodic.state.runtime = runner.RuntimeIdentity( + "20260812T070000-abcdef12", + "vignette-periodic-11111111-abcdef12", + "periodic:" + "1" * 40 + ":" + "2" * 40 + ":run", + 18080, + 18443, + 15439, + 19199, + ) + periodic.state.docker_context = "desktop-linux" + periodic.state.env_file = Path(raw) / "stack.env" + periodic.state.override_file = Path(raw) / "override.yml" + periodic.state.stack_attempted = True + proof = periodic._capture_failure_diagnostics( + { + "POSTGRES_PASSWORD": "owner-secret-value", + "ENGINE_GATEWAY_SHARED_SECRET": "engine-secret-value", + "POSTGRES_DB": "vignette_periodic", + } + ) + periodic._cleanup() + + rendered = json.dumps(proof, ensure_ascii=False) + self.assertTrue(proof["captured"]) + self.assertFalse(proof["secret_value_emitted"]) + self.assertNotIn("owner-secret-value", rendered) + self.assertNotIn("engine-secret-value", rendered) + self.assertNotIn("learner@hs.ac.kr", rendered) + self.assertNotIn("123e4567-e89b-42d3-a456-426614174000", rendered) + self.assertNotIn("token=visible", rendered) + self.assertIn("", rendered) + self.assertIn("", rendered) + self.assertIn("", rendered) + stages = [stage for stage, _ in fake.commands] + self.assertLess(stages.index("compose_logs"), stages.index("compose_down")) + + def test_failure_diagnostic_tail_is_bounded(self) -> None: + sanitized = runner._sanitize_diagnostic_text( + "x" * (runner.DIAGNOSTIC_TAIL_CHARS + 50), + {"SESSION_SECRET": "not-present-secret"}, + ) + self.assertEqual(len(sanitized), runner.DIAGNOSTIC_TAIL_CHARS) + def test_rejects_every_protected_port_from_argv_and_runtime_env(self) -> None: for port in sorted(runner.FORBIDDEN_PORTS): with self.subTest(port=port):