NAS 엔진 릴레이 복구 계약 보강
This commit is contained in:
parent
b5f44fe1cb
commit
946661926b
8 changed files with 930 additions and 73 deletions
|
|
@ -32,6 +32,20 @@ AI provider는 모두 호스트 엔진 게이트웨이 뒤에 둔다. 게이트
|
|||
|
||||
> 호스트 게이트웨이는 `apps/api/engine_gateway/`에 있으며 컨테이너 밖에서 실행한다. Claude CLI는 상주 멀티턴 풀을, Codex/Agy는 각 CLI를, `claude_api`는 Anthropic API를 호출한다. 관리자 UI의 모델 목록은 이 게이트웨이의 `GET /v1/capabilities`가 소유한다.
|
||||
|
||||
### 2.1 NAS preview 원격 엔진 relay
|
||||
|
||||
NAS preview가 Windows 호스트의 엔진을 소비할 때는 `scripts/start-nas-preview-engine.ps1`만 쓴다. 이 경로는
|
||||
기본 loopback이며, LAN 주소는 `-ConfirmNasPreviewLanExposure`를 명시한 경우에만 허용한다. 실행 source는
|
||||
detached-clean HEAD/tree, launcher·Python·env SHA-256, NAS가 소비할 정확한 `ENGINE_URL`에 결속한다. 기존
|
||||
listener 재사용, wildcard bind, source 내부 runtime/receipt, reparse-point 경로, readiness 실패는 모두
|
||||
fail-closed다. 실제 provider 생성은 `/ready?force=true`로 정확히 한 번 확인하고, 실패하면 이번 실행이 소유한
|
||||
PID/listener만 정리한다.
|
||||
|
||||
먼저 `-CheckOnly`로 `mutation=false` 영수증을 확인한다. 실제 시작은 별도 승인 뒤 외부 runtime-state/receipt
|
||||
디렉터리를 미리 만들고 같은 인자로 `-CheckOnly`만 제거해 단 한 번 실행한다. 비밀값은 명령행·stdout·receipt에
|
||||
기록하지 않는다. 성공 뒤 NAS origin의 health 3회, `/auth/me` 401, OpenAPI·asset, 실제 회기 turn→review를 다시
|
||||
통과하기 전에는 preview를 GREEN으로 표시하지 않는다.
|
||||
|
||||
## 3. 환경변수 (.env)
|
||||
|
||||
| 변수 | 설명 |
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
# Vignette G0~G8 완료 추진 핸드오프
|
||||
|
||||
> Updated: 2026-08-09 KST (G8 clean-head NAS-origin 종료 · 남은 핵심은 G7 external proof)
|
||||
> Updated: 2026-08-12 KST (G8 증거 종료 · current NAS engine incident · G7 external proof)
|
||||
> Workspace: `D:\workspace\vignette`
|
||||
> 이 문서는 다음 에이전트가 **G0~G8 아홉 목표 중 마지막 G7 외부 게이트를 닫기 위한** 실행 기준이다.
|
||||
|
||||
## 0. 현재 상태 한 줄 요약
|
||||
|
||||
**G0~G6의 내부 구현·증거와 G8 clean-head NAS-origin runtime은 종료됐다. G8은 source HEAD
|
||||
**G0~G6의 내부 구현·증거와 G8 clean-head 배포·rollback 증거는 종료됐다. G8은 source HEAD
|
||||
`61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`로 candidate 112/112와 actual NAS-origin
|
||||
112/112를 모두 통과했다. 앞선 fail-closed·verified rollback 이력과 previous `6030a677…c611`은 보존했다. G7 공개 런타임은
|
||||
112/112를 모두 통과했다. 다만 2026-08-12 current NAS는 고정 LAN engine relay 상실로 DEGRADED이며 15:24 KST
|
||||
health 요청도 3회 타임아웃이라, 과거 증거와 현재 runtime GREEN을 분리한다. 공개 앱은 health ok·db/engine true·
|
||||
OpenAPI 126·auth 401·local voice exact를 유지한다. 앞선 fail-closed·verified rollback 이력과 previous
|
||||
`6030a677…c611`은 보존했다. G7 공개 런타임은
|
||||
detached-clean `a73bcd24…`·tree `b02b3a1b…`로 fresh 승격했고 OpenAPI 126, local Whisper/MeloTTS, Uvicorn queue 4,
|
||||
API/cloudflared provenance receipt와 source-pinned Scheduled Task result 0을 확인했다. 인증 WSS·runtime·Windows topology의
|
||||
30초 무마이크 rehearsal도 통과했다. 남은 것은 명시 동의 물리 마이크 3,120초·공통 3,000초 high-water,
|
||||
|
|
@ -24,7 +27,7 @@ API/cloudflared provenance receipt와 source-pinned Scheduled Task result 0을
|
|||
| G5 Calibration & Transfer | DONE | actual transfer execution, independent/model-run/instrument/evidence provenance, DB/browser idempotency |
|
||||
| G6 Supervision & Research | DONE | safety event metadata-only projection, safety priority 1 > deterioration 2, raw detail 0 |
|
||||
| G7 Multimodal Alliance | **internal DONE · external GATE** | consent/withdrawal, synthetic soak 59/59, public `a73bcd24…`·OpenAPI 126·local voice, no-mic rehearsal 3 legs × 7 samples |
|
||||
| G8 Continuous Improvement | **DONE · CLEAN-HEAD NAS VERIFIED** | 실제 image rollback 2회 + executed receipt 2건, source/archive 결속, candidate 112/112, actual NAS-origin 112/112 |
|
||||
| G8 Continuous Improvement | **증거 DONE · CURRENT NAS DEGRADED** | 실제 image rollback 2회 + executed receipt 2건, source/archive 결속, candidate/origin 112/112; current engine relay 복구·재검증 대기 |
|
||||
|
||||
대시보드 status count는 **33 done / 2 doing / 0 planned**다.
|
||||
|
||||
|
|
@ -42,6 +45,11 @@ G8 배포 source 기준선은 branch `master`, HEAD `61a41d1f08239b5f9e99cfae42a
|
|||
promotion에서 별도로 fresh 승격했다.
|
||||
- active goal은 계속 `active`다. 목표는 “문서 작성”이 아니라 **G0~G8 아홉 목표를 실제 runtime과 E2E로
|
||||
모두 닫는 것**이다. G8은 종료됐고 G7 canonical checker exit 0이 없으면 `goal complete`를 호출하지 않는다.
|
||||
- current NAS 복구용 `scripts/start-nas-preview-engine.ps1`은 source-pinned fail-closed 계약으로 보강했다.
|
||||
detached-clean HEAD/tree, launcher·Python·env SHA, exact consumer URL, explicit LAN 승인, 외부 state/receipt,
|
||||
listener owner와 generation readiness를 강제하고 loopback contract **8/8**을 통과했다. 실제 9100 listener,
|
||||
NAS 설정·컨테이너·DB는 건드리지 않았다. 다음 mutation은 소유자 승인 뒤 단일 owner가 check-only→actual 1회→
|
||||
NAS health/auth/OpenAPI/assets/실회기 재검증 순서로만 수행한다.
|
||||
- 마지막 완료 작업은 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`을 확인했다.
|
||||
|
|
@ -64,7 +72,7 @@ G8 배포 source 기준선은 branch `master`, HEAD `61a41d1f08239b5f9e99cfae42a
|
|||
|
||||
| # | 조건 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| 1 | G0~G8 각각의 코드 계약과 실제 runtime 증거 | **부분 충족** | G0~G6과 G8 충족. G7 외부 GATE만 열려 있다 |
|
||||
| 1 | G0~G8 각각의 코드 계약과 실제 runtime 증거 | **부분 충족** | G0~G6과 G8 종료 증거는 충족. current NAS runtime 복구와 G7 외부 GATE가 열려 있다 |
|
||||
| 2 | 학생 홈→회기→리뷰→처방→재연습→전이 폐루프가 실제 API/DB/browser 통과 | **충족** | 일회용 클론에서 `returned-practice-db-closed-loop` desktop 2/2 + mobile 2/2, route mock 0 |
|
||||
| 3 | current source 증거와 대시보드·TODO·backlog 일치 | **충족** | SSOT checker FAIL 0, dashboard E2E 10/10, count 33/2/0이 카드와 일치 |
|
||||
| 4 | G7을 가짜·합성 증거로 DONE 처리하지 않음 | **충족** | `check-g7-external-proof.py` exit 1 유지, G7 카드는 `GATE` |
|
||||
|
|
@ -743,7 +751,8 @@ NAS/public/DB mutation owner는 항상 한 에이전트만 둔다.
|
|||
|
||||
1. **G7 proof lane:** 완료된 clean public runtime과 무마이크 rehearsal을 기준선으로 보존하고, 장치 선택·명시 동의 뒤
|
||||
3,120초 mic/runtime/topology 동시 캡처와 human pack·canonical checker를 닫는다.
|
||||
2. **G8/NAS monitor lane:** 완료된 archive·images·health·자동화 drift를 읽기 전용으로 감시. 새 material milestone 전 mutation 0.
|
||||
2. **G8/NAS recovery lane:** 완료된 archive·images·rollback 증거는 보존한다. current relay는 source-pinned launcher
|
||||
8/8만 완료됐고 실제 9100은 0 listener다. 소유자 승인 전 mutation 0; 승인 뒤 check-only→actual 1회→NAS-origin 재검증.
|
||||
3. **E2E/SSOT lane:** local fixture, disposable DB browser, visual/accessibility, dashboard/TODO/backlog 정합을 읽기 전용으로 감사.
|
||||
|
||||
루트 에이전트는 각 lane의 증거 SHA와 실패 원인을 합쳐 게이트를 판정한다. 같은 blocker가 두 번 반복되면 세 번째
|
||||
|
|
@ -756,9 +765,10 @@ D:\workspace\vignette의 docs/HANDOFF.md를 인수인계 SSOT로 읽고, AGENTS.
|
|||
docs/dev_dashboard.html → docs/TODO.md → docs/ops/backlog-2026-06-26.md 순서로 현재 상태를 재확인해.
|
||||
|
||||
G8 배포 source는 HEAD `61a41d1f…6af`·tree `87dec55d…3b77`·archive `4d15d055…119d4d`다. 격리 NAS의
|
||||
API/Web `d5021950…e4b1`/`9796c092…4b36`, candidate 112/112, actual NAS-origin 112/112, health·auth 401·OpenAPI
|
||||
126을 통과했고 fresh dump `36ec8748…24db8`과 previous `6030a677…c611`을 보존했다. G8은 DONE이며 새 material
|
||||
milestone 전에는 NAS를 mutation하지 마. 현재 공유 worktree의 후속 dirty 변경은 이 배포 source와 분리해서 다뤄.
|
||||
API/Web `d5021950…e4b1`/`9796c092…4b36`, candidate 112/112, actual NAS-origin 112/112, 당시 health·auth 401·OpenAPI
|
||||
126을 통과했고 fresh dump `36ec8748…24db8`과 previous `6030a677…c611`을 보존했다. 이 종료 증거는 DONE이지만
|
||||
current NAS는 engine relay 부재로 DEGRADED이고 2026-08-12 15:24 health 3회가 타임아웃했다. 실제 relay 복구는
|
||||
소유자 승인 전 금지한다. 현재 공유 worktree의 후속 변경은 이 배포 source와 분리해서 다뤄.
|
||||
|
||||
최종 목표는 G0~G8 아홉 목표를 코드-only가 아니라 실제 runtime 증거로 모두 닫는 것이다. G0~G6과 G8은 DONE이다.
|
||||
G7은 내부 구현과 clean public runtime·local_whisper/melotts ready·authenticated public WSS 무마이크 rehearsal까지 완료했다.
|
||||
|
|
|
|||
15
docs/TODO.md
15
docs/TODO.md
|
|
@ -73,7 +73,15 @@
|
|||
검증: `pytest engine_gateway` 58/58, 워치독 `-CheckOnly` healthy 5/5, 장애 재현(9299를
|
||||
`CLAUDE_BIN` 부재로 기동)에서 `/health`는 `ok:true`로 통과하고 `/ready`는 503으로 검출되어
|
||||
워치독이 `unhealthy (1/3)` + 503 본문을 남겼다.
|
||||
남은 것: NAS용 9100은 실사용 세션 7개가 있어 미접촉이라 (2)는 다음 재기동 때 반영된다.
|
||||
NAS용 9100은 2026-08-12 listener 상실을 확인했다. current relay launcher는 detached-clean HEAD/tree,
|
||||
launcher·Python·env SHA, exact consumer URL, explicit LAN 승인, 외부 receipt/runtime, listener owner,
|
||||
generation readiness와 실패 cleanup을 강제하도록 보강했고 contract 8/8을 통과했다. 실제 9100 시작은
|
||||
소유자 승인 전 미실행이다.
|
||||
- [ ] **NAS preview engine relay current 복구** — `scripts/start-nas-preview-engine.ps1 -CheckOnly`의
|
||||
`mutation=false`를 exact release에서 확인한 뒤, 승인된 단일 owner가 LAN listener를 1회 시작하고 NAS
|
||||
health 3/3·db/engine true, auth 401, OpenAPI 126·assets, actual-origin turn→review를 재검증한다. 실패 시 이번
|
||||
실행 소유 PID/listener만 정리하고 자동 재시도하지 않는다. 2026-08-12 15:24 KST local health는 3회
|
||||
타임아웃이므로 현재 NAS를 GREEN으로 표기하지 않는다.
|
||||
- [ ] **stable-source task 재등록 + 재부팅 후 watchdog smoke** — 사용자 승인 clean commit의 detached release root에
|
||||
watchdog·로그온 boot task를 함께 재등록하고 action의 commit/tree/script SHA pin, 5분 watchdog
|
||||
`LastTaskResult=0`, 로그온 boot, 실제 Windows 재부팅 후 엔진/API/터널 자동 복구 + public `/turn`을 실측한다.
|
||||
|
|
@ -143,7 +151,10 @@
|
|||
> `9796c092…4b36`로 실행 중이며 previous active `6030a677…c611`과 이전 images도 보존한다.
|
||||
> `api/web/db/proxy` 4개는 running + `unless-stopped`, volume은 `vignette-preview-20260807_pgdata`다.
|
||||
> candidate session E2E 112/112와 실제 NAS-origin browser review 112/112(11 specs, desktop/mobile/single-run),
|
||||
> health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. fresh dump `36ec8748…24db8`은 1,097,100 bytes,
|
||||
> 당시 health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. 2026-08-12 current runtime은 고정
|
||||
> `ENGINE_URL=http://192.168.0.223:9100` listener 상실 뒤 degraded였고 15:24 KST health 3회도 타임아웃했다.
|
||||
> 따라서 G8 배포·rollback 증거는 DONE으로 보존하되 current NAS는 relay 복구와 actual-origin 재검증 전까지
|
||||
> DEGRADED다. fresh dump `36ec8748…24db8`은 1,097,100 bytes,
|
||||
> TOC 1,738 / TABLE DATA 129다. release agent는 DB
|
||||
> dump/restore를 수행하지 않으므로 매 execute 직전 fresh custom dump SHA/size/TOC와 DB identity를 별도 결속한다.
|
||||
> 자동 rollback은 image/Compose/active-state만 복구하며 적용 migration/data restore는 별도 owner 승인이 필요하다.
|
||||
|
|
|
|||
|
|
@ -301,10 +301,10 @@
|
|||
<div class="pulse-main">
|
||||
<span class="eyebrow">project pulse</span>
|
||||
<h1>지금 위치: <em>G0~G6 · G8 증거 DONE</em>. <em>NAS 엔진 복구 · G7 external proof</em>가 남았다.</h1>
|
||||
<p class="pulse-state"><b>현재:</b> 공개 앱·주기 실회기는 GREEN이지만 NAS 프리뷰는 DB 정상·engine 불능으로 DEGRADED다. <b>다음:</b> 승인된 source-pinned engine relay 복구와 NAS-origin 재검증, 이후 G7 명시 동의 mic·human pack·checker exit 0.</p>
|
||||
<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>
|
||||
<details class="pulse-details">
|
||||
<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 프리뷰 health는 3/3 <code>degraded·db=true·engine=false</code>이고 API 컨테이너가 unhealthy다. 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>
|
||||
<p class="pulse-state">2026-08-07 공개 DB는 recovered named volume으로 전환했고 원본 container·dump·volume을 보존했다. 2026-08-12에는 휴면 Gradle daemon의 <code>8001</code> 점유·cloudflared/voice sidecar 중단과 PowerShell 5.1 native stderr 조기 종료 결함을 복구했다. DB mutation 전에 fresh custom dump <code>9cbda31e…0ce5</code>(7,532,625 bytes·TOC 1,492)를 고정했다. 공개 API·cloudflared는 detached-clean <code>a73bcd24…</code>·tree <code>b02b3a1b…</code>로 fresh 교체했고 public health 3/3·db/engine true·OpenAPI 126·local voice exact를 통과했다. 로그온·5분 watchdog 두 task도 같은 root/commit/tree/script SHA에 pin해 명시 실행 결과 0을 확인했다. 공개 runtime receipt는 <code>47388d58…9b08</code>, 무마이크 rehearsal evidence는 voice <code>61af2e98…</code>·runtime <code>c6e8670e…</code>·topology <code>89f1cb86…</code>이며 UUID/email literal 0이다. G7은 배포가 아니라 실제 mic·동시 high-water·human evidence만 남은 external GATE다.</p>
|
||||
</details>
|
||||
<div class="phase-rail" id="phase-rail" aria-label="Phase 진행 추정"></div>
|
||||
|
|
@ -678,6 +678,7 @@
|
|||
<p class="dg-note">199차 적용(2026-08-12): 실제 공개 학생 홈을 390×844·320×568에서 읽기 전용으로 확인해 부트 진단·console error·가로 overflow·literal PII placeholder는 0이고 핵심 학습 카드·탭·리뷰 CTA는 44px 이상임을 재확인했다. 반면 공통 톱바의 공간 전환 링크는 28px, 테마·로그아웃은 34px에 머물러 있던 P1을 발견했다. 소스는 720px 이하에서 워드마크 글자·사용자 칩을 접고 브랜드·공간 전환·테마·로그아웃 6개를 모두 44×44px로 고정했으며, 관리자 3역할 최악 조건 390/320 desktop/mobile <b>2/2</b>, 학생 자기주도 홈→회기→리뷰→재연습 desktop/mobile <b>6/6</b>, G1·G4·G5·G7 focused <b>50/50</b>, 대시보드 <b>10/10</b>, type/API contract와 SSOT checker를 통과했다. clean HEAD <code>9687f186…c48b</code>·tree <code>be2fc4d2…749d7</code>의 full disposable runner도 485.8초에 GREEN이었고 receipt <code>periodic-learner-e2e-20260812-143426.json</code>·SHA-256 <code>501d2d7f…c557</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을 확인했다. 공개 앱에는 아직 이 변경을 배포하지 않았으므로 실제 공개 톱바는 별도 승인 배포 전까지 기존 크기이며, G7은 명시 동의 물리 마이크·독립 human pack·canonical checker exit 0 전까지 external GATE다.</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">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>
|
||||
<div class="dg-principles" aria-label="디자인 생성 가드레일">
|
||||
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
|
||||
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
|
||||
|
|
@ -936,7 +937,7 @@
|
|||
</article>
|
||||
<article class="scard" data-status="done" data-cat="Outcome OS·에이전틱" data-owner="0">
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">G8 DONE · EVIDENCE VERIFIED · RUNTIME DEGRADED</span><span class="scard-mid"><span class="scard-title">Autonomous Content & Continuous Improvement</span><span class="scard-sum">agentic worker·human gate·실제 rollback·NAS-origin 112/112 증거는 닫혔고, 현재 NAS engine relay는 재검증이 필요하다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
<div class="scard-body"><div class="kv k-warn"><b>현재 NAS runtime 인시던트</b><p>2026-08-12 NAS health는 3/3 <code>degraded·db=true·engine=false</code>이며 API 컨테이너가 unhealthy다. 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener가 없고 공개 엔진은 별도 loopback 9099에서 정상이다. 과거 배포·rollback 증거를 무효화하지는 않지만, 승인된 relay 복구와 actual-origin 회기 재검증 전에는 current runtime GREEN으로 표시하지 않는다.</p></div><div class="kv"><b>agentic worker + 운영 환류 + human gate</b><p>승인 source의 실제 hash·PII를 engine 호출 전에 검사하고 structured generator 1개, 결과를 공유하지 않는 red-team 2개, variant generator와 variant별 독립 judge 3개 이상을 실행한다. 정답 누수·PII·unknown source claim·open finding·안전/보상 해킹·모델/structured 실패는 store 호출 전에 차단한다. repo-approved source는 기본 비활성 scheduler가 durable lease/retry job으로 관리한다. 실제 <code>claude_cli/claude-opus-4-8</code> 7 calls와 dev PostgreSQL reviewer 2·variant 3·pass rate 1.0을 거쳐 synthetic payload를 <code>pending_human_approval</code>로 저장했다. G6 canonical drift trigger는 metadata-only incident→DAG→job을 원자 enqueue하고, 실제 pending 후보의 관리자 검수는 catalog 0→1·approval 1과 역할 403·멱등/409·민감정보 비노출을 통과했다. 별도 누수 후보는 422·저장 0이다. 상세: <code>ops/outcome-os-g8-agentic-worker-evidence-2026-08-07.md</code>.</p></div><div class="kv k-good"><b>clean-head NAS-origin 종료 증거</b><p>source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>·2회 동일 archive <code>4d15d055…119d4d</code>를 active release에 결속했다. candidate session E2E <b>112/112</b>와 실제 평문 NAS-origin browser review <b>112/112</b>(11 specs, desktop/mobile/single-run)가 GREEN이며 API/Web <code>d5021950…e4b1</code>/<code>9796c092…4b36</code>, 당시 health ok·db/engine true, auth 401, OpenAPI 126을 확인했다. fresh dump <code>36ec8748…24db8</code>(1,097,100 bytes, TOC 1,738 / TABLE DATA 129)와 previous <code>6030a677…c611</code>을 보존했다.</p></div><div class="kv k-good"><b>실제 rollback receipt DONE</b><p>runtime+release_gate 전용 executor는 승인·idempotency·artifact·Compose/env/resolved-policy SHA와 exact API/Web image를 일회성 manifest에 결속하고 HMAC journal·crash recovery·previous-image 복구를 고정한다. 2026-08-07 격리 NAS에서 별도 loopback control-plane API(<code>127.0.0.1:8018</code>)와 별도 executor(<code>127.0.0.1:18149</code>)로 <b>실제 image rollback 2회</b>를 실행했다. <code>rollback-old</code>는 receipt <code>nas-g8-723eeef2…</code>로 previous 이미지 <code>79ec…4450</code>/<code>c530…2f28</code>를, <code>restore-current</code>는 receipt <code>nas-g8-2738846c…</code>로 current 이미지 <code>52e0…8b2d</code>/<code>6fdb…f215</code>를 활성화했다. release gate·approval 각각 2회 멱등, lifecycle <code>rollback/executed</code> 2건, 승인 <code>authorize_rollback</code> 2건, silent auto-promotion 0, HMAC journal 6-record 체인 검증을 확인했다. 상세: <a href="./ops/nas-preview-g8-rollback-proof-runbook.md">런북</a> · <a href="./ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json">기계 판독 증거</a>.</p></div></div>
|
||||
<div class="scard-body"><div class="kv k-warn"><b>현재 NAS runtime 인시던트</b><p>2026-08-12 NAS health는 처음 3/3 <code>degraded·db=true·engine=false</code>였고 15:24 KST에는 local 요청 3회가 타임아웃했다. 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener가 없고 공개 엔진은 별도 loopback 9099에서 정상이다. relay launcher의 source-pinned contract 8/8은 완료했지만 실제 listener는 0이다. 과거 배포·rollback 증거를 무효화하지는 않되, 소유자 승인 relay 복구와 actual-origin 회기 재검증 전에는 current runtime GREEN으로 표시하지 않는다.</p></div><div class="kv"><b>agentic worker + 운영 환류 + human gate</b><p>승인 source의 실제 hash·PII를 engine 호출 전에 검사하고 structured generator 1개, 결과를 공유하지 않는 red-team 2개, variant generator와 variant별 독립 judge 3개 이상을 실행한다. 정답 누수·PII·unknown source claim·open finding·안전/보상 해킹·모델/structured 실패는 store 호출 전에 차단한다. repo-approved source는 기본 비활성 scheduler가 durable lease/retry job으로 관리한다. 실제 <code>claude_cli/claude-opus-4-8</code> 7 calls와 dev PostgreSQL reviewer 2·variant 3·pass rate 1.0을 거쳐 synthetic payload를 <code>pending_human_approval</code>로 저장했다. G6 canonical drift trigger는 metadata-only incident→DAG→job을 원자 enqueue하고, 실제 pending 후보의 관리자 검수는 catalog 0→1·approval 1과 역할 403·멱등/409·민감정보 비노출을 통과했다. 별도 누수 후보는 422·저장 0이다. 상세: <code>ops/outcome-os-g8-agentic-worker-evidence-2026-08-07.md</code>.</p></div><div class="kv k-good"><b>clean-head NAS-origin 종료 증거</b><p>source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>·2회 동일 archive <code>4d15d055…119d4d</code>를 active release에 결속했다. candidate session E2E <b>112/112</b>와 실제 평문 NAS-origin browser review <b>112/112</b>(11 specs, desktop/mobile/single-run)가 GREEN이며 API/Web <code>d5021950…e4b1</code>/<code>9796c092…4b36</code>, 당시 health ok·db/engine true, auth 401, OpenAPI 126을 확인했다. fresh dump <code>36ec8748…24db8</code>(1,097,100 bytes, TOC 1,738 / TABLE DATA 129)와 previous <code>6030a677…c611</code>을 보존했다.</p></div><div class="kv k-good"><b>실제 rollback receipt DONE</b><p>runtime+release_gate 전용 executor는 승인·idempotency·artifact·Compose/env/resolved-policy SHA와 exact API/Web image를 일회성 manifest에 결속하고 HMAC journal·crash recovery·previous-image 복구를 고정한다. 2026-08-07 격리 NAS에서 별도 loopback control-plane API(<code>127.0.0.1:8018</code>)와 별도 executor(<code>127.0.0.1:18149</code>)로 <b>실제 image rollback 2회</b>를 실행했다. <code>rollback-old</code>는 receipt <code>nas-g8-723eeef2…</code>로 previous 이미지 <code>79ec…4450</code>/<code>c530…2f28</code>를, <code>restore-current</code>는 receipt <code>nas-g8-2738846c…</code>로 current 이미지 <code>52e0…8b2d</code>/<code>6fdb…f215</code>를 활성화했다. release gate·approval 각각 2회 멱등, lifecycle <code>rollback/executed</code> 2건, 승인 <code>authorize_rollback</code> 2건, silent auto-promotion 0, HMAC journal 6-record 체인 검증을 확인했다. 상세: <a href="./ops/nas-preview-g8-rollback-proof-runbook.md">런북</a> · <a href="./ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json">기계 판독 증거</a>.</p></div></div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1168,7 +1169,7 @@
|
|||
<tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code> 매시간 heartbeat / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current clean source HEAD <code>61a41d1f…6af</code>·tree <code>87dec55d…3b77</code>의 archive <code>4d15d055…119d4d</code>를 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. candidate 112/112와 실제 NAS-origin 112/112는 당시 통과했지만 영수증은 실 API/DB 22건과 route fixture 90건을 분리한다. <b>2026-08-12 현재는 health 3/3 degraded·db true·engine false</b>이며, 고정 LAN engine relay listener 부재로 current runtime 재검증이 필요하다. 자동화는 공개/NAS 화면과 health를 읽기 전용으로 매시간 보여준다. material milestone의 동일 학습자 SSE→review→G4/G5 actual 폐루프는 clean HEAD/tree·전용 engine/DB/API/Web·보호 포트 거부·sentinel·exact cleanup runner가 소유한다. 1~3차는 하네스, 4차는 reload ledger hydration 제품 결함, 5차는 fixture 공유, 6차는 mobile success-state locator를 fail-closed했다. 7차 clean <code>aa81af29…</code>는 same-learner SSE·review·G4/G5 <code>0→1→1</code>과 별도 returned desktop/mobile <code>4/4</code>, route mock 0, cleanup 0으로 첫 전체 GREEN을 만들었다. heartbeat는 최신 GREEN이 6시간 이상 오래됐거나 학생 runtime·회기 폐루프 material milestone이 바뀔 때만 full runner를 최대 1회 실행하고 실패 시 자동 재시도하지 않는다. G7 human intake·checker·template-only 변경은 G7 focused gate만 실행한다. release gate·NAS preflight·배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.</td></tr>
|
||||
<tr><td>G7 외부 증거 3-artifact 동시 시간창 오케스트레이터</td><td><code>scripts/run-g7-external-proof-window.py</code> · <a href="./ops/outcome-os-g7-external-proof-readiness-2026-08-07.md">준비 문서</a></td><td>soak·runtime·topology 세 캡처를 같은 host의 공통 3,000초 시간창으로 실행하고 checker까지 잇는다. 공개 배포·프로세스·source pin은 clean commit <code>a73bcd24…</code>와 fresh receipt <code>9f8d1941…</code>로 완료했다. Cloudflare가 Python 기본 User-Agent를 403으로 거부하는 경계도 브라우저 호환 header와 회귀 4/4로 고정했다. 30초 rehearsal은 세 leg 7 samples를 모두 통과했으며 마이크를 열지 않았다. <b>남은 선행 조건:</b> 사용자의 물리 장치 선택과 명시 동의, 독립 human voice-gain pack이다.</td></tr>
|
||||
<tr><td>G7 external GATE · 최종 감사 정정</td><td><code>run-g7-external-proof-window.py</code> → <code>check-g7-external-proof.py</code></td><td><b>상태는 external GATE 유지.</b> runner 프로세스 exit는 canonical checker <code>exit 0</code>·<code>gate_closed=true</code>에 결속되고, browser Origin과 API transport host는 별도 allowlist로 검증된다. 세 artifact 교집합은 <code>≥3000s</code> + 120초 margin이고 Windows proof는 detached-clean HEAD/tree·tool SHA·<code>psutil==6.1.1</code>을 pin한다. 직접 물리 soak를 실행해도 production 기본·최소 3,120초를 강제하고 3,000초 요청은 장치를 열기 전에 차단한다. clean-source 배포와 무마이크 rehearsal은 완료했다. 남은 것은 물리 마이크 3,120초, 동시 runtime/topology, 독립 human pack을 합친 4-artifact 실증뿐이다.</td></tr>
|
||||
<tr><td>G8 증거 DONE · current NAS DEGRADED</td><td><code>vignette-preview-20260807</code> · <code>run-outcome-os-release-agent.py</code></td><td><b>배포·rollback 증거는 DONE, 현재 runtime은 재검증 중.</b> active archive <code>4d15d055…119d4d</code>, source HEAD/tree <code>61a41d1f…6af</code>/<code>87dec55d…3b77</code>, API/Web <code>d5021950…e4b1</code>/<code>9796c092…4b36</code> 기준선은 보존한다. candidate 112/112와 actual NAS-origin 112/112, 당시 health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. 2026-08-12 현재는 engine relay listener 부재로 health degraded·db true·engine false다. fresh dump <code>36ec8748…24db8</code> 1,097,100 bytes, TOC 1,738 / TABLE DATA 129와 previous active <code>6030a677…c611</code>을 보존했다. release agent의 DB restore 비소유·image/Compose/active-state rollback 경계는 유지한다.</td></tr>
|
||||
<tr><td>G8 증거 DONE · current NAS DEGRADED</td><td><code>vignette-preview-20260807</code> · <code>run-outcome-os-release-agent.py</code> · <code>start-nas-preview-engine.ps1</code></td><td><b>배포·rollback 증거는 DONE, 현재 runtime은 재검증 중.</b> active archive <code>4d15d055…119d4d</code>, source HEAD/tree <code>61a41d1f…6af</code>/<code>87dec55d…3b77</code>, API/Web <code>d5021950…e4b1</code>/<code>9796c092…4b36</code> 기준선은 보존한다. candidate 112/112와 actual NAS-origin 112/112, 당시 health ok·db/engine true·auth 401·OpenAPI 126을 통과했다. 2026-08-12 current는 engine relay listener 부재 뒤 degraded였고 15:24 KST health 3회가 타임아웃했다. source-pinned relay 계약 8/8은 통과했지만 실제 9100 listener는 0이다. fresh dump <code>36ec8748…24db8</code> 1,097,100 bytes, TOC 1,738 / TABLE DATA 129와 previous active <code>6030a677…c611</code>을 보존했다. release agent의 DB restore 비소유·image/Compose/active-state rollback 경계는 유지한다.</td></tr>
|
||||
<tr><td>노트북 로컬 음성 스택 — faster-whisper STT + MeloTTS TTS</td><td><code>scripts/local-whisper-stt-server.py</code> · <code>scripts/start-local-whisper-stt.ps1</code> · <code>voice_stt_provider=local_whisper</code></td><td>2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주 로컬 모델로 정했다. TTS는 처음 Higgs로 잡았으나 연구/비상업 라이선스라 <code>config.py</code>가 운영에서 차단하고 있었고 그 가드를 푸는 건 법적 판단이라, 상업 사용이 허용된 <b>MeloTTS Korean(MIT)</b>으로 바꿔 가드 자체를 불필요하게 만들었다. Kokoro-82M은 Apache 2.0이지만 공식 <code>VOICES.md</code>에 <b>한국어가 없어</b> 탈락했다. local Whisper는 현재 호스트의 cuDNN 부재를 반영해 <code>small/cpu-int8</code>를 운영 기본으로 고정했고, API health도 MeloTTS model을 실제 provider와 맞춰 보고한다. 합성 시드 8.72초에서 interim 9·final 5·word timestamp 12개, 한 글자 차이 전사를 확인했고 MeloTTS CPU RTF는 0.27~0.28, 합성음→local STT 왕복은 완전 일치했다. current 통합은 launcher/sidecar 80/80·G7 전체 166/166·API voice 71/71·API 전체 921·gateway 58을 통과했다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다.</td></tr>
|
||||
<tr><td>비-secure origin 회기 리뷰 크래시 수정</td><td><code>apps/web/src/lib/uuid.ts</code> · <code>apps/web/src/lib/sha256.ts</code> · <code>apps/web/e2e/insecure-context-uuid.spec.ts</code></td><td>격리 NAS 프리뷰(평문 HTTP·비-localhost)에서 드러난 <code>crypto.randomUUID</code>와 <code>crypto.subtle.digest</code> secure-context 의존을 portable fallback으로 교체했다. source-only UUID/SHA 회귀 12/12와 secure/insecure exact digest 회귀를 통과했고, 최종 archive <code>4d15d055…119d4d</code>의 actual NAS-origin 112/112로 배포 검증까지 닫았다.</td></tr>
|
||||
<tr><td>공개 워치독 engine readiness 사각지대(해결)</td><td><code>scripts/watch-public-runtime.ps1</code> · <code>scripts/start-public-runtime.ps1</code> · <code>apps/api/engine_gateway/gateway.py</code></td><td>2026-08-07 22:0x KST 소유자 승인으로 9099를 재기동해 복구했다. 재기동 직후 <code>/ready?force=true</code> 200 <code>ok:true·detail:OK</code>, API 8001과 공개 <code>api-vignette.chanpaca.net</code> 모두 <code>status:ok·engine:true·engine_detail:OK</code>다. 원인은 16:06부터 상주한 9099 프로세스 하나였다(CLI·코드·cwd는 정상 — 동일 인자 직접 실행과 <code>EngineSession</code> 재현이 <code>"OK"</code>를 반환). 재발 방지 3건: (1) 워치독·기동 스크립트의 engine 판정을 <code>/health</code>(프로세스 liveness)에서 <code>/ready</code>(실제 생성)로 바꾸고 API 판정에 <code>engine</code>을 추가, shared secret 인스턴스용 토큰 헤더와 503 본문 로깅 포함. (2) 게이트웨이가 자식 <code>claude -p</code>의 stderr를 상시 드레인해(PIPE 미독 시 자식 블록도 차단) 실패 detail에 <code>exit</code>·stderr를 붙인다 — 실증: <code>empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz')</code>. (3) 기동 시 게이트웨이 로그 회전, <code>Stop-UvicornByPort</code>에 <code>Name -like python*</code> 추가(호출자 자기 자신 종료 방지). 검증: <code>pytest engine_gateway</code> 58/58, 워치독 <code>-CheckOnly</code> healthy 5/5, 장애 재현(9299·<code>CLAUDE_BIN</code> 부재)에서 <code>/health</code>는 <code>ok:true</code>로 통과하고 <code>/ready</code>는 503으로 검출되어 워치독이 <code>unhealthy (1/3)</code> + 503 본문을 남겼다. <b>NAS용 9100(06:27 상주)은 실사용 세션 7개가 있어 미접촉</b>이라 게이트웨이 stderr 개선은 다음 재기동 때 반영된다.</td></tr>
|
||||
|
|
|
|||
|
|
@ -595,6 +595,11 @@ DB 검사를 켜면 G0~G8 migration별 대표 원장 테이블도 전부 확인
|
|||
|
||||
엔진 게이트웨이(9099)는 컴포즈 밖 호스트에서 돌리고, api 컨테이너는
|
||||
`ENGINE_URL=http://host.docker.internal:9099`로 호출한다(compose 기본값).
|
||||
NAS preview처럼 LAN을 건너는 별도 relay는 개발용 9099를 재사용하거나 wildcard로 열지 않는다.
|
||||
`scripts/start-nas-preview-engine.ps1`의 detached-clean HEAD/tree·launcher/Python/env SHA·exact consumer URL
|
||||
preflight를 통과하고, 명시 승인된 IPv4에만 bind한다. `-CheckOnly`는 listener·runtime dir·receipt를 만들지
|
||||
않으며, 실제 시작은 generation readiness와 listener owner를 receipt에 결속한다. 현재 incident와 승인 대기
|
||||
상태는 `docs/dev_dashboard.html`이 소유한다.
|
||||
RAG 임베딩/리랭커 의존성은 기본 이미지에 설치하지 않는다. 모델까지 포함한 API 이미지를 만들 때만
|
||||
`infra/.env`에 `INSTALL_RAG=true`를 설정한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,11 @@
|
|||
격리 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),
|
||||
[브라우저 증거](./evidence/nas-preview-live-turn-2026-08-07.png). 명시 동의 물리 마이크와 독립 human voice-gain 증거는 아직 없으므로 G7은 external GATE로 유지한다.
|
||||
[브라우저 증거](./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
|
||||
origin 재검증은 소유자 승인 대기다. 명시 동의 물리 마이크와 독립 human voice-gain 증거도 아직 없으므로
|
||||
G7은 external GATE로 유지한다.
|
||||
- [ ] **claude_cli ↔ Anthropic API live 동일성** — provider 라우팅·Anthropic `/v1/models` 탐색·지원 추론 강도·관리자 fail-closed 저장 경로는 구현 완료. 남은 범위는 연구팀/기관 `ANTHROPIC_API_KEY`를 게이트웨이 호스트에 주입한 live 응답·계량·오류 표면화 비교다. Claude CLI·Codex CLI(Terra/Medium)·Agy CLI(Gemini 3.6 Flash/High)는 로컬 live probe를 통과했다.
|
||||
- [ ] **stable-source 재부팅 후 watchdog smoke** — watchdog·로그온 boot는 detached-clean `a73bcd24…` stable
|
||||
root와 exact commit/tree/script SHA에 재등록했고 명시 실행 결과 0·Ready를 확인했다. 남은 것은 실제 Windows
|
||||
|
|
|
|||
|
|
@ -2,93 +2,520 @@ param(
|
|||
[Parameter(Mandatory = $true)]
|
||||
[string]$EnvFile,
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[string]$ListenAddress = "0.0.0.0",
|
||||
[string]$ListenAddress = "127.0.0.1",
|
||||
[ValidateRange(1024, 65535)]
|
||||
[int]$Port = 9100,
|
||||
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedCommit,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedTree,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedScriptSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedPythonSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedEnvSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedConsumerEngineUrl,
|
||||
[string]$RuntimeStateDir = "$env:LOCALAPPDATA\Temp\vignette-nas-preview",
|
||||
[string]$ReceiptPath = "",
|
||||
[ValidateRange(10, 300)]
|
||||
[int]$ReadyTimeoutSec = 120,
|
||||
[switch]$ConfirmNasPreviewLanExposure,
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
function Get-EnvValue {
|
||||
param([string]$Path, [string]$Key)
|
||||
function Get-Sha256Lower {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$stream = [IO.File]::Open(
|
||||
$Path,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read
|
||||
)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = $sha.ComputeHash($stream)
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
return (($bytes | ForEach-Object { $_.ToString("x2") }) -join "")
|
||||
}
|
||||
|
||||
function Get-EnvValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
$values = @()
|
||||
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
if ($line -match "^\s*$([Regex]::Escape($Key))\s*=(.*)$") {
|
||||
return $Matches[1].Trim().Trim('"').Trim("'")
|
||||
$values += $Matches[1].Trim().Trim('"').Trim("'")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
if ($values.Count -ne 1) {
|
||||
throw "$Key must appear exactly once in the NAS preview env file"
|
||||
}
|
||||
return $values[0]
|
||||
}
|
||||
|
||||
if (!(Test-Path -LiteralPath $EnvFile)) {
|
||||
function Invoke-GitText {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Repository,
|
||||
[Parameter(Mandatory = $true)][string[]]$GitArguments
|
||||
)
|
||||
|
||||
$output = @(& git -C $Repository @GitArguments)
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "git command failed with exit ${exitCode}: $($GitArguments -join ' ')"
|
||||
}
|
||||
return (($output | ForEach-Object { "$_" }) -join "`n").Trim()
|
||||
}
|
||||
|
||||
function Test-IsPathWithin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Candidate,
|
||||
[Parameter(Mandatory = $true)][string]$Parent
|
||||
)
|
||||
|
||||
$candidateFull = [IO.Path]::GetFullPath($Candidate).TrimEnd('\')
|
||||
$parentFull = [IO.Path]::GetFullPath($Parent).TrimEnd('\')
|
||||
if ($candidateFull.Equals($parentFull, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
return $candidateFull.StartsWith(
|
||||
$parentFull + [IO.Path]::DirectorySeparatorChar,
|
||||
[StringComparison]::OrdinalIgnoreCase
|
||||
)
|
||||
}
|
||||
|
||||
function Assert-NoReparseAncestor {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$full = [IO.Path]::GetFullPath($Path).TrimEnd('\')
|
||||
$root = [IO.Path]::GetPathRoot($full)
|
||||
$current = $root
|
||||
$relative = $full.Substring($root.Length)
|
||||
foreach ($segment in $relative.Split([char[]]@('\'), [StringSplitOptions]::RemoveEmptyEntries)) {
|
||||
$current = Join-Path $current $segment
|
||||
$item = Get-Item -LiteralPath $current -Force
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "External state path contains a reparse-point ancestor: $current"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ListenerOwners {
|
||||
param([Parameter(Mandatory = $true)][int]$LocalPort)
|
||||
|
||||
return @(
|
||||
Get-NetTCPConnection -State Listen -LocalPort $LocalPort -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty OwningProcess -Unique
|
||||
)
|
||||
}
|
||||
|
||||
function Stop-OwnedGateway {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][System.Diagnostics.Process]$OwnedProcess,
|
||||
[Parameter(Mandatory = $true)][int]$LocalPort
|
||||
)
|
||||
|
||||
$OwnedProcess.Refresh()
|
||||
if (!$OwnedProcess.HasExited) {
|
||||
Stop-Process -Id $OwnedProcess.Id -Force -ErrorAction SilentlyContinue
|
||||
try {
|
||||
$OwnedProcess.WaitForExit(10000) | Out-Null
|
||||
} catch {
|
||||
# The exact process may have exited between Refresh and WaitForExit.
|
||||
}
|
||||
}
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
do {
|
||||
$owners = @(Get-ListenerOwners -LocalPort $LocalPort)
|
||||
if ($owners -notcontains $OwnedProcess.Id) {
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 200
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
throw "Failed to remove the owned gateway listener for PID $($OwnedProcess.Id)"
|
||||
}
|
||||
|
||||
function Write-AtomicReceipt {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Json
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
throw "Receipt already exists: $Path"
|
||||
}
|
||||
$temporary = "$Path.tmp.$([Guid]::NewGuid().ToString('N'))"
|
||||
$utf8 = [Text.UTF8Encoding]::new($false)
|
||||
try {
|
||||
[IO.File]::WriteAllText($temporary, $Json, $utf8)
|
||||
$stream = [IO.File]::Open(
|
||||
$temporary,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::ReadWrite,
|
||||
[IO.FileShare]::None
|
||||
)
|
||||
try {
|
||||
$stream.Flush($true)
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
[IO.File]::Move($temporary, $Path)
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $temporary) {
|
||||
Remove-Item -LiteralPath $temporary -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
throw "git is required for source provenance verification"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $Workspace -PathType Container)) {
|
||||
throw "Workspace directory not found: $Workspace"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $EnvFile -PathType Leaf)) {
|
||||
throw "NAS preview env file not found: $EnvFile"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $Python)) {
|
||||
if (!(Test-Path -LiteralPath $Python -PathType Leaf)) {
|
||||
throw "Python 3.11 not found: $Python"
|
||||
}
|
||||
$apiDir = Join-Path $Workspace "apps\api"
|
||||
if (!(Test-Path -LiteralPath $apiDir)) {
|
||||
if (!(Test-Path -LiteralPath $RuntimeStateDir -PathType Container) -and $CheckOnly) {
|
||||
# Check-only is mutation-free; the actual start may create its external state directory.
|
||||
$runtimeParent = Split-Path -Parent $RuntimeStateDir
|
||||
if (!(Test-Path -LiteralPath $runtimeParent -PathType Container)) {
|
||||
throw "Runtime state parent directory not found: $runtimeParent"
|
||||
}
|
||||
}
|
||||
|
||||
$workspaceResolved = (Resolve-Path -LiteralPath $Workspace).Path.TrimEnd('\')
|
||||
$envResolved = (Resolve-Path -LiteralPath $EnvFile).Path
|
||||
$pythonResolved = (Resolve-Path -LiteralPath $Python).Path
|
||||
$scriptResolved = (Resolve-Path -LiteralPath $PSCommandPath).Path
|
||||
$expectedScriptPath = (Join-Path $workspaceResolved "scripts\start-nas-preview-engine.ps1")
|
||||
if (!$scriptResolved.Equals($expectedScriptPath, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "The engine launcher must run from the pinned workspace"
|
||||
}
|
||||
|
||||
$repositoryTop = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"rev-parse", "--show-toplevel"
|
||||
)
|
||||
$repositoryTopResolved = (Resolve-Path -LiteralPath $repositoryTop).Path.TrimEnd('\')
|
||||
if (!$repositoryTopResolved.Equals($workspaceResolved, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Workspace must be the exact Git toplevel"
|
||||
}
|
||||
|
||||
$symbolicRef = @(& git -C $workspaceResolved symbolic-ref -q HEAD)
|
||||
$symbolicExit = $LASTEXITCODE
|
||||
if ($symbolicExit -eq 0) {
|
||||
throw "NAS engine source must be a detached HEAD"
|
||||
}
|
||||
if ($symbolicExit -ne 1) {
|
||||
throw "Unable to verify detached HEAD state"
|
||||
}
|
||||
|
||||
$dirty = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"status", "--porcelain=v1", "--untracked-files=normal"
|
||||
)
|
||||
if ($dirty) {
|
||||
throw "NAS engine source must be tracked-clean and untracked-clean"
|
||||
}
|
||||
|
||||
$actualCommit = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"rev-parse", "HEAD"
|
||||
)
|
||||
$actualTree = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"rev-parse", "HEAD^{tree}"
|
||||
)
|
||||
if (!$actualCommit.Equals($ExpectedCommit, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Source commit does not match ExpectedCommit"
|
||||
}
|
||||
if (!$actualTree.Equals($ExpectedTree, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Source tree does not match ExpectedTree"
|
||||
}
|
||||
|
||||
$actualScriptSha = Get-Sha256Lower -Path $scriptResolved
|
||||
$actualPythonSha = Get-Sha256Lower -Path $pythonResolved
|
||||
$actualEnvSha = Get-Sha256Lower -Path $envResolved
|
||||
if (!$actualScriptSha.Equals($ExpectedScriptSha256, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Launcher SHA-256 does not match ExpectedScriptSha256"
|
||||
}
|
||||
if (!$actualPythonSha.Equals($ExpectedPythonSha256, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Python SHA-256 does not match ExpectedPythonSha256"
|
||||
}
|
||||
if (!$actualEnvSha.Equals($ExpectedEnvSha256, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Env SHA-256 does not match ExpectedEnvSha256"
|
||||
}
|
||||
|
||||
$apiDir = Join-Path $workspaceResolved "apps\api"
|
||||
if (!(Test-Path -LiteralPath $apiDir -PathType Container)) {
|
||||
throw "API directory not found: $apiDir"
|
||||
}
|
||||
|
||||
$secret = Get-EnvValue -Path $EnvFile -Key "ENGINE_GATEWAY_SHARED_SECRET"
|
||||
$parsedAddress = $null
|
||||
if (![Net.IPAddress]::TryParse($ListenAddress, [ref]$parsedAddress)) {
|
||||
throw "ListenAddress must be a literal IP address"
|
||||
}
|
||||
if ($parsedAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw "Only an explicit IPv4 ListenAddress is supported"
|
||||
}
|
||||
if ($parsedAddress.Equals([Net.IPAddress]::Any) -or $ListenAddress -eq "255.255.255.255") {
|
||||
throw "Wildcard and broadcast listen addresses are forbidden"
|
||||
}
|
||||
$isLoopback = [Net.IPAddress]::IsLoopback($parsedAddress)
|
||||
if (!$isLoopback) {
|
||||
if (!$ConfirmNasPreviewLanExposure) {
|
||||
throw "Non-loopback binding requires -ConfirmNasPreviewLanExposure"
|
||||
}
|
||||
$assigned = @(
|
||||
Get-NetIPAddress -AddressFamily IPv4 -AddressState Preferred -ErrorAction Stop |
|
||||
Where-Object { $_.IPAddress -eq $ListenAddress }
|
||||
)
|
||||
if ($assigned.Count -ne 1) {
|
||||
throw "ListenAddress must be assigned exactly once on this host"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$consumerUri = [Uri]$ExpectedConsumerEngineUrl
|
||||
} catch {
|
||||
throw "ExpectedConsumerEngineUrl must be an absolute HTTP URL"
|
||||
}
|
||||
if (!$consumerUri.IsAbsoluteUri -or $consumerUri.Scheme -ne "http") {
|
||||
throw "ExpectedConsumerEngineUrl must use http"
|
||||
}
|
||||
if ($consumerUri.Host -ne $ListenAddress -or $consumerUri.Port -ne $Port) {
|
||||
throw "ExpectedConsumerEngineUrl must match ListenAddress and Port exactly"
|
||||
}
|
||||
if ($consumerUri.AbsolutePath -ne "/" -or $consumerUri.Query -or $consumerUri.Fragment) {
|
||||
throw "ExpectedConsumerEngineUrl must not contain a path, query, or fragment"
|
||||
}
|
||||
|
||||
$runtimeFull = [IO.Path]::GetFullPath($RuntimeStateDir)
|
||||
$runtimeLeaf = Split-Path -Leaf $runtimeFull
|
||||
$runtimeParent = Split-Path -Parent $runtimeFull
|
||||
if (!$runtimeLeaf -or $runtimeLeaf -match '[:\\/]') {
|
||||
throw "RuntimeStateDir must end in a plain directory name"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $runtimeParent -PathType Container)) {
|
||||
throw "Runtime state parent directory must be pre-provisioned: $runtimeParent"
|
||||
}
|
||||
Assert-NoReparseAncestor -Path $runtimeParent
|
||||
$runtimeParentResolved = (Resolve-Path -LiteralPath $runtimeParent).Path
|
||||
$runtimeTarget = Join-Path $runtimeParentResolved $runtimeLeaf
|
||||
if (Test-IsPathWithin -Candidate $runtimeTarget -Parent $workspaceResolved) {
|
||||
throw "RuntimeStateDir must resolve outside the source workspace"
|
||||
}
|
||||
if ($ReceiptPath) {
|
||||
$receiptRequestedFull = [IO.Path]::GetFullPath($ReceiptPath)
|
||||
$receiptLeaf = Split-Path -Leaf $receiptRequestedFull
|
||||
if (!$receiptLeaf -or $receiptLeaf -match '[:\\/]') {
|
||||
throw "ReceiptPath must end in a plain file name"
|
||||
}
|
||||
$receiptParent = Split-Path -Parent $receiptRequestedFull
|
||||
if (!(Test-Path -LiteralPath $receiptParent -PathType Container)) {
|
||||
throw "Receipt parent directory must be pre-provisioned: $receiptParent"
|
||||
}
|
||||
Assert-NoReparseAncestor -Path $receiptParent
|
||||
$receiptParentResolved = (Resolve-Path -LiteralPath $receiptParent).Path
|
||||
$receiptFull = Join-Path $receiptParentResolved $receiptLeaf
|
||||
if (Test-IsPathWithin -Candidate $receiptFull -Parent $workspaceResolved) {
|
||||
throw "ReceiptPath must resolve outside the source workspace"
|
||||
}
|
||||
if (Test-Path -LiteralPath $receiptFull) {
|
||||
throw "Receipt already exists: $receiptFull"
|
||||
}
|
||||
} elseif (!$CheckOnly) {
|
||||
throw "ReceiptPath is required for an actual start"
|
||||
}
|
||||
|
||||
$secret = Get-EnvValue -Path $envResolved -Key "ENGINE_GATEWAY_SHARED_SECRET"
|
||||
if ($secret.Length -lt 32 -or $secret.ToLowerInvariant().StartsWith("change-me")) {
|
||||
throw "ENGINE_GATEWAY_SHARED_SECRET must be a non-placeholder value of at least 32 characters"
|
||||
}
|
||||
|
||||
$existing = Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like "*uvicorn engine_gateway.gateway:app*" -and
|
||||
$_.CommandLine -like "*--port $Port*"
|
||||
}
|
||||
if ($existing) {
|
||||
throw "An engine gateway is already running on the requested preview port $Port"
|
||||
$listenerOwners = @(Get-ListenerOwners -LocalPort $Port)
|
||||
if ($listenerOwners.Count -gt 0) {
|
||||
throw "Port $Port already has a listener; silent reuse is forbidden"
|
||||
}
|
||||
|
||||
$logDir = Join-Path $env:LOCALAPPDATA "Temp\vignette-nas-preview"
|
||||
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddHHmmss")
|
||||
$outLog = Join-Path $logDir "engine-$Port-$stamp.out.log"
|
||||
$errLog = Join-Path $logDir "engine-$Port-$stamp.err.log"
|
||||
$binding = [ordered]@{
|
||||
source_commit = $actualCommit
|
||||
source_tree = $actualTree
|
||||
launcher_sha256 = $actualScriptSha
|
||||
python_sha256 = $actualPythonSha
|
||||
env_sha256 = $actualEnvSha
|
||||
listen_address = $ListenAddress
|
||||
port = $Port
|
||||
consumer_engine_url = $ExpectedConsumerEngineUrl
|
||||
secret_value_emitted = $false
|
||||
}
|
||||
|
||||
$env:ENGINE_GATEWAY_SHARED_SECRET = $secret
|
||||
$env:PYTHONUTF8 = "1"
|
||||
$process = Start-Process -WindowStyle Hidden -FilePath $Python `
|
||||
-ArgumentList @("-X", "utf8", "-m", "uvicorn", "engine_gateway.gateway:app", "--host", $ListenAddress, "--port", "$Port") `
|
||||
if ($CheckOnly) {
|
||||
[ordered]@{
|
||||
schema = "vignette.nas_preview_engine_preflight.v1"
|
||||
status = "preflight_passed"
|
||||
mutation = $false
|
||||
binding = $binding
|
||||
} | ConvertTo-Json -Depth 5
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (!(Test-Path -LiteralPath $runtimeTarget -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $runtimeTarget | Out-Null
|
||||
}
|
||||
$runtimeResolved = (Resolve-Path -LiteralPath $runtimeTarget).Path
|
||||
if (Test-IsPathWithin -Candidate $runtimeResolved -Parent $workspaceResolved) {
|
||||
throw "Resolved RuntimeStateDir must remain outside the source workspace"
|
||||
}
|
||||
|
||||
# Prove receipt write/flush/delete before the process mutation.
|
||||
$receiptProbe = Join-Path (Split-Path -Parent $receiptFull) (
|
||||
".vignette-nas-engine-receipt-probe-$([Guid]::NewGuid().ToString('N'))"
|
||||
)
|
||||
try {
|
||||
[IO.File]::WriteAllText($receiptProbe, "probe", [Text.UTF8Encoding]::new($false))
|
||||
$probeStream = [IO.File]::Open(
|
||||
$receiptProbe,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::ReadWrite,
|
||||
[IO.FileShare]::None
|
||||
)
|
||||
try {
|
||||
$probeStream.Flush($true)
|
||||
} finally {
|
||||
$probeStream.Dispose()
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $receiptProbe) {
|
||||
Remove-Item -LiteralPath $receiptProbe -Force
|
||||
}
|
||||
}
|
||||
|
||||
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ")
|
||||
$outLog = Join-Path $runtimeResolved "engine-$Port-$stamp.out.log"
|
||||
$errLog = Join-Path $runtimeResolved "engine-$Port-$stamp.err.log"
|
||||
$gatewayProcess = $null
|
||||
$hadPriorSecret = Test-Path Env:ENGINE_GATEWAY_SHARED_SECRET
|
||||
$priorSecret = $env:ENGINE_GATEWAY_SHARED_SECRET
|
||||
$hadPriorPythonUtf8 = Test-Path Env:PYTHONUTF8
|
||||
$priorPythonUtf8 = $env:PYTHONUTF8
|
||||
|
||||
try {
|
||||
try {
|
||||
$env:ENGINE_GATEWAY_SHARED_SECRET = $secret
|
||||
$env:PYTHONUTF8 = "1"
|
||||
$gatewayProcess = Start-Process -WindowStyle Hidden -FilePath $pythonResolved `
|
||||
-ArgumentList @(
|
||||
"-X", "utf8", "-m", "uvicorn", "engine_gateway.gateway:app",
|
||||
"--host", $ListenAddress, "--port", "$Port"
|
||||
) `
|
||||
-WorkingDirectory $apiDir `
|
||||
-RedirectStandardOutput $outLog `
|
||||
-RedirectStandardError $errLog `
|
||||
-PassThru
|
||||
Remove-Item Env:ENGINE_GATEWAY_SHARED_SECRET
|
||||
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
$health = $null
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
if ($process.HasExited) {
|
||||
$detail = if (Test-Path -LiteralPath $errLog) {
|
||||
(Get-Content -LiteralPath $errLog -Encoding UTF8 -Tail 40) -join "`n"
|
||||
} finally {
|
||||
if ($hadPriorSecret) {
|
||||
$env:ENGINE_GATEWAY_SHARED_SECRET = $priorSecret
|
||||
} else {
|
||||
"no stderr log"
|
||||
Remove-Item Env:ENGINE_GATEWAY_SHARED_SECRET -ErrorAction SilentlyContinue
|
||||
}
|
||||
throw "Preview engine gateway exited with $($process.ExitCode): $detail"
|
||||
if ($hadPriorPythonUtf8) {
|
||||
$env:PYTHONUTF8 = $priorPythonUtf8
|
||||
} else {
|
||||
Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$headers = @{ "X-Vignette-Engine-Token" = $secret }
|
||||
$health = $null
|
||||
$ready = $null
|
||||
$deadline = (Get-Date).AddSeconds($ReadyTimeoutSec)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$gatewayProcess.Refresh()
|
||||
if ($gatewayProcess.HasExited) {
|
||||
throw "Preview engine gateway exited before readiness with code $($gatewayProcess.ExitCode)"
|
||||
}
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2
|
||||
$health = Invoke-RestMethod -Uri "http://$ListenAddress`:$Port/health" -TimeoutSec 2
|
||||
} catch {
|
||||
$health = $null
|
||||
}
|
||||
} while ($null -eq $health -and (Get-Date) -lt $deadline)
|
||||
} while (($null -eq $health -or !$health.ok) -and (Get-Date) -lt $deadline)
|
||||
|
||||
if ($null -eq $health -or !$health.ok) {
|
||||
throw "Preview engine gateway health timed out on port $Port"
|
||||
if ($null -eq $health -or !$health.ok) {
|
||||
throw "Preview engine gateway liveness did not pass"
|
||||
}
|
||||
|
||||
# force=true performs a real provider generation. Call it exactly once after
|
||||
# liveness is established so a failing provider cannot trigger a retry storm.
|
||||
try {
|
||||
$ready = Invoke-RestMethod `
|
||||
-Uri "http://$ListenAddress`:$Port/ready?force=true" `
|
||||
-Headers $headers `
|
||||
-TimeoutSec $ReadyTimeoutSec
|
||||
} catch {
|
||||
$ready = $null
|
||||
}
|
||||
if ($null -eq $ready -or !$ready.ok) {
|
||||
throw "Preview engine gateway generation readiness did not pass"
|
||||
}
|
||||
|
||||
$owners = @(Get-ListenerOwners -LocalPort $Port)
|
||||
if ($owners.Count -ne 1 -or $owners[0] -ne $gatewayProcess.Id) {
|
||||
throw "Gateway listener owner does not match the started process"
|
||||
}
|
||||
|
||||
$receipt = [ordered]@{
|
||||
schema = "vignette.nas_preview_engine_launch_receipt.v1"
|
||||
status = "passed"
|
||||
started_at = [DateTime]::UtcNow.ToString("o")
|
||||
pid = $gatewayProcess.Id
|
||||
binding = $binding
|
||||
checks = [ordered]@{
|
||||
source_detached_clean = $true
|
||||
port_previously_unused = $true
|
||||
listener_owner_matches = $true
|
||||
liveness_ok = $true
|
||||
generation_ready = $true
|
||||
}
|
||||
logs = [ordered]@{
|
||||
stdout = $outLog
|
||||
stderr = $errLog
|
||||
}
|
||||
}
|
||||
Write-AtomicReceipt `
|
||||
-Path $receiptFull `
|
||||
-Json ($receipt | ConvertTo-Json -Depth 6)
|
||||
|
||||
Write-Output "Preview engine gateway PID=$($gatewayProcess.Id) port=$Port"
|
||||
Write-Output "GenerationReady=true"
|
||||
Write-Output "SecretValueEmitted=false"
|
||||
Write-Output "Receipt=$receiptFull"
|
||||
Write-Output "StdoutLog=$outLog"
|
||||
Write-Output "StderrLog=$errLog"
|
||||
} catch {
|
||||
if ($null -ne $gatewayProcess) {
|
||||
Stop-OwnedGateway -OwnedProcess $gatewayProcess -LocalPort $Port
|
||||
}
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Output "Preview engine gateway PID=$($process.Id) port=$Port"
|
||||
Write-Output "Health=$($health | ConvertTo-Json -Compress)"
|
||||
Write-Output "SecretValueEmitted=false"
|
||||
Write-Output "StdoutLog=$outLog"
|
||||
Write-Output "StderrLog=$errLog"
|
||||
|
|
|
|||
385
scripts/test_start_nas_preview_engine_contract.py
Normal file
385
scripts/test_start_nas_preview_engine_contract.py
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_SCRIPT = REPO_ROOT / "scripts" / "start-nas-preview-engine.ps1"
|
||||
POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run(command: list[str], *, cwd: Path, timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def run_without_pipe_capture(
|
||||
command: list[str], *, cwd: Path, output_dir: Path, timeout: int = 60
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Wait for the launcher process without waiting on inherited pipe EOF."""
|
||||
stdout_path = output_dir / "launcher.stdout.log"
|
||||
stderr_path = output_dir / "launcher.stderr.log"
|
||||
with stdout_path.open("w", encoding="utf-8") as stdout_handle, stderr_path.open(
|
||||
"w", encoding="utf-8"
|
||||
) as stderr_handle:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=stdout_handle,
|
||||
stderr=stderr_handle,
|
||||
)
|
||||
returncode = process.wait(timeout=timeout)
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
returncode,
|
||||
stdout_path.read_text(encoding="utf-8", errors="replace"),
|
||||
stderr_path.read_text(encoding="utf-8", errors="replace"),
|
||||
)
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def wait_for_port_closed(port: int, *, timeout: float = 10.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.2)
|
||||
if sock.connect_ex(("127.0.0.1", port)) != 0:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipUnless(POWERSHELL, "Windows PowerShell is required")
|
||||
class NasPreviewEngineLauncherContractTests(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="vignette-nas-engine-test-")
|
||||
self.root = Path(self.temp.name)
|
||||
self.repo = self.root / "release"
|
||||
self.script = self.repo / "scripts" / SOURCE_SCRIPT.name
|
||||
self.api = self.repo / "apps" / "api"
|
||||
self.env_file = self.root / "preview.env"
|
||||
self.runtime = self.root / "runtime"
|
||||
self.receipts = self.root / "receipts"
|
||||
self.receipts.mkdir()
|
||||
self.script.parent.mkdir(parents=True)
|
||||
self.api.mkdir(parents=True)
|
||||
shutil.copy2(SOURCE_SCRIPT, self.script)
|
||||
self.secret = "nas-preview-engine-test-secret-" + ("x" * 32)
|
||||
self.env_file.write_text(
|
||||
f"ENGINE_GATEWAY_SHARED_SECRET={self.secret}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.started_pid: int | None = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if self.started_pid is not None:
|
||||
subprocess.run(
|
||||
["taskkill.exe", "/PID", str(self.started_pid), "/T", "/F"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.temp.cleanup()
|
||||
|
||||
def _write_gateway(self, *, ready: bool = True) -> None:
|
||||
package = self.api / "engine_gateway"
|
||||
package.mkdir(parents=True, exist_ok=True)
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
ready_literal = "True" if ready else "False"
|
||||
(package / "gateway.py").write_text(
|
||||
"""
|
||||
import os
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
|
||||
app = FastAPI()
|
||||
secret = os.environ["ENGINE_GATEWAY_SHARED_SECRET"]
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/ready")
|
||||
async def ready(force: bool = False, token: str | None = Header(default=None, alias="X-Vignette-Engine-Token")):
|
||||
if token != secret:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
return {"ok": READY, "force": force}
|
||||
""".replace("READY", ready_literal).lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _commit_detached(self) -> tuple[str, str]:
|
||||
commands = [
|
||||
["git", "init", "-q"],
|
||||
["git", "config", "user.name", "Yun Chan"],
|
||||
["git", "config", "user.email", "yunchan@twentyoz.kr"],
|
||||
["git", "add", "--all"],
|
||||
["git", "commit", "-q", "-m", "test fixture"],
|
||||
]
|
||||
for command in commands:
|
||||
result = run(command, cwd=self.repo)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
head = run(["git", "rev-parse", "HEAD"], cwd=self.repo).stdout.strip()
|
||||
tree = run(["git", "rev-parse", "HEAD^{tree}"], cwd=self.repo).stdout.strip()
|
||||
result = run(["git", "checkout", "-q", "--detach", head], cwd=self.repo)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
return head, tree
|
||||
|
||||
def _launcher_args(
|
||||
self,
|
||||
*,
|
||||
port: int,
|
||||
head: str,
|
||||
tree: str,
|
||||
check_only: bool = True,
|
||||
listen_address: str = "127.0.0.1",
|
||||
script_sha: str | None = None,
|
||||
env_sha: str | None = None,
|
||||
ready_timeout: int = 20,
|
||||
) -> list[str]:
|
||||
receipt = self.receipts / f"receipt-{port}.json"
|
||||
args = [
|
||||
str(POWERSHELL),
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(self.script),
|
||||
"-EnvFile",
|
||||
str(self.env_file),
|
||||
"-Workspace",
|
||||
str(self.repo),
|
||||
"-ListenAddress",
|
||||
listen_address,
|
||||
"-Port",
|
||||
str(port),
|
||||
"-Python",
|
||||
sys.executable,
|
||||
"-ExpectedCommit",
|
||||
head,
|
||||
"-ExpectedTree",
|
||||
tree,
|
||||
"-ExpectedScriptSha256",
|
||||
script_sha or sha256_file(self.script),
|
||||
"-ExpectedPythonSha256",
|
||||
sha256_file(Path(sys.executable)),
|
||||
"-ExpectedEnvSha256",
|
||||
env_sha or sha256_file(self.env_file),
|
||||
"-ExpectedConsumerEngineUrl",
|
||||
f"http://{listen_address}:{port}",
|
||||
"-RuntimeStateDir",
|
||||
str(self.runtime),
|
||||
"-ReceiptPath",
|
||||
str(receipt),
|
||||
"-ReadyTimeoutSec",
|
||||
str(ready_timeout),
|
||||
]
|
||||
if check_only:
|
||||
args.append("-CheckOnly")
|
||||
return args
|
||||
|
||||
def test_check_only_is_mutation_free_and_secret_free(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual("preflight_passed", payload["status"])
|
||||
self.assertFalse(payload["mutation"])
|
||||
self.assertFalse(payload["binding"]["secret_value_emitted"])
|
||||
self.assertNotIn(self.secret, result.stdout + result.stderr)
|
||||
self.assertFalse(self.runtime.exists())
|
||||
self.assertEqual([], list(self.receipts.iterdir()))
|
||||
|
||||
def test_attached_or_dirty_source_is_rejected(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
branch = run(["git", "switch", "-q", "-c", "unsafe"], cwd=self.repo)
|
||||
self.assertEqual(0, branch.returncode, branch.stderr)
|
||||
attached = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, attached.returncode)
|
||||
self.assertIn("detached HEAD", attached.stderr)
|
||||
|
||||
detached = run(["git", "checkout", "-q", "--detach", head], cwd=self.repo)
|
||||
self.assertEqual(0, detached.returncode, detached.stderr)
|
||||
(self.repo / "untracked.txt").write_text("unsafe", encoding="utf-8")
|
||||
dirty = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, dirty.returncode)
|
||||
self.assertIn("tracked-clean and untracked-clean", dirty.stderr)
|
||||
|
||||
def test_hash_drift_is_rejected(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run(
|
||||
self._launcher_args(
|
||||
port=port,
|
||||
head=head,
|
||||
tree=tree,
|
||||
script_sha="0" * 64,
|
||||
),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("Launcher SHA-256", result.stderr)
|
||||
|
||||
def test_wildcard_and_unconfirmed_lan_bindings_are_rejected(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
wildcard = run(
|
||||
self._launcher_args(
|
||||
port=free_port(),
|
||||
head=head,
|
||||
tree=tree,
|
||||
listen_address="0.0.0.0",
|
||||
),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, wildcard.returncode)
|
||||
self.assertIn("Wildcard and broadcast", wildcard.stderr)
|
||||
|
||||
unconfirmed = run(
|
||||
self._launcher_args(
|
||||
port=free_port(),
|
||||
head=head,
|
||||
tree=tree,
|
||||
listen_address="192.0.2.1",
|
||||
),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, unconfirmed.returncode)
|
||||
self.assertIn("ConfirmNasPreviewLanExposure", unconfirmed.stderr)
|
||||
|
||||
def test_existing_listener_is_never_reused(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen()
|
||||
port = int(listener.getsockname()[1])
|
||||
result = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("already has a listener", result.stderr)
|
||||
|
||||
def test_external_junction_cannot_redirect_receipt_into_source(self) -> None:
|
||||
self._write_gateway()
|
||||
source_receipts = self.repo / "source-receipts"
|
||||
source_receipts.mkdir()
|
||||
(source_receipts / ".keep").write_text("pinned\n", encoding="utf-8")
|
||||
head, tree = self._commit_detached()
|
||||
junction = self.root / "receipt-junction"
|
||||
created = run(
|
||||
["cmd.exe", "/c", "mklink", "/J", str(junction), str(source_receipts)],
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertEqual(0, created.returncode, created.stderr)
|
||||
port = free_port()
|
||||
args = self._launcher_args(port=port, head=head, tree=tree)
|
||||
receipt_index = args.index("-ReceiptPath") + 1
|
||||
args[receipt_index] = str(junction / "receipt.json")
|
||||
result = run(args, cwd=self.root)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("reparse-point ancestor", result.stderr)
|
||||
self.assertFalse((source_receipts / "receipt.json").exists())
|
||||
|
||||
def test_actual_loopback_launch_binds_receipt_and_owner(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run_without_pipe_capture(
|
||||
self._launcher_args(
|
||||
port=port,
|
||||
head=head,
|
||||
tree=tree,
|
||||
check_only=False,
|
||||
),
|
||||
cwd=self.root,
|
||||
output_dir=self.root,
|
||||
timeout=60,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
receipt_path = self.receipts / f"receipt-{port}.json"
|
||||
payload = json.loads(receipt_path.read_text(encoding="utf-8"))
|
||||
self.started_pid = int(payload["pid"])
|
||||
self.assertEqual("passed", payload["status"])
|
||||
self.assertEqual(head, payload["binding"]["source_commit"])
|
||||
self.assertEqual(tree, payload["binding"]["source_tree"])
|
||||
self.assertTrue(payload["checks"]["generation_ready"])
|
||||
self.assertTrue(payload["checks"]["listener_owner_matches"])
|
||||
serialized = json.dumps(payload, ensure_ascii=False)
|
||||
self.assertNotIn(self.secret, serialized)
|
||||
self.assertNotIn(self.secret, result.stdout + result.stderr)
|
||||
|
||||
def test_failed_readiness_removes_only_the_owned_process(self) -> None:
|
||||
self._write_gateway(ready=False)
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run(
|
||||
self._launcher_args(
|
||||
port=port,
|
||||
head=head,
|
||||
tree=tree,
|
||||
check_only=False,
|
||||
ready_timeout=10,
|
||||
),
|
||||
cwd=self.root,
|
||||
timeout=45,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("generation readiness did not pass", result.stderr)
|
||||
self.assertTrue(wait_for_port_closed(port))
|
||||
self.assertFalse((self.receipts / f"receipt-{port}.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue