ops: finalize production domain and hardware benchmark tooling

Add /version.json build identity, deploy 16e7930 release markers,
DNS/Named Tunnel prep docs, portable browser benchmark, and replay
stability probe. Keep Quick Tunnel until permanent domain cutover.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Даня Архипов
2026-07-15 22:21:13 +00:00
parent 16e7930304
commit 4fcce5bff9
29 changed files with 1068 additions and 138 deletions

2
.gitignore vendored
View File

@@ -7,10 +7,12 @@ playwright.config.js
playwright.config.d.ts
.env
.env.*
public/version.json
npm-debug.log*
input_info/extracted/
agent/state/
agent/reports/
docs/HARDWARE_BROWSER_BENCHMARK_LATEST.md
.agent/worktrees/
.demo-preview.pid
.demo-preview.log

View File

@@ -3,6 +3,13 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# .git is dockerignored — pass identity from host deploy script
ARG BUILD_COMMIT=unknown
ARG BUILD_BRANCH=unknown
ARG BUILD_RELEASE=unknown
ENV VITE_BUILD_COMMIT=$BUILD_COMMIT \
VITE_BUILD_BRANCH=$BUILD_BRANCH \
VITE_BUILD_RELEASE=$BUILD_RELEASE
RUN npm run build
FROM nginx:alpine AS runtime

View File

@@ -0,0 +1,100 @@
# CLOUDFLARE_NAMED_TUNNEL_SETUP
## Goal
Постоянный HTTPS для `arhipovdan.ru` / `www.arhipovdan.ru` → production `http://127.0.0.1:3100` (`owl-web-1`), без открытия 80/443 на OwlPrime.
Quick Tunnel (`*.trycloudflare.com`) **сохранять** до полного PASS постоянного домена.
## Architecture choice
```text
REG.RU = registrar only
Cloudflare = authoritative DNS + Named Tunnel + managed TLS
```
## Current blocker in this environment
```text
cloudflared tunnel list → no origin cert (cert.pem)
CLOUDFLARE_API_TOKEN → unset
```
Named Tunnel **нельзя** создать/привязать DNS из coder без `cloudflared login` или API token.
## Recommended tunnel name
```text
owlprime-sorter-production
```
## Config template (do not commit credentials)
Path suggestion (host, outside git):
```text
~/.cloudflared/config.yml
~/.cloudflared/<TUNNEL_UUID>.json # credentials — chmod 600
```
```yaml
tunnel: <TUNNEL_UUID>
credentials-file: /home/<user>/.cloudflared/<TUNNEL_UUID>.json
ingress:
- hostname: arhipovdan.ru
service: http://127.0.0.1:3100
- hostname: www.arhipovdan.ru
service: http://127.0.0.1:3100
- service: http_status:404
```
Service target assumes cloudflared on **host network namespace** with publish `127.0.0.1:3100` (current). If cloudflared runs in Docker, use `http://host.docker.internal:3100` or shared network alias `http://owl-web-1:80` — never a ephemeral container IP.
## Operator steps (when Cloudflare access available)
1. `cloudflared login` (or set API token with Tunnel + Zone DNS edit).
2. `cloudflared tunnel list` — reuse existing sorter tunnel if present.
3. Else: `cloudflared tunnel create owlprime-sorter-production`
4. Write config (template above). Validate: `cloudflared tunnel ingress validate`
5. Run connector (systemd preferred): `cloudflared tunnel run owlprime-sorter-production`
6. Add domain to Cloudflare → note **exact** assigned nameservers.
7. Import **all** DNS from `DNS_MIGRATION_INVENTORY.md` / REG.RU panel.
8. Create public hostnames / `cloudflared tunnel route dns <tunnel> arhipovdan.ru` (+ www).
9. Remove conflicting A `185.160.137.162` for apex/www in Cloudflare zone.
10. Only then change NS at REG.RU.
11. Keep Quick Tunnel until:
```bash
curl -s https://arhipovdan.ru/version.json
# expected commit from production
PLAYWRIGHT_BASE_URL=https://arhipovdan.ru EXPECTED_COMMIT=<sha> npm run test:e2e:production
```
## Coexistence with Quick Tunnel
| Process | Role |
| ------- | ---- |
| `cloudflared tunnel --url http://127.0.0.1:3100` | Temporary public URL |
| Named Tunnel connector | Permanent hostnames (after CF zone) |
They can run in parallel; different Cloudflare edge bindings.
## Secrets policy
Never commit:
* `*.json` credentials
* `cert.pem`
* API tokens
* tunnel tokens in systemd unit Environment=
## Status
```text
Named Tunnel created: NO (auth missing)
Ingress prepared as docs: YES
Connector healthy: N/A
DNS route: N/A
NS cutover: WAITING_USER
```

View File

@@ -5,6 +5,7 @@
**Рабочий публичный URL (временный):** Quick Tunnel — см. `PRODUCTION_DEPLOYMENT_REPORT.md`
**Ожидаемый постоянный домен:** https://arhipovdan.ru/ — **BLOCKED_EXTERNAL** (`PUBLIC_DOMAIN_DIAGNOSTIC.md`)
**Локальный production:** http://127.0.0.1:3100/
**Проверка версии:** `curl -s http://127.0.0.1:3100/version.json`
**Репозиторий:** `/home/coder/arhipovdan/app`
---

View File

