주기 회기 진단과 로그인 폭 보강

This commit is contained in:
Yun Chan 2026-08-12 16:33:59 +09:00
parent 946661926b
commit f97e7fadac
10 changed files with 216 additions and 17 deletions

View file

@ -115,6 +115,39 @@ test.describe("full sweep auth", () => {
await useRealApi(page); 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<HTMLElement>(".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 // 검증 checklist: auth-login-generic-google-button
test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({ test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({
page, page,

View file

@ -1,8 +1,8 @@
.vg-auth-shell { .vg-auth-shell {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
inline-size: 100%; inline-size: auto;
max-inline-size: none; max-inline-size: 100%;
min-block-size: 100vh; min-block-size: 100vh;
min-block-size: 100dvh; min-block-size: 100dvh;
overflow-x: clip; overflow-x: clip;

View file

@ -1,7 +1,7 @@
.lg-root { .lg-root {
min-height: 100dvh; min-height: 100dvh;
width: 100%; width: auto;
max-width: none; max-width: 100%;
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 500px); grid-template-columns: minmax(0, 1fr) minmax(360px, 500px);

View file

@ -50,6 +50,14 @@ G8 배포 source 기준선은 branch `master`, HEAD `61a41d1f08239b5f9e99cfae42a
listener owner와 generation readiness를 강제하고 loopback contract **8/8**을 통과했다. 실제 9100 listener, listener owner와 generation readiness를 강제하고 loopback contract **8/8**을 통과했다. 실제 9100 listener,
NAS 설정·컨테이너·DB는 건드리지 않았다. 다음 mutation은 소유자 승인 뒤 단일 owner가 check-only→actual 1회→ NAS 설정·컨테이너·DB는 건드리지 않았다. 다음 mutation은 소유자 승인 뒤 단일 owner가 check-only→actual 1회→
NAS health/auth/OpenAPI/assets/실회기 재검증 순서로만 수행한다. 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` - 마지막 완료 작업은 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 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`을 확인했다. 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 --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가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다. 제품 failure로 오인하지 않는다. 같은 blocker가 두 번 반복되면 전체 재시도 대신 원인·증거·수정 계획을 먼저 보고한다.
주기 실회기 검증은 `scripts/run-periodic-learner-e2e.py`가 소유한다. NAS 112건 중 실 API/DB는 22건이고 주기 실회기 검증은 `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는 금지다. 검증한 뒤 sentinel resource·PID·listener·temp 잔여 0을 영수증으로 남긴다. 공개 8001/55432/9099와 NAS는 금지다.
최초 세 full run은 SSE body replay, durable turn 전 종료, 비종료 SSE `response.finished()` 대기를 차례로 찾아 모두 최초 세 full run은 SSE body replay, durable turn 전 종료, 비종료 SSE `response.finished()` 대기를 차례로 찾아 모두
fail-closed했다. 4차 clean run은 reload 뒤 persisted episode를 `학습자만 실행`으로 표시하는 제품 결함을 검출했다. 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. 핵심 변경 파일 ## 9. 핵심 변경 파일

View file

@ -161,12 +161,12 @@
> 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다. > 과거 `6030a677…c611`의 localhost 108/108·평문 origin UUID 24건 실패와 후속 verified rollback은 이력으로 보존한다.
> 매시간 `vignette-e2e` heartbeat 자동화는 ACTIVE다. 공개/NAS 화면과 health는 읽기 전용으로 보여주고, > 매시간 `vignette-e2e` heartbeat 자동화는 ACTIVE다. 공개/NAS 화면과 health는 읽기 전용으로 보여주고,
> 소스 material milestone에서만 전용 disposable 회기 E2E를 실행한다. runner의 clean HEAD/tree·로컬 npipe Docker· > 소스 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은 > durable turn 전 종료, 비종료 SSE의 `response.finished()` 대기라는 harness 결함을 fail-closed로 잡았다. 4차 clean run은
> 6.3분 안에 동일 학습자 SSE·review ready·G4 원장 0→1까지 통과한 뒤, full reload가 persisted episode를 화면에 hydrate하지 > 6.3분 안에 동일 학습자 SSE·review ready·G4 원장 0→1까지 통과한 뒤, full reload가 persisted episode를 화면에 hydrate하지
> 못해 이미 기록된 상태를 `학습자만 실행`으로 되돌리는 제품 결함을 잡았다. 네 번 모두 public/NAS/active DB 접촉 0·자원 잔여 0이다. > 못해 이미 기록된 상태를 `학습자만 실행`으로 되돌리는 제품 결함을 잡았다. 네 번 모두 public/NAS/active DB 접촉 0·자원 잔여 0이다.
> 현재 UI는 현재 session+prescription의 persisted episode를 `현재 원장 상태`로 복원하며 route fixture desktop/mobile 6/6을 통과했다. > 현재 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 물리 마이크·독립 사람 평가 증거를 대체하지 않는다. > 증거: [배포 증거](./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 응답·계량·오류 표면화를 비교하는 것이다. - [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·모델 탐색·설정 저장 경로는 구현 완료. 남은 게이트는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 뒤 같은 프롬프트의 live 응답·계량·오류 표면화를 비교하는 것이다.

View file

@ -301,7 +301,7 @@
<div class="pulse-main"> <div class="pulse-main">
<span class="eyebrow">project pulse</span> <span class="eyebrow">project pulse</span>
<h1>지금 위치: <em>G0~G6 · G8 증거 DONE</em>. <em>NAS 엔진 복구 · G7 external proof</em>가 남았다.</h1> <h1>지금 위치: <em>G0~G6 · G8 증거 DONE</em>. <em>NAS 엔진 복구 · G7 external proof</em>가 남았다.</h1>
<p class="pulse-state"><b>현재:</b> 공개 앱·주기 실회기는 GREEN이지만 NAS 프리뷰 API는 health 3회 타임아웃으로 DEGRADED다. <b>다음:</b> 승인된 source-pinned engine relay 복구와 NAS-origin 재검증, 이후 G7 명시 동의 mic·human pack·checker exit 0.</p> <p class="pulse-state"><b>현재:</b> 공개 앱은 GREEN이다. 주기 실회기 current 확인은 DB init에서 fail-closed(cleanup 0), NAS는 health timeout이다. <b>다음:</b> clean single run → 승인 relay → G7 mic·human proof.</p>
<details class="pulse-details"> <details class="pulse-details">
<summary>운영 복구·watchdog·배포 상세 보기</summary> <summary>운영 복구·watchdog·배포 상세 보기</summary>
<p class="pulse-state"><b>2026-08-12 현재 인시던트:</b> 공개 API는 health ok·db/engine true·OpenAPI 126·auth 401과 local voice를 유지한다. NAS 프리뷰는 앞선 3/3 <code>degraded·db=true·engine=false</code>에서 15:24 KST local health 3회 타임아웃으로 악화됐고 정적 로그인 화면만 계속 보인다. NAS API의 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener가 사라졌으며 공개 엔진은 별도 loopback <code>127.0.0.1:9099</code>에서 정상이다. 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가 남는다.</p> <p class="pulse-state"><b>2026-08-12 현재 인시던트:</b> 공개 API는 health ok·db/engine true·OpenAPI 126·auth 401과 local voice를 유지한다. NAS 프리뷰는 앞선 3/3 <code>degraded·db=true·engine=false</code>에서 15:24 KST local health 3회 타임아웃으로 악화됐고 정적 로그인 화면만 계속 보인다. NAS API의 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener가 사라졌으며 공개 엔진은 별도 loopback <code>127.0.0.1:9099</code>에서 정상이다. 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가 남는다.</p>
@ -679,6 +679,8 @@
<p class="dg-note">200차 적용(2026-08-12): actual NAS 로그인 320/390 읽기 전용 관측에서 조작면·boot·console은 정상이지만 비-overlay 세로 scrollbar 환경의 <code>100vw</code> 두 선언이 10px 가로 넘침을 만드는 P1을 확인했다. auth/login shell을 scrollbar를 제외한 컨테이너 기준 <code>100%</code>로 바꾸고 320px stable-scrollbar OAuth fixture를 고정했다. 인증 desktop/mobile <b>2/2</b>, 자기주도+G1·G4·G5·G7 <b>56/56</b>, 대시보드 <b>10/10</b>, G7 runner·checker·human/API 계약 <b>110/110</b>, type/API contract·Ruff·SSOT checker를 통과했다. 물리 soak standalone도 canonical 공통 3,000초를 보존하도록 production 기본·최소를 3,120초로 통일해 3,000초 요청을 장치 열거 전에 차단한다. clean HEAD <code>64e1b221…a4e</code>·tree <code>2c687bac…30bf</code>의 full disposable runner는 489.6초에 GREEN이었고 receipt <code>periodic-learner-e2e-20260812-150209.json</code>·SHA-256 <code>a7b2f758…8071</code>에서 same-learner SSE·review·G4/G5 <code>0→1→1</code>, returned desktop/mobile <b>4/4</b>, route mock 0, public·NAS·active DB 접촉 0과 container·volume·network·listener·temp 잔여 0을 확인했다. 이 소스는 아직 공개/NAS에 배포하지 않았다.</p> <p class="dg-note">200차 적용(2026-08-12): actual NAS 로그인 320/390 읽기 전용 관측에서 조작면·boot·console은 정상이지만 비-overlay 세로 scrollbar 환경의 <code>100vw</code> 두 선언이 10px 가로 넘침을 만드는 P1을 확인했다. auth/login shell을 scrollbar를 제외한 컨테이너 기준 <code>100%</code>로 바꾸고 320px stable-scrollbar OAuth fixture를 고정했다. 인증 desktop/mobile <b>2/2</b>, 자기주도+G1·G4·G5·G7 <b>56/56</b>, 대시보드 <b>10/10</b>, G7 runner·checker·human/API 계약 <b>110/110</b>, type/API contract·Ruff·SSOT checker를 통과했다. 물리 soak standalone도 canonical 공통 3,000초를 보존하도록 production 기본·최소를 3,120초로 통일해 3,000초 요청을 장치 열거 전에 차단한다. clean HEAD <code>64e1b221…a4e</code>·tree <code>2c687bac…30bf</code>의 full disposable runner는 489.6초에 GREEN이었고 receipt <code>periodic-learner-e2e-20260812-150209.json</code>·SHA-256 <code>a7b2f758…8071</code>에서 same-learner SSE·review·G4/G5 <code>0→1→1</code>, returned desktop/mobile <b>4/4</b>, route mock 0, public·NAS·active DB 접촉 0과 container·volume·network·listener·temp 잔여 0을 확인했다. 이 소스는 아직 공개/NAS에 배포하지 않았다.</p>
<p class="dg-note">201차 관측(2026-08-12): 읽기 전용 현재-state 대조에서 공개 앱은 health ok·db/engine true·OpenAPI 126·auth 401과 <code>local_whisper/small</code>·<code>melotts/melotts-korean</code>을 유지했지만, NAS 프리뷰는 3회 연속 <code>status=degraded·db=true·engine=false</code>였다. NAS의 DB·Web·proxy는 running이고 API는 restart 0인 채 unhealthy였다. 원인은 API의 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener 부재로 좁혔다. 현재 공개 엔진은 별도 loopback <code>127.0.0.1:9099</code>에만 있어 공개 서비스는 영향받지 않는다. 과거 G8 clean-head NAS-origin 112/112와 실제 rollback receipt는 완료 증거로 보존하되, 현 NAS runtime은 <b>DEGRADED · REVALIDATION</b>으로 표시한다. 별도 승인 전에는 LAN 포트 노출·Scheduled Task 생성·컨테이너 재시작·배포를 하지 않는다. source-pinned·secret-bound relay를 복구한 뒤 health 3/3·auth 401·OpenAPI 126·assets·실제 NAS-origin 회기 smoke를 다시 통과해야 current GREEN으로 돌아간다.</p> <p class="dg-note">201차 관측(2026-08-12): 읽기 전용 현재-state 대조에서 공개 앱은 health ok·db/engine true·OpenAPI 126·auth 401과 <code>local_whisper/small</code>·<code>melotts/melotts-korean</code>을 유지했지만, NAS 프리뷰는 3회 연속 <code>status=degraded·db=true·engine=false</code>였다. NAS의 DB·Web·proxy는 running이고 API는 restart 0인 채 unhealthy였다. 원인은 API의 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener 부재로 좁혔다. 현재 공개 엔진은 별도 loopback <code>127.0.0.1:9099</code>에만 있어 공개 서비스는 영향받지 않는다. 과거 G8 clean-head NAS-origin 112/112와 실제 rollback receipt는 완료 증거로 보존하되, 현 NAS runtime은 <b>DEGRADED · REVALIDATION</b>으로 표시한다. 별도 승인 전에는 LAN 포트 노출·Scheduled Task 생성·컨테이너 재시작·배포를 하지 않는다. source-pinned·secret-bound relay를 복구한 뒤 health 3/3·auth 401·OpenAPI 126·assets·실제 NAS-origin 회기 smoke를 다시 통과해야 current GREEN으로 돌아간다.</p>
<p class="dg-note">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 <code>/ready?force=true</code>를 한 번만 호출한다. readiness/receipt 실패는 이번 실행이 소유한 PID/listener만 정리한다. Windows PowerShell parse, contract <b>8/8</b>, 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은 소유자 승인 전 실행하지 않는다.</p> <p class="dg-note">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 <code>/ready?force=true</code>를 한 번만 호출한다. readiness/receipt 실패는 이번 실행이 소유한 PID/listener만 정리한다. Windows PowerShell parse, contract <b>8/8</b>, 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은 소유자 승인 전 실행하지 않는다.</p>
<p class="dg-note">203차 관측·보강(2026-08-12): detached-clean <code>94666192…</code>의 periodic full run은 전용 engine ready를 통과한 뒤 <code>app.ci_regression_dag_node</code>가 준비되지 않은 disposable PostgreSQL health에서 실패해 브라우저 단계 전에 종료됐다. receipt <code>periodic-learner-e2e-20260812-155929.json</code>·SHA-256 <code>1bd95e01…52a8</code>은 FAILED이며 public·NAS·active DB 접촉 0과 container·volume·network·listener·temp 잔여 0을 확인했다. stack source는 마지막 GREEN <code>64e1b221…</code>과 동일하지만 cleanup 전에 DB/API 로그를 영수증에 남기지 않아 정확한 환경/init 원인은 미확정이다. 같은 heartbeat에서 재시도하지 않았다. runner는 이제 cleanup 전에 <code>compose ps</code>와 db/api 로그 tail을 캡처하고 secret·email·UUID·민감 query를 제거하며 raw SHA만 결속한다. contract <b>14/14</b>·Ruff·compile을 통과했다.</p>
<p class="dg-note">204차 적용(2026-08-12): actual NAS 로그인 390×844에서 <code>innerWidth=390</code>, document usable width <code>380</code>, login root width <code>390</code>으로 10px 가로 넘침이 계속 재현돼 200차의 <code>100vw→100%</code> 보정만으로는 부족함을 확인했다. auth/login 최상위 shell을 block auto-size와 <code>max-inline-size:100%</code>로 바꿔 classic scrollbar를 usable scrollport에서 이중 계산하지 않게 했다. route-mocked 학생 자기주도·G1~G5·G7 desktop/mobile <b>76/76</b>, 인증 <b>2/2</b>, 대시보드 <b>10/10</b>, type/API contract·SSOT checker를 통과했다. 이 수정은 아직 공개/NAS에 배포하지 않았고, 현 NAS는 health 타임아웃·실제 화면 overflow 10px인 DEGRADED 상태다.</p>
<div class="dg-principles" aria-label="디자인 생성 가드레일"> <div class="dg-principles" aria-label="디자인 생성 가드레일">
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div> <div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div> <div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
@ -1089,7 +1091,7 @@
<tbody> <tbody>
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr> <tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr> <tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-09 현재 수집은 <b>627 tests / 45 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 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는 범위가 다른 역사 기준선으로 보존한다.</td></tr> <tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-12 현재 수집은 <b>629 tests / 45 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 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는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr> <tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr>
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr> <tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
<tr><td>Outcome &amp; Alliance OS G0</td><td><code>py -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</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>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 완료.</td></tr> <tr><td>Outcome &amp; Alliance OS G0</td><td><code>py -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</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>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 완료.</td></tr>

View file

@ -20,7 +20,7 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass | | 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 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)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.** 핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다. **E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
@ -271,8 +271,8 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
### 3.6 실측 테스트 개수 (현재) ### 3.6 실측 테스트 개수 (현재)
2026-08-09 `npx playwright test --list` 기준 **현재 수집 627 tests / 45 files**다. 2026-08-12 `npx playwright test --list` 기준 **현재 수집 629 tests / 45 files**다.
이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 627개 완주는 아직 증거가 없으며, 이 숫자는 수집량이지 통과량이 아니다. 현 작업트리 전체 629개 완주는 아직 증거가 없으며,
과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다. 과거 전체 GREEN 기록과 이번 focused/release gate 결과를 구분해 적는다.
NAS-origin 112건은 전부 실 API/DB 회기가 아니다. release receipt는 `session-layout`·`session-persistence` 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차는 핵심 동일 학습자 persisted episode를 UI에 hydrate하지 못하는 제품 결함을 각각 fail-closed로 검출했다. 5·6차는 핵심 동일 학습자
SSE·review·G4/G5 `0→1→1`을 연속 통과했지만 fixture 공유와 mobile success-state locator를 각각 검출했다. 보정 후 7차 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, 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이다. exact cleanup 0으로 첫 전체 GREEN receipt를 만들었다. 최신 full GREEN은 clean `64e1b221…`
hourly heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 material milestone이 바뀔 때만 preflight 뒤 full runner를 최대 1회 `periodic-learner-e2e-20260812-150209.json`·SHA-256 `a7b2f758…8071`이다. current `94666192…` run은
실행하며, 실패 시 같은 heartbeat에서 재시도하지 않는다. 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) - **병렬 시나리오**: 166 tests (desktop 83 + mobile 83)
- **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등) - **`@single-run` 직렬 시나리오**: 49 tests (DB 영속화·세션 MVP·음성 성공경로·인증 시각 테마·회기말 평가 저장·교수자 명시 재평가 저장·교수자 턴 재평가 저장·교수자 UI 평가 재시도·교수자 사용자별 분석·source pack sync·이론모드 저장·동의 철회 후 voice 차단·브라우저 stream PII 마스킹·음성 transcript 저장 실패 UI 표면화 등)

View file

@ -98,7 +98,7 @@
무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다. 무마이크 rehearsal은 완료됐지만 물리 마이크·human pack을 대체하지 않는다.
격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와 격리 NAS 프리뷰 `http://100.116.83.60:8088`은 전용 Compose 프로젝트·포트·네트워크·볼륨에 배포했고, 실제 브라우저 회기와
review API 저장 축어록 2턴을 확인했다. 기존 프로젝트 중단·재생성 명령은 실행하지 않았다. 매시간 `Vignette 앱 상태·회기 E2E 정기 검증` 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은 배포 당시 증거다. [브라우저 증거](./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 요청도 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 3회 타임아웃했다. source-pinned relay launcher와 loopback contract 8/8은 완료했지만 실제 9100 시작·NAS

View file

@ -103,6 +103,17 @@ CRITICAL_UNTRACKED_SUFFIXES = frozenset(
) )
PROJECT_RE = re.compile(r"^vignette-periodic-[0-9a-f]{8}-[a-z0-9-]{6,32}$") 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}$") 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): class GateError(RuntimeError):
@ -362,6 +373,27 @@ def _safe_error(exc: BaseException) -> str:
return str(exc).replace(str(REPO_ROOT), "<repo>")[-1600:] return str(exc).replace(str(REPO_ROOT), "<repo>")[-1600:]
def _sanitize_diagnostic_text(text: str, environ: Mapping[str, str]) -> str:
sanitized = text.replace(str(REPO_ROOT), "<repo>")
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, "<redacted>")
sanitized = EMAIL_RE.sub("<redacted-email>", sanitized)
sanitized = UUID_RE.sub("<redacted-uuid>", sanitized)
sanitized = SENSITIVE_QUERY_RE.sub(r"\1<redacted>", 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: def assert_safe_value(label: str, value: str) -> None:
normalized = value.strip().lower().replace("\\", "/") normalized = value.strip().lower().replace("\\", "/")
if any(marker.lower().replace("\\", "/") in normalized for marker in FORBIDDEN_MARKERS): if any(marker.lower().replace("\\", "/") in normalized for marker in FORBIDDEN_MARKERS):
@ -1056,6 +1088,46 @@ class PeriodicRunner:
proof["errors"].append(_safe_error(exc)) proof["errors"].append(_safe_error(exc))
return proof 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 @staticmethod
def _cleanup_green(cleanup: Mapping[str, Any]) -> bool: def _cleanup_green(cleanup: Mapping[str, Any]) -> bool:
return ( return (
@ -1116,6 +1188,7 @@ class PeriodicRunner:
status = "FAILED" status = "FAILED"
error = "" error = ""
cleanup: dict[str, Any] = {} cleanup: dict[str, Any] = {}
stack_env: dict[str, str] = {}
try: try:
self.state.source = self.source_identity(require_clean=True) self.state.source = self.source_identity(require_clean=True)
self.state.runtime = build_runtime_identity(self.state.source) self.state.runtime = build_runtime_identity(self.state.source)
@ -1329,6 +1402,16 @@ class PeriodicRunner:
status = "GREEN" status = "GREEN"
except BaseException as exc: except BaseException as exc:
error = _safe_error(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: finally:
cleanup = self._cleanup() cleanup = self._cleanup()
if not self._cleanup_green(cleanup): if not self._cleanup_green(cleanup):

View file

@ -183,6 +183,75 @@ def config(receipt_path: Path) -> runner.RunnerConfig:
class SafetyContractTests(unittest.TestCase): 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("<redacted>", rendered)
self.assertIn("<redacted-email>", rendered)
self.assertIn("<redacted-uuid>", 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: def test_rejects_every_protected_port_from_argv_and_runtime_env(self) -> None:
for port in sorted(runner.FORBIDDEN_PORTS): for port in sorted(runner.FORBIDDEN_PORTS):
with self.subTest(port=port): with self.subTest(port=port):