diff --git a/.env.example b/.env.example index 29fea43..fb005dc 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,15 @@ UDP_PORT=20777 HTTP_PORT=3001 ANTHROPIC_API_KEY= + +# Demo mode (see README, "Demo mode"). Leave DEMO_MODE unset for local mode. +# In demo mode the three ports above are not read, and ANTHROPIC_API_KEY and +# DB_PATH must not be set: the server refuses to start with either. +# DEMO_MODE=true +# PORT=10000 +# DEMO_ALLOWED_ORIGIN=http://localhost:5173 +# DEMO_MAX_CONNECTIONS=5 +# DEMO_MAX_CONNECTION_MINUTES=5 +# DEMO_WS_COMPRESSION=true +# DEMO_RECORDING_PATH=recordings/demo-interlagos-tt-20hz-laps6-8.jsonl +# DEMO_SEED_PATH=demo/coach-seed.json diff --git a/CLAUDE.md b/CLAUDE.md index ddd5f47..c056ed1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Backend that ingests the F1 24 game's UDP telemetry, persists it to SQLite, stre ## Stack -- Node.js: major 22 pinned in `.nvmrc`, minimum 22.17 declared in `engines`. TypeScript (CommonJS, `strict`), run with `ts-node-dev` (no build step yet) +- Node.js: major 22 pinned in `.nvmrc`, minimum 22.17 declared in `engines`. TypeScript (CommonJS, `strict`), run with `ts-node-dev` in development; `npm run build` compiles to `dist/` with its own `tsconfig.build.json` and `npm start` runs the compiled server - `dgram` (UDP), `ws` (WebSocket), Express 5 (REST), `better-sqlite3` (SQLite, WAL) - `@anthropic-ai/sdk` for the coach - Tests: Node's built-in runner (`node:test` + `node:assert/strict`) via `ts-node/register` @@ -19,12 +19,15 @@ npm test # all src/**/*.test.ts, found by N npm run record # writes recordings/session-.jsonl npm run replay -- recordings/.jsonl [--speed=1x|2x|max] [--loop] # re-sends a recording to 127.0.0.1:UDP_PORT; default 1x, no loop npm run analyze -- # prints the LapInsights JSON for two laps, no AI call +npm run export-coach-seed # PAID, the maintainer's only: writes demo/coach-seed.json; needs DB_PATH=:memory: (or a temp path) and her key; the agent never runs it npm run typecheck # type-check, covers src/ and tools/ (same as npx tsc --noEmit) +npm run build # compiles src/ and tools/ to dist/ (tsconfig.build.json, tests excluded), then downloads and verifies the demo recording (tools/fetchDemoRecording.ts) +npm start # runs the compiled server, dist/src/server.js ``` -There are no `build`, `start` or `lint` scripts and no ESLint config yet. +There is no `lint` script and no ESLint config yet. The CI runs `typecheck` and `test`, not `build`: `tsconfig.build.json` is not checked by it. -Configuration comes from `.env` (copy `.env.example`): `WS_PORT`, `UDP_PORT`, `HTTP_PORT` are required and validated at startup by the server, `record` and `replay`; `analyze` and the tests do not need them, only the database path (below); `ANTHROPIC_API_KEY` is optional. The database path is fixed in production: `data/telemetry.db`. An optional `DB_PATH` environment variable overrides it — tests and manual verification use this to point at a temporary database, never at the real one (see Rules). +Configuration comes from `.env` (copy `.env.example`): `WS_PORT`, `UDP_PORT`, `HTTP_PORT` are required and validated at startup by the server, `record` and `replay`; `analyze` and the tests do not need them, only the database path (below); `ANTHROPIC_API_KEY` is optional. Demo mode (`DEMO_MODE=true`, spec 003, partial on its branch: the server runs end to end, but no coach seed is committed yet, so it refuses to start without `DEMO_SEED_PATH`; README "Demo mode") does not read the three ports: it validates `PORT`, `DEMO_ALLOWED_ORIGIN` and its optional settings (`src/config/demoEnv.ts`, table in README "Demo mode") and refuses to start with `ANTHROPIC_API_KEY` or `DB_PATH` set; any `DEMO_MODE` value other than `true` or empty stops the server. The database path is fixed in production: `data/telemetry.db`, resolved from the project root (the folder holding `package.json`, `src/config/projectRoot.ts`), so the compiled server under `dist/` uses the same file. An optional `DB_PATH` environment variable overrides it — tests and manual verification use this to point at a temporary database, never at the real one (see Rules). In demo mode the database is always `:memory:` (`src/config/dbPath.ts`). ## Architecture @@ -33,19 +36,20 @@ Flow: `network/udpListener` → `telemetry/dispatcher` (parse) → `persistence/ | Path | Responsibility | |---|---| | `src/server.ts` | Entry point: wires UDP, WS and HTTP; per-packet try/catch so a malformed packet never kills the process | -| `src/config/` | `env.ts` loads and validates the ports with fail-fast; `dbPath.ts` resolves the database path (including `DB_PATH`) and is the only config file the persistence layer imports, so persistence does not depend on the ports | +| `src/config/` | `env.ts` loads and validates the ports with fail-fast (local mode only); `dbPath.ts` resolves the database path (including `DB_PATH`, and `:memory:` in demo mode) and is the only config file the persistence layer imports, so persistence does not depend on the ports; `mode.ts` reads `DEMO_MODE`; `demoEnv.ts` validates the demo settings; `projectRoot.ts` finds the folder holding `package.json` | | `src/network/` | Transport only: UDP socket, WebSocket server with ping/pong heartbeat | | `src/telemetry/` | Binary parsing of the F1 24 protocol, one folder per packet type (`header`, `motion`, `lapData`, `carTelemetry`, `session`), shared `BufferCursor`, sizes and `assertBytesRead` in `shared/` | | `src/ws/` | WebSocket message envelope | | `src/persistence/` | SQLite schema, per-session in-memory context, decimation, lap completion detection, repositories (one per table); sample tables deduplicate on `(session_id, overall_frame_identifier)` — that field never resets or regresses within a session, including across a flashback, unlike session time or lap distance | | `src/analysis/` | Pure functions, no I/O: corner detection, braking/throttle, delta, line deviation, tyre windows → `buildInsights` | | `src/coaching/` | Static system prompt, user-message builder, Anthropic call with typed errors | -| `src/http/` | Express app and routers (`sessionsRouter`, `analysisRouter`) | +| `src/http/` | Express app and routers (`sessionsRouter`, `analysisRouter`, `healthRouter`); `createApp(options)` builds the same app for both modes, adding the demo's CORS to one origin and cache headers in demo mode | +| `src/demo/` | Everything only demo mode runs: recording check, in-process session build, capture times, coach seed validation and export, single-car message, the demo WebSocket server (origin, capacity, time limit, compression) and its log, and `demoServer.ts`, the start sequence. Pure modules here never load `persistence/db` (`purityBoundary.test.ts`); the writing ones live in their own modules | | `src/replay/` | Pure replay pacing (`computeDelayMs`) and orchestration (`runReplay`): speed, loop, absolute-deadline scheduling | | `src/tooling/` | Tests of the project's own tooling: the test of the empty-discovery guard | -| `tools/` | CLI scripts: recorder, analyze, a thin replayer CLI over `src/replay/`, and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | +| `tools/` | CLI scripts: recorder, analyze, a thin replayer CLI over `src/replay/`, `fetchDemoRecording.ts` (the last step of `npm run build`: downloads and verifies the demo recording), `exportCoachSeed.ts` (the maintainer's paid, one-off export of `demo/coach-seed.json`; the agent never runs it), and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | -The REST and WebSocket payloads are a contract consumed by the frontend, which mirrors the types by hand. This repo owns that contract: any change to `src/ws/messageFormat.ts`, the routers or the repository row types must be flagged so the frontend's `src/types/` can be updated. +The REST and WebSocket payloads are a contract consumed by the frontend, which mirrors the types by hand. This repo owns that contract: any change to `src/ws/messageFormat.ts`, the routers or the repository row types must be flagged so the frontend's `src/types/` can be updated. Demo mode changes content, not shapes: WebSocket `data` holds only the player's car with `playerCarIndex` 0, compressed with per-message deflate, close codes `4001` "demo full" and `4002` "time limit", `403` for a foreign origin, CORS to one origin, `Cache-Control` on reads; `GET /health` exists in both modes. ## Conventions @@ -92,7 +96,14 @@ These are also listed under "Known limitations" in the README. When a limitation - Switching the Node major without running `npm ci` again makes `better-sqlite3` fail to load with `ERR_DLOPEN_FAILED` (its native binary is built for one major's ABI). - `sessionType` 18 is Time Trial in F1 24 (the numbering differs from F1 23). - `.env` is read only at startup; `ts-node-dev` does not restart on `.env` changes. +- Replaying an old recording writes into the session it came from, if that session is already in the database (same `sessionUID`), and moves its `last_seen_at` to now, so the history shows its duration stretched from the original capture to the replay. - Without `ANTHROPIC_API_KEY`, `POST /sessions/:id/analysis` returns 503 (cached analyses are still served). +- The demo's coach seed covers the six ordered pairs of laps 6, 7 and 8, but today's frontend fixes the primary lap to the latest one, so only (8, 6) and (8, 7) are requested. +- The demo's hosting includes 5 GB of outbound bandwidth a month, about 1,330–2,410 full visits; without a payment method the service is suspended until the next month when it runs out. +- Demo mode has no per-IP request limit (the platform's client-IP forwarding is undocumented), so one client can spend the allowance. +- Whether the browser's pong frames count as activity that keeps the demo awake is undocumented; the 5-minute connection limit bounds it. +- The demo's live stream shows only the player's car. +- WebSocket compression costs an estimated 11–12% of the free instance's CPU per connection (measured locally, not on the platform); `DEMO_WS_COMPRESSION=false` turns it off. - The coach's system prompt (1,378 tokens for `claude-opus-4-8`, measured with the token-counting endpoint) is below the model's minimum cacheable length, so `cache_control` has no effect. Each call logs its token `usage` (`[coachService] usage ...`). Re-measure if the prompt changes. ## How to work in this repo diff --git a/README.md b/README.md index 164713f..f66ac84 100644 --- a/README.md +++ b/README.md @@ -30,24 +30,25 @@ flowchart LR | Path | Responsibility | |---|---| -| `src/config/` | `env.ts` loads and validates the ports and fails fast; `dbPath.ts` resolves the database path (including `DB_PATH`) and is the only config file the persistence layer imports, so persistence does not depend on the ports | +| `src/config/` | `env.ts` loads and validates the ports and fails fast (local mode only); `dbPath.ts` resolves the database path (including `DB_PATH`, and an in-memory database in demo mode) and is the only config file the persistence layer imports, so persistence does not depend on the ports; `mode.ts` reads `DEMO_MODE`; `demoEnv.ts` validates the demo settings; `projectRoot.ts` finds the folder holding `package.json` | | `src/network/` | UDP socket; WebSocket server with a ping/pong heartbeat every 15 s | | `src/ws/` | WebSocket message envelope | | `src/telemetry/` | Binary parsers for packet ids 0 (motion), 1 (session, partial), 2 (lap data) and 6 (car telemetry), over a little-endian `BufferCursor`; each full parser asserts the exact byte count | | `src/persistence/` | SQLite schema, per-session in-memory state, decimation, lap completion detection, one repository per table; samples are deduplicated by `(session_id, overall_frame_identifier)`, since a packet's frame identifier survives a flashback and a looped or repeated replay unscathed, unlike session time or lap distance | | `src/analysis/` | Pure, deterministic lap analysis producing a compact `LapInsights` JSON | | `src/coaching/` | Static system prompt, user-message builder, Anthropic call with typed errors | -| `src/http/` | Express REST API | +| `src/http/` | Express REST API and `GET /health`; `createApp` builds the same app for both modes, with the demo's CORS and cache headers in demo mode | +| `src/demo/` | Everything only demo mode runs: the recording check, the in-process session build, capture times, the coach seed's validation and export, the single-car message, the demo WebSocket server and its log, and `demoServer.ts`, which wires the start sequence | | `src/replay/` | Pure replay pacing (`computeDelayMs`) and the orchestration loop (`runReplay`): speed, loop, absolute-deadline scheduling so error does not accumulate over a long recording | | `src/tooling/` | Tests of the project's own tooling: the test of the empty-discovery guard | -| `tools/` | Recorder, an offline `analyze` CLI, a thin replayer CLI over `src/replay/`, and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | +| `tools/` | Recorder, an offline `analyze` CLI, a thin replayer CLI over `src/replay/`, `fetchDemoRecording.ts` (the last step of `npm run build`: downloads and verifies the demo recording), `exportCoachSeed.ts` (the maintainer's paid, one-off export of `demo/coach-seed.json`), and `assertTestFiles.ts`, the guard that runs as the `pretest` of `npm test` | ## Features - **UDP ingestion and parsing** of the F1 24 protocol (`packetFormat` 2024). Malformed or foreign packets are logged and dropped without crashing the process. - **Persistence**: sessions, completed laps (time, sectors, validity) and decimated per-lap samples of car telemetry and motion. The track id is read from the session packet. - **Lap completion detection** based on the highest lap number seen, so lost or out-of-order UDP packets do not create false laps. Missing sector data is stored as `NULL`. -- **Live WebSocket stream** of motion, lap data and car telemetry (all 22 cars per message). +- **Live WebSocket stream** of motion, lap data and car telemetry (all 22 cars per message; in demo mode, only the player's car, with `playerCarIndex` 0, see "Demo mode"). - **REST API**: | Method | Path | Description | @@ -57,6 +58,7 @@ flowchart LR | GET | `/sessions/:id/benchmark` | Best valid lap and theoretical best (best sectors) on the session's track, across all recorded sessions | | GET | `/sessions/:id/laps/:lapNumber/telemetry` | Decimated car telemetry and motion samples for a lap | | POST | `/sessions/:id/analysis` | Body `{ lapA, lapB }`; returns `{ analysis, insights, cached }` | + | GET | `/health` | `200 {"status":"ok"}`; in demo mode, `503 {"status":"replay stopped"}` once the replay loop has stopped. Never cached | - **Lap analysis engine** (`npm run analyze`, also used by the coach): corner detection from the speed trace, braking point and trail braking, throttle pick-up, coasting and throttle oscillation, per-corner time loss, lateral line deviation between laps, and tyre surface temperature excursions. - **AI coach**: turns the insights of two laps into pit-engineer style advice in Portuguese, with results cached per lap pair. @@ -72,6 +74,14 @@ cp .env.example .env # WS_PORT, UDP_PORT, HTTP_PORT; ANTHROPIC_API_KEY is opt npm run dev ``` +To run the compiled server instead of `ts-node-dev`: + +```bash +npm run build # compiles src/ and tools/ to dist/ with tsconfig.build.json (tests excluded), + # then downloads and verifies the demo recording (see "Demo mode") +npm start # runs dist/src/server.js, with the same .env +``` + In the game, enable UDP telemetry under *Settings → Telemetry*: IP `127.0.0.1`, port `20777`, UDP format `2024`. Without `ANTHROPIC_API_KEY` everything works except new coach analyses, which return HTTP 503. @@ -121,6 +131,64 @@ To inspect the analysis for two laps without calling the AI: npm run analyze -- ``` +## Demo mode + +> **Partial: being built on this branch (spec 003).** The demo server runs end to end, but the repository has no coach seed yet: the first export was rejected at review, and the seed will be produced again after two smaller specs (the coach's input and prompt, and artifacts of the analysis engine). Until then, `DEMO_MODE=true npm start` stops with `[demo] cache do coach não encontrado em …` unless `DEMO_SEED_PATH` points at a seed made for the recording in use. + +**What it does.** A self-contained public demonstration on a host that exposes a single port and keeps no disk. At every start, in this order, before listening: + +1. validates the settings below; +2. reads the coach seed and checks the recording's SHA-256 against the one the seed names (in production, the published recording's); a missing recording, a missing or malformed seed, or a mismatch stops the start; +3. builds the whole session in an in-memory database, feeding every packet of the recording through the same parsers and persistence as UDP ingestion (no UDP socket is opened in demo mode); +4. rewrites the session's start and last-seen times and each lap's time to the recording's own capture times (`receivedAt`), so the history shows the 2026-10-16 capture and a 4-minute session however long the demo runs; +5. validates the seed against the built session (its session UID, its laps, and each pair's insights against today's analysis, structurally, with numbers within 0.01 + 1e-9) and loads it into the coach cache; any difference beyond that stops the start, naming the pair; +6. switches the database to read-only (`PRAGMA query_only`), so any later write fails inside SQLite; +7. listens on `PORT` (`0.0.0.0`) with REST and WebSocket on that one port, and starts replaying the recording at `1x` in a loop, broadcast only (the loop never writes to the database). + +The log line `[demo] pronto em ms (montagem …, hash …, cache …; compressão do WebSocket ligada|desligada)` marks the end of the start. + +**WebSocket in demo mode.** Same message shape as local mode, but each message's `data` holds only the player's car and `playerCarIndex` is always `0`. Messages are compressed with per-message deflate when the client offers it (every current browser does); `DEMO_WS_COMPRESSION=false` turns compression off without a code change. At most `DEMO_MAX_CONNECTIONS` connections (default 5): one more is accepted and closed at once with code `4001`, reason `demo full`. Each connection is closed after `DEMO_MAX_CONNECTION_MINUTES` (default 5) with code `4002`, reason `time limit`; the server never reopens it. A handshake from a browser origin other than `DEMO_ALLOWED_ORIGIN` gets HTTP `403`; a client without an `Origin` header is accepted. + +**REST in demo mode.** CORS answers only `DEMO_ALLOWED_ORIGIN` (exact match). This, like the WebSocket origin check, only limits use from browsers: a script sends whatever `Origin` it wants, so it is not an access control, and nothing a client sends can write to the database or trigger a paid call anyway. The four read routes carry `Cache-Control: no-cache` with Express's ETag, so a browser revalidates and gets `304` while the data is unchanged; `POST /sessions/:id/analysis` and `GET /health` carry `Cache-Control: no-store`. The application does not compress REST responses: the hosting platform's load balancers do. There is no per-IP request limit. + +**The coach in demo mode.** Seeded pairs are answered from the cache (`cached: true`); any other pair gets the existing `503` body. Demo mode refuses to start with `ANTHROPIC_API_KEY` set, so it can never call the API. + +**Health.** `GET /health` answers `200 {"status":"ok"}` while the replay loop runs and `503 {"status":"replay stopped"}` if it has stopped. Since the server only listens once the demo is ready, a probe during the start finds no listener. + +**Logging in demo mode.** No per-packet lines. The log has the start and the time until ready, each new pass of the loop, each WebSocket connection opened and closed with its code (normal, `4001 demo full`, `4002 time limit`, `403` origin refusal), and every 15 minutes a summary line: open connections, bytes written to the sockets by the application over WebSocket and over REST and their sum, coach `503` count, and process CPU time. The byte counts are what the application writes (after WebSocket compression, before the platform's HTTP compression), not the platform's billing figure. No IP address is logged. + +**The recording.** `npm run build` ends by downloading the published recording ([sample-recording-v1](https://github.com/AnaLauraFFerraz/TelemetryF1_API/releases/tag/sample-recording-v1)) to `recordings/demo-interlagos-tt-20hz-laps6-8.jsonl` (or `DEMO_RECORDING_PATH`) and checking its SHA-256 against the one published in "Running without the game". If a verified copy is already there, nothing is downloaded. The download goes to a temporary file next to the destination and replaces it only after the check; a failed download or a mismatch fails the build and leaves any existing file as it was. An existing file with the wrong hash is replaced by a verified download, with a log line. + +Demo mode is selected with `DEMO_MODE=true` and is off by default. With it on, the three local ports are not read, and the database is always in memory (`data/telemetry.db` is never opened). + +| Variable | Required | Rule | Default | +|---|---|---|---| +| `DEMO_MODE` | to enable | `true` turns demo mode on; unset or empty is local mode; any other value stops the server | — | +| `PORT` | yes | integer 1–65535 | — | +| `DEMO_ALLOWED_ORIGIN` | yes | one origin, `scheme://host[:port]`, no path, no trailing slash | — | +| `DEMO_MAX_CONNECTIONS` | no | integer ≥ 1 | `5` | +| `DEMO_MAX_CONNECTION_MINUTES` | no | integer ≥ 1 | `5` | +| `DEMO_WS_COMPRESSION` | no | `true` or `false`; unset or empty is `true`; any other value stops the server | `true` | +| `DEMO_RECORDING_PATH` | no | path to the recording | `recordings/demo-interlagos-tt-20hz-laps6-8.jsonl` | +| `DEMO_SEED_PATH` | no | path to the coach seed file | `demo/coach-seed.json` | +| `ANTHROPIC_API_KEY` | **must not be set** | the server stops: demo mode never makes a paid call | — | +| `DB_PATH` | **must not be set** | the server stops: the demo database is always in memory | — | + +Every check runs at startup, before any port is bound, and stops the server with a message naming the variable. + +**The coach seed.** Demo mode never calls the AI coach; its comparisons come from a versioned file, `demo/coach-seed.json`, which the maintainer produces once with a paid export. The file is public once committed, so it holds only the recording's identity (its SHA-256 and session UID) and, for each ordered pair of laps, the insights, the coach's text and the model name: nothing about the account, the request or the usage. The export: + +```bash +DB_PATH=:memory: ANTHROPIC_API_KEY= npm run export-coach-seed +``` + +- builds the session from the published recording in a disposable database (it refuses an unset `DB_PATH` and any spelling of `data/telemetry.db`, and refuses to run without a key); +- asks the coach for the six ordered pairs of laps 6, 7 and 8, printing each call's token usage; +- rewrites the file after each paid pair, atomically (a temporary file in `demo/` renamed over `coach-seed.json`), so an interrupted run keeps every pair it paid for and never leaves half a JSON; +- reuses a pair already in the file when the recording, the model and the insights still match, so a second run makes no call, and a run after an interrupted one asks only for the missing pairs. Insights are compared structurally, with numbers equal within 0.01 + 1e-9, because the file is made on one machine and checked on another, possibly with another engine version. + +Run it only on your own machine, with a spending limit set in the Anthropic Console, and never set the key where the demo runs. The step-by-step is in `specs/003-demo-mode/quickstart.md`, section D. Demo mode refuses to start while the file is missing. + ## Tests ```bash @@ -130,7 +198,7 @@ npm run typecheck # type-check (src/ and tools/) `npm test` first runs a guard (`pretest`) that fails when no test file is found, because `node --test` exits 0 on an empty glob. -The tests cover pure logic (decimation, lap completion detection, every analysis module, the coach's user-message builder, replay pacing) with synthetic data, plus the sample repositories' deduplication and one end-to-end test of the replay path against a real UDP socket and an isolated, disposable database. HTTP routes and the WebSocket server are not covered yet. +The tests cover pure logic (decimation, lap completion detection, every analysis module, the coach's user-message builder, replay pacing) with synthetic data, plus the sample repositories' deduplication and one end-to-end test of the replay path against a real UDP socket and an isolated, disposable database. In demo mode, the HTTP app (CORS, cache headers, health, the coach's cached and `503` answers), the demo WebSocket server and the whole server are tested, the last one spawned against a synthetic recording and seed generated by the test. The local-mode routes and the local WebSocket server are not covered yet. ## CI and branch protection @@ -170,7 +238,7 @@ The game's send rate sets the other limit. The recording used for validation was **Partial session-packet parser.** Only `sessionType` and `trackId` are read. The full struct contains large variable-length arrays the project does not use yet, so this parser is the only one that does not assert the total byte count. -**Contract ownership.** This repo owns the REST and WebSocket payloads. The frontend mirrors the types by hand, a deliberate trade-off over a shared package for two small repos. +**Contract ownership.** This repo owns the REST and WebSocket payloads. The frontend mirrors the types by hand, a deliberate trade-off over a shared package for two small repos. In demo mode the WebSocket message keeps its shape but not its content: `data` holds only the player's car and `playerCarIndex` is `0`, so a client must read the car through `playerCarIndex`, never by a fixed index. ## Known limitations @@ -181,7 +249,14 @@ The game's send rate sets the other limit. The recording used for validation was - **The analysis cache** is keyed only by session and lap pair. It ignores the model, the prompt version and later benchmark changes. - **Error responses** are plain strings in mixed languages, and the frontend matches some of them literally. - **Switching the Node major without running `npm ci` again** makes `better-sqlite3` fail to load with `ERR_DLOPEN_FAILED`: its native binary is built for one major's ABI. -- No build or lint scripts yet. +- **Replaying an old recording stretches its session's duration.** A replay re-sends the original packets, so it writes into the session the recording came from, if that session is already in the database, and moves its last-seen time to now: the history then shows a duration from the original capture to the time of the replay. +- **The demo seeds six lap pairs, but the frontend reaches only two.** The coach seed covers the six ordered pairs of laps 6, 7 and 8; today's frontend fixes the primary lap to the latest one (lap 8), so only (8, 6) and (8, 7) can be requested. The other four are ready for a frontend that lets the visitor change the primary lap. +- **The demo's monthly bandwidth.** The free hosting includes 5 GB of outbound bandwidth a month, about 1,330–2,410 full demo visits (spec 003 research, point 1); without a payment method, the platform suspends the service until the next month when it runs out. +- **No per-IP limit in demo mode.** The platform's documentation does not state how it forwards the visitor's address, so one client can spend the allowance by itself. +- **Whether the browser's pong frames keep the demo awake is undocumented** by the platform; the 5-minute connection limit bounds how long an abandoned tab can keep it awake. +- **The demo shows only the player's car** in the live stream. +- **WebSocket compression costs an estimated 11–12% of the free instance's CPU per connection** (measured locally at 1.1–1.2% of one core, not on the platform). `DEMO_WS_COMPRESSION=false` is the way out. +- No lint script yet. ## Next steps @@ -191,4 +266,6 @@ Planned with Spec Driven Development (spec, plan and tasks are committed before 2. **Parser tests with real packets** extracted from a recording. 3. **Stable error codes**, e.g. `{ code: "SESSION_NOT_FOUND", message }`, with the frontend deciding by code, and a single language for messages. 4. **Single source for tyre temperature windows**: the API exposes the windows and the frontend consumes them, replacing the two diverging hard-coded ranges. Open question to settle in the spec: whether the ideal window in F1 24 depends on the tyre compound. -5. **Public demo** running from a recorded session (the real-time loop this repo now supports), and a **build** and **lint** step in the CI (which already runs the type-check and the tests). +5. **Coach input**: tell the coach which laps it compares and their official times, the sign conventions, and label each corner as the frontend does. +6. **Analysis engine artifacts**: overlapping corner search windows, a braking start that stops at the apex, and a throttle pick-up shared by neighbouring corners (spec 003 research, "Analysis engine observations"). +7. **Public demo deploy**, once the coach seed exists (see "Demo mode"), and a **build** and **lint** step in the CI (which already runs the type-check and the tests). diff --git a/package.json b/package.json index 653b3aa..7817519 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,12 @@ }, "scripts": { "dev": "ts-node-dev src/server.ts", + "build": "tsc -p tsconfig.build.json && node dist/tools/fetchDemoRecording.js", + "start": "node dist/src/server.js", "record": "ts-node-dev tools/recorder.ts", "replay": "ts-node-dev tools/replayer.ts", "analyze": "ts-node-dev tools/analyze.ts", + "export-coach-seed": "ts-node-dev tools/exportCoachSeed.ts", "typecheck": "tsc --noEmit", "pretest": "node -r ts-node/register tools/assertTestFiles.ts", "test": "node -r ts-node/register --test \"src/**/*.test.ts\"" diff --git a/specs/003-demo-mode/checklists/requirements.md b/specs/003-demo-mode/checklists/requirements.md new file mode 100644 index 0000000..d90f2f4 --- /dev/null +++ b/specs/003-demo-mode/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Demo mode for a public demonstration + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-10-21 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) — see note 1 +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders — see note 2 +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain — see note 3 +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification — see note 1 + +## Notes + +1. The section "Current behaviour read from the code" names files and functions on purpose: the author asked for the three facts to be recorded from the code before the spec was written. The requirements themselves name what the feature acts on (the recording, the ports, `data/telemetry.db`, the commands, the existing 503 body) but not how: no listener wiring, no file format for the seed, no download mechanism, no tsconfig layout, no route path for the health endpoint. Those are plan decisions. +2. The audience is the maintainer, as in specs 001 and 002; "non-technical" is read as "does not require knowing the implementation". +3. The three questions the spec left open (the state of the demo at the first request, the time base of the session's dates, the lap pairs of the seed file) and the three defaults listed in Assumptions were resolved in the clarify session of 2026-10-21, together with four further questions (simultaneous connections, volume per connection, REST protection, logging). All are recorded in the spec's Clarifications section; no `[NEEDS CLARIFICATION]` marker remains. +4. SC-007 reuses the measured spec 001 figure (2,411 samples at `1x`); SC-002 sets 20 passes as a test length, not a measured result. +5. Constitution: FR-002 and FR-003 keep fail-fast (II); FR-010 forbids invented values (IV); FR-022 follows V; FR-020 lists the contract changes (VI). Principle III (paid calls cached) is strengthened: demo mode makes no paid call at all. diff --git a/specs/003-demo-mode/contracts/coach-seed-file.md b/specs/003-demo-mode/contracts/coach-seed-file.md new file mode 100644 index 0000000..cf44c17 --- /dev/null +++ b/specs/003-demo-mode/contracts/coach-seed-file.md @@ -0,0 +1,56 @@ +# Contract: coach seed file + +Internal to this repository (not consumed by the frontend). Produced by `tools/exportCoachSeed.ts`, run once by the maintainer; read by demo mode at every start. + +## Location + +`demo/coach-seed.json` at the repository root, versioned. Overridable with `DEMO_SEED_PATH` (used by the tests to point at a synthetic seed). + +## Shape + +```json +{ + "formatVersion": 1, + "recordingSha256": "c41bdfaf7ff0feb40702875b15031654742de5fdb10ea3a05c0a21f1ac70b033", + "sessionUid": "1026725433292396341", + "entries": [ + { + "lapA": 8, + "lapB": 7, + "model": "claude-opus-4-8", + "analysisText": "…", + "insights": { "…": "the LapInsights object, as buildInsights returns it" } + } + ] +} +``` + +| Field | Rule | +|---|---| +| `formatVersion` | Integer; only `1` is accepted. | +| `recordingSha256` | Lowercase hex SHA-256 of the recording file the entries were produced from. | +| `sessionUid` | Decimal string of the recording's `sessionUID`. | +| `entries` | One per ordered pair `(lapA, lapB)`, `lapA ≠ lapB`, no duplicate pair. For the published recording: the six ordered pairs of laps 6, 7 and 8. | +| `entries[].model` | The model that produced `analysisText`, as `generateCoaching` returns it. | +| `entries[].analysisText` | The coach's text, served as `analysis`. | +| `entries[].insights` | The exact insights sent to the coach, served as `insights`. | + +## What the file never holds + +The file is public once committed. Besides the recording's identity (`recordingSha256`, `sessionUid`), it holds only, per pair, the insights, the coach's text and the model name. It never holds anything about the account, the request or the usage: no key or key identifier, no request or message id, no timestamp of the call, no token counts, no cost. The token `usage` of each call is printed to the maintainer's console by the export and recorded, as counts only, in research.md (tasks.md T062). + +## Validation at start (demo mode) + +Demo mode refuses to start, with a clear message naming the problem, when: + +1. the file is missing, is not valid JSON, or breaks a rule above; +2. `recordingSha256` differs from the SHA-256 of the recording in use (already verified against `PUBLISHED_RECORDING_SHA256` in production); +3. `sessionUid` differs from the `sessionUID` of the session just built; +4. an entry names a lap that is not among the built session's completed laps; +5. an entry's `insights` do not match what `buildInsights` computes now for that pair, under `insightsMatch`: the same structure (keys, array lengths and order, strings, booleans, `null`s) and every number within `|a − b| ≤ 0.01 + 1e-9`. The rule follows from how the coach receives the insights (`JSON.stringify`, with the arithmetic values rounded by `round2` and the rest copied from samples) and absorbs cross-platform and engine-version differences without hiding a real change (research.md, point 7). The message names the pair and the first differing path. + +Each valid entry becomes one `lap_analyses` row (`insertAnalysis`, existing), before the connection is switched to read-only. + +## The export procedure (idempotent with respect to paid calls) + +For each requested pair, `tools/exportCoachSeed.ts` reuses the existing entry from the current `demo/coach-seed.json` when its `recordingSha256` and `sessionUid` match, its `insights` pass the same `insightsMatch` used at start, and its `model` equals the model the coach uses now; it calls the coach only for missing or stale pairs. Running it twice in a row makes zero calls the second time. After each paid pair it rewrites the file atomically (`demo/coach-seed.json.tmp`, then renamed over `demo/coach-seed.json`) with the pairs processed so far plus the previous entries of the pairs not yet processed, so a run interrupted after some paid calls leaves a valid, partial file that the next run reads, asking only for the missing pairs (decided at the stop before T060/T061, 2026-10-21: principle III, the same input is never paid twice). Step-by-step in [../quickstart.md](../quickstart.md), section D. diff --git a/specs/003-demo-mode/contracts/configuration.md b/specs/003-demo-mode/contracts/configuration.md new file mode 100644 index 0000000..be96d6d --- /dev/null +++ b/specs/003-demo-mode/contracts/configuration.md @@ -0,0 +1,32 @@ +# Contract: configuration + +## Local mode (default) + +Unchanged: `WS_PORT`, `UDP_PORT`, `HTTP_PORT` required (1–65535) with today's messages; `ANTHROPIC_API_KEY` optional; `DB_PATH` optional override, otherwise `data/telemetry.db` at the repository root (now resolved from the project root, so the compiled server agrees with the source; research.md, point 8). + +## Demo mode + +| Variable | Required | Rule | Default | +|---|---|---|---| +| `DEMO_MODE` | to enable | `true` turns demo mode on. Unset or empty: local mode. Any other value: the server stops with a message (a typo must not silently fall back to local mode). | — | +| `PORT` | yes | Integer 1–65535. | none in the code (Render sets `10000`) | +| `DEMO_ALLOWED_ORIGIN` | yes | A single origin, `scheme://host[:port]`, with no path and no trailing slash (`new URL(value).origin === value`). | — | +| `DEMO_MAX_CONNECTIONS` | no | Integer ≥ 1. | `5` | +| `DEMO_MAX_CONNECTION_MINUTES` | no | Integer ≥ 1. | `5` | +| `DEMO_WS_COMPRESSION` | no | `true` or `false`. Unset or empty: `true`. Any other value: the server stops with a message, as for `DEMO_MODE`. `false` turns off the WebSocket's per-message deflate, the operational way out, with no code deploy, if the process CPU time in the 15-minute summary shows the instance saturated; the messages are then sent uncompressed (47.3 KiB/s instead of 6.7 KiB/s per connection, research.md point 6). | `true` | +| `DEMO_RECORDING_PATH` | no | Path to the recording. | `recordings/demo-interlagos-tt-20hz-laps6-8.jsonl` at the project root | +| `DEMO_SEED_PATH` | no | Path to the seed file. | `demo/coach-seed.json` at the project root | +| `ANTHROPIC_API_KEY` | **forbidden** | If non-empty, the server stops with a message saying demo mode does not accept it. | — | +| `DB_PATH` | **forbidden** | If non-empty, the server stops with a message: demo mode always uses an in-memory database. | — | +| `WS_PORT`, `UDP_PORT`, `HTTP_PORT` | ignored | Not read in demo mode. | — | + +All messages are in Portuguese, following the existing ones in `src/config/env.ts`. Every check runs before the database is opened and before any port is bound. + +## Commands + +| Command | What it does | +|---|---| +| `npm run build` | `tsc -p tsconfig.build.json` into `dist/`, then `node dist/tools/fetchDemoRecording.js` (downloads and verifies the recording unless a verified copy is already there). | +| `npm start` | `node dist/src/server.js`: the compiled server, in whichever mode the environment selects. | +| `npm run dev`, `npm test`, `npm run typecheck`, `record`, `replay`, `analyze` | Unchanged. | +| `npm run export-coach-seed` | `ts-node-dev tools/exportCoachSeed.ts`: the maintainer's paid, one-off procedure (quickstart.md, section D). Never run by the CI, the tests or the agent. | diff --git a/specs/003-demo-mode/contracts/rest.md b/specs/003-demo-mode/contracts/rest.md new file mode 100644 index 0000000..30e3547 --- /dev/null +++ b/specs/003-demo-mode/contracts/rest.md @@ -0,0 +1,40 @@ +# Contract: REST + +Owner: this repository (constitution, principle VI). The frontend mirrors it in `Telemetry-F1/src/types/rest.ts`. + +## Unchanged in both modes + +- Routes, methods, payload shapes, status codes and error strings of `GET /sessions`, `GET /sessions/:id`, `GET /sessions/:id/benchmark`, `GET /sessions/:id/laps/:lapNumber/telemetry` and `POST /sessions/:id/analysis`, including the literal `"session not found"` and the coach's 503 body `"coaching indisponível: configure ANTHROPIC_API_KEY"`. +- Express's default weak `ETag` and its automatic `304` on a matching `If-None-Match`. + +## New in both modes + +### `GET /health` + +| Mode | Status | Body | Headers | +|---|---|---|---| +| Local | `200` | `{"status":"ok"}` | `Cache-Control: no-store` | +| Demo, replay running | `200` | `{"status":"ok"}` | `Cache-Control: no-store` | +| Demo, replay loop stopped unexpectedly | `503` | `{"status":"replay stopped"}` | `Cache-Control: no-store` | + +In demo mode the server does not listen before the demo is ready (session built, capture times written, cache seeded), so "not ready" shows to the platform as a refused connection, not as a response. The endpoint is not subject to the origin rule; the platform's probe sends no `Origin`. + +## Demo mode: what changes + +| Aspect | Demo mode | Requirement | +|---|---|---| +| Address | `PORT`, the single port the platform assigns, on `0.0.0.0` | FR-002, FR-016 | +| CORS | `Access-Control-Allow-Origin` only for the configured frontend origin (with `Vary: Origin`); no CORS headers for any other origin. Requests without `Origin` are served (non-browser clients). Not access control. | FR-018 | +| Read routes | `Cache-Control: no-cache` plus the ETag: the browser revalidates every time and gets `304` without a body while the data is unchanged; after a deploy that changes the data the ETag changes. Cache lifetime: zero, with revalidation. | FR-026 | +| `POST /sessions/:id/analysis` | `Cache-Control: no-store`. A seeded pair returns `200` with `cached: true`; any other pair returns the existing `503` body. Never calls Anthropic, never writes. | FR-012, FR-015 | +| Session dates | `startedAt` = capture time of the recording's first packet (`2026-10-16T16:30:23.797Z`), `lastSeenAt` = capture time of its last packet (`2026-10-16T16:34:06.755Z`), each lap's `recordedAt` = capture time of the packet that completed it. Fixed for the life of the deployment. Same fields, same ISO format. | FR-010 | +| Session id | Always `1` (the only session of an empty in-memory database), on every start. | — | +| Compression | None by the application. The platform's load balancers compress HTTP responses with Brotli and gzip ([Render vs Heroku](https://render.com/docs/render-vs-heroku-comparison), read 2026-10-21); one lap's telemetry is 554–561 KB raw, 77–79 KB with Brotli (quality 4), 84–86 KB with gzip. | T-3 | +| 429 | **Not introduced**: the per-IP limit (FR-027) is not adopted (research.md, point 1). | FR-027 | + +## For the frontend's spec 003 + +- Point the REST base URL at the demo's host. +- Expect `304` revalidations to be handled transparently by the browser (`fetch` with the default cache mode, as today). +- In demo mode, the coach's 503 means "this comparison is not prepared"; its body still mentions `ANTHROPIC_API_KEY` and how to present it is the frontend's decision. With today's fixed primary lap, only (8, 6) and (8, 7) are requested, and both are seeded. +- If the frontend is hosted in the same account as the API, its traffic spends the same 5 GB monthly allowance. diff --git a/specs/003-demo-mode/contracts/websocket.md b/specs/003-demo-mode/contracts/websocket.md new file mode 100644 index 0000000..4303e98 --- /dev/null +++ b/specs/003-demo-mode/contracts/websocket.md @@ -0,0 +1,42 @@ +# Contract: WebSocket stream + +Owner: this repository (constitution, principle VI). The frontend mirrors it in `Telemetry-F1/src/types/ws.ts`. + +## Unchanged in both modes + +- Envelope (`src/ws/messageFormat.ts`, `TelemetryWsMessage`): `type` (`"motion" | "lapData" | "carTelemetry"`), `sessionUID` (decimal string), `sessionTime`, `frameIdentifier`, `playerCarIndex`, `data` (array). No field is added, removed or renamed. +- Server-to-client only; client messages are ignored. +- Ping every 15 s; a client that misses two pongs is terminated. + +## Local mode (demo off): unchanged + +- Address: `ws://:WS_PORT`, its own port. +- `data` holds all 22 cars; `playerCarIndex` is the game's index into it. +- No connection limit, no time limit, no origin check. + +## Demo mode: what changes + +| Aspect | Demo mode | Requirement | +|---|---|---| +| Address | Same host and port as REST (`PORT`), any path: `wss:///` behind the platform's TLS | FR-016 | +| `data` | **Only the player's car**: an array of length 1 | FR-025 | +| `playerCarIndex` | **Always `0`**, the index of that single car | FR-025 | +| Compression | `permessage-deflate` (RFC 7692) negotiated whenever the client offers it, as every current browser does; server and client context takeover, the library's defaults. A client that does not offer it receives the same messages uncompressed. Measured on the wire: 6.7 KiB/s instead of 47.3 KiB/s. On by default; `DEMO_WS_COMPRESSION=false` turns it off without a code deploy ([configuration.md](configuration.md)), and the server then declines the extension. | FR-025 | +| Rate | The recording's own 20 Hz per packet type, unchanged. | FR-025 | +| Origin | Handshake with an `Origin` header different from the configured frontend origin: refused with HTTP `403 Forbidden` before the upgrade. No `Origin` header (non-browser client): accepted. This limits browsers only; it is not access control. | FR-018 | +| Capacity | At most `DEMO_MAX_CONNECTIONS` (default 5) open connections. One more is accepted and closed at once with **code `4001`, reason `"demo full"`**. | FR-023 | +| Duration | Each connection is closed **5 minutes** (`DEMO_MAX_CONNECTION_MINUTES`) after it opened, with **code `4002`, reason `"time limit"`**. The server does not reopen it. | FR-024 | +| Shutdown | On `SIGTERM` (deploy, restart, spin-down) the server closes open connections with code `1001` ("going away", standard). | — | + +Codes 4001 and 4002 are in the private-use range 4000–4999 of RFC 6455 §7.4.2, accepted by `ws` 8.18.0 (`lib/validation.js`, `isValidStatusCode`). The reasons are ASCII and well under the 123-byte limit of a close reason. + +**Why this is a contract change even though the shape is the same**: a frontend that reads `data[playerCarIndex]`, as today's does (`Telemetry-F1/src/context/TelemetryProvider.tsx`), works in both modes; a frontend that reads any other car, or assumes 22 entries, would break only in the demo. + +## For the frontend's spec 003 + +- Configure the demo's WebSocket URL as the API's host on the same port as REST. +- Handle `4001` ("demo full") and `4002` ("time limit") as distinct, visible states; reconnect only on the visitor's action after either. +- Keep reading the car through `playerCarIndex`. +- Nothing to do for compression: the browser's `WebSocket` negotiates and inflates it. +- If the frontend is hosted in the same account as the API, its traffic spends the same 5 GB monthly allowance. +- Suggested in this spec's clarifications: close the connection when the tab is hidden. diff --git a/specs/003-demo-mode/data-model.md b/specs/003-demo-mode/data-model.md new file mode 100644 index 0000000..d8c006b --- /dev/null +++ b/specs/003-demo-mode/data-model.md @@ -0,0 +1,63 @@ +# Data model: Demo mode + +## Database schema + +**No schema change.** Same tables, columns, indexes and migrations (`src/persistence/schema.ts`, `src/persistence/db.ts`). In demo mode the database is in memory (`:memory:`), built from the recording at every start, and switched to read-only (`PRAGMA query_only = ON`) once seeded (research.md, point 3). + +What demo mode writes, all before "ready": + +| Table | Rows | Written by | +|---|---|---| +| `sessions` | 1 (`id` 1, the recording's `session_uid`, `track_id` 16) | existing ingestion, then `applyCaptureTimes` for `started_at` / `last_seen_at` | +| `laps` | 3 (laps 6, 7, 8) | existing ingestion (`upsertCompletedLap`), then `applyCaptureTimes` for `recorded_at` | +| `car_telemetry_samples` | 2,411 (797 / 801 / 793 for laps 6–8, 20 for the start of lap 9) | existing ingestion | +| `motion_samples` | 2,411 (same split) | existing ingestion | +| `lap_analyses` | one per seed entry (6 for the published recording) | `insertAnalysis` (existing), from the seed file | + +Counts measured in research.md, point 3. + +## New repository function + +- `applySessionCaptureTimes(id, startedAt, lastSeenAt)` in `sessionRepository` and `applyLapCaptureTimes(sessionId, lapNumber, recordedAt)` in `lapRepository`, each with a prepared statement created at module load, as the project's repositories do; `applyCaptureTimes(sessionId, times: CaptureTimes)` in `src/demo/applyCaptureTimes.ts` calls both. Used only by the demo build and by the export tool. No existing statement changes. + +## New in-process values (not stored as such) + +### `CaptureTimes` + +| Field | Type | Source | Rule | +|---|---|---|---| +| `startedAt` | ISO string | `receivedAt` of the first packet carrying the session's UID | required | +| `lastSeenAt` | ISO string | `receivedAt` of the last packet carrying the session's UID | required, ≥ `startedAt` | +| `lapRecordedAt` | map lap number → ISO string | `receivedAt` of the lap data packet that completed that lap | one per completed lap | + +Built by the pure function `buildCaptureTimes`; a missing `receivedAt` makes it throw, and the demo refuses to start (FR-010). For the published recording: `2026-10-16T16:30:23.797Z`, `2026-10-16T16:34:06.755Z`, laps 6/7/8 at `16:31:38.248Z`, `16:32:52.362Z`, `16:34:05.758Z`. + +### `DemoConfig` + +The validated demo settings: port, allowed origin, connection limit, connection duration, WebSocket compression on or off, recording path, seed path. Rules in [contracts/configuration.md](contracts/configuration.md). + +### `CoachSeedFile` + +The versioned file of prepared analyses. Shape and validation in [contracts/coach-seed-file.md](contracts/coach-seed-file.md). + +### Live connection (demo mode) + +| State | Entered when | Leaves to | +|---|---|---| +| refused (403) | handshake with a foreign `Origin` | — (no WebSocket created) | +| full (closed 4001) | handshake accepted while `DEMO_MAX_CONNECTIONS` are open | — (never counted) | +| open | handshake accepted below the limit; counted; 5-minute timer started; per-message deflate if the client offered it | closed | +| closed | client close, heartbeat failure, `4002` at the time limit, `1001` at shutdown | — (uncounted, timer cleared) | + +### Demo counters + +Kept in memory for the 15-minute summary (FR-028): active connections, WebSocket and REST bytes written to the sockets (`socket.bytesWritten`), coach 503 responses, and the process CPU time. Reset only by a restart; never persisted. + +### Insight comparison + +`insightsMatch(stored, computed)` returns a match or the first differing path. Same structure required; numbers equal within `0.01 + 1e-9` (research.md, point 7). + +## Relationships + +- The **demo recording** (identified by its SHA-256) produces the **demo session** (identified by its `sessionUID`) and its **capture times**. +- The **seed file** names the recording's SHA-256 and the session's UID; each entry names two of the session's completed laps. diff --git a/specs/003-demo-mode/evidence/fetch-recording.mjs b/specs/003-demo-mode/evidence/fetch-recording.mjs new file mode 100644 index 0000000..fdbe003 --- /dev/null +++ b/specs/003-demo-mode/evidence/fetch-recording.mjs @@ -0,0 +1,25 @@ +// Baixa o asset da Release com o fetch do próprio Node (sem dependência), segue o +// redirecionamento e confere o SHA-256 publicado no README. Também pede um asset +// inexistente, para registrar o status de falha. +// Uso: node specs/003-demo-mode/evidence/fetch-recording.mjs +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; + +const url = + "https://github.com/AnaLauraFFerraz/TelemetryF1_API/releases/download/sample-recording-v1/demo-interlagos-tt-20hz-laps6-8.jsonl"; +const expected = "c41bdfaf7ff0feb40702875b15031654742de5fdb10ea3a05c0a21f1ac70b033"; + +const t0 = performance.now(); +const res = await fetch(url, { redirect: "follow" }); +console.log(`status ${res.status}, final host ${new URL(res.url).host}, content-length ${res.headers.get("content-length")}`); +if (!res.ok) process.exit(1); +const body = Buffer.from(await res.arrayBuffer()); +const t1 = performance.now(); +const sha = createHash("sha256").update(body).digest("hex"); +const t2 = performance.now(); +await writeFile(process.argv[2], body); +console.log(`bytes ${body.length}, download ${(t1 - t0).toFixed(0)} ms, sha256 ${(t2 - t1).toFixed(0)} ms`); +console.log(`sha256 ${sha}, matches README: ${sha === expected}`); + +const missing = await fetch(url.replace("laps6-8", "missing"), { redirect: "follow" }); +console.log(`missing asset: status ${missing.status}`); diff --git a/specs/003-demo-mode/evidence/measure-compression.ts b/specs/003-demo-mode/evidence/measure-compression.ts new file mode 100644 index 0000000..d60ede2 --- /dev/null +++ b/specs/003-demo-mode/evidence/measure-compression.ts @@ -0,0 +1,145 @@ +// Mede, sem chamada paga e sem tocar no data/telemetry.db: +// 1. o WebSocket de verdade (ws, loopback) com a mensagem de um carro, sem e com +// perMessageDeflate, contando os bytes que saem pelo socket do servidor e a CPU +// do processo durante uma passada inteira enviada sem pausa; +// 2. o corpo da telemetria de uma volta (REST) sem compressão, com gzip e com brotli. +// Uso (na raiz do repositório): +// npx ts-node --transpile-only --project tsconfig.json specs/003-demo-mode/evidence/measure-compression.ts +import fs from "node:fs"; +import zlib from "node:zlib"; +import type { AddressInfo } from "node:net"; +import { WebSocketServer, WebSocket, type PerMessageDeflateOptions } from "ws"; + +process.env.DB_PATH = ":memory:"; +/* eslint-disable @typescript-eslint/no-var-requires */ +const { dispatchPacket } = require("../../../src/telemetry/dispatcher"); +const { buildWsMessage } = require("../../../src/ws/messageFormat"); +const { persistTelemetry } = require("../../../src/persistence/persistTelemetry"); +const { listCarTelemetrySamplesForLap } = require("../../../src/persistence/carTelemetrySampleRepository"); +const { listMotionSamplesForLap } = require("../../../src/persistence/motionSampleRepository"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const lines = fs.readFileSync(process.argv[2], "utf8").split("\n").filter((l) => l.trim()); +let firstTs = Infinity; +let lastTs = -Infinity; +const playerMessages: string[] = []; +const sizeByType: Record = {}; + +for (const line of lines) { + const { timestampMs, data } = JSON.parse(line) as { timestampMs: number; data: string }; + firstTs = Math.min(firstTs, timestampMs); + lastTs = Math.max(lastTs, timestampMs); + const result = dispatchPacket(Buffer.from(data, "base64")); + if (!result) continue; + persistTelemetry(result.header, result.packetId, result.data); + const d = result.data as Record; + const arr = d.carMotionData ?? d.lapData ?? d.carTelemetryData; + if (!arr) continue; + const msg = buildWsMessage(result.header, result.packetId, arr); + if (!msg) continue; + const json = JSON.stringify({ ...msg, data: [arr[result.header.playerCarIndex]], playerCarIndex: 0 }); + playerMessages.push(json); + const entry = (sizeByType[msg.type] ??= { count: 0, bytes: 0 }); + entry.count += 1; + entry.bytes += Buffer.byteLength(json); +} +const seconds = (lastTs - firstTs) / 1000; +for (const [type, e] of Object.entries(sizeByType)) { + console.log(`player-only ${type}: avg ${(e.bytes / e.count).toFixed(0)} B per message`); +} + +async function measureWs(label: string, perMessageDeflate: false | PerMessageDeflateOptions): Promise { + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1", perMessageDeflate }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as AddressInfo).port; + const serverSide = new Promise((resolve) => wss.once("connection", (ws) => resolve(ws))); + const client = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => client.once("open", () => resolve())); + const ws = await serverSide; + const socket = (ws as unknown as { _socket: import("node:net").Socket })._socket; + + let received = 0; + const done = new Promise((resolve) => + client.on("message", () => { + received += 1; + if (received === playerMessages.length) resolve(); + }) + ); + const before = socket.bytesWritten; + const cpu0 = process.cpuUsage(); + const t0 = performance.now(); + for (const json of playerMessages) ws.send(json); + await done; + const cpu = process.cpuUsage(cpu0); + const wall = performance.now() - t0; + const bytes = socket.bytesWritten - before; + console.log( + `ws ${label}: extension "${ws.extensions || "none"}", ${(bytes / 2 ** 20).toFixed(2)} MiB on the wire per pass, ` + + `${(bytes / seconds / 1024).toFixed(1)} KiB/s at 1x, process CPU ${((cpu.user + cpu.system) / 1000).toFixed(0)} ms per pass ` + + `(${(((cpu.user + cpu.system) / 1e6 / seconds) * 100).toFixed(3)}% of one core at 1x; sender and receiver in one process), wall ${wall.toFixed(0)} ms` + ); + client.close(); + await new Promise((resolve) => wss.close(() => resolve())); +} + +// Mesmo envio, mas com o cliente num processo separado (ws-sink.mjs): a CPU medida +// aqui é só a do servidor (serialização já feita, compressão e escrita no socket). +async function measureWsServerOnly(label: string, perMessageDeflate: false | PerMessageDeflateOptions): Promise { + const { spawn } = await import("node:child_process"); + const path = await import("node:path"); + const wss = new WebSocketServer({ port: 0, host: "127.0.0.1", perMessageDeflate }); + await new Promise((resolve) => wss.once("listening", () => resolve())); + const port = (wss.address() as AddressInfo).port; + const serverSide = new Promise((resolve) => wss.once("connection", (ws) => resolve(ws))); + const sink = spawn(process.execPath, [path.join(__dirname, "ws-sink.mjs"), `ws://127.0.0.1:${port}`, String(playerMessages.length)]); + const done = new Promise((resolve) => sink.stdout.on("data", (chunk: Buffer) => chunk.toString().includes("done") && resolve())); + const ws = await serverSide; + const socket = (ws as unknown as { _socket: import("node:net").Socket })._socket; + const before = socket.bytesWritten; + const cpu0 = process.cpuUsage(); + for (const json of playerMessages) ws.send(json); + await done; + const cpu = process.cpuUsage(cpu0); + const bytes = socket.bytesWritten - before; + console.log( + `ws server only, ${label}: ${(bytes / 2 ** 20).toFixed(2)} MiB per pass, ${(bytes / seconds / 1024).toFixed(1)} KiB/s at 1x, ` + + `server CPU ${((cpu.user + cpu.system) / 1000).toFixed(0)} ms per pass (${(((cpu.user + cpu.system) / 1e6 / seconds) * 100).toFixed(3)}% of one core at 1x)` + ); + sink.kill(); + await new Promise((resolve) => wss.close(() => resolve())); +} + +async function main(): Promise { + await measureWsServerOnly("uncompressed", false); + await measureWsServerOnly("perMessageDeflate (defaults: context takeover)", {}); + await measureWsServerOnly("perMessageDeflate, zlib level 1", { zlibDeflateOptions: { level: 1 } }); + await measureWs("uncompressed", false); + await measureWs("perMessageDeflate threshold 0, context takeover", { threshold: 0 }); + await measureWs("perMessageDeflate threshold 0, no context takeover", { + threshold: 0, + serverNoContextTakeover: true, + clientNoContextTakeover: true, + }); + await measureWs("perMessageDeflate default threshold (1024 B)", {}); + + for (const lap of [6, 7, 8]) { + const body = Buffer.from(JSON.stringify({ carTelemetry: listCarTelemetrySamplesForLap(1, lap), motion: listMotionSamplesForLap(1, lap) })); + const time = (fn: () => Buffer) => { + const t = performance.now(); + const out = fn(); + return { size: out.length, ms: performance.now() - t }; + }; + const gzip = time(() => zlib.gzipSync(body)); + const br4 = time(() => zlib.brotliCompressSync(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })); + const br11 = time(() => zlib.brotliCompressSync(body)); + console.log( + `REST lap ${lap} telemetry: raw ${body.length} B; gzip(6) ${gzip.size} B in ${gzip.ms.toFixed(1)} ms; ` + + `brotli(4) ${br4.size} B in ${br4.ms.toFixed(1)} ms; brotli(11) ${br11.size} B in ${br11.ms.toFixed(1)} ms` + ); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/specs/003-demo-mode/evidence/measure-preload.ts b/specs/003-demo-mode/evidence/measure-preload.ts new file mode 100644 index 0000000..79371b9 --- /dev/null +++ b/specs/003-demo-mode/evidence/measure-preload.ts @@ -0,0 +1,125 @@ +// Monta a sessão da gravação dentro do processo, o mais rápido possível, pelo +// dispatcher e pelo persistTelemetry reais, num banco descartável (nunca o +// data/telemetry.db), e mede tempo, memória, contagens, datas de captura, +// tamanho das respostas REST e o tamanho da entrada do coach (sem chamar a API). +// Uso (na raiz do repositório): +// npx ts-node --transpile-only --project tsconfig.json specs/003-demo-mode/evidence/measure-preload.ts <:memory: | caminho temporário> +import fs from "node:fs"; +import path from "node:path"; + +const [recordingPath, dbArg] = process.argv.slice(2); +if (!dbArg || path.resolve(dbArg) === path.resolve("data", "telemetry.db")) { + throw new Error("recusado: passe :memory: ou um caminho temporário, nunca data/telemetry.db"); +} +process.env.DB_PATH = dbArg; + +const mem = (label: string) => { + const m = process.memoryUsage(); + console.log(`${label}: rss ${(m.rss / 2 ** 20).toFixed(1)} MiB, heapUsed ${(m.heapUsed / 2 ** 20).toFixed(1)} MiB, external ${(m.external / 2 ** 20).toFixed(1)} MiB`); +}; + +mem("start"); +const tRead0 = performance.now(); +const packets = fs + .readFileSync(recordingPath, "utf8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => { + const { timestampMs, receivedAt, data } = JSON.parse(l) as { timestampMs: number; receivedAt?: number; data: string }; + return { timestampMs, receivedAt, data: Buffer.from(data, "base64") }; + }); +console.log( + `read+decode ${packets.length} packets: ${(performance.now() - tRead0).toFixed(0)} ms; ` + + `missing receivedAt: ${packets.filter((p) => typeof p.receivedAt !== "number").length}` +); +mem("after decode"); + +// require() depois de definir DB_PATH: um import estático abriria o banco antes (ver src/persistence/testDb.ts). +/* eslint-disable @typescript-eslint/no-var-requires */ +const { dispatchPacket } = require("../../../src/telemetry/dispatcher"); +const { persistTelemetry } = require("../../../src/persistence/persistTelemetry"); +const { db } = require("../../../src/persistence/db"); +const { listSessions, getSessionById } = require("../../../src/persistence/sessionRepository"); +const { listLapsForSession, getTrackBenchmark } = require("../../../src/persistence/lapRepository"); +const { listCarTelemetrySamplesForLap } = require("../../../src/persistence/carTelemetrySampleRepository"); +const { listMotionSamplesForLap } = require("../../../src/persistence/motionSampleRepository"); +const { buildInsights } = require("../../../src/analysis/buildInsights"); +const { buildCoachUserMessage } = require("../../../src/coaching/coachUserMessage"); +const { COACH_SYSTEM_PROMPT } = require("../../../src/coaching/coachPrompt"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const lapCount = db.prepare("SELECT COUNT(*) AS n FROM laps"); +const latestLap = db.prepare("SELECT lap_number FROM laps ORDER BY id DESC LIMIT 1"); +const completions: Array<{ lap: number; at: number | undefined }> = []; +let lastLaps = 0; +let firstSessionAt: number | undefined; +let lastSessionAt: number | undefined; + +const t0 = performance.now(); +for (const p of packets) { + const result = dispatchPacket(p.data); + if (!result) continue; + if (result.header.sessionUID !== 0n) { + firstSessionAt ??= p.receivedAt; + lastSessionAt = p.receivedAt; + } + persistTelemetry(result.header, result.packetId, result.data); + if (result.packetId === 2) { + const n = (lapCount.get() as { n: number }).n; + if (n !== lastLaps) { + completions.push({ lap: (latestLap.get() as { lap_number: number }).lap_number, at: p.receivedAt }); + lastLaps = n; + } + } +} +console.log(`in-process preload: ${(performance.now() - t0).toFixed(0)} ms`); +mem("after preload"); + +const all = (sql: string) => db.prepare(sql).all(); +console.log("sessions", JSON.stringify(all("SELECT id, session_uid, track_id, started_at, last_seen_at FROM sessions"))); +console.log("laps", JSON.stringify(all("SELECT lap_number, lap_time_ms, sector1_ms, sector2_ms, sector3_ms, is_valid, recorded_at FROM laps ORDER BY lap_number"))); +console.log("car samples per lap", JSON.stringify(all("SELECT lap_number, COUNT(*) AS n FROM car_telemetry_samples GROUP BY lap_number"))); +console.log("motion samples per lap", JSON.stringify(all("SELECT lap_number, COUNT(*) AS n FROM motion_samples GROUP BY lap_number"))); +console.log("totals", JSON.stringify(all("SELECT (SELECT COUNT(*) FROM car_telemetry_samples) AS car, (SELECT COUNT(*) FROM motion_samples) AS motion")[0])); +const pages = db.pragma("page_count", { simple: true }) as number; +const pageSize = db.pragma("page_size", { simple: true }) as number; +console.log(`db size ${((pages * pageSize) / 2 ** 20).toFixed(2)} MiB; journal_mode ${db.pragma("journal_mode", { simple: true })}`); + +const iso = (ms?: number) => (ms === undefined ? "missing" : new Date(ms).toISOString()); +console.log(`capture times: first ${iso(firstSessionAt)} last ${iso(lastSessionAt)} span ${((lastSessionAt! - firstSessionAt!) / 1000).toFixed(3)} s`); +console.log("lap completed at", JSON.stringify(completions.map((c) => ({ lap: c.lap, at: iso(c.at) })))); + +const t1 = performance.now(); +for (const p of packets) { + const result = dispatchPacket(p.data); + if (result) persistTelemetry(result.header, result.packetId, result.data); +} +console.log( + `second pass (same process): ${(performance.now() - t1).toFixed(0)} ms; counts after ` + + JSON.stringify(all("SELECT (SELECT COUNT(*) FROM sessions) AS sessions, (SELECT COUNT(*) FROM laps) AS laps, (SELECT COUNT(*) FROM car_telemetry_samples) AS car, (SELECT COUNT(*) FROM motion_samples) AS motion")[0]) +); +mem("end"); + +const size = (v: unknown) => Buffer.byteLength(JSON.stringify(v)); +console.log(`GET /sessions body ${size(listSessions())} B`); +console.log(`GET /sessions/1 body ${size({ ...getSessionById(1), laps: listLapsForSession(1) })} B`); +for (const lap of [6, 7, 8]) { + console.log(`GET /sessions/1/laps/${lap}/telemetry body ${size({ carTelemetry: listCarTelemetrySamplesForLap(1, lap), motion: listMotionSamplesForLap(1, lap) })} B`); +} + +const benchmark = getTrackBenchmark(getSessionById(1).trackId); +console.log(`coach system prompt ${COACH_SYSTEM_PROMPT.length} chars`); +for (const [a, b] of [[6, 7], [6, 8], [7, 6], [7, 8], [8, 6], [8, 7]]) { + const input = () => ({ + primaryTelemetry: listCarTelemetrySamplesForLap(1, a), + comparisonTelemetry: listCarTelemetrySamplesForLap(1, b), + primaryMotion: listMotionSamplesForLap(1, a), + comparisonMotion: listMotionSamplesForLap(1, b), + }); + const first = JSON.stringify(buildInsights(input())); + const second = JSON.stringify(buildInsights(input())); + console.log( + `pair (${a},${b}): insights ${first.length} chars, deterministic ${first === second}, ` + + `user message ${buildCoachUserMessage(JSON.parse(first), benchmark).length} chars` + ); +} diff --git a/specs/003-demo-mode/evidence/measure-ws-volume.ts b/specs/003-demo-mode/evidence/measure-ws-volume.ts new file mode 100644 index 0000000..04ba7de --- /dev/null +++ b/specs/003-demo-mode/evidence/measure-ws-volume.ts @@ -0,0 +1,67 @@ +// Mede o volume que cada conexão WebSocket recebe hoje, sem banco e sem rede: +// passa a gravação pelos parsers reais e serializa cada mensagem com buildWsMessage. +// Uso (na raiz do repositório): +// npx ts-node --transpile-only --project tsconfig.json specs/003-demo-mode/evidence/measure-ws-volume.ts +import fs from "node:fs"; +import zlib from "node:zlib"; +import { dispatchPacket } from "../../../src/telemetry/dispatcher"; +import { buildWsMessage } from "../../../src/ws/messageFormat"; + +const lines = fs.readFileSync(process.argv[2], "utf8").split("\n").filter((l) => l.trim()); +const bytesByType: Record = {}; +let firstTs = Infinity; +let lastTs = -Infinity; +let deflated = 0; +let deflatedFast = 0; +let playerOnly = 0; +let playerDeflated = 0; +let deflateNs = 0n; +let deflateFastNs = 0n; +let playerDeflateNs = 0n; + +for (const line of lines) { + const { timestampMs, data } = JSON.parse(line) as { timestampMs: number; data: string }; + firstTs = Math.min(firstTs, timestampMs); + lastTs = Math.max(lastTs, timestampMs); + const result = dispatchPacket(Buffer.from(data, "base64")); + if (!result) continue; + const d = result.data as Record; + const arr = (d.carMotionData ?? d.lapData ?? d.carTelemetryData) as unknown[] | undefined; + if (!arr) continue; + const msg = buildWsMessage(result.header, result.packetId, arr); + if (!msg) continue; + + const json = JSON.stringify(msg); + const entry = (bytesByType[msg.type] ??= { count: 0, bytes: 0 }); + entry.count += 1; + entry.bytes += Buffer.byteLength(json); + + const t0 = process.hrtime.bigint(); + deflated += zlib.deflateRawSync(json).length; + deflateNs += process.hrtime.bigint() - t0; + const t1 = process.hrtime.bigint(); + deflatedFast += zlib.deflateRawSync(json, { level: 1 }).length; + deflateFastNs += process.hrtime.bigint() - t1; + + const playerJson = JSON.stringify({ ...msg, data: [arr[result.header.playerCarIndex]], playerCarIndex: 0 }); + playerOnly += Buffer.byteLength(playerJson); + const t2 = process.hrtime.bigint(); + playerDeflated += zlib.deflateRawSync(playerJson).length; + playerDeflateNs += process.hrtime.bigint() - t2; +} + +const seconds = (lastTs - firstTs) / 1000; +const kibs = (bytes: number) => (bytes / seconds / 1024).toFixed(1); +const cpu = (ns: bigint) => ((Number(ns) / 1e9 / seconds) * 100).toFixed(3); +let total = 0; +let count = 0; +for (const [type, entry] of Object.entries(bytesByType)) { + total += entry.bytes; + count += entry.count; + console.log(`${type}: ${entry.count} msgs, avg ${(entry.bytes / entry.count).toFixed(0)} B, ${kibs(entry.bytes)} KiB/s`); +} +console.log(`all 22 cars: ${count} msgs per pass, ${(total / 2 ** 20).toFixed(1)} MiB per pass, ${kibs(total)} KiB/s, ${(((total / seconds) * 3600) / 2 ** 30).toFixed(2)} GiB/h`); +console.log(`deflate per message, default level: ${kibs(deflated)} KiB/s (${(total / deflated).toFixed(1)}x), CPU ${cpu(deflateNs)}% of one core`); +console.log(`deflate per message, level 1: ${kibs(deflatedFast)} KiB/s, CPU ${cpu(deflateFastNs)}% of one core`); +console.log(`player car only: ${kibs(playerOnly)} KiB/s; deflated ${kibs(playerDeflated)} KiB/s, CPU ${cpu(playerDeflateNs)}% of one core`); +console.log(`span ${seconds.toFixed(1)} s`); diff --git a/specs/003-demo-mode/evidence/ws-sink.mjs b/specs/003-demo-mode/evidence/ws-sink.mjs new file mode 100644 index 0000000..df6e112 --- /dev/null +++ b/specs/003-demo-mode/evidence/ws-sink.mjs @@ -0,0 +1,16 @@ +// Cliente WebSocket que só conta mensagens, num processo separado, para que a CPU +// medida em measure-compression.ts seja só a do servidor. +// Uso: node specs/003-demo-mode/evidence/ws-sink.mjs +import { WebSocket } from "ws"; + +const [url, expected] = process.argv.slice(2); +const client = new WebSocket(url); +let received = 0; +client.on("message", () => { + received += 1; + if (received === Number(expected)) { + process.stdout.write("done\n"); + client.close(); + } +}); +client.on("open", () => process.stdout.write("open\n")); diff --git a/specs/003-demo-mode/plan.md b/specs/003-demo-mode/plan.md new file mode 100644 index 0000000..370ec18 --- /dev/null +++ b/specs/003-demo-mode/plan.md @@ -0,0 +1,235 @@ +# Implementation Plan: Demo mode for a public demonstration + +**Branch**: `003-demo-mode` | **Date**: 2026-10-21 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/003-demo-mode/spec.md` + +## Summary + +A demo mode, selected with `DEMO_MODE=true` and off by default, turns the server into a self-contained public demonstration that fits Render's free web service (one port, 0.1 CPU, 512 MB, temporary disk, sleep after 15 minutes, 5 GB of outbound bandwidth a month). At start it verifies the published recording against the README's SHA-256, builds the whole session in an in-memory database by feeding every packet through the existing parsers and persistence (measured: 282 ms locally, 2,411 + 2,411 samples, exactly spec 001's ground truth), rewrites the session's and laps' dates to the recording's capture times, seeds the coach cache from a versioned file tied to the recording (checking each entry's insights against today's analysis with a cross-platform tolerance), switches the database to read-only, and only then listens. REST and WebSocket share the platform's `PORT`; the live stream is the existing spec 001 replay loop at `1x`, in-process and broadcast-only, carrying only the player's car, compressed with per-message deflate, to at most 5 connections of at most 5 minutes each, with their own close codes. CORS and the WebSocket handshake accept only the frontend's origin (a browser-only limit); reads are cacheable by revalidation; the coach never calls Anthropic, because demo mode refuses to start with a key. A build configuration compiles to `dist/` and `npm start` runs it. Local mode is unchanged. + +With the decisions of the plan review, a full visit costs about 2.2–3.8 MB, so the 5 GB allowance covers **roughly 1,330–2,410 full visits a month**, against about 165–175 at the clarified settings ([research.md](research.md), point 1). The per-IP limit (FR-027) is not adopted, because the platform's client-IP forwarding cannot be confirmed from its documentation. + +## Technical Context + +**Language/Version**: Node.js ≥ 22.17 (major 22 from `.nvmrc`), TypeScript 5.6.3, `strict`, CommonJS. Unchanged. + +**Primary Dependencies**: no new dependency, runtime or development. `ws` 8.18.0 (`noServer` + `upgrade`, `perMessageDeflate`), Express 5.2.1, `cors` 2.8.6, `better-sqlite3`; built-ins `node:http`, `node:crypto` (SHA-256) and the global `fetch` (download). + +**Storage**: SQLite through the existing repositories. Local mode: `data/telemetry.db` (or `DB_PATH`), now resolved from the project root. Demo mode: `:memory:`, built at every start, read-only (`PRAGMA query_only = ON`) once seeded. No schema change ([data-model.md](data-model.md)). + +**Testing**: `node:test` + `node:assert/strict` via `ts-node/register`, discovered from `src/**/*.test.ts` with no script edit. TDD for the new pure functions, each test seen failing on its own assertion first; one demo integration test against a synthetic recording generated by the test. + +**Target Platform**: Linux on Render's free web service for demo mode (build `npm ci && npm run build`, start `npm start`, configured at deploy time); the maintainer's Windows machine for local mode, development and the seed export. + +**Project Type**: single backend web service plus CLI tools. + +**Performance Goals**: ready within the platform's start window (measured locally: 121 ms decode + 55–72 ms hash + 282 ms build; about ten times that estimated on 0.1 CPU); the live stream at the recording's own 20 Hz; ≤ 10 KiB/s per live connection on the wire (6.7 KiB/s measured, SC-012). + +**Constraints**: 512 MB memory (measured peak RSS 171 MiB under ts-node, about 80 MiB of it ts-node's baseline, plus about 256 KiB of zlib state per compressed connection); 0.1 CPU (compression estimated at about 11–12% of the instance per client); 5 GB outbound a month; no paid call except the maintainer's manual seed export; nothing ever written to or read from `data/telemetry.db` by demo mode, the tests or the verification steps. + +**Scale/Scope**: one session (the published recording), ≤ 5 simultaneous live connections, about 1,330–2,410 full visits a month within the allowance; no change to analysis, parsing or the coach's prompt. + +No `NEEDS CLARIFICATION` remains. + +## Constitution Check (v1.1.0) + +*Gate before Phase 0, re-checked after Phase 1 and after the plan review. Result: pass, no violation, no amendment.* + +| Principle / section | Status | How this plan complies | +|---|---|---| +| I. Validated binary parsing | ✅ Pass | No packet layout, field order or size changes; the demo feeds the existing parsers unchanged. Nothing in `src/telemetry/` is touched. | +| II. Ingestion never crashes; configuration fails fast | ✅ Pass | The in-process loop keeps a per-packet try/catch like `server.ts`. Every demo setting is validated before the database opens or a port binds; an invalid `DEMO_MODE` value fails instead of falling back to local; a missing recording, a mismatching seed or insights beyond the tolerance stop the start with a clear message. The coach degrades to its existing 503. | +| III. Deterministic analysis; the AI only explains | ✅ Pass | `src/analysis/` untouched. The coach still receives only `LapInsights`. Demo mode makes no paid call; the export tool reuses every pair already in the seed file with the same recording, model and insights, so a second run makes zero calls. The start-time check keeps the served text tied to what today's engine computes. | +| IV. Record what is known; never invent | ✅ Pass | Dates come from the recording's `receivedAt`; if any is missing, demo mode refuses to start. Measured numbers only; CPU figures on 0.1 CPU and the bandwidth range are labelled as estimates with their unknowns. FR-027 is recorded as not adopted rather than built on an unconfirmed assumption. | +| V. Simple, embedded infrastructure | ✅ Pass | No new dependency. SQLite stays embedded (in memory in demo mode). The new repository writes use prepared statements created at module load and the existing snake_case/camelCase mapping. | +| VI. This repository owns the API contract | ✅ Pass | Every change the frontend sees is listed below and in [contracts/](contracts/), including the single-car content of the WebSocket message as a contract change despite the unchanged shape. No existing route, field, status or error string changes. | +| Technology and quality constraints | ✅ Pass | Node ≥ 22.17, TS strict, CommonJS. New pure logic has unit tests. Tests use synthetic data. Logs and test names in Portuguese, identifiers in English, minimal comments. No secret committed; the key is never set on the platform. | +| Development workflow | ✅ Pass | Spec, plan and tasks committed (`docs:`) before implementation; `npm test` and `npm run typecheck` before every commit; the CI's two checks unchanged (the build check is future work, below); README and CLAUDE.md change in the same commit as each behaviour; known limitations added to both. | +| Governance | ✅ Pass | No amendment is needed, so the Sync Impact Report at the top of the constitution stays as is (its removal was asked for only together with an amendment). | + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-demo-mode/ +├── spec.md +├── plan.md # this file +├── research.md # Phase 0 +├── data-model.md # Phase 1 +├── quickstart.md # Phase 1 +├── contracts/ # Phase 1 +│ ├── websocket.md +│ ├── rest.md +│ ├── coach-seed-file.md +│ └── configuration.md +├── evidence/ # measurement scripts behind research.md +│ ├── fetch-recording.mjs +│ ├── measure-ws-volume.ts +│ ├── measure-preload.ts +│ ├── measure-compression.ts +│ └── ws-sink.mjs +├── checklists/requirements.md +└── tasks.md # Phase 2 (/speckit-tasks), not created here +``` + +### Source code (repository root) + +```text +src/ +├── config/ +│ ├── mode.ts # NEW: reads and validates DEMO_MODE; imported by env.ts and dbPath.ts +│ ├── demoEnv.ts # NEW: pure parseDemoConfig(env) + the demo's fail-fast messages +│ ├── projectRoot.ts # NEW: walks up to package.json, so src/ and dist/ agree +│ ├── env.ts # CHANGED: reads the three ports only in local mode +│ └── dbPath.ts # CHANGED: project root; ':memory:' in demo mode +├── demo/ # NEW: everything only demo mode runs +│ ├── recording.ts # read .jsonl with receivedAt, SHA-256 check, PUBLISHED_RECORDING_SHA256 +│ ├── captureTimes.ts # pure buildCaptureTimes (no persistence import) +│ ├── applyCaptureTimes.ts # writes the capture times through the repositories (loads persistence/db) +│ ├── buildDemoSession.ts # in-process build of the whole session (shared with the export tool) +│ ├── insightsMatch.ts # pure structural comparison with the numeric tolerance (T-5) +│ ├── coachSeed.ts # pure: the seed file's types, validateCoachSeed and assertExportEnvironment (no persistence import) +│ ├── testFixtures.ts # synthetic demo recordings (N laps, receivedAt) from the packet builders src/replay/testFixtures.ts exports +│ ├── applyCoachSeed.ts # inserts the seed entries (loads persistence/db) +│ ├── exportCoachSeed.ts # the export core, with an injected coach (loads persistence/db) +│ ├── purityBoundary.test.ts # the pure modules never load persistence/db +│ ├── playerOnly.ts # pure toPlayerOnlyMessage +│ ├── liveConnections.ts # pure origin and capacity decisions +│ ├── demoWsServer.ts # attachDemoWsServer(httpServer, options): origin, capacity, time limit, compression, socket bytes +│ ├── demoLog.ts # counters (socket bytes, 503s, CPU) and the 15-minute summary line +│ └── demoServer.ts # wiring: start order, HTTP + WS on PORT, 1x loop, shutdown +├── network/wsBroadcaster.ts # CHANGED: only exports its heartbeat helper (startHeartbeat); standalone path unchanged; no import from src/demo/ +├── http/ +│ ├── httpServer.ts # CHANGED: createApp(options) shared by both modes; startHttpServer(port) unchanged +│ └── routes/healthRouter.ts # NEW: GET /health +├── persistence/ +│ ├── persistTelemetry.ts # CHANGED: returns the completed lap (null otherwise); callers may ignore it +│ ├── sessionContext.ts # CHANGED: handleLapDataPacket returns the completed lap +│ ├── sessionRepository.ts # CHANGED: + capture-time statement for the session row +│ └── lapRepository.ts # CHANGED: + capture-time statement for lap rows +└── server.ts # CHANGED: local wiring moved into a function; demo mode delegates to demo/demoServer.ts + +tools/ +├── fetchDemoRecording.ts # NEW: build-time download + SHA-256 +└── exportCoachSeed.ts # NEW: the maintainer's paid, idempotent seed export (CLI over the core in src/demo/) + +demo/coach-seed.json # NEW: produced by the maintainer (quickstart.md, section D), versioned +tsconfig.build.json # NEW: outDir dist, src + tools, tests excluded +package.json # CHANGED: build, start, export-coach-seed scripts +.env.example # CHANGED: a commented demo-mode block +README.md, CLAUDE.md # CHANGED with each behaviour (see Documentation) +``` + +**Structure decision**: the existing single-project layout, with one new folder, `src/demo/`, holding every module that only demo mode runs, so local mode's files change only where a shared seam is needed (configuration, the HTTP app factory, the WebSocket heartbeat helper, the lap-completion return value). The pure demo modules (`captureTimes`, `coachSeed`, `insightsMatch`, `playerOnly`, `liveConnections`, `recording`, `demoLog`'s summary, and `config/demoEnv`, `config/mode`, `config/projectRoot`) never import persistence, because `persistence/db.ts` opens its connection at import; everything that writes lives in its own module (`applyCaptureTimes.ts`, `applyCoachSeed.ts`, `exportCoachSeed.ts`), loaded by tests only after `setupIsolatedTestDb()`, and `src/demo/purityBoundary.test.ts` checks the rule through `require.cache` (decided at the stop before T044, 2026-10-21). The generic network layer (`src/network/`) never imports from `src/demo/`: the demo's WebSocket server lives in `src/demo/demoWsServer.ts` and takes only the shared heartbeat helper from `src/network/wsBroadcaster.ts` (decided before the push of Phase 3, 2026-10-21). The HTTP layer (`src/http/`) does not either, not even a type: the `RestCounter` interface that the demo's REST counter implements is declared in `src/http/httpServer.ts`, its consumer, and `src/demo/demoLog.ts` imports it from there (decided before the push of Phases 6–7, 2026-10-23). + +## Design + +### Start sequence in demo mode (`src/demo/demoServer.ts`) + +1. `parseDemoConfig(process.env)`: `DEMO_MODE`, `PORT`, `DEMO_ALLOWED_ORIGIN`, limits (5 connections, 5 minutes), `DEMO_WS_COMPRESSION` (default `true`), paths; refuse `ANTHROPIC_API_KEY` and `DB_PATH` ([contracts/configuration.md](contracts/configuration.md)). Nothing opened yet. +2. Read the recording, compute its SHA-256, compare with the seed file's `recordingSha256`; in production that value equals `PUBLISHED_RECORDING_SHA256`, which a unit test ties to the README. A missing recording stops the start (T-4). +3. Open the database (`:memory:`), then `buildDemoSession(packets)`: every packet through `dispatchPacket` + `persistTelemetry`, collecting the completed laps' `receivedAt`. +4. `buildCaptureTimes(...)` → `applyCaptureTimes(sessionId, times)`. +5. Validate the seed ([contracts/coach-seed-file.md](contracts/coach-seed-file.md)), including `insightsMatch(entry.insights, buildInsights(pair))` for every entry (research.md, point 7); insert the entries. +6. `PRAGMA query_only = ON`. +7. Create the HTTP server from `createApp({ demo })`; `attachDemoWsServer(server, { allowedOrigin, maxConnections, maxConnectionMs, perMessageDeflate: config.wsCompression ? {} : false, onEvent })`; `listen(PORT, "0.0.0.0")`. `DEMO_WS_COMPRESSION=false` is the way out, without a code deploy, if the summary's process CPU time shows the instance saturated; the start log states whether compression is on. +8. `runReplay(packets, { speed: "1x", loop: true }, { send })`, where `send` dispatches the buffer and broadcasts `toPlayerOnlyMessage(buildWsMessage(...))` for motion, lap data and car telemetry, without persisting. `onPass` logs each pass. +9. Log "pronto" with the elapsed time; start the 15-minute summary (connections, WebSocket and REST socket bytes, coach 503s, process CPU time). +10. `SIGTERM`/`SIGINT`: abort the replay, close the connections with `1001`, close the server and the database. + +### Local mode + +`server.ts` keeps today's wiring (standalone WebSocket on `WS_PORT` without compression, Express on `HTTP_PORT`, UDP on `UDP_PORT`, per-packet logs), moved into a function and selected when `DEMO_MODE` is unset or empty. `createApp()` with no demo options builds the same Express app as today (`cors({ origin: true })`, the same routers) plus `GET /health`. + +### Test strategy (TDD) + +For each pure function, the order is: create the module with the final signature and a body that returns a neutral wrong value (never a missing export or a thrown "not implemented"), write the test, run it and **see it fail on its own `assert`**, then implement until green. A test that fails on an import, a type error or a thrown stub does not count as red. + +| Pure function | Module | What the tests pin | +|---|---|---| +| `toPlayerOnlyMessage` | `src/demo/playerOnly.ts` | one-element `data` equal to the player's car; `playerCarIndex` 0; other fields untouched; the input not mutated | +| `buildCaptureTimes` | `src/demo/captureTimes.ts` | ISO strings for start, last seen and each lap; throws when a `receivedAt` is missing; last ≥ first | +| `decideOrigin` | `src/demo/liveConnections.ts` | exact match accepted; other origin refused; absent `Origin` accepted; trailing-slash and case variants refused | +| `decideCapacity` | `src/demo/liveConnections.ts` | below the limit accepted; at the limit "full" (4001); the limit configurable | +| `parseDemoConfig` | `src/config/demoEnv.ts` | refuses a non-empty `ANTHROPIC_API_KEY` and `DB_PATH` with the exact messages; requires `PORT` and `DEMO_ALLOWED_ORIGIN`; rejects an origin with a path; defaults 5 connections and 5 minutes; rejects `DEMO_MODE=yes`; `DEMO_WS_COMPRESSION` unset or empty → on, `false` → off, `no` → refused | +| `insightsMatch` | `src/demo/insightsMatch.ts` | equal objects match; 0.35 vs 0.36 matches; 0.35 vs 0.37 does not; a one-ulp difference in a copied distance matches; a corner more or less, a changed boolean or `severity`, `null` vs a number, a missing or extra key do not; the result names the first differing path | +| `validateCoachSeed` | `src/demo/coachSeed.ts` | accepts a valid file; refuses another recording's hash, another session UID, an unknown lap, a duplicate pair, `lapA = lapB`, an unknown `formatVersion`, insights beyond the tolerance | +| `findProjectRoot` | `src/config/projectRoot.ts` | from source, the folder holding this repository's `package.json` | +| README hash | `src/demo/recording.test.ts` | `README.md` contains `PUBLISHED_RECORDING_SHA256`; the committed `demo/coach-seed.json` names it (added once the maintainer has produced the file, so the test is never skipped); a mismatching file is refused | +| Seed export core | `src/demo/exportCoachSeed.ts` | with a fake coach, on a two-lap synthetic recording: one call per pair on an empty seed, zero on a second run, one after one entry is made stale or another model, all again for another recording; a coach failing on the second call leaves one paid pair in the progress, and a run from that partial file makes one call; the progress keeps the previous entries of pairs not yet processed | +| `writeCoachSeedFile` | `src/demo/coachSeed.ts` | writes the JSON to `.tmp` in the same folder and renames it over the file: readable JSON, folder created, no temporary left, an existing file replaced whole | +| `assertExportEnvironment` | `src/demo/coachSeed.ts` | with fake roots (`C:\projeto`, `/projeto`) and an explicit `platform`, so the Windows cases also run on the Linux CI: refuses an unset `DB_PATH`, every spelling of `data/telemetry.db` (resolved from the root; on Windows case-insensitive and with both separators: `./data/…`, `data\…`, a `..` that comes back), and a missing key; accepts `:memory:` and a temporary path | + +**What the synthetic tests do not cover.** The export and seed tests run on synthetic recordings of a few samples per lap, so `buildInsights` returns nearly empty insights: they exercise the reuse logic, the fake coach's calls and the validation rules, not the real analysis. The real analysis, and the insight tolerance against it, are exercised only by the maintainer's export on the published recording (tasks.md T061) and by demo mode checking that seed at start (T084). The tool's refusal of `data/telemetry.db` is tested only through `assertExportEnvironment` with a fake root; the spawned test of the tool covers only the refusals that need no real path (no `DB_PATH`; a temporary `DB_PATH` with no key), so no test ever puts the real database's path in an environment. + +The request-limit function is not built, because FR-027 is not adopted. + +**In-process tests with an isolated database** (`testDb.ts`): `persistTelemetry` returns the completed lap; `applyCaptureTimes` rewrites the rows; after `query_only`, an insert fails; `attachDemoWsServer` (`src/demo/demoWsServer.ts`) on an ephemeral port with `maxConnectionMs` of a few hundred milliseconds closes with 4002, the second connection with a limit of 1 closes with 4001, a foreign origin gets 403, a client that offers per-message deflate gets `permessage-deflate` in `extensions`, and with compression turned off the same client gets no extension. + +**Demo integration test** (spawned, like `serverFailFast.test.ts`): a synthetic recording with `receivedAt` and a matching synthetic seed (its insights computed by the test from the same synthetic laps), both written by the test to a temporary folder; the server in demo mode on a free port; asserts one session with the synthetic capture dates, the laps and samples, `Cache-Control` and a `304` on revalidation, the seeded pair cached and another 503, CORS for the allowed origin only, single-car compressed WebSocket messages, and that no file named `telemetry.db` appears anywhere it can see. Plus spawned refusals: the key present, `DB_PATH` present, the recording missing or altered, the seed for another recording, a seed whose insights differ by more than the tolerance; each exits non-zero with its message and binds no port. + +**SC-007** is checked twice: automatically, the existing suite (including the replay integration test and the fail-fast tests) stays green with demo mode off; and manually, quickstart.md section C repeats spec 001's measurement with a temporary `DB_PATH`, expecting **2,411 of 2,411** samples per table at `1x`. The in-process ground truth measured in research.md point 3 (2,411 + 2,411) is today's baseline for it. + +### Changes the frontend sees (for the frontend's spec 003) + +| # | Change | Mode | Details | +|---|---|---|---| +| 1 | REST and WebSocket on one host and port | demo | [contracts/websocket.md](contracts/websocket.md), [contracts/rest.md](contracts/rest.md) | +| 2 | WebSocket `data` holds only the player's car | demo | contract change, same shape | +| 3 | `playerCarIndex` is always 0 | demo | read the car through it, as today | +| 4 | Messages compressed with per-message deflate | demo | transparent to the browser's `WebSocket` | +| 5 | Close codes `4001` "demo full" and `4002` "time limit" (after **5 minutes**) | demo | reconnect only on the visitor's action | +| 6 | WebSocket handshake refused with `403` for a foreign origin | demo | only a misconfigured origin would see it | +| 7 | CORS only for the configured origin | demo | the frontend's production origin must equal `DEMO_ALLOWED_ORIGIN` exactly | +| 8 | `Cache-Control: no-cache` + ETag on reads (`304`), `no-store` on the coach and health | demo | transparent to `fetch` | +| 9 | Session dates are the capture times (2026-10-16, 4 min); session id 1 | demo | same fields | +| 10 | `GET /health` | both | addition | +| 11 | No `429` | demo | FR-027 not adopted | +| 12 | The coach's 503 body still mentions `ANTHROPIC_API_KEY` | demo | presentation is the frontend's decision | +| 13 | Only (8, 6) and (8, 7) are reachable with today's fixed primary lap; all six pairs are seeded | demo | letting the visitor change the primary lap would use the other four | +| 14 | Hosting the frontend in the same account as the API would spend the same 5 GB monthly allowance | — | a hosting decision for the frontend's spec | + +### Documentation, in the same commit as each behaviour + +| Behaviour | README.md | CLAUDE.md | +|---|---|---| +| Build, start, project root | Getting started: `npm run build`, `npm start` | Stack line ("no build step yet"), Commands, the "There are no `build`, `start` …" sentence | +| Demo configuration and refusals | new "Demo mode" section; configuration table | Configuration paragraph; the database-path paragraph (demo mode is in memory) | +| In-process build, capture dates, read-only | Demo mode section | Architecture table (`src/demo/`, `demo/`) | +| WebSocket on `PORT`, limits, single car, compression | Features bullet ("all 22 cars per message") and the Contract ownership paragraph; Demo mode section | the contract note under Architecture | +| HTTP caching; the platform compresses REST | Demo mode section | — | +| Coach seed, insight tolerance, export tool | Demo mode section; commands | Commands; the coach pitfalls still hold | +| Logging | Demo mode section | — | +| Known limitations (both files) | the local replay of an old recording stretches its session's duration to the current time (an existing behaviour, documented in the first commit of the feature); the 5 GB allowance ≈ 1,330–2,410 full visits a month, and a suspension until the next month without a payment method; no per-IP limit, so one client can spend the allowance; whether the browser's pong frames keep the instance awake is undocumented; the demo shows only the player's car; the six seeded pairs while today's frontend reaches two; compression costs an estimated 11–12% of the free instance's CPU per connection | same entries under Known pitfalls | + +## Decisions taken at the plan review (2026-10-21) + +| # | Question | Decision | Where it lands | +|---|---|---|---| +| T-1 | Does the live loop persist? | No: broadcast only; read-only database after seeding | FR-005, FR-015, SC-014 | +| T-2 | Six pairs, two reachable | Keep six; known limitation | Story 5, FR-021 | +| T-3 | 5 GB a month | Per-message deflate on the single-car messages and a 5-minute limit; 20 Hz kept; REST left to the platform's documented compression | FR-024, FR-025, FR-027, SC-012; research.md points 1, 6, 9 | +| T-4 | Recording missing at start | Refuse to start | FR-007; research.md point 2 | +| T-5 | Seeded insights versus today's analysis | Refuse to start beyond a structural comparison with tolerance 0.01 + 1e-9, derived from the coach's serialization | FR-013, Story 5; research.md point 7 | +| T-6 | Per-IP limit through `CF-Connecting-IP` | Not in this feature; revisit after the deploy | FR-027 | +| T-7 | Integration fixture | Synthetic, generated by the test | research.md point 11 | + +The spec amendments listed by the first version of this plan (the 222.958 s capture span, FR-005, FR-017, FR-027 and FR-028, the fixed primary lap, the platform facts in Assumptions) and those that follow from the decisions above were applied to [spec.md](spec.md) in the same commit as this plan. + +## Future work (outside this spec) + +- **A build check in the API's CI**, in a small spec before the deploy: this feature adds `npm run build` with its own `tsconfig.build.json`, while the CI's `typecheck` uses `tsconfig.json`, so a break in the build configuration would not be caught. A new required check changes the ruleset of `main` (check names are a contract), which is why it is a separate piece of work. +- **Revisit FR-027** after the deploy, checking empirically which header carries the visitor's address. + +## Artifacts that do not apply + +- **Agent context file update**: not applicable; this Spec Kit installation has no update script (`.specify/scripts/powershell/` holds only `check-prerequisites`, `common`, `create-new-feature`, `resolve-template`, `setup-plan`, `setup-tasks`), and `CLAUDE.md` is updated by the documentation rows above instead. +- **A constitution amendment**: not needed (Constitution Check), so the Sync Impact Report removal does not happen in this feature. +- **A CI change**: not in this feature (future work above). +- All four Phase 1 artifacts apply and were produced: research.md, data-model.md, contracts/, quickstart.md. + +## Complexity Tracking + +Not needed: the Constitution Check has no violation. diff --git a/specs/003-demo-mode/quickstart.md b/specs/003-demo-mode/quickstart.md new file mode 100644 index 0000000..3df96c4 --- /dev/null +++ b/specs/003-demo-mode/quickstart.md @@ -0,0 +1,81 @@ +# Quickstart: validating demo mode + +Validation scenarios for the finished feature. None of them makes a paid call, and none touches `data/telemetry.db`: demo mode is in memory by construction, and the local-mode check uses a temporary `DB_PATH`. Section D is the maintainer's paid, one-off procedure; it is not part of the agent's validation. + +Prerequisites: `npm ci` done; Node from `.nvmrc`; the published recording downloaded and verified by `npm run build` (or already in `recordings/`); for section D only, the maintainer's own `ANTHROPIC_API_KEY`. + +**Keeping the key out of sections B and C.** `dotenv` loads `.env` from the working directory, or from `DOTENV_CONFIG_PATH`. Setting `ANTHROPIC_API_KEY=` empty is not enough: `cmd` and Windows PowerShell 5.1 remove an empty variable, and `dotenv` then loads the key from `.env`. So in sections B and C, point `DOTENV_CONFIG_PATH` at a scratch `.env` with no key (for C, a copy holding only `WS_PORT`, `UDP_PORT` and `HTTP_PORT`; for B, an empty file), or run from a working directory outside the repository, and first check, in the same shell: + +```bash +node -e "require('dotenv/config'); console.log(Boolean(process.env.ANTHROPIC_API_KEY))" # must print false +``` + +If it prints `true`, start nothing. Never print the key or the content of a `.env`. Section D is the only one that runs with the key. + +## A. Automated checks + +```bash +npm run typecheck +npm test +``` + +Expected: both pass. The suite includes the new unit tests (configuration, origin and capacity decisions, player-only message, capture times, insight comparison, seed validation, project root, README hash) and the demo integration test, which spawns the server in demo mode against a synthetic recording and seed generated by the test. + +## B. Demo mode locally (SC-001, SC-006, SC-008 to SC-013) + +```bash +npm run build +export DOTENV_CONFIG_PATH="/empty.env" # an empty file +node -e "require('dotenv/config'); console.log(Boolean(process.env.ANTHROPIC_API_KEY))" # false +DEMO_MODE=true PORT=10000 DEMO_ALLOWED_ORIGIN=http://localhost:5173 npm start +``` + +(PowerShell: set each variable with `$env:NAME = "value"` first.) Expected in the log: the `[demo]` start line, the build/hash/seed timings and "pronto" with the milliseconds until ready; no per-packet lines. + +| Check | Command | Expected | +|---|---|---| +| Health | `curl -i http://localhost:10000/health` | `200`, `{"status":"ok"}`, `Cache-Control: no-store` | +| One session, capture dates | `curl -s http://localhost:10000/sessions` | one session, `startedAt` `2026-10-16T16:30:23.797Z`, `lastSeenAt` `2026-10-16T16:34:06.755Z`, `lapCount` 3, best `73372` | +| Laps | `curl -s http://localhost:10000/sessions/1` | laps 6 (74306, invalid), 7 (74144), 8 (73372) with the README's sectors | +| Samples | `curl -s http://localhost:10000/sessions/1/laps/8/telemetry` | 793 car telemetry and 793 motion samples | +| Revalidation | repeat the last request with `-H 'If-None-Match: '` | `304`, no body; first response had `Cache-Control: no-cache` | +| Seeded pair | `curl -s -X POST -H 'content-type: application/json' -d '{"lapA":8,"lapB":7}' http://localhost:10000/sessions/1/analysis` | `200`, `cached: true`, `Cache-Control: no-store` | +| Unseeded pair | same with an unseeded pair (or any pair before section D has produced the seed) | `503`, existing body | +| CORS allowed | `curl -si -H 'Origin: http://localhost:5173' http://localhost:10000/sessions` | `Access-Control-Allow-Origin: http://localhost:5173` | +| CORS other | same with `Origin: https://example.com` | no `Access-Control-Allow-Origin` | +| WS origin | open a WebSocket to `ws://localhost:10000/` with `Origin: https://example.com` | handshake refused, `403` | +| WS single car | open one with the allowed origin | messages with `data.length === 1`, `playerCarIndex === 0` | +| WS compression | the same connection, from a browser or a client that offers it | `permessage-deflate` negotiated (the browser's network panel shows it in `Sec-WebSocket-Extensions`); restarted with `DEMO_WS_COMPRESSION=false`, no extension is negotiated and the start log says compression is off | +| WS capacity | open 6 | the sixth closes with `4001` `"demo full"`; after closing one, a new one opens | +| WS time limit | keep one open 5 minutes (or start with `DEMO_MAX_CONNECTION_MINUTES=1`) | closed with `4002` `"time limit"` | +| Summary | wait 15 minutes | one `[demo]` summary with connections, WS/REST/total socket bytes, coach 503s and process CPU time | + +Refusals at start, each with a clear message and no port bound: `ANTHROPIC_API_KEY` set; `DB_PATH` set; `DEMO_ALLOWED_ORIGIN` missing or with a path; `DEMO_MODE=yes`; `DEMO_WS_COMPRESSION=no`; the recording missing or altered; the seed file missing, malformed or for another recording; a seed entry whose insights differ beyond the tolerance (edit one number in a copy of the seed by 0.05 and point `DEMO_SEED_PATH` at it). + +## C. Local mode unchanged (SC-007) + +Same method as spec 001's [baseline-measurement.md](../001-realtime-replay/baseline-measurement.md), with a temporary database: + +```bash +export DOTENV_CONFIG_PATH="/ports.env" # only WS_PORT, UDP_PORT, HTTP_PORT +node -e "require('dotenv/config'); console.log(Boolean(process.env.ANTHROPIC_API_KEY))" # false +DB_PATH="/telemetry.db" npm run dev # demo mode off +npm run replay -- recordings/demo-interlagos-tt-20hz-laps6-8.jsonl # same shell settings, another terminal +# then count rows in the temporary database +``` + +Expected: the startup log names the temporary database; 3 laps with the README's times; **2,411 of 2,411** car telemetry samples and **2,411 of 2,411** motion samples; per-packet log lines as today; the WebSocket on `WS_PORT` with 22 cars per message. `data/telemetry.db` is not opened. + +Also, with demo mode off, `npm run build && npm start` resolves the database to `data/telemetry.db` at the repository root (startup log), not `dist/data/`; run it with a temporary `DB_PATH` and the same `DOTENV_CONFIG_PATH` and key check as above, to check without opening the real one, and confirm the log names that path. + +## D. The maintainer's one-off coach seed (paid; never run by the agent) + +1. In the Anthropic Console, set a monthly spending limit on the key's workspace. +2. In the maintainer's own shell, with the published recording in `recordings/`: + ```bash + DB_PATH=:memory: ANTHROPIC_API_KEY= npm run export-coach-seed + ``` + The tool refuses to run without `DB_PATH`, or with `DB_PATH` pointing at `data/telemetry.db`. It prints, for each of the six pairs, either "reused" or the call's token `usage`, and writes `demo/coach-seed.json`. +3. Run it a second time: it must report six "reused" and make no call. +4. Read the six texts: the file is public once committed, so each text must cite only numbers found in its own entry's `insights` or in the two benchmark lines the coach receives (personal best and theoretical best), and hold nothing but coaching about the two laps. A cited personal best must be 1:13.372 and a cited theoretical best 1:13.208, the recording's own (the export builds its database from the recording alone), never a time from another session. If one fails, delete that entry and run step 2 again (only that pair is called). Then hand the file to the agent, which commits it (tasks.md T064, `feat:`). +5. Remove the key from the shell. It is never set on the hosting platform: demo mode refuses to start with it. diff --git a/specs/003-demo-mode/research.md b/specs/003-demo-mode/research.md new file mode 100644 index 0000000..f60ca12 --- /dev/null +++ b/specs/003-demo-mode/research.md @@ -0,0 +1,539 @@ +# Research: Demo mode for a public demonstration + +**Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Date**: 2026-10-21 + +Every point gives the evidence first (command output, or the official documentation with its link and the date it was read), then the decision. Commands were run on the maintainer's machine (Windows 11, Node 22.23.1, Intel i7-9750H), from the `003-demo-mode` branch. No paid call was made. Nothing touched `data/telemetry.db`: the measurements use `:memory:` or a temporary file in the session's scratch folder, and the scripts refuse `data/telemetry.db` explicitly. The measurement scripts are versioned in [evidence/](evidence/) so they can be re-run; the published recording was downloaded into an empty scratch folder by [evidence/fetch-recording.mjs](evidence/fetch-recording.mjs). + +Documentation pages were read on **2026-10-21**. Render articles (under `render.com/articles/`) are Render's own content but not the reference documentation; they are cited as such. + +--- + +## 1. The hosting platform (Render, free web service) + +### Evidence + +| Topic | What the official source says | Source | +|---|---|---| +| Single port | "Render forwards inbound traffic to only *one* HTTP port per web service." "always bind your public HTTP server to the value of the `PORT` environment variable." Bind on host `0.0.0.0`. "The default value of `PORT` is `10000`." | [Web Services](https://render.com/docs/web-services); [Default Environment Variables](https://render.com/docs/environment-variables) | +| Spin-down | "Render spins down a Free web service that goes 15 minutes without receiving any inbound traffic." "This includes both HTTP requests and WebSocket messages from existing connections." Spin-up "takes about one minute". | [Deploy for Free](https://render.com/docs/free) | +| WebSocket messages as activity | Changelog of **2026-02-24**: free web services "now spin down after going 15 minutes without receiving *either*" an incoming HTTP request or "An incoming WebSocket message from an existing connection". "Previously, only incoming HTTP requests delayed spindown." It does not mention messages sent by the server, nor ping/pong control frames. | [Changelog](https://render.com/changelog/free-web-services-now-remain-active-while-receiving-websocket-messages) | +| Instance hours | "750 Free instance hours" per workspace per calendar month; spun-down services do not consume them; when they run out, all Free web services are suspended until the next month. | [Deploy for Free](https://render.com/docs/free) | +| CPU and memory | Plan `free`: "0.1 CPU", "512 MB". | [Compute Plans](https://render.com/docs/compute-plans) | +| Outbound bandwidth | Hobby workspace: **5 GB per month** included; "$0.15 per GB" beyond it. HTTP responses and WebSocket messages sent to the public internet count; inbound traffic does not. Free page: without a payment method, Render "suspends all of your Free services for the remainder of the month". Render "may suspend a Free web service that initiates an uncommonly high volume of traffic". | [Outbound Bandwidth](https://render.com/docs/outbound-bandwidth); [Deploy for Free](https://render.com/docs/free) | +| WebSocket | "Render does not enforce a maximum duration for WebSocket connections." Connections "close automatically when the instance is replaced (for example, during a deploy)"; `SIGTERM` then a 30-second grace period. Outbound WebSocket messages count as outbound bandwidth. | [WebSockets on Render](https://render.com/docs/websocket) | +| Temporary disk | Ephemeral filesystem: local changes, "such as uploads or SQLite files", are lost on redeploy, restart or spin-down. No persistent disk on Free. "Render might restart a Free web service at any time." | [Deploy for Free](https://render.com/docs/free) | +| Health checks | GET to a configured path "every few seconds"; any `2xx`/`3xx` is healthy, must answer within five seconds; without a path, a TCP probe on the port. During a deploy, traffic is not routed to the new instance until it passes; after 15 minutes Render cancels the deploy and keeps the existing one. On a running service: 15 s of failures stop routing, 60 s restart the instance. The page does not say whether Free services support it (the service creation steps list it under Advanced). | [Health Checks](https://render.com/docs/health-checks) | +| Deploys | A failed build or deploy leaves the last good deploy running, "with zero downtime". Build command timeout 120 min, start command 15 min. The page does not say explicitly that build outputs are present at run time. | [Deploying on Render](https://render.com/docs/deploys) | +| Build environment | `NODE_ENV` is `production` "(runtime only)"; the other default variables are available at build time and run time. | [Default Environment Variables](https://render.com/docs/environment-variables) | +| Client IP | Reference docs: no page found that names the header or the number of proxies. Render article (2026-04-01): traffic "passes through Cloudflare's global network" and "Render's load balancers"; "read the client IP from `x-forwarded-for`". Render article (2026-07-08): "Cloudflare appends to `X-Forwarded-For` rather than replacing it, so a caller who sends their own copy of the header controls the leftmost entry"; it recommends `CF-Connecting-IP`, which Cloudflare "overwrites whatever the caller sent". Neither gives a hop count. | [How Render handles DDoS attacks](https://render.com/articles/how-render-handles-ddos-attacks); [Host PocketBase on Render](https://render.com/articles/host-pocketbase-on-render) | + +### Consequences + +- **One port.** REST and WebSocket share the `PORT` the platform gives (point 5). +- **Sleep and the connection time limit.** Only messages *received* by the service keep it awake. In this project the browser sends nothing but the automatic pong replies to the server's 15-second pings; whether a pong counts as a "WebSocket message" is not documented. Either way, the 5-minute limit (FR-024) ends every connection, so an abandoned tab keeps the instance awake for at most 5 minutes plus the 15-minute idle window. Instance hours: a month has at most 744 hours, below the 750 included, so one service running all month fits **if it is the only Free web service in the workspace**; the hours become a risk only with other Free services in the same workspace. +- **Bandwidth is the binding limit.** 5 GB per month, and a suspension (not a charge) without a payment method. At the clarified settings (single car, uncompressed, 10 minutes), a full visit cost about 27.6 MiB of live stream plus 1.1–1.7 MB of REST, about 30 MB, so the allowance covered only **about 165–175 full visits a month**. With the settings decided at the plan review (T-3: per-message deflate and 5 minutes; 20 Hz kept), recomputed from the measurements of points 6 and 9: + + | Part of a full visit | Bytes | + |---|---| + | Live stream, 5 minutes at 6.7 KiB/s on the wire | 2,010 KiB ≈ **2.06 MB** | + | REST, history + session + 2 laps (8, 7) + 2 coach answers | 1.14 MB uncompressed, about 0.17 MB with the platform's Brotli/gzip | + | REST, the same with 3 laps (8, 7, 6) | 1.70 MB uncompressed, about 0.27 MB compressed | + | **Total per full visit** | **about 2.2–2.3 MB** if bandwidth is counted after the platform's compression; **about 3.2–3.8 MB** if before | + + **5 GB therefore covers roughly 1,330–2,410 full visits a month**: about 1,330–1,680 if REST is counted uncompressed, about 2,150–2,410 if compressed (each range spans the 10⁹ / 2³⁰ reading of "GB", which the page does not define, and 2 or 3 laps). The documentation does not say whether bandwidth is counted before or after the platform's compression (point 9). The coach answers (about 10 KB each: 3.7–6.4 KB of insights plus the text) and response headers are small against the stream and not itemized. A visitor who reconnects after the 5 minutes spends another 2.06 MB per connection. +- **CPU.** 0.1 CPU. Every local timing in this file is on a full core of an i7-9750H; on the instance, the same work takes on the order of ten times longer (an estimate, not a measurement: the deploy is out of scope). The heaviest steady cost is the WebSocket compression, 1.1–1.2% of a local core per client (point 6), so roughly 11–12% of the instance per client and 55–60% with all 5 connections open, on top of the loop itself (estimates). FR-028's "time until ready" line and the summary's process CPU time measure both on the platform. +- **Disk.** Nothing written at run time survives; the demo database lives in memory (point 3). +- **Health check.** The server listens only once the demo is ready (point 3), so both the TCP probe and an HTTP probe of the health path fail until then and succeed after; a deploy that never becomes ready is cancelled and the previous one keeps serving. +- **Build.** `NODE_ENV` is unset during the build, so `npm ci` installs the devDependencies (TypeScript) the build needs. +- **FR-027 (per-IP limit) is not adopted.** The maintainer's condition was that the plan confirm how the platform forwards the visitor's address and configure the proxy trust with the exact number of hops. The reference documentation does not state either; the two Render articles name at least two proxy layers (Cloudflare and Render's load balancer) without a count, and say the leftmost `X-Forwarded-For` entry is caller-controlled. A hop count cannot be confirmed from the documentation, and verifying it empirically needs a deployed instance (out of scope). Per the clarification, only the HTTP caching (FR-026) applies, and this is the recorded reason. The `CF-Connecting-IP` route is a different mechanism from the one the maintainer specified; it was put to the maintainer as T-6 and not adopted (plan review, 2026-10-21). + +--- + +## 2. The recording on the server + +### Evidence + +```text +$ node specs/003-demo-mode/evidence/fetch-recording.mjs /demo.jsonl +status 200, final host release-assets.githubusercontent.com, content-length 25173414 +bytes 25173414, download 4206 ms, sha256 55 ms +sha256 c41bdfaf7ff0feb40702875b15031654742de5fdb10ea3a05c0a21f1ac70b033, matches README: true +missing asset: status 404 +``` + +(A first run, before the script was versioned, took 5,828 ms to download and 72 ms to hash, with the same result.) Node 22's built-in `fetch` follows GitHub's redirect to `release-assets.githubusercontent.com`; `node:crypto` computes the SHA-256. No dependency is needed. + +### Decision + +- A build-time tool, `tools/fetchDemoRecording.ts`, downloads the asset into `recordings/demo-interlagos-tt-20hz-laps6-8.jsonl` (gitignored), verifies the SHA-256 against the constant `PUBLISHED_RECORDING_SHA256` (the README's value), writes to a temporary name and renames only after the hash matches, and exits non-zero with a clear message on a non-2xx status, a network error or a mismatch. If a file with the right hash is already there, it does nothing (local runs do not download twice). +- The build fails on any of those errors, and Render keeps the previous deploy running ([Deploying on Render](https://render.com/docs/deploys)). +- At start, demo mode **always** re-verifies the hash (55–72 ms locally) and **refuses to start** if the file is missing or different; it never downloads at start (T-4, decided at the plan review). +- A unit test asserts that `README.md` contains `PUBLISHED_RECORDING_SHA256`, so the two cannot drift apart. + +### Alternatives considered + +- **Download at every start, or only when missing**: about 4–6 s locally per wake (more on 0.1 CPU) and a dependency on GitHub being reachable at a wake. Rejected (T-4). +- **Commit the recording**: forbidden by FR-007 and the project rules (25.2 MB). + +--- + +## 3. The replay inside the process, the complete session before "ready", and where the database lives + +### Evidence + +```text +$ npx ts-node --transpile-only --project tsconfig.json specs/003-demo-mode/evidence/measure-preload.ts :memory: +start: rss 82.6 MiB, heapUsed 37.1 MiB, external 11.8 MiB +read+decode 13960 packets: 121 ms; missing receivedAt: 0 +after decode: rss 143.4 MiB, heapUsed 65.3 MiB, external 25.6 MiB +[db] usando banco em :memory: +in-process preload: 282 ms +after preload: rss 171.0 MiB, heapUsed 90.2 MiB, external 25.6 MiB +laps [{"lap_number":6,"lap_time_ms":74306,"sector1_ms":19307,"sector2_ms":38182,"sector3_ms":16817,"is_valid":0,...}, + {"lap_number":7,"lap_time_ms":74144,"sector1_ms":18310,"sector2_ms":37654,"sector3_ms":18180,"is_valid":1,...}, + {"lap_number":8,"lap_time_ms":73372,"sector1_ms":18474,"sector2_ms":37573,"sector3_ms":17325,"is_valid":1,...}] +car samples per lap [{"lap_number":6,"n":797},{"lap_number":7,"n":801},{"lap_number":8,"n":793},{"lap_number":9,"n":20}] +motion samples per lap [{"lap_number":6,"n":797},{"lap_number":7,"n":801},{"lap_number":8,"n":793},{"lap_number":9,"n":20}] +totals {"car":2411,"motion":2411} +db size 0.67 MiB; journal_mode memory +[sessionContext] reinício de passada detectado na sessão 1 (overallFrameIdentifier caiu de 14111 para 9635) - reiniciando estado em memória +second pass (same process): 218 ms; counts after {"sessions":1,"laps":3,"car":2411,"motion":2411} + +$ ... measure-preload.ts /tmpdb2/preload.db +in-process preload: 718 ms +after preload: rss 165.3 MiB, heapUsed 71.4 MiB, external 27.8 MiB +db size 0.67 MiB; journal_mode wal +``` + +- Feeding every packet through the real `dispatchPacket` and `persistTelemetry`, as fast as possible, stores exactly what a `1x` UDP replay stores: **2,411 car telemetry and 2,411 motion samples**, the ground truth of spec 001 ([baseline-measurement.md](../001-realtime-replay/baseline-measurement.md)), and the laps, times, sectors and validity in the README. Decimation keys on the game's `sessionTime` and lap distance, not on wall-clock arrival, so speed does not change what is kept; the loss of `--speed=max` came from the UDP receive buffer, which an in-process feed does not have. +- A second pass in the same process changes nothing (dedup by `overallFrameIdentifier`, lap upsert), and the existing pass-restart detection fires as designed. +- In memory, the build takes 282 ms; with a temporary file in WAL mode, 718 ms. The database is 0.67 MiB either way. Peak RSS is about 171 MiB, of which about 80 MiB is ts-node's own baseline (the `start` line); the compiled server needs less. Both fit in 512 MB. +- Estimated on 0.1 CPU (about ten times the local numbers, not measured): read and decode about 1.2 s, hash about 0.6 s, build about 3 s; a few seconds in all, against the platform's one-minute wake. + +### Decisions + +- **Reuse, no socket.** A new `src/demo/` module reads the recording once (keeping the decoded `RecordedPacket[]` in memory, about 25 MiB), builds the session by calling `dispatchPacket` + `persistTelemetry` for every packet, then starts `runReplay` (spec 001, unchanged) with `{ speed: "1x", loop: true }` and an `io.send` that dispatches the buffer in-process. No UDP socket is created (FR-006). +- **Order at start**: validate the demo configuration → verify the recording's hash → open the database → build the whole session → write the capture times (point 4) → seed the coach cache (point 7) → switch the connection to read-only (see below) → start listening on `PORT` → start the `1x` loop → "ready". The server answers no request before that, because it is not listening yet (FR-009). Render holds and retries during the wake; the health check fails until the port is open. +- **Database in memory** (`:memory:`). It cannot be the real file, it is new on every start by construction, nothing on the temporary disk can be reused by mistake (FR-004), and it is faster. A temporary file was measured (718 ms vs 282 ms) and brings only a file to delete on each start. `dbPath.ts`, the only configuration file persistence imports, returns `:memory:` in demo mode, and demo mode refuses to start if `DB_PATH` is also set, so the two can never be combined by mistake. +- **The loop does not persist** (T-1, decided at the plan review; FR-005 reworded accordingly). After the build, every further write would be a no-op (identical lap upserts, ignored samples) plus `last_seen_at` refreshes, which FR-010 forbids. With a broadcast-only loop, the database is complete before "ready" and never written again, which lets the connection be switched to `PRAGMA query_only = ON` after seeding: any write from any code path then fails inside SQLite, a guarantee for FR-015 that does not depend on reviewing every route. + +### Alternatives considered + +- **Persist in the loop too** (FR-005 as first written): requires suppressing `touchSession` in demo mode inside `sessionContext.ts`, keeps writes running forever for no data gain, and rules out `query_only`. +- **Listen early and answer 503 while building**: a visitor who wakes the instance would get an error instead of waiting a few more seconds. + +--- + +## 4. Dates from `receivedAt`, for the session and its laps + +### Evidence + +From the same run of `measure-preload.ts`: + +```text +capture times: first 2026-10-16T16:30:23.797Z last 2026-10-16T16:34:06.755Z span 222.958 s +lap completed at [{"lap":6,"at":"2026-10-16T16:31:38.248Z"},{"lap":7,"at":"2026-10-16T16:32:52.362Z"},{"lap":8,"at":"2026-10-16T16:34:05.758Z"}] +sessions [{"id":1,...,"started_at":"2026-10-21T20:22:29.051Z","last_seen_at":"2026-10-21T20:22:29.051Z"}] +``` + +```text +$ node -e "" +first [ 0, 1792168223797 ] last [ 222839, 1792168446755 ] timestamp span 222839 receivedAt span 222958 offset min 1792168219812 max 1792168223917 +``` + +- Every one of the 13,960 lines has `receivedAt`. Without intervention, the build writes the wall-clock time of the build (2026-10-21 above), which is what FR-010 forbids. +- The capture-time span is **222.958 s**, not the 222.8 s in the README (which is the `timestampMs` span). The difference between `receivedAt` and `timestampMs` is not constant across the file (it varies by about 4.1 s), so the two fields were not produced from one clock reading in the published file; the cut it was made from is not in this repository and was not investigated. FR-010 names `receivedAt`, so the session shows 222.958 s, displayed by the frontend as `Math.round(3.716)` = **4 min**, as SC-010 expects. The spec's "222.8 s" in Story 2, scenario 3, is corrected in the same commit as this plan (see the plan's spec amendments). +- `strftime('%Y-%m-%dT%H:%M:%fZ','now')` and `Date.prototype.toISOString()` produce the same format (`2026-10-21T20:22:29.051Z`, `2026-10-16T16:30:23.797Z`), so capture times written from JavaScript are indistinguishable in shape from the times SQLite writes today. + +### Decision + +- `persistTelemetry` returns the lap that a lap data packet completed (`CompletedLap | null`, today discarded inside `handleLapDataPacket`). `server.ts` ignores the return value, so local mode is unchanged; the demo build records `lapNumber → receivedAt` of the completing packet. +- A pure function `buildCaptureTimes(firstReceivedAt, lastReceivedAt, completions)` returns the session's start and last-seen times and each lap's recorded time as ISO strings; it throws when any of them is missing (FR-010: refuse rather than invent). "First" and "last" are the first and last packets carrying the session's UID. +- Two new repository functions write them with two prepared statements (`UPDATE sessions SET started_at, last_seen_at`; `UPDATE laps SET recorded_at`), combined by `applyCaptureTimes(sessionId, times)` in `src/demo/applyCaptureTimes.ts` and called once by the demo build before seeding. No existing statement changes, so local mode keeps SQLite's wall-clock defaults. + +### Alternatives considered + +- **An injectable clock inside the repositories**: touches every insert statement and `sessionContext`'s touch interval, which local mode uses, for the same result. + +--- + +## 5. REST and WebSocket on the same port + +### Evidence + +- Installed versions: `ws` 8.18.0, `express` 5.2.1, `cors` 2.8.6, `typescript` 5.6.3 (`node -p "require('ws/package.json').version ..."`). +- `ws` README (`node_modules/ws/README.md`, section "Client authentication") shows the pattern: `new WebSocketServer({ noServer: true })`, `server.on('upgrade', ...)`, reject with `socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy()`, accept with `wss.handleUpgrade(...)`. +- `ws` defaults `perMessageDeflate: false` (`node_modules/ws/lib/websocket-server.js`, line 67), so no compression happens today. +- Valid close codes in `ws` (`node_modules/ws/lib/validation.js`, `isValidStatusCode`): 1000–1014 except 1004–1006, and 3000–4999. RFC 6455 §7.4.2 reserves 4000–4999 for private use. + +### Decisions + +- **Local mode**: unchanged, a standalone `WebSocketServer` on `WS_PORT` and Express on `HTTP_PORT`. +- **Demo mode**: one `http.Server` created from the Express app, listening on `PORT` and `0.0.0.0`; the `WebSocketServer` uses `noServer: true`, and the server's `upgrade` handler decides: + 1. `Origin` present and different from `DEMO_ALLOWED_ORIGIN` (exact match): answer `403 Forbidden` and destroy the socket, before any WebSocket exists (FR-018). `Origin` absent (a non-browser client): accepted, since the rule only limits browsers. + 2. Otherwise `handleUpgrade`. If the number of open connections is already at `DEMO_MAX_CONNECTIONS` (default 5), close the new socket at once with **4001** and reason `"demo full"` (FR-023); the browser receives the code and reason in its `close` event, which a refused handshake would not give it. + 3. Otherwise register it, and start a timer of `DEMO_MAX_CONNECTION_MINUTES` (default 10) that closes it with **4002** and reason `"time limit"` (FR-024); the timer is cleared on close. +- The existing 15-second ping/pong heartbeat is kept in both modes. +- The decisions on whether to accept, refuse with 4001 or close with 4002 are pure functions (origin check, capacity check), unit-tested; the wiring is covered by the integration test (plan, test strategy). + +--- + +## 6. The message with only the player's car + +### Evidence + +```text +$ npx ts-node --transpile-only --project tsconfig.json specs/003-demo-mode/evidence/measure-ws-volume.ts +lapData: 4458 msgs, avg 16938 B, 330.9 KiB/s +motion: 4458 msgs, avg 10192 B, 199.1 KiB/s +carTelemetry: 4458 msgs, avg 13391 B, 261.6 KiB/s +all 22 cars: 13374 msgs per pass, 172.3 MiB per pass, 791.6 KiB/s, 2.72 GiB/h +deflate per message, default level: 79.9 KiB/s (9.9x), CPU 0.826% of one core +deflate per message, level 1: 90.4 KiB/s, CPU 0.547% of one core +player car only: 47.1 KiB/s; deflated 24.2 KiB/s, CPU 0.289% of one core +span 222.8 s +``` + +- The frontend reads only `message.data[message.playerCarIndex]` (`Telemetry-F1/src/context/TelemetryProvider.tsx`, line 51, read-only). + +### Decision + +- A pure function `toPlayerOnlyMessage(message)` returns the same envelope with `data: [data[playerCarIndex]]` and `playerCarIndex: 0`; the demo's broadcast applies it to every message (FR-025). Local mode broadcasts the full array as today. +- Measured: 47.1 KiB/s per connection instead of 791.6 KiB/s before compression (47.3 KiB/s on the wire with frames); compressed, see below. +- **Where the README documents it**: the Features bullet "Live WebSocket stream of motion, lap data and car telemetry (all 22 cars per message)" gains "in demo mode, only the player's car, with `playerCarIndex` 0"; the "Contract ownership" paragraph under "Technical decisions and trade-offs" gains a sentence that the demo's message content differs and that the frontend must read the car through `playerCarIndex`; and the new "Demo mode" section lists every demo difference (contract summary in [contracts/websocket.md](contracts/websocket.md)). +### Compression of the single-car message (T-3, decided at the plan review) + +The figure above (24.2 KiB/s at 0.289% of a core) came from compressing each message independently with `deflateRawSync`. The real library behaves differently, so it was measured with `ws` itself: a server and a client on loopback, one full pass of single-car messages sent without pauses, the bytes taken from the server socket's `bytesWritten` (frames included), and, in the "server only" runs, the client in a separate process ([evidence/ws-sink.mjs](evidence/ws-sink.mjs)) so that the CPU is only the server's: + +```text +$ npx ts-node --transpile-only --project tsconfig.json specs/003-demo-mode/evidence/measure-compression.ts +player-only lapData: avg 917 B per message +player-only motion: avg 688 B per message +player-only carTelemetry: avg 804 B per message +ws server only, uncompressed: 10.29 MiB per pass, 47.3 KiB/s at 1x, server CPU 313 ms per pass (0.140% of one core at 1x) +ws server only, perMessageDeflate (defaults: context takeover): 1.46 MiB per pass, 6.7 KiB/s at 1x, server CPU 2765 ms per pass (1.241% of one core at 1x) +ws server only, perMessageDeflate, zlib level 1: 1.68 MiB per pass, 7.7 KiB/s at 1x, server CPU 2562 ms per pass (1.150% of one core at 1x) +(second run) uncompressed 0.119%; defaults 1.220%; level 1 1.045% +ws perMessageDeflate threshold 0, no context takeover: ... 5.33 MiB on the wire per pass, 24.5 KiB/s at 1x +ws perMessageDeflate default threshold (1024 B): ... 1.46 MiB on the wire per pass, 6.7 KiB/s at 1x +``` + +- With context takeover (the library's default, which browsers accept), the compressor keeps its dictionary across messages of the same connection, and consecutive telemetry messages are very similar: **6.7 KiB/s on the wire, 7 times less than uncompressed**, against 24.5 KiB/s without context takeover. +- `ws` documents its `threshold` (default 1024 B) as applying only "if context takeover is disabled" (`node_modules/ws/lib/permessage-deflate.js`, lines 44–45), which is why the default and `threshold: 0` give the same 1.46 MiB with context takeover even though the messages average 688–917 B. +- **CPU**: 1.1–1.2% of a local core per client for the server, about ten times the uncompressed send. Level 1 saves little (1.0–1.15%): the cost is mostly the library's per-message asynchronous zlib work, not the compression level. On the 0.1-CPU instance this is roughly 11–12% per client (estimate). The summary log reports the process CPU time so the estimate can be checked after the deploy (point 10). +- **Memory**: with context takeover each connection keeps its own zlib state (about 256 KiB for the default window and memory level, from zlib's documented formula), so about 1.3 MiB for 5 connections. + +**Decision**: `perMessageDeflate` with the library's defaults (context takeover, default level) on the demo's WebSocket server only; 20 Hz kept; the maximum connection time is 5 minutes (FR-024). A client that does not offer the extension receives the same messages uncompressed. Because the CPU figure on 0.1 CPU is an estimate, `DEMO_WS_COMPRESSION=false` (default `true`) turns compression off without a code deploy if the summary's process CPU time shows the instance saturated (added after the plan review, at the maintainer's request). + +--- + +## 7. The coach seed file + +### Evidence + +From `measure-preload.ts` (no API call; only the inputs the coach would receive): + +```text +coach system prompt 3236 chars +pair (6,7): insights 6250 chars, deterministic true, user message 6396 chars +pair (6,8): insights 6260 chars, deterministic true, user message 6406 chars +pair (7,6): insights 3677 chars, deterministic true, user message 3823 chars +pair (7,8): insights 3670 chars, deterministic true, user message 3816 chars +pair (8,6): insights 3658 chars, deterministic true, user message 3804 chars +pair (8,7): insights 3657 chars, deterministic true, user message 3803 chars +``` + +- The system prompt is 1,378 tokens (measured earlier, `CLAUDE.md`); `MAX_TOKENS` is 2,048 (`src/coaching/coachService.ts`). The user messages are 3.8–6.4 thousand characters of mostly JSON; their token count was not measured (it needs the API key) and is not estimated here. The procedure logs the real `usage` of each call, as `coachService` already does. +- Insights computed twice from the same data, on the same machine, are identical for all six pairs. That says nothing about another platform or engine version, which is what T-5 is about (below). +- **Frontend reachability** (read-only, `Telemetry-F1/src/pages/sessionAnalysis/SessionAnalysisPage.tsx`, lines 60–79): the primary lap is always the latest lap (`laps[laps.length - 1]`, lap 8) and "volta atual não é des-selecionável". Today's frontend can therefore only request (8, 6) and (8, 7). The six pairs decided in the clarification cover those two plus four that no current frontend path reaches; the maintainer kept the six at the plan review (T-2), with the limitation recorded. (The clarify question's option B described lap 8 as the "default" primary; it is in fact fixed. The answer, option A, does not depend on that wording.) + +### Decisions + +- **Location and format**: `demo/coach-seed.json` at the repository root, read at run time through the project root (point 8), so the build copies nothing. Shape in [contracts/coach-seed-file.md](contracts/coach-seed-file.md): `formatVersion`, `recordingSha256`, `sessionUid`, and one entry per ordered pair with `lapA`, `lapB`, `model`, `analysisText` and `insights`. The file is public once committed, so it holds nothing about the account, the request or the usage (no timestamp of the call, no token counts). +- **Identity**: the seed is tied to the recording by `recordingSha256` and `sessionUid`. At start, demo mode refuses to run when `recordingSha256` differs from the verified hash of the recording in use, when `sessionUid` differs from the built session, when a pair's laps do not exist in the built session, or when the file is missing or malformed (FR-013). Seeding inserts one `lap_analyses` row per entry, before the connection becomes read-only. +- **Tests use a synthetic seed file** (FR-014, Story 5 scenario 5): `DEMO_SEED_PATH` points the integration test at a seed generated for the synthetic recording. +- **The procedure** (written by the agent, never run by it with a key): `tools/exportCoachSeed.ts`. It requires `DB_PATH` to be `:memory:` or a path outside `data/` (refusing `data/telemetry.db` and an unset `DB_PATH`), verifies the recording's hash, builds the session with the same function the demo uses, applies the capture times, computes the insights for each pair and calls the existing `generateCoaching`, then writes the file. It is idempotent with respect to paid calls (constitution, principle III): when `demo/coach-seed.json` already holds a pair for the same recording, the same model and identical insights, that pair is copied, not requested again; only missing or stale pairs are called. It runs only with `ANTHROPIC_API_KEY` set in the maintainer's own shell, under the spending limit configured in the Anthropic Console, and it prints each call's token `usage`. The core loop takes the coaching function as a parameter, so its test passes a fake and no test can make a paid call. The step-by-step procedure for the maintainer is in [quickstart.md](quickstart.md), section D. + +### Comparing the seeded insights at start (T-5, decided at the plan review) + +**How the coach receives the insights** (read in the code): `buildCoachUserMessage` (`src/coaching/coachUserMessage.ts`) sends `JSON.stringify(insights)` as is, so the coach sees each number with the shortest decimal representation of its double. The values are of two kinds: + +- **Rounded by `round2`** (`Math.round(n * 100) / 100`, `src/analysis/numberUtils.ts`): `totalDeltaS`, `deltaLossS`, `brakingPointDiffM`, `peakBrakeDiff`, `throttleOnDiffM`, the coasting times, `maxLateralDeviationM`, `avgLateralDeviationM`, `avgTempC`. These are the values computed with arithmetic, and `lineDeviation.ts` is the only place a transcendental function enters (`Math.hypot`). +- **Copied from a sample, not computed**: the corner's `apexDistance`, `apexSpeed`, `brakingStartDistance`, `exitDistance`, and the tyre window's `distanceStart`/`distanceEnd` (`cornerDetection.ts`, lines 101–104; `tyreAnalysis.ts`, lines 33–34); plus counts, booleans, strings (`severity`, `wheel`, `status`) and `null`s. + +**Where a cross-platform difference can come from**: the seed is produced on the maintainer's Windows machine and checked on the platform's Linux, and `.nvmrc` pins only the major version, so the V8 version may differ too (local: V8 12.4.254.21-node.56). Additions, multiplications and divisions in the same order are IEEE-754 exact on both; a function like `Math.hypot` is not guaranteed bit-identical across engine versions. A one-ulp difference before `round2` changes nothing, unless the value sits on a rounding boundary (`x.xx5`), where it flips the rounded result by exactly 0.01. A different sample could also be chosen if two computed values tie, which would move a copied distance by metres; that is a real difference in what the coach was told. + +**Comparison rule** (`insightsMatch(stored, computed)`, a pure function): + +- the same structure: the same keys, arrays of the same length in the same order, the same strings, booleans and `null`s; +- numbers equal within **|a − b| ≤ 0.01 + 1e-9**: 0.01 absorbs a `round2` flip, and 1e-9 absorbs the binary representation of the step itself (`0.36 − 0.35` is `0.010000000000000009`). For the copied values, which are not computed, it also absorbs any representation noise, while a different sample (metres) still fails. + +A failed comparison stops the demo with a message naming the pair and the first differing path (for example `corners[3].deltaLossS: 0.12 vs 0.15`). The failed deploy leaves the previous one running. + +**Tests**: equal objects match; a `round2` flip (0.35 vs 0.36) matches; 0.35 vs 0.37 does not; a one-ulp difference in a copied distance matches; a corner more or less, a boolean or `severity` changed, a `null` against a number, and a missing or extra key do not; the message names the path. + +--- + +## 8. Build and start + +### Evidence + +Compiling `src/` with a separate configuration into a scratch folder (`tsconfig.build.json` extending the repository's, `rootDir` the repository root, test files excluded): + +```text +$ npx tsc -p /build/tsconfig.build.json +exit 0 in 3142 ms +src\server.js, src\analysis\..., src\config\dbPath.js, src\config\env.js, ... src\ws\messageFormat.js +``` + +Where the compiled `dbPath.ts` points (only the path was printed; no database was opened): + +```text +dbPath resolved from compiled dist/src/config: \build\dist\data\telemetry.db +from ts source: C:\Telemetry\TelemetryF1_API\data\telemetry.db +``` + +- `dbPath.ts` resolves `data/` as `__dirname/../..`. From `dist/src/config/` that is `dist/data/`, not the repository's `data/`. A compiled server in local mode (`npm start` without demo mode) would silently create a new, empty database under `dist/`. + +### Decisions + +- `tsconfig.build.json` at the root: extends `tsconfig.json`, `outDir: dist`, includes `src` and `tools`, excludes `**/*.test.ts`, `**/testFixtures.ts` and `src/persistence/testDb.ts`. `npm run typecheck` keeps using `tsconfig.json` with `--noEmit` (FR-019). +- A small `src/config/projectRoot.ts` finds the repository root by walking up from `__dirname` to the first folder containing `package.json`; `dbPath.ts`, the demo's default recording path and the seed path resolve from it, so source and compiled code agree. A unit test asserts that the root resolved from source holds this repository's `package.json`. +- Scripts: `"build": "tsc -p tsconfig.build.json && node dist/tools/fetchDemoRecording.js"`, `"start": "node dist/src/server.js"`. On Render, build command `npm ci && npm run build`, start command `npm start` (configured at deploy time, out of scope). +- Nothing besides JavaScript needs copying: the schema is a TypeScript string, the seed file and the recording are read from the repository root at run time, and `better-sqlite3`'s native binary is installed by `npm ci` on the build machine (Linux, already exercised by the CI of spec 002). +- `dist/` is already in `.gitignore`. + +--- + +## 9. HTTP caching, and the per-IP limit + +### Evidence + +- Express 5.2.1 (`node_modules/express/lib/application.js`, line 95): `this.set('etag', 'weak')` by default, and `this.set('trust proxy', false)` (line 99). `res.send` computes the ETag (`lib/response.js`, lines 168–194) and answers 304 when `req.fresh` (line 199). No code in this repository sets `Cache-Control` or changes `etag`. +- `cors` adds `Vary: Origin` when it reflects a specific origin (`node_modules/cors/lib/index.js`). +- The frontend's `httpGet` calls `fetch(url)` with no `cache` option (`Telemetry-F1/src/api/httpClient.ts`), so the browser's HTTP cache and conditional requests apply as usual. +- Response sizes in demo mode (from `measure-preload.ts`): `GET /sessions` 172 B, `GET /sessions/1` 622 B, `GET /sessions/1/laps/{6,7,8}/telemetry` 560,795 / 561,147 / 554,889 B. +- **Requests of a normal visit**, read from the frontend (read-only): the history page makes 1 request (`/sessions`); the session page makes 1 (`/sessions/:id`) plus 1 telemetry request per selected lap (`useLapTelemetry`, refetched when the selection changes); the coach makes 1 POST per click. The dashboard uses only the WebSocket. No polling exists (`setInterval` appears only in `useIsRecent`, a local clock). A visit that opens the history, the session, compares lap 8 with 7 and then 6, and asks the coach twice makes about **8 requests and 1.1–1.7 MB** of REST. + +### Decisions + +- **Demo mode** sets `Cache-Control: no-cache` on the four read routes, keeping Express's ETag. A browser revalidates every time and gets `304` with no body while the data is unchanged (SC-013); since the ETag is derived from the body, a deploy that changes the data changes the ETag, so a visitor never sees data from a previous deploy. The documented cache lifetime is therefore zero, with revalidation (FR-026). `POST /sessions/:id/analysis` and `GET /health` get `Cache-Control: no-store`. +- **Local mode** keeps today's headers (Express's ETag only). +- **Per-IP limit (FR-027): not adopted**, for the reason recorded in point 1. The visit count above (about 8 requests) is recorded for when the limit is revisited. +### Does the platform compress REST responses? (T-3, decided at the plan review) + +| Question | Official source (read 2026-10-21) | +|---|---| +| Does the platform compress HTTP responses? | **Yes.** "Render's load balancers automatically compress HTTP responses from your apps using Brotli and gzip compression" ([Render vs Heroku](https://render.com/docs/render-vs-heroku-comparison)); "Brotli compression" listed among the web services' additional features ([Web Services](https://render.com/docs/web-services)). Neither page names which content types are compressed or mentions `Accept-Encoding`. | +| Is bandwidth counted after that compression? | **Not stated.** The bandwidth page counts "network traffic sent from your workspace's services to destinations outside of Render" and never mentions compression or where it measures ([Outbound Bandwidth](https://render.com/docs/outbound-bandwidth)). | +| Cloudflare | No Render reference page documents Cloudflare's role in compression; the earlier articles (point 1) only place Cloudflare in front of the services. | + +Measured on one lap's telemetry body (same script, `zlib` of Node 22): + +```text +REST lap 6 telemetry: raw 560795 B; gzip(6) 86284 B in 10.6 ms; brotli(4) 78752 B in 8.0 ms; brotli(11) 64882 B in 1778.3 ms +REST lap 7 telemetry: raw 561147 B; gzip(6) 85487 B in 10.6 ms; brotli(4) 78079 B in 6.8 ms; brotli(11) 64310 B in 1726.3 ms +REST lap 8 telemetry: raw 554889 B; gzip(6) 84400 B in 7.9 ms; brotli(4) 77314 B in 5.7 ms; brotli(11) 63622 B in 1778.4 ms +``` + +**Decision**: by the maintainer's rule (compress in the application only if the platform does not), **the application does not compress REST**: the platform documents that it does. What remains unknown is only how the bytes are counted, which is why point 1 gives the visit count as a range. Hypothesis to check after the deploy (out of scope): JSON responses arrive with `Content-Encoding: br` or `gzip` in the browser, and the Metrics page's outbound graph is closer to the compressed figure. + +--- + +## 10. Logging in demo mode + +### Decisions + +- `server.ts`'s per-packet lines (`shouldLog`) are not used in demo mode; the demo's in-process loop does not go through them, and `sessionContext`'s pass-restart line does not occur because the loop does not persist (point 3). +- A small demo logger writes, in Portuguese and with the `[demo]` prefix: the start and the milliseconds until ready (with the build, hash and seed times); each new pass (`onPass` of `runReplay`); each connection opened and closed with its reason (normal, `4001 demo full`, `4002 time limit`, `403` origin refusal); and, every 15 minutes (`setInterval(...).unref()`), a summary with the active connections, the WebSocket, REST and total bytes since start, and the count of coach 503s since start. Since FR-027 is not adopted, there are no 429 lines. +- **Counting bytes.** With compression, the serialized message size no longer says what leaves the server, so both counts use Node's public `socket.bytesWritten`. WebSocket: the `upgrade` handler holds each connection's socket; the count is the sum of `bytesWritten` of closed sockets plus the open ones at summary time (handshake, frames, compression and pings included). REST: a demo-only middleware, on each response's `finish`, adds the increase of `req.socket.bytesWritten` since that socket's last response (kept in a `WeakMap`, since keep-alive reuses sockets); headers included. These are the bytes the application writes, before the platform's HTTP compression, so the REST figure is an upper bound of what that compression lets out, and neither figure is the platform's billing number; the log line says so. +- **CPU.** The summary also logs the process CPU time since start (`process.cpuUsage()`), so the estimate of point 6 can be checked on the 0.1-CPU instance. +- **Coach 503s**: the same middleware counts responses to `POST /sessions/:id/analysis` with status 503. +- **No IP address is logged** at all (simpler than masking, and enough for the summary). + +--- + +## 11. Test strategy and the recording fixture + +### Evidence + +- `src/replay/testFixtures.ts` already builds real binary packets in memory (`buildSyntheticRecording`, `buildLoopableSyntheticRecording`: lap data, motion and car telemetry, a closing lap, a chosen `sessionUID` and frame span), with no file in git. `src/config/serverFailFast.test.ts` already spawns the server as a child process with a controlled environment and a temporary directory. + +### Decision (T-7, decided at the plan review) + +A **synthetic recording generated by the test itself**: the demo integration test extends the existing builders with `receivedAt` values, writes the packets to a `.jsonl` in a temporary folder, computes its SHA-256, writes a synthetic seed file for its laps, and spawns the server in demo mode with `DEMO_RECORDING_PATH`, `DEMO_SEED_PATH` and a `PORT` the test has just found free (binding port 0 on loopback and releasing it), since `PORT` keeps the 1–65535 validation. It needs no file in git, no network and no key. + +### Alternatives considered + +- **A minimal real recording committed as a registered exception**: real game bytes, but it breaks the "recordings stay out of git" rule, even if small. +- **Download the published recording in the test**: network in the CI, 25 MB per run, and the CI would then depend on GitHub's release hosting. + +--- + +## Implementation evidence + +### T026 — the recording at build time (2026-10-21) + +Run from the repository with `DOTENV_CONFIG_PATH` pointing at an empty scratch `.env` (key check: `false`). + +```text +$ npm run build # recording already in recordings/ +[fetchDemoRecording] gravação já presente e conferida em C:\Telemetry\TelemetryF1_API\recordings\demo-interlagos-tt-20hz-laps6-8.jsonl; nada baixado. +exit 0 + +$ DEMO_RECORDING_PATH=/download3/demo.jsonl node dist/tools/fetchDemoRecording.js # empty folder +[fetchDemoRecording] gravação baixada e conferida em \download3\demo.jsonl. +elapsed 3347 ms, exit 0, 25173414 bytes, no demo.jsonl.part left + +$ (same command again) +[fetchDemoRecording] gravação já presente e conferida em \download3\demo.jsonl; nada baixado. +``` + +Decided at the stop before T025 (2026-10-21): `ensureRecording` takes an optional third parameter `{ url, expectedSha256 }` defaulting to the Release URL and `PUBLISHED_RECORDING_SHA256` (a test calls it without the parameter and checks both defaults); an existing file with a wrong hash is replaced by a verified download, with a log line, and stays untouched if the download fails; the destination folder is created when missing. + +### Stop before T044 — pure modules and the database opened at import (2026-10-21) + +`src/persistence/db.ts` opens the SQLite connection, creates the schema and runs its migrations **at import** (`new Database(dbPath)` at module level). Any module that imports a repository therefore opens a database as soon as it is loaded: in a pure test, which does not call `setupIsolatedTestDb()`, that database would be the one `DB_PATH` names, or `data/telemetry.db`. T044 as first written put `applyCaptureTimes` (which imports the repositories) inside the pure `src/demo/captureTimes.ts`, and T052/T053 did the same in `src/demo/coachSeed.ts`. + +Decided by the maintainer: the writing functions live in their own modules (`src/demo/applyCaptureTimes.ts`, `src/demo/applyCoachSeed.ts`, `src/demo/exportCoachSeed.ts`), loaded in tests only after `setupIsolatedTestDb()`; `captureTimes.ts` and `coachSeed.ts` import nothing from persistence. A boundary test, `src/demo/purityBoundary.test.ts`, requires the pure modules and asserts through `require.cache` that `persistence/db` was not loaded; it sets a temporary `DB_PATH` first, so even a broken boundary cannot open the real database. It was seen failing on its own assertion with a temporary `import "../persistence/sessionRepository"` in `src/demo/liveConnections.ts`, reverted with `git checkout`: + +```text +not ok 1 - os módulos puros do modo demo não carregam persistence/db + error: './liveConnections carregou persistence/db' + code: 'ERR_ASSERTION' +(after git checkout -- src/demo/liveConnections.ts) +ok 1 - os módulos puros do modo demo não carregam persistence/db +``` + +The root cause, `db.ts` opening the connection at import, is a known debt and stays outside this spec (a lazily opened connection, or dependency injection of the database, was already recorded as a future improvement in spec 002). + +### T061 — the first coach seed, rejected (2026-10-23) + +The maintainer ran the export twice on her machine (six paid calls the first time, none the second; the spending limit of T060 was set beforehand). The resulting `demo/coach-seed.json` (`formatVersion` 1, six pairs) was **not approved** and was never committed. It was moved out of the repository, as evidence, to `C:\Telemetry\evidence-003\coach-seed-v1-reprovado.json` (SHA-256 `eae8b746820bc1be85136b62617d97db740ed624279783300dbe079ea981ad80`); it does not come back into the repository. The token counts of that run were not recorded here. + +Reasons, from the maintainer's reading of the six texts against each pair's insights: + +1. Four texts treat `totalDeltaS` as a difference against the personal best, when it is against the comparison lap: (6,7) "0,28s acima do seu recorde", (7,6) "0.2s mais rápida que o seu recorde", (8,6) "0.92s mais rápido que seu recorde", (8,7) "0.65s mais rápido que seu recorde". The 1:13.372 personal best is lap 8 itself. +2. Inverted signs in (7,6): corner index 0 has `brakingPointDiffM` +179.97 and the text says "freou 180 metros antes"; corner index 1 has `throttleOnDiffM` −10.6 and the text says "voltou ao acelerador 10 metros tarde demais". The other five texts read the signs correctly. +3. Inconsistent corner numbers: in most texts "Curva N" is index N−1; in (7,8) "Curva 6" and "Curva 4" are indices 6 and 4 and "Curva 1" is index 2; in (8,6) two different corners are both "Curva 1". +4. In (7,8), the +0.02 s corner is described as trail braking ("já tá segurando o freio na curva") while `trailBrakingPrimary` is `false`. +5. Minor: "segundo setor" in (6,8) is not in the data; "patinando" in (6,7) describes as wheelspin what is coasting. + +What the code shows (read-only, no paid call): + +- **Signs.** All three differences are primary − comparison: `brakingPointDiffM` (`src/analysis/brakingAnalysis.ts:33`; negative = the primary braked earlier, `src/analysis/types.ts:24-25`), `peakBrakeDiff` (`brakingAnalysis.ts:34`; positive = the primary braked harder, documented nowhere: neither `types.ts` nor the prompt states its sign) and `throttleOnDiffM` (`src/analysis/throttleAnalysis.ts:38`; positive = the primary was back on the throttle later, `types.ts:33-34`). The prompt states the first and third correctly (`src/coaching/coachPrompt.ts:13, 16`), so the inversion in (7,6) is the model's. +- **Reason 1.** The user message names neither lap (`src/coaching/coachUserMessage.ts:20-32`: the benchmark lines, then the JSON), the prompt says the comparison lap is "normalmente a melhor volta do próprio piloto" (`coachPrompt.ts:7`), and the line right before the JSON is "Recorde pessoal do piloto nesta pista". `analysisRouter` and `exportCoachSeed` know `lapA` and `lapB` but do not pass them to the coach. +- **Reason 3.** The insights carry only the 0-based `corner.index`, and the prompt's example is "Curva 4". The frontend never shows a corner number: its coach chips read `. m · +s` (`Telemetry-F1/src/components/coach/CoachPanel.tsx:65`) and its chart markers carry the same rank (`Telemetry-F1/src/pages/sessionAnalysis/SessionAnalysisPage.tsx:163-167`), the rank being `topOpportunities` (`Telemetry-F1/src/utils/coachInsights.ts`: `deltaLossS > 0`, descending, first three). + +These errors come from the coach's prompt and input, so they exist in local mode too. Decisions of the maintainer (2026-10-23): + +- The fix of the coach's input and prompt (the laps compared and their official times, the delta stated as against the comparison lap, the benchmark labelled as context, the sign conventions, each opportunity labelled as the frontend labels it) goes into **its own spec, from `main`, before this one**. The opening sentence uses the official difference of the lap times. +- Because the insights themselves state differences that may not have happened (see the analysis engine observations below), and the coach explains them as facts even with a corrected prompt, **a second spec, for the analysis engine's artifacts**, also comes before the seed is generated again. +- The seed is generated **once, after both specs are merged**. Then this branch is updated with `main` and this spec is amended: the seed's `formatVersion` 2 with a `promptVersion` per entry, FR-013 and FR-014, and new tasks before T061; at start, demo mode refuses a seed whose `promptVersion` differs from the current one. T061–T064 and T084 wait for that (tasks.md). +- To avoid renumbering, this spec refers to the flashback work as "the flashback spec", without a number. + +### Analysis engine observations (2026-10-23) + +Recorded, not fixed here: they are the subject of the analysis-engine spec above. The numbers come from the insights in the rejected seed (computed by today's `buildInsights` on the published recording); the code references are from `003-demo-mode` at `ef19bf4`. Each cause is consistent with the code and the numbers; none was measured further. + +**1. `totalDeltaS` is not symmetric and differs from the official difference.** It is the cumulative delta at the primary lap's last decimated sample (`src/analysis/buildInsights.ts:26`), with each lap normalized by its own first sample's `sessionTime` (`src/analysis/segmentDelta.ts:15-19`) and the comparison lap's time clamped at its own last sample (`segmentDelta.ts:23-24`). The result depends on which lap is primary, and no pair equals the difference of `lastLapTimeInMS` (laps 6, 7, 8: 74.306, 74.144, 73.372 s): + +| Pair | `totalDeltaS` | Reverse pair | Official difference | +|---|---|---|---| +| (6,7) | +0.28 | (7,6) −0.20 | ±0.162 | +| (7,8) | +0.72 | (8,7) −0.65 | ±0.772 | +| (6,8) | +0.93 | (8,6) −0.92 | ±0.934 | + +**Visible to the visitor?** The frontend does not display `totalDeltaS`. It shows the official difference in the laps table's "Delta" column (lap time minus the best lap, `Telemetry-F1/src/components/sessions/LapsTable.tsx:26, 45`) and draws its own "Delta acumulado (volta X vs. Y)" chart from its port of the same algorithm (`computeDelta`, `Telemetry-F1/src/pages/sessionAnalysis/SessionAnalysisPage.tsx:152-155, 258`), whose values appear in the chart's hover tooltip (`Telemetry-F1/src/components/chart/Chart.tsx`). So the difference against the official time is visible wherever the chart's end and the table disagree, if the port gives the same series (not checked: the frontend was only read). + +**2. Overlapping ±80 m search windows.** Corners are detected on the primary lap only (`buildInsights.ts:24`); for each one, the braking and throttle analyses look for the "equivalent" apex in both laps as the slowest sample within ±80 m (`CORNER_SEARCH_WINDOW_M`, `brakingAnalysis.ts:5`, `throttleAnalysis.ts:5`; `findApexIndexNear`, `src/analysis/sampleSearch.ts:9-23`). Two corners whose apexes are less than 160 m apart share part of the window and can pick the same sample in both laps. In (6,7), the corners of index 0 (apex 334 m) and 1 (apex 399 m) have identical braking and throttle values: `brakingPointDiffM` −179.97, `peakBrakeDiff` 1, `throttleOnDiffM` 19.43; in (6,8) the same two corners share −189.82, 1 and 17.38. This, rather than the out-lap, is what the code points to for the identical −179.97. + +**3. The braking start stops at the apex.** `findBrakingStartIndex` (`sampleSearch.ts:28-34`) walks back from the apex only while the previous sample is braking above 0.1, so when the brake was released before the apex, the "braking start" is the apex itself. `brakingStartDistance` equals `apexDistance` (to the metre) in 10 of the 12 corners when lap 6 is primary, 6 of 7 when lap 7 is primary and 4 of 7 when lap 8 is primary. A braking point compared with an apex on the other lap gives differences of 100–190 m that may not have happened: (6,8) index 0 −189.82 and (8,6) index 0 +189.82; (6,8) index 5 −172.47; (6,7) index 5 −156.71; (7,8) index 6 +102.83; (8,6) index 6 −100.7. + +**4. The throttle pick-up repeats across neighbouring corners.** `findThrottleOnIndex` (`sampleSearch.ts:38-44`) walks forward from the apex to the first sample at full throttle (0.9); with no full throttle between two corners, both get the same exit point. In (6,7), `throttleOnDiffM` is 19.43 on indices 0–2, 10.6 on 3–4, 112.93 on 5–6 and 10.81 on 7–9; in (7,6) −10.81 on 3–4; in (7,8) 24.46 on 3–4; in (8,6) −35.27 on 3–4. + +**5. The number of corners depends on the primary lap.** With lap 6 as primary, 12 corners are detected; with lap 7 or 8, 7 (`detectCorners` runs on the primary lap only, `buildInsights.ts:24`, `src/analysis/cornerDetection.ts:26-36`). So (6,7) and (7,6) do not even describe the same corners. Lap 6 is the invalid lap; whether the difference relates to the Time Trial out-lap sharing lap 6's number (a known limitation) is a hypothesis, not confirmed. + +### Implementation evidence — SC-007, local mode unchanged (T080, 2026-10-23) + +Run from the repository at `3b7385e`, following quickstart.md section C. `DOTENV_CONFIG_PATH` pointed at a scratch `ports.env` holding only the three port lines copied from the repository's `.env` (`WS_PORT`, `UDP_PORT`, `HTTP_PORT`; nothing else read or printed); the key check printed `false` in that environment before each process; `DB_PATH` pointed at a scratch `telemetry.db`; `DEMO_MODE` unset. + +```text +$ DB_PATH=/sc007/telemetry.db npm run dev +[db] usando banco em \sc007\telemetry.db +[wsBroadcaster] servidor WebSocket em ws://localhost:8080 +[httpServer] servidor REST em http://localhost:3001 +[udpListener] escutando pacotes UDP em 0.0.0.0:20777 + +$ npm run replay -- recordings/demo-interlagos-tt-20hz-laps6-8.jsonl # default 1x, same environment +[replayer] recordings/demo-interlagos-tt-20hz-laps6-8.jsonl - velocidade=1x loop=false 13960 pacotes, duração original 222839ms +[replayer] 13960 pacotes reenviados, 1 passada(s) + +# during the replay, a ws client on ws://localhost:8080 +{"lapData":{"cars":22,"playerCarIndex":0},"motion":{"cars":22,"playerCarIndex":0},"carTelemetry":{"cars":22,"playerCarIndex":0}} extensions: "" + +# server stopped; the temporary database opened read-only with better-sqlite3 +sessions [{"id":1,"session_uid":"1026725433292396341","track_id":16}] +laps [{"lap_number":6,"lap_time_ms":74306,"sector1_ms":19307,"sector2_ms":38182,"sector3_ms":16817,"is_valid":0}, + {"lap_number":7,"lap_time_ms":74144,"sector1_ms":18310,"sector2_ms":37654,"sector3_ms":18180,"is_valid":1}, + {"lap_number":8,"lap_time_ms":73372,"sector1_ms":18474,"sector2_ms":37573,"sector3_ms":17325,"is_valid":1}] +car [{"lap_number":6,"n":797},{"lap_number":7,"n":801},{"lap_number":8,"n":793},{"lap_number":9,"n":20}] total 2411 +motion [{"lap_number":6,"n":797},{"lap_number":7,"n":801},{"lap_number":8,"n":793},{"lap_number":9,"n":20}] total 2411 +``` + +Result: 1 session, the README's three laps (times, sectors, validity), **2,411 of 2,411** car telemetry and **2,411 of 2,411** motion samples, the same per-lap split as the in-process build (point 3); the per-packet log lines as before (662 lines in the server log for the pass); 22 cars per WebSocket message, uncompressed. The only database named in the log is the temporary one. + +### Compiled local mode (T081, 2026-10-23) + +Same `DOTENV_CONFIG_PATH`, key check `false`. + +```text +$ npm run build +[fetchDemoRecording] gravação já presente e conferida em C:\Telemetry\TelemetryF1_API\recordings\demo-interlagos-tt-20hz-laps6-8.jsonl; nada baixado. + +$ DB_PATH=/sc007/compiled.db npm start +> node dist/src/server.js +[db] usando banco em \sc007\compiled.db +[wsBroadcaster] servidor WebSocket em ws://localhost:8080 +[httpServer] servidor REST em http://localhost:3001 +[udpListener] escutando pacotes UDP em 0.0.0.0:20777 + +GET http://localhost:3001/health → 200 {"status":"ok"}, Cache-Control: no-store +GET http://localhost:3001/sessions → 200 [] +# a ws client on ws://localhost:8080 during `npm run replay -- … --speed=max` (into the same temporary database) +{"lapData":{"cars":22,"playerCarIndex":0},"motion":{"cars":22,"playerCarIndex":0},"carTelemetry":{"cars":22,"playerCarIndex":0}} extensions: "" + +# server stopped; DB_PATH unset; only the path printed, nothing opened +$ node -e "process.stdout.write(require('./dist/src/config/dbPath.js').dbPath)" +C:\Telemetry\TelemetryF1_API\data\telemetry.db +``` + +The compiled server runs with no TypeScript runtime, names the temporary database, serves REST and the standalone WebSocket on their own ports with 22 cars per message, and answers `GET /health`; without `DB_PATH` the compiled code resolves the real database at the repository root, not under `dist/` (FR-001, FR-019). + +### No new dependency (T082, 2026-10-23) + +```text +$ git diff main --stat -- package.json + package.json | 3 +++ ++ "build": "tsc -p tsconfig.build.json && node dist/tools/fetchDemoRecording.js", ++ "start": "node dist/src/server.js", ++ "export-coach-seed": "ts-node-dev tools/exportCoachSeed.ts", +``` + +Only the three scripts; `dependencies` and `devDependencies` unchanged (FR-022). + +--- + +## CI runs + +Pull request [#12](https://github.com/AnaLauraFFerraz/TelemetryF1_API/pull/12) (draft). Each row is read from `gh api repos/AnaLauraFFerraz/TelemetryF1_API/actions/runs//jobs`; a run counts as green only with both jobs `completed` and `success`. + +| Push | Head SHA | Run | Event | `typecheck` | `test` | Note | +|---|---|---|---|---|---|---| +| T020, first push (foundational phase) | `84879db` | 39678700345 | `pull_request` | completed, success (21:25:45–21:25:56 UTC) | completed, success (21:25:45–21:26:36 UTC) | — | +| T040, Phase 3 | `cd0f772` | 39682631564 | `pull_request` | completed, success (21:55:36–21:55:46 UTC) | completed, **failure** (21:55:37–21:56:32 UTC) | Real failure, not infrastructure: `not ok 78 - no tempo máximo a conexão é fechada com 4002 time limit`, `error: '[{"type":"opened","openConnections":1}]'`. A race in the test: on Linux the client's close (4002) arrived before the server emitted its own `closed` event (`ws` emits the server-side close only when the closing handshake ends), and the test checked the events at once. Fixed in the test (wait for the event with a deadline), 10 of 10 local runs green; no re-run. | +| Fix push (`test:` commit) | `68150ac` | 39683194366 | `pull_request` | completed, success (22:00:07–22:00:18 UTC) | completed, success (22:00:07–22:00:47 UTC) | — | +| T059, Phases 4–5 | `2c33817` | 39687290016 | `pull_request` | completed, success (22:33:01–22:33:15 UTC) | completed, success (22:33:01–22:34:09 UTC) | — | +| Atomic save after each paid pair | `ef19bf4` | 39688868014 | `pull_request` | completed, success (22:46:14–22:46:26 UTC) | completed, success (22:46:14–22:47:23 UTC) | — | +| T079, Phases 6–7 | `3b7385e` | 39949988647 | `pull_request` | completed, success (2026-10-23 11:59:29–11:59:39 UTC) | completed, success (11:59:29–12:00:27 UTC) | — | +| Phase 8 evidence (`docs:`) | `f91f167` | 39956673000 | `pull_request` | completed, success (2026-10-23 12:52:47–12:53:00 UTC) | completed, success (12:52:47–12:54:09 UTC) | — | diff --git a/specs/003-demo-mode/spec.md b/specs/003-demo-mode/spec.md new file mode 100644 index 0000000..f61c556 --- /dev/null +++ b/specs/003-demo-mode/spec.md @@ -0,0 +1,350 @@ +# Feature Specification: Demo mode for a public demonstration + +**Feature Branch**: `003-demo-mode` + +**Created**: 2026-10-21 + +**Status**: Draft + +**Input**: User description (verbatim, as given to `/speckit-specify`): + +> Spec 003 da API: modo demo para uma demonstração pública. +> +> Contexto: a API vai ficar publicada numa hospedagem gratuita (Render, camada web gratuita: dorme depois de 15 minutos sem tráfego de entrada, leva cerca de um minuto para acordar, tem disco temporário que se perde a cada reinício e, pelo que sabemos, expõe uma porta só, pela variável PORT; o plano confirma isso na documentação). O front vai ficar numa hospedagem estática e se conectar a ela. Orçamento zero. +> +> Antes de escrever a spec, leia o código (só leitura, sem executar nada) e registre como fato ou como hipótese não confirmada: +> 1. Como o replay identifica a sessão em que grava. Numa conferência em 21/10, o replay da gravação de exemplo não criou sessão nova e pareceu regravar a sessão de 16/10, de onde a gravação saiu. +> 2. Como a duração da sessão mostrada no histórico é calculada. Na mesma conferência, a duração dessa sessão passou de 6.962 min para 7.083 min entre duas rodadas, batendo com a hora do relógio das capturas, o que sugere que ela usa a hora em que o último pacote chegou, e não o tempo da sessão. +> 3. Como as portas são configuradas hoje (REST em HTTP_PORT, WebSocket em WS_PORT, UDP em UDP_PORT) e que endpoints existem, dizendo quais escrevem no banco ou chamam a Anthropic. +> +> O que a spec precisa cobrir (o quê e por quê; o como fica para o plano): +> - Um modo demo, ligado por configuração, que não muda o funcionamento local de hoje. +> - No modo demo, a telemetria da gravação de exemplo (a Release sample-recording-v1, fora do git) é reproduzida em loop a 1x pelo próprio servidor, sem porta UDP pública. +> - REST e WebSocket atendidos na única porta que a hospedagem expõe. +> - Sem estado: a cada inicialização, o banco é criado do zero a partir da gravação, e o cache do coach é semeado a partir de um arquivo versionado. Reinícios, sono e disco temporário não podem quebrar nada. +> - O coach roda sem ANTHROPIC_API_KEY: comparações com cache respondem, as outras dão 503. Gerar o arquivo de cache exige chamadas pagas: é tarefa manual da mantenedora, feita uma vez, localmente, com o limite de gastos do Console; o agente nunca faz chamada paga. +> - A duração e os dados da sessão mostrados ao visitante continuam coerentes com o replay em loop rodando indefinidamente (ligado aos itens 1 e 2 acima). +> - Um endpoint de saúde para a hospedagem, CORS restrito à origem do front, e build e start com JavaScript compilado (tsconfig próprio de build), não com ts-node-dev. +> - Nenhum visitante consegue escrever no banco além do que o próprio replay grava, nem disparar chamada paga. +> +> Fora do escopo: a configuração de produção do front e o aviso de servidor acordando (spec 003 do front), o deploy em si e os links nos READMEs (passo seguinte), o flashback e a out-lap (spec 004). +> +> O que for decisão com trade-off ou não puder ser afirmado pelo código fica marcado como NEEDS CLARIFICATION, para o clarify. A documentação se refere à mantenedora pelo papel. Quando terminar, commite a spec como docs:, sem trailer, e me mostre o resumo: os três fatos lidos no código, os requisitos e as perguntas abertas. Edição só com Edit e Write. + +## Clarifications + +### Session 2026-10-21 + +- Q: What does a visitor see on the first request after the server starts or wakes: the complete session, or a history that fills in during the first 1x pass? → A: The complete session (the three laps and all their samples) is in place before the first request is answered, and the health endpoint reports ready only from then on. The plan measures how much this adds to startup. +- Q: Which time base do the demo session's dates and duration use? → A: The recording's own capture times (the `receivedAt` field the recorder already writes). They never change with the loop or with restarts. Using the game's session time (the former option C) is out of this spec: it goes back on a flashback, which is the subject of the flashback spec, and adopting it now would change the contract on a base not yet studied. Local mode keeps its current behaviour; this spec adds to Known limitations in `README.md` and `CLAUDE.md` that replaying an old recording in local mode stretches that session's duration to the current time. +- Q: Which lap pairs does the coach seed file cover? → A: All six ordered pairs of laps 6, 7 and 8. +- Q: Are the three defaults recorded in Assumptions approved (refuse to start in demo mode with `ANTHROPIC_API_KEY` set; the WebSocket handshake follows the same origin rule as CORS; the existing 503 body unchanged)? → A: Yes, all three. The origin rule only limits use from browsers, because a client outside a browser sends whatever `Origin` header it wants; the guarantee that no visitor writes to the database or triggers a paid call must not depend on it. +- Q: When many visitors open the live view at once, should the server limit the number of simultaneous WebSocket connections, and what does a visitor beyond the limit see? → A: A configurable limit of simultaneous connections, default 5. A visitor beyond the limit has the connection accepted and then closed at once with a close code and reason of its own ("demo full"), which the frontend can show (frontend spec 003); REST keeps working. The limit only protects against many simultaneous visitors, not against the volume of each connection: measured on the published recording, one connection receives about 792 KiB/s, about 2.7 GiB per hour, so a single forgotten tab consumes that much on its own. The volume per connection is a separate decision. +- Q: In demo mode, what reduces the volume of each WebSocket connection? → A: Both a maximum time per connection, 10 minutes by default (configurable), after which the connection is closed with a close code and reason of its own ("time limit") and reconnection happens only by the visitor's action; and, in demo mode only, messages that carry only the player's car, with `playerCarIndex` set to 0 (measured: 47.1 KiB/s instead of 791.6 KiB/s, about 28 MiB for a 10-minute visit, with no compression and so no extra CPU). This is recorded as a contract change even though the message shape is the same: it goes in the list of changes the frontend sees and in the contract documentation in `README.md`, so that a future reading of the other cars does not break only in the demo. Suggested for frontend spec 003: close the connection when the tab is hidden. (Revised at the plan review below: the maximum time is 5 minutes, and the single-car messages are compressed.) +- Q: In demo mode, should REST have any protection against volume, or stay open as today? → A: HTTP caching of the demo's read responses plus a per-IP request limit, under two conditions. The per-IP limit is adopted only if the plan confirms how the hosting platform forwards the visitor's real IP and configures the server's proxy trust (Express `trust proxy`) with the exact number of hops, so that a forged `X-Forwarded-For` cannot escape the limit and all visitors are not counted as one; if that cannot be confirmed, only the HTTP caching applies, with the reason recorded. The limit must sit well above the number of requests of a normal visit, which the plan measures. The coach's POST is excluded from HTTP caching. +- Q: In demo mode, what should the server write to its log? → A: The per-packet log is silenced in demo mode, and only events are logged: start and time until ready, each new pass of the loop, the opening and closing of connections (normal, "demo full", "time limit"), 429 responses, and a periodic summary (every 15 minutes) with the active connections, the bytes sent over WebSocket and over REST and their sum (the bandwidth quota is a single one), and the count of coach 503 responses, which should stay at zero with the six pairs cached and signals that the frontend offers a combination with no cache. No full IP address goes to the log. Local-mode logs do not change. + +### Session 2026-10-21 (plan review) + +Decisions taken by the maintainer on the trade-offs of [plan.md](plan.md), after the research ([research.md](research.md)). + +- Q: Does the live loop keep writing to the database after the session is built? → A: No. The loop only broadcasts; the database is complete before "ready" and becomes read-only after the coach cache is seeded (FR-005, FR-015). +- Q: Today's frontend fixes the primary lap to the latest one, so only (8, 6) and (8, 7) can be requested; keep the six seeded pairs? → A: Yes, the six pairs, with the limitation recorded as a known limitation. (The clarify question above had described lap 8 as the "default" primary; it is fixed.) +- Q: The free workspace includes 5 GB of outbound bandwidth a month, about 165–175 full visits at the clarified settings; what stretches it? → A: Compress the single-car messages with the WebSocket library's per-message deflate and reduce the maximum time per connection to 5 minutes; the rate stays at 20 Hz. REST responses are compressed by the application only if the platform does not compress them; the platform's documentation says its load balancers compress HTTP responses with Brotli and gzip, so the application does not (research.md, point 9). +- Q: What happens when the recording is missing at start? → A: Demo mode refuses to start; the build is what downloads it. +- Q: What happens when a seeded entry's insights differ from what the analysis computes now? → A: Demo mode refuses to start. Because the seed file is generated on the maintainer's Windows machine and compared on the platform's Linux, and JavaScript's math does not guarantee bit-identical results across platforms and engine versions, the comparison is not a text comparison of every decimal: it is structural, with numbers equal within a tolerance derived from how the insights are serialized for the coach (research.md, point 7), and it is tested. +- Q: Should a per-IP limit use `CF-Connecting-IP` instead of the proxy-trust hop count, which cannot be confirmed? → A: No, not in this feature; FR-027 is not adopted, and the question can be revisited after the deploy. +- Q: Which recording do the integration tests use? → A: A synthetic recording generated by the test itself; no recording enters git. + +## Current behaviour read from the code + +Items 1 to 3 were read on 2026-10-21, without running anything, from `main` at `6fa1837` and, for the duration, from the frontend repository's working copy. Item 4 was measured during the clarify step of the same day, by parsing the recording, with no database and no network. Each item is marked **Fact** (stated by the code) or **Hypothesis** (consistent with the code and the observation, not confirmed). + +### 1. How a replay picks the session it writes to + +- **Fact.** The session is identified only by the `sessionUID` in each packet's header. `getActiveSession` (`src/persistence/sessionContext.ts`) keys its in-memory map by `header.sessionUID`, and on a miss calls `getOrCreateSession` (`src/persistence/sessionRepository.ts`), which looks the UID up in `sessions.session_uid` (UNIQUE) and inserts a row only if none exists. +- **Fact.** The replayer re-sends each recorded packet's bytes unchanged: `tools/replayer.ts` decodes the base64 `data` field into a buffer and `runReplay` (`src/replay/runner.ts`) sends that buffer as is. A replay therefore carries the `sessionUID` of the original capture. Nothing in the replay path creates a new identity. +- **Fact.** When the row already exists, the replay reuses it: `started_at` keeps its original value; `last_seen_at` is set to the current time; laps are upserted on `(session_id, lap_number)` (`lapRepository.upsertCompletedLap`), so they are overwritten with the same values; samples use `INSERT OR IGNORE` on `(session_id, overall_frame_identifier)`, so a sample whose frame is already stored is dropped, not rewritten. "Rewrote the 16/10 session" is, precisely, "reused it, refreshed its last-seen time, re-upserted identical laps and added no samples". +- **Fact.** The same holds across the passes of a looped replay inside one server process: the laps and samples of every pass after the first hit the same keys, so the stored data stays bounded. +- **Hypothesis.** The session dated 16/10 in the database used on 21/10 has the same `session_uid` as the published recording. The database was not opened. It is consistent with the recording's first line, whose `receivedAt` field (an absolute timestamp written by `tools/recorder.ts` and ignored by the replayer) is `1792168223797`, that is 2026-10-16 16:30:23 UTC. + +### 2. How the session duration in the history is computed + +- **Fact.** The API computes no duration. `GET /sessions` and `GET /sessions/:id` return `startedAt` and `lastSeenAt`, both server wall-clock times written by SQLite: `started_at` is set when the row is inserted and never changes; `last_seen_at` is refreshed when a server process first sees the session (`getOrCreateSession` on an existing row) and then at most every 15 s while its packets keep arriving (`TOUCH_INTERVAL_MS` in `sessionContext.ts`). +- **Fact.** The frontend computes the duration as `lastSeenAt − startedAt`, rounded to whole minutes (`formatSessionDuration` in `Telemetry-F1/src/utils/formatDate.ts`; its own comment calls it an approximation). Neither side uses the game's `sessionTime` nor the recording's own timestamps. +- **Fact, applied to the observation.** The two readings (6,962 and 7,083 minutes, about 4.8 days) are consistent with `startedAt` fixed on 16/10 and `lastSeenAt` moved by each replay to the wall-clock time it ran, 121 minutes apart. The hypothesis in the description is confirmed: the duration follows the arrival time of the last packet, not the session time. +- **Consequence.** With a replay looping indefinitely, the duration shown grows by one minute per minute of uptime, without bound, and the start date is the date the row was inserted, not the capture date. + +### 3. Ports and endpoints + +- **Fact.** `src/config/env.ts` reads `WS_PORT`, `UDP_PORT` and `HTTP_PORT`, each required, an integer from 1 to 65535, and stops with a clear message at load time otherwise. It is loaded by `src/server.ts`, `tools/recorder.ts`, `tools/replayer.ts` and `src/coaching/coachService.ts` (and so by the analysis router). No `PORT` variable is read anywhere. +- **Fact.** There are three separate listeners: REST with `app.listen(HTTP_PORT)` (`src/http/httpServer.ts`), a standalone WebSocket server on `WS_PORT` (`src/network/wsBroadcaster.ts`), and UDP bound to `0.0.0.0:UDP_PORT` (`src/network/udpListener.ts`). +- **Fact.** CORS is `cors({ origin: true })`: any origin is reflected and allowed. There is no health endpoint. No `Cache-Control` header is set anywhere, and nothing changes Express's default `etag` setting, so JSON responses carry only Express's default weak ETag. +- **Fact.** Endpoints, and what writes or pays: + +| Surface | What it does | Writes to the database | Calls Anthropic | +|---|---|---|---| +| `GET /sessions` | Session list with lap count and best valid lap | No | No | +| `GET /sessions/:id` | Session detail with its laps | No | No | +| `GET /sessions/:id/benchmark` | Best lap and theoretical best on the session's track, across all sessions | No | No | +| `GET /sessions/:id/laps/:lapNumber/telemetry` | Car telemetry and motion samples of one lap | No | No | +| `POST /sessions/:id/analysis` (`{ lapA, lapB }`) | Coach analysis of a lap pair; a cached pair is returned as is; without `ANTHROPIC_API_KEY`, a non-cached pair returns 503 before any work | Yes, one `lap_analyses` row, only after a successful paid call | Yes, on a cache miss when the key is set and both laps have samples | +| WebSocket | Server-to-client broadcast of motion, lap data and car telemetry; client messages are not handled | No | No | +| UDP | Ingestion of game packets | Yes: `sessions`, `laps`, `car_telemetry_samples`, `motion_samples` | No | + +Anyone who can reach the UDP port can write to the database; the REST and WebSocket surfaces cannot write anything except the coach's cache row. + +### 4. Volume of the live stream (measured 2026-10-21, during the clarify step) + +- **Fact (measured).** Every connected WebSocket client receives, for every motion, lap data and car telemetry packet, a JSON message with the full array of 22 cars (`src/ws/messageFormat.ts`), with no compression. Parsing the published recording with the existing parsers and serializing each message with `buildWsMessage` (no database, no network) gives, per connection: 13,374 messages per pass, averaging 10.2 KB (motion), 16.9 KB (lap data) and 13.4 KB (car telemetry); 172.3 MiB per pass; **791.6 KiB/s, 2.72 GiB per hour**. +- **Fact (measured).** Compressed message by message (deflate, default level, no shared context): 79.9 KiB/s, 9.9 times less, at a CPU cost of 0.79% of one core of the maintainer's machine (Intel i7-9750H) per client; at the fastest level: 90.4 KiB/s for 0.52%. The player's car only, uncompressed: 47.1 KiB/s; compressed: 24.2 KiB/s. +- **Hypothesis.** The free instance's CPU is a fraction of one core (to be confirmed by the plan against the platform's documentation), so compression per client would cost a multiple of the percentages above on it. +- **Fact (measured at the plan stage with the real WebSocket library on loopback).** The single-car message on the wire: 47.3 KiB/s uncompressed, **6.7 KiB/s with per-message deflate** (context takeover, the library's default), at 1.1–1.2% of one local core of server CPU per client (research.md, point 6). +- **Fact.** The frontend reads only `message.data[message.playerCarIndex]` from each message (`Telemetry-F1/src/context/TelemetryProvider.tsx`), so a message whose array holds only the player's car, with `playerCarIndex` 0, is read the same way by today's frontend. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - A visitor explores a live-looking session at any time (Priority: P1) + +A visitor opens the published frontend. The API, running in demo mode on the free hosting, is replaying the published Interlagos recording at its original pace, in a loop, from inside its own process. The visitor sees one session in the history, opens it, sees laps 6, 7 and 8 with the same times, sectors and validity the README documents, opens the per-lap telemetry, and watches the live dashboard move as the replay goes on. Whether the instance just woke up, restarted or has been running for days, what they see is the same. + +**Why this priority**: this is the demonstration itself. Without it nothing else in the feature has an audience. + +**Independent Test**: start the server in demo mode with no database on disk, no UDP port and no API key; open the history, the session, its laps and the live view from the allowed origin; repeat after a restart and after many loop passes, and compare. + +**Acceptance Scenarios**: + +1. **Given** demo mode is on and no database exists, **When** the server starts, **Then** it builds its data from the published recording only, and the history lists exactly one session. +2. **Given** the demo session, **When** the visitor opens it, **Then** it shows laps 6 (1:14.306, invalid), 7 (1:14.144) and 8 (1:13.372, best valid lap), with sectors and validity identical to a local replay of the same recording at `1x`. +3. **Given** the live view is open, **When** the replay advances, **Then** the visitor receives the live telemetry continuously at the recording's pace, and the stream restarts from the beginning of the recording at the end of each pass, with no gap longer than the recording's own. +4. **Given** the replay has looped for any number of passes, **When** the visitor reloads the history, **Then** it still shows one session, three laps, the same times and the same per-lap sample counts as after the first full pass. +5. **Given** demo mode, **When** a live message reaches the visitor, **Then** its array holds only the player's car and `playerCarIndex` is 0, and it travels compressed when the browser offers per-message deflate (every current browser does). +6. **Given** a visitor has had the live view open for 5 minutes, **When** the time limit is reached, **Then** the server closes the connection with the demo's "time limit" close code and reason and does not reopen it; reconnecting only by the visitor's action is the frontend's behaviour (frontend spec 003). +7. **Given** the instance is restarted, put to sleep and woken, or its disk is wiped, **When** the visitor comes back, **Then** they see the same session and laps as before, with no error and no manual action by the maintainer. +8. **Given** demo mode is on, **When** the server starts, **Then** no UDP port is opened. +9. **Given** demo mode is on, **When** the visitor first loads the history after a start, **Then** the session is already complete, with its three laps and all their samples, because the server answers no request before that; the health endpoint reports ready only from then on. + +--- + +### User Story 2 - The session's dates and duration stay believable forever (Priority: P1) + +The visitor looks at the session in the history: its date, start time and duration. Those values describe the session the recording contains, about 3 min 43 s of driving, not how long the demo instance has been up. They do not drift while the replay loops, and they do not jump after a restart. The live dashboard still shows the replay as it happens. + +**Why this priority**: today the duration grows by one minute per minute of uptime (see "Current behaviour", item 2). After one day of looping the history would show a 1,440-minute session for 3 laps, which makes the whole demo look broken. + +**Independent Test**: read the session's start, last-seen and resulting duration after the first pass, after many passes and after a restart; all readings must agree with the rule chosen below and with each other. + +**Acceptance Scenarios**: + +1. **Given** the demo has been looping for hours, **When** the visitor reads the session's duration, **Then** it equals the value after the first pass. +2. **Given** a restart, **When** the visitor reads the session's dates and duration, **Then** they follow the same rule as before the restart. +3. **Given** demo mode, **When** the session's dates are shown, **Then** they are the recording's own capture times: the session starts at the capture time of the recording's first packet (2026-10-16 16:30:23 UTC), its last-seen time is the capture time of the last packet, so the duration the frontend shows is the span of the capture times (222.958 s, displayed as 4 min; the README's 222.8 s is the span of the recording's relative `timestampMs`, research.md point 4), and each lap's recorded time is the capture time of the packet that completed it. +4. **Given** local mode, **When** any replay or live session runs, **Then** the session's dates and duration behave exactly as they do today; replaying an old recording still stretches that session's duration to the current time, now documented as a known limitation. + +--- + +### User Story 3 - Visitors cannot write data or cost money (Priority: P1) + +Anyone on the internet can call the public API, with a browser or with a script. In demo mode, the only thing that ever writes to the database is the server's own replay of the published recording (plus the coach cache seeded at startup). No request from a visitor adds, changes or deletes data, and nothing a visitor does can make the server call a paid service. The coach answers the comparisons the maintainer has prepared in advance and declines the others with the existing 503. + +**Why this priority**: the budget is zero. A single path to a paid call or to unbounded writes on a public URL is a cost or an abuse risk. + +**Independent Test**: with demo mode running, send every route and method the API exposes, including malformed bodies, cached and non-cached lap pairs and any UDP traffic to the instance; compare the row counts of every table before and after, and confirm no outbound call to Anthropic was attempted. + +**Acceptance Scenarios**: + +1. **Given** demo mode, **When** a visitor requests the coach analysis for a seeded pair, **Then** the answer is the seeded analysis, marked as cached, with no outbound call. +2. **Given** demo mode, **When** a visitor requests a pair that is not seeded, **Then** the response is 503 with the existing error body, and nothing is written. +3. **Given** demo mode is configured with an `ANTHROPIC_API_KEY` present, **When** the server starts, **Then** it stops at startup with a clear message saying demo mode does not accept the key, so a misconfiguration can never enable paid calls. +4. **Given** demo mode, **When** any client sends any message over the WebSocket, **Then** nothing is written. +5. **Given** demo mode, **When** a visitor sends any other method or path, **Then** it is rejected as it is today, and nothing is written. +6. **Given** demo mode, **When** packets are sent to the instance's UDP port number, **Then** nothing receives them, because no UDP socket exists in demo mode. +7. **Given** demo mode and a visitor who already loaded a read endpoint, **When** their browser asks for it again, **Then** it is served from the browser's HTTP cache or revalidated without downloading the body again; the coach's POST and the health endpoint are never cached. +8. **Given** demo mode, **When** one client repeats requests, **Then** no per-IP limit applies (FR-027 is not adopted), nothing is written, and a repeated read costs only a revalidation while the data is unchanged. +9. **Given** demo mode, **When** the database has been seeded, **Then** it is read-only: a write attempted by any code path fails instead of changing data. + +--- + +### User Story 4 - The hosting can run, probe and restart the API (Priority: P2) + +The hosting platform builds the API once, starts it from compiled JavaScript, routes all traffic to the single port it assigns, probes a health endpoint to decide whether the instance is up, and restarts or sleeps it when it wants. REST and WebSocket both work on that one port. Only the frontend's published origin can use the API from a browser. + +**Why this priority**: it is what makes the demo deployable on the chosen free tier. It is secondary to the demo being correct, and the deploy itself is the next step, not this feature. + +**Independent Test**: build with the documented command, start with the documented command and only the demo configuration, then probe the health endpoint, call REST and open the WebSocket on the single port, from the allowed origin and from another origin. + +**Acceptance Scenarios**: + +1. **Given** a clean checkout with dependencies installed, **When** the documented build command runs, **Then** it produces compiled JavaScript using a build configuration of its own, separate from the type check. +2. **Given** the build output, **When** the documented start command runs, **Then** the server runs from the compiled output, with no TypeScript runtime involved. +3. **Given** demo mode and the single port the platform assigns, **When** clients connect, **Then** REST and WebSocket are both served on that port. +4. **Given** the health endpoint, **When** the platform probes it, **Then** it answers success only once the demo is ready to serve visitors (data built, coach cache seeded, replay running), and failure otherwise, without requiring any origin header. +5. **Given** a browser page on the frontend's configured origin, **When** it calls REST or opens the WebSocket, **Then** it succeeds. +6. **Given** a browser page on any other origin, **When** it calls REST or opens the WebSocket, **Then** the browser is not granted access to REST responses and the WebSocket handshake is refused. +7. **Given** demo mode without the frontend origin configured, **When** the server starts, **Then** it stops with a clear message naming the missing setting. + +--- + +### User Story 5 - The maintainer prepares the coach cache once (Priority: P2) + +Before publishing, the maintainer runs, once, on their own machine, a documented procedure that replays the published recording into a disposable database, asks the coach for each lap pair to be offered in the demo, and writes the results to a file committed to the repository. This is the only step that costs money, it is bounded by the spending limit set in the Anthropic Console, and it is never run by the CI, the tests or the agent. + +**Why this priority**: without the file, every comparison in the demo returns 503. It is P2 because the rest of the demo works without it. + +**Independent Test**: with a synthetic seed file, start demo mode and request each seeded pair (cached answer) and an unseeded pair (503). The real file is produced only by the maintainer. + +**Acceptance Scenarios**: + +1. **Given** the procedure, **When** the maintainer runs it, **Then** it never opens `data/telemetry.db`, it uses a disposable database, and it logs the token usage of every call. +2. **Given** the committed seed file, **When** demo mode starts, **Then** every analysis in the file is served as cached for its pair. +3. **Given** a seed file produced from a different recording, **When** demo mode starts, **Then** it stops with a clear message instead of serving analyses that do not match the data the visitor sees. +4. **Given** the procedure, **When** it produces analyses, **Then** it produces exactly the six ordered pairs of laps 6, 7 and 8 (six paid calls on the first run). Today's frontend fixes the primary lap to the latest one, so only (8, 6) and (8, 7) are requested; the other four are ready for a frontend that lets the visitor change the primary lap, and this is recorded as a known limitation. +5. **Given** the procedure has already produced the file, **When** it runs again for the same recording, model and insights, **Then** it makes no paid call. +6. **Given** a seed entry whose insights differ from what the analysis computes now for that pair, beyond the tolerance defined in the plan, **When** demo mode starts, **Then** it stops with a clear message naming the pair; a difference within the tolerance (for example one caused by a different platform or engine version) is accepted. +7. **Given** the CI or the test suite, **When** they run, **Then** none of them can make a paid call; tests use a synthetic seed file. + +--- + +### User Story 6 - Local use is unchanged (Priority: P1) + +With demo mode off, which is the default, the maintainer runs the server, the recorder, the replayer and the analyze tool exactly as today: three ports from `.env`, UDP from the game or from `npm run replay`, the real database at `data/telemetry.db`, any origin allowed, the coach enabled by the key, `npm run dev` with live reload. + +**Why this priority**: the local workflow is the product; the demo is a showcase of it. A regression here is worse than a missing demo. + +**Independent Test**: with demo mode off, run the test suite, the type check and the spec 001 replay validation (a `1x` replay of the published recording into a disposable database delivers every lap and the 2,411 predicted samples); all must match today's results. + +**Acceptance Scenarios**: + +1. **Given** demo mode is off, **When** the server starts, **Then** it requires and uses `WS_PORT`, `UDP_PORT` and `HTTP_PORT` as today, with the same messages when one is missing. +2. **Given** demo mode is off, **When** the game or the replayer sends UDP packets, **Then** they are ingested as today. +3. **Given** demo mode is off, **When** the coach is asked for a non-cached pair with a key set, **Then** it calls Anthropic and caches the result as today. +4. **Given** demo mode is on, on any machine, **When** the server starts, **Then** it never opens, modifies or deletes `data/telemetry.db`. + +### Edge Cases + +- **First request right after a cold start.** The free tier takes about a minute to wake, and building the session adds to that (measured by the plan). A visitor never sees a partial session: the server answers no request until the session is complete (Story 1, scenario 9). +- **A lap only exists once the next one starts.** Lap completion is detected when the game reports the next lap number, so lap 8 is only recorded at the very end of a pass. Because the session is built before the first request, this is invisible to the visitor. +- **Building the session must not lose data.** The session built at startup must hold the same laps and the same samples as a full `1x` pass (2,411 per-distance samples, spec 001); building it faster than `1x` is acceptable only if nothing is lost, unlike the `max` replay speed over UDP. +- **The recording lacks capture times.** If the recording's first or last packet has no `receivedAt`, the demo cannot know the session's dates and refuses to start rather than invent them. +- **Restart in the middle of a pass.** The new process starts from a fresh database; the WebSocket clients of the old process are disconnected. Telling the visitor the server is waking or reconnecting is the frontend's job (frontend spec 003). +- **The temporary disk survives a process restart.** A database left on disk by a previous process must not be reused: every start in demo mode begins from an empty database. +- **The recording is unavailable or altered at build or start.** The published asset may be missing, partially downloaded or replaced. The demo must refuse to run with a clear message, checking the recording against the SHA-256 published in the README, rather than serve partial or different data. +- **Loop boundary.** The recording's `overallFrameIdentifier` span (about 4,500 frames) is well above the threshold that resets the in-memory lap state between passes (100), so each pass starts clean (spec 001). A shorter recording would not; this feature uses only the published one. +- **Cache seeded for a pair whose laps differ in the demo.** The coach cache is keyed only by session and lap numbers (known limitation). In demo mode the session is always the same recording, so the file must be tied to that recording (Story 5, scenario 3). +- **An `ANTHROPIC_API_KEY` set by mistake on the hosting.** Demo mode refuses to start (Story 3, scenario 3) rather than silently ignoring it, so the mistake is visible on the first deploy. +- **Non-browser clients.** The origin rule (CORS and the WebSocket handshake) only limits use from browsers: a client outside a browser sends whatever `Origin` header it wants. A script can therefore call every read endpoint and open the WebSocket, which is acceptable because everything readable is public demo data. The guarantee that no visitor writes or triggers a paid call does not rely on the origin rule (FR-015). +- **Health probe during startup.** While the session is being built or the seed loaded, the health endpoint reports not ready, so the platform does not route visitors to a half-built demo. +- **Local replay of an old recording.** In local mode, replaying a recording whose session already exists in the database moves that session's last-seen time to now, so its duration stretches to the current time. Unchanged by this feature; documented as a known limitation. +- **Several visitors at once.** All share one replay and one broadcast; a visitor joining mid-pass sees the stream from that point, not from the start of the recording. At most 5 live connections are served at once (configurable); the sixth is accepted and closed immediately with the demo's own "demo full" close code and reason, while its REST pages keep working. When a connection ends, the next visitor can connect. +- **One forgotten tab.** The connection limit does not reduce what each connection costs: at today's message volume an open tab takes about 2.7 GiB per hour (see "Current behaviour", item 4). In demo mode, sending only the player's car and compressing it brings it to about 6.7 KiB/s on the wire, and the 5-minute limit closes the connection after about 2 MiB; the tab reconnects only if the visitor acts. +- **The monthly bandwidth runs out.** The free workspace includes 5 GB of outbound bandwidth a month; without a payment method, the platform suspends the service until the next month instead of charging. With the decided settings, a full visit costs about 2.2–3.8 MB, so the allowance covers roughly 1,300–2,400 full visits a month (research.md, point 1). No payment method is added to the workspace. +- **A forged `X-Forwarded-For`.** A client can send any `X-Forwarded-For` value, and the platform's documentation states neither the header to trust nor the number of proxy hops. A per-IP limit would either be escaped by forging the header or count every visitor as one; this is why FR-027 is not adopted. +- **Insights computed on another platform.** The seed file is produced on Windows and checked on Linux, possibly with another engine version; a tolerance on the numbers keeps such differences from blocking the start, while a real change to the analysis still does (Story 5, scenario 6). +- **Cached responses across deploys.** The demo's data is identical on every start, but a later deploy may change the recording or the seed; a browser cache must not keep a visitor on the old data longer than the cache lifetime the plan documents. +- **A client that reconnects on its own.** The server cannot stop a script, or a frontend that reconnects automatically, from opening a new connection after the time limit; each new connection simply counts against the limit of 5 and gets its own 5 minutes. Reconnecting only by the visitor's action is the frontend's behaviour (frontend spec 003). +- **The frontend offers a pair with no cache.** With all six ordered pairs seeded, a coach 503 in demo mode means the frontend offered a combination outside them (for example a lap number the recording does not have); the periodic summary's 503 count makes that visible to the maintainer (FR-028). +- **The existing 503 body.** The coach's 503 message says to configure `ANTHROPIC_API_KEY`. It is part of the contract and stays unchanged here; how the frontend presents it to a visitor belongs to the frontend's spec 003. + +## Requirements *(mandatory)* + +### Functional Requirements + +**Mode and configuration** + +- **FR-001**: The server MUST have a demo mode, switched on by configuration and off by default. With demo mode off, the behaviour of the server, the recorder, the replayer, the analyze tool and the test suite MUST be identical to today's, including the required ports, their error messages, `npm run dev` and the `DB_PATH` semantics. +- **FR-002**: In demo mode, the server MUST listen on the single port given by the hosting platform's port variable and MUST NOT require `WS_PORT`, `UDP_PORT` or `HTTP_PORT`. A missing or invalid required demo setting MUST stop the server at startup with a clear message naming it (constitution, principle II). +- **FR-003**: In demo mode, the server MUST refuse to start when `ANTHROPIC_API_KEY` is set, with a clear message. +- **FR-004**: In demo mode, the server MUST NOT open, modify or delete `data/telemetry.db`; it uses a database of its own, and every start begins from an empty one, regardless of anything left on disk. + +**Replay of the recording** + +- **FR-005**: In demo mode, the server MUST build the whole session once at start from the published recording (Release `sample-recording-v1`, file `demo-interlagos-tt-20hz-laps6-8.jsonl`), through the same parsing and persistence as a UDP packet would go through (FR-009), and then replay the recording from inside its own process, at `1x`, in a loop, for as long as it runs, through the same parsing and the live broadcast only: the loop does not write to the database. +- **FR-006**: In demo mode, the server MUST NOT open any UDP socket. +- **FR-007**: The recording MUST NOT be committed to git. The demo MUST obtain it from the published Release and MUST verify it against the SHA-256 published in the README before using it; on a missing file or a mismatch it MUST refuse to run with a clear message. +- **FR-008**: The data visible to visitors MUST be bounded and stable across passes: one session, the recording's completed laps with the times, sectors and validity of a local `1x` replay, and per-lap sample counts that do not change after the first full pass. +- **FR-009**: In demo mode, before answering its first request after a start, the server MUST have the whole session in place: the recording's completed laps and all their samples, equal to what a full `1x` pass stores, with no sample lost. The health endpoint (FR-017) MUST report not ready until then. The plan measures the time this adds to startup. + +**Session dates and duration** + +- **FR-010**: In demo mode, the session's start time MUST be the capture time (`receivedAt`) of the recording's first packet, its last-seen time the capture time of the recording's last packet, and each lap's recorded time the capture time of the packet that completed it. These values MUST NOT change while the replay loops or across restarts. If the recording lacks those capture times, the server MUST refuse to start rather than invent them (constitution, principle IV). The payload shapes do not change. +- **FR-011**: In local mode, the session's dates and duration MUST behave as today. The game's session time is not adopted as a time base by this feature, because it goes back on a flashback (the flashback spec). + +**Coach** + +- **FR-012**: In demo mode, the coach MUST answer seeded pairs from the cache and MUST answer any other pair with the existing 503 status and body, with no outbound call and no write. +- **FR-013**: At every start in demo mode, the coach cache MUST be seeded from a file versioned in the repository. The file MUST identify the recording it was produced from, and a mismatch with the recording in use MUST stop the server with a clear message. Each entry's insights MUST also match what the analysis computes at start for that pair, compared structurally with a numeric tolerance (defined in the plan from how insights are serialized for the coach), not as text; a mismatch beyond the tolerance MUST stop the server with a message naming the pair. +- **FR-014**: The repository MUST provide and document a procedure for the maintainer to produce the seed file locally, once, against a disposable database, logging the token usage of each call. It MUST NOT run in the CI or the test suite. Running it is a manual task of the maintainer; tasks of this feature MUST NOT require the agent to make any paid call. + +**No writes or costs from visitors** + +- **FR-015**: In demo mode, no request or message from a client, on any route, method or channel, MAY write to the database or trigger a call to a paid service. The only writes are those of the session build and of the seeding at startup; after the seeding, the database MUST be read-only, so that a write attempted by any code path fails. This guarantee MUST hold for a client that sends any `Origin` header, or none, so it MUST NOT depend on the origin rule of FR-018. + +**Hosting** + +- **FR-016**: In demo mode, REST and WebSocket MUST be served on the same single port. The WebSocket messages and the REST payloads MUST keep their current shapes; the only change in content is the one in FR-025. +- **FR-017**: The API MUST expose a health endpoint that reports success only when the demo is ready to serve visitors and failure otherwise, and that does not depend on the request's origin. In demo mode the server listens only once ready, so before that the platform's probe finds no listener (a failed check), and after that the endpoint reports failure if the live loop has stopped. In local mode it also exists and reports success while the server runs; it MUST NOT change any other route. +- **FR-018**: In demo mode, CORS MUST allow only the frontend's configured origin, and the WebSocket handshake MUST be refused for any browser origin other than that one. This rule only limits use from browsers, since a client outside a browser chooses its own `Origin` header; it is not an access control, and the documentation MUST say so. In local mode, any origin remains allowed, as today. +- **FR-019**: The repository MUST provide a build command that compiles the server to JavaScript using a build configuration of its own, separate from the type check, and a start command that runs the compiled output with no TypeScript runtime. `npm run dev` and `npm run typecheck` MUST keep working as today. + +**Contract and documentation** + +- **FR-020**: Every change visible to the frontend MUST be listed in the plan so the frontend's types and configuration can follow (constitution, principle VI). Known so far: the new health endpoint (addition), the single-port WebSocket address in demo mode (configuration), CORS restricted in demo mode, the demo session's date and duration values (FR-010), the "demo full" and "time limit" close codes and reasons (FR-023, FR-024), the demo's live messages carrying only the player's car with `playerCarIndex` 0 and compressed with per-message deflate (FR-025, a contract change despite the unchanged shape), the 5-minute connection limit (FR-024), HTTP caching headers on the demo's read responses (FR-026), no 429 (FR-027 not adopted), and no change to any existing route, error string or WebSocket message shape. +- **FR-021**: `README.md` and `CLAUDE.md` MUST describe the demo mode, the build and start commands and the seed procedure as built, and the "no `build`, `start`" statement in `CLAUDE.md` MUST be updated in the same change. Limitations introduced by demo mode MUST be added to Known limitations in both files, and so MUST the existing local-mode behaviour that replaying an old recording stretches that session's duration to the current time, and the demo's six seeded pairs of which today's frontend reaches two. +- **FR-022**: No runtime dependency MAY be added without a reason stated in the plan (constitution, principle V). + +**Live stream capacity** + +- **FR-023**: In demo mode, the server MUST limit the number of simultaneous WebSocket connections, configurable, with a default of 5. A connection beyond the limit MUST be accepted and closed immediately with a close code and a reason specific to the demo ("demo full"), so a browser can tell why; REST MUST keep working for that visitor. In local mode there is no limit, as today. The limit protects against many simultaneous visitors only, not against the volume of each connection. +- **FR-024**: In demo mode, the server MUST close each WebSocket connection when it reaches a maximum duration, configurable, with a default of 5 minutes, using a close code and a reason specific to the demo ("time limit"), distinct from "demo full". The server MUST NOT reopen it; a new connection is a new visit, subject to FR-023 and to its own time limit. In local mode there is no time limit, as today. +- **FR-025**: In demo mode, each WebSocket message MUST carry only the player's car in its data array, with `playerCarIndex` set to 0, and MUST be compressed with per-message deflate whenever the client offers it (a client that does not offer it receives the same messages uncompressed). Compression is on by default and MUST be possible to turn off by configuration, with no code deploy, as the way out if the instance's CPU saturates. The rate stays the recording's own (20 Hz). This is a contract change even though the message shape is the same, and MUST be documented as such in `README.md` wherever it describes the WebSocket payload (today, "all 22 cars per message" under Features, and the contract-ownership note; the README has no dedicated contract section yet) and in the list of FR-020. In local mode the array keeps all 22 cars and the game's `playerCarIndex`, as today. + +**REST volume** + +- **FR-026**: In demo mode, the read endpoints' responses MUST carry HTTP caching information that lets a browser reuse or revalidate them without downloading the body again, with a cache lifetime documented by the plan that also bounds how long a visitor can see data from a previous deploy. The coach's POST and the health endpoint MUST NOT be cacheable. In local mode, the caching behaviour stays as today (Express's default validators only; see "Current behaviour", item 3). +- **FR-027**: *Not adopted.* The clarification made a per-IP request limit conditional on the plan confirming how the hosting platform forwards the visitor's real address and configuring the server's proxy trust with the exact number of hops. The platform's reference documentation states neither, and its articles describe at least two proxy layers without a count and a caller-controlled leftmost `X-Forwarded-For` entry (research.md, point 1). Only FR-026 applies; no 429 is introduced; a normal visit makes about 8 requests (research.md, point 9), recorded for when this is revisited after the deploy. REST responses are compressed by the platform's load balancers, not by the application (research.md, point 9). + +**Observability** + +- **FR-028**: In demo mode, the server MUST NOT write the per-packet log lines it writes today, and MUST log these events instead: the start and the time until ready; each new pass of the loop; each WebSocket connection opened and closed, with the reason (normal, "demo full", "time limit"); and, every 15 minutes, a summary with the number of active connections, the bytes sent since start over WebSocket, over REST and their sum, the number of coach 503 responses since start, and the process CPU time since start (to check the CPU estimate for the free instance, research.md point 6). The byte counts are the bytes the application writes to its sockets (after the WebSocket compression, before the platform's HTTP compression), not the platform's billing figure. No full IP address MAY be written to the log. In local mode, the logs stay as today. + +### Key Entities *(include if feature involves data)* + +- **Demo configuration**: the setting that turns demo mode on, the port assigned by the platform, the frontend's allowed origin, and where the recording comes from. Distinct from the local `.env` ports. +- **Demo recording**: the published Interlagos recording (13,960 packets, 222.8 s of relative timestamps and 222.958 s of capture times, laps 6–8, SHA-256 in the README), the only telemetry source in demo mode; identified by its checksum. +- **Demo session**: the single session the replay produces from the recording; its laps, samples and reported dates are what visitors see. +- **Coach seed file**: a versioned file holding, for each offered lap pair, the analysis text, the insights it was produced from and the model, plus the identity of the recording it belongs to. +- **Health status**: whether the demo is ready (data built, seed loaded, replay running), as reported to the hosting platform. +- **Live connection (demo mode)**: one visitor's WebSocket connection; counts against the limit of simultaneous connections (default 5), lives at most the maximum duration (default 5 minutes), receives only the player's car, compressed when it offers per-message deflate, and ends with a normal close, "demo full" or "time limit". + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In demo mode, from the first request answered after any start, the history shows exactly 1 session with laps 6, 7 and 8 whose times, sectors, validity and per-lap sample counts match a local `1x` replay of the recording (2,411 per-distance samples in total). +- **SC-002**: After at least 20 consecutive loop passes (about 75 minutes), the session count, lap count, lap times and per-lap sample counts equal those after the first full pass, and the session's reported duration has changed by 0 minutes. +- **SC-003**: After 3 restarts in a row, each starting with no database, visitors see the same session, laps and dates rule every time. +- **SC-004**: A scripted battery that hits every route and method, with valid and malformed inputs, cached and non-cached pairs, WebSocket client messages and UDP traffic, changes 0 rows in any table and causes 0 outbound calls to Anthropic. +- **SC-005**: Every seeded comparison is answered as cached; every other comparison returns 503; both in under 1 second. +- **SC-006**: From the frontend's origin, REST and the live stream both work on the single port; from another browser origin, 0 REST responses are readable and 0 WebSocket handshakes succeed. +- **SC-007**: With demo mode off, the test suite and the type check pass, and the spec 001 validation still delivers every lap and all 2,411 predicted samples at `1x`. +- **SC-008**: The health endpoint reports not ready until the demo can serve visitors, and ready from then on, checked once by probing during and after startup. +- **SC-009**: The server starts from the compiled output with the documented build and start commands and runs without any TypeScript runtime. +- **SC-010**: The demo session's start is 2026-10-16 16:30:23 UTC and its displayed duration is 4 min, read after the first pass, after 20 passes and after a restart. +- **SC-011**: With 5 live connections open, a sixth receives the "demo full" close code and reason within 1 second and can still load the history; after one of the 5 closes, a new connection succeeds. +- **SC-012**: In demo mode, with a client that offers per-message deflate, a live connection receives on the wire no more than 10 KiB/s on average over a full pass (6.7 KiB/s measured, against 791.6 KiB/s today), and is closed with the "time limit" close code and reason 5 minutes after it opened, having received no more than 3 MiB. +- **SC-013**: In demo mode, a second load of the same pages by the same browser downloads 0 response bodies again for unchanged read endpoints (served from cache or revalidated), while the coach's POST and the health endpoint are answered fresh every time. +- **SC-014**: After the seeding, a write attempted through the database connection fails, and the row counts of every table stay unchanged for the life of the process (the per-IP criterion this number held before is void, since FR-027 is not adopted). +- **SC-015**: Over one hour of demo mode with visitors, the log holds 0 per-packet lines, one summary every 15 minutes whose WebSocket, REST and total byte counts are each non-zero when traffic occurred, a coach 503 count of 0 while visitors use only the six cached pairs, and no full IP address. + +## Assumptions + +- **Hosting (confirmed against the platform's documentation, research.md point 1).** Free web instance with 0.1 CPU and 512 MB; sleeps after 15 minutes without inbound traffic (HTTP requests or WebSocket messages received from existing connections) and takes about a minute to wake; temporary disk lost on restart; a single public port given by `PORT`; WebSocket supported on that port; 750 free instance hours a month per workspace; 5 GB of outbound bandwidth a month in the free workspace, and without a payment method a suspension until the next month when it runs out; the load balancers compress HTTP responses with Brotli and gzip. Not documented: whether the browser's pong frames count as inbound messages, and whether bandwidth is counted before or after the platform's compression; the requirements hold either way. +- **Budget zero.** No paid service runs in demo mode. The only paid step is the maintainer's one-off generation of the seed file, bounded by the spending limit set in the Anthropic Console. +- **Data.** The published recording has no flashback, and its laps are 6, 7 and 8, so the flashback and Time Trial out-lap limitations do not affect the demo; fixing them is the subject of the flashback spec. +- **Coach semantics.** An analysis is directional: the pair (8, 7) and the pair (7, 8) are different cache entries, as today. +- **CI.** The CI keeps its `typecheck` and `test` checks unchanged. Whether the build is also verified in the CI is a plan decision; adding a required check follows the check-names rule in `CLAUDE.md`. +- **Approved by the maintainer (clarify session 2026-10-21).** Demo mode refuses to start with an API key present (FR-003) rather than ignoring it; the WebSocket handshake follows the same origin rule as CORS (FR-018); the existing 503 body stays unchanged. +- **Suggestions for frontend spec 003** (recorded here, decided there): show the "demo full" and "time limit" closes to the visitor; reconnect only by the visitor's action; close the connection when the tab is hidden; and note that hosting the frontend in the same account as the API would spend the same 5 GB monthly allowance. +- **Future work, outside this spec.** A build check in the API's CI, in a small spec before the deploy: this feature introduces a build with its own configuration, while the CI's type check uses another one, so a build break would not be caught. Adding a required check changes the ruleset of `main`, which is why it is a separate piece of work. +- **Out of scope.** The frontend's production configuration and its "server is waking up" notice (frontend spec 003); the deploy itself and the links in both READMEs (the next step); flashback and out-lap handling (the flashback spec); changing how local mode reports session dates (only documented as a known limitation); the game's session time as a time base (the flashback spec). +- Commits follow the project rule: spec, plan and tasks are committed (`docs:`) before any implementation, with Conventional Commits and no `Co-Authored-By` trailer. diff --git a/specs/003-demo-mode/tasks.md b/specs/003-demo-mode/tasks.md new file mode 100644 index 0000000..bbc26af --- /dev/null +++ b/specs/003-demo-mode/tasks.md @@ -0,0 +1,412 @@ +--- + +description: "Task list for spec 003, demo mode for a public demonstration" +--- + +# Tasks: Demo mode for a public demonstration + +**Input**: Design documents from `specs/003-demo-mode/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ (`websocket.md`, `rest.md`, `coach-seed-file.md`, `configuration.md`) and quickstart.md are present. + +**Tests**: requested (TDD, plan.md "Test strategy"). + +- **Pure functions** go through three separate tasks, always in this order: (1) **stub**: the module with the final signature and a body that returns a neutral wrong value (never a missing export, never a thrown "not implemented"); (2) **red**: the test written and run with `npm test`, seen failing **on its own `assert`** (an import error, a type error or a throw from the stub does not count as red; if it fails any other way, fix the stub, not the test); (3) **green**: the implementation, until the suite passes. +- Other code (database, sockets, the spawned server) is also test-first: the test is written and seen failing on its assertion before the implementation, in one task or two. +- A test that fails by design is committed together with the code that makes it pass, never alone, so no commit has a red suite (as in spec 002). +- Every test that needs a database uses `src/persistence/testDb.ts` (a disposable file) or `:memory:` in a spawned demo server. **No task opens, writes, copies or points anything at `data/telemetry.db`** (CLAUDE.md, Rules). +- No test can make a paid call: the coach is always a fake in tests, and demo mode refuses to start with a key. +- **No process the agent starts can see the maintainer's key.** `dotenv/config` loads `.env` from the current directory (or from `DOTENV_CONFIG_PATH`). An empty `ANTHROPIC_API_KEY=` on the command line is **not** enough: in `cmd` and in Windows PowerShell 5.1 an empty variable is removed, and `dotenv` then loads the key from `.env`. So: + - every spawned test runs with a clean environment and a `cwd` outside the repository (as `src/config/serverFailFast.test.ts` does); + - every server or tool the agent starts by hand (T026, T080, T081, T084–T086) runs either with its working directory outside the repository or with `DOTENV_CONFIG_PATH` pointing at a scratch `.env` that has no key (for local mode it holds only `WS_PORT`, `UDP_PORT`, `HTTP_PORT`; for demo mode it is empty), and **before starting it**, in the same environment, `node -e "require('dotenv/config'); console.log(Boolean(process.env.ANTHROPIC_API_KEY))"` must print `false`; if it prints `true`, nothing is started (**[PARADA]**); + - `npm test` and `npm run typecheck` also run with `DOTENV_CONFIG_PATH` pointing at the empty scratch `.env`, after the same check: test files load `dotenv/config` through `src/config/dbPath.ts` from the repository's working directory, and the repository's `.env` does hold a key (found at T006, 2026-10-21, with the `Boolean(...)` check only; no test calls the coach, so nothing was spent); + - the value of a key or the content of any `.env` is never printed; + - only T061, the maintainer's, runs with a key. + +**Commits**: + +- Small Conventional Commits, English subject, **no `Co-Authored-By` trailer**. Allowed types: `feat`, `fix`, `docs`, `test`, `chore`, `ci`. +- **No attribution anywhere**: no `Co-Authored-By` trailer in any commit and no "Generated with Claude Code" footer (or any other attribution line) in the pull request body, even if a session reminder asks for one. This is the project's rule; it is not a choice to put to the maintainer. +- `npm test` and `npm run typecheck` pass before every commit; a failure is reported, never committed around. +- `README.md` and `CLAUDE.md` change **in the same commit as the behaviour they describe**, following plan.md's "Documentation" table. A behaviour becomes "visible" when a user can run it; building blocks that nothing runs yet get no documentation until the commit that wires them. While the branch is in progress, the README's "Demo mode" section says it is partial and lists only what exists (constitution IV). +- Text files are edited only with the Edit and Write tools, never with shell commands (CLAUDE.md, How to work in this repo). + +**Markers**: + +- **[MANUAL]**: done by the maintainer, not by the agent; each says what the maintainer hands back. +- **[PARADA]**: the session stops and waits for the maintainer, including at every conditional stop listed. The agent never pushes, opens or updates the pull request's state, or merges without the maintainer's explicit go-ahead for that specific action. +- **The agent never runs the coach seed export with a key** (T061 is [MANUAL]). + +**Pull request and CI** (one pull request for the whole feature): + +- It is opened **as a draft at the first push** (T020), and marked ready only at T094. It is the feature's only pull request: the spec's status line is set before the merge (T089), not by a second pull request. +- After **every** push, the CI is awaited and recorded: `gh run list --branch 003-demo-mode --limit 1 --json databaseId,headSha,event,status,conclusion`, then `gh run watch --exit-status`, then the jobs from the API: `gh api repos/AnaLauraFFerraz/TelemetryF1_API/actions/runs//jobs --jq '.jobs[] | [.name, .status, .conclusion, .started_at, .completed_at] | @tsv'`. The row (push, head SHA, run id, `typecheck`, `test`) goes into the "CI runs" table of research.md, committed with the next group of changes; the last push's row goes into the pull request body (T097), since recording it in a commit would need one more push. +- **CI red**: the cause is diagnosed from the job log (`gh run view --log-failed`), fixed in its own commit, and pushed after its own **[PARADA]**. A re-run (`gh run rerun --failed`) is allowed **only for an infrastructure failure** (runner, network, GitHub outage), stated as such in the "CI runs" table; never to get past a real failure. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: can run in parallel: a different file from every other task it could run alongside, and no dependency on an incomplete task. +- **[Story]**: US1–US6 from spec.md. Setup, Foundational and Polish tasks carry no story label. +- Every task names its file(s), the FRs it serves and the plan decision it follows (T-1 … T-7 from plan.md "Decisions taken at the plan review", or the plan section). + +--- + +## Phase 1: Setup + +- [x] T001 Baseline, read-only, on branch `003-demo-mode`: `node -v` is v22.23.x (`fnm use` if not); `git status` clean; `npm run typecheck` passes and `npm test` reports 95 tests, 95 passing; the published recording is in `recordings/demo-interlagos-tt-20hz-laps6-8.jsonl` and its SHA-256 (`Get-FileHash -Algorithm SHA256`) equals the README's `c41bdfaf…b033`. No file changes. If any check differs, **[PARADA]** and report. + +--- + +## Phase 2: Foundational (blocking prerequisites) + +**Purpose**: the build, the project root, the demo configuration and the lap-completion seam that every story uses. + +### F1. Project root, build and start (FR-019; plan "Source code", research.md point 8) — the first implementation commit, which also documents the existing local-replay limitation (FR-021) + +- [x] T002 Stub `src/config/projectRoot.ts`: `export function findProjectRoot(startDir: string = __dirname): string` that returns `startDir` unchanged (neutral wrong value). +- [x] T003 Red: write `src/config/projectRoot.test.ts` (test names in Portuguese): from `__dirname` it returns the folder whose `package.json` has `"name": "telemetryf1-server"`; from a nested folder created under `os.tmpdir()` with a `package.json` two levels up it returns that folder; from a temporary folder tree with no `package.json` up to the filesystem root it throws `[projectRoot] package.json não encontrado acima de `. Run `npm test`; confirm the failure is an `assert` failure. +- [x] T004 Green: implement `findProjectRoot` (walk up with `path.dirname` until a `package.json` exists; throw the message above at the root). +- [x] T005 Change `src/config/dbPath.ts` to resolve `data/telemetry.db` as `path.join(findProjectRoot(), "data", "telemetry.db")`, keeping the `DB_PATH` semantics and its comment unchanged. Test first in `src/config/dbPathRoot.test.ts`: a child process (clean environment, `cwd` a temporary folder, as in `src/config/serverFailFast.test.ts`) runs `node -r ts-node/register -e "process.stdout.write(require('/src/config/dbPath').dbPath)"` and must print `/data/telemetry.db`. It only prints the path: nothing opens the database. +- [x] T006 Create `tsconfig.build.json` (extends `./tsconfig.json`; `compilerOptions.outDir: "dist"`, `noEmit: false`; `include: ["src", "tools"]`; `exclude: ["**/*.test.ts", "**/testFixtures.ts", "src/persistence/testDb.ts", "node_modules"]`) and, in `package.json`, `"build": "tsc -p tsconfig.build.json"` and `"start": "node dist/src/server.js"`. Verify: `npm run build` creates `dist/src/server.js`; with `DB_PATH` unset, `node -e "process.stdout.write(require('./dist/src/config/dbPath.js').dbPath)"` prints `\data\telemetry.db` (path only, nothing opened). `dist/` stays ignored. +- [x] T007 Docs for F1, in the same commit: `README.md` Getting started gains `npm run build` and `npm start`; `README.md` Known limitations gains "replaying an old recording in local mode moves its session's last-seen time to now, so the history shows its duration stretched to the current time" (spec Edge Cases, FR-021); `CLAUDE.md` Stack line ("no build step yet" → the build and start scripts), Commands (`npm run build`, `npm start`), the sentence "There are no `build`, `start` or `lint` scripts" → only `lint` remains absent, and the same limitation under Known pitfalls. +- [x] T008 Commit T002–T007: `feat: add a compiled build and start, resolving paths from the project root`. + +### F2. Demo-mode configuration (FR-001, FR-002, FR-003, FR-004; contracts/configuration.md; plan "Start sequence" step 1) + +- [x] T009 [P] Stub `src/config/mode.ts`: `export type RunMode = "local" | "demo"; export function parseRunMode(raw: string | undefined): RunMode` returning `"local"` for every input. +- [x] T010 [P] Stub `src/config/demoEnv.ts`: `export interface DemoConfig { port: number; allowedOrigin: string; maxConnections: number; maxConnectionMs: number; wsCompression: boolean; recordingPath: string; seedPath: string }` and `export function parseDemoConfig(env: NodeJS.ProcessEnv, projectRoot: string): DemoConfig` returning `{ port: 0, allowedOrigin: "", maxConnections: 0, maxConnectionMs: 0, wsCompression: false, recordingPath: "", seedPath: "" }` without reading `env`. +- [x] T011 Red: write `src/config/mode.test.ts` and `src/config/demoEnv.test.ts`. The messages are fixed by these tests, in Portuguese: + - `parseRunMode`: `undefined` and `""` → `"local"`; `"true"` → `"demo"`; `"yes"` → throws `Variável de ambiente DEMO_MODE="yes" inválida: use "true" para o modo demo, ou deixe vazia para o modo local.` + - `parseDemoConfig` with a valid environment returns port, origin, `maxConnections` 5, `maxConnectionMs` 300000 (5 minutes), `wsCompression` true, `recordingPath` `/recordings/demo-interlagos-tt-20hz-laps6-8.jsonl`, `seedPath` `/demo/coach-seed.json`; `DEMO_MAX_CONNECTIONS=2`, `DEMO_MAX_CONNECTION_MINUTES=1`, `DEMO_WS_COMPRESSION=false`, `DEMO_RECORDING_PATH`, `DEMO_SEED_PATH` are honoured; + - throws `O modo demo não aceita ANTHROPIC_API_KEY: remova a variável (o modo demo nunca faz chamada paga).` when the key is non-empty (FR-003); + - throws `O modo demo não aceita DB_PATH: o banco do modo demo é sempre em memória.` when `DB_PATH` is non-empty (FR-004); + - throws `Variável de ambiente PORT não definida (obrigatória no modo demo).` and `Variável de ambiente PORT="abc" não é uma porta válida (1-65535).`; + - throws `Variável de ambiente DEMO_ALLOWED_ORIGIN não definida (obrigatória no modo demo).` and, for `https://x.github.io/app` and `https://x.github.io/`, `Variável de ambiente DEMO_ALLOWED_ORIGIN="" não é uma origem válida (esquema://host[:porta], sem caminho nem barra no fim).`; + - throws `Variável de ambiente DEMO_MAX_CONNECTIONS="0" deve ser um inteiro ≥ 1.` (same wording for `DEMO_MAX_CONNECTION_MINUTES`); + - `DEMO_WS_COMPRESSION` unset or `""` → true, `"false"` → false, `"no"` → throws `Variável de ambiente DEMO_WS_COMPRESSION="no" inválida: use "true" ou "false".` + Run `npm test`; confirm `assert` failures. +- [x] T012 Green: implement `src/config/mode.ts` and `src/config/demoEnv.ts` (the key and `DB_PATH` checks first, before any other). +- [x] T013 Change `src/config/env.ts` so the three ports are read (with today's exact messages) only when `parseRunMode(process.env.DEMO_MODE)` is `"local"`; `src/config/dbPath.ts` so it returns `":memory:"` in demo mode; and `src/server.ts` so that, in demo mode, it calls `parseDemoConfig(process.env, findProjectRoot())` before anything else (an error prints its message and exits 1, binding no port and opening no database) and, if the configuration is valid, prints `[demo] modo demo ainda não disponível nesta versão: só a configuração é validada.` and exits 1 (a temporary state of the branch, replaced in T075; it keeps a demo run from falling into the local wiring with undefined ports). Tests first: in `src/config/dbPathRoot.test.ts` (same child-process method as T005), `DEMO_MODE=true` prints `:memory:` and `DEMO_MODE` unset still prints `/data/telemetry.db`; a new spawned `src/config/demoFailFast.test.ts` (clean environment, `cwd` a temporary folder) starts `src/server.ts` with `DEMO_MODE=true` and each of: `ANTHROPIC_API_KEY` set, `DB_PATH` set, `PORT` missing, and asserts exit ≠ 0, the exact message from T011 on stderr, and no database file created; plus `DEMO_MODE=yes` with its message. `src/config/serverFailFast.test.ts` and `src/persistence/dbWithoutPorts.test.ts` stay green unchanged (FR-001). +- [x] T014 Docs for F2, same commit, describing only what exists at this commit: `README.md` gains a "Demo mode" section marked partial ("being built in this branch; at this point only the configuration is validated, and the server stops after validating it"), with the configuration table of `contracts/configuration.md` (including `DEMO_WS_COMPRESSION`) and the refusals; `.env.example` gains a commented demo-mode block (`DEMO_MODE`, `PORT`, `DEMO_ALLOWED_ORIGIN`, the optional settings including `DEMO_WS_COMPRESSION`; no key); `CLAUDE.md` Configuration paragraph (demo variables; the ports are not read in demo mode) and database-path paragraph (demo mode uses an in-memory database, and refuses `DB_PATH`). +- [x] T015 Commit T009–T014: `feat: add the demo-mode configuration and its fail-fast checks`. + +### F3. The completed lap from persistence (FR-010 prerequisite; research.md point 4) + +- [x] T016 Red: write `src/persistence/persistTelemetryCompletion.test.ts` (isolated database via `setupIsolatedTestDb`, packets from `buildSyntheticRecording()` in `src/replay/testFixtures.ts`): `persistTelemetry` returns `null` for every packet except the closing lap data packet, which returns the completed lap with `lapNumber` 1 and the expected lap time. First change the signatures of `persistTelemetry` (`src/persistence/persistTelemetry.ts`) and `handleLapDataPacket` (`src/persistence/sessionContext.ts`) to return `CompletedLap | null`, both returning `null` always (stub); run `npm test`; confirm the `assert` failure. +- [x] T017 Green: `handleLapDataPacket` returns the detector's completed lap (or `null`), `persistTelemetry` returns it for packet id 2 and `null` otherwise; `src/server.ts` keeps ignoring the value. No other behaviour changes. +- [x] T018 Commit T016–T017: `feat: return the completed lap from persistTelemetry`. + +### First push and the draft pull request + +- [x] T019 **[PARADA] [MANUAL] the maintainer approves the first push and the opening of the draft pull request.** Hands back: "go" for both. +- [x] T020 Push `003-demo-mode` to `origin` and open the pull request **as a draft**: `gh pr create --draft --base main --head 003-demo-mode --title "feat: demo mode for a public demonstration (spec 003)" --body-file /pr-body.md`. The body (English) summarizes the spec, links spec/plan/tasks, and starts the "Changes the frontend sees" list from plan.md (14 items) for the frontend's spec 003 (FR-020). No attribution footer (see Commits). +- [x] T021 Await and record the CI of this push (procedure in the header): add a "CI runs" table to `specs/003-demo-mode/research.md` with the first row. It is committed with the next commit (T028). + +**Checkpoint**: the build exists, paths agree between `src/` and `dist/`, the demo configuration is validated, local mode is unchanged (suite green). + +--- + +## Phase 3: User Story 1 — a visitor explores a live-looking session (P1) 🎯 MVP building blocks + +**Goal**: the recording, the in-process session build, the single-car message, the origin and capacity decisions and the demo WebSocket server, each tested on its own (FR-005–FR-009, FR-016, FR-018, FR-023, FR-024, FR-025; T-1, T-3, T-4, T-7). They are wired into a running server in Phase 7. + +**Independent Test**: `npm test` runs the recording, session-build, player-only, origin/capacity and demo-WebSocket tests green; the session-build test stores exactly the synthetic recording's lap and samples. + +### The recording (FR-007; T-4; research.md point 2) + +- [x] T022 [US1] Stub `src/demo/recording.ts`: `export const PUBLISHED_RECORDING_SHA256 = "c41bdfaf7ff0feb40702875b15031654742de5fdb10ea3a05c0a21f1ac70b033"`; `export interface DemoPacket extends RecordedPacket { receivedAt: number | undefined }`; `parseRecording(text: string): DemoPacket[]` returning `[]`; `sha256Hex(data: Buffer): string` returning `""`; `verifyRecording(data: Buffer, expectedSha256: string): void` that never throws. +- [x] T023 [US1] Red: write `src/demo/recording.test.ts`: `parseRecording` decodes `timestampMs`, `receivedAt` and the base64 `data` of each non-empty line and keeps `receivedAt` `undefined` when absent; `sha256Hex(Buffer.from("abc"))` equals `ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad`; `verifyRecording` throws `[demo] gravação não confere com o SHA-256 esperado (esperado , obtido ).` on a mismatch; `README.md` (read from `findProjectRoot()`) contains `PUBLISHED_RECORDING_SHA256`. Run; confirm `assert` failures. +- [x] T024 [US1] Green: implement `parseRecording`, `sha256Hex` (`node:crypto`), `verifyRecording`. +- [x] T025 [US1] Test first, then implement, `ensureRecording(destPath: string, fetchFn: typeof fetch): Promise<"present" | "downloaded">` in `src/demo/recording.ts` (test in `src/demo/recording.test.ts`, with a fake `fetchFn`, files under `os.tmpdir()`): an existing file with the right hash → `"present"` and `fetchFn` not called; missing → downloads, writes to `.part`, verifies, renames, `"downloaded"`; wrong hash → throws the `verifyRecording` message and leaves no file at `destPath` or `.part`; a non-2xx status → throws `[demo] download da gravação falhou: HTTP em `. The URL is the Release asset in README.md. +- [x] T026 [US1] Create `tools/fetchDemoRecording.ts` (thin CLI: `ensureRecording(process.env.DEMO_RECORDING_PATH || /recordings/demo-interlagos-tt-20hz-laps6-8.jsonl, fetch)`, logs `[fetchDemoRecording] …` in Portuguese, exits 1 with the message on error) and append `&& node dist/tools/fetchDemoRecording.js` to the `build` script in `package.json`. Verify: `npm run build` with the recording present logs that it is already verified and downloads nothing; with `DEMO_RECORDING_PATH` pointing to a new empty folder under the scratch directory, it downloads 25,173,414 bytes and verifies them. Record both outputs in research.md "Implementation evidence". +- [x] T027 [US1] Docs, same commit, describing only what exists at this commit: `README.md` Getting started and Demo mode (partial): the build downloads the recording from the Release and verifies its SHA-256, and skips the download when a verified copy is present; `CLAUDE.md` Commands: `npm run build` also downloads and verifies the recording. The start-time refusal of a missing or altered recording is documented in T076, when it exists. +- [x] T028 [US1] Commit T022–T027 (plus the pending "CI runs" row from T021): `feat: download and verify the demo recording at build time`. + +### The in-process session build (FR-005, FR-006, FR-008, FR-009; T-1; research.md point 3) + +- [x] T029 [US1] Red: write `src/demo/buildDemoSession.test.ts` (isolated database; `buildSyntheticRecording()` with `receivedAt` added to every packet, increasing by 50 ms): `buildDemoSession(packets)` returns `{ sessionId, sessionUid, firstReceivedAt, lastReceivedAt, completions: [{ lapNumber, receivedAt }] }` matching the fixture; the database holds 1 session, the fixture's lap and `expectedSampleCount` samples per table; a packet that throws in `dispatchPacket` (a truncated buffer appended to the input) is skipped, not fatal (constitution II). Create `src/demo/buildDemoSession.ts` with the final signature returning `{ sessionId: 0, sessionUid: "", firstReceivedAt: undefined, lastReceivedAt: undefined, completions: [] }` and touching nothing; run; confirm `assert` failures. +- [x] T030 [US1] Green: implement `buildDemoSession` (every packet through `dispatchPacket` + `persistTelemetry`, in order, per-packet try/catch logging `[demo] pacote descartado na montagem: …`; first/last `receivedAt` of packets with a non-zero `sessionUID`; completions from T017's return value). Commit T029–T030: `feat: build the demo session in-process from the recording`. + +### The single-car message (FR-025; T-3; research.md point 6) + +- [x] T031 [P] [US1] Stub `src/demo/playerOnly.ts`: `export function toPlayerOnlyMessage(message: TelemetryWsMessage): TelemetryWsMessage` returning `message` unchanged. +- [x] T032 [US1] Red: write `src/demo/playerOnly.test.ts`: for a message with 22 cars and `playerCarIndex` 7, the result has `data` of length 1 equal to car 7 and `playerCarIndex` 0; `type`, `sessionUID`, `sessionTime`, `frameIdentifier` unchanged; the input object and its array are not mutated. Run; confirm `assert` failures. +- [x] T033 [US1] Green: implement `toPlayerOnlyMessage`. + +### Origin and capacity decisions (FR-018, FR-023; research.md point 5) + +- [x] T034 [P] [US1] Stub `src/demo/liveConnections.ts`: `decideOrigin(origin: string | undefined, allowedOrigin: string): "accept" | "refuse"` and `decideCapacity(openConnections: number, maxConnections: number): "accept" | "full"`, both returning `"accept"`. +- [x] T035 [US1] Red: write `src/demo/liveConnections.test.ts`: `decideOrigin` accepts the exact origin and an absent `Origin`; refuses another origin, the allowed origin with a trailing slash, and a different case of the host; `decideCapacity` accepts 0…max−1 open and returns `"full"` at max, for max 1 and 5. Run; confirm `assert` failures. +- [x] T036 [US1] Green: implement both. Commit T031–T036: `feat: add the single-car message and the live-connection decisions`. + +### The demo WebSocket server (FR-016, FR-018, FR-023, FR-024, FR-025, FR-028 counters; T-3; contracts/websocket.md) + +- [x] T037 [US1] (Done first in `src/network/`; moved before the push of Phase 3 to `src/demo/demoWsServer.ts` and `src/demo/demoWsServer.test.ts`, so the network layer never imports from `src/demo/`, and `src/network/wsBroadcaster.ts` only exports the shared `startHeartbeat`.) Red: write `src/demo/demoWsServer.test.ts` (an `http.Server` on `127.0.0.1:0`, `ws` clients): with `allowedOrigin` `http://localhost:5173`, a client with `Origin: https://example.com` gets HTTP 403 (`unexpected-response`); clients without `Origin` and with the allowed origin open; with `maxConnections` 1, the second open closes with code **4001**, reason `"demo full"`; with `maxConnectionMs` 300, an open connection closes with **4002**, reason `"time limit"`, and its timer is cleared when it closes earlier; with `perMessageDeflate: {}`, a client offering it has `extensions` containing `permessage-deflate`; with `perMessageDeflate: false`, none; `broadcast(json)` reaches every open client; `stats()` returns open connections and the sum of `socket.bytesWritten` of closed and open sockets (> 0 after a broadcast); `onEvent` receives `opened`, `closed` with the code, `refused-origin` and `full`. Add to `src/demo/demoWsServer.ts` the final signature `attachDemoWsServer(server: http.Server, options: { allowedOrigin: string; maxConnections: number; maxConnectionMs: number; perMessageDeflate: false | PerMessageDeflateOptions; onEvent?: (event: DemoWsEvent) => void }): { broadcast(json: string): void; stats(): { openConnections: number; bytesWritten: number }; close(code: number, reason: string): void }` doing nothing (no `upgrade` listener); run; confirm `assert` failures (not timeouts: every wait in the test has its own short deadline that fails an `assert`). +- [x] T038 [US1] Green: implement `attachDemoWsServer` (`noServer: true`, the server's `upgrade` handler with `decideOrigin` → `socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")` + `destroy()`; `handleUpgrade`; `decideCapacity` → `close(4001, "demo full")`; per-connection timer → `close(4002, "time limit")`; the existing 15-second heartbeat, extracted into a helper shared with `startWsBroadcaster`, whose standalone behaviour is unchanged; the helper, `startHeartbeat`, stays exported from `src/network/wsBroadcaster.ts`, and `src/demo/demoWsServer.ts` imports it). Commit T037–T038: `feat: add the demo WebSocket server with origin, capacity, time limit and compression`. + +**Checkpoint**: every US1 building block is green in isolation. Nothing runs in demo mode yet. + +- [x] T039 **[PARADA] [MANUAL] the maintainer approves the push of Phase 3.** Hands back: "go". +- [x] T040 Push; await and record the CI (header procedure) in the research.md "CI runs" table (committed with the next group). + +--- + +## Phase 4: User Story 2 — the session's dates and duration stay believable (P1) + +**Goal**: capture times from `receivedAt` for the session and its laps, written once after the build (FR-010, FR-011; research.md point 4). + +**Independent Test**: after `buildDemoSession` + `applyCaptureTimes` on the synthetic recording, `GET`-shaped reads (`getSessionById`, `listLapsForSession`) return the fixture's capture times; local ingestion still writes wall-clock times. + +- [x] T041 [P] [US2] Stub `src/demo/captureTimes.ts`: `export interface CaptureTimes { startedAt: string; lastSeenAt: string; lapRecordedAt: Map }` and `buildCaptureTimes(firstReceivedAt: number | undefined, lastReceivedAt: number | undefined, completions: Array<{ lapNumber: number; receivedAt: number | undefined }>): CaptureTimes` returning `{ startedAt: "", lastSeenAt: "", lapRecordedAt: new Map() }`. +- [x] T042 [US2] Red: write `src/demo/captureTimes.test.ts`: for `1792168223797`, `1792168446755` and laps 6/7/8 at `1792168298248`, `1792168372362`, `1792168445758` it returns `2026-10-16T16:30:23.797Z`, `2026-10-16T16:34:06.755Z` and the three ISO times (research.md point 4); a missing first, last or lap `receivedAt` throws `[demo] a gravação não tem receivedAt em : o modo demo não inventa datas.` (FR-010, constitution IV); last < first throws. Run; confirm `assert` failures. +- [x] T043 [US2] Green: implement `buildCaptureTimes` (`new Date(ms).toISOString()`). +- [x] T044 [US2] Red, then green: `applyCaptureTimes(sessionId: number, times: CaptureTimes): void`, split as `applySessionCaptureTimes` in `src/persistence/sessionRepository.ts` (`UPDATE sessions SET started_at = @startedAt, last_seen_at = @lastSeenAt WHERE id = @id`) and `applyLapCaptureTimes` in `src/persistence/lapRepository.ts` (`UPDATE laps SET recorded_at = @recordedAt WHERE session_id = @sessionId AND lap_number = @lapNumber`), prepared statements at module load; the combining function in **`src/demo/applyCaptureTimes.ts`**, its own module, so `captureTimes.ts` stays free of persistence imports (decided at the stop before T044: `persistence/db.ts` opens the connection at import). Also add `src/demo/purityBoundary.test.ts`: with a temporary `DB_PATH`, it requires the pure modules and asserts through `require.cache` that `persistence/db` was not loaded; seen failing on its own assertion with a temporary repository import in `src/demo/liveConnections.ts`, reverted with `git checkout`. Each later pure module (`insightsMatch`, `coachSeed`, `demoLog`) joins its list in the commit that creates it. Test in `src/demo/applyCaptureTimes.test.ts` (isolated database, synthetic recording through `buildDemoSession`): after the call, `getSessionById` and `listLapsForSession` return the synthetic capture times; a session ingested without it keeps SQLite's wall-clock format (FR-011). No existing statement changes. +- [x] T045 [US2] Commit T041–T044: `feat: compute and apply the recording's capture times`. The same commit adds `fetchDemoRecording.ts` to the `tools/` row of the folder tables in `README.md` and `CLAUDE.md`, since the documented build already runs it (decided before the push of Phase 3); the `src/demo/` row waits for T076. It also carries the pending "CI runs" row of T040. + +**Checkpoint**: dates are ready to be applied by the demo server (Phase 7). + +--- + +## Phase 5: User Story 5 — the maintainer prepares the coach cache once (P2) + +**Goal**: the insight comparison, the seed file's validation and seeding, the idempotent export core and CLI, then the maintainer's one-off export and the committed file (FR-012, FR-013, FR-014; T-2, T-5; contracts/coach-seed-file.md; research.md point 7). + +**Independent Test**: with a fake coach, the export core produces six entries on an empty seed and zero calls on a second run; `validateCoachSeed` accepts a valid synthetic seed and refuses each documented defect. + +### The insight comparison (FR-013; T-5) + +- [x] T046 [P] [US5] Stub `src/demo/insightsMatch.ts`: `export type InsightsMatch = { match: true } | { match: false; path: string; stored: unknown; computed: unknown }` and `insightsMatch(stored: unknown, computed: unknown): InsightsMatch` returning `{ match: true }`. +- [x] T047 [US5] Red: write `src/demo/insightsMatch.test.ts` (fixtures from `makeSample`/`buildInsights` in `src/analysis/testFixtures.ts`, or literal `LapInsights` objects): equal objects match; `0.35` vs `0.36` matches; `0.35` vs `0.37` does not; `1234.56` vs `1234.57` matches; a copied distance differing by one ulp (`x` vs `x + Number.EPSILON * x`) matches; one corner more, `trailBrakingPrimary` flipped, `severity` changed, `null` vs a number, a missing key and an extra key do not; the failure's `path` is the first difference, for example `corners[1].deltaLossS`. Tolerance: `Math.abs(a - b) <= 0.01 + 1e-9` (research.md point 7). Run; confirm `assert` failures. +- [x] T048 [US5] Green: implement `insightsMatch` (recursive; arrays by index and length; objects by sorted key sets; numbers by the tolerance; everything else by `===`). + +### The seed file (FR-012, FR-013; contracts/coach-seed-file.md) + +- [x] T049 [P] [US5] Stub `src/demo/coachSeed.ts` (no persistence import): the types of contracts/coach-seed-file.md (`CoachSeedFile`, `CoachSeedEntry`) and `validateCoachSeed(raw: unknown, context: { recordingSha256: string; sessionUid: string; completedLaps: number[]; computeInsights: (lapA: number, lapB: number) => LapInsights }): CoachSeedFile`, returning `raw as CoachSeedFile` without checking. +- [x] T050 [US5] Red: write `src/demo/coachSeed.test.ts`: a valid synthetic seed is returned; each of these throws a message naming the problem, in Portuguese and prefixed `[demo] cache do coach inválido:` — not an object, `formatVersion` ≠ 1, `recordingSha256` different (message names both hashes), `sessionUid` different, a lap not in `completedLaps`, `lapA = lapB`, a duplicate pair, an entry without `analysisText`, a key outside the contract at the top level or in an entry (for example `usage` or `createdAt`: the file is public and holds nothing about the account, the request or the usage, contracts/coach-seed-file.md), and insights beyond the tolerance (message names the pair and `insightsMatch`'s path, values stored and computed). Run; confirm `assert` failures. +- [x] T051 [US5] Green: implement `validateCoachSeed`. +- [x] T052 [US5] Test first, then implement, `applyCoachSeed(sessionId: number, seed: CoachSeedFile): void` in **`src/demo/applyCoachSeed.ts`** (its own module; `coachSeed.ts` stays free of persistence imports, and `insightsMatch` and `coachSeed` join the list of `src/demo/purityBoundary.test.ts`), inserting each entry with the existing `insertAnalysis` (`JSON.stringify(entry.insights)`, `analysisText`, `model`). Test in `src/demo/applyCoachSeed.test.ts` (isolated database): after the call, `getCachedAnalysis(sessionId, lapA, lapB)` returns each entry. Commit T046–T052: `feat: validate and apply the coach seed file`. + +### The export core and CLI (FR-014; T-2; quickstart.md section D) + +- [x] T053 [US5] (The two-lap recording comes from a new `src/demo/testFixtures.ts`, `buildDemoRecording({ sessionUID, laps })`, built from `buildLapDataPacket`, `buildMotionPacket` and `buildCarTelemetryPacket`, now exported from `src/replay/testFixtures.ts` without any change to their logic; decided at the stop before T053. These synthetic tests cover the reuse logic and the fake coach's calls; the real analysis and the insight tolerance against it are exercised only in T061 and T084.) Red: write `src/demo/exportCoachSeed.test.ts` (isolated database, synthetic recording with two laps, so two ordered pairs; a **fake** `generate` function that counts calls and returns `{ text: "análise -", model: "modelo-falso" }`): `exportCoachSeed({ packets, recordingSha256, pairs, existing, generate, currentModel })` returns a seed with one entry per pair and calls `generate` once per pair on an empty `existing`; called again with its own result as `existing`, it calls `generate` zero times; with one entry's `insights` altered beyond the tolerance, it calls once; with `currentModel` different from an entry's `model`, it calls for that entry. Create the function with that signature in **`src/demo/exportCoachSeed.ts`** (its own module, loaded by the test only after `setupIsolatedTestDb()`) returning an empty seed without calling `generate`; run; confirm `assert` failures. +- [x] T054 [US5] Green: implement `exportCoachSeed` (`buildDemoSession`, `buildCaptureTimes` + `applyCaptureTimes`, `buildInsights` per pair from the repositories, `insightsMatch` to decide reuse, `generate(insights, getTrackBenchmark(trackId))` otherwise; entries sorted by `lapA`, `lapB`; each entry built with exactly the contract's fields `lapA`, `lapB`, `model`, `analysisText`, `insights`, so the result passes `validateCoachSeed`, which T053's test also asserts). +- [x] T055 [US5] Create `tools/exportCoachSeed.ts`: refuses to run when `DB_PATH` is unset or resolves to `/data/telemetry.db` (`[exportCoachSeed] defina DB_PATH=:memory: ou um caminho temporário; o data/telemetry.db nunca é usado.`), and when `ANTHROPIC_API_KEY` is unset; reads and verifies the recording against `PUBLISHED_RECORDING_SHA256`; uses the six ordered pairs of laps 6, 7 and 8 (T-2); passes the existing `demo/coach-seed.json` if present; uses `generateCoaching` as `generate` and the coach's model constant as `currentModel` (exported from `src/coaching/coachService.ts` if it is not yet); prints per pair `reutilizado` or the call's token `usage` (already logged by `coachService`); writes `demo/coach-seed.json` (2-space JSON, trailing newline) through `writeCoachSeedFile` in `src/demo/coachSeed.ts`, atomically (`.tmp` in `demo/`, renamed over the file), **after each paid pair** and at the end, with the pairs processed so far plus the previous entries of those not yet processed, so an interrupted run never loses a paid pair nor leaves half a JSON, and the next run reads the partial file and asks only for the missing pairs (added at the stop before T060/T061, 2026-10-21, with its own tests: `src/demo/coachSeedFile.test.ts`, and two cases in `src/demo/exportCoachSeed.test.ts`). Add `"export-coach-seed": "ts-node-dev tools/exportCoachSeed.ts"` to `package.json`. The checks live in a pure `assertExportEnvironment(env, projectRoot, platform = process.platform)` in `src/demo/coachSeed.ts`, called before any module that opens the database or reads the coach's configuration; it resolves `DB_PATH` from the root and, on Windows, compares case-insensitively with both separators. Its unit test, `src/demo/exportEnvironment.test.ts` (stub → red → green), uses fake roots and an explicit `platform`, and covers the spellings that point at the same file: swapped case, `./data/telemetry.db`, the `\` separator and a `..` path that comes back to `data/` (decided at the stop before T055, 2026-10-21, so that no test puts the real database's path in an environment). The spawned test in `src/demo/exportCoachSeedCli.test.ts` (clean environment without `ANTHROPIC_API_KEY`, `cwd` a temporary folder so no `.env` is loaded) checks only the refusals that need no real path: no `DB_PATH`, and a temporary `DB_PATH` with no key (and that nothing was created); nothing can be called. **The agent does not run the CLI with a key.** +- [x] T056 [US5] Docs, same commit: `README.md` Demo mode (partial): the seed file, what it is tied to, the tolerance check, and the maintainer's export procedure (quickstart.md section D); Commands: `npm run export-coach-seed` (paid, manual); Known limitations: the demo seeds six ordered pairs while today's frontend, which fixes the primary lap to the latest one, can request only (8, 6) and (8, 7) (FR-021, T-2). `CLAUDE.md` Commands (`export-coach-seed`, paid, never run by the agent) and Known pitfalls (the same limitation). +- [x] T057 [US5] Commit T053–T056: `feat: add the idempotent coach seed export`. +- [x] T058 **[PARADA] [MANUAL] the maintainer approves the push of Phases 4–5.** Hands back: "go". +- [x] T059 Push; await and record the CI (header procedure). + +### The maintainer's export (manual, paid) + +**Blocked, and the order changed (decided by the maintainer, 2026-10-23).** The first export (T061) produced a seed that was rejected at the maintainer's reading, for errors that come from the coach's prompt and input and from artifacts of the analysis engine (research.md, "T061 — the first coach seed, rejected" and "Analysis engine observations"). The rejected file was moved out of the repository as evidence and is not committed. Consequences: + +- **T061 depends on two specs, each from `main` and merged before it:** the spec of the coach's input and prompt, and the spec of the analysis engine's artifacts. The seed is generated **once**, after both. +- After those merges, this branch is updated with `main` (its own **[PARADA]**, followed by the CI record; how, below) and this spec is amended before T061: the seed's `formatVersion` 2 with a `promptVersion` per entry, demo mode refusing a seed whose `promptVersion` differs from the current one, FR-013 and FR-014, contracts/coach-seed-file.md, and new tasks inserted before T061. T061–T064 and T084 wait for that amendment. +- **Phases 6 and 7 (T065–T079) run before T061–T064**, since they do not need the real seed (T073 writes a synthetic one). Every **[PARADA]** still holds, including T078. + +**Updating this branch with `main` (recorded 2026-10-23; to be decided by the maintainer when it is reached, with its own [PARADA]).** `003-demo-mode` is published (pull request #12), and the project never rewrites published history (CLAUDE.md, Rules): a `git rebase origin/main` of this branch, as T095 first said, could only be pushed with `--force`, so it is excluded, and **`--force` is not used in any case**. The ruleset of `main` requires linear history, branches up to date before merging, and "Rebase and merge" as the only merge method. What GitHub's documentation says (read 2026-10-23): + +- "Update branch": "Click **Update branch** to perform a traditional merge"; the button is available "when there are no merge conflicts and the branch is behind the base branch"; the alternative in the same menu, "Update with rebase", rewrites the branch ("Rebasing creates a linear history without a merge commit") and is excluded for the same reason as a local rebase ([Keeping your pull request in sync with the base branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/keeping-your-pull-request-in-sync-with-the-base-branch)). +- "Rebase and merge": "all commits from the topic branch (or head branch) are added onto the base branch individually without a merge commit", it "always updates the committer information and creates new commit SHAs" ([About merge methods on GitHub](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/about-merge-methods-on-github)); "If GitHub cannot safely rebase the pull request automatically, you can rebase locally, resolve conflicts, and push the updated branch" ([About pull request merges](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges)). +- Linear history: it "prevents collaborators from pushing merge commits to the branch", and "any pull requests merged into the protected branch must use a squash merge or a rebase merge"; strict status checks: "The branch must be up to date with the base branch before merging" ([About protected branches](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches); [Available rules for rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets)). +- **Not documented** on any of these pages: what "Rebase and merge" does with a merge commit that "Update branch" added to the head branch (whether it drops it and replays only the branch's own commits, or reports that it "cannot safely rebase"), and whether such a merge commit counts against linear history. None of them says so either way. + +Options, to be chosen then: + +1. **Update by merge** ("Update branch", or `git merge origin/main` and a normal push): no history rewritten. It works only if "Rebase and merge" linearizes a head branch that contains a merge commit, which the documentation does not state. It would have to be confirmed first, for example on a throw-away pull request with a merge commit from "Update branch" against the protected `main` (as spec 002 demonstrated its refusals), closed unmerged; if the merge box then offers "Rebase and merge" and the result on a test is linear, this option holds; if it reports that it cannot rebase, it does not. +2. **A new branch from the updated `main`** with this branch's commits reapplied (`git cherry-pick`, in order, conflicts resolved commit by commit), and **a new pull request**, closing #12 unmerged with a link to the new one. No history rewritten and nothing pushed with `--force`; #12 and `003-demo-mode` stay as published. The cost: new commit SHAs (the "CI runs" table and the pull request body then refer to #12's SHAs, which still exist on `origin`), the CI awaited on the new pull request, and every reference to "#12" in this spec updated. + +T095's update, if the branch is behind again at the merge, follows the same choice. + +**The demo is not published until the frontend's delta chart is fixed (recorded 2026-10-25, spec 005).** Spec 005 changes the backend's cumulative delta series (each lap counted from its extrapolated start; `totalDeltaS` within 0.022 s of the official difference on the recording), and the frontend still computes the old series in its "Delta acumulado" chart. Until the frontend spec that ports the new series, or receives it from the API, is live, the demo's chart and the chips' `deltaLossS` would disagree on the same screen; the chart already shows the error today (0.28 s against the official 0.162 s in (6,7), and 0.13 s off in (8,7)). So the demo is not published before that frontend spec is live. Source: `specs/005-analysis-engine-artifacts/spec.md` FR-020 and `specs/005-analysis-engine-artifacts/research.md` section 7. + +- [x] T060 **[MANUAL] [PARADA] spending limit.** (Done by the maintainer before the first export, 2026-10-23.) Before any export, the maintainer opens the Anthropic Console and confirms that the workspace of the key has a monthly spending limit set. Hands back: "limit set" (the amount is optional to share). The session waits. +- [ ] T061 **[MANUAL] [PARADA] run the export, only after T057 is committed, the suite is green with the fake coach, T059's CI is green, and the two specs and the amendment above are in place** (a first run on 2026-10-23 was rejected; see above). On the maintainer's own machine and shell, following quickstart.md section D, with the published recording verified in `recordings/`: `DB_PATH=:memory: ANTHROPIC_API_KEY= npm run export-coach-seed`, then the same command a second time. Never against `data/telemetry.db` (the tool refuses it). Then **the maintainer reads the six texts**, since `demo/coach-seed.json` is public once committed, checking for each pair that every number the text cites is either one of the numbers in that entry's `insights` or one of the two benchmark lines the coach receives with them, and that the text holds nothing but coaching about the two laps. A cited personal best or theoretical best must be the recording's own (the export builds its database from the recording alone): **1:13.372** (lap 8, the best valid lap) and **1:13.208** (the best valid sectors of laps 7 and 8: 18.310 + 37.573 + 17.325 s; lap 6 is invalid and does not count), never a time from another session. If a text fails, the maintainer deletes that entry from the file and runs the export again: only that pair is called. Hands back: `demo/coach-seed.json` in the working tree (uncommitted); the console output of both runs (six calls with their `usage` the first time, six `reutilizado` the second); the confirmation that the six texts were read and pass the check. The key is then removed from the shell and never set on the hosting platform. **Conditional [PARADA]**: if the second run makes any call, or the tool refuses the recording or the seed, the session stops and reports before anything else. +- [ ] T062 [US5] After T061: record the six `usage` lines and the second run's output in research.md "Implementation evidence" (token counts only; no key, no text of the analyses is needed there; the counts never go into the seed file). Check `demo/coach-seed.json` against contracts/coach-seed-file.md, including that it has no key outside the contract: `formatVersion` 1, `recordingSha256` equal to `PUBLISHED_RECORDING_SHA256`, `sessionUid` `1026725433292396341`, six entries (6,7), (6,8), (7,6), (7,8), (8,6), (8,7). +- [ ] T063 [US5] Add the test of the committed file, which only now can exist without being skipped: in `src/demo/recording.test.ts`, `demo/coach-seed.json` (read from `findProjectRoot()`) has `recordingSha256 === PUBLISHED_RECORDING_SHA256`, six entries, and passes `validateCoachSeed` with the context `{ recordingSha256: PUBLISHED_RECORDING_SHA256, sessionUid: "1026725433292396341", completedLaps: [6, 7, 8], computeInsights: (a, b) => }`, which exercises the structural checks without the recording (the insight check against the real analysis runs at start, and in T084). +- [ ] T064 [US5] Commit `demo/coach-seed.json`, T062 and T063: `feat: add the coach seed for the demo recording`. + +**Checkpoint**: the seed exists, is tied to the published recording, and is tested. + +--- + +## Phase 6: User Story 3 — visitors cannot write data or cost money (P1) + +**Goal**: the read-only switch and the demo HTTP app (CORS to one origin, cache headers, no-store), with no visitor write path and no paid call (FR-012, FR-015, FR-018, FR-026, FR-027 not adopted; T-1, T-6; contracts/rest.md). + +**Independent Test**: in-process tests show a write failing after the switch, the demo app answering only the allowed origin with CORS headers, reads with `Cache-Control: no-cache` and a `304` on revalidation, and the coach POST with `no-store`. + +- [x] T065 [US3] Test first, then implement, `setReadOnly(): void` in `src/persistence/db.ts` (`db.pragma("query_only = ON")`). Test in `src/persistence/readOnly.test.ts` (isolated database): after `setReadOnly()`, `insertAnalysis` throws (`SQLITE_READONLY` or the attempt-to-write error) and the row counts of every table are unchanged; reads still work (FR-015, T-1). +- [x] T066 [US3] Red: write `src/http/demoApp.test.ts` (an `http.Server` from the app on `127.0.0.1:0`; isolated database with a synthetic session and one seeded pair): `createApp({ demo: { allowedOrigin: "http://localhost:5173" } })` answers a request with `Origin: http://localhost:5173` with `Access-Control-Allow-Origin: http://localhost:5173` and `Vary: Origin`; with `Origin: https://example.com`, no `Access-Control-Allow-Origin`; without `Origin`, the body is served; `GET /sessions`, `/sessions/:id`, `/sessions/:id/benchmark` and `/sessions/:id/laps/:lap/telemetry` carry `Cache-Control: no-cache` and an `ETag`, and a repeat with `If-None-Match` gets `304` with an empty body (sent with `node:http`: `fetch` with `If-None-Match` switches to `no-store` and adds `Cache-Control: no-cache`, which Express never answers with 304); `POST /sessions/:id/analysis` carries `Cache-Control: no-store`, returns the seeded pair with `cached: true`, and returns the existing 503 body for another pair, writing nothing; `createApp()` with no options keeps today's headers (no `Cache-Control`, any origin reflected). Refactor `src/http/httpServer.ts` into `createApp(options?: { demo?: { allowedOrigin: string } }): express.Express` plus the unchanged `startHttpServer(port)` (= `createApp().listen(port, …)`), with the demo branch not yet implemented (it returns the local app); run; confirm `assert` failures. +- [x] T067 [US3] Green: implement the demo branch of `createApp` (`cors({ origin: [allowedOrigin] })`: a list, not the string, since `cors` 2.8.6 sends a string origin as `Access-Control-Allow-Origin` for every request origin, while a list reflects only an exact match and adds `Vary: Origin`; decided at the stop before T067, 2026-10-23; a middleware setting `Cache-Control: no-cache` on the four read routes and `no-store` on the POST; Express's default ETag left on). No per-IP limit and no `trust proxy` setting (FR-027 not adopted, T-6). No REST compression (T-3: the platform compresses). +- [x] T068 [US3] Commit T065–T067: `feat: add the read-only switch and the demo HTTP app`. + +**Checkpoint**: the write and cost protections exist as code; Phase 7 turns them on. + +--- + +## Phase 7: User Story 4 — the hosting can run, probe and restart the API (P2), and the demo wired end to end (US1–US5) + +**Goal**: the health endpoint, the demo logger, and `startDemoServer` running the full start sequence of plan.md on one port, with the spawned integration test and refusals (FR-002–FR-009, FR-012, FR-013, FR-015–FR-018, FR-023–FR-026, FR-028; all T-decisions). + +**Independent Test**: the spawned demo integration test (synthetic recording and seed) passes, and the spawned refusals exit non-zero with their messages and bind no port. + +### Health and logging (FR-017, FR-028) + +- [x] T069 [P] [US4] Test first, then implement, `src/http/routes/healthRouter.ts`: `createHealthRouter(isHealthy: () => boolean)`, `GET /health` → `200 {"status":"ok"}` or `503 {"status":"replay stopped"}`, always `Cache-Control: no-store`; mounted by `createApp` in both modes (local: always healthy). Test in `src/http/healthRouter.test.ts`; `src/http/demoApp.test.ts` gains the health assertions. +- [x] T070 [P] [US4] Stub `src/demo/demoLog.ts`: `export interface DemoCounters { openConnections: number; wsBytes: number; restBytes: number; coach503: number; cpuUserMs: number; cpuSystemMs: number }` and `formatSummary(c: DemoCounters): string` returning `""`. +- [x] T071 [US4] Red: write `src/demo/demoLog.test.ts`: `formatSummary` returns one line starting `[demo] resumo:` with the open connections, WebSocket, REST and total bytes (in bytes and MiB), the coach 503 count, the CPU time (user + system, ms) and the note `bytes escritos nos sockets pela aplicação; não é o número cobrado pela hospedagem`; it never contains an IP-like string (the counters have none). Run; confirm `assert` failures. +- [x] T072 [US4] Green: implement `formatSummary`; add to `src/demo/demoLog.ts` the REST counting middleware (on `finish`, add the increase of `req.socket.bytesWritten` since that socket's previous response, kept in a `WeakMap`; count status 503 on `POST /sessions/:id/analysis`) with an in-process test in `src/demo/demoLog.test.ts` (one keep-alive client, two requests: the sum equals the socket's `bytesWritten`). Wire the middleware into `createApp`'s demo branch. + +### The demo server (FR-002–FR-009, FR-012, FR-013, FR-015, FR-016, FR-023–FR-025, FR-028; plan "Start sequence") + +- [x] T073 [US4] Red: write `src/demo/demoServer.integration.test.ts`. It builds a synthetic recording with `receivedAt` (extending `src/replay/testFixtures.ts`'s builders, two completed laps), writes it as `.jsonl` to a temporary folder, computes its SHA-256, computes the two pairs' insights in-process exactly as the server will (same builders, `:memory:` through a separate child or `testDb`), writes a synthetic seed for them, finds a free port, and spawns `src/server.ts` (`ts-node/register`, clean environment, `cwd` the temporary folder) with `DEMO_MODE=true`, `PORT`, `DEMO_ALLOWED_ORIGIN=http://localhost:5173`, `DEMO_RECORDING_PATH`, `DEMO_SEED_PATH`, `DEMO_MAX_CONNECTION_MINUTES=1`. It waits for the `[demo] pronto` line (with its own deadline), then asserts: `GET /health` 200; `GET /sessions` one session with the synthetic capture times; laps and sample counts of the fixture; a `304` on revalidation; the seeded pair `cached: true` and another pair 503; CORS only for the allowed origin; a WebSocket from the allowed origin negotiates `permessage-deflate` and receives messages with `data.length === 1` and `playerCarIndex === 0`; a foreign origin gets 403; no `telemetry.db` file and no `data/` folder appear in the temporary folder. Then the refusals that need the wired server (the configuration refusals are already covered by T013's `src/config/demoFailFast.test.ts`), each spawned with one defect and asserting exit ≠ 0, the message on stderr, and that the port is still free afterwards: the recording missing (T-4); the recording altered by one byte; the seed for another hash; a seed with one insight number off by 0.05 (T-5). Run against today's `server.ts` (which has no demo branch); confirm the failures are `assert` failures. +- [x] T074 [US4] Green: create `src/demo/demoServer.ts` `startDemoServer(config: DemoConfig): Promise` with the plan's ten steps: verify the recording against the seed's `recordingSha256` (in production equal to `PUBLISHED_RECORDING_SHA256`); `buildDemoSession`; `buildCaptureTimes` + `applyCaptureTimes`; `validateCoachSeed` (with `computeInsights` from the built session) + `applyCoachSeed`; `setReadOnly()`; `http.createServer(createApp({ demo }))` + `attachDemoWsServer(…, perMessageDeflate: config.wsCompression ? {} : false)`; `listen(config.port, "0.0.0.0")`; `runReplay(packets, { speed: "1x", loop: true }, { send, onPass })` with `send` = dispatch (try/catch per packet) → `buildWsMessage` → `toPlayerOnlyMessage` → `broadcast`, **no persistence** (FR-005, T-1); log `[demo] pronto em ms (montagem , hash , cache ; compressão do WebSocket )`; the 15-minute `setInterval(...).unref()` summary (`formatSummary`, `process.cpuUsage()`); `SIGTERM`/`SIGINT` → abort the replay, `close(1001, "going away")`, close the server, `closeDb()`. The health callback reports false once the replay promise settles. Every startup error is printed (message only) and exits 1 before any port is bound. +- [x] T075 [US4] Change `src/server.ts` (the move of today's wiring into `startLocalServer()`, with its modules loaded inside it, was done in T013, because the demo validation must run before `persistence/db` opens; what remains here is the demo branch): today's wiring stays in `startLocalServer()`, unchanged (ports, UDP, standalone WebSocket, per-packet logs), selected with `parseRunMode(process.env.DEMO_MODE)`; in demo mode replace T013's temporary "ainda não disponível" exit with `startDemoServer(parseDemoConfig(process.env, findProjectRoot()))`, and never touch the local wiring. `src/config/demoFailFast.test.ts` stays green. `src/config/serverFailFast.test.ts` stays green; T073 goes green. +- [x] T076 [US4] Docs, same commit (every row of plan.md's Documentation table that is now visible): `README.md` Demo mode section complete and no longer partial (start sequence, in-memory database, capture dates, read-only, one port, the WebSocket rules: single car, `playerCarIndex` 0, compression and `DEMO_WS_COMPRESSION`, 4001/4002, 5 minutes, 403 by origin; CORS as a browser-only limit; cache headers; the platform compresses REST; health; the log and its summary; the coach in demo mode; the start-time refusals of a missing or altered recording and of a mismatching seed); the new `GET /health` also in the description of the REST API, since it exists in both modes; the Features bullet "all 22 cars per message" gains the demo exception, and the Contract ownership paragraph gains the sentence that the demo's message content differs (FR-025); Known limitations: the 5 GB allowance ≈ 1,330–2,410 full visits a month and a suspension until the next month without a payment method; no per-IP limit; the browser's pong frames may not count as activity (undocumented); the demo shows only the player's car; compression costs an estimated 11–12% of the free instance's CPU per connection (`DEMO_WS_COMPRESSION=false` is the way out). `CLAUDE.md`: Architecture table (`src/demo/`, `demo/`), the contract note (demo differences), Known pitfalls (the same new limitations). +- [x] T077 [US4] Commit T069–T076: `feat: run the demo server end to end on one port`. + Recorded at this commit (2026-10-23): (a) the README's "Demo mode" section stays marked partial instead of "no longer partial", because no coach seed is committed (the first was rejected; T061 waits for the two specs), so demo mode refuses to start without `DEMO_SEED_PATH` (constitution IV); (b) the server reads the seed first and checks the recording against the seed's `recordingSha256` (plan step 2), so in T073 the altered recording and the seed for another hash fail with the same `verifyRecording` message; a missing recording, a missing seed and invalid seed JSON have their own `[demo] …` messages; (c) `createApp` takes `isHealthy` and the demo's `restCounter` as options; the `RestCounter` type, first imported by `src/http/httpServer.ts` from `src/demo/demoLog.ts`, was moved before the push into `src/http/httpServer.ts`, with `demoLog.ts` importing it from there, so nothing in `src/http/` or `src/network/` imports from `src/demo/` (plan.md, Structure decision); the seed-hash refusal names both values, abbreviated: the SHA-256 of the recording read and the `recordingSha256` the seed expects; (d) the README's Tests paragraph no longer says the HTTP routes and the WebSocket server are untested, and its Next steps list the coach-input and analysis-engine specs. +- [x] T078 **[PARADA] [MANUAL] the maintainer approves the push of Phases 6–7.** Hands back: "go". +- [x] T079 Push; await and record the CI (header procedure). + +**Checkpoint**: demo mode runs end to end against synthetic data in the test suite. + +--- + +## Phase 8: User Story 6 — local use is unchanged (P1) + +**Goal**: evidence that local mode behaves as before (FR-001, FR-011, FR-019; SC-007; quickstart.md section C). + +**Independent Test**: the spec 001 measurement gives 2,411 of 2,411 samples per table at `1x`, with a temporary `DB_PATH`. + +- [x] T080 [US6] **SC-007**, following quickstart.md section C and spec 001's [baseline-measurement.md](../001-realtime-replay/baseline-measurement.md) method: create a temporary folder under the scratch directory with a `.env` holding only `WS_PORT`, `UDP_PORT` and `HTTP_PORT` (the same values as the repository's, read without printing anything else); with `DOTENV_CONFIG_PATH` pointing at it, run the key check of the header (must print `false`), then `DB_PATH=/telemetry.db npm run dev` (demo mode off) and, in the same environment, the replay; confirm the startup log names the temporary database; `npm run replay -- recordings/demo-interlagos-tt-20hz-laps6-8.jsonl` (default `1x`) to completion; stop the server; count rows in the **temporary** database with a one-off `node -e` using `better-sqlite3` opened read-only on that path. Expected: 1 session, 3 laps with the README's times, **2,411 of 2,411** car telemetry and **2,411 of 2,411** motion samples. Record the commands and output in research.md "Implementation evidence — SC-007". **Conditional [PARADA]**: any count other than 2,411, or the log naming any other database, stops the session for a report. +- [x] T081 [US6] (The 22-car check needs traffic: a `--speed=max` replay into the same temporary database fed the compiled server while a client listened; 2026-10-23.) Compiled local mode: `npm run build`, then, with the same `DOTENV_CONFIG_PATH` as T080 and the key check printing `false`, `DB_PATH=/compiled.db npm start`; confirm the log names the temporary database, the WebSocket and REST answer on their own ports with 22 cars per message, and `GET /health` returns 200; stop it. Then, with `DB_PATH` unset, only print the compiled `dbPath` (as in T006) to confirm it is `/data/telemetry.db`, without starting the server. Record in research.md (FR-001, FR-019). +- [x] T082 [US6] Check that no runtime or development dependency was added (FR-022): `git diff main -- package.json` shows only the `build`, `start` and `export-coach-seed` scripts. Record in research.md. +- [x] T083 [US6] Commit T080–T082 evidence plus the pending "CI runs" rows: `docs: record the local-mode checks for spec 003`. + +--- + +## Phase 9: Polish and cross-cutting + +- [ ] T084 **Demo mode locally, quickstart.md section B**, with the committed seed and the published recording: `npm run build`, then, with `DOTENV_CONFIG_PATH` pointing at an empty scratch `.env` and the key check printing `false`, `DEMO_MODE=true PORT=10000 DEMO_ALLOWED_ORIGIN=http://localhost:5173 npm start` (PowerShell `$env:` form; T085–T086 start it the same way). Run every row of section B's table, including the 6-connection capacity check, the time limit with `DEMO_MAX_CONNECTION_MINUTES=1`, a restart with `DEMO_WS_COMPRESSION=false` (no extension; log says off), one 15-minute summary, and every refusal listed under the table. The WebSocket checks use a small script added as `specs/003-demo-mode/evidence/check-demo-ws.mjs` (origin, capacity, time limit, extension, `data.length`, `playerCarIndex`, bytes received). Record the commands and outputs in research.md "Implementation evidence — quickstart B", including the measured time until ready and the WebSocket bytes per second on the wire (SC-001, SC-005, SC-006, SC-008–SC-013, SC-015). **Conditional [PARADA]**: a real seed refused by the insight tolerance, a capture date other than 2026-10-16T16:30:23.797Z / 4 min, a sample count other than 2,411, or more than 10 KiB/s per connection stops the session for a report. +- [ ] T085 SC-002 and SC-003, in the same demo run as T084 or a second one: let the loop run at least 20 passes (about 75 minutes), reading `GET /sessions` and `GET /sessions/1` after the first and after the twentieth pass; then restart the server three times and read them again. Expected: identical bodies (session, laps, dates, counts) every time. Over the same run (more than one hour), check SC-015 in the log: no per-packet line, one summary every 15 minutes with non-zero WebSocket, REST and total bytes after traffic, a coach 503 count of 0 while only (8, 6) and (8, 7) are requested, and no IP address. Record in research.md. +- [ ] T086 SC-004: with the demo running, a scripted battery (`specs/003-demo-mode/evidence/check-demo-writes.mjs`) hitting every route and method (including malformed JSON bodies, unknown paths, `PUT`/`DELETE`, cached and uncached pairs, WebSocket client messages, and a UDP datagram to the port number), then `GET /health` and the session reads unchanged and the log showing no coach call; record the output in research.md. +- [ ] T087 Contract check (FR-020, constitution VI): `git diff main -- src/ws/messageFormat.ts src/http/routes/sessionsRouter.ts src/http/routes/analysisRouter.ts src/persistence/*Repository.ts` shows no change to any existing field, route, status or error string (only the new capture-time statements); `src/http/routes/healthRouter.ts` is the only new route. Record in research.md, and complete the pull request body's "Changes the frontend sees" list (plan.md, 14 items) for the frontend's spec 003. +- [ ] T088 Documentation review (FR-021, constitution IV): README.md and CLAUDE.md describe only what exists; the Demo mode section is no longer marked partial; every new known limitation is in both files (local replay, six pairs/two reachable, 5 GB, no per-IP limit, pong frames, single car, compression CPU); no stale "10 minutes", "no build", or "22 cars" without the demo exception remain (`grep -n "10 min\|no build\|22 cars" README.md CLAUDE.md`). Fix anything stale in this task. +- [ ] T089 Set the status line of `specs/003-demo-mode/spec.md` to "Implemented; pull request #" (the merge date is not known before the merge, and no second pull request is opened to add it), mark the done tasks in this file, and commit with T084–T088: `docs: record the demo-mode validation for spec 003` (plus any fix from T088, with the matching type in its own commit if it changes code). +- [ ] T090 **[PARADA] [MANUAL] the maintainer approves the push of Phases 8–9.** Hands back: "go". +- [ ] T091 Push; await the CI (header procedure). **CI red**: follow the header's rule (diagnose, fix in its own commit, **[PARADA]** for that push, re-run only for infrastructure). +- [ ] T092 Run `/speckit-converge` for spec 003 (every FR and SC against the code). Any appended task follows the same rules; each extra push is its own **[PARADA]**. +- [ ] T093 Update the pull request body (`gh pr edit 003-demo-mode --body-file /pr-body.md`) with: the spec summary, the "Changes the frontend sees" list, the validation summary (T080–T086), the manual steps done by the maintainer (T060, T061), the out-of-scope items (deploy, CI build check, frontend), and the last push's CI row (run id, head SHA, `typecheck`, `test` from the jobs API). **[PARADA] before editing the body**, since it is visible on GitHub. +- [ ] T094 **[PARADA] [MANUAL] the maintainer approves marking the pull request ready for review.** Hands back: "go". Then `gh pr ready 003-demo-mode`. +- [ ] T095 **[MANUAL] [PARADA] the merge is the maintainer's**, on GitHub with "Rebase and merge", with `typecheck` and `test` green and the branch up to date with `main` (ruleset). Hands back: "merged". If the branch is behind, the update (by one of the two options recorded in Phase 5, "Updating this branch with `main`"; never a rebase of the published branch, never `--force`) is its own **[PARADA]** push, followed like every push by the CI wait and the jobs-API record (header procedure), and the merge waits for it. +- [ ] T096 After the merge: await and record the push-to-`main` CI run (jobs API), and confirm the README badge shows passing. The record goes into the pull request as a closing note only if the maintainer asks; otherwise it is reported in the session. +- [ ] T097 Delete the merged branch locally (`git branch -d 003-demo-mode` after switching to an updated `main`); deleting it on `origin`, if GitHub did not, is a push: **[PARADA]**. +- [ ] T098 Report to the maintainer: tasks done, evidence recorded, known limitations added, and the two items of future work (the CI build check spec; FR-027 after the deploy). The deploy itself, the CI build check and any frontend change are out of scope and not started. + +--- + +## Outside this feature's tasks + +- **The deploy** to Render and the links in both READMEs (the next step). +- **A build check in the API's CI**, in its own small spec before the deploy (plan.md "Future work"): it changes the ruleset of `main`. +- **FR-027 revisited** after the deploy, checking which header carries the visitor's address. +- **Any frontend change**: the frontend's spec 003 consumes the "Changes the frontend sees" list (plan.md and the pull request body). + +--- + +## Dependencies & execution order + +### Phase dependencies + +- **Setup (T001)**: first, read-only. +- **Foundational (T002–T021)**: after Setup; blocks every story. F1 (T002–T008) → F2 (T009–T015) → F3 (T016–T018) → first push and draft pull request (T019–T021). +- **US1 (T022–T040)**: after Foundational. +- **US2 (T041–T045)**: after F3 (needs the completed-lap return) and T030 (`buildDemoSession`). +- **US5 (T046–T064)**: T046–T052 after Foundational; T053–T057 after T030 and T045; T060 (spending limit) any time before T061; **T061 only after T057 is committed, green locally with the fake coach, and T059's CI is green**; T062–T064 (including the committed-seed test T063) only after T061. Since 2026-10-23, T061 also waits for the coach input spec, the analysis-engine spec and this spec's `promptVersion` amendment (see "Blocked, and the order changed" in Phase 5). +- **US3 (T065–T068)**: after Foundational; T066 needs an applied seed in its fixture, so after T052. +- **US4 (T069–T079)**: T069 ∥ T070; T071–T072 after T070; **T073–T077 after US1, US2, US3 and T052**, since the start sequence uses all of them; it does not need the real seed (the test writes a synthetic one). Phases 6 and 7 run before T061–T064 (decided 2026-10-23). +- **US6 (T080–T083)**: after T077 (the local wiring was moved in T075). +- **Polish (T084–T098)**: T084–T086 after T064 (real seed) and T077; then T087–T089; then T090–T098 in order. + +### After every push + +Every push task (T020, T040, T059, T079, T091, a fix push, the rebase push of T095) is followed by the CI wait and the jobs-API record of the header procedure, and **the next phase starts only once that run is green** (T021 → Phase 3; T040 → Phase 4; T059 → T060/T061 and Phase 6; T079 → Phase 8; T091 → T092). A red run blocks until it is fixed by its own commit and push (**[PARADA]**), or re-run once for an infrastructure failure recorded as such. + +### Within each pure function + +stub → red (seen failing on its own `assert`) → green, then commit with the test. Never commit the red step alone. + +### Parallel opportunities + +- T009 (`src/config/mode.ts`) ∥ T010 (`src/config/demoEnv.ts`). +- T031 (`src/demo/playerOnly.ts`) ∥ T034 (`src/demo/liveConnections.ts`), and their red/green steps pairwise, since they share no file; they are committed together in T036. +- T041 (`src/demo/captureTimes.ts`) can run alongside Phase 3's tail. +- T046 (`src/demo/insightsMatch.ts`) ∥ T049 (`src/demo/coachSeed.ts`). Both stay free of persistence imports (`src/demo/purityBoundary.test.ts`). +- T069 (`src/http/routes/healthRouter.ts`, plus its mount in `src/http/httpServer.ts`) ∥ T070 (`src/demo/demoLog.ts`). T072 also touches `src/http/httpServer.ts`, so it runs after T067 and T069. +- Tasks that edit `README.md`, `CLAUDE.md`, `package.json`, `src/http/httpServer.ts`, `src/network/wsBroadcaster.ts` or `research.md` are never parallel with each other. + +## Parallel example: User Story 1 + +```text +in parallel: + T031 stub src/demo/playerOnly.ts T034 stub src/demo/liveConnections.ts + T032 red src/demo/playerOnly.test.ts T035 red src/demo/liveConnections.test.ts + T033 green src/demo/playerOnly.ts T036 green src/demo/liveConnections.ts + commit both +then T037 (red, demo WebSocket server) → T038 (green, commit) +``` + +## Implementation strategy + +### MVP + +Phases 1–3 give the tested building blocks; the demo is first runnable at the end of Phase 7 (T077), which is the real MVP: one session, live single-car stream on one port, against the synthetic recording in the suite and the published one locally. + +### Incremental delivery + +1. Foundational: build, paths, configuration, the completed-lap seam. Push, draft pull request. +2. US1 building blocks. Push. +3. US2 dates, US5 seed tooling. Push. Then the maintainer's export (manual, paid) and the committed seed. +4. US3 protections, US4 health, logging and the end-to-end wiring. Push. +5. US6 local checks and the quickstart validation. Push. Converge, ready, merge by the maintainer. + +## Notes + +- Never commit to `main`; the ruleset refuses direct pushes anyway. +- Every scratch file (pull request bodies, temporary databases, downloads) lives in the session's scratch directory, never in the repository. +- The evidence scripts under `specs/003-demo-mode/evidence/` are not part of `npm test` or the type check; they are run by hand and their output is recorded in research.md. + +--- + +## FR coverage + +| FR | Requirement (short) | Tasks | +|---|---|---| +| FR-001 | Demo mode by configuration; local mode unchanged | T009–T013, T075, T080, T081 | +| FR-002 | Single `PORT`; ports not required in demo; fail-fast | T010–T013, T073–T075 | +| FR-003 | Refuse to start with `ANTHROPIC_API_KEY` | T011, T012, T013 | +| FR-004 | Never `data/telemetry.db`; empty database each start | T005, T013, T073, T081 (and the key/`DB_PATH` rule in the header) | +| FR-005 | Build once, then loop at `1x`, broadcast only | T029, T030, T074 | +| FR-006 | No UDP socket in demo mode | T074, T075, T086 | +| FR-007 | Recording from the Release, verified by SHA-256; refuse otherwise | T022–T028, T073 | +| FR-008 | Bounded, stable data across passes | T029, T030, T074, T085 | +| FR-009 | Whole session before the first request; health not ready until then | T029, T030, T074, T084 | +| FR-010 | Capture times for session and laps; refuse if missing | T016–T018, T041–T045, T074, T084 | +| FR-011 | Local dates as today; no game session time | T044, T080 | +| FR-012 | Seeded pairs cached; others 503; no call, no write | T052, T066, T073, T086 | +| FR-013 | Seed from a versioned file tied to the recording; insights within tolerance | T046–T052, T063, T073 | +| FR-014 | Manual, idempotent, local export; never in CI/tests; agent makes no paid call | T053–T057, T060–T064 | +| FR-015 | No visitor writes or paid calls; read-only after seeding; independent of origin | T065, T066, T074, T086 | +| FR-016 | REST and WebSocket on one port; shapes unchanged | T037, T038, T074, T087 | +| FR-017 | Health endpoint | T069, T074, T084 | +| FR-018 | CORS and WebSocket handshake to one origin; browser-only limit, documented | T034–T038, T066, T067, T076 | +| FR-019 | Build and start commands, own build config | T002–T008, T026, T081 | +| FR-020 | Every frontend-visible change listed | T020, T087, T093 | +| FR-021 | README and CLAUDE.md as built; known limitations | T007, T014, T027, T056, T076, T088 | +| FR-022 | No new dependency without a reason | T082 | +| FR-023 | Max 5 connections; 4001 "demo full" | T034–T038, T073, T084 | +| FR-024 | Max 5 minutes; 4002 "time limit" | T037, T038, T073, T084 | +| FR-025 | Single car, `playerCarIndex` 0, per-message deflate, switchable; 20 Hz | T010–T012, T031–T033, T037, T038, T074, T076, T084 | +| FR-026 | Read responses cacheable by revalidation; POST and health not cacheable | T066, T067, T069, T084 | +| FR-027 | Not adopted: no per-IP limit, no 429, no `trust proxy` | T067, T076, T087 | +| FR-028 | Event log, 15-minute summary with socket bytes, 503s and CPU; no IP | T037, T070–T072, T074, T084 | diff --git a/src/coaching/coachService.ts b/src/coaching/coachService.ts index d7cbb85..6e8e014 100644 --- a/src/coaching/coachService.ts +++ b/src/coaching/coachService.ts @@ -5,7 +5,7 @@ import { TrackBenchmark } from "../persistence/lapRepository"; import { COACH_SYSTEM_PROMPT } from "./coachPrompt"; import { buildCoachUserMessage } from "./coachUserMessage"; -const MODEL = "claude-opus-4-8"; +export const MODEL = "claude-opus-4-8"; const MAX_TOKENS = 2048; export class CoachingUnavailableError extends Error {} diff --git a/src/config/dbPath.ts b/src/config/dbPath.ts index db56c7e..de3724f 100644 --- a/src/config/dbPath.ts +++ b/src/config/dbPath.ts @@ -1,7 +1,9 @@ import "dotenv/config"; import path from "node:path"; +import { findProjectRoot } from "./projectRoot"; +import { parseRunMode } from "./mode"; -const FIXED_DB_PATH = path.resolve(__dirname, "..", "..", "data", "telemetry.db"); +const FIXED_DB_PATH = path.join(findProjectRoot(), "data", "telemetry.db"); // DB_PATH é uma saída de emergência só para o teste de integração (ver // specs/001-realtime-replay/research.md, ponto 4) - em produção ela nunca é @@ -9,6 +11,10 @@ const FIXED_DB_PATH = path.resolve(__dirname, "..", "..", "data", "telemetry.db" // definida": passar "" pro better-sqlite3 abre um banco temporário privado // sem aviso nenhum, o que esconderia o erro em vez de sinalizá-lo. function resolveDbPath(): string { + // O modo demo recusa DB_PATH (demoEnv.ts) e reconstrói tudo da gravação a + // cada início: nunca toca o data/telemetry.db nem um arquivo no disco. + if (parseRunMode(process.env.DEMO_MODE) === "demo") return ":memory:"; + const raw = process.env.DB_PATH?.trim(); return raw ? raw : FIXED_DB_PATH; } diff --git a/src/config/dbPathRoot.test.ts b/src/config/dbPathRoot.test.ts new file mode 100644 index 0000000..a1a25ea --- /dev/null +++ b/src/config/dbPathRoot.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +// Só imprime o caminho resolvido: nada abre o banco. cwd fora do repositório +// para o dotenv/config não carregar o .env local. +function printDbPath(extraEnv: Record): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-dbpath-")); + try { + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.DB_PATH; + delete env.DEMO_MODE; + delete env.DOTENV_CONFIG_PATH; + Object.assign(env, extraEnv); + env.TS_NODE_PROJECT = path.join(REPO_ROOT, "tsconfig.json"); + + const modulePath = JSON.stringify(path.join(REPO_ROOT, "src", "config", "dbPath")); + const result = spawnSync( + process.execPath, + ["-r", require.resolve("ts-node/register"), "-e", `process.stdout.write(require(${modulePath}).dbPath)`], + { cwd: tempDir, env, encoding: "utf8", timeout: 30000 }, + ); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + return result.stdout; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test("sem DB_PATH, o caminho do banco é data/telemetry.db na raiz do repositório", () => { + assert.equal(printDbPath({}), path.join(REPO_ROOT, "data", "telemetry.db")); +}); + +test("no modo demo, o banco é sempre em memória", () => { + assert.equal(printDbPath({ DEMO_MODE: "true" }), ":memory:"); +}); diff --git a/src/config/demoEnv.test.ts b/src/config/demoEnv.test.ts new file mode 100644 index 0000000..b22f183 --- /dev/null +++ b/src/config/demoEnv.test.ts @@ -0,0 +1,96 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { parseDemoConfig } from "./demoEnv"; + +const ROOT = path.join(path.sep, "projeto"); + +function validEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { DEMO_MODE: "true", PORT: "10000", DEMO_ALLOWED_ORIGIN: "https://exemplo.github.io", ...overrides }; +} + +test("configuração válida usa os padrões do contrato", () => { + assert.deepEqual(parseDemoConfig(validEnv(), ROOT), { + port: 10000, + allowedOrigin: "https://exemplo.github.io", + maxConnections: 5, + maxConnectionMs: 5 * 60 * 1000, + wsCompression: true, + recordingPath: path.join(ROOT, "recordings", "demo-interlagos-tt-20hz-laps6-8.jsonl"), + seedPath: path.join(ROOT, "demo", "coach-seed.json"), + }); +}); + +test("as configurações opcionais são respeitadas", () => { + const config = parseDemoConfig( + validEnv({ + DEMO_MAX_CONNECTIONS: "2", + DEMO_MAX_CONNECTION_MINUTES: "1", + DEMO_WS_COMPRESSION: "false", + DEMO_RECORDING_PATH: "/tmp/gravacao.jsonl", + DEMO_SEED_PATH: "/tmp/cache.json", + }), + ROOT, + ); + assert.equal(config.maxConnections, 2); + assert.equal(config.maxConnectionMs, 60 * 1000); + assert.equal(config.wsCompression, false); + assert.equal(config.recordingPath, "/tmp/gravacao.jsonl"); + assert.equal(config.seedPath, "/tmp/cache.json"); +}); + +test("recusa ANTHROPIC_API_KEY no modo demo", () => { + assert.throws(() => parseDemoConfig(validEnv({ ANTHROPIC_API_KEY: "qualquer" }), ROOT), { + message: "O modo demo não aceita ANTHROPIC_API_KEY: remova a variável (o modo demo nunca faz chamada paga).", + }); +}); + +test("recusa DB_PATH no modo demo", () => { + assert.throws(() => parseDemoConfig(validEnv({ DB_PATH: "/tmp/x.db" }), ROOT), { + message: "O modo demo não aceita DB_PATH: o banco do modo demo é sempre em memória.", + }); +}); + +test("a recusa da chave vem antes de qualquer outra checagem", () => { + assert.throws(() => parseDemoConfig({ DEMO_MODE: "true", ANTHROPIC_API_KEY: "qualquer" }, ROOT), { + message: "O modo demo não aceita ANTHROPIC_API_KEY: remova a variável (o modo demo nunca faz chamada paga).", + }); +}); + +test("PORT é obrigatória e precisa ser uma porta válida", () => { + assert.throws(() => parseDemoConfig(validEnv({ PORT: undefined }), ROOT), { + message: "Variável de ambiente PORT não definida (obrigatória no modo demo).", + }); + assert.throws(() => parseDemoConfig(validEnv({ PORT: "abc" }), ROOT), { + message: 'Variável de ambiente PORT="abc" não é uma porta válida (1-65535).', + }); +}); + +test("DEMO_ALLOWED_ORIGIN é obrigatória e precisa ser só uma origem", () => { + assert.throws(() => parseDemoConfig(validEnv({ DEMO_ALLOWED_ORIGIN: undefined }), ROOT), { + message: "Variável de ambiente DEMO_ALLOWED_ORIGIN não definida (obrigatória no modo demo).", + }); + for (const value of ["https://x.github.io/app", "https://x.github.io/"]) { + assert.throws(() => parseDemoConfig(validEnv({ DEMO_ALLOWED_ORIGIN: value }), ROOT), { + message: `Variável de ambiente DEMO_ALLOWED_ORIGIN="${value}" não é uma origem válida (esquema://host[:porta], sem caminho nem barra no fim).`, + }); + } +}); + +test("os limites precisam ser inteiros ≥ 1", () => { + assert.throws(() => parseDemoConfig(validEnv({ DEMO_MAX_CONNECTIONS: "0" }), ROOT), { + message: 'Variável de ambiente DEMO_MAX_CONNECTIONS="0" deve ser um inteiro ≥ 1.', + }); + assert.throws(() => parseDemoConfig(validEnv({ DEMO_MAX_CONNECTION_MINUTES: "0" }), ROOT), { + message: 'Variável de ambiente DEMO_MAX_CONNECTION_MINUTES="0" deve ser um inteiro ≥ 1.', + }); +}); + +test("DEMO_WS_COMPRESSION: vazia liga, false desliga, outro valor falha", () => { + assert.equal(parseDemoConfig(validEnv({ DEMO_WS_COMPRESSION: "" }), ROOT).wsCompression, true); + assert.equal(parseDemoConfig(validEnv({ DEMO_WS_COMPRESSION: "true" }), ROOT).wsCompression, true); + assert.equal(parseDemoConfig(validEnv({ DEMO_WS_COMPRESSION: "false" }), ROOT).wsCompression, false); + assert.throws(() => parseDemoConfig(validEnv({ DEMO_WS_COMPRESSION: "no" }), ROOT), { + message: 'Variável de ambiente DEMO_WS_COMPRESSION="no" inválida: use "true" ou "false".', + }); +}); diff --git a/src/config/demoEnv.ts b/src/config/demoEnv.ts new file mode 100644 index 0000000..4e0f14e --- /dev/null +++ b/src/config/demoEnv.ts @@ -0,0 +1,79 @@ +import path from "node:path"; + +export interface DemoConfig { + port: number; + allowedOrigin: string; + maxConnections: number; + maxConnectionMs: number; + wsCompression: boolean; + recordingPath: string; + seedPath: string; +} + +const DEFAULT_MAX_CONNECTIONS = 5; +const DEFAULT_MAX_CONNECTION_MINUTES = 5; + +function readPort(env: NodeJS.ProcessEnv): number { + const raw = env.PORT; + if (!raw) throw new Error("Variável de ambiente PORT não definida (obrigatória no modo demo)."); + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Variável de ambiente PORT="${raw}" não é uma porta válida (1-65535).`); + } + return port; +} + +function readOrigin(env: NodeJS.ProcessEnv): string { + const raw = env.DEMO_ALLOWED_ORIGIN; + if (!raw) throw new Error("Variável de ambiente DEMO_ALLOWED_ORIGIN não definida (obrigatória no modo demo)."); + let origin: string | undefined; + try { + origin = new URL(raw).origin; + } catch { + origin = undefined; + } + if (origin !== raw) { + throw new Error( + `Variável de ambiente DEMO_ALLOWED_ORIGIN="${raw}" não é uma origem válida (esquema://host[:porta], sem caminho nem barra no fim).` + ); + } + return raw; +} + +function readPositiveInteger(env: NodeJS.ProcessEnv, name: string, fallback: number): number { + const raw = env[name]; + if (raw === undefined || raw === "") return fallback; + if (!/^\d+$/.test(raw) || Number(raw) < 1) { + throw new Error(`Variável de ambiente ${name}="${raw}" deve ser um inteiro ≥ 1.`); + } + return Number(raw); +} + +function readCompression(env: NodeJS.ProcessEnv): boolean { + const raw = env.DEMO_WS_COMPRESSION; + if (raw === undefined || raw === "" || raw === "true") return true; + if (raw === "false") return false; + throw new Error(`Variável de ambiente DEMO_WS_COMPRESSION="${raw}" inválida: use "true" ou "false".`); +} + +// A chave e o DB_PATH são recusados antes de tudo: mesmo com o resto da +// configuração errado, a mensagem que aparece é a que evita custo ou o +// banco errado. +export function parseDemoConfig(env: NodeJS.ProcessEnv, projectRoot: string): DemoConfig { + if (env.ANTHROPIC_API_KEY) { + throw new Error("O modo demo não aceita ANTHROPIC_API_KEY: remova a variável (o modo demo nunca faz chamada paga)."); + } + if (env.DB_PATH?.trim()) { + throw new Error("O modo demo não aceita DB_PATH: o banco do modo demo é sempre em memória."); + } + + return { + port: readPort(env), + allowedOrigin: readOrigin(env), + maxConnections: readPositiveInteger(env, "DEMO_MAX_CONNECTIONS", DEFAULT_MAX_CONNECTIONS), + maxConnectionMs: readPositiveInteger(env, "DEMO_MAX_CONNECTION_MINUTES", DEFAULT_MAX_CONNECTION_MINUTES) * 60 * 1000, + wsCompression: readCompression(env), + recordingPath: env.DEMO_RECORDING_PATH || path.join(projectRoot, "recordings", "demo-interlagos-tt-20hz-laps6-8.jsonl"), + seedPath: env.DEMO_SEED_PATH || path.join(projectRoot, "demo", "coach-seed.json"), + }; +} diff --git a/src/config/demoFailFast.test.ts b/src/config/demoFailFast.test.ts new file mode 100644 index 0000000..ed4046d --- /dev/null +++ b/src/config/demoFailFast.test.ts @@ -0,0 +1,68 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +// Mesmo método de serverFailFast.test.ts: cwd fora do repositório e ambiente +// limpo, para o .env local não entrar no processo filho. +function runDemoServer(vars: Record) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-demofailfast-")); + try { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const name of ["WS_PORT", "UDP_PORT", "HTTP_PORT", "DB_PATH", "ANTHROPIC_API_KEY", "DOTENV_CONFIG_PATH", "PORT", "DEMO_ALLOWED_ORIGIN"]) { + delete env[name]; + } + Object.assign(env, vars); + env.TS_NODE_PROJECT = path.join(REPO_ROOT, "tsconfig.json"); + + const result = spawnSync( + process.execPath, + ["-r", require.resolve("ts-node/register"), path.join(REPO_ROOT, "src", "server.ts")], + { cwd: tempDir, env, encoding: "utf8", timeout: 30000 }, + ); + const dbFiles = fs.readdirSync(tempDir).filter((name) => name.endsWith(".db")); + return { result, dbFiles }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +const VALID = { DEMO_MODE: "true", PORT: "10000", DEMO_ALLOWED_ORIGIN: "http://localhost:5173" }; + +function assertRefused(vars: Record, message: string): void { + const { result, dbFiles } = runDemoServer(vars); + assert.notEqual(result.status, null, "o servidor subiu em vez de falhar (timeout)"); + assert.notEqual(result.status, 0); + assert.ok(result.stderr.includes(message), `stderr: ${result.stderr}`); + assert.deepEqual(dbFiles, [], "um arquivo de banco foi criado"); +} + +test("modo demo com ANTHROPIC_API_KEY para na inicialização", () => { + assertRefused( + { ...VALID, ANTHROPIC_API_KEY: "chave-de-teste" }, + "O modo demo não aceita ANTHROPIC_API_KEY: remova a variável (o modo demo nunca faz chamada paga).", + ); +}); + +test("modo demo com DB_PATH para na inicialização sem criar o banco", () => { + assertRefused( + { ...VALID, DB_PATH: "demo.db" }, + "O modo demo não aceita DB_PATH: o banco do modo demo é sempre em memória.", + ); +}); + +test("modo demo sem PORT para na inicialização", () => { + const { PORT: _omitted, ...withoutPort } = VALID; + assertRefused(withoutPort, "Variável de ambiente PORT não definida (obrigatória no modo demo)."); +}); + +test("DEMO_MODE com valor inválido para na inicialização", () => { + assertRefused( + { DEMO_MODE: "yes" }, + 'Variável de ambiente DEMO_MODE="yes" inválida: use "true" para o modo demo, ou deixe vazia para o modo local.', + ); +}); diff --git a/src/config/env.ts b/src/config/env.ts index 98241cb..91b397b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,5 +1,6 @@ import "dotenv/config"; import { dbPath } from "./dbPath"; +import { parseRunMode } from "./mode"; function readPort(name: string): number { const raw = process.env[name]; @@ -15,10 +16,34 @@ function readPort(name: string): number { return port; } +interface LocalPorts { + wsPort: number; + udpPort: number; + httpPort: number; +} + +// No modo demo as três portas não existem (a hospedagem dá uma só, PORT, lida +// em demoEnv.ts): não são lidas, e acessá-las é erro de programação. +const localPorts: LocalPorts | undefined = + parseRunMode(process.env.DEMO_MODE) === "local" + ? { wsPort: readPort("WS_PORT"), udpPort: readPort("UDP_PORT"), httpPort: readPort("HTTP_PORT") } + : undefined; + +function requireLocalPort(name: keyof LocalPorts): number { + if (!localPorts) throw new Error(`[env] ${name} não existe no modo demo.`); + return localPorts[name]; +} + export const env = { - wsPort: readPort("WS_PORT"), - udpPort: readPort("UDP_PORT"), - httpPort: readPort("HTTP_PORT"), + get wsPort(): number { + return requireLocalPort("wsPort"); + }, + get udpPort(): number { + return requireLocalPort("udpPort"); + }, + get httpPort(): number { + return requireLocalPort("httpPort"); + }, dbPath, anthropicApiKey: process.env.ANTHROPIC_API_KEY || undefined, diff --git a/src/config/mode.test.ts b/src/config/mode.test.ts new file mode 100644 index 0000000..7fa86af --- /dev/null +++ b/src/config/mode.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseRunMode } from "./mode"; + +test("DEMO_MODE ausente ou vazia é o modo local", () => { + assert.equal(parseRunMode(undefined), "local"); + assert.equal(parseRunMode(""), "local"); +}); + +test("DEMO_MODE=true é o modo demo", () => { + assert.equal(parseRunMode("true"), "demo"); +}); + +test("qualquer outro valor de DEMO_MODE falha, em vez de cair no modo local", () => { + assert.throws(() => parseRunMode("yes"), { + message: 'Variável de ambiente DEMO_MODE="yes" inválida: use "true" para o modo demo, ou deixe vazia para o modo local.', + }); +}); diff --git a/src/config/mode.ts b/src/config/mode.ts new file mode 100644 index 0000000..f5e63c9 --- /dev/null +++ b/src/config/mode.ts @@ -0,0 +1,9 @@ +export type RunMode = "local" | "demo"; + +// Um valor digitado errado (ex.: "yes") não pode cair em silêncio no modo +// local: quem pediu o modo demo veria o servidor subir nas portas do .env. +export function parseRunMode(raw: string | undefined): RunMode { + if (raw === undefined || raw === "") return "local"; + if (raw === "true") return "demo"; + throw new Error(`Variável de ambiente DEMO_MODE="${raw}" inválida: use "true" para o modo demo, ou deixe vazia para o modo local.`); +} diff --git a/src/config/projectRoot.test.ts b/src/config/projectRoot.test.ts new file mode 100644 index 0000000..6ed8e03 --- /dev/null +++ b/src/config/projectRoot.test.ts @@ -0,0 +1,55 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { findProjectRoot } from "./projectRoot"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-root-")); + tempDirs.push(dir); + return dir; +} + +after(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("a partir do próprio módulo, encontra a raiz deste repositório", () => { + const root = findProjectRoot(__dirname); + const pkgPath = path.join(root, "package.json"); + assert.ok(fs.existsSync(pkgPath), `sem package.json em ${root}`); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { name: string }; + assert.equal(pkg.name, "telemetryf1-server"); +}); + +test("a partir de uma pasta aninhada, sobe até o package.json mais próximo", () => { + const base = makeTempDir(); + fs.writeFileSync(path.join(base, "package.json"), "{}"); + const nested = path.join(base, "a", "b"); + fs.mkdirSync(nested, { recursive: true }); + + assert.equal(findProjectRoot(nested), base); +}); + +test("sem package.json até a raiz do disco, falha com mensagem clara", () => { + const base = makeTempDir(); + const nested = path.join(base, "x"); + fs.mkdirSync(nested); + + // os.tmpdir() pode estar dentro de uma pasta com package.json numa máquina + // qualquer; o teste só vale se nenhum ancestral tiver um. + let dir = nested; + while (true) { + assert.ok(!fs.existsSync(path.join(dir, "package.json")), `ancestral com package.json: ${dir}`); + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + assert.throws(() => findProjectRoot(nested), { + message: `[projectRoot] package.json não encontrado acima de ${nested}`, + }); +}); diff --git a/src/config/projectRoot.ts b/src/config/projectRoot.ts new file mode 100644 index 0000000..444e5db --- /dev/null +++ b/src/config/projectRoot.ts @@ -0,0 +1,17 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Compilado, este arquivo fica em dist/src/config/ - um caminho fixo como +// __dirname/../.. apontaria para dist/, não para a raiz do repositório. +// Subir até o package.json dá a mesma raiz a partir do fonte e do build. +export function findProjectRoot(startDir: string = __dirname): string { + let dir = startDir; + while (true) { + if (fs.existsSync(path.join(dir, "package.json"))) return dir; + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`[projectRoot] package.json não encontrado acima de ${startDir}`); + } + dir = parent; + } +} diff --git a/src/demo/applyCaptureTimes.test.ts b/src/demo/applyCaptureTimes.test.ts new file mode 100644 index 0000000..a76991e --- /dev/null +++ b/src/demo/applyCaptureTimes.test.ts @@ -0,0 +1,49 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "../persistence/testDb"; +import { buildSyntheticRecording, buildLoopableSyntheticRecording } from "../replay/testFixtures"; +import { buildCaptureTimes } from "./captureTimes"; +import type { DemoPacket } from "./recording"; + +const handle = setupIsolatedTestDb(); +/* eslint-disable @typescript-eslint/no-var-requires */ +const { buildDemoSession } = require("./buildDemoSession") as typeof import("./buildDemoSession"); +const { applyCaptureTimes } = require("./applyCaptureTimes") as typeof import("./applyCaptureTimes"); +const { getSessionById } = require("../persistence/sessionRepository") as typeof import("../persistence/sessionRepository"); +const { listLapsForSession } = require("../persistence/lapRepository") as typeof import("../persistence/lapRepository"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +after(() => teardownIsolatedTestDb(handle)); + +const BASE = 1792168223797; +const SQLITE_NOW_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +test("as horas de captura substituem as do relógio na sessão e nas voltas", () => { + const { packets } = buildSyntheticRecording(); + const demoPackets: DemoPacket[] = packets.map((p, i) => ({ ...p, receivedAt: BASE + i * 50 })); + const build = buildDemoSession(demoPackets); + const times = buildCaptureTimes(build.firstReceivedAt, build.lastReceivedAt, build.completions); + + applyCaptureTimes(build.sessionId, times); + + const session = getSessionById(build.sessionId); + assert.ok(session); + assert.equal(session.startedAt, times.startedAt); + assert.equal(session.lastSeenAt, times.lastSeenAt); + const laps = listLapsForSession(build.sessionId); + assert.equal(laps.length, 1); + assert.equal(laps[0].recordedAt, times.lapRecordedAt.get(laps[0].lapNumber)); +}); + +test("uma sessão gravada sem applyCaptureTimes continua com as horas do relógio do SQLite", () => { + const { packets } = buildLoopableSyntheticRecording(); + const demoPackets: DemoPacket[] = packets.map((p) => ({ ...p, receivedAt: BASE })); + const before = Date.now(); + const build = buildDemoSession(demoPackets); + + const session = getSessionById(build.sessionId); + assert.ok(session); + assert.match(session.startedAt, SQLITE_NOW_FORMAT); + assert.ok(Date.parse(session.startedAt) >= before - 1000, `startedAt ${session.startedAt} não é a hora atual`); + assert.notEqual(session.startedAt, new Date(BASE).toISOString()); +}); diff --git a/src/demo/applyCaptureTimes.ts b/src/demo/applyCaptureTimes.ts new file mode 100644 index 0000000..c811de4 --- /dev/null +++ b/src/demo/applyCaptureTimes.ts @@ -0,0 +1,12 @@ +import { applySessionCaptureTimes } from "../persistence/sessionRepository"; +import { applyLapCaptureTimes } from "../persistence/lapRepository"; +import type { CaptureTimes } from "./captureTimes"; + +// Separado de captureTimes.ts de propósito: os repositórios abrem o banco no +// import (persistence/db), e captureTimes.ts precisa continuar puro. +export function applyCaptureTimes(sessionId: number, times: CaptureTimes): void { + applySessionCaptureTimes(sessionId, times.startedAt, times.lastSeenAt); + for (const [lapNumber, recordedAt] of times.lapRecordedAt) { + applyLapCaptureTimes(sessionId, lapNumber, recordedAt); + } +} diff --git a/src/demo/applyCoachSeed.test.ts b/src/demo/applyCoachSeed.test.ts new file mode 100644 index 0000000..de25e7f --- /dev/null +++ b/src/demo/applyCoachSeed.test.ts @@ -0,0 +1,40 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "../persistence/testDb"; +import { buildSyntheticRecording } from "../replay/testFixtures"; +import type { CoachSeedFile } from "./coachSeed"; +import type { DemoPacket } from "./recording"; + +const handle = setupIsolatedTestDb(); +/* eslint-disable @typescript-eslint/no-var-requires */ +const { buildDemoSession } = require("./buildDemoSession") as typeof import("./buildDemoSession"); +const { applyCoachSeed } = require("./applyCoachSeed") as typeof import("./applyCoachSeed"); +const { getCachedAnalysis } = require("../persistence/analysisRepository") as typeof import("../persistence/analysisRepository"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +after(() => teardownIsolatedTestDb(handle)); + +test("cada entrada do cache vira uma análise em cache para o seu par", () => { + const { packets } = buildSyntheticRecording(); + const build = buildDemoSession(packets.map((p): DemoPacket => ({ ...p, receivedAt: 1792168223797 }))); + const insights = { totalDeltaS: 0.12, corners: [], tyreWindowExcursions: [] }; + const seed: CoachSeedFile = { + formatVersion: 1, + recordingSha256: "a".repeat(64), + sessionUid: build.sessionUid, + entries: [ + { lapA: 1, lapB: 2, model: "modelo-falso", analysisText: "análise 1-2", insights }, + { lapA: 2, lapB: 1, model: "modelo-falso", analysisText: "análise 2-1", insights }, + ], + }; + + applyCoachSeed(build.sessionId, seed); + + for (const entry of seed.entries) { + const cached = getCachedAnalysis(build.sessionId, entry.lapA, entry.lapB); + assert.ok(cached, `par (${entry.lapA}, ${entry.lapB}) não está em cache`); + assert.equal(cached.analysisText, entry.analysisText); + assert.equal(cached.model, entry.model); + assert.deepEqual(JSON.parse(cached.insightsJson), entry.insights); + } +}); diff --git a/src/demo/applyCoachSeed.ts b/src/demo/applyCoachSeed.ts new file mode 100644 index 0000000..354dd69 --- /dev/null +++ b/src/demo/applyCoachSeed.ts @@ -0,0 +1,10 @@ +import { insertAnalysis } from "../persistence/analysisRepository"; +import type { CoachSeedFile } from "./coachSeed"; + +// Separado de coachSeed.ts de propósito: o repositório abre o banco no +// import (persistence/db), e coachSeed.ts precisa continuar puro. +export function applyCoachSeed(sessionId: number, seed: CoachSeedFile): void { + for (const entry of seed.entries) { + insertAnalysis(sessionId, entry.lapA, entry.lapB, JSON.stringify(entry.insights), entry.analysisText, entry.model); + } +} diff --git a/src/demo/buildDemoSession.test.ts b/src/demo/buildDemoSession.test.ts new file mode 100644 index 0000000..a77d42b --- /dev/null +++ b/src/demo/buildDemoSession.test.ts @@ -0,0 +1,39 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "../persistence/testDb"; +import { buildSyntheticRecording } from "../replay/testFixtures"; +import type { DemoPacket } from "./recording"; + +// buildDemoSession abre o banco por persistence/db: só pode ser carregado +// depois de setupIsolatedTestDb() definir DB_PATH (ver testDb.ts). +const handle = setupIsolatedTestDb(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { db } = require("../persistence/db") as { db: import("better-sqlite3").Database }; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { buildDemoSession } = require("./buildDemoSession") as typeof import("./buildDemoSession"); + +after(() => teardownIsolatedTestDb(handle)); + +const BASE_RECEIVED_AT = 1792168223797; + +test("monta a sessão da gravação no próprio processo e devolve as horas de captura", () => { + const { packets, expectedLap, expectedSampleCount, sessionUID } = buildSyntheticRecording(); + const demoPackets: DemoPacket[] = packets.map((p, i) => ({ ...p, receivedAt: BASE_RECEIVED_AT + i * 50 })); + // um pacote truncado no fim: dispatchPacket lança, e a montagem segue + demoPackets.push({ timestampMs: 999, data: Buffer.alloc(10), receivedAt: BASE_RECEIVED_AT + 999_999 }); + + const closingIndex = packets.length - 1; + const build = buildDemoSession(demoPackets); + + assert.equal(build.sessionUid, sessionUID.toString()); + assert.ok(build.sessionId > 0, `sessionId ${build.sessionId}`); + assert.equal(build.firstReceivedAt, BASE_RECEIVED_AT); + assert.equal(build.lastReceivedAt, BASE_RECEIVED_AT + closingIndex * 50, "o pacote truncado não pode contar como o último"); + assert.deepEqual(build.completions, [{ lapNumber: expectedLap.lapNumber, receivedAt: BASE_RECEIVED_AT + closingIndex * 50 }]); + + const count = (sql: string) => (db.prepare(sql).get({ id: build.sessionId }) as { n: number }).n; + assert.equal(count("SELECT COUNT(*) AS n FROM sessions WHERE id = @id"), 1); + assert.equal(count("SELECT COUNT(*) AS n FROM laps WHERE session_id = @id"), 1); + assert.equal(count("SELECT COUNT(*) AS n FROM car_telemetry_samples WHERE session_id = @id"), expectedSampleCount); + assert.equal(count("SELECT COUNT(*) AS n FROM motion_samples WHERE session_id = @id"), expectedSampleCount); +}); diff --git a/src/demo/buildDemoSession.ts b/src/demo/buildDemoSession.ts new file mode 100644 index 0000000..24ca3c3 --- /dev/null +++ b/src/demo/buildDemoSession.ts @@ -0,0 +1,49 @@ +import { dispatchPacket } from "../telemetry/dispatcher"; +import { persistTelemetry } from "../persistence/persistTelemetry"; +import { listSessions } from "../persistence/sessionRepository"; +import { DemoPacket } from "./recording"; + +export interface DemoSessionBuild { + sessionId: number; + sessionUid: string; + firstReceivedAt: number | undefined; + lastReceivedAt: number | undefined; + completions: Array<{ lapNumber: number; receivedAt: number | undefined }>; +} + +/** + * Passa a gravação inteira pelo dispatcher e pela persistência de verdade, o + * mais rápido possível, antes de o servidor aceitar visitas: a decimação usa + * o sessionTime do jogo e a distância, não a hora de chegada, então o banco + * fica igual ao de uma passada a 1x (research.md, ponto 3), sem a perda de + * buffer do UDP. + */ +export function buildDemoSession(packets: DemoPacket[]): DemoSessionBuild { + let sessionUid: string | undefined; + let firstReceivedAt: number | undefined; + let lastReceivedAt: number | undefined; + const completions: DemoSessionBuild["completions"] = []; + + for (const packet of packets) { + try { + const result = dispatchPacket(packet.data); + if (!result) continue; + if (result.header.sessionUID !== 0n) { + sessionUid ??= result.header.sessionUID.toString(); + if (firstReceivedAt === undefined) firstReceivedAt = packet.receivedAt; + lastReceivedAt = packet.receivedAt; + } + const completed = persistTelemetry(result.header, result.packetId, result.data); + if (completed) completions.push({ lapNumber: completed.lapNumber, receivedAt: packet.receivedAt }); + } catch (err) { + console.error("[demo] pacote descartado na montagem:", err instanceof Error ? err.message : err); + } + } + + const session = listSessions().find((s) => s.sessionUid === sessionUid); + if (!sessionUid || !session) { + throw new Error("[demo] a gravação não tem nenhum pacote de sessão: não há sessão para montar."); + } + + return { sessionId: session.id, sessionUid, firstReceivedAt, lastReceivedAt, completions }; +} diff --git a/src/demo/captureTimes.test.ts b/src/demo/captureTimes.test.ts new file mode 100644 index 0000000..c891965 --- /dev/null +++ b/src/demo/captureTimes.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildCaptureTimes } from "./captureTimes"; + +// Horas de captura da gravação publicada (research.md, ponto 4). +const FIRST = 1792168223797; +const LAST = 1792168446755; +const COMPLETIONS = [ + { lapNumber: 6, receivedAt: 1792168298248 }, + { lapNumber: 7, receivedAt: 1792168372362 }, + { lapNumber: 8, receivedAt: 1792168445758 }, +]; + +test("as datas da sessão e das voltas vêm das horas de captura, em ISO", () => { + const times = buildCaptureTimes(FIRST, LAST, COMPLETIONS); + + assert.equal(times.startedAt, "2026-10-16T16:30:23.797Z"); + assert.equal(times.lastSeenAt, "2026-10-16T16:34:06.755Z"); + assert.deepEqual( + [...times.lapRecordedAt.entries()], + [ + [6, "2026-10-16T16:31:38.248Z"], + [7, "2026-10-16T16:32:52.362Z"], + [8, "2026-10-16T16:34:05.758Z"], + ], + ); +}); + +test("sem receivedAt no primeiro pacote, recusa em vez de inventar", () => { + assert.throws(() => buildCaptureTimes(undefined, LAST, COMPLETIONS), { + message: "[demo] a gravação não tem receivedAt em primeiro pacote da sessão: o modo demo não inventa datas.", + }); +}); + +test("sem receivedAt no último pacote, recusa em vez de inventar", () => { + assert.throws(() => buildCaptureTimes(FIRST, undefined, COMPLETIONS), { + message: "[demo] a gravação não tem receivedAt em último pacote da sessão: o modo demo não inventa datas.", + }); +}); + +test("sem receivedAt no pacote que fecha uma volta, recusa em vez de inventar", () => { + assert.throws(() => buildCaptureTimes(FIRST, LAST, [{ lapNumber: 7, receivedAt: undefined }]), { + message: "[demo] a gravação não tem receivedAt em pacote que fecha a volta 7: o modo demo não inventa datas.", + }); +}); + +test("último antes do primeiro é recusado", () => { + assert.throws(() => buildCaptureTimes(LAST, FIRST, []), { + message: `[demo] horas de captura fora de ordem: o último pacote (${FIRST}) vem antes do primeiro (${LAST}).`, + }); +}); diff --git a/src/demo/captureTimes.ts b/src/demo/captureTimes.ts new file mode 100644 index 0000000..e8fab0a --- /dev/null +++ b/src/demo/captureTimes.ts @@ -0,0 +1,34 @@ +export interface CaptureTimes { + startedAt: string; + lastSeenAt: string; + lapRecordedAt: Map; +} + +function requireReceivedAt(value: number | undefined, where: string): number { + if (value === undefined) { + throw new Error(`[demo] a gravação não tem receivedAt em ${where}: o modo demo não inventa datas.`); + } + return value; +} + +// Mesmo formato que o strftime('%Y-%m-%dT%H:%M:%fZ') do SQLite grava hoje +// (research.md, ponto 4): as datas da demo têm a mesma forma das locais. +export function buildCaptureTimes( + firstReceivedAt: number | undefined, + lastReceivedAt: number | undefined, + completions: Array<{ lapNumber: number; receivedAt: number | undefined }> +): CaptureTimes { + const first = requireReceivedAt(firstReceivedAt, "primeiro pacote da sessão"); + const last = requireReceivedAt(lastReceivedAt, "último pacote da sessão"); + if (last < first) { + throw new Error(`[demo] horas de captura fora de ordem: o último pacote (${last}) vem antes do primeiro (${first}).`); + } + + const lapRecordedAt = new Map(); + for (const { lapNumber, receivedAt } of completions) { + const at = requireReceivedAt(receivedAt, `pacote que fecha a volta ${lapNumber}`); + lapRecordedAt.set(lapNumber, new Date(at).toISOString()); + } + + return { startedAt: new Date(first).toISOString(), lastSeenAt: new Date(last).toISOString(), lapRecordedAt }; +} diff --git a/src/demo/coachSeed.test.ts b/src/demo/coachSeed.test.ts new file mode 100644 index 0000000..d8aab00 --- /dev/null +++ b/src/demo/coachSeed.test.ts @@ -0,0 +1,108 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { LapInsights } from "../analysis/types"; +import { validateCoachSeed, type CoachSeedContext } from "./coachSeed"; + +const HASH = "a".repeat(64); +const OTHER_HASH = "b".repeat(64); +const PREFIX = "[demo] cache do coach inválido:"; + +function insightsFor(lapA: number, lapB: number): LapInsights { + return { totalDeltaS: (lapA - lapB) / 10, corners: [], tyreWindowExcursions: [] }; +} + +function context(overrides: Partial = {}): CoachSeedContext { + return { recordingSha256: HASH, sessionUid: "42", completedLaps: [1, 2], computeInsights: insightsFor, ...overrides }; +} + +function seed(): Record { + return { + formatVersion: 1, + recordingSha256: HASH, + sessionUid: "42", + entries: [ + { lapA: 1, lapB: 2, model: "modelo", analysisText: "texto 1-2", insights: insightsFor(1, 2) }, + { lapA: 2, lapB: 1, model: "modelo", analysisText: "texto 2-1", insights: insightsFor(2, 1) }, + ], + }; +} + +function entries(raw: Record): Array> { + return raw.entries as Array>; +} + +test("um cache válido é devolvido", () => { + const raw = seed(); + assert.deepEqual(validateCoachSeed(raw, context()), raw); +}); + +test("recusa o que não é um objeto", () => { + assert.throws(() => validateCoachSeed([], context()), { message: `${PREFIX} o arquivo não é um objeto JSON.` }); +}); + +test("recusa outro formatVersion", () => { + assert.throws(() => validateCoachSeed({ ...seed(), formatVersion: 2 }, context()), { + message: `${PREFIX} formatVersion 2, esperado 1.`, + }); +}); + +test("recusa o cache de outra gravação, citando os dois hashes", () => { + assert.throws(() => validateCoachSeed({ ...seed(), recordingSha256: OTHER_HASH }, context()), { + message: `${PREFIX} feito para outra gravação (recordingSha256 ${OTHER_HASH}, gravação em uso ${HASH}).`, + }); +}); + +test("recusa outro sessionUid", () => { + assert.throws(() => validateCoachSeed({ ...seed(), sessionUid: "43" }, context()), { + message: `${PREFIX} sessionUid 43, sessão montada 42.`, + }); +}); + +test("recusa uma volta que a sessão montada não tem", () => { + const raw = seed(); + entries(raw)[1].lapA = 7; + assert.throws(() => validateCoachSeed(raw, context()), { + message: `${PREFIX} entries[1] cita a volta 7, que a sessão montada não tem (voltas: 1, 2).`, + }); +}); + +test("recusa um par da volta com ela mesma", () => { + const raw = seed(); + entries(raw)[0].lapB = 1; + assert.throws(() => validateCoachSeed(raw, context()), { message: `${PREFIX} entries[0] compara a volta 1 com ela mesma.` }); +}); + +test("recusa um par repetido", () => { + const raw = seed(); + entries(raw).push({ ...entries(raw)[0] }); + assert.throws(() => validateCoachSeed(raw, context()), { message: `${PREFIX} par (1, 2) repetido.` }); +}); + +test("recusa uma entrada sem analysisText", () => { + const raw = seed(); + delete entries(raw)[0].analysisText; + assert.throws(() => validateCoachSeed(raw, context()), { message: `${PREFIX} entries[0] sem analysisText.` }); +}); + +test("recusa chaves fora do contrato, no topo e numa entrada", () => { + assert.throws(() => validateCoachSeed({ ...seed(), usage: { input_tokens: 1 } }, context()), { + message: `${PREFIX} chave fora do contrato: usage.`, + }); + const raw = seed(); + entries(raw)[1].createdAt = "2026-10-21T00:00:00Z"; + assert.throws(() => validateCoachSeed(raw, context()), { message: `${PREFIX} chave fora do contrato em entries[1]: createdAt.` }); +}); + +test("recusa insights além da tolerância, citando o par, o caminho e os dois valores", () => { + const raw = seed(); + (entries(raw)[0].insights as LapInsights).totalDeltaS = -0.15; + assert.throws(() => validateCoachSeed(raw, context()), { + message: `${PREFIX} insights do par (1, 2) diferem da análise atual em totalDeltaS: -0.15 no arquivo, -0.1 agora.`, + }); +}); + +test("aceita insights dentro da tolerância", () => { + const raw = seed(); + (entries(raw)[0].insights as LapInsights).totalDeltaS = -0.11; + assert.doesNotThrow(() => validateCoachSeed(raw, context())); +}); diff --git a/src/demo/coachSeed.ts b/src/demo/coachSeed.ts new file mode 100644 index 0000000..7f4cb42 --- /dev/null +++ b/src/demo/coachSeed.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { LapInsights } from "../analysis/types"; +import { insightsMatch } from "./insightsMatch"; + +// A exportação é a única etapa paga e só roda na máquina da mantenedora: +// DB_PATH tem que ser :memory: ou um temporário, nunca o banco real. O +// caminho é resolvido a partir da raiz e, no Windows, comparado sem caixa e +// com os dois separadores, para nenhuma grafia do mesmo arquivo escapar. +// platform é parâmetro para os testes cobrirem o Windows também na CI. +export function assertExportEnvironment( + env: NodeJS.ProcessEnv, + projectRoot: string, + platform: NodeJS.Platform = process.platform +): void { + const raw = env.DB_PATH?.trim(); + if (!raw) { + throw new Error("[exportCoachSeed] defina DB_PATH=:memory: ou um caminho temporário; o data/telemetry.db nunca é usado."); + } + if (raw !== ":memory:") { + const paths = platform === "win32" ? path.win32 : path.posix; + const normalize = (p: string) => (platform === "win32" ? p.toLowerCase() : p); + const resolved = paths.resolve(projectRoot, raw); + const realDb = paths.resolve(projectRoot, "data", "telemetry.db"); + if (normalize(resolved) === normalize(realDb)) { + throw new Error("[exportCoachSeed] DB_PATH aponta para o data/telemetry.db do repositório: use :memory: ou um caminho temporário."); + } + } + if (!env.ANTHROPIC_API_KEY) { + throw new Error("[exportCoachSeed] defina ANTHROPIC_API_KEY no seu shell: a exportação é a única etapa paga e só roda com a sua chave."); + } +} + +const PREFIX = "[demo] cache do coach inválido:"; +const FILE_KEYS = ["formatVersion", "recordingSha256", "sessionUid", "entries"]; +const ENTRY_KEYS = ["lapA", "lapB", "model", "analysisText", "insights"]; + +function invalid(detail: string): Error { + return new Error(`${PREFIX} ${detail}`); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export interface CoachSeedEntry { + lapA: number; + lapB: number; + model: string; + analysisText: string; + insights: LapInsights; +} + +export interface CoachSeedFile { + formatVersion: 1; + recordingSha256: string; + sessionUid: string; + entries: CoachSeedEntry[]; +} + +export interface CoachSeedContext { + recordingSha256: string; + sessionUid: string; + completedLaps: number[]; + computeInsights: (lapA: number, lapB: number) => LapInsights; +} + +// O arquivo é público depois de commitado: qualquer chave fora do contrato +// (usage, createdAt, ids de requisição) é recusada, para que nada da conta, +// da requisição ou do uso entre nele (contracts/coach-seed-file.md). +export function validateCoachSeed(raw: unknown, context: CoachSeedContext): CoachSeedFile { + if (!isObject(raw)) throw invalid("o arquivo não é um objeto JSON."); + for (const key of Object.keys(raw)) { + if (!FILE_KEYS.includes(key)) throw invalid(`chave fora do contrato: ${key}.`); + } + if (raw.formatVersion !== 1) throw invalid(`formatVersion ${String(raw.formatVersion)}, esperado 1.`); + if (raw.recordingSha256 !== context.recordingSha256) { + throw invalid( + `feito para outra gravação (recordingSha256 ${String(raw.recordingSha256)}, gravação em uso ${context.recordingSha256}).` + ); + } + if (raw.sessionUid !== context.sessionUid) { + throw invalid(`sessionUid ${String(raw.sessionUid)}, sessão montada ${context.sessionUid}.`); + } + if (!Array.isArray(raw.entries)) throw invalid("entries não é uma lista."); + + const seen = new Set(); + raw.entries.forEach((entry: unknown, i: number) => { + if (!isObject(entry)) throw invalid(`entries[${i}] não é um objeto.`); + for (const key of Object.keys(entry)) { + if (!ENTRY_KEYS.includes(key)) throw invalid(`chave fora do contrato em entries[${i}]: ${key}.`); + } + if (typeof entry.analysisText !== "string" || !entry.analysisText) throw invalid(`entries[${i}] sem analysisText.`); + if (typeof entry.model !== "string" || !entry.model) throw invalid(`entries[${i}] sem model.`); + const { lapA, lapB } = entry; + if (!Number.isInteger(lapA) || !Number.isInteger(lapB)) throw invalid(`entries[${i}] sem lapA e lapB inteiros.`); + const a = lapA as number; + const b = lapB as number; + if (a === b) throw invalid(`entries[${i}] compara a volta ${a} com ela mesma.`); + for (const lap of [a, b]) { + if (!context.completedLaps.includes(lap)) { + throw invalid(`entries[${i}] cita a volta ${lap}, que a sessão montada não tem (voltas: ${context.completedLaps.join(", ")}).`); + } + } + const pair = `${a},${b}`; + if (seen.has(pair)) throw invalid(`par (${a}, ${b}) repetido.`); + seen.add(pair); + + const result = insightsMatch(entry.insights, context.computeInsights(a, b)); + if (!result.match) { + throw invalid( + `insights do par (${a}, ${b}) diferem da análise atual em ${result.path}: ` + + `${String(result.stored)} no arquivo, ${String(result.computed)} agora.` + ); + } + }); + + return raw as unknown as CoachSeedFile; +} + +// Grava num temporário na mesma pasta e renomeia por cima: um processo +// interrompido no meio da escrita nunca deixa um JSON pela metade no lugar +// do cache (a exportação chama isto a cada par pago). +export function writeCoachSeedFile(filePath: string, seed: CoachSeedFile): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(seed, null, 2)}\n`); + fs.renameSync(tempPath, filePath); +} diff --git a/src/demo/coachSeedFile.test.ts b/src/demo/coachSeedFile.test.ts new file mode 100644 index 0000000..598d745 --- /dev/null +++ b/src/demo/coachSeedFile.test.ts @@ -0,0 +1,55 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { writeCoachSeedFile, type CoachSeedFile } from "./coachSeed"; + +const tempDirs: string[] = []; +after(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function seed(entries: number): CoachSeedFile { + return { + formatVersion: 1, + recordingSha256: "a".repeat(64), + sessionUid: "42", + entries: Array.from({ length: entries }, (_, i) => ({ + lapA: 1, + lapB: i + 2, + model: "modelo-falso", + analysisText: `texto ${i}`, + insights: { totalDeltaS: i, corners: [], tyreWindowExcursions: [] }, + })), + }; +} + +test("grava o cache como JSON legível, criando a pasta, sem deixar o temporário", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-seedfile-")); + tempDirs.push(dir); + const filePath = path.join(dir, "demo", "coach-seed.json"); + + writeCoachSeedFile(filePath, seed(1)); + + assert.ok(fs.existsSync(filePath), "o arquivo não foi gravado"); + const text = fs.readFileSync(filePath, "utf8"); + assert.deepEqual(JSON.parse(text), seed(1)); + assert.ok(text.endsWith("\n"), "sem quebra de linha no fim"); + assert.deepEqual(fs.readdirSync(path.dirname(filePath)), ["coach-seed.json"], "sobrou um temporário na pasta"); +}); + +test("substitui por inteiro um arquivo que já existe", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-seedfile-")); + tempDirs.push(dir); + const filePath = path.join(dir, "coach-seed.json"); + const old = "conteúdo antigo, maior que o novo, para pegar sobra de bytes ".repeat(50); + fs.writeFileSync(filePath, old); + + writeCoachSeedFile(filePath, seed(2)); + + const text = fs.readFileSync(filePath, "utf8"); + assert.notEqual(text, old, "o arquivo antigo não foi substituído"); + assert.deepEqual(JSON.parse(text), seed(2)); + assert.deepEqual(fs.readdirSync(dir), ["coach-seed.json"]); +}); diff --git a/src/demo/demoLog.test.ts b/src/demo/demoLog.test.ts new file mode 100644 index 0000000..764b86f --- /dev/null +++ b/src/demo/demoLog.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo, Socket } from "node:net"; +import express from "express"; +import { createRestCounter, formatSummary, type DemoCounters } from "./demoLog"; + +const COUNTERS: DemoCounters = { + openConnections: 2, + wsBytes: 3 * 1024 * 1024, + restBytes: 512 * 1024, + coach503: 1, + cpuUserMs: 1200, + cpuSystemMs: 34, +}; + +test("o resumo é uma linha só, com conexões, bytes de WebSocket, REST e total, 503 do coach e CPU", () => { + assert.equal( + formatSummary(COUNTERS), + "[demo] resumo: 2 conexões abertas; WebSocket 3145728 B (3.00 MiB); REST 524288 B (0.50 MiB); " + + "total 3670016 B (3.50 MiB); 503 do coach: 1; CPU 1234 ms (usuário 1200, sistema 34); " + + "bytes escritos nos sockets pela aplicação; não é o número cobrado pela hospedagem" + ); +}); + +test("o contador de REST soma os bytes do socket nas respostas de um cliente keep-alive e conta os 503 do coach", async () => { + const counter = createRestCounter(); + const app = express(); + app.use(counter.middleware); + app.get("/sessions", (_req, res) => { + res.json({ dados: "x".repeat(5000) }); + }); + app.post("/sessions/:id/analysis", (_req, res) => { + res.status(503).json({ error: "coaching indisponível: configure ANTHROPIC_API_KEY" }); + }); + + const server = http.createServer(app); + const sockets: Socket[] = []; + server.on("connection", (socket) => sockets.push(socket)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + + const request = (method: string, path: string) => + new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, method, path, agent }, (res) => { + res.resume(); + res.on("end", () => resolve()); + }); + req.on("error", reject); + req.end(); + }); + + try { + await request("GET", "/sessions"); + await request("POST", "/sessions/1/analysis"); + + assert.equal(sockets.length, 1, "as duas requisições deviam usar o mesmo socket"); + const { restBytes, coach503 } = counter.stats(); + assert.equal(restBytes, sockets[0].bytesWritten); + assert.ok(restBytes > 5000, `restBytes ${restBytes}`); + assert.equal(coach503, 1); + } finally { + agent.destroy(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("o resumo não tem quebra de linha nem nada parecido com um endereço IP", () => { + const line = formatSummary(COUNTERS); + assert.ok(line.startsWith("[demo] resumo:"), line); + assert.ok(!line.includes("\n"), line); + assert.doesNotMatch(line, /\b\d{1,3}(\.\d{1,3}){3}\b/); + assert.doesNotMatch(line, /\b[0-9a-f]{1,4}(:[0-9a-f]{0,4}){2,7}\b/i); +}); diff --git a/src/demo/demoLog.ts b/src/demo/demoLog.ts new file mode 100644 index 0000000..06f8cbb --- /dev/null +++ b/src/demo/demoLog.ts @@ -0,0 +1,49 @@ +import type { Socket } from "node:net"; +import type { RequestHandler } from "express"; +import type { RestCounter } from "../http/httpServer"; + +export interface DemoCounters { + openConnections: number; + wsBytes: number; + restBytes: number; + coach503: number; + cpuUserMs: number; + cpuSystemMs: number; +} + +const mib = (bytes: number) => (bytes / (1024 * 1024)).toFixed(2); + +export function formatSummary(c: DemoCounters): string { + const total = c.wsBytes + c.restBytes; + return ( + `[demo] resumo: ${c.openConnections} conexões abertas; ` + + `WebSocket ${c.wsBytes} B (${mib(c.wsBytes)} MiB); REST ${c.restBytes} B (${mib(c.restBytes)} MiB); ` + + `total ${total} B (${mib(total)} MiB); 503 do coach: ${c.coach503}; ` + + `CPU ${c.cpuUserMs + c.cpuSystemMs} ms (usuário ${c.cpuUserMs}, sistema ${c.cpuSystemMs}); ` + + "bytes escritos nos sockets pela aplicação; não é o número cobrado pela hospedagem" + ); +} + +const COACH_ROUTE = /^\/sessions\/[^/]+\/analysis$/; + +// Conta o que sai pelo socket (cabeçalhos incluídos), não o tamanho do corpo. +// Com keep-alive o mesmo socket serve várias respostas: o WeakMap guarda até +// onde cada socket já foi contado. +export function createRestCounter(): RestCounter { + const counted = new WeakMap(); + let restBytes = 0; + let coach503 = 0; + + const middleware: RequestHandler = (req, res, next) => { + res.on("finish", () => { + const socket = req.socket; + const previous = counted.get(socket) ?? 0; + restBytes += socket.bytesWritten - previous; + counted.set(socket, socket.bytesWritten); + if (req.method === "POST" && COACH_ROUTE.test(req.path) && res.statusCode === 503) coach503 += 1; + }); + next(); + }; + + return { middleware, stats: () => ({ restBytes, coach503 }) }; +} diff --git a/src/demo/demoServer.integration.test.ts b/src/demo/demoServer.integration.test.ts new file mode 100644 index 0000000..6dd96cd --- /dev/null +++ b/src/demo/demoServer.integration.test.ts @@ -0,0 +1,286 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { WebSocket } from "ws"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "../persistence/testDb"; +import { buildDemoRecording } from "./testFixtures"; +import { sha256Hex } from "./recording"; +import type { CoachSeedFile } from "./coachSeed"; +import type { LapInsights } from "../analysis/types"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const ALLOWED = "http://localhost:5173"; +const READY_DEADLINE_MS = 30_000; +const WAIT_MS = 5_000; + +// Os insights do cache sintético são calculados aqui, com as mesmas funções +// que o servidor vai usar, num banco descartável deste processo. +const handle = setupIsolatedTestDb(); +/* eslint-disable @typescript-eslint/no-var-requires */ +const { buildDemoSession } = require("./buildDemoSession") as typeof import("./buildDemoSession"); +const { buildInsights } = require("../analysis/buildInsights") as typeof import("../analysis/buildInsights"); +const { listCarTelemetrySamplesForLap } = require("../persistence/carTelemetrySampleRepository") as typeof import("../persistence/carTelemetrySampleRepository"); +const { listMotionSamplesForLap } = require("../persistence/motionSampleRepository") as typeof import("../persistence/motionSampleRepository"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-demoserver-")); +const children: ChildProcess[] = []; + +after(() => { + for (const child of children) if (child.exitCode === null) child.kill(); + teardownIsolatedTestDb(handle); + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +const recording = buildDemoRecording({ sessionUID: 777n, laps: 2 }); +const build = buildDemoSession(recording.packets); +const computeInsights = (lapA: number, lapB: number): LapInsights => + buildInsights({ + primaryTelemetry: listCarTelemetrySamplesForLap(build.sessionId, lapA), + comparisonTelemetry: listCarTelemetrySamplesForLap(build.sessionId, lapB), + primaryMotion: listMotionSamplesForLap(build.sessionId, lapA), + comparisonMotion: listMotionSamplesForLap(build.sessionId, lapB), + }); +const samplesPerLap = new Map(recording.completedLaps.map((lap) => [lap, listCarTelemetrySamplesForLap(build.sessionId, lap).length])); + +const recordingText = recording.packets + .map((p) => JSON.stringify({ timestampMs: p.timestampMs, receivedAt: p.receivedAt, data: p.data.toString("base64") })) + .join("\n"); +const recordingPath = path.join(workDir, "demo.jsonl"); +fs.writeFileSync(recordingPath, `${recordingText}\n`); +const recordingSha256 = sha256Hex(fs.readFileSync(recordingPath)); + +function writeSeed(name: string, seed: CoachSeedFile): string { + const seedPath = path.join(workDir, name); + fs.writeFileSync(seedPath, `${JSON.stringify(seed, null, 2)}\n`); + return seedPath; +} + +const seed: CoachSeedFile = { + formatVersion: 1, + recordingSha256, + sessionUid: build.sessionUid, + entries: [{ lapA: 2, lapB: 1, model: "modelo-falso", analysisText: "análise 2-1", insights: computeInsights(2, 1) }], +}; +const seedPath = writeSeed("seed.json", seed); + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const port = (probe.address() as net.AddressInfo).port; + probe.close(() => resolve(port)); + }); + }); +} + +function portIsFree(port: number): Promise { + return new Promise((resolve) => { + const probe = net.createServer(); + probe.once("error", () => resolve(false)); + probe.listen(port, "0.0.0.0", () => probe.close(() => resolve(true))); + }); +} + +// Ambiente limpo e cwd fora do repositório (como em serverFailFast.test.ts), +// para nenhum .env, chave ou porta local chegar ao processo filho. +function demoEnv(vars: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const name of ["WS_PORT", "UDP_PORT", "HTTP_PORT", "DB_PATH", "ANTHROPIC_API_KEY", "DOTENV_CONFIG_PATH", "PORT", "DEMO_ALLOWED_ORIGIN"]) { + delete env[name]; + } + Object.assign(env, { DEMO_MODE: "true", DEMO_ALLOWED_ORIGIN: ALLOWED }, vars); + env.TS_NODE_PROJECT = path.join(REPO_ROOT, "tsconfig.json"); + return env; +} + +const SERVER_ARGS = ["-r", require.resolve("ts-node/register"), path.join(REPO_ROOT, "src", "server.ts")]; + +function startDemo(port: number): Promise<{ child: ChildProcess; output: () => string }> { + const child = spawn(process.execPath, SERVER_ARGS, { + cwd: workDir, + env: demoEnv({ + PORT: String(port), + DEMO_RECORDING_PATH: recordingPath, + DEMO_SEED_PATH: seedPath, + DEMO_MAX_CONNECTION_MINUTES: "1", + }), + }); + children.push(child); + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString())); + child.stderr?.on("data", (chunk: Buffer) => (output += chunk.toString())); + + return new Promise((resolve, reject) => { + const fail = (why: string) => { + clearInterval(poll); + clearTimeout(timer); + reject(new assert.AssertionError({ message: `${why}; saída: ${output}` })); + }; + const timer = setTimeout(() => fail("prazo esgotado esperando [demo] pronto"), READY_DEADLINE_MS); + const poll = setInterval(() => { + if (output.includes("[demo] pronto")) { + clearInterval(poll); + clearTimeout(timer); + resolve({ child, output: () => output }); + } + }, 50); + child.once("exit", (code) => fail(`o servidor saiu com código ${code} antes de ficar pronto`)); + }); +} + +function withDeadline(promise: Promise, what: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new assert.AssertionError({ message: `prazo esgotado: ${what}` })), WAIT_MS)), + ]); +} + +test("modo demo de ponta a ponta: sessão, datas, cache, CORS, WebSocket compactado de um carro só, sem arquivo de banco", async () => { + const port = await freePort(); + const { child } = await startDemo(port); + const base = `http://127.0.0.1:${port}`; + + try { + const health = await fetch(`${base}/health`); + assert.equal(health.status, 200); + + const sessions = (await (await fetch(`${base}/sessions`)).json()) as Array<{ id: number; startedAt: string; lastSeenAt: string }>; + assert.equal(sessions.length, 1); + assert.equal(sessions[0].startedAt, new Date(build.firstReceivedAt as number).toISOString()); + assert.equal(sessions[0].lastSeenAt, new Date(build.lastReceivedAt as number).toISOString()); + const sessionId = sessions[0].id; + + const detail = (await (await fetch(`${base}/sessions/${sessionId}`)).json()) as { laps: Array<{ lapNumber: number }> }; + assert.deepEqual( + detail.laps.map((lap) => lap.lapNumber), + recording.completedLaps, + ); + for (const lap of recording.completedLaps) { + const telemetry = (await (await fetch(`${base}/sessions/${sessionId}/laps/${lap}/telemetry`)).json()) as { + carTelemetry: unknown[]; + motion: unknown[]; + }; + assert.equal(telemetry.carTelemetry.length, samplesPerLap.get(lap), `volta ${lap}`); + assert.equal(telemetry.motion.length, samplesPerLap.get(lap), `volta ${lap}`); + } + + const first = await fetch(`${base}/sessions`); + const etag = first.headers.get("etag"); + assert.ok(etag); + assert.equal(first.headers.get("cache-control"), "no-cache"); + await first.arrayBuffer(); + const revalidated = await new Promise((resolve, reject) => { + require("node:http") + .get(`${base}/sessions`, { headers: { "If-None-Match": etag } }, (res: import("node:http").IncomingMessage) => { + res.resume(); + resolve(res.statusCode ?? 0); + }) + .on("error", reject); + }); + assert.equal(revalidated, 304); + + const post = (lapA: number, lapB: number) => + fetch(`${base}/sessions/${sessionId}/analysis`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lapA, lapB }), + }); + const cached = (await (await post(2, 1)).json()) as { analysis: string; cached: boolean }; + assert.deepEqual([cached.analysis, cached.cached], ["análise 2-1", true]); + assert.equal((await post(1, 2)).status, 503); + + const allowed = await fetch(`${base}/sessions`, { headers: { Origin: ALLOWED } }); + assert.equal(allowed.headers.get("access-control-allow-origin"), ALLOWED); + const foreign = await fetch(`${base}/sessions`, { headers: { Origin: "https://example.com" } }); + assert.equal(foreign.headers.get("access-control-allow-origin"), null); + + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { origin: ALLOWED }); + const message = await withDeadline( + new Promise<{ data: unknown[]; playerCarIndex: number }>((resolve, reject) => { + ws.once("message", (raw) => resolve(JSON.parse(raw.toString()))); + ws.once("error", reject); + }), + "primeira mensagem do WebSocket", + ); + assert.match(ws.extensions, /permessage-deflate/); + assert.equal(message.data.length, 1); + assert.equal(message.playerCarIndex, 0); + ws.close(); + + const refused = new WebSocket(`ws://127.0.0.1:${port}`, { origin: "https://example.com" }); + const status = await withDeadline( + new Promise((resolve) => { + refused.once("unexpected-response", (_req, res) => resolve(res.statusCode ?? 0)); + refused.once("open", () => resolve(101)); + refused.once("error", () => undefined); + }), + "resposta ao handshake de outra origem", + ); + assert.equal(status, 403); + + const leftovers = fs.readdirSync(workDir); + assert.ok(!leftovers.includes("data"), `pasta data/ criada: ${leftovers.join(", ")}`); + assert.ok(!leftovers.some((name) => name.endsWith(".db")), `arquivo de banco criado: ${leftovers.join(", ")}`); + } finally { + child.kill(); + } +}); + +const short = (sha256: string) => `${sha256.slice(0, 8)}…${sha256.slice(-4)}`; + +async function assertRefused(vars: Record, ...messages: string[]): Promise { + const port = await freePort(); + const result = spawnSync(process.execPath, SERVER_ARGS, { + cwd: workDir, + env: demoEnv({ PORT: String(port), DEMO_RECORDING_PATH: recordingPath, DEMO_SEED_PATH: seedPath, ...vars }), + encoding: "utf8", + timeout: READY_DEADLINE_MS, + }); + assert.notEqual(result.status, null, `o servidor subiu em vez de falhar (timeout); stdout: ${result.stdout}`); + assert.notEqual(result.status, 0); + for (const message of messages) assert.ok(result.stderr.includes(message), `falta "${message}"; stderr: ${result.stderr}`); + assert.equal(await portIsFree(port), true, "a porta ficou ocupada"); +} + +test("modo demo sem a gravação para na inicialização", async () => { + await assertRefused({ DEMO_RECORDING_PATH: path.join(workDir, "nao-existe.jsonl") }, "[demo] gravação não encontrada em"); +}); + +test("modo demo com a gravação alterada em um byte para na inicialização", async () => { + const altered = Buffer.from(fs.readFileSync(recordingPath)); + altered[altered.length - 2] ^= 1; + const alteredPath = path.join(workDir, "alterada.jsonl"); + fs.writeFileSync(alteredPath, altered); + await assertRefused( + { DEMO_RECORDING_PATH: alteredPath }, + "[demo] a gravação não confere com o cache do coach", + `SHA-256 da gravação lida ${short(sha256Hex(altered))}`, + `recordingSha256 esperado pelo cache ${short(recordingSha256)}`, + ); +}); + +test("modo demo com cache do coach de outra gravação para na inicialização", async () => { + const otherSha256 = "b".repeat(64); + const other = writeSeed("outra.json", { ...seed, recordingSha256: otherSha256 }); + await assertRefused( + { DEMO_SEED_PATH: other }, + "[demo] a gravação não confere com o cache do coach", + `SHA-256 da gravação lida ${short(recordingSha256)}`, + `recordingSha256 esperado pelo cache ${short(otherSha256)}`, + ); +}); + +test("modo demo com insights do cache fora da tolerância para na inicialização", async () => { + const entry = seed.entries[0]; + const off = writeSeed("fora.json", { + ...seed, + entries: [{ ...entry, insights: { ...entry.insights, totalDeltaS: entry.insights.totalDeltaS + 0.05 } }], + }); + await assertRefused({ DEMO_SEED_PATH: off }, "[demo] cache do coach inválido: insights do par (2, 1) diferem da análise atual em totalDeltaS"); +}); diff --git a/src/demo/demoServer.ts b/src/demo/demoServer.ts new file mode 100644 index 0000000..15cf02b --- /dev/null +++ b/src/demo/demoServer.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import http from "node:http"; +import { setTimeout as delay } from "node:timers/promises"; +import type { DemoConfig } from "../config/demoEnv"; +import type { LapInsights } from "../analysis/types"; +import type { PacketMotionData } from "../telemetry/motion/types"; +import type { PacketLapData } from "../telemetry/lapData/types"; +import type { PacketCarTelemetryData } from "../telemetry/carTelemetry/types"; +import { parseRecording, sha256Hex } from "./recording"; +import { buildCaptureTimes } from "./captureTimes"; +import { validateCoachSeed } from "./coachSeed"; +import { toPlayerOnlyMessage } from "./playerOnly"; +import { createRestCounter, formatSummary } from "./demoLog"; +import type { DemoWsEvent } from "./demoWsServer"; + +const SUMMARY_INTERVAL_MS = 15 * 60 * 1000; + +function readOrRefuse(filePath: string, missing: string): Buffer { + try { + return fs.readFileSync(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") throw new Error(missing); + throw err; + } +} + +function abbreviate(sha256: string): string { + return sha256.length > 12 ? `${sha256.slice(0, 8)}…${sha256.slice(-4)}` : sha256; +} + +function describeEvent(event: DemoWsEvent): string { + switch (event.type) { + case "opened": + return `[demo] conexão aberta (${event.openConnections} abertas)`; + case "closed": { + const reason = event.code === 4001 ? "demo full" : event.code === 4002 ? "time limit" : "normal"; + return `[demo] conexão fechada: ${event.code} ${reason} (${event.openConnections} abertas)`; + } + case "full": + return "[demo] conexão recusada: 4001 demo full"; + case "refused-origin": + return "[demo] conexão recusada: 403 origem não permitida"; + } +} + +/** + * Sobe o modo demo na ordem do plano: confere a gravação contra o cache do + * coach, monta a sessão inteira num banco em memória, aplica as datas de + * captura, semeia e valida o cache, deixa o banco só leitura e só então + * escuta na PORT. Qualquer erro até aí rejeita antes de abrir porta. + */ +export async function startDemoServer(config: DemoConfig): Promise { + const startedAt = Date.now(); + + const seedText = readOrRefuse(config.seedPath, `[demo] cache do coach não encontrado em ${config.seedPath}.`).toString("utf8"); + let rawSeed: unknown; + try { + rawSeed = JSON.parse(seedText); + } catch (err) { + throw new Error(`[demo] cache do coach inválido: o arquivo não é JSON válido (${err instanceof Error ? err.message : err}).`); + } + const expectedSha256 = (rawSeed as { recordingSha256?: unknown } | null)?.recordingSha256; + if (typeof expectedSha256 !== "string") throw new Error("[demo] cache do coach inválido: sem recordingSha256."); + + const recordingData = readOrRefuse( + config.recordingPath, + `[demo] gravação não encontrada em ${config.recordingPath}: o npm run build baixa e confere a gravação.` + ); + const hashStart = Date.now(); + const recordingSha256 = sha256Hex(recordingData); + const hashMs = Date.now() - hashStart; + // Os dois valores no log: comparando com o SHA-256 publicado no README, + // quem lê o deploy sabe se a errada é a gravação ou o cache. + if (recordingSha256 !== expectedSha256) { + throw new Error( + `[demo] a gravação não confere com o cache do coach: SHA-256 da gravação lida ${abbreviate(recordingSha256)}, ` + + `recordingSha256 esperado pelo cache ${abbreviate(expectedSha256)} (compare com o SHA-256 publicado no README).` + ); + } + const packets = parseRecording(recordingData.toString("utf8")); + + // A persistência abre o banco (:memory: no modo demo) no import: só é + // carregada depois de a gravação e o cache passarem na conferência. + /* eslint-disable @typescript-eslint/no-var-requires */ + const { buildDemoSession } = require("./buildDemoSession") as typeof import("./buildDemoSession"); + const { applyCaptureTimes } = require("./applyCaptureTimes") as typeof import("./applyCaptureTimes"); + const { applyCoachSeed } = require("./applyCoachSeed") as typeof import("./applyCoachSeed"); + const { setReadOnly, closeDb } = require("../persistence/db") as typeof import("../persistence/db"); + const { listLapsForSession } = require("../persistence/lapRepository") as typeof import("../persistence/lapRepository"); + const { listCarTelemetrySamplesForLap } = require("../persistence/carTelemetrySampleRepository") as typeof import("../persistence/carTelemetrySampleRepository"); + const { listMotionSamplesForLap } = require("../persistence/motionSampleRepository") as typeof import("../persistence/motionSampleRepository"); + const { buildInsights } = require("../analysis/buildInsights") as typeof import("../analysis/buildInsights"); + const { createApp } = require("../http/httpServer") as typeof import("../http/httpServer"); + const { attachDemoWsServer } = require("./demoWsServer") as typeof import("./demoWsServer"); + const { runReplay } = require("../replay/runner") as typeof import("../replay/runner"); + const { dispatchPacket } = require("../telemetry/dispatcher") as typeof import("../telemetry/dispatcher"); + const { NUM_CARS } = require("../telemetry/shared/constants") as typeof import("../telemetry/shared/constants"); + const { buildWsMessage } = require("../ws/messageFormat") as typeof import("../ws/messageFormat"); + /* eslint-enable @typescript-eslint/no-var-requires */ + + const buildStart = Date.now(); + const build = buildDemoSession(packets); + applyCaptureTimes(build.sessionId, buildCaptureTimes(build.firstReceivedAt, build.lastReceivedAt, build.completions)); + const buildMs = Date.now() - buildStart; + + const seedStart = Date.now(); + const computeInsights = (lapA: number, lapB: number): LapInsights => + buildInsights({ + primaryTelemetry: listCarTelemetrySamplesForLap(build.sessionId, lapA), + comparisonTelemetry: listCarTelemetrySamplesForLap(build.sessionId, lapB), + primaryMotion: listMotionSamplesForLap(build.sessionId, lapA), + comparisonMotion: listMotionSamplesForLap(build.sessionId, lapB), + }); + const seed = validateCoachSeed(rawSeed, { + recordingSha256, + sessionUid: build.sessionUid, + completedLaps: listLapsForSession(build.sessionId).map((lap) => lap.lapNumber), + computeInsights, + }); + applyCoachSeed(build.sessionId, seed); + const seedMs = Date.now() - seedStart; + + setReadOnly(); + + let replayRunning = false; + const restCounter = createRestCounter(); + const server = http.createServer( + createApp({ demo: { allowedOrigin: config.allowedOrigin, restCounter }, isHealthy: () => replayRunning }) + ); + const live = attachDemoWsServer(server, { + allowedOrigin: config.allowedOrigin, + maxConnections: config.maxConnections, + maxConnectionMs: config.maxConnectionMs, + perMessageDeflate: config.wsCompression ? {} : false, + onEvent: (event) => console.log(describeEvent(event)), + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(config.port, "0.0.0.0", () => { + server.off("error", reject); + resolve(); + }); + }); + + function send(buffer: Buffer): void { + // Mesma regra do servidor local: um pacote malformado é descartado, o + // loop segue (constituição, princípio II). + try { + const result = dispatchPacket(buffer); + if (!result) return; + const { header, packetId, data } = result; + if (header.playerCarIndex < 0 || header.playerCarIndex >= NUM_CARS) { + throw new Error(`playerCarIndex fora do intervalo (0-${NUM_CARS - 1}): ${header.playerCarIndex}`); + } + const cars = + packetId === 0 + ? (data as PacketMotionData).carMotionData + : packetId === 2 + ? (data as PacketLapData).lapData + : packetId === 6 + ? (data as PacketCarTelemetryData).carTelemetryData + : null; + if (!cars) return; + const message = buildWsMessage(header, packetId, cars); + if (message) live.broadcast(JSON.stringify(toPlayerOnlyMessage(message))); + } catch (err) { + console.error("[demo] pacote descartado no loop:", err instanceof Error ? err.message : err); + } + } + + const abort = new AbortController(); + replayRunning = true; + // O loop só transmite: o banco já está completo e só leitura (FR-005). + void runReplay( + packets, + { speed: "1x", loop: true }, + { + send, + wait: (ms) => delay(ms, undefined, { signal: abort.signal }), + onPass: (pass) => console.log(`[demo] passada ${pass} do loop`), + } + ) + .catch((err) => console.error("[demo] o loop parou com erro:", err instanceof Error ? err.message : err)) + .finally(() => { + replayRunning = false; + }); + + console.log( + `[demo] pronto em ${Date.now() - startedAt} ms (montagem ${buildMs} ms, hash ${hashMs} ms, cache ${seedMs} ms; ` + + `compressão do WebSocket ${config.wsCompression ? "ligada" : "desligada"})` + ); + + setInterval(() => { + const cpu = process.cpuUsage(); + const ws = live.stats(); + const rest = restCounter.stats(); + console.log( + formatSummary({ + openConnections: ws.openConnections, + wsBytes: ws.bytesWritten, + restBytes: rest.restBytes, + coach503: rest.coach503, + cpuUserMs: Math.round(cpu.user / 1000), + cpuSystemMs: Math.round(cpu.system / 1000), + }) + ); + }, SUMMARY_INTERVAL_MS).unref(); + + const shutdown = () => { + abort.abort(); + live.close(1001, "going away"); + server.closeAllConnections(); + server.close(); + closeDb(); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} diff --git a/src/demo/demoWsServer.test.ts b/src/demo/demoWsServer.test.ts new file mode 100644 index 0000000..0b52bae --- /dev/null +++ b/src/demo/demoWsServer.test.ts @@ -0,0 +1,180 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { WebSocket } from "ws"; +import { attachDemoWsServer, type DemoWsEvent, type DemoWsOptions, type DemoWsServer } from "./demoWsServer"; + +const ALLOWED = "http://localhost:5173"; +const WAIT_MS = 2000; + +const cleanups: Array<() => Promise | void> = []; +after(async () => { + for (const cleanup of cleanups.reverse()) await cleanup(); +}); + +async function startServer(overrides: Partial = {}) { + const events: DemoWsEvent[] = []; + const server = http.createServer(); + const demo: DemoWsServer = attachDemoWsServer(server, { + allowedOrigin: ALLOWED, + maxConnections: 5, + maxConnectionMs: 60_000, + perMessageDeflate: {}, + onEvent: (event) => events.push(event), + ...overrides, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`; + cleanups.push( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ); + return { url, demo, events }; +} + +// Toda espera tem prazo próprio e, se estourar, falha num assert: o vermelho +// contra o esboço tem que ser uma asserção, não um timeout do runner. +function waitFor(register: (resolve: (value: T) => void, fail: (reason: string) => void) => void, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new assert.AssertionError({ message: `prazo esgotado esperando ${what}` })), WAIT_MS); + register( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (reason) => { + clearTimeout(timer); + reject(new assert.AssertionError({ message: `${what}: ${reason}` })); + }, + ); + }); +} + +// O close do cliente pode chegar antes do evento "closed" do servidor (o ws +// só emite o close do lado do servidor quando o fechamento termina): espera +// o evento aparecer, com prazo, em vez de conferir na hora. +function eventSeen(events: DemoWsEvent[], match: (e: DemoWsEvent) => boolean, what: string): Promise { + return waitFor((resolve) => { + const poll = () => (events.some(match) ? resolve() : setTimeout(poll, 10)); + poll(); + }, what); +} + +function connect(url: string, origin?: string): WebSocket { + const client = new WebSocket(url, origin ? { origin } : {}); + cleanups.push(() => client.terminate()); + return client; +} + +function opened(client: WebSocket): Promise { + return waitFor((resolve, fail) => { + client.once("open", () => resolve()); + client.once("error", (err) => fail(`erro em vez de abrir: ${err.message}`)); + }, "a conexão abrir"); +} + +function closed(client: WebSocket): Promise<{ code: number; reason: string }> { + return waitFor((resolve, fail) => { + client.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); + client.once("error", (err) => fail(`erro em vez de fechar: ${err.message}`)); + }, "a conexão fechar"); +} + +test("origem estrangeira recebe 403 no handshake", async () => { + const { url, events } = await startServer(); + const client = connect(url, "https://example.com"); + const status = await waitFor((resolve, fail) => { + client.once("unexpected-response", (_req, res) => resolve(res.statusCode ?? 0)); + client.once("open", () => fail("abriu")); + client.once("error", (err) => fail(`erro sem resposta HTTP: ${err.message}`)); + }, "a resposta do handshake"); + assert.equal(status, 403); + assert.ok(events.some((e) => e.type === "refused-origin"), JSON.stringify(events)); +}); + +test("sem Origin e com a origem permitida a conexão abre", async () => { + const { url, events } = await startServer(); + await opened(connect(url)); + await opened(connect(url, ALLOWED)); + assert.equal(events.filter((e) => e.type === "opened").length, 2); +}); + +test("acima do limite a conexão é aceita e fechada com 4001 demo full", async () => { + const { url, events } = await startServer({ maxConnections: 1 }); + await opened(connect(url, ALLOWED)); + const second = connect(url, ALLOWED); + const result = await closed(second); + assert.deepEqual(result, { code: 4001, reason: "demo full" }); + assert.ok(events.some((e) => e.type === "full"), JSON.stringify(events)); +}); + +test("no tempo máximo a conexão é fechada com 4002 time limit", async () => { + const { url, events } = await startServer({ maxConnectionMs: 300 }); + const client = connect(url, ALLOWED); + await opened(client); + const result = await closed(client); + assert.deepEqual(result, { code: 4002, reason: "time limit" }); + await eventSeen(events, (e) => e.type === "closed" && e.code === 4002, "o evento closed 4002 no servidor"); +}); + +test("fechada antes do tempo máximo, a conexão não recebe o 4002 depois", async () => { + const { url, events } = await startServer({ maxConnectionMs: 300 }); + const client = connect(url, ALLOWED); + await opened(client); + client.close(1000, "tchau"); + await closed(client); + await eventSeen(events, (e) => e.type === "closed", "o evento closed no servidor"); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(!events.some((e) => e.type === "closed" && e.code === 4002), JSON.stringify(events)); +}); + +test("com compressão ligada, o cliente que oferece negocia permessage-deflate", async () => { + const { url } = await startServer({ perMessageDeflate: {} }); + const client = connect(url, ALLOWED); + await opened(client); + assert.match(client.extensions, /permessage-deflate/); +}); + +test("com compressão desligada, nenhuma extensão é negociada", async () => { + const { url } = await startServer({ perMessageDeflate: false }); + const client = connect(url, ALLOWED); + await opened(client); + assert.equal(client.extensions, ""); +}); + +test("broadcast chega a todos os abertos, e stats conta conexões e bytes escritos", async () => { + const { url, demo } = await startServer(); + const a = connect(url, ALLOWED); + const b = connect(url, ALLOWED); + await opened(a); + await opened(b); + await waitFor((resolve, fail) => { + const started = Date.now(); + const poll = () => { + if (demo.stats().openConnections === 2) return resolve(); + if (Date.now() - started > WAIT_MS / 2) return fail(`openConnections=${demo.stats().openConnections}`); + setTimeout(poll, 20); + }; + poll(); + }, "as duas conexões contarem"); + + const got = (client: WebSocket) => + waitFor((resolve) => client.once("message", (data) => resolve(data.toString())), "a mensagem chegar"); + const pending = [got(a), got(b)]; + demo.broadcast('{"ola":1}'); + assert.deepEqual(await Promise.all(pending), ['{"ola":1}', '{"ola":1}']); + assert.ok(demo.stats().bytesWritten > 0, `bytesWritten=${demo.stats().bytesWritten}`); +}); + +test("close fecha todas as conexões com o código pedido", async () => { + const { url, demo } = await startServer(); + const client = connect(url, ALLOWED); + await opened(client); + const pending = closed(client); + demo.close(1001, "going away"); + assert.deepEqual(await pending, { code: 1001, reason: "going away" }); +}); diff --git a/src/demo/demoWsServer.ts b/src/demo/demoWsServer.ts new file mode 100644 index 0000000..6343447 --- /dev/null +++ b/src/demo/demoWsServer.ts @@ -0,0 +1,98 @@ +import type http from "node:http"; +import type { Duplex } from "node:stream"; +import { WebSocketServer, WebSocket, type PerMessageDeflateOptions } from "ws"; +import { startHeartbeat } from "../network/wsBroadcaster"; +import { decideCapacity, decideOrigin } from "./liveConnections"; + +export type DemoWsEvent = + | { type: "opened"; openConnections: number } + | { type: "closed"; code: number; openConnections: number } + | { type: "refused-origin" } + | { type: "full" }; + +export interface DemoWsOptions { + allowedOrigin: string; + maxConnections: number; + maxConnectionMs: number; + perMessageDeflate: false | PerMessageDeflateOptions; + onEvent?: (event: DemoWsEvent) => void; +} + +export interface DemoWsServer { + broadcast(json: string): void; + stats(): { openConnections: number; bytesWritten: number }; + close(code: number, reason: string): void; +} + +/** + * WebSocket do modo demo, na mesma porta do HTTP (a hospedagem expõe uma só): + * a origem é checada antes do upgrade, quem passa do limite é aceito e logo + * fechado com 4001 (o navegador só vê o motivo num close, não num handshake + * recusado), e cada conexão vive no máximo maxConnectionMs antes do 4002. + * Os bytes são contados no socket, depois da compressão, porque é isso que + * sai pela banda da hospedagem (research.md, ponto 10). + */ +export function attachDemoWsServer(server: http.Server, options: DemoWsOptions): DemoWsServer { + const wss = new WebSocketServer({ noServer: true, perMessageDeflate: options.perMessageDeflate }); + const clients = new Set(); + const liveSockets = new Set(); + let closedSocketBytes = 0; + const emit = (event: DemoWsEvent) => options.onEvent?.(event); + + function trackBytes(socket: Duplex): void { + const netSocket = socket as Duplex & { bytesWritten: number }; + liveSockets.add(socket); + socket.once("close", () => { + liveSockets.delete(socket); + closedSocketBytes += netSocket.bytesWritten; + }); + } + + server.on("upgrade", (request: http.IncomingMessage, socket: Duplex, head: Buffer) => { + socket.on("error", () => socket.destroy()); + trackBytes(socket); + + if (decideOrigin(request.headers.origin, options.allowedOrigin) === "refuse") { + emit({ type: "refused-origin" }); + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, (ws) => { + if (decideCapacity(clients.size, options.maxConnections) === "full") { + emit({ type: "full" }); + ws.close(4001, "demo full"); + return; + } + + clients.add(ws); + emit({ type: "opened", openConnections: clients.size }); + const stopHeartbeat = startHeartbeat(ws); + const timeLimit = setTimeout(() => ws.close(4002, "time limit"), options.maxConnectionMs); + + ws.on("close", (code) => { + clearTimeout(timeLimit); + stopHeartbeat(); + clients.delete(ws); + emit({ type: "closed", code, openConnections: clients.size }); + }); + }); + }); + + return { + broadcast(json: string): void { + for (const client of clients) { + if (client.readyState === WebSocket.OPEN) client.send(json); + } + }, + stats() { + let liveBytes = 0; + for (const socket of liveSockets) liveBytes += (socket as Duplex & { bytesWritten: number }).bytesWritten; + return { openConnections: clients.size, bytesWritten: closedSocketBytes + liveBytes }; + }, + close(code: number, reason: string): void { + for (const client of clients) client.close(code, reason); + }, + }; +} diff --git a/src/demo/exportCoachSeed.test.ts b/src/demo/exportCoachSeed.test.ts new file mode 100644 index 0000000..d9539c7 --- /dev/null +++ b/src/demo/exportCoachSeed.test.ts @@ -0,0 +1,170 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "../persistence/testDb"; +import type { LapInsights } from "../analysis/types"; +import { validateCoachSeed, type CoachSeedFile } from "./coachSeed"; +import { buildDemoRecording } from "./testFixtures"; + +const handle = setupIsolatedTestDb(); +/* eslint-disable @typescript-eslint/no-var-requires */ +const { exportCoachSeed } = require("./exportCoachSeed") as typeof import("./exportCoachSeed"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +after(() => teardownIsolatedTestDb(handle)); + +const HASH = "c".repeat(64); +const PAIRS: Array<[number, number]> = [ + [1, 2], + [2, 1], +]; + +// Coach falso: nenhuma chamada paga em teste. Conta as chamadas e devolve +// um texto que identifica a ordem em que foi chamado. +function fakeCoach() { + const calls: LapInsights[] = []; + const generate = async (insights: LapInsights) => { + calls.push(insights); + return { text: `análise ${calls.length}`, model: "modelo-falso" }; + }; + return { generate, calls }; +} + +const recording = buildDemoRecording({ sessionUID: 77n, laps: 2 }); + +async function firstRun() { + const first = await run(undefined); + assert.equal(first.calls, 2, "a primeira exportação deveria chamar o coach para os dois pares"); + assert.equal(first.seed.entries.length, 2, "a primeira exportação deveria ter os dois pares"); + return first; +} + +async function run(existing: CoachSeedFile | undefined, currentModel = "modelo-falso") { + const coach = fakeCoach(); + const seed = await exportCoachSeed({ + packets: recording.packets, + recordingSha256: HASH, + pairs: PAIRS, + existing, + generate: coach.generate, + currentModel, + }); + return { seed, calls: coach.calls.length }; +} + +test("sem cache anterior, chama o coach uma vez por par e monta um cache válido", async () => { + const { seed, calls } = await run(undefined); + + assert.equal(calls, 2); + assert.equal(seed.recordingSha256, HASH); + assert.equal(seed.sessionUid, "77"); + assert.deepEqual( + seed.entries.map((e) => [e.lapA, e.lapB]), + [ + [1, 2], + [2, 1], + ], + ); + for (const entry of seed.entries) assert.deepEqual(Object.keys(entry).sort(), ["analysisText", "insights", "lapA", "lapB", "model"]); + assert.doesNotThrow(() => + validateCoachSeed(seed, { + recordingSha256: HASH, + sessionUid: "77", + completedLaps: recording.completedLaps, + computeInsights: (a, b) => seed.entries.find((e) => e.lapA === a && e.lapB === b)!.insights, + }), + ); +}); + +test("rodando de novo com o próprio resultado, não chama o coach", async () => { + const first = await firstRun(); + const second = await run(first.seed); + + assert.equal(second.calls, 0); + assert.deepEqual(second.seed, first.seed); +}); + +test("um par com insights fora da tolerância é pedido de novo, só ele", async () => { + const first = await firstRun(); + const stale: CoachSeedFile = structuredClone(first.seed); + stale.entries[0].insights.totalDeltaS += 1; + + const second = await run(stale); + assert.equal(second.calls, 1); + assert.deepEqual(second.seed.entries[1], first.seed.entries[1]); +}); + +test("um par gerado por outro modelo é pedido de novo", async () => { + const first = await firstRun(); + const otherModel: CoachSeedFile = structuredClone(first.seed); + otherModel.entries[1].model = "modelo-antigo"; + + const second = await run(otherModel); + assert.equal(second.calls, 1); + assert.deepEqual(second.seed.entries[0], first.seed.entries[0]); +}); + +test("a cada par pedido, o progresso parcial é entregue, e uma falha no meio não perde os pares já pagos", async () => { + const partials: CoachSeedFile[] = []; + let calls = 0; + const failingOnSecond = async () => { + calls += 1; + if (calls === 2) throw new Error("falha simulada da API"); + return { text: `análise ${calls}`, model: "modelo-falso" }; + }; + + await assert.rejects( + exportCoachSeed({ + packets: recording.packets, + recordingSha256: HASH, + pairs: PAIRS, + existing: undefined, + generate: failingOnSecond, + currentModel: "modelo-falso", + onProgress: (partial) => partials.push(structuredClone(partial)), + }), + { message: "falha simulada da API" }, + ); + assert.equal(partials.length, 1, "o par pago antes da falha não foi entregue como progresso"); + assert.deepEqual( + partials[0].entries.map((e) => [e.lapA, e.lapB]), + [[1, 2]], + ); + + // Um arquivo parcial válido (menos pares que o pedido) é lido pela rodada + // seguinte, que só pede o que falta. + const resumed = await run(partials[0]); + assert.equal(resumed.calls, 1); + assert.deepEqual(resumed.seed.entries[0], partials[0].entries[0]); + assert.equal(resumed.seed.entries.length, 2); +}); + +test("o progresso parcial mantém as entradas anteriores dos pares ainda não processados", async () => { + const first = await firstRun(); + const stale: CoachSeedFile = structuredClone(first.seed); + stale.entries[0].insights.totalDeltaS += 1; + + const partials: CoachSeedFile[] = []; + const coach = fakeCoach(); + await exportCoachSeed({ + packets: recording.packets, + recordingSha256: HASH, + pairs: PAIRS, + existing: stale, + generate: coach.generate, + currentModel: "modelo-falso", + onProgress: (partial) => partials.push(structuredClone(partial)), + }); + + assert.equal(coach.calls.length, 1); + assert.equal(partials.length, 1); + assert.equal(partials[0].entries.length, 2, "o par ainda não processado sumiu do progresso parcial"); + assert.deepEqual(partials[0].entries[1], first.seed.entries[1]); +}); + +test("um cache de outra gravação não é reaproveitado", async () => { + const first = await firstRun(); + const otherRecording: CoachSeedFile = { ...structuredClone(first.seed), recordingSha256: "d".repeat(64) }; + + const second = await run(otherRecording); + assert.equal(second.calls, 2); +}); diff --git a/src/demo/exportCoachSeed.ts b/src/demo/exportCoachSeed.ts new file mode 100644 index 0000000..df06441 --- /dev/null +++ b/src/demo/exportCoachSeed.ts @@ -0,0 +1,85 @@ +import { buildInsights } from "../analysis/buildInsights"; +import type { LapInsights } from "../analysis/types"; +import { listCarTelemetrySamplesForLap } from "../persistence/carTelemetrySampleRepository"; +import { getTrackBenchmark, listLapsForSession, type TrackBenchmark } from "../persistence/lapRepository"; +import { listMotionSamplesForLap } from "../persistence/motionSampleRepository"; +import { getSessionById } from "../persistence/sessionRepository"; +import { applyCaptureTimes } from "./applyCaptureTimes"; +import { buildDemoSession } from "./buildDemoSession"; +import { buildCaptureTimes } from "./captureTimes"; +import { validateCoachSeed, type CoachSeedEntry, type CoachSeedFile } from "./coachSeed"; +import { insightsMatch } from "./insightsMatch"; +import type { DemoPacket } from "./recording"; + +export type GenerateCoaching = (insights: LapInsights, benchmark: TrackBenchmark | null) => Promise<{ text: string; model: string }>; + +export interface ExportCoachSeedInput { + packets: DemoPacket[]; + recordingSha256: string; + pairs: Array<[number, number]>; + existing: CoachSeedFile | undefined; + generate: GenerateCoaching; + currentModel: string; + onProgress?: (partial: CoachSeedFile) => void; +} + +/** + * Monta o cache do coach a partir da gravação, num banco que quem chama já + * isolou (DB_PATH temporário ou :memory:). O coach entra por parâmetro: a + * ferramenta passa o de verdade, os testes passam um falso. Um par só é + * pedido de novo quando não existe no cache anterior da mesma gravação, ou + * quando os insights saíram da tolerância ou o modelo mudou - a mesma + * entrada nunca é paga duas vezes (constituição, princípio III). + */ +export async function exportCoachSeed(input: ExportCoachSeedInput): Promise { + const build = buildDemoSession(input.packets); + applyCaptureTimes(build.sessionId, buildCaptureTimes(build.firstReceivedAt, build.lastReceivedAt, build.completions)); + + const session = getSessionById(build.sessionId); + const benchmark = session && session.trackId !== null ? getTrackBenchmark(session.trackId) : null; + const completedLaps = listLapsForSession(build.sessionId).map((lap) => lap.lapNumber); + const computeInsights = (lapA: number, lapB: number): LapInsights => + buildInsights({ + primaryTelemetry: listCarTelemetrySamplesForLap(build.sessionId, lapA), + comparisonTelemetry: listCarTelemetrySamplesForLap(build.sessionId, lapB), + primaryMotion: listMotionSamplesForLap(build.sessionId, lapA), + comparisonMotion: listMotionSamplesForLap(build.sessionId, lapB), + }); + + const reusable = + input.existing && input.existing.recordingSha256 === input.recordingSha256 && input.existing.sessionUid === build.sessionUid + ? input.existing.entries + : []; + + const pairs = [...input.pairs].sort(([a1, b1], [a2, b2]) => a1 - a2 || b1 - b2); + const findPrevious = (lapA: number, lapB: number) => reusable.find((entry) => entry.lapA === lapA && entry.lapB === lapB); + const entries: CoachSeedEntry[] = []; + + // O progresso leva os pares já processados mais as entradas anteriores dos + // que ainda faltam: uma falha no meio não pode apagar um par já pago, nem + // um par válido que esta rodada ainda nem conferiu. + const progress = (): CoachSeedFile => ({ + formatVersion: 1, + recordingSha256: input.recordingSha256, + sessionUid: build.sessionUid, + entries: pairs.flatMap(([lapA, lapB], i) => { + const entry = i < entries.length ? entries[i] : findPrevious(lapA, lapB); + return entry ? [entry] : []; + }), + }); + + for (const [lapA, lapB] of pairs) { + const insights = computeInsights(lapA, lapB); + const previous = findPrevious(lapA, lapB); + if (previous && previous.model === input.currentModel && insightsMatch(previous.insights, insights).match) { + entries.push(previous); + continue; + } + const { text, model } = await input.generate(insights, benchmark); + entries.push({ lapA, lapB, model, analysisText: text, insights }); + input.onProgress?.(progress()); + } + + const seed: CoachSeedFile = { formatVersion: 1, recordingSha256: input.recordingSha256, sessionUid: build.sessionUid, entries }; + return validateCoachSeed(seed, { recordingSha256: input.recordingSha256, sessionUid: build.sessionUid, completedLaps, computeInsights }); +} diff --git a/src/demo/exportCoachSeedCli.test.ts b/src/demo/exportCoachSeedCli.test.ts new file mode 100644 index 0000000..58d2ed2 --- /dev/null +++ b/src/demo/exportCoachSeedCli.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); + +// A ferramenta de exportação de verdade, num processo filho com ambiente +// limpo e cwd fora do repositório (nenhum .env entra). Só as recusas que não +// precisam do banco real: a do data/telemetry.db é coberta com uma raiz falsa +// em exportEnvironment.test.ts. Sem chave nenhuma, nada pode ser chamado. +function runExport(vars: Record) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-export-")); + try { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const name of ["ANTHROPIC_API_KEY", "DB_PATH", "DOTENV_CONFIG_PATH", "DEMO_MODE", "WS_PORT", "UDP_PORT", "HTTP_PORT"]) { + delete env[name]; + } + for (const [name, value] of Object.entries(vars)) env[name] = value.replace("", tempDir); + env.TS_NODE_PROJECT = path.join(REPO_ROOT, "tsconfig.json"); + + const result = spawnSync( + process.execPath, + ["-r", require.resolve("ts-node/register"), path.join(REPO_ROOT, "tools", "exportCoachSeed.ts")], + { cwd: tempDir, env, encoding: "utf8", timeout: 60000 }, + ); + return { result, files: fs.readdirSync(tempDir) }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test("sem DB_PATH, a exportação sai com código 1 e a mensagem, sem criar nada", () => { + const { result, files } = runExport({}); + assert.equal(result.status, 1, `stderr: ${result.stderr}`); + assert.ok( + result.stderr.includes("[exportCoachSeed] defina DB_PATH=:memory: ou um caminho temporário; o data/telemetry.db nunca é usado."), + `stderr: ${result.stderr}`, + ); + assert.deepEqual(files, []); +}); + +test("com DB_PATH temporário e sem chave, a exportação sai com código 1 e a mensagem, sem abrir o banco", () => { + const { result, files } = runExport({ DB_PATH: "/exportacao.db" }); + assert.equal(result.status, 1, `stderr: ${result.stderr}`); + assert.ok( + result.stderr.includes("[exportCoachSeed] defina ANTHROPIC_API_KEY no seu shell: a exportação é a única etapa paga e só roda com a sua chave."), + `stderr: ${result.stderr}`, + ); + assert.deepEqual(files, [], "o banco temporário foi aberto antes da recusa"); +}); diff --git a/src/demo/exportEnvironment.test.ts b/src/demo/exportEnvironment.test.ts new file mode 100644 index 0000000..3c7abdb --- /dev/null +++ b/src/demo/exportEnvironment.test.ts @@ -0,0 +1,53 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { assertExportEnvironment } from "./coachSeed"; + +// Raízes falsas: nenhum caminho real do repositório aparece nestes testes. +const WIN_ROOT = "C:\\projeto"; +const POSIX_ROOT = "/projeto"; +const KEY = { ANTHROPIC_API_KEY: "chave-de-teste" }; + +const NO_DB_PATH = "[exportCoachSeed] defina DB_PATH=:memory: ou um caminho temporário; o data/telemetry.db nunca é usado."; +const REAL_DB = "[exportCoachSeed] DB_PATH aponta para o data/telemetry.db do repositório: use :memory: ou um caminho temporário."; +const NO_KEY = "[exportCoachSeed] defina ANTHROPIC_API_KEY no seu shell: a exportação é a única etapa paga e só roda com a sua chave."; + +test("sem DB_PATH, recusa", () => { + assert.throws(() => assertExportEnvironment({ ...KEY }, POSIX_ROOT, "linux"), { message: NO_DB_PATH }); + assert.throws(() => assertExportEnvironment({ ...KEY, DB_PATH: " " }, POSIX_ROOT, "linux"), { message: NO_DB_PATH }); +}); + +test(":memory: e um caminho temporário são aceitos", () => { + assert.doesNotThrow(() => assertExportEnvironment({ ...KEY, DB_PATH: ":memory:" }, POSIX_ROOT, "linux")); + assert.doesNotThrow(() => assertExportEnvironment({ ...KEY, DB_PATH: "/tmp/exportacao.db" }, POSIX_ROOT, "linux")); + assert.doesNotThrow(() => assertExportEnvironment({ ...KEY, DB_PATH: "D:\\temp\\exportacao.db" }, WIN_ROOT, "win32")); +}); + +test("no Linux, recusa as variantes que apontam para o data/telemetry.db", () => { + for (const dbPath of ["/projeto/data/telemetry.db", "./data/telemetry.db", "data/telemetry.db", "tmp/../data/telemetry.db", "/projeto/x/../data/telemetry.db"]) { + assert.throws(() => assertExportEnvironment({ ...KEY, DB_PATH: dbPath }, POSIX_ROOT, "linux"), { message: REAL_DB }, dbPath); + } +}); + +test("no Linux, maiúsculas trocadas são outro arquivo e passam", () => { + assert.doesNotThrow(() => assertExportEnvironment({ ...KEY, DB_PATH: "/projeto/DATA/telemetry.db" }, POSIX_ROOT, "linux")); +}); + +test("no Windows, recusa maiúsculas trocadas, ./, separador \\ e um caminho com .. que volta para data/", () => { + for (const dbPath of [ + "C:\\projeto\\data\\telemetry.db", + "c:\\PROJETO\\DATA\\Telemetry.DB", + "./data/telemetry.db", + ".\\data\\telemetry.db", + "data\\telemetry.db", + "data/telemetry.db", + "tmp\\..\\data\\telemetry.db", + "C:/projeto/x/../data/telemetry.db", + ]) { + assert.throws(() => assertExportEnvironment({ ...KEY, DB_PATH: dbPath }, WIN_ROOT, "win32"), { message: REAL_DB }, dbPath); + } +}); + +test("sem ANTHROPIC_API_KEY, recusa depois de aceitar o DB_PATH", () => { + assert.throws(() => assertExportEnvironment({ DB_PATH: ":memory:" }, POSIX_ROOT, "linux"), { message: NO_KEY }); + assert.throws(() => assertExportEnvironment({ DB_PATH: ":memory:", ANTHROPIC_API_KEY: "" }, POSIX_ROOT, "linux"), { message: NO_KEY }); +}); diff --git a/src/demo/insightsMatch.test.ts b/src/demo/insightsMatch.test.ts new file mode 100644 index 0000000..30d6fa4 --- /dev/null +++ b/src/demo/insightsMatch.test.ts @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { LapInsights } from "../analysis/types"; +import { insightsMatch } from "./insightsMatch"; + +function insights(): LapInsights { + return { + totalDeltaS: 0.35, + corners: [ + { + corner: { index: 0, apexDistance: 412.73046875, apexSpeed: 98.5, brakingStartDistance: 350.1, exitDistance: 470.2, severity: "slow" }, + deltaLossS: 0.12, + braking: { brakingPointDiffM: -3.25, peakBrakeDiff: 0.05, trailBrakingPrimary: true, trailBrakingComparison: false }, + throttle: { throttleOnDiffM: 4.5, coastingTimePrimaryS: 0.2, coastingTimeComparisonS: 0.1, throttleOscillationPrimary: 1, throttleOscillationComparison: 0 }, + lineDeviation: { maxLateralDeviationM: 1.23, avgLateralDeviationM: 0.45 }, + }, + { + corner: { index: 1, apexDistance: 1234.56, apexSpeed: 210, brakingStartDistance: 1180, exitDistance: 1300, severity: "fast" }, + deltaLossS: 0.08, + braking: { brakingPointDiffM: null, peakBrakeDiff: null, trailBrakingPrimary: false, trailBrakingComparison: false }, + throttle: { throttleOnDiffM: null, coastingTimePrimaryS: 0, coastingTimeComparisonS: 0, throttleOscillationPrimary: 0, throttleOscillationComparison: 0 }, + lineDeviation: null, + }, + ], + tyreWindowExcursions: [{ wheel: "frontLeft", distanceStart: 100, distanceEnd: 300, avgTempC: 112.4, status: "hot" }], + }; +} + +function mismatchPath(stored: unknown, computed: unknown): string | undefined { + const result = insightsMatch(stored, computed); + return result.match ? undefined : result.path; +} + +test("objetos iguais batem", () => { + assert.deepEqual(insightsMatch(insights(), insights()), { match: true }); +}); + +test("uma virada do round2 (0,35 contra 0,36) está dentro da tolerância", () => { + const computed = insights(); + computed.totalDeltaS = 0.36; + assert.equal(mismatchPath(insights(), computed), undefined); +}); + +test("0,35 contra 0,37 está fora da tolerância", () => { + const computed = insights(); + computed.totalDeltaS = 0.37; + assert.equal(mismatchPath(insights(), computed), "totalDeltaS"); +}); + +test("1234,56 contra 1234,57 está dentro da tolerância", () => { + const computed = insights(); + computed.corners[1].corner.apexDistance = 1234.57; + assert.equal(mismatchPath(insights(), computed), undefined); +}); + +test("uma distância copiada diferente em um ulp bate", () => { + const computed = insights(); + const x = computed.corners[0].corner.apexDistance; + computed.corners[0].corner.apexDistance = x + Number.EPSILON * x; + assert.equal(mismatchPath(insights(), computed), undefined); +}); + +test("uma curva a mais não bate", () => { + const computed = insights(); + computed.corners.push(computed.corners[1]); + assert.equal(mismatchPath(insights(), computed), "corners"); +}); + +test("um booleano trocado não bate", () => { + const computed = insights(); + computed.corners[0].braking.trailBrakingPrimary = false; + assert.equal(mismatchPath(insights(), computed), "corners[0].braking.trailBrakingPrimary"); +}); + +test("severity trocada não bate", () => { + const computed = insights(); + computed.corners[1].corner.severity = "medium"; + assert.equal(mismatchPath(insights(), computed), "corners[1].corner.severity"); +}); + +test("null contra um número não bate", () => { + const computed = insights(); + computed.corners[1].braking.brakingPointDiffM = 0; + assert.equal(mismatchPath(insights(), computed), "corners[1].braking.brakingPointDiffM"); +}); + +test("uma chave faltando ou sobrando não bate", () => { + const missing = insights() as unknown as Record; + delete missing.tyreWindowExcursions; + assert.equal(mismatchPath(insights(), missing), "tyreWindowExcursions"); + + const extra = { ...insights(), usage: 1 }; + assert.equal(mismatchPath(insights(), extra), "usage"); +}); + +test("a falha aponta o primeiro caminho diferente, com os dois valores", () => { + const computed = insights(); + computed.corners[1].deltaLossS = 0.5; + computed.tyreWindowExcursions[0].avgTempC = 140; + assert.deepEqual(insightsMatch(insights(), computed), { + match: false, + path: "corners[1].deltaLossS", + stored: 0.08, + computed: 0.5, + }); +}); diff --git a/src/demo/insightsMatch.ts b/src/demo/insightsMatch.ts new file mode 100644 index 0000000..09c3b2b --- /dev/null +++ b/src/demo/insightsMatch.ts @@ -0,0 +1,50 @@ +export type InsightsMatch = { match: true } | { match: false; path: string; stored: unknown; computed: unknown }; + +// O cache é gerado no Windows da mantenedora e conferido no Linux da +// hospedagem, possivelmente com outro V8: a comparação não pode ser texto +// com todas as casas. 0,01 absorve uma virada de fronteira do round2 (o +// coach recebe os valores calculados já arredondados a 2 casas) e 1e-9 +// absorve a representação binária desse passo (0,36 - 0,35 = +// 0,010000000000000009). research.md, ponto 7. +const TOLERANCE = 0.01 + 1e-9; + +function join(path: string, key: string): string { + return path ? `${path}.${key}` : key; +} + +function compare(stored: unknown, computed: unknown, path: string): InsightsMatch { + const mismatch: InsightsMatch = { match: false, path, stored, computed }; + + if (typeof stored === "number" && typeof computed === "number") { + return Math.abs(stored - computed) <= TOLERANCE ? { match: true } : mismatch; + } + + if (Array.isArray(stored) || Array.isArray(computed)) { + if (!Array.isArray(stored) || !Array.isArray(computed) || stored.length !== computed.length) return mismatch; + for (let i = 0; i < stored.length; i += 1) { + const result = compare(stored[i], computed[i], `${path}[${i}]`); + if (!result.match) return result; + } + return { match: true }; + } + + if (stored !== null && computed !== null && typeof stored === "object" && typeof computed === "object") { + const a = stored as Record; + const b = computed as Record; + const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); + for (const key of keys) { + if (!(key in a) || !(key in b)) return { match: false, path: join(path, key), stored: a[key], computed: b[key] }; + } + for (const key of keys) { + const result = compare(a[key], b[key], join(path, key)); + if (!result.match) return result; + } + return { match: true }; + } + + return stored === computed ? { match: true } : mismatch; +} + +export function insightsMatch(stored: unknown, computed: unknown): InsightsMatch { + return compare(stored, computed, ""); +} diff --git a/src/demo/liveConnections.test.ts b/src/demo/liveConnections.test.ts new file mode 100644 index 0000000..cfbec93 --- /dev/null +++ b/src/demo/liveConnections.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { decideCapacity, decideOrigin } from "./liveConnections"; + +const ALLOWED = "https://exemplo.github.io"; + +test("aceita a origem configurada, exatamente igual", () => { + assert.equal(decideOrigin(ALLOWED, ALLOWED), "accept"); +}); + +test("aceita um handshake sem Origin (cliente fora do navegador)", () => { + assert.equal(decideOrigin(undefined, ALLOWED), "accept"); +}); + +test("recusa outra origem, a origem com barra no fim e o host em outra caixa", () => { + assert.equal(decideOrigin("https://outro.example", ALLOWED), "refuse"); + assert.equal(decideOrigin(`${ALLOWED}/`, ALLOWED), "refuse"); + assert.equal(decideOrigin("https://Exemplo.github.io", ALLOWED), "refuse"); +}); + +test("abaixo do limite aceita; no limite, lotado", () => { + for (const max of [1, 5]) { + for (let open = 0; open < max; open += 1) { + assert.equal(decideCapacity(open, max), "accept", `open=${open} max=${max}`); + } + assert.equal(decideCapacity(max, max), "full", `open=${max} max=${max}`); + } +}); diff --git a/src/demo/liveConnections.ts b/src/demo/liveConnections.ts new file mode 100644 index 0000000..87eb0b9 --- /dev/null +++ b/src/demo/liveConnections.ts @@ -0,0 +1,11 @@ +// Sem Origin é um cliente fora do navegador: a regra de origem só limita +// navegadores, já que qualquer outro cliente manda o Origin que quiser +// (spec, FR-018). Comparação exata, sem normalizar caixa nem barra. +export function decideOrigin(origin: string | undefined, allowedOrigin: string): "accept" | "refuse" { + if (origin === undefined) return "accept"; + return origin === allowedOrigin ? "accept" : "refuse"; +} + +export function decideCapacity(openConnections: number, maxConnections: number): "accept" | "full" { + return openConnections < maxConnections ? "accept" : "full"; +} diff --git a/src/demo/playerOnly.test.ts b/src/demo/playerOnly.test.ts new file mode 100644 index 0000000..a3d3069 --- /dev/null +++ b/src/demo/playerOnly.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { TelemetryWsMessage } from "../ws/messageFormat"; +import { toPlayerOnlyMessage } from "./playerOnly"; + +function message22Cars(): TelemetryWsMessage<{ car: number }> { + return { + type: "carTelemetry", + sessionUID: "1026725433292396341", + sessionTime: 12.5, + frameIdentifier: 640, + playerCarIndex: 7, + data: Array.from({ length: 22 }, (_, car) => ({ car })), + }; +} + +test("a mensagem do modo demo leva só o carro do jogador, no índice 0", () => { + const result = toPlayerOnlyMessage(message22Cars()); + + assert.deepEqual(result.data, [{ car: 7 }]); + assert.equal(result.playerCarIndex, 0); +}); + +test("os outros campos do envelope não mudam", () => { + const original = message22Cars(); + const result = toPlayerOnlyMessage(original); + + assert.equal(result.type, original.type); + assert.equal(result.sessionUID, original.sessionUID); + assert.equal(result.sessionTime, original.sessionTime); + assert.equal(result.frameIdentifier, original.frameIdentifier); +}); + +test("a mensagem original e o array dela não são alterados", () => { + const original = message22Cars(); + toPlayerOnlyMessage(original); + + assert.equal(original.playerCarIndex, 7); + assert.equal(original.data.length, 22); + assert.deepEqual(original.data[7], { car: 7 }); +}); diff --git a/src/demo/playerOnly.ts b/src/demo/playerOnly.ts new file mode 100644 index 0000000..e0a5427 --- /dev/null +++ b/src/demo/playerOnly.ts @@ -0,0 +1,8 @@ +import { TelemetryWsMessage } from "../ws/messageFormat"; + +// No modo demo cada mensagem leva só o carro do jogador (17x menos bytes, +// research.md ponto 6). O formato do envelope não muda; playerCarIndex passa +// a ser 0 porque é o índice desse carro no array de um elemento. +export function toPlayerOnlyMessage(message: TelemetryWsMessage): TelemetryWsMessage { + return { ...message, data: [message.data[message.playerCarIndex]], playerCarIndex: 0 }; +} diff --git a/src/demo/purityBoundary.test.ts b/src/demo/purityBoundary.test.ts new file mode 100644 index 0000000..6b12302 --- /dev/null +++ b/src/demo/purityBoundary.test.ts @@ -0,0 +1,42 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Os módulos puros do modo demo não podem carregar persistence/db: ele abre a +// conexão no import, e um teste puro sem testDb abriria o banco do DB_PATH ou +// o data/telemetry.db. Se a fronteira quebrar, o DB_PATH temporário abaixo +// garante que nem este teste toca no banco real. +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-purity-")); +process.env.DB_PATH = path.join(tempDir, "nunca-deveria-abrir.db"); +const dbModule = require.resolve("../persistence/db"); + +after(() => { + if (require.cache[dbModule]) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + (require(dbModule) as { closeDb(): void }).closeDb(); + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +const PURE_MODULES = [ + "./captureTimes", + "./coachSeed", + "./demoLog", + "./insightsMatch", + "./playerOnly", + "./liveConnections", + "./recording", + "../config/demoEnv", + "../config/mode", + "../config/projectRoot", +]; + +test("os módulos puros do modo demo não carregam persistence/db", () => { + for (const name of PURE_MODULES) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require(name); + assert.ok(!(dbModule in require.cache), `${name} carregou persistence/db`); + } +}); diff --git a/src/demo/recording.test.ts b/src/demo/recording.test.ts new file mode 100644 index 0000000..7cff154 --- /dev/null +++ b/src/demo/recording.test.ts @@ -0,0 +1,163 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { findProjectRoot } from "../config/projectRoot"; +import { + PUBLISHED_RECORDING_SHA256, + PUBLISHED_RECORDING_URL, + ensureRecording, + parseRecording, + sha256Hex, + verifyRecording, +} from "./recording"; + +const tempDirs: string[] = []; +after(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tempDest(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-recording-")); + tempDirs.push(dir); + return path.join(dir, "sub", "gravacao.jsonl"); +} + +const CONTENT = Buffer.from('{"timestampMs":0,"data":"AQID"}\n'); +const URL = "https://exemplo.invalid/gravacao.jsonl"; + +function fakeFetch(body: Buffer, status = 200) { + const calls: string[] = []; + const fn = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response(body, { status }); + }) as typeof fetch; + return { fn, calls }; +} + +test("ensureRecording: arquivo presente com o hash certo não baixa nada", async () => { + const dest = tempDest(); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, CONTENT); + const { fn, calls } = fakeFetch(Buffer.from("outro")); + + assert.equal(await ensureRecording(dest, fn, { url: URL, expectedSha256: sha256Hex(CONTENT) }), "present"); + assert.deepEqual(calls, []); +}); + +test("ensureRecording: arquivo ausente é baixado, conferido e gravado, criando a pasta", async () => { + const dest = tempDest(); + const { fn, calls } = fakeFetch(CONTENT); + + assert.equal(await ensureRecording(dest, fn, { url: URL, expectedSha256: sha256Hex(CONTENT) }), "downloaded"); + assert.deepEqual(calls, [URL]); + assert.deepEqual(fs.readFileSync(dest), CONTENT); + assert.ok(!fs.existsSync(`${dest}.part`), "o temporário ficou para trás"); +}); + +test("ensureRecording: download com hash errado falha e não deixa arquivo nenhum", async () => { + const dest = tempDest(); + const { fn } = fakeFetch(Buffer.from("conteúdo errado")); + const expected = sha256Hex(CONTENT); + + await assert.rejects(ensureRecording(dest, fn, { url: URL, expectedSha256: expected }), { + message: `[demo] gravação não confere com o SHA-256 esperado (esperado ${expected}, obtido ${sha256Hex(Buffer.from("conteúdo errado"))}).`, + }); + assert.ok(!fs.existsSync(dest), "ficou um arquivo no destino"); + assert.ok(!fs.existsSync(`${dest}.part`), "o temporário ficou para trás"); +}); + +test("ensureRecording: status HTTP diferente de 2xx falha com mensagem clara", async () => { + const dest = tempDest(); + const { fn } = fakeFetch(Buffer.from("não encontrado"), 404); + + await assert.rejects(ensureRecording(dest, fn, { url: URL, expectedSha256: sha256Hex(CONTENT) }), { + message: `[demo] download da gravação falhou: HTTP 404 em ${URL}`, + }); + assert.ok(!fs.existsSync(dest)); + assert.ok(!fs.existsSync(`${dest}.part`)); +}); + +test("ensureRecording: arquivo existente com hash errado é substituído pelo download conferido, com log", async () => { + const dest = tempDest(); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, "antigo e errado"); + const { fn } = fakeFetch(CONTENT); + + const logged: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => logged.push(args.join(" ")); + try { + assert.equal(await ensureRecording(dest, fn, { url: URL, expectedSha256: sha256Hex(CONTENT) }), "downloaded"); + } finally { + console.log = originalLog; + } + assert.deepEqual(fs.readFileSync(dest), CONTENT); + assert.ok( + logged.some((line) => line.includes("tinha SHA-256 errado") && line.includes("substituída")), + `log: ${JSON.stringify(logged)}`, + ); +}); + +test("ensureRecording: se o download falha, o arquivo existente fica como estava", async () => { + const dest = tempDest(); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.writeFileSync(dest, "antigo e errado"); + const { fn } = fakeFetch(Buffer.from("também errado")); + + await assert.rejects(ensureRecording(dest, fn, { url: URL, expectedSha256: sha256Hex(CONTENT) })); + assert.equal(fs.readFileSync(dest, "utf8"), "antigo e errado"); + assert.ok(!fs.existsSync(`${dest}.part`)); +}); + +test("ensureRecording sem opções usa a URL da Release e o SHA-256 publicado", async () => { + const dest = tempDest(); + const { fn, calls } = fakeFetch(CONTENT); + + await assert.rejects(ensureRecording(dest, fn), { + message: `[demo] gravação não confere com o SHA-256 esperado (esperado ${PUBLISHED_RECORDING_SHA256}, obtido ${sha256Hex(CONTENT)}).`, + }); + assert.deepEqual(calls, [PUBLISHED_RECORDING_URL]); + assert.equal( + PUBLISHED_RECORDING_URL, + "https://github.com/AnaLauraFFerraz/TelemetryF1_API/releases/download/sample-recording-v1/demo-interlagos-tt-20hz-laps6-8.jsonl", + ); +}); + +test("parseRecording decodifica timestampMs, receivedAt e o pacote em base64 de cada linha", () => { + const text = [ + JSON.stringify({ timestampMs: 0, receivedAt: 1792168223797, size: 3, data: Buffer.from([1, 2, 3]).toString("base64") }), + "", + JSON.stringify({ timestampMs: 50, size: 2, data: Buffer.from([9, 8]).toString("base64") }), + "", + ].join("\n"); + + const packets = parseRecording(text); + + assert.equal(packets.length, 2); + assert.equal(packets[0].timestampMs, 0); + assert.equal(packets[0].receivedAt, 1792168223797); + assert.deepEqual([...packets[0].data], [1, 2, 3]); + assert.equal(packets[1].timestampMs, 50); + assert.equal(packets[1].receivedAt, undefined); + assert.deepEqual([...packets[1].data], [9, 8]); +}); + +test("sha256Hex calcula o SHA-256 em hexadecimal minúsculo", () => { + assert.equal(sha256Hex(Buffer.from("abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +}); + +test("verifyRecording recusa um conteúdo que não confere com o hash esperado", () => { + const data = Buffer.from("abc"); + const wrong = "0".repeat(64); + assert.throws(() => verifyRecording(data, wrong), { + message: `[demo] gravação não confere com o SHA-256 esperado (esperado ${wrong}, obtido ${sha256Hex(data)}).`, + }); + assert.doesNotThrow(() => verifyRecording(data, sha256Hex(data))); +}); + +test("o README publica o mesmo SHA-256 da gravação que o código confere", () => { + const readme = fs.readFileSync(path.join(findProjectRoot(), "README.md"), "utf8"); + assert.ok(readme.includes(PUBLISHED_RECORDING_SHA256), "o README não contém PUBLISHED_RECORDING_SHA256"); +}); diff --git a/src/demo/recording.ts b/src/demo/recording.ts new file mode 100644 index 0000000..ee6a92d --- /dev/null +++ b/src/demo/recording.ts @@ -0,0 +1,83 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { RecordedPacket } from "../replay/types"; + +export const PUBLISHED_RECORDING_SHA256 = "c41bdfaf7ff0feb40702875b15031654742de5fdb10ea3a05c0a21f1ac70b033"; + +export const PUBLISHED_RECORDING_URL = + "https://github.com/AnaLauraFFerraz/TelemetryF1_API/releases/download/sample-recording-v1/demo-interlagos-tt-20hz-laps6-8.jsonl"; + +// receivedAt é a hora absoluta da captura (epoch ms) que tools/recorder.ts +// grava em cada linha; o modo demo tira dele as datas da sessão. +export interface DemoPacket extends RecordedPacket { + receivedAt: number | undefined; +} + +export function parseRecording(text: string): DemoPacket[] { + const packets: DemoPacket[] = []; + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const { timestampMs, receivedAt, data } = JSON.parse(line) as { timestampMs: number; receivedAt?: number; data: string }; + packets.push({ timestampMs, receivedAt: typeof receivedAt === "number" ? receivedAt : undefined, data: Buffer.from(data, "base64") }); + } + return packets; +} + +export function sha256Hex(data: Buffer): string { + return createHash("sha256").update(data).digest("hex"); +} + +export function verifyRecording(data: Buffer, expectedSha256: string): void { + const actual = sha256Hex(data); + if (actual !== expectedSha256) { + throw new Error(`[demo] gravação não confere com o SHA-256 esperado (esperado ${expectedSha256}, obtido ${actual}).`); + } +} + +export interface EnsureRecordingOptions { + url?: string; + expectedSha256?: string; +} + +// O download vai para .part na mesma pasta e só substitui o destino +// depois de conferido: um download que falha (rede, HTTP, hash) nunca deixa +// um arquivo parcial nem estraga um arquivo que já existia. +export async function ensureRecording( + destPath: string, + fetchFn: typeof fetch, + options: EnsureRecordingOptions = {} +): Promise<"present" | "downloaded"> { + const url = options.url ?? PUBLISHED_RECORDING_URL; + const expectedSha256 = options.expectedSha256 ?? PUBLISHED_RECORDING_SHA256; + + let staleHash: string | undefined; + if (fs.existsSync(destPath)) { + const existingHash = sha256Hex(fs.readFileSync(destPath)); + if (existingHash === expectedSha256) return "present"; + staleHash = existingHash; + } + + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + const partPath = `${destPath}.part`; + try { + const response = await fetchFn(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`[demo] download da gravação falhou: HTTP ${response.status} em ${url}`); + } + const body = Buffer.from(await response.arrayBuffer()); + verifyRecording(body, expectedSha256); + fs.writeFileSync(partPath, body); + fs.renameSync(partPath, destPath); + } catch (err) { + fs.rmSync(partPath, { force: true }); + throw err; + } + + if (staleHash !== undefined) { + console.log( + `[demo] a gravação existente em ${destPath} tinha SHA-256 errado (${staleHash}); substituída pelo download conferido.` + ); + } + return "downloaded"; +} diff --git a/src/demo/testFixtures.ts b/src/demo/testFixtures.ts new file mode 100644 index 0000000..49f7acf --- /dev/null +++ b/src/demo/testFixtures.ts @@ -0,0 +1,67 @@ +import { buildCarTelemetryPacket, buildLapDataPacket, buildMotionPacket, type HeaderFields } from "../replay/testFixtures"; +import type { DemoPacket } from "./recording"; + +export interface DemoRecordingFixture { + packets: DemoPacket[]; + sessionUID: bigint; + completedLaps: number[]; + lapTimesMs: Map; +} + +const TICKS_PER_LAP = 5; +const SECTOR1_MS = 20000; +const SECTOR2_MS = 25000; + +/** + * Gravação sintética do modo demo: pacotes binários de verdade (lap data, + * motion e car telemetry do mesmo builder de src/replay/testFixtures.ts), + * `laps` voltas completas e um último pacote que abre a volta seguinte, cada + * pacote com um receivedAt crescente (50 ms entre eles). Cada volta tem + * 5 ticks espaçados de 4 m, o limiar de decimação, então todos viram + * amostras. O primeiro pacote de cada volta nova traz o lastLapTimeInMS da + * anterior, que é o que fecha a volta no lapCompletionDetector. + */ +export function buildDemoRecording(options: { sessionUID: bigint; laps: number; firstReceivedAt?: number }): DemoRecordingFixture { + const { sessionUID, laps } = options; + const firstReceivedAt = options.firstReceivedAt ?? 1792168223797; + const packets: DemoPacket[] = []; + const lapTimesMs = new Map(); + let frame = 1; + + const push = (data: Buffer) => { + const index = packets.length; + packets.push({ timestampMs: index * 50, receivedAt: firstReceivedAt + index * 50, data }); + }; + + for (let lap = 1; lap <= laps + 1; lap += 1) { + const previousLapTime = lapTimesMs.get(lap - 1) ?? 0; + const ticks = lap <= laps ? TICKS_PER_LAP : 1; + for (let i = 0; i < ticks; i += 1) { + const header: HeaderFields = { packetId: 2, sessionTime: frame / 10, frameIdentifier: frame, overallFrameIdentifier: frame, sessionUID }; + const lapDistance = i * 4; + push( + buildLapDataPacket({ + header, + currentLapNum: lap, + lapDistance, + currentLapTimeInMS: i * 1000, + sector1TimeMSPart: lap <= laps ? SECTOR1_MS : 0, + sector1TimeMinutesPart: 0, + sector2TimeMSPart: lap <= laps ? SECTOR2_MS : 0, + sector2TimeMinutesPart: 0, + currentLapInvalid: 0, + carPosition: 1, + lastLapTimeInMS: i === 0 ? previousLapTime : 0, + }) + ); + if (lap <= laps) { + push(buildMotionPacket({ ...header, packetId: 0 }, lapDistance)); + push(buildCarTelemetryPacket({ ...header, packetId: 6 })); + } + frame += 1; + } + if (lap <= laps) lapTimesMs.set(lap, 63000 + lap * 100); + } + + return { packets, sessionUID, completedLaps: Array.from({ length: laps }, (_, i) => i + 1), lapTimesMs }; +} diff --git a/src/http/demoApp.test.ts b/src/http/demoApp.test.ts new file mode 100644 index 0000000..5c344bc --- /dev/null +++ b/src/http/demoApp.test.ts @@ -0,0 +1,177 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "../persistence/testDb"; +import { buildSyntheticRecording } from "../replay/testFixtures"; + +// O analysisRouter carrega config/env, que no modo local lê as três portas e +// o .env: as portas são fictícias (nada é aberto nelas), e o .env é um vazio +// temporário, para a chave do .env do repositório nunca chegar a este +// processo. O teste ainda confere que o coach não está configurado antes de +// pedir um par sem cache. +const envDir = fs.mkdtempSync(path.join(os.tmpdir(), "telemetryf1-demoapp-env-")); +fs.writeFileSync(path.join(envDir, ".env"), ""); +process.env.DOTENV_CONFIG_PATH = path.join(envDir, ".env"); +delete process.env.ANTHROPIC_API_KEY; +process.env.WS_PORT = "1"; +process.env.UDP_PORT = "1"; +process.env.HTTP_PORT = "1"; + +const handle = setupIsolatedTestDb(); +/* eslint-disable @typescript-eslint/no-var-requires */ +const { db } = require("../persistence/db") as typeof import("../persistence/db"); +const { dispatchPacket } = require("../telemetry/dispatcher") as typeof import("../telemetry/dispatcher"); +const { persistTelemetry } = require("../persistence/persistTelemetry") as typeof import("../persistence/persistTelemetry"); +const { listSessions } = require("../persistence/sessionRepository") as typeof import("../persistence/sessionRepository"); +const { insertAnalysis } = require("../persistence/analysisRepository") as typeof import("../persistence/analysisRepository"); +const { isCoachingConfigured } = require("../coaching/coachService") as typeof import("../coaching/coachService"); +const { createApp } = require("./httpServer") as typeof import("./httpServer"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const ALLOWED = "http://localhost:5173"; +const servers: http.Server[] = []; + +after(async () => { + for (const server of servers) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + teardownIsolatedTestDb(handle); + fs.rmSync(envDir, { recursive: true, force: true }); +}); + +// Sessão e par em cache montados só com a ingestão e os repositórios: nada +// em src/http/ importa de src/demo/, nem os testes. +for (const packet of buildSyntheticRecording().packets) { + const result = dispatchPacket(packet.data); + if (result) persistTelemetry(result.header, result.packetId, result.data); +} +const build = { sessionId: listSessions()[0].id }; +const seededInsights = { totalDeltaS: 0.12, corners: [], tyreWindowExcursions: [] }; +insertAnalysis(build.sessionId, 2, 1, JSON.stringify(seededInsights), "análise 2-1", "modelo-falso"); + +async function listen(app: ReturnType): Promise { + const server = http.createServer(app); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +// A revalidação usa node:http, não fetch: com If-None-Match no pedido, o +// fetch passa ao modo no-store e acrescenta Cache-Control: no-cache, e aí o +// Express nunca responde 304. O navegador revalidando o próprio cache não faz +// isso. +function revalidate(url: string, etag: string): Promise<{ status: number; bodyBytes: number }> { + return new Promise((resolve, reject) => { + http + .get(url, { headers: { "If-None-Match": etag } }, (res) => { + let bodyBytes = 0; + res.on("data", (chunk: Buffer) => (bodyBytes += chunk.length)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, bodyBytes })); + }) + .on("error", reject); + }); +} + +const analysisCount = () => (db.prepare("SELECT COUNT(*) AS n FROM lap_analyses").get() as { n: number }).n; + +const READ_PATHS = [ + "/sessions", + `/sessions/${build.sessionId}`, + `/sessions/${build.sessionId}/benchmark`, + `/sessions/${build.sessionId}/laps/1/telemetry`, +]; + +test("modo demo: CORS só para a origem permitida, e sem Origin o corpo é servido", async () => { + const base = await listen(createApp({ demo: { allowedOrigin: ALLOWED } })); + + const allowed = await fetch(`${base}/sessions`, { headers: { Origin: ALLOWED } }); + assert.equal(allowed.headers.get("access-control-allow-origin"), ALLOWED); + assert.match(allowed.headers.get("vary") ?? "", /Origin/); + + const foreign = await fetch(`${base}/sessions`, { headers: { Origin: "https://example.com" } }); + assert.equal(foreign.headers.get("access-control-allow-origin"), null); + + const noOrigin = await fetch(`${base}/sessions`); + assert.equal(noOrigin.status, 200); + assert.equal(((await noOrigin.json()) as unknown[]).length, 1); +}); + +test("modo demo: as leituras levam Cache-Control no-cache e ETag, e a revalidação devolve 304 sem corpo", async () => { + const base = await listen(createApp({ demo: { allowedOrigin: ALLOWED } })); + + for (const readPath of READ_PATHS) { + const first = await fetch(`${base}${readPath}`); + assert.equal(first.status, 200, readPath); + assert.equal(first.headers.get("cache-control"), "no-cache", readPath); + const etag = first.headers.get("etag"); + assert.ok(etag, `${readPath} sem ETag`); + await first.arrayBuffer(); + + const again = await revalidate(`${base}${readPath}`, etag); + assert.equal(again.status, 304, readPath); + assert.equal(again.bodyBytes, 0, readPath); + } +}); + +test("modo demo: o POST do coach é no-store, serve o par semeado e dá 503 sem escrever para outro par", async () => { + assert.equal(isCoachingConfigured(), false, "o coach não pode estar configurado neste teste"); + const base = await listen(createApp({ demo: { allowedOrigin: ALLOWED } })); + const post = (lapA: number, lapB: number) => + fetch(`${base}/sessions/${build.sessionId}/analysis`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lapA, lapB }), + }); + + const seeded = await post(2, 1); + assert.equal(seeded.status, 200); + assert.equal(seeded.headers.get("cache-control"), "no-store"); + assert.deepEqual(await seeded.json(), { analysis: "análise 2-1", insights: seededInsights, cached: true }); + + const before = analysisCount(); + const other = await post(1, 2); + assert.equal(other.status, 503); + assert.equal(other.headers.get("cache-control"), "no-store"); + assert.deepEqual(await other.json(), { error: "coaching indisponível: configure ANTHROPIC_API_KEY" }); + assert.equal(analysisCount(), before); +}); + +test("modo demo: /health responde sem depender da origem e nunca é cacheável", async () => { + let healthy = true; + const base = await listen(createApp({ demo: { allowedOrigin: ALLOWED }, isHealthy: () => healthy })); + + const variants: Array> = [{}, { Origin: "https://example.com" }]; + for (const headers of variants) { + const response = await fetch(`${base}/health`, { headers }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { status: "ok" }); + } + + healthy = false; + const stopped = await fetch(`${base}/health`); + assert.equal(stopped.status, 503); + assert.equal(stopped.headers.get("cache-control"), "no-store"); +}); + +test("modo local: /health existe e responde 200", async () => { + const base = await listen(createApp()); + const response = await fetch(`${base}/health`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: "ok" }); +}); + +test("modo local: createApp() sem opções mantém os cabeçalhos de hoje", async () => { + const base = await listen(createApp()); + + const response = await fetch(`${base}/sessions`, { headers: { Origin: "https://example.com" } }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("access-control-allow-origin"), "https://example.com"); + assert.equal(response.headers.get("cache-control"), null); + assert.ok(response.headers.get("etag")); +}); diff --git a/src/http/healthRouter.test.ts b/src/http/healthRouter.test.ts new file mode 100644 index 0000000..d5b2c1b --- /dev/null +++ b/src/http/healthRouter.test.ts @@ -0,0 +1,41 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express from "express"; +import { createHealthRouter } from "./routes/healthRouter"; + +const servers: http.Server[] = []; +after(async () => { + for (const server of servers) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +async function listen(isHealthy: () => boolean): Promise { + const app = express(); + app.use(createHealthRouter(isHealthy)); + const server = http.createServer(app); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +test("GET /health responde 200 ok enquanto está saudável, sem cache", async () => { + const base = await listen(() => true); + const response = await fetch(`${base}/health`); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { status: "ok" }); +}); + +test("GET /health responde 503 replay stopped quando o replay parou, sem cache", async () => { + let healthy = true; + const base = await listen(() => healthy); + healthy = false; + const response = await fetch(`${base}/health`); + assert.equal(response.status, 503); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { status: "replay stopped" }); +}); diff --git a/src/http/httpServer.ts b/src/http/httpServer.ts index 6d0ded5..649ae51 100644 --- a/src/http/httpServer.ts +++ b/src/http/httpServer.ts @@ -2,16 +2,53 @@ import express from "express"; import cors from "cors"; import { sessionsRouter } from "./routes/sessionsRouter"; import { analysisRouter } from "./routes/analysisRouter"; +import { createHealthRouter } from "./routes/healthRouter"; -export function startHttpServer(port: number): void { +// Declarado aqui, por quem consome: src/http/ nunca importa de src/demo/. +export interface RestCounter { + middleware: express.RequestHandler; + stats(): { restBytes: number; coach503: number }; +} + +export interface CreateAppOptions { + demo?: { allowedOrigin: string; restCounter?: RestCounter }; + isHealthy?: () => boolean; +} + +const READ_ROUTES = ["/sessions", "/sessions/:id", "/sessions/:id/benchmark", "/sessions/:id/laps/:lapNumber/telemetry"]; + +function cacheControl(value: string): express.RequestHandler { + return (_req, res, next) => { + res.setHeader("Cache-Control", value); + next(); + }; +} + +export function createApp(options: CreateAppOptions = {}): express.Express { const app = express(); - app.use(cors({ origin: true })); + if (options.demo) { + if (options.demo.restCounter) app.use(options.demo.restCounter.middleware); + // Lista, não string: com uma string o cors manda sempre a origem + // configurada, para qualquer Origin; com a lista, só quando ela bate. + app.use(cors({ origin: [options.demo.allowedOrigin] })); + // no-cache + o ETag padrão do Express: o navegador sempre revalida e + // recebe 304 enquanto os dados não mudam, e um deploy novo muda o ETag. + app.get(READ_ROUTES, cacheControl("no-cache")); + app.post("/sessions/:id/analysis", cacheControl("no-store")); + } else { + app.use(cors({ origin: true })); + } app.use(express.json()); + app.use(createHealthRouter(options.isHealthy ?? (() => true))); app.use(sessionsRouter); app.use(analysisRouter); - app.listen(port, () => { + return app; +} + +export function startHttpServer(port: number): void { + createApp().listen(port, () => { console.log(`[httpServer] servidor REST em http://localhost:${port}`); }); } diff --git a/src/http/routes/healthRouter.ts b/src/http/routes/healthRouter.ts new file mode 100644 index 0000000..8f818c1 --- /dev/null +++ b/src/http/routes/healthRouter.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; + +export function createHealthRouter(isHealthy: () => boolean): Router { + const router = Router(); + + router.get("/health", (_req, res) => { + res.setHeader("Cache-Control", "no-store"); + if (isHealthy()) { + res.json({ status: "ok" }); + } else { + res.status(503).json({ status: "replay stopped" }); + } + }); + + return router; +} diff --git a/src/network/wsBroadcaster.ts b/src/network/wsBroadcaster.ts index 1e41071..cd4eb38 100644 --- a/src/network/wsBroadcaster.ts +++ b/src/network/wsBroadcaster.ts @@ -6,6 +6,28 @@ export interface WsBroadcaster { const HEARTBEAT_INTERVAL_MS = 15_000; +// Marca o socket como "vivo" a cada pong recebido - se não responder a +// dois ciclos de heartbeat seguidos, é considerado morto e derrubado +// (ex: PC suspendeu, rede caiu sem um close/FIN limpo). Devolve a função +// que para o heartbeat, chamada no close. +export function startHeartbeat(socket: WebSocket): () => void { + let isAlive = true; + socket.on("pong", () => { + isAlive = true; + }); + + const heartbeat = setInterval(() => { + if (!isAlive) { + socket.terminate(); + return; + } + isAlive = false; + socket.ping(); + }, HEARTBEAT_INTERVAL_MS); + + return () => clearInterval(heartbeat); +} + /** * Mantém a lista de clientes WebSocket conectados e expõe um único método * `broadcast` para mandar a mesma mensagem (JSON) para todos eles. @@ -18,25 +40,10 @@ export function startWsBroadcaster(port: number): WsBroadcaster { clients.add(socket); console.log(`[wsBroadcaster] cliente conectado (${clients.size} no total)`); - // Marca o socket como "vivo" a cada pong recebido - se não responder a - // dois ciclos de heartbeat seguidos, é considerado morto e derrubado - // (ex: PC suspendeu, rede caiu sem um close/FIN limpo). - let isAlive = true; - socket.on("pong", () => { - isAlive = true; - }); - - const heartbeat = setInterval(() => { - if (!isAlive) { - socket.terminate(); - return; - } - isAlive = false; - socket.ping(); - }, HEARTBEAT_INTERVAL_MS); + const stopHeartbeat = startHeartbeat(socket); socket.on("close", () => { - clearInterval(heartbeat); + stopHeartbeat(); clients.delete(socket); console.log(`[wsBroadcaster] cliente desconectado (${clients.size} no total)`); }); diff --git a/src/persistence/db.ts b/src/persistence/db.ts index d82e50a..e61c98e 100644 --- a/src/persistence/db.ts +++ b/src/persistence/db.ts @@ -57,6 +57,12 @@ db.exec(` ON motion_samples(session_id, overall_frame_identifier); `); +// Modo demo: depois de montar a sessão e semear o cache, qualquer escrita +// falha dentro do próprio SQLite, sem depender de revisar cada rota. +export function setReadOnly(): void { + db.pragma("query_only = ON"); +} + export function closeDb(): void { db.close(); } diff --git a/src/persistence/lapRepository.ts b/src/persistence/lapRepository.ts index 913afd8..b81d4e7 100644 --- a/src/persistence/lapRepository.ts +++ b/src/persistence/lapRepository.ts @@ -37,6 +37,15 @@ export function upsertCompletedLap(sessionId: number, lap: CompletedLap): void { }); } +const applyLapCaptureTimeStmt = db.prepare<{ sessionId: number; lapNumber: number; recordedAt: string }>( + `UPDATE laps SET recorded_at = @recordedAt WHERE session_id = @sessionId AND lap_number = @lapNumber` +); + +// Par de sessionRepository.applySessionCaptureTimes: só o modo demo chama. +export function applyLapCaptureTimes(sessionId: number, lapNumber: number, recordedAt: string): void { + applyLapCaptureTimeStmt.run({ sessionId, lapNumber, recordedAt }); +} + export interface LapRow { lapNumber: number; lapTimeMs: number; diff --git a/src/persistence/persistTelemetry.ts b/src/persistence/persistTelemetry.ts index 47f7997..929c929 100644 --- a/src/persistence/persistTelemetry.ts +++ b/src/persistence/persistTelemetry.ts @@ -4,34 +4,36 @@ import { PacketSessionData } from "../telemetry/session/types"; import { PacketLapData } from "../telemetry/lapData/types"; import { PacketCarTelemetryData } from "../telemetry/carTelemetry/types"; import { handleLapDataPacket, handleMotionPacket, handleCarTelemetryPacket, handleSessionPacket } from "./sessionContext"; +import { CompletedLap } from "./types"; // Entry point único chamado por server.ts a cada pacote dispatchado. Sem SQL // nem regra de negócio própria - só roteia por packetId para sessionContext. -export function persistTelemetry(header: PacketHeader, packetId: number, data: unknown): void { +export function persistTelemetry(header: PacketHeader, packetId: number, data: unknown): CompletedLap | null { // sessionUID=0 é o que o jogo manda fora de uma sessão de verdade (menus) - // persistir isso criaria uma "sessão 0" fantasma no banco. - if (header.sessionUID === 0n) return; + if (header.sessionUID === 0n) return null; if (packetId === 1) { handleSessionPacket(header, data as PacketSessionData); - return; + return null; } if (packetId === 2) { const lapData = data as PacketLapData; - handleLapDataPacket(header, lapData.lapData[header.playerCarIndex]); - return; + return handleLapDataPacket(header, lapData.lapData[header.playerCarIndex]); } if (packetId === 0) { const motion = data as PacketMotionData; handleMotionPacket(header, motion.carMotionData[header.playerCarIndex]); - return; + return null; } if (packetId === 6) { const carTelemetry = data as PacketCarTelemetryData; handleCarTelemetryPacket(header, carTelemetry.carTelemetryData[header.playerCarIndex]); - return; + return null; } + + return null; } diff --git a/src/persistence/persistTelemetryCompletion.test.ts b/src/persistence/persistTelemetryCompletion.test.ts new file mode 100644 index 0000000..44f535e --- /dev/null +++ b/src/persistence/persistTelemetryCompletion.test.ts @@ -0,0 +1,34 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "./testDb"; +import { dispatchPacket } from "../telemetry/dispatcher"; +import { buildSyntheticRecording } from "../replay/testFixtures"; + +const handle = setupIsolatedTestDb(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { persistTelemetry } = require("./persistTelemetry") as typeof import("./persistTelemetry"); + +after(() => teardownIsolatedTestDb(handle)); + +test("persistTelemetry devolve a volta concluída só no pacote que a fecha", () => { + const { packets, expectedLap } = buildSyntheticRecording(); + + const results = packets.map((packet) => { + const result = dispatchPacket(packet.data); + assert.ok(result, "pacote sintético não reconhecido pelo dispatcher"); + return persistTelemetry(result.header, result.packetId, result.data); + }); + + const lastIndex = results.length - 1; + assert.deepEqual( + results.slice(0, lastIndex).filter((r) => r !== null), + [], + "algum pacote antes do fechamento devolveu uma volta", + ); + + const closing = results[lastIndex]; + assert.ok(closing, "o pacote que fecha a volta não devolveu nada"); + assert.equal(closing.lapNumber, expectedLap.lapNumber); + assert.equal(closing.lapTimeMs, expectedLap.lapTimeMs); + assert.equal(closing.isValid, expectedLap.isValid); +}); diff --git a/src/persistence/readOnly.test.ts b/src/persistence/readOnly.test.ts new file mode 100644 index 0000000..399daca --- /dev/null +++ b/src/persistence/readOnly.test.ts @@ -0,0 +1,33 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { setupIsolatedTestDb, teardownIsolatedTestDb } from "./testDb"; +import { buildSyntheticRecording } from "../replay/testFixtures"; +import type { DemoPacket } from "../demo/recording"; + +const handle = setupIsolatedTestDb(); +/* eslint-disable @typescript-eslint/no-var-requires */ +const { db, setReadOnly } = require("./db") as typeof import("./db"); +const { buildDemoSession } = require("../demo/buildDemoSession") as typeof import("../demo/buildDemoSession"); +const { insertAnalysis } = require("./analysisRepository") as typeof import("./analysisRepository"); +const { getSessionById } = require("./sessionRepository") as typeof import("./sessionRepository"); +/* eslint-enable @typescript-eslint/no-var-requires */ + +after(() => teardownIsolatedTestDb(handle)); + +const TABLES = ["sessions", "laps", "car_telemetry_samples", "motion_samples", "lap_analyses"]; + +function rowCounts(): Record { + return Object.fromEntries(TABLES.map((t) => [t, (db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get() as { n: number }).n])); +} + +test("depois de setReadOnly, uma escrita falha e nenhuma tabela muda; leituras seguem funcionando", () => { + const { packets } = buildSyntheticRecording(); + const build = buildDemoSession(packets.map((p): DemoPacket => ({ ...p, receivedAt: 1792168223797 }))); + const before = rowCounts(); + + setReadOnly(); + + assert.throws(() => insertAnalysis(build.sessionId, 1, 2, "{}", "texto", "modelo-falso"), /readonly|read-only|attempt to write/i); + assert.deepEqual(rowCounts(), before); + assert.equal(getSessionById(build.sessionId)?.id, build.sessionId); +}); diff --git a/src/persistence/sessionContext.ts b/src/persistence/sessionContext.ts index 9111ad7..00c89d2 100644 --- a/src/persistence/sessionContext.ts +++ b/src/persistence/sessionContext.ts @@ -9,7 +9,7 @@ import { insertCarTelemetrySample } from "./carTelemetrySampleRepository"; import { insertMotionSample } from "./motionSampleRepository"; import { createLapCompletionDetector, LapCompletionDetector } from "./lapCompletionDetector"; import { shouldPersistSample, DecimationState } from "./decimation"; -import { LapProgressSnapshot } from "./types"; +import { CompletedLap, LapProgressSnapshot } from "./types"; interface ActiveSession { sessionId: number; @@ -104,7 +104,7 @@ export function handleSessionPacket(header: PacketHeader, session: PacketSession active.trackId = session.trackId; } -export function handleLapDataPacket(header: PacketHeader, player: LapData): void { +export function handleLapDataPacket(header: PacketHeader, player: LapData): CompletedLap | null { const active = getActiveSession(header); const completed = active.lapDetector.process(player); @@ -117,6 +117,8 @@ export function handleLapDataPacket(header: PacketHeader, player: LapData): void lapDistance: player.lapDistance, sessionTime: header.sessionTime, }; + + return completed; } export function handleMotionPacket(header: PacketHeader, player: CarMotionData): void { diff --git a/src/persistence/sessionRepository.ts b/src/persistence/sessionRepository.ts index 1ff6615..5acc88b 100644 --- a/src/persistence/sessionRepository.ts +++ b/src/persistence/sessionRepository.ts @@ -53,6 +53,16 @@ export function setSessionTrackId(id: number, trackId: number): void { setTrackIdStmt.run({ id, trackId }); } +const applyCaptureTimesStmt = db.prepare<{ id: number; startedAt: string; lastSeenAt: string }>( + `UPDATE sessions SET started_at = @startedAt, last_seen_at = @lastSeenAt WHERE id = @id` +); + +// Só o modo demo chama isto, uma vez, depois de montar a sessão: as datas +// passam a ser as da captura (receivedAt), não as do relógio do servidor. +export function applySessionCaptureTimes(id: number, startedAt: string, lastSeenAt: string): void { + applyCaptureTimesStmt.run({ id, startedAt, lastSeenAt }); +} + export interface SessionSummary { id: number; sessionUid: string; diff --git a/src/replay/testFixtures.ts b/src/replay/testFixtures.ts index 6d9607f..5699390 100644 --- a/src/replay/testFixtures.ts +++ b/src/replay/testFixtures.ts @@ -56,7 +56,7 @@ class WriteCursor { const PLAYER_CAR_INDEX = 0; -interface HeaderFields { +export interface HeaderFields { packetId: number; sessionTime: number; frameIdentifier: number; @@ -79,7 +79,7 @@ function writeHeader(cursor: WriteCursor, fields: HeaderFields): void { cursor.writeUInt8(255); // secondaryPlayerCarIndex: nenhum segundo jogador } -interface SyntheticLapDataTick { +export interface SyntheticLapDataTick { header: HeaderFields; currentLapNum: number; lapDistance: number; @@ -93,7 +93,7 @@ interface SyntheticLapDataTick { lastLapTimeInMS: number; } -function buildLapDataPacket(tick: SyntheticLapDataTick): Buffer { +export function buildLapDataPacket(tick: SyntheticLapDataTick): Buffer { const buffer = Buffer.alloc(LAP_DATA_PACKET_SIZE); const cursor = new WriteCursor(buffer); writeHeader(cursor, tick.header); @@ -144,7 +144,7 @@ function buildLapDataPacket(tick: SyntheticLapDataTick): Buffer { return buffer; } -function buildMotionPacket(header: HeaderFields, lapDistance: number): Buffer { +export function buildMotionPacket(header: HeaderFields, lapDistance: number): Buffer { const buffer = Buffer.alloc(MOTION_PACKET_SIZE); const cursor = new WriteCursor(buffer); writeHeader(cursor, header); @@ -177,7 +177,7 @@ function buildMotionPacket(header: HeaderFields, lapDistance: number): Buffer { return buffer; } -function buildCarTelemetryPacket(header: HeaderFields): Buffer { +export function buildCarTelemetryPacket(header: HeaderFields): Buffer { const buffer = Buffer.alloc(CAR_TELEMETRY_PACKET_SIZE); const cursor = new WriteCursor(buffer); writeHeader(cursor, header); diff --git a/src/replay/types.ts b/src/replay/types.ts index 8515291..30887e4 100644 --- a/src/replay/types.ts +++ b/src/replay/types.ts @@ -7,7 +7,7 @@ export interface ReplayOptions { // Uma linha decodificada de um .jsonl gravado por tools/recorder.ts - // timestampMs é o offset desde o início da gravação, data é o pacote UDP -// cru. tools/replayer.ts é o único lugar que ainda lida com base64/JSON; +// cru. Só tools/replayer.ts e src/demo/recording.ts lidam com base64/JSON; // daqui pra baixo é só isso. export interface RecordedPacket { timestampMs: number; diff --git a/src/server.ts b/src/server.ts index 186899e..cf6b7f7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,109 +1,142 @@ -import { env } from "./config/env"; -import { startUdpListener } from "./network/udpListener"; -import { startWsBroadcaster } from "./network/wsBroadcaster"; -import { startHttpServer } from "./http/httpServer"; -import { dispatchPacket } from "./telemetry/dispatcher"; -import { packetName } from "./telemetry/packetIds"; -import { NUM_CARS } from "./telemetry/shared/constants"; -import { buildWsMessage } from "./ws/messageFormat"; -import { PacketMotionData } from "./telemetry/motion/types"; -import { PacketLapData } from "./telemetry/lapData/types"; -import { PacketCarTelemetryData } from "./telemetry/carTelemetry/types"; -import { persistTelemetry } from "./persistence/persistTelemetry"; -import { closeDb } from "./persistence/db"; - -const { broadcast } = startWsBroadcaster(env.wsPort); -startHttpServer(env.httpPort); - -// Loga no máximo 1x/segundo por tipo de pacote - a até 60Hz, logar todo -// pacote satura o console (I/O síncrono) numa sessão real do jogo. -const LOG_INTERVAL_MS = 1000; -const lastLoggedAt: Partial> = {}; - -function shouldLog(packetId: number): boolean { - const now = Date.now(); - const last = lastLoggedAt[packetId] ?? 0; - if (now - last < LOG_INTERVAL_MS) return false; - lastLoggedAt[packetId] = now; - return true; -} +import "dotenv/config"; +import { parseRunMode } from "./config/mode"; +import { parseDemoConfig } from "./config/demoEnv"; +import { findProjectRoot } from "./config/projectRoot"; +import type { PacketMotionData } from "./telemetry/motion/types"; +import type { PacketLapData } from "./telemetry/lapData/types"; +import type { PacketCarTelemetryData } from "./telemetry/carTelemetry/types"; + +// Os módulos do modo local são carregados aqui dentro, não no topo: um import +// no topo abriria o banco (persistence/db) e leria as portas antes de o modo +// demo validar a configuração dele. +function startLocalServer(): void { + /* eslint-disable @typescript-eslint/no-var-requires */ + const { env } = require("./config/env") as typeof import("./config/env"); + const { startUdpListener } = require("./network/udpListener") as typeof import("./network/udpListener"); + const { startWsBroadcaster } = require("./network/wsBroadcaster") as typeof import("./network/wsBroadcaster"); + const { startHttpServer } = require("./http/httpServer") as typeof import("./http/httpServer"); + const { dispatchPacket } = require("./telemetry/dispatcher") as typeof import("./telemetry/dispatcher"); + const { packetName } = require("./telemetry/packetIds") as typeof import("./telemetry/packetIds"); + const { NUM_CARS } = require("./telemetry/shared/constants") as typeof import("./telemetry/shared/constants"); + const { buildWsMessage } = require("./ws/messageFormat") as typeof import("./ws/messageFormat"); + const { persistTelemetry } = require("./persistence/persistTelemetry") as typeof import("./persistence/persistTelemetry"); + const { closeDb } = require("./persistence/db") as typeof import("./persistence/db"); + /* eslint-enable @typescript-eslint/no-var-requires */ -function handlePacket(buffer: Buffer): void { - const result = dispatchPacket(buffer); - if (!result) return; // tipo de pacote ainda não suportado (ver telemetry/dispatcher.ts) + const { broadcast } = startWsBroadcaster(env.wsPort); + startHttpServer(env.httpPort); - const { header, packetId, data } = result; + // Loga no máximo 1x/segundo por tipo de pacote - a até 60Hz, logar todo + // pacote satura o console (I/O síncrono) numa sessão real do jogo. + const LOG_INTERVAL_MS = 1000; + const lastLoggedAt: Partial> = {}; - if (header.playerCarIndex < 0 || header.playerCarIndex >= NUM_CARS) { - throw new Error(`playerCarIndex fora do intervalo (0-${NUM_CARS - 1}): ${header.playerCarIndex}`); + function shouldLog(packetId: number): boolean { + const now = Date.now(); + const last = lastLoggedAt[packetId] ?? 0; + if (now - last < LOG_INTERVAL_MS) return false; + lastLoggedAt[packetId] = now; + return true; } - if (packetId === 0) { - const motion = data as PacketMotionData; - const player = motion.carMotionData[header.playerCarIndex]; - if (shouldLog(packetId)) { - console.log( - `[${packetName(packetId)}] frame=${header.frameIdentifier} ` + - `pos=(${player.worldPosition.x.toFixed(1)}, ${player.worldPosition.y.toFixed(1)}, ${player.worldPosition.z.toFixed(1)}) ` + - `gForce lat=${player.gForceLateral.toFixed(2)} lon=${player.gForceLongitudinal.toFixed(2)} vert=${player.gForceVertical.toFixed(2)}` - ); + function handlePacket(buffer: Buffer): void { + const result = dispatchPacket(buffer); + if (!result) return; // tipo de pacote ainda não suportado (ver telemetry/dispatcher.ts) + + const { header, packetId, data } = result; + + if (header.playerCarIndex < 0 || header.playerCarIndex >= NUM_CARS) { + throw new Error(`playerCarIndex fora do intervalo (0-${NUM_CARS - 1}): ${header.playerCarIndex}`); } - persistTelemetry(header, packetId, motion); - broadcast(buildWsMessage(header, packetId, motion.carMotionData)); - } - if (packetId === 1) { - // Pacote de sessão só alimenta a persistência (trackId) - não é - // transmitido via WS: o frontend não consome dados de sessão ao vivo - // (ws.ts só tipa motion/lapData/carTelemetry). - persistTelemetry(header, packetId, data); - } + if (packetId === 0) { + const motion = data as PacketMotionData; + const player = motion.carMotionData[header.playerCarIndex]; + if (shouldLog(packetId)) { + console.log( + `[${packetName(packetId)}] frame=${header.frameIdentifier} ` + + `pos=(${player.worldPosition.x.toFixed(1)}, ${player.worldPosition.y.toFixed(1)}, ${player.worldPosition.z.toFixed(1)}) ` + + `gForce lat=${player.gForceLateral.toFixed(2)} lon=${player.gForceLongitudinal.toFixed(2)} vert=${player.gForceVertical.toFixed(2)}` + ); + } + persistTelemetry(header, packetId, motion); + broadcast(buildWsMessage(header, packetId, motion.carMotionData)); + } - if (packetId === 2) { - const lapData = data as PacketLapData; - const player = lapData.lapData[header.playerCarIndex]; - if (shouldLog(packetId)) { - console.log( - `[${packetName(packetId)}] frame=${header.frameIdentifier} ` + - `lap=${player.currentLapNum} sector=${player.sector} pos=${player.carPosition} ` + - `currentLapTimeMS=${player.currentLapTimeInMS} lastLapTimeMS=${player.lastLapTimeInMS}` - ); + if (packetId === 1) { + // Pacote de sessão só alimenta a persistência (trackId) - não é + // transmitido via WS: o frontend não consome dados de sessão ao vivo + // (ws.ts só tipa motion/lapData/carTelemetry). + persistTelemetry(header, packetId, data); + } + + if (packetId === 2) { + const lapData = data as PacketLapData; + const player = lapData.lapData[header.playerCarIndex]; + if (shouldLog(packetId)) { + console.log( + `[${packetName(packetId)}] frame=${header.frameIdentifier} ` + + `lap=${player.currentLapNum} sector=${player.sector} pos=${player.carPosition} ` + + `currentLapTimeMS=${player.currentLapTimeInMS} lastLapTimeMS=${player.lastLapTimeInMS}` + ); + } + persistTelemetry(header, packetId, lapData); + broadcast(buildWsMessage(header, packetId, lapData.lapData)); + } + + if (packetId === 6) { + const carTelemetry = data as PacketCarTelemetryData; + const player = carTelemetry.carTelemetryData[header.playerCarIndex]; + if (shouldLog(packetId)) { + console.log( + `[${packetName(packetId)}] frame=${header.frameIdentifier} ` + + `speed=${player.speed}km/h throttle=${player.throttle.toFixed(2)} brake=${player.brake.toFixed(2)} ` + + `gear=${player.gear} rpm=${player.engineRPM} ` + + `tyresPressure RL=${player.tyresPressure.rearLeft.toFixed(1)} FR=${player.tyresPressure.frontRight.toFixed(1)}` + ); + } + persistTelemetry(header, packetId, carTelemetry); + broadcast(buildWsMessage(header, packetId, carTelemetry.carTelemetryData)); } - persistTelemetry(header, packetId, lapData); - broadcast(buildWsMessage(header, packetId, lapData.lapData)); } - if (packetId === 6) { - const carTelemetry = data as PacketCarTelemetryData; - const player = carTelemetry.carTelemetryData[header.playerCarIndex]; - if (shouldLog(packetId)) { - console.log( - `[${packetName(packetId)}] frame=${header.frameIdentifier} ` + - `speed=${player.speed}km/h throttle=${player.throttle.toFixed(2)} brake=${player.brake.toFixed(2)} ` + - `gear=${player.gear} rpm=${player.engineRPM} ` + - `tyresPressure RL=${player.tyresPressure.rearLeft.toFixed(1)} FR=${player.tyresPressure.frontRight.toFixed(1)}` - ); + startUdpListener(env.udpPort, (buffer) => { + // Um pacote malformado (formato errado, truncado, de outra aplicação + // mandando pro mesmo IP/porta) não pode derrubar o processo inteiro - + // descarta e segue escutando os próximos. + try { + handlePacket(buffer); + } catch (err) { + console.error("[server] pacote UDP descartado:", err instanceof Error ? err.message : err); } - persistTelemetry(header, packetId, carTelemetry); - broadcast(buildWsMessage(header, packetId, carTelemetry.carTelemetryData)); + }); + + function shutdown(): void { + closeDb(); + process.exit(0); } + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); } -startUdpListener(env.udpPort, (buffer) => { - // Um pacote malformado (formato errado, truncado, de outra aplicação - // mandando pro mesmo IP/porta) não pode derrubar o processo inteiro - - // descarta e segue escutando os próximos. +function startDemoMode(): void { + const fail = (err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }; try { - handlePacket(buffer); + const config = parseDemoConfig(process.env, findProjectRoot()); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { startDemoServer } = require("./demo/demoServer") as typeof import("./demo/demoServer"); + startDemoServer(config).catch(fail); } catch (err) { - console.error("[server] pacote UDP descartado:", err instanceof Error ? err.message : err); + fail(err); } -}); - -function shutdown(): void { - closeDb(); - process.exit(0); } -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); +if (parseRunMode(process.env.DEMO_MODE) === "demo") { + startDemoMode(); +} else { + startLocalServer(); +} diff --git a/tools/exportCoachSeed.ts b/tools/exportCoachSeed.ts new file mode 100644 index 0000000..6dc349c --- /dev/null +++ b/tools/exportCoachSeed.ts @@ -0,0 +1,66 @@ +import "dotenv/config"; +import fs from "node:fs"; +import path from "node:path"; +import { findProjectRoot } from "../src/config/projectRoot"; +import { assertExportEnvironment, writeCoachSeedFile, type CoachSeedFile } from "../src/demo/coachSeed"; +import { PUBLISHED_RECORDING_SHA256, parseRecording, verifyRecording } from "../src/demo/recording"; + +/** + * A única etapa paga do modo demo, rodada uma vez pela mantenedora, na + * máquina dela, com a chave dela (quickstart.md, seção D): monta a sessão da + * gravação publicada num banco descartável, pede ao coach os seis pares + * ordenados das voltas 6, 7 e 8 e grava demo/coach-seed.json. Um par que já + * está no arquivo, para a mesma gravação, modelo e insights, não é pedido de + * novo. Nunca roda na CI, nos testes nem pelo agente. + */ + +const PAIRS: Array<[number, number]> = [ + [6, 7], + [6, 8], + [7, 6], + [7, 8], + [8, 6], + [8, 7], +]; + +async function main(): Promise { + const root = findProjectRoot(); + // Antes de qualquer módulo que abra o banco ou leia a configuração do coach. + assertExportEnvironment(process.env, root); + + const recordingPath = path.join(root, "recordings", "demo-interlagos-tt-20hz-laps6-8.jsonl"); + const seedPath = path.join(root, "demo", "coach-seed.json"); + const recordingBytes = fs.readFileSync(recordingPath); + verifyRecording(recordingBytes, PUBLISHED_RECORDING_SHA256); + const existing = fs.existsSync(seedPath) ? (JSON.parse(fs.readFileSync(seedPath, "utf8")) as CoachSeedFile) : undefined; + + /* eslint-disable @typescript-eslint/no-var-requires */ + const { exportCoachSeed } = require("../src/demo/exportCoachSeed") as typeof import("../src/demo/exportCoachSeed"); + const { generateCoaching, MODEL } = require("../src/coaching/coachService") as typeof import("../src/coaching/coachService"); + /* eslint-enable @typescript-eslint/no-var-requires */ + + const seed = await exportCoachSeed({ + packets: parseRecording(recordingBytes.toString("utf8")), + recordingSha256: PUBLISHED_RECORDING_SHA256, + pairs: PAIRS, + existing, + generate: (insights, benchmark) => generateCoaching(insights, benchmark), + currentModel: MODEL, + // A cada par pago, o arquivo é regravado (atomicamente): uma falha no + // meio deixa os pares já pagos, e a próxima rodada só pede os que faltam. + onProgress: (partial) => writeCoachSeedFile(seedPath, partial), + }); + + for (const entry of seed.entries) { + const reused = existing?.entries.includes(entry) ?? false; + console.log(`[exportCoachSeed] par (${entry.lapA}, ${entry.lapB}): ${reused ? "reutilizado" : "pedido ao coach"}`); + } + + writeCoachSeedFile(seedPath, seed); + console.log(`[exportCoachSeed] ${seed.entries.length} pares gravados em ${seedPath}`); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/tools/fetchDemoRecording.ts b/tools/fetchDemoRecording.ts new file mode 100644 index 0000000..c31b3ee --- /dev/null +++ b/tools/fetchDemoRecording.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { findProjectRoot } from "../src/config/projectRoot"; +import { ensureRecording } from "../src/demo/recording"; + +/** + * Passo final do `npm run build`: garante a gravação do modo demo em + * recordings/ (ou em DEMO_RECORDING_PATH), baixando-a da Release e + * conferindo o SHA-256 publicado no README. Se ela já está lá e confere, + * não baixa nada. Qualquer falha sai com código 1, o que falha o build. + */ +async function main(): Promise { + const destPath = + process.env.DEMO_RECORDING_PATH || path.join(findProjectRoot(), "recordings", "demo-interlagos-tt-20hz-laps6-8.jsonl"); + + const outcome = await ensureRecording(destPath, fetch); + console.log( + outcome === "present" + ? `[fetchDemoRecording] gravação já presente e conferida em ${destPath}; nada baixado.` + : `[fetchDemoRecording] gravação baixada e conferida em ${destPath}.` + ); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..73691af --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "noEmit": false + }, + "include": ["src", "tools"], + "exclude": ["**/*.test.ts", "**/testFixtures.ts", "src/persistence/testDb.ts", "node_modules"] +}