@@ -0,0 +1,78 @@
# DNS_MIGRATION_INVENTORY
Дата: 2026-07-15
Домен: `arhipovdan.ru`
Источник: публичные `dig` запросы с OwlPrime/coder.
## Текущие authoritative NS
```text
ns1.reg.ru.
ns2.reg.ru.
```
REG.RU = регистратор **и** DNS provider. Cloudflare zone пока **не** создана из этого окружения (нет `cert.pem` / API token).
## Публичные записи
| Type | Name | Current value | TTL (approx) | Must migrate | Purpose |
| ---- | ---- | ------------- | -----------: | ------------ | ------- |
| NS | @ | ns1.reg.ru / ns2.reg.ru | — | Yes (delegation) | Authoritative DNS |
| SOA | @ | ns1.reg.ru. hostmaster… | 10800 | Auto in CF | Zone SOA |
| A | @ | 185.160.137.162 | 21600 | **Replace** with CF tunnel route | Broken openresty (404 / TLS SNI fail) |
| A | www | 185.160.137.162 | — | **Replace** with CF tunnel route | Same broken endpoint |
| AAAA | @ | *(none)* | — | No | — |
| CNAME | @ | *(none)* | — | No | Apex CNAME not used |
| MX | @ | *(none publicly visible)* | — | **Check in REG.RU panel** | Mail may be absent |
| TXT | @ | *(none publicly visible)* | — | **Check in REG.RU panel** | SPF / domain verify |
| TXT | `_dmarc` | *(none)* | — | **Check panel** | DMARC |
| TXT | `default._domainkey` | *(none)* | — | **Check panel** | DKIM |
| CAA | @ | *(none)* | — | Optional | TLS CA policy |
### Subdomain probe (public A)
Many common labels resolve to the **same** `185.160.137.162` (likely REG.RU catch-all / parking):
| Name | A | Notes |
| ---- | - | ----- |
| mail | 185.160.137.162 | Confirm real mail usage in panel |
| smtp | 185.160.137.162 | |
| api | 185.160.137.162 | |
| app | 185.160.137.162 | |
| staging | 185.160.137.162 | |
| blog | 185.160.137.162 | |
| ftp | 185.160.137.162 | |
**Перед сменой NS** откройте панель REG.RU → DNS и перенесите **все** реальные записи (особенно MX/TXT/DKIM), не только A для сайта.
## Что нельзя потерять
1. Любые MX / SPF / DKIM / DMARC (если почта используется).
2. TXT верификации (Google, Yandex, Cloudflare, etc.).
3. Поддомены других сервисов, если отличаются от parking IP.
4. CAA, если планируется нестандартный CA.
## Рекомендуемый target после Cloudflare zone
| Hostname | Type on Cloudflare | Target |
| -------- | ------------------ | ------ |
| `arhipovdan.ru` | Cloudflare Tunnel CNAME / route | Named Tunnel `owlprime-sorter-production` |
| `www.arhipovdan.ru` | same | same tunnel ingress |
Не создавать «A = Named Tunnel» — A принимает только IPv4.
## Сравнение вариантов публикации
| Вариант | Требования | Риск | Рекомендация |
| ------- | ---------- | ---- | ------------ |
| Cloudflare Named Tunnel + NS на CF | Cloudflare zone + миграция всех DNS | Низкий | **Предпочтительно** |
| Direct nginx на OwlPrime :80/:443 | Public IP, NAT, firewall, TLS | Средний/высокий | Не делать автоматически |
| Quick Tunnel | Нет постоянного hostname | Высокий для demo | Только temporary fallback |
## Статус миграции
```text
Inventory: DONE (public)
Cloudflare zone: NOT CREATED (needs user / API login)
NS changed: NO
```

View File

@@ -1,40 +1,40 @@
# FINAL_DEMO_READINESS
Дата: 2026-07-15
Ветка: `feature/maximum-demo-realism`
Production release (не тронут этим коммитом): `20260715-1712` / `index-ncgt6PBL.js`
## Status board
| Область | Статус | Комментарий |
| ------- | ------ | ----------- |
| Git | PASS | Feature branch clean after commit; no push |
| Production container | PASS | `owl-web-1` на `:3100`, bundle `index-ncgt6PBL.js` |
| Permanent domain | BLOCKED_EXTERNAL | `arhipovdan.ru` → openresty 404 / TLS SNI fail |
| TLS | BLOCKED_EXTERNAL | Нет SNI-сертификата на destination IP |
| Main route | PASS | Local + Quick Tunnel |
| Details route | PASS | SPA fallback 200 |
| Unit tests | PASS | 166 |
| E2E | PASS | 15 (excl. production) |
| Visual regression | PASS | 10 snapshots |
| GPU performance | PARTIAL | SwiftShader/xvfb only; hardware baseline blocked in coder |
| Safety scenarios | PASS | Jam + E-stop e2e |
| Rollback | PASS | Tag `backup/pre-maximum-demo-realism-20260715` + docker rename |
| Agent safety | PASS | Worktree / dry-run / no auto DNS |
| Git | PASS | Feature branch; no push |
| Production container | PASS | `20260715-2215` / `index-AagIOJbd.js` / `version.json``16e7930` |
| Permanent domain | BLOCKED_EXTERNAL | REG.RU → openresty; CF Named Tunnel not auth'd |
| TLS (permanent) | BLOCKED_EXTERNAL | SNI fail on 185.160.137.162 |
| Quick Tunnel | PASS | Temporary public URL OK |
| Main / details | PASS | Local + tunnel |
| Unit / E2E / visual | PASS | 166 / 15 / 10 |
| GPU hardware (server) | BLOCKED_BY_DISPLAY_ENVIRONMENT | SwiftShader only |
| Portable benchmark | PASS tooling | `npm run perf:browser` on laptop |
| Replay stability | STABLE | See `REPLAY_RESOURCE_STABILITY.md` |
| Rollback | PASS | docker rename backup |
| Agent / secrets | PASS | no credentials in git |
## Application readiness
```text
READY
```
## Public domain readiness
```text
WAITING — Cloudflare login + NS cutover (see CLOUDFLARE_NAMED_TUNNEL_SETUP.md)
```
## Verdict
```text
READY WITH EXTERNAL DOMAIN BLOCKER
READY FOR LIVE DEMO VIA QUICK TUNNEL
```
Live demo **можно** вести с:
- `http://127.0.0.1:3100/` (локально), или
- актуального Quick Tunnel `https://invitations-based-characters-accent.trycloudflare.com/`
Постоянный домен `https://arhipovdan.ru/` **не готов** до DNS/proxy фикса пользователем (см. `PUBLIC_DOMAIN_DIAGNOSTIC.md`).
## Next deploy note
Новый код (perf overlay, twin layout, visual snapshots) собран как `index-DEbogfOZ.js` в `dist/`, но **не** задеплоен в `owl-web-1`, чтобы не нарушить работающий production. Redeploy — отдельный шаг после доменного фикса или явного запроса.
After user completes Cloudflare zone + NS change → `READY, WAITING FOR NS CUTOVER` then permanent PASS.

View File

@@ -0,0 +1,58 @@
# HARDWARE_BENCHMARK_RUNBOOK
## Server status (OwlPrime / coder)
```text
GPU devices: NVIDIA GTX 1080 ×2 present (/dev/nvidia*, /dev/dri)
DISPLAY: unset (unless xvfb)
Chromium WebGL in this env: SwiftShader / software
Hardware acceleration for demo metrics: BLOCKED_BY_DISPLAY_ENVIRONMENT
```
Do **not** install a display server or change NVIDIA drivers for benchmark.
## Portable benchmark (presentation laptop)
On the machine that will show the demo to the jury:
```bash
cd /path/to/app
npm ci
npm run build
npx vite preview --host 127.0.0.1 --port 3100
# or point at production / Quick Tunnel:
# export PLAYWRIGHT_BASE_URL=https://<working-url>/
npm run perf:browser
# writes agent/reports/browser-benchmark.json
# and docs/HARDWARE_BROWSER_BENCHMARK_LATEST.md (gitignored)
npm run perf:browser:export # same
```
Requirements:
* Real headed Chrome/Chromium (`DISPLAY` / macOS / Windows)
* **No** forced GPU flags (measures the jury machine as-is)
* Modes: low / medium / high / demo
* Includes playlist seeks + 3 replay cycles
### Manual export from UI
Open `/?perf=1` → button **Export benchmark** downloads JSON locally.
## Acceptance (Demo mode)
| Grade | Avg FPS | Min FPS | p95 |
| ----- | ------: | ------: | --: |
| Excellent | ≥55 | ≥40 | ≤20 ms |
| Acceptable | ≥30 | ≥24 | ≤33 ms |
| Not for live demo | <30 or p95 >40 ms |
Hardware run is valid only if renderer string does **not** contain SwiftShader / llvmpipe / Software.
## Server diagnostic (optional)
```bash
npm run perf:gpu # may re-exec under xvfb; labels software renderers
```

View File

@@ -7,13 +7,15 @@ Display: headed (:99)
Software renderer detected: **YES**
Verdict: **NOT A GPU BASELINE — software renderer (SwiftShader/llvmpipe/etc.)**
## Notes
## Server hardware WebGL re-check (2026-07-15)
- Headed Chromium with GPU-preferring flags.
- PerfCollector only with `?perf=1`; quality forced via `?quality=`.
- Prior headless ~9.6 FPS is labeled software/headless WebGL diagnostic baseline.
- **Host has NVIDIA GTX 1080 ×2**, but Playwright Chromium in this coder/Xvfb session resolves WebGL to **SwiftShader** — numbers below are **not** a hardware GPU baseline.
- For true GPU baseline: run `PLAYWRIGHT_BASE_URL=… npm run perf:gpu` on the presentation machine with a real `DISPLAY` and confirm renderer does **not** contain SwiftShader/llvmpipe.
Tried Chromium flags independently (`--use-gl=egl`, `--use-angle=gl-egl`, `--use-angle=gl`, Vulkan ANGLE) with NVIDIA EGL vendor file present. All resolved to **SwiftShader**.
```text
BLOCKED_BY_DISPLAY_ENVIRONMENT
```
Portable path: `npm run perf:browser` / `docs/HARDWARE_BENCHMARK_RUNBOOK.md`.
## Mode table (after Play)

View File

@@ -1,38 +1,33 @@
# PERFORMANCE_AFTER_MAXIMUM_DEMO
Measured: 2026-07-15
Commits referenced: `985f7c3` / tooling on current branch
## Production identity
## Bundle (production container, unchanged)
| Метрика | Значение |
| ------- | -------: |
| Main chunk | `index-ncgt6PBL.js` |
| Release | `20260715-1712` |
| Field | Value |
| ----- | ----- |
| Release | `20260715-2215` |
| Bundle | `index-AagIOJbd.js` |
| Commit | `16e7930` (via `/version.json`) |
## software/headless WebGL diagnostic baseline
**Не является характеристикой живой демонстрации.**
**Not a live-demo GPU characteristic.**
```text
Average FPS: 9.6
Minimum FPS: 3.3
p95 frame time: 200 ms
Average FPS: ~8.99.6
Renderer: SwiftShader
Status: BLOCKED_BY_DISPLAY_ENVIRONMENT on server
```
Повторено под xvfb/SwiftShader (~8.9 FPS demo) — см. `HEADED_GPU_PERFORMANCE_REPORT.md`.
## Replay geometries
## Headed / GPU tooling
Earlier probe showed +12 geometries after 3 replays under SwiftShader.
Re-run on production (`npm run perf:replay-stability`): **STABLE** (147 geos flat through 10 replays; +3 after GC pause only).
| Item | Status |
| ---- | ------ |
| `?perf=1` overlay | Implemented |
| `?quality=low\|medium\|high\|demo` | Implemented |
| `npm run perf:gpu` | Writes report + JSON |
| Hardware NVIDIA WebGL in coder | **Not available** (Chromium → SwiftShader) |
| True GPU baseline | Run on presentation laptop with DISPLAY + real GPU |
## Tooling
## Notes
- Demo quality keeps `shadows=false` for stable FPS.
- Do not quote SwiftShader FPS to jury as demo capability.
| Command | Purpose |
| ------- | ------- |
| `npm run perf:gpu` | Server/xvfb diagnostic |
| `npm run perf:browser` | Portable headed laptop benchmark |
| `npm run perf:replay-stability` | Geometry/heap plateau check |
| `/?perf=1` → Export benchmark | Local JSON download |

View File

@@ -2,71 +2,60 @@
Дата: 2026-07-15
Ветка: `feature/maximum-demo-realism`
Базовый commit этапа 1: `985f7c3`
Production bundle после этапа 2: `index-ncgt6PBL.js`
## Способ деплоя
Docker-контейнер `owl-web-1` (image `owl-web:<release>`), порт `127.0.0.1:3100→80`.
Docker API: `DOCKER_HOST=tcp://127.0.0.1:2375` (из coder-контейнера).
Compose-файл: `docker-compose.server.yml` (исторически `-p owl`).
Скрипт: `scripts/deploy-production.sh`.
Публичный доступ в этой среде:
- Loopback: `http://127.0.0.1:3100/`
- Quick tunnel cloudflared: `https://invitations-based-characters-accent.trycloudflare.com/`
- `https://arhipovdan.ru/` из контейнера даёт TLS SNI / openresty 404 — DNS/прокси домена вне текущего coder-окружения; проверяйте снаружи.
## Releases
## Current production
| Поле | Значение |
| ---- | -------- |
| Old release container | `owl-web-1-backup-20260715-165347` / `…-1712` |
| Old bundle | `index-CCdZzxPJ.js` |
| New release | `20260715-1712` |
| New image | `owl-web:20260715-1712` |
| New bundle | `index-ncgt6PBL.js` |
| Backup HTML | `releases/backup-pre-*` |
| Container | `owl-web-1` |
| Image | `owl-web:20260715-2215` |
| Bundle | `index-AagIOJbd.js` |
| Version endpoint | `/version.json` |
| Deployed commit (identity) | `16e7930` |
| Release id | `20260715-2215` |
| Publish | `127.0.0.1:3100→80` |
| Backup container | `owl-web-1-backup-20260715-2215` |
## Команды
## Previous
| Поле | Значение |
| ---- | -------- |
| Release | `20260715-1712` |
| Bundle | `index-ncgt6PBL.js` |
| Commit identity | *(pre-version.json)* |
## Public access
| URL | Status |
| --- | ------ |
| `http://127.0.0.1:3100/` | PASS — commit `16e7930` |
| Quick Tunnel | PASS — same commit (kept running) |
| `https://arhipovdan.ru/` | BLOCKED_EXTERNAL — openresty / TLS SNI |
## Deploy command
```bash
export DOCKER_HOST=tcp://127.0.0.1:2375
cd /home/coder/arhipovdan/app
npm test && npm run build
bash scripts/deploy-production.sh 20260715-1712
curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js'
EXPECTED_COMMIT=16e7930 bash scripts/deploy-production.sh 20260715-2215
```
## Healthcheck
| Check | Result |
| ----- | ------ |
| `GET /` | 200 |
| `GET /details` (SPA) | 200 |
| Bundle | `index-ncgt6PBL.js` |
| Tunnel HTML | same bundle |
| `https://arhipovdan.ru/` | **BLOCKED_EXTERNAL**`PUBLIC_DOMAIN_DIAGNOSTIC.md` |
## Public smoke
```bash
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e:production
PLAYWRIGHT_BASE_URL=https://invitations-based-characters-accent.trycloudflare.com/ npm run test:e2e:production
```
Build args inject `BUILD_COMMIT` / `BUILD_BRANCH` / `BUILD_RELEASE` because `.git` is dockerignored.
## Rollback
```bash
export DOCKER_HOST=tcp://127.0.0.1:2375
docker stop owl-web-1 && docker rm owl-web-1
docker rename owl-web-1-backup-20260715-1712 owl-web-1
docker rename owl-web-1-backup-20260715-2215 owl-web-1
docker start owl-web-1
```
Или восстановить HTML из `releases/backup-pre-<release>/`.
## Smoke
## Smoke (tunnel)
```bash
curl -s http://127.0.0.1:3100/version.json
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 EXPECTED_COMMIT=16e7930 npm run test:e2e:production
PLAYWRIGHT_BASE_URL=https://invitations-based-characters-accent.trycloudflare.com EXPECTED_COMMIT=16e7930 npm run test:e2e:production
```
Подтверждено: CASE 1/10, Jump to case 110, Previous/Next, Present, Play Demo, `/details`.
Both local and Quick Tunnel: **PASS**.

View File

@@ -9,7 +9,11 @@
BLOCKED_EXTERNAL
```
Постоянный домен `https://arhipovdan.ru/` **не обслуживается** текущим production-контейнером `owl-web-1`. Приложение на `127.0.0.1:3100` здорово и отдаёт актуальный bundle; проблема на уровне DNS → чужой openresty на публичном IP.
Постоянный домен `https://arhipovdan.ru/` **не** обслуживает `owl-web-1`.
Production на `:3100` и Quick Tunnel обновлены до commit `16e7930` (`/version.json`).
Named Tunnel: **не создан** из этого окружения — нет Cloudflare origin cert / API token.
См. `CLOUDFLARE_NAMED_TUNNEL_SETUP.md` и `DNS_MIGRATION_INVENTORY.md`.
## DNS

View File

@@ -0,0 +1,21 @@
# REPLAY_RESOURCE_STABILITY
Measured: 2026-07-15T22:18:59.976Z
Verdict: **STABLE**
| Cycle | Heap MB | Geometries | Textures | Programs | Canvas |
| ----: | ------: | ---------: | -------: | -------: | -----: |
| 0 (initial) | 14.5 | 147 | 1 | 4 | 1 |
| 1 (after_1_replays) | 14.5 | 147 | 1 | 4 | 1 |
| 3 (after_3_replays) | 14.5 | 147 | 1 | 4 | 1 |
| 5 (after_5_replays) | 14.5 | 147 | 1 | 4 | 1 |
| 10 (after_10_replays) | 14.5 | 147 | 1 | 4 | 1 |
| 10 (after_gc_pause) | 14.5 | 150 | 1 | 4 | 1 |
## Interpretation
- **STABLE** — no meaningful growth.
- **LAZY_ALLOCATION_PLATEAU** — early growth then flat (expected for first-seen assets).
- **POSSIBLE_LEAK / CONFIRMED_LEAK** — investigate dispose paths.
Raw: `agent/reports/replay-stability.json`

View File

@@ -1,10 +1,10 @@
# Реестр рисков (Risk Register)
**Проект:** OZON Tech Sorter Simulation
**Дата:** 2026-07-15
**Проект:** OZON Tech Sorter Simulation
**Дата:** 2026-07-15
**Ветка:** `feature/maximum-demo-realism`
Шкала: вероятность / влияние = Low · Medium · High · Critical.
Шкала: вероятность / влияние = Low · Medium · High · Critical.
Статус: Open · Mitigated · Accepted.
---
@@ -13,7 +13,10 @@
| ID | Риск | Вероятность | Влияние | Статус | Митигация |
| -- | ---- | ----------- | ------- | ------ | --------- |
| R01 | Production :3100 / https://arhipovdan.ru/ отдаёт старый dist / домен 404 | High | Critical | Open | Local+tunnel PASS; domain BLOCKED_EXTERNAL`PUBLIC_DOMAIN_DIAGNOSTIC.md` |
| R01 | Production :3100 / arhipovdan.ru | Medium | Critical | Partially mitigated | Local+tunnel on `16e7930`+version.json; domain still BLOCKED_EXTERNAL |
| R18 | Tunnel/cloudflared outage / permanent DNS | High | Critical | Open | Quick Tunnel kept; Named Tunnel needs CF login — `CLOUDFLARE_NAMED_TUNNEL_SETUP.md` |
| R21 | Hardware GPU baseline недоступен в coder | High | Medium | Accepted | `BLOCKED_BY_DISPLAY_ENVIRONMENT`; portable `perf:browser` |
| R22 | DNS NS cutover теряет MX/TXT | Medium | High | Open | `DNS_MIGRATION_INVENTORY.md` — check REG.RU panel before NS change |
| R02 | Визуальный разрыв `/` vs `/details` путает жюри | Medium | High | Mitigated | `sharedLayout` + routeConsistency tests |
| R03 | Жюри воспринимает кинематику как «фейк» | Medium | High | Mitigated | Proof HUD + честные формулировки; jam/e-stop |
| R04 | Pseudo-CV раскрыт как «обман» | Medium | Medium | Accepted | RULE overlay + confidence; не обещать ML |
@@ -40,15 +43,15 @@
### R01 — Устаревший production dist
**Симптом:** локально proof/hotkeys есть, на https://arhipovdan.ru/ — нет.
**Детектор:** сравнить UI; `demo:health`; hash файлов в контейнере (если доступен docker на хосте).
**Реакция:** ручной redeploy; на показе переключиться на проверенный preview.
**Симптом:** локально proof/hotkeys есть, на https://arhipovdan.ru/ — нет.
**Детектор:** сравнить UI; `demo:health`; hash файлов в контейнере (если доступен docker на хосте).
**Реакция:** ручной redeploy; на показе переключиться на проверенный preview.
**Тип данных о риске:** подтверждён аудитом как **процессный** факт («may still serve OLD dist»).
### R09 — Автономный агент vs production
**Симптом:** гипотетический merge/deploy без человека.
**Текущий контроль:** `forbidMergeToMain`, `forbidProductionDeploy`, нет Implementer auto-patch.
**Симптом:** гипотетический merge/deploy без человека.
**Текущий контроль:** `forbidMergeToMain`, `forbidProductionDeploy`, нет Implementer auto-patch.
**Остаточный риск:** появится при расширении MVP без обновления safety caps.
### R10 — Секреты
@@ -93,11 +96,11 @@
## 6. Триггеры пересмотра реестра
- Смена хоста/GPU/RAM.
- Включение Implementer auto-patch.
- Добавление physics engine.
- Подключение реального CV inference.
- Появление CI e2e.
- Смена хоста/GPU/RAM.
- Включение Implementer auto-patch.
- Добавление physics engine.
- Подключение реального CV inference.
- Появление CI e2e.
- Инцидент на живом показе.
---

View File

@@ -1,13 +1,13 @@
import { test, expect } from '@playwright/test';
/**
* Production smoke — run manually:
* PLAYWRIGHT_BASE_URL=https://arhipovdan.ru/ npm run test:e2e:production
* or against the working tunnel / local prod:
* PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run test:e2e:production
* Production smoke — manual only:
* PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 EXPECTED_COMMIT=16e7930 npm run test:e2e:production
*/
test.describe('production smoke @production', () => {
test('serves current app shell and demo controls', async ({ page }) => {
test('version, routes, controls, safety, webgl', async ({ page }) => {
test.setTimeout(120_000);
const expectedCommit = process.env.EXPECTED_COMMIT?.trim();
const pageErrors: string[] = [];
const failed: string[] = [];
page.on('pageerror', (e) => pageErrors.push(String(e)));
@@ -23,22 +23,94 @@ test.describe('production smoke @production', () => {
expect(bundle).toMatch(/^index-/);
expect(bundle).not.toBe('index-CCdZzxPJ.js');
const version = await page.evaluate(async () => {
const r = await fetch('/version.json', { cache: 'no-store' });
if (!r.ok) return null;
return r.json();
});
expect(version, 'version.json missing').toBeTruthy();
expect(version.commit).toMatch(/^[0-9a-f]{7,40}$/i);
if (expectedCommit) {
expect(version.commit).toBe(expectedCommit);
}
await expect(page.locator('canvas')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('demo-play')).toBeVisible();
const webgl = await page.evaluate(() => {
const c = document.createElement('canvas');
const gl = c.getContext('webgl2') || c.getContext('webgl');
if (!gl) return { ok: false, renderer: null as string | null };
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
return {
ok: true,
renderer: dbg
? (gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string)
: (gl.getParameter(gl.RENDERER) as string),
};
});
expect(webgl.ok).toBe(true);
await expect(page.getByTestId('demo-play').or(page.getByTestId('demo-pause'))).toBeVisible();
await expect(page.getByTestId('demo-case-9')).toBeVisible();
await page.getByTestId('demo-play').click();
await page.waitForTimeout(800);
// Play / seek
const pauseBtn = page.getByTestId('demo-pause');
if (!(await pauseBtn.isVisible().catch(() => false))) {
await page.getByTestId('demo-play').click();
}
await page.waitForTimeout(600);
await page.getByTestId('demo-case-0').click();
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
// Jam
await page.getByTestId('demo-speed-2').click();
await page.getByTestId('demo-case-8').click();
await expect(page.getByTestId('demo-case-label')).toHaveText('9/10');
await expect
.poll(async () => {
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
return `${status} ${command}`;
}, { timeout: 30_000 })
.toMatch(/FAULT|JAM/i);
// E-stop
await page.getByTestId('demo-case-9').click();
await expect
.poll(async () => {
const status = (await page.getByTestId('demo-status').textContent()) ?? '';
const command = (await page.getByTestId('demo-command').textContent()) ?? '';
return `${status} ${command}`;
}, { timeout: 30_000 })
.toMatch(/EMERGENCY/i);
// Reset / recover toward case 0
const stop = page.getByTestId('demo-stop');
if (await stop.isVisible().catch(() => false)) {
await stop.click({ force: true });
}
await page.getByTestId('demo-case-0').click({ force: true });
await expect(page.getByTestId('demo-case-label')).toHaveText('1/10');
await page.goto('/details');
await expect(page.locator('#root')).toBeVisible();
await page.reload();
await expect(page.locator('#root')).toBeVisible();
// Back home — version still matches
await page.goto('/');
const version2 = await page.evaluate(async () => {
const r = await fetch('/version.json', { cache: 'no-store' });
return r.ok ? r.json() : null;
});
expect(version2?.commit).toBe(version.commit);
expect(pageErrors, pageErrors.join('\n')).toEqual([]);
const critical = failed.filter((f) => !f.includes('favicon'));
expect(critical, critical.join('\n')).toEqual([]);
// Attach renderer for report (not a hard fail if software)
test.info().annotations.push({
type: 'webgl-renderer',
description: webgl.renderer ?? 'unknown',
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html;
index index.html;
location = /version.json {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -21,7 +21,10 @@
"demo:start": "bash scripts/demo-start.sh",
"demo:stop": "bash scripts/demo-stop.sh",
"demo:health": "bash scripts/demo-health.sh",
"perf:gpu": "node scripts/headed-gpu-baseline.mjs"
"perf:gpu": "node scripts/headed-gpu-baseline.mjs",
"perf:browser": "node scripts/portable-browser-benchmark.mjs",
"perf:browser:export": "node scripts/portable-browser-benchmark.mjs --export",
"perf:replay-stability": "node scripts/replay-stability.mjs"
},
"dependencies": {
"@react-three/drei": "^10.7.7",

View File

@@ -1 +1 @@
index-ncgt6PBL.js
index-AagIOJbd.js

View File

@@ -0,0 +1 @@
16e7930

View File

@@ -1 +1 @@
20260715-1712
20260715-2215

View File

@@ -0,0 +1,6 @@
{
"commit": "16e7930",
"branch": "feature/maximum-demo-realism",
"builtAt": "2026-07-15T22:17:42.644Z",
"release": "20260715-2215"
}

View File

@@ -13,12 +13,21 @@ echo "[deploy] release=$RELEASE"
OLD_BUNDLE=$(curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' | head -1 || true)
echo "[deploy] old bundle: ${OLD_BUNDLE:-unknown}"
BUILD_COMMIT="${BUILD_COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo unknown)}"
BUILD_BRANCH="${BUILD_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)}"
export BUILD_COMMIT BUILD_BRANCH
echo "[deploy] commit=$BUILD_COMMIT branch=$BUILD_BRANCH"
if docker ps --format '{{.Names}}' | grep -qx owl-web-1; then
docker cp owl-web-1:/usr/share/nginx/html/. "$BACKUP_DIR/" || true
fi
echo "[deploy] building image..."
docker build -t "owl-web:${RELEASE}" -t owl-web:latest .
docker build \
--build-arg "BUILD_COMMIT=${BUILD_COMMIT}" \
--build-arg "BUILD_BRANCH=${BUILD_BRANCH}" \
--build-arg "BUILD_RELEASE=${RELEASE}" \
-t "owl-web:${RELEASE}" -t owl-web:latest .
if docker ps -a --format '{{.Names}}' | grep -qx owl-web-1; then
NET=$(docker inspect owl-web-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo owl_default)
@@ -40,10 +49,22 @@ sleep 2
NEW_BUNDLE=$(curl -s http://127.0.0.1:3100/ | grep -Eo 'index-[A-Za-z0-9_-]+\.js' | head -1 || true)
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/)
DETAILS=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/details)
VERSION_JSON=$(curl -s http://127.0.0.1:3100/version.json || true)
VERSION_COMMIT=$(printf '%s' "$VERSION_JSON" | grep -Eo '"commit"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)
echo "[deploy] http=$CODE details=$DETAILS bundle=$NEW_BUNDLE"
echo "[deploy] http=$CODE details=$DETAILS bundle=$NEW_BUNDLE version.commit=${VERSION_COMMIT:-missing}"
if [[ "$CODE" != "200" || -z "$NEW_BUNDLE" || "$NEW_BUNDLE" == "$OLD_BUNDLE" && "$OLD_BUNDLE" == "index-CCdZzxPJ.js" ]]; then
HEALTH_OK=1
[[ "$CODE" == "200" ]] || HEALTH_OK=0
[[ -n "$NEW_BUNDLE" ]] || HEALTH_OK=0
[[ "$DETAILS" == "200" ]] || HEALTH_OK=0
[[ -n "$VERSION_COMMIT" && "$VERSION_COMMIT" != "unknown" ]] || HEALTH_OK=0
if [[ -n "${EXPECTED_COMMIT:-}" && "$VERSION_COMMIT" != "$EXPECTED_COMMIT" ]]; then
echo "[deploy] expected commit $EXPECTED_COMMIT but got ${VERSION_COMMIT:-empty}"
HEALTH_OK=0
fi
if [[ "$HEALTH_OK" != "1" ]]; then
echo "[deploy] health failed — attempting rollback"
docker stop owl-web-1 && docker rm owl-web-1 || true
if docker ps -a --format '{{.Names}}' | grep -qx "owl-web-1-backup-${RELEASE}"; then
@@ -55,5 +76,7 @@ fi
echo "$RELEASE" > releases/CURRENT_RELEASE.txt
echo "$NEW_BUNDLE" > releases/CURRENT_BUNDLE.txt
echo "$VERSION_COMMIT" > releases/CURRENT_COMMIT.txt
printf '%s\n' "$VERSION_JSON" > releases/CURRENT_VERSION.json
echo "[deploy] SUCCESS"
echo "Rollback: docker stop owl-web-1 && docker rm owl-web-1 && docker rename owl-web-1-backup-${RELEASE} owl-web-1 && docker start owl-web-1"

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* Portable headed browser benchmark for the presentation laptop.
* Uses the system Chrome/Chromium channel when available — no forced GPU flags.
*
* npm run perf:browser
* npm run perf:browser:export
*
* Env: PLAYWRIGHT_BASE_URL (default http://127.0.0.1:3100)
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { chromium } from 'playwright';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const MODES = ['low', 'medium', 'high', 'demo'];
const exportOnly = process.argv.includes('--export') || process.env.PERF_EXPORT === '1';
function baseUrl() {
const raw = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3100';
return new URL(raw.includes('://') ? raw : `http://${raw}`);
}
function urlFor(mode) {
const u = baseUrl();
u.searchParams.set('perf', '1');
u.searchParams.set('quality', mode);
return u.toString();
}
function isSoft(renderer = '') {
return /swiftshader|llvmpipe|softpipe|software|mesa offscreen|microsoft basic render/i.test(
renderer,
);
}
async function sample(page) {
return (
(await page.evaluate(() => {
const s = window.__PERF_SNAPSHOT__;
return s ? { ...s } : null;
})) || {
mode: 'demo',
renderer: 'unknown',
averageFps: 0,
minimumFps: 0,
p95FrameTimeMs: 0,
p99FrameTimeMs: 0,
longFramesOver33: 0,
longFramesOver50: 0,
drawCalls: 0,
triangles: 0,
geometries: 0,
textures: 0,
programs: 0,
heapMb: 0,
dpr: 1,
shadows: false,
antialias: false,
hardwareAccelerated: false,
}
);
}
async function measureMode(browser, mode, notes) {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await page.goto(urlFor(mode), { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForSelector('canvas', { timeout: 60_000 });
await page.waitForTimeout(2000);
await page.evaluate(() => window.__PERF_RESET__?.());
await page.waitForTimeout(2500);
const idle = await sample(page);
await page.evaluate(() => window.__PERF_RESET__?.());
await page.getByTestId('demo-play').click({ timeout: 8_000 }).catch(() => notes.push(`${mode}: play skipped`));
await page.waitForTimeout(5000);
const play = await sample(page);
// Full-ish playlist: jump through cases quickly
for (const idx of [0, 2, 4, 8, 9]) {
await page.getByTestId(`demo-case-${idx}`).click().catch(() => undefined);
await page.waitForTimeout(900);
}
const afterPlaylist = await sample(page);
// Three replays (Replay Demo overlay or seek case 0 + play)
for (let i = 0; i < 3; i++) {
const finished = page.getByTestId('demo-finished');
if (await finished.isVisible().catch(() => false)) {
await finished.getByRole('button', { name: /Replay/i }).click().catch(() => undefined);
} else {
await page.getByTestId('demo-case-0').click().catch(() => undefined);
await page.getByTestId('demo-play').click().catch(() => undefined);
}
await page.waitForTimeout(1500);
}
const afterReplay = await sample(page);
const meta = await page.evaluate(() => ({
userAgent: navigator.userAgent,
viewport: { width: window.innerWidth, height: window.innerHeight },
webgl: (() => {
const c = document.createElement('canvas');
const gl = c.getContext('webgl2') || c.getContext('webgl');
if (!gl) return { ok: false };
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
return {
ok: true,
vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null,
renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
};
})(),
}));
return {
mode,
idle,
play,
afterPlaylist,
afterReplay,
meta,
software: isSoft(play.renderer || meta.webgl?.renderer || ''),
};
} finally {
await page.close();
}
}
function toMarkdown(report) {
const rows = report.modes
.map((m) => {
const s = m.play;
return `| ${m.mode} | ${s.renderer} | ${s.averageFps} | ${s.minimumFps} | ${s.p95FrameTimeMs} | ${s.drawCalls} | ${s.triangles} | ${s.heapMb} |`;
})
.join('\n');
const soft = report.softwareRendererDetected;
return `# HARDWARE_BROWSER_BENCHMARK
Measured: ${report.measuredAt}
Commit probe URL: ${report.baseUrl}
Browser: ${report.browserVersion}
Headed: yes (no forced GPU flags)
Software renderer: **${soft ? 'YES' : 'no'}**
## Mode table (after Play)
| Mode | Renderer | Avg FPS | Min FPS | p95 | Calls | Triangles | Heap |
| ---- | -------- | ------: | ------: | --: | ----: | --------: | ---: |
${rows}
## Notes
${report.notes.map((n) => `- ${n}`).join('\n')}
Raw JSON: \`agent/reports/browser-benchmark.json\`
`;
}
async function main() {
const notes = [];
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
notes.push(
'No DISPLAY — attempting headed launch may fail. Run on the presentation laptop with a real display.',
);
}
let browser;
try {
browser = await chromium.launch({
headless: false,
channel: process.env.PERF_CHROME_CHANNEL || undefined,
// Intentionally NO GPU override flags — measure the machine as the jury sees it.
});
} catch (err) {
notes.push(`channel launch failed (${err.message}); falling back to bundled Chromium headed`);
browser = await chromium.launch({ headless: false });
}
const modes = [];
try {
for (const mode of MODES) {
modes.push(await measureMode(browser, mode, notes));
}
} finally {
await browser.close();
}
const commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: ROOT,
encoding: 'utf8',
}).stdout.trim();
const report = {
measuredAt: new Date().toISOString(),
commit,
baseUrl: baseUrl().toString(),
browserVersion: modes[0]?.meta?.userAgent ?? 'unknown',
softwareRendererDetected: modes.some((m) => m.software),
modes,
notes,
};
mkdirSync(join(ROOT, 'agent', 'reports'), { recursive: true });
const jsonPath = join(ROOT, 'agent', 'reports', 'browser-benchmark.json');
const mdPath = join(ROOT, 'docs', 'HARDWARE_BROWSER_BENCHMARK_LATEST.md');
writeFileSync(jsonPath, JSON.stringify(report, null, 2));
writeFileSync(mdPath, toMarkdown(report));
console.log(
JSON.stringify(
{
ok: true,
exportOnly,
softwareRendererDetected: report.softwareRendererDetected,
demoFps: modes.find((m) => m.mode === 'demo')?.play.averageFps,
renderer: modes.find((m) => m.mode === 'demo')?.play.renderer,
jsonPath,
mdPath,
},
null,
2,
),
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Replay resource stability probe — geometries/textures/programs/heap across N replays.
*
* PLAYWRIGHT_BASE_URL=http://127.0.0.1:3100 npm run perf:replay-stability
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const CYCLES = [0, 1, 3, 5, 10]; // cumulative replay counts to sample after
function targetUrl() {
const raw = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3100';
const u = new URL(raw.includes('://') ? raw : `http://${raw}`);
u.searchParams.set('perf', '1');
u.searchParams.set('quality', 'demo');
return u.toString();
}
async function snap(page) {
return page.evaluate(() => {
const s = window.__PERF_SNAPSHOT__;
const canvasCount = document.querySelectorAll('canvas').length;
return {
at: new Date().toISOString(),
geometries: s?.geometries ?? null,
textures: s?.textures ?? null,
programs: s?.programs ?? null,
heapMb: s?.heapMb ?? null,
drawCalls: s?.drawCalls ?? null,
triangles: s?.triangles ?? null,
renderer: s?.renderer ?? null,
canvasCount,
};
});
}
async function dismissFinished(page) {
const finished = page.getByTestId('demo-finished');
if (await finished.isVisible().catch(() => false)) {
await finished.getByRole('button', { name: /Replay/i }).click();
await page.waitForTimeout(500);
}
}
async function doReplay(page) {
await dismissFinished(page);
await page.getByTestId('demo-case-0').click().catch(() => undefined);
await page.waitForTimeout(200);
const pause = page.getByTestId('demo-pause');
if (!(await pause.isVisible().catch(() => false))) {
await page.getByTestId('demo-play').click().catch(() => undefined);
}
await page.waitForTimeout(1200);
}
function classify(rows) {
const geos = rows.map((r) => r.geometries).filter((n) => typeof n === 'number');
if (geos.length < 3) return 'INCONCLUSIVE';
const first = geos[0];
const last = geos[geos.length - 1];
const mid = geos[Math.floor(geos.length / 2)];
// plateau: late samples within +2 of mid
const late = geos.slice(-2);
const plateau = late.every((g) => Math.abs(g - mid) <= 2);
const monotonic = geos.every((g, i) => i === 0 || g >= geos[i - 1]);
const growth = last - first;
if (plateau && growth <= 20) return 'LAZY_ALLOCATION_PLATEAU';
if (monotonic && growth > 20 && !plateau) return 'POSSIBLE_LEAK';
if (growth > 50) return 'CONFIRMED_LEAK';
if (growth <= 5) return 'STABLE';
return plateau ? 'LAZY_ALLOCATION_PLATEAU' : 'POSSIBLE_LEAK';
}
async function main() {
const headed = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
const browser = await chromium.launch({
headless: !headed,
args: ['--enable-webgl', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const samples = [];
try {
await page.goto(targetUrl(), { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForSelector('canvas', { timeout: 60_000 });
await page.waitForTimeout(3000);
samples.push({ cycle: 0, label: 'initial', ...(await snap(page)) });
let done = 0;
for (const target of CYCLES.slice(1)) {
while (done < target) {
await doReplay(page);
done += 1;
}
samples.push({ cycle: target, label: `after_${target}_replays`, ...(await snap(page)) });
}
// GC pause
await page.evaluate(() => {
// @ts-expect-error
if (typeof gc === 'function') gc();
}).catch(() => undefined);
await page.waitForTimeout(3000);
samples.push({ cycle: 10, label: 'after_gc_pause', ...(await snap(page)) });
} finally {
await browser.close();
}
const verdict = classify(samples);
const report = {
measuredAt: new Date().toISOString(),
url: targetUrl(),
headed,
verdict,
samples,
};
mkdirSync(join(ROOT, 'agent', 'reports'), { recursive: true });
writeFileSync(join(ROOT, 'agent', 'reports', 'replay-stability.json'), JSON.stringify(report, null, 2));
const md = `# REPLAY_RESOURCE_STABILITY
Measured: ${report.measuredAt}
Verdict: **${verdict}**
| Cycle | Heap MB | Geometries | Textures | Programs | Canvas |
| ----: | ------: | ---------: | -------: | -------: | -----: |
${samples
.map(
(s) =>
`| ${s.cycle} (${s.label}) | ${s.heapMb ?? '—'} | ${s.geometries ?? '—'} | ${s.textures ?? '—'} | ${s.programs ?? '—'} | ${s.canvasCount ?? '—'} |`,
)
.join('\n')}
## Interpretation
- **STABLE** — no meaningful growth.
- **LAZY_ALLOCATION_PLATEAU** — early growth then flat (expected for first-seen assets).
- **POSSIBLE_LEAK / CONFIRMED_LEAK** — investigate dispose paths.
Raw: \`agent/reports/replay-stability.json\`
`;
writeFileSync(join(ROOT, 'docs', 'REPLAY_RESOURCE_STABILITY.md'), md);
console.log(JSON.stringify({ ok: true, verdict, samples: samples.length }, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
export interface VersionInfo {
commit: string;
branch: string;
builtAt: string;
release: string;
}
/**
* Engineering-only build chip. Hidden in presentation mode.
* Visible with ?perf=1 or ?build=1.
*/
export default function BuildIdentityBadge() {
const [info, setInfo] = useState<VersionInfo | null>(null);
const [show, setShow] = useState(false);
const [inPresentation, setInPresentation] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setShow(params.get('perf') === '1' || params.get('build') === '1');
}, []);
useEffect(() => {
if (!show) return;
let cancelled = false;
fetch('/version.json', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (!cancelled && j?.commit) setInfo(j as VersionInfo);
})
.catch(() => undefined);
const id = window.setInterval(() => {
setInPresentation(!!document.querySelector('.presentation-mode'));
}, 500);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [show]);
if (!show || !info || inPresentation) return null;
return (
<div className="build-identity" data-testid="build-identity" title={`${info.branch} · ${info.builtAt}`}>
<span className="build-identity-label">build</span>
<span className="build-identity-commit">{info.commit}</span>
<span className="build-identity-release">{info.release}</span>
</div>
);
}

View File

@@ -49,6 +49,22 @@ export default function PerfOverlay({ enabled }: PerfOverlayProps) {
const soft = !snap.hardwareAccelerated;
const exportBenchmark = () => {
const payload = {
exportedAt: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent,
viewport: { width: window.innerWidth, height: window.innerHeight },
snapshot: snap,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `sorter-benchmark-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<div
className="perf-overlay"
@@ -86,6 +102,14 @@ export default function PerfOverlay({ enabled }: PerfOverlayProps) {
{soft ? 'SW' : 'GPU'} {snap.renderer.slice(0, 42)}
{snap.renderer.length > 42 ? '…' : ''}
</div>
<button
type="button"
className="perf-export-btn"
data-testid="perf-export"
onClick={exportBenchmark}
>
Export benchmark
</button>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { getMeasurementData, shouldShowMeasurement } from '../domain/measurement
import { getViewportType, type ViewportType } from '../domain/cinematicCamera';
import { detectQualityMode } from '../domain/qualityMode';
import { resolveItem } from '../data/resolveItem';
import BuildIdentityBadge from '../components/BuildIdentityBadge';
import CVInspectionOverlay from '../components/CVInspectionOverlay';
const SorterDigitalTwinContinuous = lazy(() => import('../components/ThreeD/SorterDigitalTwinContinuous'));
@@ -391,6 +392,8 @@ export default function MainPage({
Exit presentation (P)
</button>
)}
<BuildIdentityBadge />
</div>
);
}

View File

@@ -2992,7 +2992,7 @@ button:disabled {
color: #e8f0f7;
background: rgba(8, 14, 22, 0.78);
border: 1px solid rgba(120, 160, 200, 0.35);
pointer-events: none;
pointer-events: auto;
user-select: none;
}
.perf-overlay-title {
@@ -3004,7 +3004,39 @@ button:disabled {
.perf-overlay .perf-muted { color: rgba(200, 220, 240, 0.55); }
.perf-overlay .perf-soft { color: #f0b4a0; margin-top: 4px; }
.perf-overlay .perf-hw { color: #a8e0b8; margin-top: 4px; }
.main-page.presentation-mode .perf-overlay { display: none; }
.perf-export-btn {
margin-top: 8px;
width: 100%;
padding: 4px 6px;
font: inherit;
font-size: 10px;
color: #0b1220;
background: #9fd0ff;
border: none;
cursor: pointer;
}
.perf-export-btn:hover { background: #c5e4ff; }
.build-identity {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 35;
display: flex;
gap: 8px;
align-items: baseline;
padding: 4px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
color: #c8d8e8;
background: rgba(8, 14, 22, 0.7);
border: 1px solid rgba(120, 160, 200, 0.3);
pointer-events: none;
}
.build-identity-label { text-transform: uppercase; letter-spacing: 0.08em; color: #7aa8cc; }
.build-identity-commit { color: #e8f4ff; font-weight: 600; }
.build-identity-release { color: rgba(200, 220, 240, 0.55); }
.main-page.presentation-mode .perf-overlay,
.main-page.presentation-mode .build-identity { display: none; }
.main-page.presentation-mode .main-hud,
.main-page.presentation-mode .main-progress,

View File

@@ -1,8 +1,71 @@
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
import { execSync } from 'node:child_process';
import { writeFileSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
export interface BuildVersionInfo {
commit: string;
branch: string;
builtAt: string;
release: string;
}
function git(cmd: string): string {
try {
return execSync(cmd, { encoding: 'utf8' }).trim();
} catch {
return '';
}
}
export function resolveBuildVersion(): BuildVersionInfo {
const commit =
process.env.VITE_BUILD_COMMIT ||
process.env.BUILD_COMMIT ||
git('git rev-parse --short HEAD') ||
'unknown';
const branch =
process.env.VITE_BUILD_BRANCH ||
process.env.BUILD_BRANCH ||
git('git rev-parse --abbrev-ref HEAD') ||
'unknown';
const release =
process.env.VITE_BUILD_RELEASE ||
process.env.BUILD_RELEASE ||
process.env.RELEASE ||
'';
return {
commit: commit.replace(/^v/, '').slice(0, 40),
branch,
builtAt: new Date().toISOString(),
release: release || new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14),
};
}
/** Emits /version.json into dist (and public during build) — no secrets/paths. */
function versionJsonPlugin(): Plugin {
return {
name: 'emit-version-json',
apply: 'build',
buildStart() {
const info = resolveBuildVersion();
const publicDir = resolve(process.cwd(), 'public');
mkdirSync(publicDir, { recursive: true });
writeFileSync(resolve(publicDir, 'version.json'), `${JSON.stringify(info, null, 2)}\n`);
},
closeBundle() {
const info = resolveBuildVersion();
const out = resolve(process.cwd(), 'dist', 'version.json');
writeFileSync(out, `${JSON.stringify(info, null, 2)}\n`);
// eslint-disable-next-line no-console
console.log(`[version] ${info.commit} @ ${info.branch} release=${info.release}`);
},
};
}
export default defineConfig({
plugins: [react()],
plugins: [react(), versionJsonPlugin()],
server: {
host: '127.0.0.1',
port: 3100,