diff --git a/.env.example b/.env.example index a26bd06..e83f527 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,13 @@ # Copia esto a .env (gitignorado) con los valores de tu proyecto de Supabase. +# +# Es el UNICO fichero de credenciales del repo: lo lee load_env() del lado Python +# y, a traves de frontend/next.config.ts, tambien el front. No hay .env.local. # Project Settings -> Data API -> Project URL, y API Keys. SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co -# Pública: lectura bajo RLS. Es la que acaba en el navegador. +# Pública: lectura bajo RLS. Es la que acaba en el navegador, publicada por +# next.config.ts como NEXT_PUBLIC_SUPABASE_ANON_KEY. SUPABASE_ANON_KEY=eyJ... # SECRETA: se salta RLS. Solo el lado Python (SDK, backfill, seed). Nunca en el front. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f36d1a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +# Lo mínimo que tiene que contestar un push: ¿aplica el SQL?, ¿siguen cuadrando el +# esquema y lo que el front consulta?, ¿compila la interfaz? +# +# El Postgres no es decoración: el último bug de esquema que se coló (`round()` sobre +# double precision) solo aparece EJECUTANDO el SQL, y el de `started_at` solo +# comparando las consultas del front contra las columnas de verdad. + +name: CI + +on: + push: + branches: ['**'] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + # El test crea su propio esquema desechable y solo acepta localhost. + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' # el mínimo que declara pyproject + + # El SDK no tiene dependencias; pytest y ruff viven en el extra dev. + - run: pip install -e './backend[dev]' + + - run: ruff check backend + + # psql viene de serie en ubuntu-latest; el test lo usa como cliente para no + # meter psycopg solo para esto. + - run: pytest backend -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + env: + # De pega, pero tienen que existir: createClient se evalúa al importar el módulo + # y sin ellas el build se cae. Son públicas por definición, así que aquí no hay + # nada que esconder; la service_role no aparece en el front jamás. + NEXT_PUBLIC_SUPABASE_URL: https://ejemplo.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: clave-de-pega-para-compilar + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + + - run: npm run lint + + # `build` y no `tsc --noEmit`: Next 16 genera durante el build los tipos de + # PageProps<'/runs/[id]'>, que es justo lo que van a necesitar las rutas + # pendientes. + - run: npm run build diff --git a/.gitignore b/.gitignore index f2ec80c..12a5ef7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -# Secretos. Las credenciales de Supabase viven en .env (service_role, lado Python) y -# en frontend/.env.local (solo la clave anon). Ninguno de los dos se versiona jamás. +# Secretos. Las credenciales de Supabase viven en UN solo fichero, el .env de la raíz: +# la clave anon y la service_role. No se versiona jamás. (Antes había además un +# frontend/.env.local con la anon; se quitó porque dos copias se desincronizan solas, +# pero el patrón .env.* lo sigue cubriendo por si alguien lo recrea.) .env .env.* !.env.example @@ -29,6 +31,9 @@ dist/ .mypy_cache/ # Editor / SO +# settings.local.json son los permisos de herramientas de cada cual, no del proyecto: +# .claude/settings.json (compartido) sí se versionaría; el local nunca. +.claude/settings.local.json .vscode/ .idea/ *.swp @@ -39,3 +44,12 @@ Thumbs.db # Logs y volcados *.log core + +# CodeGraph +.codegraph/ + +# Ejecuciones escritas por RunLog. El jsonl es la fuente de verdad en la maquina +# que lo produce, no algo que se versione: son datos, no codigo. +# Anclado a la raiz: sin la barra inicial ignoraba CUALQUIER carpeta llamada `runs`, y se +# tragaba en silencio las rutas del front (frontend/app/runs/[id]) y sus clientes. +/runs/ diff --git a/.gitmessage b/.gitmessage index b80f64a..2a94017 100644 --- a/.gitmessage +++ b/.gitmessage @@ -1,7 +1,7 @@ # (<ámbito>): # # Tipos: feat fix docs style refactor test chore perf ci revert -# Ámbitos de este repo: sim perception planning control runner metrics configs scripts +# Ámbitos de este repo: front live sdk telemetry seed sql backend contrato vocabulario # # Cuerpo: explica el POR QUÉ, no el qué (el diff ya dice el qué). # diff --git a/AGENTS.md b/AGENTS.md index ad304fb..4edab9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ percepción. Este repo solo sabe de episodios, métricas y pantallas. backend/ SQL + SDK. Supabase ES el backend; no hay servidor propio. sql/001_schema.sql tablas, vistas y RLS sql/002_design.sql columnas y vistas que pide el diseño + sql/003_snapshots.sql las fotos del palé (solo la URL; los PNG van a Storage) theker_telemetry/ el SDK que importa quien produce episodios schema.py EpisodeResult, FAILURES, TASKS, RunWriter core.py cliente PostgREST y RunLog @@ -37,15 +38,30 @@ backend/ SQL + SDK. Supabase ES el backend; no hay servidor prop frontend/ Next.js 16. Lee Supabase con la clave anon, bajo RLS. app/ rutas: / (Live), /runs, /runs/[id], /runs/[id]/[seed] - components/ Pallet, Episode, Runs, primitives, TopBar + api/clients/ una función por endpoint de PostgREST + api/hooks/ los mismos, envueltos en TanStack Query + components/ atoms / organisms / screens lib/ui.ts TODA conversión de unidades, en un solo sitio - lib/supabase.ts tipos, consultas y comparability() + styles/colors.css TODOS los colores (tema claro y oscuro), en un solo sitio + lib/pallet.ts geometría del dibujo; el tamaño del palé NO es fijo + lib/supabase.ts los tipos, que son la mitad del contrato docs/ BRIEFING-observabilidad.md qué se construye y por qué + API.md el contrato: endpoints, tipos y vocabularios design/ los 11 tableros del diseño, a 1440 px + img/ capturas y variantes de marca que usa el README + +README.md cara pública del repo, en inglés: requisitos, instalación, + desarrollo, uso, dependencias con su auditoría de licencias. +CONTRIBUTING.md el flujo real: ramas, commits, comprobaciones y qué exige CI. +LICENSE MIT, 2026 STACKSPECT. ``` +Los tres anteriores están **en inglés** a propósito, y son los únicos: son lo que lee +quien llega de fuera. Todo lo demás —este documento, `docs/`, los comentarios— sigue en +castellano. + **La dependencia va en un solo sentido.** La simulación importa `theker_telemetry`; esta plataforma no sabe que MuJoCo existe. Por eso el esquema de métricas vive aquí y no allí: quien define la forma del dato es quien lo almacena, y así reescribir la @@ -59,6 +75,7 @@ simulación no se lleva por delante la observabilidad. # 1. Esquema: pegar en el SQL editor de Supabase, en orden. Son idempotentes. backend/sql/001_schema.sql backend/sql/002_design.sql +backend/sql/003_snapshots.sql # 2. Credenciales. .env en la raíz (gitignorado): # SUPABASE_URL=https://xxxx.supabase.co @@ -69,15 +86,33 @@ backend/sql/002_design.sql pip install -e backend # 4. Datos con los que ver la interfaz -python backend/seed/palletizing.py +python backend/seed/palletizing.py # histórico sembrado, 6 ejecuciones +python backend/seed/ejemplo_contrato.py # una entrada que toca TODO el contrato +python backend/seed/humo_live.py # monta un palé en vivo: comprueba Live python backend/backfill.py --runs-dir /simulation/runs -# 5. Front +# 5. Todo de una vez: comprueba credenciales, dependencias y esquema, y arranca. +./dev.sh # --check para solo comprobar, --seed para sembrar antes + +# ...o solo el front, si ya sabes que lo demás está en su sitio cd frontend && npm install && npm run dev + +# 6. Tests. Sin DATABASE_URL corren los que no tocan la red; con él se añaden los +# que aplican el esquema de verdad y verifican el contrato con el front. +pip install -e 'backend[dev]' +docker run --rm -d --name pg -e POSTGRES_PASSWORD=postgres -p 5433:5432 postgres:16 +export DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres +ruff check backend && pytest backend -q ``` -`frontend/.env.local` lleva **solo** `NEXT_PUBLIC_SUPABASE_URL` y -`NEXT_PUBLIC_SUPABASE_ANON_KEY`. La `service_role` nunca entra ahí: se salta RLS. +**Un solo fichero de credenciales**, el `.env` de la raíz. Lo lee `load_env()` del lado +Python y, vía `frontend/next.config.ts`, también el front: ese fichero traduce +`SUPABASE_URL` y `SUPABASE_ANON_KEY` a sus `NEXT_PUBLIC_*` por **lista blanca**. No hay +`frontend/.env.local`, porque dos copias de lo mismo se desincronizan solas. + +La `service_role` jamás sale de ahí: se salta RLS, y todo lo que lleva `NEXT_PUBLIC_` se +empaqueta en el JavaScript que descarga cualquiera. `next.config.ts` rompe el build si +una variable pública lleva un nombre que huela a secreto. --- @@ -108,9 +143,15 @@ calculados de las vistas SQL. Dos sitios que suman lo mismo acaban discrepando. **Medianas, no medias.** Un episodio que se derrumba a los 3 s arrastra la media del tiempo de ciclo y hace parecer rápido a un run que va mal. -**Live nunca se queda en blanco.** Sin ejecución activa enseña la última terminada; si -se pierde la conexión congela lo último recibido y lo dice. Quedarse en blanco delante -del jurado es lo peor que puede hacer esa pantalla. +**Live solo enseña lo que está pasando ahora, y nunca se queda en blanco.** Muestra el +episodio `running`. Cuando acaba, se queda 10 s con su resultado («Terminado») para que se +vea el desenlace y para que, si arranca otro de la misma ejecución, el cambio sea directo +y no un parpadeo. Sin ninguno, lo dice con un mensaje («No hay ninguna ejecución en +directo») y **no** lo rellena con lo último terminado, porque eso se leería como si +estuviera ocurriendo. Si se pierde la conexión con un episodio en pantalla, congela lo +último recibido y lo dice. Lo terminado se mira en Ejecuciones. Quedarse en blanco delante +del jurado es lo peor que puede hacer esa pantalla; un mensaje claro no es quedarse en +blanco. **El disco manda.** El `episodes.jsonl` que escribe la simulación es la fuente de verdad; Supabase es una réplica consultable. Un fallo de red avisa una vez y el @@ -122,10 +163,13 @@ episodio sigue: la demo no puede depender del wifi de la sala. Cerrado. Añadir una nueva obliga a tocar **tres sitios**: `FAILURES` en `backend/theker_telemetry/schema.py`, el CHECK de `episodes.failure` en -`backend/sql/002_design.sql`, y `FAILURE_TEXT` en `frontend/lib/ui.ts`. Saltarse el +`backend/sql/001_schema.sql`, y `FAILURE_TEXT` en `frontend/lib/ui.ts`. Saltarse el segundo hace que el episodio se escriba en disco y la subida se caiga entera sin que nadie se entere hasta ver la interfaz vacía. +Ya no hace falta acordarse: `backend/tests/test_vocabulario.py` compara los tres sitios +y falla si uno se queda atrás. + | enum | en pantalla | |---|---| | `no_detection` | no vio ningún paquete | @@ -193,20 +237,39 @@ evolución en vez de borrón y cuenta nueva. - Python 3.11, type hints en las firmas públicas. TypeScript estricto en el front. - Unidades SI en el dato, milímetros y grados en la pantalla. -- Nombres de código en inglés; comentarios y documentos en castellano. +- **Colores solo como variables, y definidas en un único archivo:** `frontend/styles/colors.css`. + Ningún otro archivo (CSS, TS, TSX) lleva `#hex`, `rgb()`, `hsl()` ni un color con nombre; + consume `var(--…)`. Un color con transparencia es otra variable de ese archivo, calculada + con `color-mix` desde la base. `npm run lint` lo comprueba (`scripts/check-colors.mjs`). +- Nombres de código en inglés; comentarios y documentos en castellano. Las tres + excepciones son `README.md`, `CONTRIBUTING.md` y `LICENSE`, que van en inglés porque + son lo que lee quien llega de fuera (§2). - Commits pequeños, con números cuando toquen comportamiento. - El diseño de referencia es `docs/design/observality-platform-design.html`: un bundle - autoextraíble con 11 tableros. Se abre en el navegador. + autoextraíble con 11 tableros. Se abre en el navegador. Manda en la **estructura y el + contenido** de cada pantalla; **no** en el aspecto: la identidad visual es la del logo de + STACKSPECT (blanco y negro, esquinas suaves, Plus Jakarta Sans y Geist Mono), con tema + claro y oscuro. El color con significado (estados, oracle, sembrado, causas de fallo) se + conserva en los dos temas. `npm run lint:contrast` garantiza el contraste AA. --- ## 9. Estado -- **Backend: completo.** Esquema, SDK, backfill y sembrado, todos probados. -- **Front:** tokens, librerías, componentes y la pantalla **Live** con Realtime y sus - tres estados (sin ejecución, fallo, conexión perdida). Compila. -- **Pendiente:** las rutas `/runs`, `/runs/[id]` y `/runs/[id]/[seed]`. Son pegamento: - sus componentes ya están en `components/Runs.tsx` y `components/Episode.tsx`. +- **Backend: completo y con red.** Esquema, SDK, backfill y sembrado, con 116 tests y + CI. `pytest backend -q` no toca la red; exportando `DATABASE_URL` a un Postgres + desechable se añaden los que levantan el esquema de verdad y comprueban el contrato + con el front (`backend/tests/test_contrato.py`). El contrato está escrito en `docs/API.md`. +- **Episodio en vivo:** `RunLog` tiene `begin()` / `event()` / `pallet_state()` / + `end()` además de `episode()`. **La simulación tiene que llamarlas** o Live enseñará + el palé ya montado: `episode()` sube todo de golpe y entonces no hay fila `running` + ni `UPDATE` a los que Realtime pueda reaccionar. +- **Front:** `/` (Live) y `/runs` (Ejecuciones), sobre TanStack Query, con Realtime y + los tres estados no felices. El tamaño del palé sale de `config.pallet_size_m` vía + `palletSize()`: **no** se dibuja siempre a 1200x800, porque el palé real puede ser una + maqueta a escala y entonces todas las cotas saldrían mal por el mismo factor. +- **Detalle de ejecución:** `/runs/[id]` y `/runs/[id]/[seed]` ya están, sobre el mismo + `EpisodeDashboard` que Live: cambia de dónde salen los datos, no cómo se ven. Aviso de Next 16: `params` es una Promise (`const { id } = await params`) y existen los tipos globales `PageProps<'/runs/[id]'>`. Hay documentación offline en diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6dc0532 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,265 @@ +# Contributing + +Thanks for taking an interest. This is a small, opinionated repository: a Supabase +schema, a dependency-free Python telemetry SDK, and a Next.js frontend. Most of the +friction a newcomer hits here comes from rules that already exist and are enforced by +CI, so this page front-loads them. + +Two documents outrank this one on matters of substance: + +- **[`AGENTS.md`](AGENTS.md)** — the permanent context of the repository: what it is, + how it is laid out, and the hard rules. Read it before writing code. If something + there is out of date, fix it in the same PR. +- **[`docs/API.md`](docs/API.md)** — the contract between the database and the + frontend: endpoints, types, and the closed vocabularies. + +Both are written in Spanish, as are all code comments and commit subjects. Issues and +pull requests in English are welcome too — see [Language](#language). + +--- + +## Branches + +`dev` is the integration branch. **Everything targets `dev`**, never `main`. + +``` +main ← dev ← your branch +``` + +- Branch off the latest `dev`: `git fetch origin dev && git switch -c feature/ origin/dev` +- Branch names in use: `feature/`, `fix/`. +- Open the pull request against `dev`. `main` only ever moves by merging `dev`. +- Keep commits small. A commit that changes behaviour should be readable on its own. + +## Commits + +Conventional commits, with the **subject written in Spanish, in the imperative, no +trailing period, ≤50 characters**. The repository ships a template — turn it on once: + +```bash +git config commit.template .gitmessage +``` + +Types: `feat` `fix` `docs` `style` `refactor` `test` `chore` `perf` `ci` `revert`. + +Scopes actually in use in this repository: `front`, `live`, `sdk`, `telemetry`, `seed`, +`sql`, `backend`, `contrato`, `vocabulario`. A scope is optional; a wrong one is worse +than none. + +The body explains **why**, not what — the diff already says what. + +**If the commit changes behaviour, put a number in it.** This repository exists to +measure improvement; a commit that claims one without evidence is the thing it was +built to prevent: + +``` +feat(planner): reordena la cola por masa descendente + +nivel 2: 62 % -> 81 % de bultos colocados (n=50) +``` + +Real examples from the history: + +``` +fix(live): el aviso de conexion perdida saltaba sin haberse perdido nada +feat(front): pantalla de detalle de una ejecución (#4) +refactor(front): un solo .env, el de la raiz +test(contrato): vocabulario de fallos cuadrado en los tres sitios +``` + +Footer: `Closes #123`, `BREAKING CHANGE: …`. + +--- + +## Getting set up + +You need **Node 22**, **Python 3.11+**, and a Supabase project you can write to. +(Docker only if you want to run the networked backend tests.) + +### 1. Credentials + +There is exactly **one** credentials file: `.env` at the repository root. Copy the +example and fill it in from *Supabase → Project Settings → Data API*: + +```bash +cp .env.example .env +``` + +```dotenv +SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co +SUPABASE_ANON_KEY=eyJ... # public, read-only under RLS +SUPABASE_SERVICE_KEY=eyJ... # SECRET, Python side only +``` + +> [!WARNING] +> **`SUPABASE_SERVICE_KEY` bypasses row-level security.** It must never reach the +> browser and never be committed. `frontend/next.config.ts` publishes only an explicit +> whitelist of keys as `NEXT_PUBLIC_*`, and fails the build if a public variable's name +> smells like a secret. Do not widen that whitelist. There is no +> `frontend/.env.local` — a second copy of the same values desynchronises on its own, +> and it already did once. + +### 2. Schema + +The schema is applied **by hand**, in order, from the Supabase SQL editor. All three +files are idempotent, so pasting one again is safe: + +| File | What it adds | +|---|---| +| [`backend/sql/001_schema.sql`](backend/sql/001_schema.sql) | tables, views, RLS | +| [`backend/sql/002_design.sql`](backend/sql/002_design.sql) | the columns and views the design needs | +| [`backend/sql/003_snapshots.sql`](backend/sql/003_snapshots.sql) | pallet snapshots (URLs; the PNGs live in Storage) | + +### 3. Everything else, in one command + +```bash +./dev.sh +``` + +`dev.sh` checks the credentials, installs the SDK into `.venv` if it is missing, runs +`npm ci` when `package-lock.json` is newer than `node_modules`, **probes the live schema +for the specific columns the frontend queries**, and then starts the dev server on +. + +```bash +./dev.sh --check # verify only, start nothing +./dev.sh --seed # seed sample data first +``` + +That schema probe is the point of the script: the SQL is pasted by hand, so it is normal +for the repository to run ahead of the database. Discovering that in front of an +audience is the expensive failure. + +If the project is empty, the generators under `backend/seed/` will fill it — see +[Sample data](README.md#4-sample-data) in the README. + +--- + +## Checks + +Run both before you push. **CI runs exactly these**, so a red result here is a red PR. + +```bash +# Frontend +cd frontend && npm ci && npm run lint && npm run build + +# Backend +pip install -e './backend[dev]' +ruff check backend +pytest backend -q +``` + +`pytest backend -q` touches no network. Export `DATABASE_URL` pointing at a throwaway +Postgres and it additionally applies the real schema and verifies the contract against +the frontend's queries (`backend/tests/test_contrato.py`) — which is what CI does: + +```bash +docker run --rm -d --name pg -e POSTGRES_PASSWORD=postgres -p 5433:5432 postgres:16 +export DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres +pytest backend -q +``` + +### What CI enforces + +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs on every push and every pull +request, in two jobs: + +- **backend** — Python 3.11 with a `postgres:16` service, then `ruff check backend` and + `pytest backend -q` with `DATABASE_URL` set, so the schema tests really run. +- **frontend** — Node 22, `npm ci`, `npm run lint`, `npm run build`. It is `build` and + not `tsc --noEmit` on purpose: Next 16 generates the `PageProps<'/runs/[id]'>` types + during the build, so only a build catches a route typed against them. + +### `npm run lint` is three checkers, not one + +``` +eslint && node scripts/check-colors.mjs && node scripts/check-contrast.mjs +``` + +The two project-specific ones fail more often than eslint does: + +- **`check-colors.mjs`** — no colour literals anywhere outside + `frontend/styles/colors.css`. It catches `#hex`, `rgb()/rgba()`, `hsl()/hsla()`, + `hwb()`, `lab()/lch()`, `oklab()/oklch()`, and named colours used as a value, in CSS, + TS and TSX alike. `transparent`, `currentColor` and `inherit` are allowed — they are + not a specific colour. Run it alone with `npm run lint:colors`. +- **`check-contrast.mjs`** — WCAG contrast on every colour pair that carries text, in + **both** themes: 4.5:1 for text, 3:1 for meaningful graphical elements. A grey that + looks fine on the laptop it was picked on may not be readable on a projector. Run it + alone with `npm run lint:contrast`. + +--- + +## Hard rules + +These are the ones a review will bounce you for. The reasoning behind each is in +[`AGENTS.md`](AGENTS.md) §4 and §7 — this is the checklist. + +**No Tailwind.** The design is exact values in `docs/design/`; restating them as +utilities adds noise where there is no decision to make. Components use CSS Modules +next to the component. + +**Colours only as variables, defined in one file.** `frontend/styles/colors.css` holds +every colour for both themes. No other file — CSS, TS, or TSX — carries a literal; they +consume `var(--…)`. A translucent colour is another variable in that same file, derived +from the base with `color-mix`. Enforced by `check-colors.mjs`. + +**No server between the frontend and Supabase.** PostgREST, RLS and Realtime *are* the +backend. One more process is one more thing that falls over at 3am. + +**Never put `service_role` in the frontend.** It bypasses RLS. + +**No dependencies without a reason.** The Python SDK has none on purpose: it speaks +PostgREST with `urllib` from the standard library. Keep it that way — it is imported by +the simulation repositories, and every package it grows is a package they inherit. + +**Units are always visible, and converted in one place.** The database stores SI +(metres, radians, seconds); the screen shows millimetres and degrees. That conversion +lives only in `frontend/lib/ui.ts`. `4.1 mm`, never `4.1`. + +**Never show a raw failure identifier.** `stack_collapse` reads as «el montón se +derrumbó». The translation is `FAILURE_TEXT` in `frontend/lib/ui.ts`. + +**Don't compute in the client what a view can compute.** Totals, medians and dominant +causes come out of the SQL views. Two places summing the same thing eventually disagree. + +**Medians, not means.** One episode that collapses at 3 s drags the mean cycle time down +and makes a bad run look fast. + +**Synthetic and oracle data never share a comparison with measured data.** `runs.oracle` +and `runs.synthetic` are drawn with a diagonal hatch, and `comparability()` in +`frontend/lib/supabase.ts` blocks the delta outright. A pretty, false chart is exactly +what an evaluator will find. + +### Adding a failure cause + +The vocabulary is closed. A new one means touching **three** places: + +1. `FAILURES` in `backend/theker_telemetry/schema.py` +2. the `CHECK` on `episodes.failure` in `backend/sql/001_schema.sql` +3. `FAILURE_TEXT` in `frontend/lib/ui.ts` + +Skip the second and the episode writes to disk fine while the upload fails wholesale — +nobody notices until the interface is empty. You no longer have to remember: +`backend/tests/test_vocabulario.py` compares all three and fails if one lags behind. + +--- + +## Language + +- **Code identifiers:** English. +- **Comments, documentation and commit subjects:** Spanish. +- **`README.md`, `CONTRIBUTING.md` and `LICENSE`:** English — they are the public face + of the repository. +- Issues and pull requests: either. Write in whichever you are clearer in. + +## A note on `frontend/AGENTS.md` + +That file is written and re-added by `next dev` itself (see +`node_modules/next/dist/server/lib/generate-agent-files.js`). Deleting it from a diff +only recreates the uncommitted change; commit it with your work to keep the tree clean. + +## Licence + +By contributing you agree that your contributions are licensed under the +[MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..78647a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 STACKSPECT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..57f573f --- /dev/null +++ b/README.md @@ -0,0 +1,597 @@ +
+ + + + + + + + + STACKSPECT + + +### Palletizing observability platform + +**Every episode a robotic arm runs, measured — centre of gravity, stability and the +improvement curve between commits.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-1f1f1f?style=flat-square)](LICENSE) +[![CI](https://img.shields.io/github/actions/workflow/status/STACKSPECT/Platform/ci.yml?branch=dev&style=flat-square&label=CI)](https://github.com/STACKSPECT/Platform/actions/workflows/ci.yml) +[![Next.js](https://img.shields.io/badge/Next.js-16.3.5-1f1f1f?style=flat-square&logo=nextdotjs&logoColor=white)](https://nextjs.org) +[![Python](https://img.shields.io/badge/Python-3.11%2B-1f1f1f?style=flat-square&logo=python&logoColor=white)](https://www.python.org) +[![Supabase](https://img.shields.io/badge/Supabase-PostgREST%20%C2%B7%20Realtime-1f1f1f?style=flat-square&logo=supabase&logoColor=white)](https://supabase.com) + +[Installation](#installation) · +[Development](#development) · +[Usage](#usage) · +[Dependencies](#dependencies) · +[Contributing](CONTRIBUTING.md) + +
+ +--- + +## What this is + +A robotic arm stacks packages on a pallet while watching the centre of gravity, reads +the parcels by vision, and reproduces ordered stacks. **This repository is the +instrument panel for that arm** — not the arm. + +It answers three questions, and nothing else: + +| | Screen | Question | +|---|---|---| +| **Live** | `/` | What is happening on the pallet *right now*? | +| **Runs** | `/runs` | Which runs exist, and did the last change actually help? | +| **Episode** | `/runs/{id}` · `/runs/{id}/{seed}` | Why did *this* episode end the way it did? | + +Built for the THEKER Robotics challenge at HackSpain '26. One of the five criteria on +track B asks literally for *"measured iteration and improvement: your own indicators, +measured, and data backing how much you improved the solution during development."* +This platform **is** that evidence, which is why it refuses to draw a comparison between +two runs that did not measure the same thing, and marks every synthetic number it shows. + +**The simulation lives in another repository.** There is no MuJoCo here, no physics, no +perception. This repository knows about episodes, metrics and screens. + +--- + +## Screenshots + +**Runs** — every run with its totals, the filters, and the comparison panel. Here it is +doing the job it exists for: **refusing** to compare two runs that do not measure the +same thing, and saying exactly why. + + + + The Runs screen: a table of runs with filters and totals, and a comparison panel showing 'No comparable — these two runs do not measure the same thing', with the seed ranges 1–1 and 900–901 and zero seeds in common. + + +**Episode** — the same dashboard Live uses, against one finished episode: pallet from +above with the support polygon and the centre-of-gravity marker, the side elevation +drawn to the real pallet size, the KPI grid, and the event feed. Every magnitude carries +its unit. + + + + The Episode dashboard: top view of a pallet with dimensions and a centre-of-gravity marker, a side elevation, KPI cards reading 10 of 10 placed, 19.9 s per package, +58 mm stability margin and 77 % utilisation, and a timestamped event feed. + + +Both screens follow the viewer's theme; the images above do too. Captured at +1440 px from real measured runs — note the Oracle badge, which marks a run whose +perception was replaced by ground-truth poses. + +--- + +## Architecture + +There is **no server of our own**. Supabase *is* the backend: PostgREST for reads, RLS +for authorisation, Realtime for the live screen, Storage for pallet snapshots. One less +process to fall over at 3am. + +```mermaid +flowchart LR + subgraph producer ["Simulation repo (MuJoCo)"] + direction TB + SIM["Palletizing
simulation"] + JSONL[("episodes.jsonl
source of truth")] + SIM -->|"disk first, always"| JSONL + end + + SDK["theker_telemetry
Python SDK
zero deps · urllib"] + + subgraph supabase ["Supabase — this is the backend"] + direction TB + PGREST["PostgREST
REST over the schema"] + PG[("PostgreSQL
runs · episodes · placements
pallet_states · events · snapshots
v_*_summary views · RLS")] + RT["Realtime
episodes · events
pallet_states
"] + ST["Storage
pallet snapshot PNGs"] + PGREST <--> PG + PG --> RT + end + + FE["Next.js 16 frontend
Live · Runs · Episode"] + + SIM -->|"RunLog.begin / event /
pallet_state / end()"| SDK + JSONL -.->|"backfill.py"| SDK + SDK -->|"write · service_role"| PGREST + SDK -->|"snapshot upload"| ST + + PGREST -->|"read · anon key, under RLS"| FE + RT -->|"live push"| FE + ST -->|"image URLs"| FE +``` + +Two properties are worth stating out loud, because everything else follows from them: + +- **The dependency points one way.** The simulation imports `theker_telemetry`; this + platform does not know MuJoCo exists. The metrics schema lives here, with whoever + stores the data — so rewriting the simulation does not take observability down with it. +- **Disk wins.** The `episodes.jsonl` the simulation writes is the source of truth; + Supabase is a queryable replica. A network failure warns once and the episode carries + on, because the demo cannot depend on the room's wifi. If the replica is lost, + [`backend/backfill.py`](backend/backfill.py) rebuilds it. + +--- + +## Requirements + +| | Version | Why | +|---|---|---| +| **Node.js** | **22.x** | what CI pins, and what `frontend/package.json` declares in `engines` | +| **Python** | **3.11+** | declared by `backend/pyproject.toml`; CI runs 3.11 | +| **Supabase project** | any | the free tier is enough; it provides PostgREST, RLS, Realtime and Storage | +| Docker | optional | only to run the networked backend tests against a throwaway `postgres:16` | + +No database server, no application server and no container runtime are needed to develop +against a Supabase project. + +--- + +## Installation + +### 1. Clone and configure credentials + +There is exactly **one** credentials file: `.env` at the repository root. It is read by +`load_env()` on the Python side and, through +[`frontend/next.config.ts`](frontend/next.config.ts), by the frontend as well. + +```bash +git clone https://github.com/STACKSPECT/Platform.git +cd Platform +cp .env.example .env +``` + +Fill it in from *Supabase → Project Settings → Data API*: + +```dotenv +SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co +SUPABASE_ANON_KEY=eyJ... # public: read-only, under RLS +SUPABASE_SERVICE_KEY=eyJ... # SECRET: bypasses RLS, Python side only +``` + +> [!WARNING] +> **`SUPABASE_SERVICE_KEY` bypasses row-level security and must never reach the +> browser.** Everything prefixed `NEXT_PUBLIC_` is bundled into JavaScript that anyone +> can download. `next.config.ts` therefore promotes an explicit **whitelist** of two +> keys and never walks the file; as a second belt, it throws at build time if any +> `NEXT_PUBLIC_*` variable has a name that smells like a secret (`SERVICE_KEY`, +> `SERVICE_ROLE`, `SECRET`, `PASSWORD`). `.env` and `.env.*` are gitignored. +> +> There is deliberately no `frontend/.env.local`: two copies of the same values +> desynchronise on their own, and once did. + +### 2. Apply the schema + +The schema is pasted **by hand**, in order, into the Supabase SQL editor. All three +files are idempotent — pasting one again breaks nothing. + +| Order | File | What it creates | +|---|---|---| +| 1 | [`backend/sql/001_schema.sql`](backend/sql/001_schema.sql) | tables, views and RLS policies | +| 2 | [`backend/sql/002_design.sql`](backend/sql/002_design.sql) | the extra columns and views the design needs | +| 3 | [`backend/sql/003_snapshots.sql`](backend/sql/003_snapshots.sql) | the `snapshots` table — URLs only; the PNGs live in Storage | + +Everything derivable is derived in a view rather than stored in a column. A stored +number that can also be computed is a number that will one day disagree with the +computed one, and then nobody knows which is lying. + +### 3. Start everything with one command + +```bash +./dev.sh +``` + +[`dev.sh`](dev.sh) is the whole installation procedure in a script. It: + +1. **verifies the credentials** — that `.env` exists and all three keys have values, and + warns if a stale `frontend/.env.local` would shadow them; +2. **installs the SDK** into `.venv` if `theker_telemetry` is not importable; +3. **runs `npm ci`** when `package-lock.json` is newer than `node_modules`; +4. **probes the live schema** for the exact columns the frontend queries + (`v_episode_summary.started_at`, `v_episode_summary.config`, `v_run_summary.config`, + `snapshots.url`) and names the `.sql` file to paste if one is missing; +5. **prints how much data is there** — runs, how many measured versus seeded, episodes; +6. **starts the dev server** on . + +```bash +./dev.sh --check # run every check, start nothing +./dev.sh --seed # seed sample data, then start +./dev.sh --help +``` + +Step 4 is the reason the script exists. The SQL is applied by hand, so it is *normal* +for the repository to run ahead of the database — and a missing column returns a 400 and +leaves the screen blank. Finding that out in front of an audience is the expensive +failure. + +
+Manual setup, without dev.sh + +```bash +# Python SDK (plus pytest and ruff) +python3 -m venv .venv && .venv/bin/pip install -e './backend[dev]' + +# Frontend +cd frontend && npm ci && npm run dev +``` + +
+ +### 4. Sample data + +The interface needs rows before it shows anything. Three generators, all writing through +the real SDK rather than raw SQL — so the write path the production pipeline will use is +exercised before it exists: + +```bash +python backend/seed/palletizing.py # a seeded history: 6 runs +python backend/seed/palletizing.py --wipe # remove only what was seeded +python backend/seed/ejemplo_contrato.py # one entry touching the ENTIRE contract +python backend/seed/humo_live.py # builds a pallet slowly: smoke-tests Live +``` + +- **`palletizing.py`** — a plausible history. Not a simulation, but the causality is + real: the CoG moves according to where and with what mass each package lands, and the + stack collapses when it leaves the support polygon. +- **`ejemplo_contrato.py`** — one run that exercises every column in + [`docs/API.md`](docs/API.md): all five tables, all six event kinds with the `payload` + keys the event feed expects, several failure causes, and the edge cases the frontend + has to render (an episode that placed nothing, planned versus actual poses, positive + overhang). +- **`humo_live.py`** — drives the `begin()` / `event()` / `pallet_state()` / `end()` + cycle so Realtime has something to deliver. With `/` open in the browser, the pallet + must build package by package and the KPIs move on their own, without a reload. Its + run is labelled `humo-live`; delete it when you are done, because unlike the other two + it is **not** flagged synthetic. + +> [!IMPORTANT] +> Everything `palletizing.py` and `ejemplo_contrato.py` write is marked +> **`synthetic = true`**, and that is neither optional nor switchable. The interface +> hatches synthetic data and never lets it into a comparison with measured data. An +> invented number that reads as a measured one is the fastest way to lose credibility in +> front of a jury. + +To import real episodes already on disk: + +```bash +python backend/backfill.py --runs-dir /simulation/runs --dry-run +python backend/backfill.py --runs-dir /simulation/runs +``` + +--- + +## Development + +### Layout + +``` +backend/ SQL + SDK. Supabase IS the backend; there is no server of ours. + sql/001_schema.sql tables, views, RLS + sql/002_design.sql the columns and views the design needs + sql/003_snapshots.sql pallet snapshots (URLs; PNGs live in Storage) + theker_telemetry/ the SDK imported by whoever produces episodes + schema.py EpisodeResult, FAILURES, TASKS, RunWriter + core.py PostgREST client and RunLog + pallet.py support polygon and stability margin + seed/ sample-data generators + backfill.py uploads runs/ from disk to Supabase + tests/ the networked ones need DATABASE_URL + +frontend/ Next.js 16. Reads Supabase with the anon key, under RLS. + app/ routes: / (Live), /runs, /runs/[id], /runs/[id]/[seed] + api/clients/ one function per PostgREST endpoint + api/hooks/ the same, wrapped in TanStack Query + components/ atoms / molecules / organisms / screens + lib/ui.ts ALL unit conversion, in one place + lib/pallet.ts drawing geometry; the pallet size is NOT fixed + lib/supabase.ts the types, which are half the contract + styles/colors.css ALL colours, both themes, in one place + scripts/ check-colors.mjs · check-contrast.mjs + +docs/ + API.md the contract: endpoints, types and vocabularies + BRIEFING-observabilidad.md what is being built and why + design/ the 11 design boards, at 1440 px + img/ screenshots and brand variants used by this README +``` + +### Checks + +These are exactly what CI runs, so a red result here is a red pull request. + +```bash +# Frontend +cd frontend && npm ci && npm run lint && npm run build + +# Backend +pip install -e './backend[dev]' +ruff check backend && pytest backend -q +``` + +`npm run lint` is **three** checkers, not one: + +``` +eslint && node scripts/check-colors.mjs && node scripts/check-contrast.mjs +``` + +- **`check-colors.mjs`** fails on any colour literal outside + `frontend/styles/colors.css` — `#hex`, `rgb()`, `hsl()`, `oklch()`, named colours — in + CSS, TS and TSX alike. +- **`check-contrast.mjs`** fails on any text colour pair below WCAG 4.5:1 (3:1 for + meaningful graphical elements), **in both themes**. + +`pytest backend -q` touches no network. Point `DATABASE_URL` at a throwaway Postgres and +it additionally applies the real schema and verifies the contract against the frontend's +queries — which is what CI does: + +```bash +docker run --rm -d --name pg -e POSTGRES_PASSWORD=postgres -p 5433:5432 postgres:16 +export DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres +pytest backend -q +``` + +### npm scripts + +| Script | What it does | +|---|---| +| `npm run dev` | dev server on | +| `npm run build` | production build (also generates Next 16's `PageProps<…>` route types) | +| `npm run start` | serve the production build | +| `npm run lint` | eslint + colour checker + contrast checker | +| `npm run lint:colors` | the colour checker alone | +| `npm run lint:contrast` | the contrast checker alone | + +### House rules worth knowing before the first commit + +The full list, with the reasoning, is in [`AGENTS.md`](AGENTS.md) and +[`CONTRIBUTING.md`](CONTRIBUTING.md). + +- **No Tailwind.** CSS Modules next to each component; the design is exact values. +- **Colours only as `var(--…)`**, defined once in `frontend/styles/colors.css`. +- **The unit is always visible** — `4.1 mm`, never `4.1`. The database stores SI; the + screen shows millimetres and degrees; the conversion lives only in `frontend/lib/ui.ts`. +- **Never render a raw failure identifier.** `stack_collapse` reads as *«el montón se + derrumbó»*, via `FAILURE_TEXT`. +- **Medians, not means.** One episode collapsing at 3 s drags the mean cycle time down + and makes a bad run look fast. +- **The client aggregates nothing.** Totals, medians and dominant causes come out of the + SQL views. + +--- + +## Usage + +### Producing telemetry from a simulation + +This is the only integration point. A simulation repository installs the SDK +(`pip install -e /backend`) and imports it — that pulls in nothing else, because +it speaks PostgREST with `urllib` from the standard library. + +```python +from theker_telemetry import EpisodeResult, RunLog + +log = RunLog( + repo, # writes runs//episodes.jsonl here + task="palletizing", level=2, + oracle=False, # True when perception is replaced by real poses + config={"pallet_size_m": [0.6, 0.4]}, # the real pallet may be a scale model + remote=True, # also replicate to Supabase +) + +log.episode( + EpisodeResult( + seed=7, level=2, n_objects=12, n_placed=11, n_misrouted=0, + success=False, duration_s=48.2, failure="stack_collapse", oracle=False, + task="palletizing", metrics={"cog_offset_xy": 0.031, "fill_ratio": 0.62}, + ), + placements=[...], pallet_states=[...], events=[...], +) +log.close() +``` + +**For the Live screen to be live, use the episode lifecycle instead.** `episode()` +uploads everything at once, so there is no `running` row and no `UPDATE` for Realtime to +react to — Live would show a pallet that is already built: + +```python +log.begin(seed=7, n_objects=12) # inserts the episode as `running` +for i, package in enumerate(queue): + log.event(kind="pick", package_id=package.id, payload={...}) + log.placement(seq=i, package_id=package.id, actual_pose=[...]) + log.pallet_state(after_seq=i, mass_kg=..., cog_x=..., stability_margin_m=...) +log.end(result) # closes it with the EpisodeResult +``` + +`failure` and `task` are validated against closed vocabularies on construction — an +unknown value raises immediately rather than failing the upload later. + +
+The failure vocabulary + +| enum | on screen (Spanish) | +|---|---| +| `no_detection` | no vio ningún paquete | +| `ik_unreachable` | no alcanza la posición | +| `collision` | chocó | +| `grasp_slip` | se le escapó de la pinza | +| `wrong_placement` | lo dejó fuera de tolerancia | +| `timeout` | se quedó sin tiempo | +| `stack_collapse` | el montón se derrumbó | +| `overhang_violation` | lo dejó fuera del palé | +| `place_inaccurate` | *historical, no longer emitted* | + +The vocabulary is closed. Adding one means touching three places — +`backend/theker_telemetry/schema.py`, the `CHECK` in `backend/sql/001_schema.sql`, and +`frontend/lib/ui.ts` — and `backend/tests/test_vocabulario.py` fails if one lags behind. + +
+ +### Reading the screens + +**Live** (`/`) shows the episode that is running *now*. When it finishes it holds the +result for ten seconds, so the outcome is visible and so a following episode of the same +run replaces it directly instead of flickering. With nothing running it says so — it does +**not** backfill with the last finished episode, because that would read as if it were +happening. If the connection drops mid-episode it freezes the last thing it received and +says that too. It never goes blank. + +**Runs** (`/runs`) lists every run with its totals, filters, and a comparison panel. +`comparability()` in `frontend/lib/supabase.ts` refuses to show a delta between two runs +that did not measure the same thing — different task, different level, different seed +range, or one of them oracle or synthetic. Blocking that is not form validation; a +pretty, false chart is exactly what an evaluator goes looking for. + +**Episode** (`/runs/{id}`, or `/runs/{id}/{seed}`) is the same dashboard as Live against +a chosen episode: a top view and a side elevation of the pallet — drawn to the real +pallet size from `config.pallet_size_m`, with a toggle to the simulator's own snapshot — +plus the KPI grid, the result card and the event feed. + +### The API + +The frontend talks to PostgREST directly, with the anon key, under RLS. Every endpoint, +type and closed vocabulary is written down in **[`docs/API.md`](docs/API.md)**; the +design rationale is in +**[`docs/BRIEFING-observabilidad.md`](docs/BRIEFING-observabilidad.md)**. + +Reads go through three views — `v_run_summary`, `v_episode_summary`, +`v_failure_breakdown` — plus the `placements`, `pallet_states`, `events` and `snapshots` +tables. Realtime is published on `episodes`, `events` and `pallet_states`. + +--- + +## Dependencies + +Both sides are deliberately thin. + +### Python — `backend/` + +**The SDK has no runtime dependencies at all**, and that is a rule rather than a +coincidence: it is imported by the simulation repositories, so every package it grows is +a package they inherit, and one more thing that can break the night before a demo. It +speaks PostgREST with `urllib` from the standard library. + +`pytest` and `ruff` are pinned to exact versions in the `dev` extra — `ruff` changes its +default rule set between minor releases, and a `>=` range lets CI go red on a Tuesday +over something nobody touched. + +### Node — `frontend/` + +Five runtime dependencies, each pinned exactly to what `package-lock.json` had already +resolved: + +| Package | Version | Licence | +|---|---|---| +| [`next`](https://github.com/vercel/next.js) | 16.3.5 | MIT | +| [`react`](https://github.com/facebook/react) | 19.2.8 | MIT | +| [`react-dom`](https://github.com/facebook/react) | 19.2.8 | MIT | +| [`@supabase/supabase-js`](https://github.com/supabase/supabase-js) | 2.116.0 | MIT | +| [`@tanstack/react-query`](https://github.com/TanStack/query) | 5.103.1 | MIT | + +Dev: `typescript` 5.9.3, `eslint` 9.39.5, `eslint-config-next` 16.3.5 and the `@types/*` +packages — pinned exactly too. + +### Licence audit + +Measured, not assumed — `license-checker` over the installed Node tree, `pip-licenses` +over a clean venv of `backend[dev]`: + +```bash +cd frontend && npm ci && npx license-checker --production --summary +pip install -e './backend[dev]' pip-licenses && pip-licenses +``` + +**Python** — `backend[dev]` resolves to six packages: `pytest` (MIT), `ruff` (MIT), +`iniconfig` (MIT), `pluggy` (MIT), `Pygments` (BSD-2-Clause) and `packaging` +(Apache-2.0 OR BSD-2-Clause). All permissive. + +**Node** — the shipped set (`--production`, 32 packages): + +| Licence | Count | +|---|---| +| MIT | 21 | +| Apache-2.0 | 5 | +| ISC | 2 | +| BSD-3-Clause | 1 | +| 0BSD | 1 | +| CC-BY-4.0 | 1 | +| LGPL-3.0-or-later | 1 | + +Including dev dependencies (350 packages) adds only BSD-2-Clause ×7, MPL-2.0, +Python-2.0, CC0-1.0 and BlueOak-1.0.0 — all permissive, all build-time only. + +Two entries deserve a sentence each, since they are the only ones that are not plainly +permissive: + +- **`@img/sharp-libvips-linux-x64` — LGPL-3.0-or-later.** This is the prebuilt `libvips` + binary behind `sharp`, which Next.js declares as an **optional** dependency for image + optimisation. It is a separate, platform-specific npm package loaded dynamically at + runtime: it is not vendored into this repository, not statically linked, and not + bundled into the JavaScript the browser downloads. Using an LGPL library that way puts + no obligation on the licence of the work that uses it, so it does not constrain + releasing this project under MIT. (`sharp` itself is Apache-2.0.) +- **`caniuse-lite` — CC-BY-4.0.** A browser-support *dataset* consumed by the toolchain + at build time. Permissive with attribution, and none of it is redistributed here. + +**Conclusion: nothing in the dependency tree is incompatible with releasing this project +under the MIT licence.** + +--- + +## Security + +- **`SUPABASE_SERVICE_KEY` bypasses row-level security.** It exists only on the Python + side — SDK, backfill, seeds. It must never be committed and must never reach the + browser, under any variable name. +- Everything prefixed `NEXT_PUBLIC_` is compiled into JavaScript that anyone can + download. [`frontend/next.config.ts`](frontend/next.config.ts) therefore promotes an + explicit whitelist of exactly two keys, never iterating the `.env` file, and **throws + at build time** if any `NEXT_PUBLIC_*` variable's name contains `SERVICE_KEY`, + `SERVICE_ROLE`, `SECRET` or `PASSWORD`. +- The frontend reads with the **anon** key only, under the RLS policies created by + `backend/sql/001_schema.sql`. +- `.env` and `.env.*` are gitignored. `.env.example` is the only one committed, and it + carries placeholders. + +Found something that looks like a real problem? Open an issue without the exploit details +and we will take it from there. + +--- + +## Documentation + +| Document | What it covers | +|---|---| +| [`AGENTS.md`](AGENTS.md) | permanent repository context and the hard rules *(Spanish)* | +| [`CONTRIBUTING.md`](CONTRIBUTING.md) | branches, commits, checks, what CI enforces | +| [`docs/API.md`](docs/API.md) | the database↔frontend contract *(Spanish)* | +| [`docs/BRIEFING-observabilidad.md`](docs/BRIEFING-observabilidad.md) | what is being built and why *(Spanish)* | +| [`docs/design/`](docs/design/) | the 11 design boards at 1440 px — open the HTML bundle in a browser | + +Code identifiers are in English; comments and internal documents are in Spanish. + +--- + +## Licence + +[MIT](LICENSE) © 2026 STACKSPECT diff --git a/backend/backfill.py b/backend/backfill.py index e709c34..6671807 100644 --- a/backend/backfill.py +++ b/backend/backfill.py @@ -20,7 +20,7 @@ import argparse import json import re -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path BACKEND = Path(__file__).resolve().parent @@ -55,7 +55,7 @@ def started_at(directory: Path) -> str | None: stamp = datetime.strptime(match.group(1), "%Y%m%d-%H%M%S") # Las carpetas se nombraron en hora local: se marcan como tal antes de pasar a # UTC, o la interfaz las pinta desplazadas dos horas. - return stamp.astimezone().astimezone(timezone.utc).isoformat() + return stamp.astimezone().astimezone(UTC).isoformat() def run_row(directory: Path, rows: list[dict]) -> dict: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a5e680e..44c71e6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,5 +11,31 @@ requires-python = ">=3.11" # paquete nuevo es algo que puede romperse la noche antes de la demo. dependencies = [] +# Solo para desarrollar y para CI. El SDK que importa la simulación sigue sin +# dependencias: nada de esto viaja con él. +# +# Clavadas a una versión exacta y no a un mínimo: ruff cambia de reglas por defecto +# entre versiones menores, así que un `>=` deja que CI se ponga rojo un martes por algo +# que nadie ha tocado. Subirlas es un commit, y eso es justo lo que se quiere. +[project.optional-dependencies] +dev = ["pytest==9.1.1", "ruff==0.16.8"] + [tool.setuptools] packages = ["theker_telemetry"] + +[tool.pytest.ini_options] +# backfill.py y seed/ son scripts sueltos, no entran en el paquete instalado. +pythonpath = ["."] +testpaths = ["tests"] + +[tool.ruff] +# El código se escribe a ~90 a mano; el límite está alto a propósito para que E501 +# solo cace líneas desbocadas y no obligue a romper la tabla alineada de CAMPAIGN, +# donde la alineación ES lo que deja leer la curva de mejora de un vistazo. +line-length = 120 + +[tool.ruff.lint] +# Explícito a propósito: sin esto, el conjunto por defecto cambia con la versión de +# ruff y CI se pone rojo por algo que nadie ha tocado. E/F son los errores de verdad, +# I ordena los imports, UP evita que el código se quede en Python 3.8. +select = ["E", "F", "I", "UP"] diff --git a/backend/seed/__init__.py b/backend/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/seed/ejemplo_contrato.py b/backend/seed/ejemplo_contrato.py new file mode 100644 index 0000000..f6f58e9 --- /dev/null +++ b/backend/seed/ejemplo_contrato.py @@ -0,0 +1,244 @@ +"""Borra los datos de humo y deja UNA entrada de ejemplo que ejercita el contrato entero. + +Toca todas las columnas de API.md: las cinco tablas, los seis tipos de evento con las +claves de `payload` que espera EventFeed, varias causas de fallo, y los casos límite que +el front tiene que saber pintar (un episodio sin nada colocado, poses planificadas +contra reales, `overhang` positivo). + +Va marcada `synthetic = true`. No es opcional: son números inventados, y la regla del +proyecto es que nunca se confundan con medidos ni entren en una comparación. + + python ejemplo_contrato.py [--borrar-solo] +""" + +import argparse +import math +import random +import sys +from datetime import UTC, datetime, timedelta +from pathlib import Path + +BACKEND = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND)) +REPO = BACKEND.parent + +from seed.palletizing import BOXES, SLOTS, stability_margin, support_polygon # noqa: E402 +from theker_telemetry import Supabase # noqa: E402 + +ETIQUETA = "ejemplo-contrato" + +# seed -> (paquetes, causa de fallo o None, en qué paquete falla) +GUION = { + 20: (8, None, None), # éxito limpio: 4 capas + 21: (10, "stack_collapse", 8), # el montón se derrumba + 22: (8, None, None), # éxito + 23: (10, "overhang_violation", 7), # se sale del palé (hueco B) + 24: (10, "grasp_slip", 4), # se le escapa de la pinza + 25: (10, "no_detection", 0), # no ve nada: 0 colocados + # Dos más de lo mismo, para que haya una causa DOMINANTE de verdad y no un + # empate que mode() resuelve a cara o cruz. + 26: (10, "stack_collapse", 8), + 27: (10, "stack_collapse", 7), +} + + +def borrar(c: Supabase, *etiquetas: str) -> int: + n = 0 + for etiqueta in etiquetas: + for fila in c.get("runs", select="id", label=etiqueta): + c.delete("runs", {"id": fila["id"]}) # cascade limpia las hijas + n += 1 + return n + + +def episodio(rng: random.Random, paquetes: int, + causa: str | None, falla_en: int | None) -> dict: + """Un episodio entero: placements, pallet_states y events coherentes entre sí.""" + t = 0.0 + mx = my = mz = masa = 0.0 + base: list[tuple[float, float, float, float]] = [] + colocaciones, estados, eventos = [], [], [] + puestos = 0 + seq_ev = 0 + + def evento(kind: str, pkg: str | None, payload: dict) -> None: + nonlocal seq_ev + eventos.append({"ts": round(t, 2), "seq": seq_ev, "kind": kind, + "package_id": pkg, "payload": payload}) + seq_ev += 1 + + if causa == "no_detection": + t = 2.4 + evento("perceive", None, {"seen": 0, "confidence": 0.11}) + evento("fail", None, {"cause": causa}) + return {"placements": [], "pallet_states": [], "events": eventos, + "n_placed": 0, "duration_s": t, "failure": causa, "final": None} + + for i in range(paquetes): + nombre, dx, dy, dz, kg = BOXES[i % len(BOXES)] + capa = i // len(SLOTS) + 1 + sx, sy = SLOTS[i % len(SLOTS)] + z = (capa - 1) * dz + dz / 2 + + t += 0.5 + evento("perceive", f"pkg_{i:02d}", + {"seen": paquetes - i, "confidence": round(rng.uniform(0.93, 0.99), 2)}) + t += 0.3 + evento("plan", f"pkg_{i:02d}", {"layer": capa, "slot": "AB"[i % len(SLOTS)]}) + t += 0.6 + evento("pick", f"pkg_{i:02d}", {"mass_kg": kg}) + + # Se le escapa de la pinza: no llega a depositarse, así que no deja colocación. + if causa == "grasp_slip" and i == falla_en: + t += 0.4 + evento("fail", f"pkg_{i:02d}", {"cause": causa}) + break + + # Sesgo sistemático + ruido, para que la traza derive en vez de temblar. + ex = rng.gauss(0.004 if causa == "stack_collapse" else 0.0, 0.005) + ey = rng.gauss(0.003 if causa == "stack_collapse" else 0.0, 0.005) + if causa == "overhang_violation" and i == falla_en: + # Hacia AFUERA, que depende del hueco: sumar en el hueco A (x negativa) + # lo acercaría al centro y no se saldría de nada. + ex += 0.075 * (1 if sx > 0 else -1) + x, y = sx + ex, sy + ey + + masa += kg + mx += x * kg + my += y * kg + mz += z * kg + if capa == 1: + base.append((x, y, dx, dy)) + + cog = (mx / masa, my / masa, mz / masa) + margen = stability_margin(*cog, base) + x0, x1, y0, y1 = support_polygon(base) + overhang = max(0.0, x + dx / 2 - x1, x0 - (x - dx / 2)) + if causa == "stack_collapse" and i == falla_en: + margen = -0.012 # aquí vuelca + error = math.hypot(ex, ey) + deriva = abs(rng.gauss(0.002, 0.0015)) * (3 if margen < 0.02 else 1) + + t += 1.4 + evento("place", f"pkg_{i:02d}", + {"error_xy_mm": round(error * 1000, 1), + "error_yaw_deg": round(abs(rng.gauss(0, 0.5)), 1), + "overhang_mm": round(overhang * 1000, 1)}) + t += 0.8 + evento("settle", f"pkg_{i:02d}", + {"layer": capa, "drift_mm": round(deriva * 1000, 1)}) + + puesto = margen > 0 and overhang <= 0.02 + colocaciones.append({ + "seq": i, "package_id": f"pkg_{i:02d}", "package_type": nombre, + "mass_kg": kg, "dims_m": [dx, dy, dz], "layer": capa, + "planned_pose": {"x": sx, "y": sy, "z": round(z, 4), "yaw": 0.0}, + "actual_pose": {"x": round(x, 4), "y": round(y, 4), "z": round(z, 4), + "yaw": round(rng.gauss(0, 0.01), 4)}, + "error_xy_m": round(error, 5), + "error_yaw_rad": round(abs(rng.gauss(0, 0.009)), 5), + "support_ratio": round(min(1.0, 1.0 - overhang / dx), 3), + "overhang_m": round(overhang, 4), + "placed": puesto, + }) + estados.append({ + "after_seq": i, "mass_kg": round(masa, 3), + "cog_x": round(cog[0], 4), "cog_y": round(cog[1], 4), + "cog_z": round(cog[2], 4), + "stability_margin_m": round(margen, 4), + "fill_ratio": round((i + 1) / paquetes * 0.78, 3), + "settle_drift_m": round(deriva, 4), + }) + + if puesto: + puestos += 1 + else: + # La causa que se escribe es la que ha ocurrido de verdad. Si el guion + # pedía otra cosa, manda la física: un episodio que dice "éxito" con + # paquetes sin colocar es justo el dato falso que la plataforma existe + # para no publicar. + causa = causa if i == falla_en and causa else ( + "stack_collapse" if margen <= 0 else "overhang_violation") + t += 0.3 + evento("fail", f"pkg_{i:02d}", {"cause": causa}) + break + + return {"placements": colocaciones, "pallet_states": estados, "events": eventos, + "n_placed": puestos, "duration_s": round(t, 2), "failure": causa, + "final": estados[-1] if estados else None} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--borrar-solo", action="store_true") + args = ap.parse_args() + + c = Supabase.from_env(REPO) + if c is None: + print("sin credenciales de Supabase") + return 1 + + print(f"borrados {borrar(c, 'humo-live', ETIQUETA)} runs de prueba") + if args.borrar_solo: + return 0 + + rng = random.Random(20260919) + inicio = datetime.now(UTC) - timedelta(minutes=18) + run_id = c.insert("runs", [{ + "task": "palletizing", + "level": 2, + "git_sha": "a1b2c3d", + "oracle": False, + "synthetic": True, + "motion_speed": 4.0, + "label": ETIQUETA, + "description": "ejemplo del contrato: las 5 tablas, los 6 tipos de evento " + "y varias causas de fallo", + # `config` es jsonb libre y hasta ahora no lo escribía nadie. + "config": {"pallet_m": [1.2, 0.8], "lookahead": 3, "balance": True, + "transport_accel_g": 0.28}, + "n_episodes": len(GUION), + "started_at": inicio.isoformat(), + "ended_at": (inicio + timedelta(minutes=17)).isoformat(), + }], returning=True)[0]["id"] + + for n, (seed, (paquetes, causa, falla_en)) in enumerate(sorted(GUION.items())): + ep = episodio(rng, paquetes, causa, falla_en) + arranque = inicio + timedelta(minutes=n * 2.5) + final = ep["final"] + eid = c.insert("episodes", [{ + "run_id": run_id, "seed": seed, "task": "palletizing", "level": 2, + "status": "success" if ep["failure"] is None else "failure", + "started_at": arranque.isoformat(), + "ended_at": (arranque + timedelta(seconds=ep["duration_s"])).isoformat(), + "duration_s": ep["duration_s"], + "n_objects": paquetes, "n_placed": ep["n_placed"], + "score": round(ep["n_placed"] / paquetes, 3), + "failure": ep["failure"], + "metrics": { + "n_misrouted": 0, + "score": round(ep["n_placed"] / paquetes, 3), + "cog_offset_xy": round(math.hypot(final["cog_x"], final["cog_y"]), 4) + if final else None, + "fill_ratio": final["fill_ratio"] if final else 0.0, + "settle_drift": final["settle_drift_m"] if final else 0.0, + "n_layers": max((p["layer"] for p in ep["placements"]), default=0), + }, + }], returning=True)[0]["id"] + + for tabla, filas in (("placements", ep["placements"]), + ("pallet_states", ep["pallet_states"]), + ("events", ep["events"])): + if filas: + c.insert(tabla, [{**f, "episode_id": eid} for f in filas]) + + print(f" seed {seed} {ep['n_placed']}/{paquetes} " + f"{ep['failure'] or 'éxito'} " + f"({len(ep['events'])} ev, {len(ep['pallet_states'])} estados)") + + print(f"\nrun {run_id} -> /runs/{run_id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/seed/humo_live.py b/backend/seed/humo_live.py new file mode 100644 index 0000000..569942b --- /dev/null +++ b/backend/seed/humo_live.py @@ -0,0 +1,138 @@ +"""Prueba de humo de la pantalla Live: monta un palé de verdad, despacio. + +Usa el ciclo begin()/event()/pallet_state()/end() para que Realtime tenga algo que +repartir. Con `/` abierto en el navegador, el palé tiene que montarse paquete a paquete +y los KPIs moverse solos, sin recargar. + + python humo_live.py [--paquetes 8] [--pausa 1.5] +""" + +import argparse +import math +import random +import sys +import time +from pathlib import Path + +# Desde el directorio de trabajo y no desde __file__: este guion vive en el scratchpad, +# fuera del repo, así que subir por su propia ruta no llega a ninguna parte. +BACKEND = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BACKEND)) +REPO = BACKEND.parent + +from seed.palletizing import BOXES, SLOTS, stability_margin, support_polygon # noqa: E402 +from theker_telemetry import EpisodeResult, RunLog # noqa: E402 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--paquetes", type=int, default=8) + ap.add_argument("--pausa", type=float, default=1.5, + help="segundos entre paquetes; sube esto para verlo mejor") + args = ap.parse_args() + + rng = random.Random(20260919) + log = RunLog(REPO, task="palletizing", level=2, motion_speed=1.0, + label="humo-live", n_episodes=1, tag="humo") + if not log.run_id: + print("no hay Supabase configurado: esto solo sirve contra el proyecto real") + return 1 + print(f"run {log.run_id} -> /runs/{log.run_id}") + + log.begin(seed=37, n_objects=args.paquetes) + if not log.episode_id: + print("no se ha podido abrir el episodio") + return 1 + print(f"episodio {log.episode_id} en curso. Abre / en el navegador.\n") + + t = 0.0 + mx = my = mz = masa = 0.0 + base: list[tuple[float, float, float, float]] = [] + colocados = 0 + fallo = None + + for i in range(args.paquetes): + nombre, dx, dy, dz, kg = BOXES[i % len(BOXES)] + capa = i // len(SLOTS) + 1 + sx, sy = SLOTS[i % len(SLOTS)] + z = (capa - 1) * dz + dz / 2 + + t += 0.6 + log.event(ts=round(t, 2), seq=i * 4, kind="perceive", + package_id=f"pkg_{i:02d}", + payload={"seen": args.paquetes - i, "confidence": 0.97}) + + t += 0.4 + log.event(ts=round(t, 2), seq=i * 4 + 1, kind="plan", + package_id=f"pkg_{i:02d}", + payload={"layer": capa, "slot": "AB"[i % len(SLOTS)]}) + + # Un poco de imprecisión, para que la traza de CoG no sea una recta. + ex, ey = rng.gauss(0, 0.006), rng.gauss(0, 0.006) + x, y = sx + ex, sy + ey + + masa += kg + mx += x * kg + my += y * kg + mz += z * kg + if capa == 1: + base.append((x, y, dx, dy)) + + cog = (mx / masa, my / masa, mz / masa) + margen = stability_margin(*cog, base) + x0, x1, y0, y1 = support_polygon(base) if base else (0.0, 0.0, 0.0, 0.0) + overhang = max(0.0, max(x + dx / 2 - x1, x0 - (x - dx / 2))) + + t += 1.2 + log.event(ts=round(t, 2), seq=i * 4 + 2, kind="place", + package_id=f"pkg_{i:02d}", + payload={"error_xy_mm": round(math.hypot(ex, ey) * 1000, 1), + "error_yaw_deg": 0.4, + "overhang_mm": round(overhang * 1000, 1)}) + + puesto = margen > 0 and overhang <= 0.02 + log.placement(seq=i, package_id=f"pkg_{i:02d}", package_type=nombre, + mass_kg=kg, dims_m=[dx, dy, dz], layer=capa, + planned_pose={"x": sx, "y": sy, "z": z, "yaw": 0.0}, + actual_pose={"x": round(x, 4), "y": round(y, 4), + "z": round(z, 4), "yaw": 0.01}, + error_xy_m=round(math.hypot(ex, ey), 5), error_yaw_rad=0.01, + support_ratio=1.0, overhang_m=round(overhang, 4), placed=puesto) + + log.pallet_state(after_seq=i, mass_kg=round(masa, 3), + cog_x=round(cog[0], 4), cog_y=round(cog[1], 4), + cog_z=round(cog[2], 4), + stability_margin_m=round(margen, 4), + fill_ratio=round((i + 1) / args.paquetes * 0.7, 3), + settle_drift_m=round(abs(rng.gauss(0, 0.002)), 4)) + + if puesto: + colocados += 1 + print(f" paquete {i + 1}/{args.paquetes} capa {capa} " + f"margen {margen * 1000:+.0f} mm {'ok' if puesto else 'FALLO'}") + + if not puesto: + fallo = "stack_collapse" if margen <= 0 else "overhang_violation" + log.event(ts=round(t, 2), seq=i * 4 + 3, kind="fail", + package_id=f"pkg_{i:02d}", payload={"cause": fallo}) + break + + time.sleep(args.pausa) + + log.end(EpisodeResult( + seed=37, level=2, n_objects=args.paquetes, n_placed=colocados, n_misrouted=0, + success=fallo is None, duration_s=round(t, 2), failure=fallo, oracle=False, + task="palletizing", + metrics={"score": round(colocados / args.paquetes, 3), + "cog_offset_xy": round(math.hypot(mx / masa, my / masa), 4)}, + )) + log.close() + + print(f"\nepisodio cerrado: {colocados}/{args.paquetes} colocados, " + f"fallo={fallo or 'ninguno'}") + print(f"jsonl en {log.path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/seed/palletizing.py b/backend/seed/palletizing.py index d779c47..4108cbe 100644 --- a/backend/seed/palletizing.py +++ b/backend/seed/palletizing.py @@ -27,34 +27,26 @@ import math import random import sys -import urllib.request -from datetime import datetime, timedelta, timezone +import zlib +from datetime import UTC, datetime, timedelta from pathlib import Path BACKEND = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BACKEND)) REPO = BACKEND.parent -from theker_telemetry import EpisodeResult, Supabase # noqa: E402 +from theker_telemetry import EpisodeResult, Supabase # noqa: E402 + +# La geometría vive en el paquete: la calculan este sembrado y la simulación, y dos +# implementaciones del mismo número acaban discrepando. +from theker_telemetry.pallet import ( # noqa: E402 + stability_margin, +) # Palé europeo. El diseño insiste en que se dibuje a escala real, así que las medidas # tienen que ser las de verdad y no un cuadrado bonito. PALLET_X, PALLET_Y = 1.200, 0.800 -# El margen de estabilidad NO se mide contra el borde del palé, ni contra una -# envolvente fija. Un palé vuelca cuando, frenando o girando, el momento de vuelco -# supera al de restitución: con una aceleración lateral `a`, el montón cae si -# -# a/g > d / h d = distancia del CoG al borde del apoyo, h = altura del CoG -# -# de donde el margen que queda es `d - (a/g)*h`. Esto es lo que hace que el indicador -# signifique algo: apilar alto y descentrado lo empeora a la vez, que es exactamente -# el compromiso que el planificador tiene que resolver. -# -# El caso que se exige aguantar es un giro normal de carretilla, no estático. El reto -# pide "un palé que aguante el transporte", así que el listón se pone ahí. -TRANSPORT_ACCEL_G = 0.28 - # Catálogo de paquetes: base de 580x380, el medio módulo europeo con la holgura que se # deja de verdad en planta (un encaje al milímetro haría que cualquier error saliera # del palé, y el fallo dominante sería el equivocado). Las masas son las del diseño; @@ -100,31 +92,6 @@ ] -def support_polygon(base: list[tuple[float, float, float, float]] - ) -> tuple[float, float, float, float]: - """Rectángulo que apoya en el palé: la envolvente de las cajas de la capa 1. - - Lo que sostiene el montón es lo que toca el suelo, no el palé entero. Un palé con - una sola caja en una esquina tiene un apoyo pequeño aunque la tabla sea enorme.""" - xs0 = [x - dx / 2 for x, _, dx, _ in base] - xs1 = [x + dx / 2 for x, _, dx, _ in base] - ys0 = [y - dy / 2 for _, y, _, dy in base] - ys1 = [y + dy / 2 for _, y, _, dy in base] - return min(xs0), max(xs1), min(ys0), max(ys1) - - -def stability_margin(cog_x: float, cog_y: float, cog_z: float, - base: list[tuple[float, float, float, float]]) -> float: - """Margen de estabilidad en metros. Negativo = vuelca en la primera frenada. - - Es el indicador que define toda la interfaz, así que se calcula en un solo sitio.""" - if not base: - return 0.0 - x0, x1, y0, y1 = support_polygon(base) - d_edge = min(cog_x - x0, x1 - cog_x, cog_y - y0, y1 - cog_y) - return d_edge - TRANSPORT_ACCEL_G * cog_z - - def _plan(queue: list, free: list[int], lookahead: int, balance: bool, mx: float, my: float, total: float) -> tuple[int, int]: """Decide qué paquete de la cola se coge y en qué hueco va. @@ -273,6 +240,16 @@ def build_episode(rng: random.Random, precision: float, bias: float, "final": states[-1] if states else None} +def episode_seed(sha: str, seed: int) -> int: + """Semilla del RNG para un (commit, episodio). + + Con `hash()` esto NO era determinista: CPython aleatoriza el hash de las cadenas + por proceso desde la 3.3 (PYTHONHASHSEED), así que cada ejecución del sembrado + producía un histórico distinto mientras el comentario prometía lo contrario. Un + histórico que cambia solo no sirve para enseñar una curva de mejora.""" + return zlib.crc32(f"{sha}:{seed}".encode()) + + def seed_run(client: Supabase, sha: str, level: int, label: str, description: str, precision: float, bias: float, lookahead: int, balance: bool, *, seeds: range, started: datetime, rng: random.Random) -> tuple[str, int]: @@ -288,7 +265,7 @@ def seed_run(client: Supabase, sha: str, level: int, label: str, description: st for seed in seeds: # Semilla determinista por (commit, episodio): relanzar el sembrado dos veces # da el mismo histórico, igual que exigimos al simulador de verdad. - rng.seed(hash((sha, seed)) & 0xFFFFFFFF) + rng.seed(episode_seed(sha, seed)) ep = build_episode(rng, precision, bias, lookahead, balance, PACKAGES_BY_LEVEL[level]) result = EpisodeResult( @@ -325,10 +302,7 @@ def wipe(client: Supabase) -> int: """Borra solo lo sembrado. Lo real no se toca ni por accidente.""" rows = client.get("runs", select="id", synthetic="true") for row in rows: - request = urllib.request.Request( - f"{client.base}/runs?id=eq.{row['id']}", method="DELETE", - headers=client._headers("return=minimal")) - urllib.request.urlopen(request, timeout=10).close() + client.delete("runs", {"id": row["id"]}) return len(rows) @@ -350,7 +324,7 @@ def main() -> int: return 0 rng = random.Random() - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for i, (sha, level, label, desc, precision, bias, lookahead, balance) in enumerate(CAMPAIGN[:args.runs]): # Las más viejas primero: la campaña cuenta una mejora a lo largo del tiempo. started = now - timedelta(hours=(len(CAMPAIGN) - i) * 9) diff --git a/backend/sql/001_schema.sql b/backend/sql/001_schema.sql index 146131f..3171a6c 100644 --- a/backend/sql/001_schema.sql +++ b/backend/sql/001_schema.sql @@ -8,20 +8,20 @@ -- algo nuevo no obliga a migrar el esquema a las tres de la mañana. -- -- El `jsonl` de runs/ sigue siendo la fuente de verdad. Esto es una réplica --- consultable: si se pierde, se reconstruye con scripts/backfill.py. +-- consultable: si se pierde, se reconstruye con backend/backfill.py. -- ───────────────────────────────────────────────────────────────────────────── -- Tablas. Los vocabularios cerrados se expresan con CHECK y no con ENUM de -- Postgres a propósito: ampliar un CHECK es un ALTER de una línea, ampliar un -- ENUM es una migración. Si añades un valor aquí, añádelo TAMBIÉN en --- src/metrics.py y en AGENTS.md §5. +-- backend/theker_telemetry/schema.py y en AGENTS.md §5. -- ───────────────────────────────────────────────────────────────────────────── create table if not exists runs ( id uuid primary key default gen_random_uuid(), started_at timestamptz not null default now(), ended_at timestamptz, - task text not null check (task in ('induction', 'palletizing')), + task text not null, -- CHECK aparte, más abajo: ver nota level int not null, git_sha text not null default '', oracle bool not null default false, @@ -40,7 +40,7 @@ create table if not exists episodes ( id uuid primary key default gen_random_uuid(), run_id uuid not null references runs(id) on delete cascade, seed int not null, - task text not null check (task in ('induction', 'palletizing')), + task text not null, -- CHECK aparte, más abajo: ver nota level int not null, status text not null default 'running' check (status in ('running', 'success', 'failure')), @@ -59,12 +59,14 @@ create table if not exists episodes ( -- que ampliar el vocabulario sea volver a pegar este fichero y no una migración. -- -- Ojo a la distinción, que no es cosmética: este CHECK dice qué se puede ALMACENAR --- e incluye el histórico; `FAILURES` en src/metrics.py dice qué se puede PRODUCIR e --- incluye solo lo vivo. Por eso hay valores aquí que el código de hoy no emite: los --- midió una versión anterior y tirarlos sería falsear la curva de mejora. +-- e incluye el histórico; `FAILURES` en theker_telemetry/schema.py dice qué se puede +-- PRODUCIR e incluye solo lo vivo. Por eso hay valores aquí que el código de hoy no +-- emite: los midió una versión anterior y tirarlos sería falsear la curva de mejora. +-- +-- backend/tests/test_vocabulario.py comprueba que la asimetría es esa y no un olvido. alter table episodes drop constraint if exists episodes_failure_check; alter table episodes add constraint episodes_failure_check check (failure in ( - -- vigentes (= src/metrics.py:FAILURES) + -- vigentes (= theker_telemetry/schema.py:FAILURES) 'no_detection', 'ik_unreachable', 'collision', 'grasp_slip', 'wrong_placement', 'timeout', 'stack_collapse', 'overhang_violation', -- histórico: ya no se emite. 60 episodios de la rama de ordenación, donde @@ -77,6 +79,25 @@ comment on column episodes.metrics is comment on column episodes.failure is 'Vocabulario cerrado. La interfaz NUNCA muestra este identificador crudo: lo traduce.'; +-- El CHECK de `task` va fuera del CREATE TABLE por lo mismo que el de `failure`: +-- `create table if not exists` no toca una tabla que ya existe, así que un vocabulario +-- declarado ahí dentro solo se podría ampliar migrando a mano. Aquí se recrea en cada +-- pasada, y ampliarlo es volver a pegar este fichero. +-- +-- Si añades un valor, añádelo TAMBIÉN en theker_telemetry/schema.py:TASKS y en +-- frontend/lib/ui.ts:TASK_TEXT. backend/tests/test_vocabulario.py compara los tres. +do $$ +declare t text; +begin + foreach t in array array['runs', 'episodes'] + loop + execute format('alter table %I drop constraint if exists %I', t, t || '_task_check'); + execute format( + 'alter table %I add constraint %I check (task in (''induction'', ''palletizing'', ''paletizado-guionizado''))', + t, t || '_task_check'); + end loop; +end $$; + -- Un paquete depositado (o intentado). Nace para paletizado, pero la tabla es -- genérica: en inducción un "placement" es un bulto metido en su caja. create table if not exists placements ( @@ -140,81 +161,19 @@ create index if not exists idx_episodes_failure on episodes (failure) where fa create index if not exists idx_placements_episode on placements (episode_id, seq); create index if not exists idx_pallet_episode on pallet_states (episode_id, after_seq); create index if not exists idx_events_episode on events (episode_id, seq); +-- Live pregunta "¿hay algo corriendo?" en cada recarga (API.md §2.2). Parcial porque +-- lo normal es que no haya ninguno: ocupa casi nada y la consulta deja de barrer la +-- tabla entera de episodios. +create index if not exists idx_episodes_running on episodes (started_at desc) where status = 'running'; --- ───────────────────────────────────────────────────────────────────────────── --- Vistas de resumen. La interfaz NO agrega nada en el cliente: lo que pinta en --- una tabla o en un KPI sale ya calculado de aquí. --- ───────────────────────────────────────────────────────────────────────────── - -create or replace view v_run_summary as -select - r.id, - r.started_at, - r.ended_at, - r.task, - r.level, - r.git_sha, - r.oracle, - r.motion_speed, - r.label, - count(e.id) as episodes, - count(*) filter (where e.status = 'success') as successes, - -- nullif evita dividir por cero en un run que aún no ha escrito ningún episodio - round((count(*) filter (where e.status = 'success'))::numeric - / nullif(count(e.id), 0), 4) as success_rate, - sum(e.n_placed) as objects_placed, - sum(e.n_objects) as objects_total, - round(sum(e.n_placed)::numeric - / nullif(sum(e.n_objects), 0), 4) as placement_rate, - round(avg(e.duration_s)::numeric, 3) as mean_duration_s, - -- tiempo de ciclo: segundos por paquete efectivamente colocado - round(sum(e.duration_s)::numeric - / nullif(sum(e.n_placed), 0), 3) as cycle_time_s, - round(avg(e.score)::numeric, 4) as mean_score -from runs r -left join episodes e on e.run_id = r.id -group by r.id; - -create or replace view v_episode_summary as -select - e.id, - e.run_id, - e.seed, - e.task, - e.level, - e.status, - e.duration_s, - e.n_objects, - e.n_placed, - e.score, - e.failure, - e.metrics, - r.git_sha, - r.oracle, - r.motion_speed, - -- último estado del palé: con qué margen de estabilidad acabó el montón - (select ps.stability_margin_m - from pallet_states ps - where ps.episode_id = e.id - order by ps.after_seq desc - limit 1) as final_stability_m, - (select ps.fill_ratio - from pallet_states ps - where ps.episode_id = e.id - order by ps.after_seq desc - limit 1) as final_fill_ratio, - (select round(avg(p.error_xy_m)::numeric, 5) - from placements p - where p.episode_id = e.id and p.placed) as mean_error_xy_m -from episodes e -join runs r on r.id = e.run_id; - --- Causas de fallo por run: alimenta directamente FailureBreakdown. -create or replace view v_failure_breakdown as -select run_id, failure, count(*) as n -from episodes -where failure is not null -group by run_id, failure; +-- Las vistas de resumen NO se definen aquí, sino en 002_design.sql. +-- +-- Estuvieron en este fichero y se quitaron: 002 las tira con `drop ... cascade` y las +-- rehace con más columnas, así que definirlas aquí solo servía para que 002 las +-- borrase. Peor aún, volver a pegar 001 DESPUÉS de 002 fallaba con "cannot drop +-- columns from view", porque `create or replace view` no puede quitar columnas. Los +-- dos ficheros prometen en su cabecera que se pueden repegar: ahora es verdad en +-- cualquier orden. -- ───────────────────────────────────────────────────────────────────────────── -- RLS. La clave `anon` viaja al navegador: sin políticas esto sería un DELETE @@ -239,11 +198,6 @@ begin end loop; end $$; --- Las vistas ejecutan con los permisos de quien consulta, así que heredan el RLS --- de sus tablas base. Se marca explícitamente por si el default cambia. -alter view v_run_summary set (security_invoker = on); -alter view v_episode_summary set (security_invoker = on); -alter view v_failure_breakdown set (security_invoker = on); -- ───────────────────────────────────────────────────────────────────────────── -- Realtime. La vista Live se alimenta de estas suscripciones y de nada más. diff --git a/backend/sql/002_design.sql b/backend/sql/002_design.sql index 0eca230..d6fe7b9 100644 --- a/backend/sql/002_design.sql +++ b/backend/sql/002_design.sql @@ -47,6 +47,11 @@ select e.task, e.level, e.status, + -- Live ordena por started_at para quedarse con el último episodio cuando no hay + -- ninguno en curso (API.md §2.2). Sin proyectarlas aquí esa consulta devuelve + -- `400 42703` y la pantalla cae a "todavía no hay episodios" delante del jurado. + e.started_at, + e.ended_at, e.duration_s, e.n_objects, e.n_placed, @@ -57,6 +62,10 @@ select r.oracle, r.synthetic, r.motion_speed, + -- El montaje. Live solo tiene el episodio en la mano, así que `pallet_size_m` tiene + -- que viajar con él: el palé es una maqueta a escala y sin esto la pantalla lo pinta + -- a 1200x800 y todas las cotas salen mal. + r.config, -- Tiempo de ciclo: el número que entiende una planta. Null si no colocó nada, que -- es más honesto que un cero, el cual parecería infinitamente rápido. round((e.duration_s / nullif(e.n_placed, 0))::numeric, 3) as cycle_time_s, @@ -103,6 +112,7 @@ select r.motion_speed, r.label, r.description, + r.config, count(e.id) as episodes, count(*) filter (where e.status = 'success') as successes, round((count(*) filter (where e.status = 'success'))::numeric diff --git a/backend/sql/003_snapshots.sql b/backend/sql/003_snapshots.sql new file mode 100644 index 0000000..751f33e --- /dev/null +++ b/backend/sql/003_snapshots.sql @@ -0,0 +1,69 @@ +-- Imágenes del palé: una foto por paquete depositado. +-- +-- Se aplica después de 002, en el SQL editor de Supabase. Idempotente. +-- +-- La traza numérica del CoG ya cuenta qué pasó, pero un derrumbe se ENTIENDE viéndolo. +-- Esto es lo que permite revisar el episodio después, y que el scrubber enseñe el palé +-- en el instante `t` sin volver a simular nada. +-- +-- Solo se guarda la URL: los PNG viven en Storage, no en la base. Meter binarios en +-- Postgres es la vía rápida a una base que no se puede ni copiar. + +create table if not exists snapshots ( + id uuid primary key default gen_random_uuid(), + episode_id uuid not null references episodes(id) on delete cascade, + -- Tras qué colocación se tomó. Casa con pallet_states.after_seq, así que la foto y + -- el punto de la traza de CoG son el mismo instante. + after_seq int not null, + view text not null check (view in ('top', 'side', 'iso', 'camera')), + url text not null, + width int, + height int, + created_at timestamptz not null default now(), + unique (episode_id, after_seq, view) +); + +comment on column snapshots.after_seq is + 'Casa con pallet_states.after_seq: la foto y el punto de la traza son el mismo instante.'; +comment on column snapshots.view is + 'Qué se ve: cenital, alzado, isométrica o la cámara que alimenta la percepción.'; + +create index if not exists idx_snapshots_episode on snapshots (episode_id, after_seq); + +-- ───────────────────────────────────────────────────────────────────────────── +-- Storage. El bucket es público: las imágenes se sirven al navegador con la clave +-- anon, igual que el resto de la lectura. +-- +-- Guardado con to_regclass porque `storage.buckets` lo crea Supabase y NO existe en un +-- Postgres pelado. Sin esto el fichero revienta en CI, donde el esquema se aplica +-- contra un postgres:16 de serie. +-- ───────────────────────────────────────────────────────────────────────────── + +do $$ +begin + if to_regclass('storage.buckets') is not null then + insert into storage.buckets (id, name, public) + values ('snapshots', 'snapshots', true) + on conflict (id) do update set public = true; + else + raise notice 'sin esquema storage: se salta el bucket (normal fuera de Supabase)'; + end if; +end $$; + +-- ───────────────────────────────────────────────────────────────────────────── +-- RLS y realtime, igual que el resto: lectura pública, escritura solo service_role. +-- ───────────────────────────────────────────────────────────────────────────── + +alter table snapshots enable row level security; + +drop policy if exists lectura_publica on snapshots; +create policy lectura_publica on snapshots + for select to anon, authenticated using (true); + +grant select on snapshots to anon, authenticated; + +-- Live las quiere según llegan, como los eventos. +do $$ +begin + begin alter publication supabase_realtime add table snapshots; exception when duplicate_object then null; end; +end $$; diff --git a/backend/tests/test_backfill.py b/backend/tests/test_backfill.py new file mode 100644 index 0000000..f9709a2 --- /dev/null +++ b/backend/tests/test_backfill.py @@ -0,0 +1,136 @@ +"""El camino de vuelta: los `episodes.jsonl` históricos de disco a Supabase. + +Lo delicado aquí es que en disco conviven DOS formatos —el de clasificación de +inducción y el de la rama de ordenación— y ninguno se puede perder: son la línea base +contra la que se mide el paletizado. Por eso todo lo que no es columna cae en `metrics` +en vez de descartarse. +""" + +import json +from datetime import datetime +from pathlib import Path + +from backfill import episode_rows, read_rows, run_row, started_at + +INDUCCION = {"seed": 3, "task": "induction", "level": 1, "duration_s": 12.5, + "n_objects": 6, "n_placed": 5, "n_misrouted": 1, "success": False, + "failure": "grasp_slip", "git_sha": "abc1234", "oracle": False} + +ORDENACION = {"seed": 4, "task": "induction", "level": 2, "duration_s": 30.0, + "n_objects": 8, "n_placed": 8, "success": True, "failure": None, + "git_sha": "abc1234", "oracle": True, + "n_slots": 4, "mean_error_xy": 0.004, "score": 0.91, + "goal_source": "oracle"} + + +def carpeta(tmp_path: Path, nombre: str, filas: list[dict]) -> Path: + directorio = tmp_path / nombre + directorio.mkdir() + (directorio / "episodes.jsonl").write_text( + "".join(json.dumps(f) + "\n" for f in filas), encoding="utf-8") + return directorio + + +# ── lectura ────────────────────────────────────────────────────────────────── + +def test_read_rows_salta_las_lineas_en_blanco(tmp_path): + path = tmp_path / "episodes.jsonl" + path.write_text('{"seed": 1}\n\n \n{"seed": 2}\n', encoding="utf-8") + assert [r["seed"] for r in read_rows(path)] == [1, 2] + + +# ── la marca de tiempo del nombre de carpeta ───────────────────────────────── + +def test_started_at_interpreta_el_sello_como_hora_local(tmp_path): + """Las carpetas se nombraron en hora local: si se leyeran como UTC, la interfaz + las pintaría desplazadas dos horas.""" + iso = started_at(carpeta(tmp_path, "20260919-082926", [INDUCCION])) + + esperado = datetime(2026, 9, 19, 8, 29, 26).astimezone() + assert datetime.fromisoformat(iso) == esperado + + +def test_started_at_acepta_el_sufijo_de_etiqueta(tmp_path): + assert started_at(carpeta(tmp_path, "20260919-000731-l1-oracle", [INDUCCION])) + + +def test_started_at_de_una_carpeta_que_no_sigue_el_patron(tmp_path): + assert started_at(carpeta(tmp_path, "pruebas-sueltas", [INDUCCION])) is None + + +# ── la fila de `runs` ──────────────────────────────────────────────────────── + +def test_run_row_toma_la_identidad_de_la_primera_fila(tmp_path): + d = carpeta(tmp_path, "20260919-082926-l1", [INDUCCION, ORDENACION]) + fila = run_row(d, [INDUCCION, ORDENACION]) + + assert fila["task"] == "induction" + assert fila["level"] == 1 + assert fila["git_sha"] == "abc1234" + assert fila["oracle"] is False + assert fila["n_episodes"] == 2 + + +def test_run_row_guarda_el_nombre_de_carpeta_como_etiqueta(tmp_path): + """Es la trazabilidad de vuelta al disco: sin ella una fila de la interfaz no se + puede casar con su `episodes.jsonl`.""" + d = carpeta(tmp_path, "20260919-082926-l4", [INDUCCION]) + assert run_row(d, [INDUCCION])["label"] == "20260919-082926-l4" + + +def test_run_row_lee_la_velocidad_del_sufijo(tmp_path): + d = carpeta(tmp_path, "20260919-082926-l2-v4", [INDUCCION]) + assert run_row(d, [INDUCCION])["motion_speed"] == 4.0 + + +def test_run_row_sin_sufijo_de_velocidad_asume_tiempo_real(tmp_path): + d = carpeta(tmp_path, "20260919-082926-l2", [INDUCCION]) + assert run_row(d, [INDUCCION])["motion_speed"] == 1.0 + + +# ── las filas de `episodes` ────────────────────────────────────────────────── + +def test_lo_que_no_es_columna_cae_en_metrics(): + fila = episode_rows([ORDENACION], "run-1")[0] + + assert fila["metrics"]["n_slots"] == 4 + assert fila["metrics"]["mean_error_xy"] == 0.004 + assert fila["metrics"]["goal_source"] == "oracle" + + +def test_el_formato_viejo_de_induccion_tambien_se_conserva(): + fila = episode_rows([INDUCCION], "run-1")[0] + assert fila["metrics"]["n_misrouted"] == 1 + + +def test_las_columnas_no_se_duplican_en_metrics(): + fila = episode_rows([ORDENACION], "run-1")[0] + for columna in ("seed", "task", "level", "duration_s", "n_objects", "n_placed", + "success", "failure"): + assert columna not in fila["metrics"] + + +def test_git_sha_y_oracle_no_bajan_al_episodio(): + """Describen el run. Repetirlos por episodio es invitarlos a desincronizarse.""" + fila = episode_rows([ORDENACION], "run-1")[0] + assert "git_sha" not in fila and "oracle" not in fila + assert "git_sha" not in fila["metrics"] and "oracle" not in fila["metrics"] + + +def test_success_se_traduce_a_status(): + filas = episode_rows([INDUCCION, ORDENACION], "run-1") + assert [f["status"] for f in filas] == ["failure", "success"] + + +def test_el_score_sube_de_metrics_a_columna(): + assert episode_rows([ORDENACION], "run-1")[0]["score"] == 0.91 + assert episode_rows([INDUCCION], "run-1")[0]["score"] is None + + +def test_todas_las_filas_llevan_su_run_id(): + filas = episode_rows([INDUCCION, ORDENACION], "run-42") + assert {f["run_id"] for f in filas} == {"run-42"} + + +def test_la_fila_resultante_es_serializable(): + json.dumps(episode_rows([INDUCCION, ORDENACION], "run-1")) diff --git a/backend/tests/test_contrato.py b/backend/tests/test_contrato.py new file mode 100644 index 0000000..26088dd --- /dev/null +++ b/backend/tests/test_contrato.py @@ -0,0 +1,325 @@ +"""El contrato con el front, verificado contra un Postgres de verdad. + +Supabase ES el backend: no hay servidor propio que pueda tener tests de integración, así +que el contrato son las relaciones que expone PostgREST y los tipos que el front escribe +a mano. Nada comparaba los dos lados, y por ahí se colaron tres cosas: `started_at` +ausente de la vista, `round()` sobre `double precision`, y `mean_score` sin declarar. + +Se levanta el esquema de verdad y se lee `information_schema` en vez de parsear los +`create view` con un regex: esas vistas tienen subqueries correlacionadas, comentarios y +alias en otra línea, y un parser frágil que da falsos positivos la noche antes de la +demo es peor que no tener nada. Además, ejecutar el SQL es lo único que caza los errores +que solo aparecen con filas dentro. + + docker run --rm -d --name pg -e POSTGRES_PASSWORD=postgres -p 5433:5432 postgres:16 + export DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres + pytest backend -q + +Sin `DATABASE_URL` se salta entero. +""" + +import os +import re +import shutil +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +RAIZ = Path(__file__).resolve().parents[2] +SQL_DIR = RAIZ / "backend" / "sql" +DSN = os.environ.get("DATABASE_URL", "") +ESQUEMA = f"contrato_{os.getpid()}" + +pytestmark = pytest.mark.skipif( + not DSN, reason="sin DATABASE_URL: el contrato contra Postgres no se comprueba") + + +# ── psql, que es todo el cliente que hace falta ────────────────────────────── + +def psql(sql: str, *, schema: str | None = ESQUEMA) -> str: + return _run(["-c", sql], schema) + + +def psql_file(path: Path, schema: str = ESQUEMA) -> str: + return _run(["-f", str(path)], schema) + + +def _run(args: list[str], schema: str | None) -> str: + entorno = dict(os.environ) + if schema: + entorno["PGOPTIONS"] = f"-c search_path={schema}" + hecho = subprocess.run( + # -q para que `insert ... returning id` devuelva el id y no además el + # "INSERT 0 1" que psql imprime como etiqueta de la orden. + ["psql", DSN, "-v", "ON_ERROR_STOP=1", "-At", "-q", *args], + capture_output=True, text=True, env=entorno, + ) + if hecho.returncode: + raise RuntimeError(hecho.stderr.strip() or hecho.stdout.strip()) + return hecho.stdout.strip() + + +@pytest.fixture(scope="module", autouse=True) +def esquema(): + """Levanta el esquema entero en una carpeta aparte y la tira al acabar. + + En un esquema propio y no en `public` por una razón muy concreta: si alguien apunta + DATABASE_URL a un Supabase de verdad, esto no puede llevarse por delante los datos. + Por si acaso, además, solo se habla con localhost. + """ + # Con DATABASE_URL puesto y sin psql, lo que toca es decirlo una vez y claro, no + # soltar veinte trazas idénticas de FileNotFoundError. + assert shutil.which("psql"), ( + "hay DATABASE_URL pero no encuentro psql. En Debian/Ubuntu: " + "apt-get install -y postgresql-client") + + host = urlparse(DSN).hostname or "" + assert host in ("localhost", "127.0.0.1", "::1", "db", "postgres"), ( + f"DATABASE_URL apunta a {host!r}: esto crea y borra objetos, " + f"solo se ejecuta contra un Postgres desechable") + + # Lo que Supabase trae puesto y un Postgres pelado no. Los .sql no se tocan. + for sentencia in ("create role anon", + "create role authenticated", + "create publication supabase_realtime"): + try: + psql(sentencia, schema=None) + except RuntimeError as err: + if "already exists" not in str(err): + raise + + psql(f"drop schema if exists {ESQUEMA} cascade; create schema {ESQUEMA}", + schema=None) + try: + yield + finally: + psql(f"drop schema if exists {ESQUEMA} cascade", schema=None) + + +@pytest.fixture(scope="module", autouse=True) +def aplicado(esquema): + """El esquema, ya aplicado, para el resto de los tests.""" + for fichero in sorted(SQL_DIR.glob("*.sql")): + psql_file(fichero) + + +# ── 1. el esquema aplica, y aplica dos veces ───────────────────────────────── + +def test_los_dos_ficheros_aplican_y_son_idempotentes(): + """Las cabeceras de 001 y 002 prometen que se pueden volver a pegar enteros. Y en + cualquier orden: 001 después de 002 fallaba con 'cannot drop columns from view' + mientras 001 definía vistas que 002 rehacía.""" + ficheros = sorted(SQL_DIR.glob("*.sql")) + assert ficheros, "no hay ningún .sql que aplicar" + for fichero in [*ficheros, *ficheros]: + psql_file(fichero) + + +# ── 2. lo que el front consulta tiene que existir ──────────────────────────── + +def columnas(relacion: str) -> set[str]: + filas = psql( + "select column_name from information_schema.columns " + f"where table_schema = '{ESQUEMA}' and table_name = '{relacion}'") + return set(filas.splitlines()) - {""} + + +def fuentes_ts() -> list[Path]: + return [p for p in (RAIZ / "frontend").rglob("*.ts") + if "node_modules" not in p.parts] + + +def consultas_del_front() -> set[tuple[str, str]]: + """Los pares (relación, columna) de cada `.from("x")` seguido de `.order`/`.eq`.""" + pares = set() + for fichero in fuentes_ts(): + texto = fichero.read_text(encoding="utf-8") + for trozo in texto.split('.from("')[1:]: + relacion, _, resto = trozo.partition('"') + # Se corta en el siguiente .from o en el fin de sentencia, para no + # atribuirle a una relación los filtros de la siguiente. + resto = re.split(r"\.from\(|;", resto)[0] + for columna in re.findall(r'\.(?:order|eq|neq|gt|lt|gte|lte)\(\s*"(\w+)"', + resto): + pares.add((relacion, columna)) + return pares + + +def test_el_front_solo_filtra_y_ordena_por_columnas_que_existen(): + """El bug de Live vivía aquí, y NO lo caza comparar columnas contra tipos: `Episode` + tampoco declaraba `started_at`, así que los dos lados coincidían y el error estaba + en un literal de cadena. `?order=started_at.desc` sobre una vista que no la proyecta + devuelve 400 42703 y la pantalla se queda sin episodio.""" + pares = consultas_del_front() + assert pares, "no he encontrado ninguna consulta en frontend/**/*.ts" + + huerfanas = sorted( + f"{relacion}.{columna}" for relacion, columna in pares + if columnas(relacion) and columna not in columnas(relacion)) + assert not huerfanas, f"columnas que no existen: {huerfanas}" + + +def test_las_relaciones_que_consulta_el_front_existen(): + relaciones = {relacion for relacion, _ in consultas_del_front()} + assert relaciones + for relacion in relaciones: + assert columnas(relacion), f"{relacion} no existe en el esquema" + + +# ── 3. los tipos TS caben en lo que devuelve la base ───────────────────────── + +CONTRATO = { + "Run": "v_run_summary", + "Episode": "v_episode_summary", + "Placement": "placements", + "PalletState": "pallet_states", + "RunEvent": "events", +} + + +def campos_ts(tipo: str) -> set[str]: + for fichero in fuentes_ts(): + bloque = re.search(rf"export type {tipo} = \{{(.*?)\n\}};", + fichero.read_text(encoding="utf-8"), re.S) + if bloque: + return set(re.findall(r"^\s{2}(\w+)\??:", bloque.group(1), re.M)) + raise AssertionError(f"no encuentro el tipo {tipo}") + + +@pytest.mark.parametrize("tipo,relacion", sorted(CONTRATO.items())) +def test_cada_campo_declarado_existe_en_la_base(tipo, relacion): + """Subconjunto, no igualdad: una columna de más no rompe nada y a veces es + deliberada —`mean_score` está en la vista y no en `Run` a propósito (API.md §2.1)—, + pero un campo declarado que no existe llega como `undefined` y se pinta mal en + silencio, que es el fallo que nadie ve hasta la demo.""" + faltan = sorted(campos_ts(tipo) - columnas(relacion)) + assert not faltan, f"{tipo} declara campos que {relacion} no tiene: {faltan}" + + +def test_v_failure_breakdown_es_lo_que_dice_el_contrato(): + """No se compara contra ningún tipo TS porque el front no la tipa (API.md §2.3); + inventarle uno sería fabricar falsos positivos.""" + assert columnas("v_failure_breakdown") == {"run_id", "failure", "n"} + + +# ── 4. las vistas calculan lo que dicen que calculan ───────────────────────── + +@pytest.fixture +def run_de_prueba(): + """Un run con tres episodios, uno lentísimo, para separar mediana de media.""" + run_id = psql( + "insert into runs (task, level, git_sha) " + "values ('palletizing', 2, 'test123') returning id") + psql( + "insert into episodes " + " (run_id, seed, task, level, status, duration_s, n_objects, n_placed, failure) " + "values " + f" ('{run_id}', 1, 'palletizing', 2, 'success', 1, 10, 1, null)," + f" ('{run_id}', 2, 'palletizing', 2, 'success', 2, 10, 1, null)," + f" ('{run_id}', 3, 'palletizing', 2, 'failure', 90, 10, 1, 'stack_collapse')") + yield run_id + psql(f"delete from runs where id = '{run_id}'") + + +def test_las_medianas_son_medianas_y_no_medias(run_de_prueba): + """AGENTS.md §4: un episodio que se derrumba a los 3 s arrastra la media y hace + parecer rápido a un run que va mal. Con 1, 2 y 90 la mediana es 2; la media, 31.""" + assert psql("select median_cycle_s from v_run_summary " + f"where id = '{run_de_prueba}'") == "2.000" + + +def test_el_tiempo_de_ciclo_es_null_si_no_coloco_nada(run_de_prueba): + """Más honesto que un cero, que parecería infinitamente rápido.""" + vacio = psql( + "insert into episodes " + " (run_id, seed, task, level, status, duration_s, n_objects, n_placed) " + f"values ('{run_de_prueba}', 9, 'palletizing', 2, 'failure', 5, 10, 0) " + "returning id") + assert psql("select cycle_time_s from v_episode_summary " + f"where id = '{vacio}'") == "" + + +def test_la_causa_dominante_sale_del_run(run_de_prueba): + assert psql("select dominant_failure from v_run_summary " + f"where id = '{run_de_prueba}'") == "stack_collapse" + + +def test_sin_fallos_no_se_inventa_una_causa_dominante(): + """`null`, no un 'ninguno' inventado.""" + run_id = psql("insert into runs (task, level) values ('palletizing', 1) returning id") + psql("insert into episodes (run_id, seed, task, level, status, n_objects, n_placed) " + f"values ('{run_id}', 1, 'palletizing', 1, 'success', 5, 5)") + try: + assert psql("select dominant_failure from v_run_summary " + f"where id = '{run_id}'") == "" + finally: + psql(f"delete from runs where id = '{run_id}'") + + +def test_un_run_sin_episodios_sale_con_seed_series_vacia(): + """`[]` y no `null`: el front hace `.map()` sobre esto sin mirar (Sparkline).""" + run_id = psql("insert into runs (task, level) values ('palletizing', 1) returning id") + try: + assert psql("select episodes, seed_series from v_run_summary " + f"where id = '{run_id}'") == "0|[]" + finally: + psql(f"delete from runs where id = '{run_id}'") + + +def test_las_columnas_derivadas_se_evaluan_con_filas_dentro(run_de_prueba): + """El commit 8f235a1 fue justo esto: `round()` sobre `double precision` no existe en + Postgres, y la expresión de `load_height_m` solo se evalúa si hay placements.""" + episodio = psql("select id from v_episode_summary " + f"where run_id = '{run_de_prueba}' and seed = 1") + psql("insert into placements " + " (episode_id, seq, package_id, package_type, dims_m, layer, " + " actual_pose, error_xy_m, placed) " + f"values ('{episodio}', 0, 'pkg_00', 'caja baja', '{{0.58,0.38,0.2}}', 1, " + " '{\"x\":0,\"y\":0,\"z\":0.1}'::jsonb, 0.004, true)") + + assert psql("select load_height_m, n_layers, mean_error_xy_m " + f"from v_episode_summary where id = '{episodio}'") == "0.2000|1|0.00400" + + +# ── 5. el vocabulario cerrado lo impone la base ────────────────────────────── + +def test_la_base_rechaza_una_causa_de_fallo_inventada(run_de_prueba): + """Es lo que hace que saltarse el CHECK al añadir una causa tumbe la subida entera, + y por eso el vocabulario se comprueba en los tres sitios (test_vocabulario.py).""" + with pytest.raises(RuntimeError, match="episodes_failure_check|23514"): + psql("insert into episodes " + " (run_id, seed, task, level, status, failure) " + f"values ('{run_de_prueba}', 77, 'palletizing', 2, 'failure', 'se_rompio')") + + +def test_no_se_pueden_repetir_semillas_dentro_de_un_run(run_de_prueba): + with pytest.raises(RuntimeError, match="unique|duplicate|llave duplicada"): + psql("insert into episodes (run_id, seed, task, level, status) " + f"values ('{run_de_prueba}', 1, 'palletizing', 2, 'success')") + + +# ── 6. realtime y RLS, que es de lo que vive Live ──────────────────────────── + +def test_las_tres_tablas_de_live_estan_publicadas(): + """Live se alimenta de estas suscripciones y de nada más (API.md §4).""" + publicadas = set(psql( + "select tablename from pg_publication_tables " + f"where pubname = 'supabase_realtime' and schemaname = '{ESQUEMA}'").splitlines()) + assert {"events", "pallet_states", "episodes"} <= publicadas + + +def test_todo_lo_legible_tiene_rls_y_politica_de_lectura(): + """La clave `anon` viaja al navegador: sin políticas esto sería un DELETE abierto.""" + assert psql("select tablename from pg_tables " + f"where schemaname = '{ESQUEMA}' and not rowsecurity") == "" + + # Sin lista a fuego: cualquier tabla nueva queda cubierta por este test el día que + # se añada, que es justo cuando alguien se puede olvidar de darle su política. + tablas = set(psql( + f"select tablename from pg_tables where schemaname = '{ESQUEMA}'").splitlines()) + con_politica = set(psql( + f"select tablename from pg_policies where schemaname = '{ESQUEMA}'").splitlines()) + assert tablas, "el esquema no tiene tablas" + assert tablas - con_politica == set(), f"sin política de lectura: {tablas - con_politica}" diff --git a/backend/tests/test_core.py b/backend/tests/test_core.py new file mode 100644 index 0000000..d699436 --- /dev/null +++ b/backend/tests/test_core.py @@ -0,0 +1,571 @@ +"""El SDK contra Supabase, sin tocar la red. + +Lo que de verdad se protege aquí es la promesa dura de AGENTS.md §4: el disco manda. +Un fallo de red avisa UNA vez y el episodio sigue. Si esto se rompe, un benchmark de +cincuenta episodios se cae por el wifi de la sala y no queda ni el jsonl. +""" + +import io +import json +import urllib.error +import urllib.request + +import pytest + +from theker_telemetry import core +from theker_telemetry.core import RunLog, Supabase, _json_safe, episode_row, load_env +from theker_telemetry.schema import EpisodeResult + + +def episodio(**cambios) -> EpisodeResult: + base = dict( + seed=7, level=2, n_objects=10, n_placed=7, n_misrouted=0, success=False, + duration_s=42.0, failure="stack_collapse", oracle=False, git_sha="abc1234", + task="palletizing", metrics={"cog_offset_xy": 0.031, "score": 0.7}, + ) + return EpisodeResult(**{**base, **cambios}) + + +class ClienteFalso: + """Un Supabase de mentira que apunta lo que le piden y falla cuando se le dice.""" + + def __init__(self, falla_en=()): + self.llamadas = [] + self.falla_en = set(falla_en) + self._n = 0 + + def insert(self, table, rows, *, returning=False): + self.llamadas.append(("insert", table, [dict(r) for r in rows])) + if table in self.falla_en: + raise RuntimeError(f"{table}: HTTP 503 se cayó") + if not returning: + return [] + self._n += 1 + return [{"id": f"{table}-{self._n}"}] + + def patch(self, table, match, data): + self.llamadas.append(("patch", table, dict(match), dict(data))) + if table in self.falla_en: + raise RuntimeError(f"{table}: HTTP 503 se cayó") + + def tablas(self, verbo="insert"): + return [c[1] for c in self.llamadas if c[0] == verbo] + + +def log_conectado(tmp_path, cliente) -> RunLog: + """Un RunLog con el remoto ya abierto, sin pasar por `Supabase.from_env`.""" + log = RunLog(tmp_path, task="palletizing", level=2, remote=False) + log.client = cliente + log.run_id = "run-1" + return log + + +# ── episode_row: lo que era demo(), como test ──────────────────────────────── + +def test_episode_row_traduce_el_episodio_a_fila(): + row = episode_row(episodio(), "11111111-1111-1111-1111-111111111111") + + assert json.loads(json.dumps(row))["status"] == "failure" + assert row["failure"] == "stack_collapse" + assert row["score"] == 0.7 + assert row["metrics"] == {"cog_offset_xy": 0.031, "score": 0.7, "n_misrouted": 0} + + +def test_episode_row_no_repite_git_sha_ni_oracle(): + """Viven en `runs`; repetirlos por episodio es invitarlos a desincronizarse, y las + vistas ya los recuperan por el join (API.md §3).""" + row = episode_row(episodio(), "run-1") + assert "git_sha" not in row + assert "oracle" not in row + + +def test_episode_row_marca_exito(): + assert episode_row(episodio(success=True, failure=None), "r")["status"] == "success" + + +def test_episode_row_no_pisa_un_n_misrouted_explicito(): + row = episode_row(episodio(n_misrouted=3, metrics={"n_misrouted": 9}), "r") + assert row["metrics"]["n_misrouted"] == 9 + + +def test_demo_sigue_funcionando_sin_red(capsys): + """Se ejecuta desde el repo de la simulación sin pytest instalado.""" + core.demo() + assert "ok" in capsys.readouterr().out + + +# ── _json_safe: numpy sin importar numpy ───────────────────────────────────── + +class EscalarFalso: + """Se comporta como un `np.float32`: tiene `.item()` y `ndim == 0`.""" + ndim = 0 + + def __init__(self, valor): + self._valor = valor + + def item(self): + return self._valor + + +class ArrayFalso: + """Se comporta como un `np.ndarray`: tiene `.tolist()`.""" + + def __init__(self, valor): + self._valor = valor + + def tolist(self): + return self._valor + + +def test_json_safe_desactiva_escalares_y_arrays(): + crudo = {"a": EscalarFalso(0.5), "b": ArrayFalso([EscalarFalso(1), 2])} + assert _json_safe(crudo) == {"a": 0.5, "b": [1, 2]} + + +def test_json_safe_recorre_estructuras_anidadas(): + crudo = {"x": [{"y": (EscalarFalso(3),)}]} + assert _json_safe(crudo) == {"x": [{"y": [3]}]} + + +def test_json_safe_deja_pasar_lo_que_ya_es_json(): + crudo = {"i": 1, "f": 1.5, "s": "t", "b": True, "n": None} + assert _json_safe(crudo) == crudo + + +def test_json_safe_convierte_a_texto_lo_que_no_sabe_serializar(): + class Raro: + def __repr__(self): + return "" + + assert _json_safe({"k": Raro()}) == {"k": ""} + + +def test_json_safe_produce_algo_que_json_dumps_acepta(): + json.dumps(_json_safe({"a": EscalarFalso(0.5), "b": ArrayFalso([1.0])})) + + +# ── load_env ───────────────────────────────────────────────────────────────── + +def test_load_env_lee_el_env_del_repo(tmp_path, monkeypatch): + monkeypatch.setattr(core.os, "environ", {}) + (tmp_path / ".env").write_text("SUPABASE_URL=https://a.supabase.co\n") + + load_env(tmp_path) + assert core.os.environ["SUPABASE_URL"] == "https://a.supabase.co" + + +def test_load_env_mira_la_carpeta_padre(tmp_path, monkeypatch): + """Las credenciales son del hackathon y se comparten entre los dos repos.""" + monkeypatch.setattr(core.os, "environ", {}) + repo = tmp_path / "platform" + repo.mkdir() + (tmp_path / ".env").write_text("SUPABASE_URL=https://padre.supabase.co\n") + + load_env(repo) + assert core.os.environ["SUPABASE_URL"] == "https://padre.supabase.co" + + +def test_el_env_del_repo_gana_al_del_padre(tmp_path, monkeypatch): + monkeypatch.setattr(core.os, "environ", {}) + repo = tmp_path / "platform" + repo.mkdir() + (repo / ".env").write_text("SUPABASE_URL=https://propio.supabase.co\n") + (tmp_path / ".env").write_text("SUPABASE_URL=https://padre.supabase.co\n") + + load_env(repo) + assert core.os.environ["SUPABASE_URL"] == "https://propio.supabase.co" + + +def test_lo_ya_exportado_gana_al_fichero(tmp_path, monkeypatch): + """Para que un `export` puntual apunte a otro proyecto sin editar nada.""" + monkeypatch.setattr(core.os, "environ", {"SUPABASE_URL": "https://mandado"}) + (tmp_path / ".env").write_text("SUPABASE_URL=https://fichero\n") + + load_env(tmp_path) + assert core.os.environ["SUPABASE_URL"] == "https://mandado" + + +def test_load_env_ignora_comentarios_y_basura(tmp_path, monkeypatch): + monkeypatch.setattr(core.os, "environ", {}) + (tmp_path / ".env").write_text( + "\n# un comentario\nsin_igual\n SUPABASE_URL = 'https://a.co' \n") + + load_env(tmp_path) + assert core.os.environ == {"SUPABASE_URL": "https://a.co"} + + +def test_from_env_sin_credenciales_no_es_un_error(tmp_path, monkeypatch): + """Correr sin Supabase es el modo por defecto.""" + monkeypatch.setattr(core.os, "environ", {}) + assert Supabase.from_env(tmp_path) is None + + +def test_from_env_exige_las_dos_variables(tmp_path, monkeypatch): + monkeypatch.setattr(core.os, "environ", {"SUPABASE_URL": "https://a.co"}) + assert Supabase.from_env(tmp_path) is None + + +# ── Supabase._send: traducir los errores de PostgREST ──────────────────────── + +def test_send_mete_el_cuerpo_de_postgrest_en_el_error(monkeypatch): + """Sin leer el cuerpo, un 400 por esquema y un 401 por clave son indistinguibles.""" + cuerpo = b'{"code":"23514","message":"viola el CHECK de failure"}' + + def urlopen_falso(request, timeout=None): + raise urllib.error.HTTPError( + "http://x", 400, "Bad Request", {}, io.BytesIO(cuerpo)) + + monkeypatch.setattr(urllib.request, "urlopen", urlopen_falso) + cliente = Supabase("https://x.supabase.co", "clave") + + with pytest.raises(RuntimeError, match="HTTP 400") as err: + cliente.insert("episodes", [{"seed": 1}]) + assert "23514" in str(err.value) + + +def test_send_traduce_un_corte_de_red(monkeypatch): + def urlopen_falso(request, timeout=None): + raise urllib.error.URLError("sin ruta al host") + + monkeypatch.setattr(urllib.request, "urlopen", urlopen_falso) + cliente = Supabase("https://x.supabase.co", "clave") + + with pytest.raises(RuntimeError, match="sin ruta al host"): + cliente.get("runs") + + +def test_insert_vacio_no_llama_a_la_red(monkeypatch): + def urlopen_falso(request, timeout=None): + raise AssertionError("no debería haber salido a la red") + + monkeypatch.setattr(urllib.request, "urlopen", urlopen_falso) + assert Supabase("https://x.supabase.co", "clave").insert("events", []) == [] + + +# ── RunLog: el disco manda ─────────────────────────────────────────────────── + +def test_el_episodio_se_escribe_en_disco_aunque_la_subida_falle(tmp_path): + log = log_conectado(tmp_path, ClienteFalso(falla_en={"episodes"})) + + log.episode(episodio()) # no propaga + + lineas = log.path.read_text(encoding="utf-8").splitlines() + assert len(lineas) == 1 + assert json.loads(lineas[0])["seed"] == 7 + + +def test_tras_un_fallo_se_apaga_el_remoto_y_se_sigue_escribiendo(tmp_path): + cliente = ClienteFalso(falla_en={"episodes"}) + log = log_conectado(tmp_path, cliente) + + for seed in range(5): + log.episode(episodio(seed=seed)) + + assert log.client is None and log.run_id is None + # Un solo intento: los cuatro episodios siguientes ni tocan la red. + assert cliente.tablas() == ["episodes"] + assert len(log.path.read_text(encoding="utf-8").splitlines()) == 5 + + +def test_solo_avisa_una_vez(tmp_path, capsys): + """Cincuenta episodios serían cincuenta timeouts, y basta con enterarse una vez.""" + log = log_conectado(tmp_path, ClienteFalso(falla_en={"episodes"})) + + for seed in range(5): + log.episode(episodio(seed=seed)) + + salida = capsys.readouterr().out + assert salida.count("aviso") == 1 + assert str(log.path) in salida + + +def test_un_episodio_correcto_sube_sus_filas_hijas(tmp_path): + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + + log.episode( + episodio(), + placements=[{"seq": 0, "package_id": "pkg_00"}], + pallet_states=[{"after_seq": 0, "mass_kg": 1.2}], + events=[{"ts": 0.1, "seq": 0, "kind": "place"}], + ) + + assert cliente.tablas() == ["episodes", "placements", "pallet_states", "events"] + # Cada hija lleva el episode_id que devolvió la inserción del episodio. + for _, tabla, filas in cliente.llamadas[1:]: + assert all(f["episode_id"] == "episodes-1" for f in filas), tabla + + +def test_sin_run_id_no_se_intenta_subir_nada(tmp_path): + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + log.run_id = None + + log.episode(episodio()) + + assert cliente.llamadas == [] + assert len(log.path.read_text(encoding="utf-8").splitlines()) == 1 + + +def test_una_respuesta_sin_id_no_tumba_el_episodio(tmp_path): + """PostgREST puede contestar 200 con algo que no trae `id`. Leerlo lanzaría + KeyError, que no es un RuntimeError y se escaparía del blindaje.""" + class SinId(ClienteFalso): + def insert(self, table, rows, *, returning=False): + super().insert(table, rows, returning=returning) + return [{"otra_cosa": 1}] if returning else [] + + log = log_conectado(tmp_path, SinId()) + + log.episode(episodio()) # no propaga + + assert len(log.path.read_text(encoding="utf-8").splitlines()) == 1 + # No se intentan colgar filas hijas de un episodio que no se sabe cuál es. + assert cliente_tablas(log) == ["episodes"] + + +def cliente_tablas(log) -> list[str]: + return log.client.tablas() if log.client else [] + + +def test_un_cuerpo_que_no_es_json_no_tumba_el_episodio(tmp_path): + """Un 200 con HTML de un proxy revienta en json.loads, que lanza ValueError.""" + class Basura(ClienteFalso): + def insert(self, table, rows, *, returning=False): + super().insert(table, rows, returning=returning) + raise json.JSONDecodeError("Expecting value", "", 0) + + log = log_conectado(tmp_path, Basura()) + + log.episode(episodio()) + + assert len(log.path.read_text(encoding="utf-8").splitlines()) == 1 + assert log.client is None + + +# ── el episodio en vivo ────────────────────────────────────────────────────── + +def test_begin_abre_el_episodio_en_curso(tmp_path): + """Sin una fila `running` no hay nada a lo que Live pueda suscribirse.""" + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + + log.begin(seed=37, n_objects=10) + + (_, tabla, filas), = cliente.llamadas + assert tabla == "episodes" + assert filas[0]["status"] == "running" + assert filas[0]["seed"] == 37 + assert filas[0]["n_objects"] == 10 + assert filas[0]["n_placed"] == 0 + assert log.episode_id == "episodes-1" + + +def test_begin_hereda_la_tarea_y_el_nivel_del_run(tmp_path): + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + + log.begin(seed=1) + + assert cliente.llamadas[0][2][0]["task"] == "palletizing" + assert cliente.llamadas[0][2][0]["level"] == 2 + + +def test_las_filas_hijas_van_colgando_del_episodio_abierto(tmp_path): + """Es lo que Realtime reparte y lo que hace que el palé se monte a la vista.""" + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + log.begin(seed=1, n_objects=2) + + log.event(ts=0.4, seq=0, kind="perceive") + log.pallet_state(after_seq=0, mass_kg=1.2, cog_x=0.0, cog_y=0.0, cog_z=0.1) + log.placement(seq=0, package_id="pkg_00", package_type="caja baja") + + assert cliente.tablas() == ["episodes", "events", "pallet_states", "placements"] + for _, tabla, filas in cliente.llamadas[1:]: + assert filas[0]["episode_id"] == "episodes-1", tabla + + +def test_end_cierra_el_episodio_con_un_patch(tmp_path): + """El PATCH es el UPDATE de `episodes` al que Live está suscrita.""" + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + log.begin(seed=7, n_objects=10) + + log.end(episodio()) + + verbo, tabla, match, datos = cliente.llamadas[-1] + assert (verbo, tabla) == ("patch", "episodes") + assert match == {"id": "episodes-1"} + assert datos["status"] == "failure" + assert datos["failure"] == "stack_collapse" + assert datos["n_placed"] == 7 + assert datos["ended_at"] == "now()" + # El run no se toca: el episodio no sabe a qué run pertenece más que al abrirse. + assert "run_id" not in datos + + +def test_end_escribe_en_disco_antes_que_nada(tmp_path): + """La promesa dura también vale para el camino en vivo.""" + cliente = ClienteFalso(falla_en={"episodes"}) + log = log_conectado(tmp_path, cliente) + log.begin(seed=7) + + log.end(episodio()) + + assert len(log.path.read_text(encoding="utf-8").splitlines()) == 1 + + +def test_end_cierra_aunque_begin_no_llegara_a_abrir(tmp_path): + """Sin remoto, `begin()`/`end()` tienen que seguir escribiendo el jsonl.""" + log = RunLog(tmp_path, task="palletizing", level=2, remote=False) + + log.begin(seed=7) + log.end(episodio()) + + assert log.episode_id is None + assert len(log.path.read_text(encoding="utf-8").splitlines()) == 1 + + +def test_sin_begin_las_filas_hijas_no_van_a_ninguna_parte(tmp_path): + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + + log.event(ts=0.1, seq=0, kind="place") + + assert cliente.llamadas == [] + + +def test_un_episodio_no_se_cierra_dos_veces(tmp_path): + """El segundo `end()` sin `begin()` de por medio escribe en disco pero no vuelve a + tocar la fila del anterior, que ya estaba cerrada.""" + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + log.begin(seed=7) + + log.end(episodio()) + log.end(episodio(seed=8)) + + assert len([c for c in cliente.llamadas if c[0] == "patch"]) == 1 + assert len(log.path.read_text(encoding="utf-8").splitlines()) == 2 + + +def test_dos_episodios_seguidos_no_se_mezclan(tmp_path): + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + + log.begin(seed=1) + log.event(ts=0.1, seq=0, kind="place") + log.end(episodio(seed=1)) + + log.begin(seed=2) + log.event(ts=0.1, seq=0, kind="place") + log.end(episodio(seed=2)) + + eventos = [c for c in cliente.llamadas if c[1] == "events"] + assert [c[2][0]["episode_id"] for c in eventos] == ["episodes-1", "episodes-2"] + + +def test_close_cierra_el_run(tmp_path): + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + + log.close() + + assert cliente.llamadas == [ + ("patch", "runs", {"id": "run-1"}, {"ended_at": "now()"})] + + +def test_close_sin_remoto_no_hace_nada(tmp_path): + log = RunLog(tmp_path, remote=False) + log.close() # no revienta + assert log.path_in_ui is None + + +def test_path_in_ui_apunta_al_run(tmp_path): + log = log_conectado(tmp_path, ClienteFalso()) + assert log.path_in_ui == "/runs/run-1" + + +# ── config del run y fotos ─────────────────────────────────────────────────── + +def test_el_run_lleva_su_config(tmp_path, monkeypatch): + """Sin `pallet_size_m` la pantalla dibuja el palé a 1200x800, y este es una maqueta + a escala: TODAS las cotas saldrían mal por el mismo factor.""" + cliente = ClienteFalso() + monkeypatch.setattr(core.Supabase, "from_env", classmethod(lambda cls, r: cliente)) + + core.RunLog(tmp_path, task="palletizing", level=2, + config={"pallet_size_m": [0.21, 0.14], "pallet_scale": 5.7}) + + fila = cliente.llamadas[0][2][0] + assert fila["config"] == {"pallet_size_m": [0.21, 0.14], "pallet_scale": 5.7} + + +def test_sin_config_se_manda_un_objeto_vacio(tmp_path, monkeypatch): + """Nunca null: la columna es `not null default '{}'`.""" + cliente = ClienteFalso() + monkeypatch.setattr(core.Supabase, "from_env", classmethod(lambda cls, r: cliente)) + + core.RunLog(tmp_path, task="palletizing", level=2) + + assert cliente.llamadas[0][2][0]["config"] == {} + + +def test_snapshot_sube_el_png_y_guarda_la_url(tmp_path): + class ConStorage(ClienteFalso): + def upload_png(self, path, data, *, bucket="snapshots"): + self.llamadas.append(("upload", bucket, path, len(data))) + return f"https://x.supabase.co/storage/v1/object/public/{bucket}/{path}" + + cliente = ConStorage() + log = log_conectado(tmp_path, cliente) + log.begin(seed=1, n_objects=2) + + log.snapshot(after_seq=0, view="top", png=b"\x89PNG-de-mentira", width=640, height=480) + + subida = [c for c in cliente.llamadas if c[0] == "upload"][0] + assert subida[1] == "snapshots" + assert subida[2] == "episodes-1/000-top.png" + + fila = [c for c in cliente.llamadas if c[1] == "snapshots" and c[0] == "insert"][0][2][0] + assert fila["url"].endswith("episodes-1/000-top.png") + assert fila["episode_id"] == "episodes-1" + assert "png" not in fila # los bytes no van a la base + + +def test_una_foto_con_url_ya_puesta_no_sube_nada(tmp_path): + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + log.begin(seed=1) + + log.snapshot(after_seq=0, view="side", url="https://ya.esta/ahi.png") + + assert cliente.tablas()[-1] == "snapshots" + + +def test_si_falla_la_subida_no_se_escribe_una_fila_que_apunta_a_nada(tmp_path): + class StorageRoto(ClienteFalso): + def upload_png(self, path, data, *, bucket="snapshots"): + raise RuntimeError("snapshots: HTTP 507 sin espacio") + + cliente = StorageRoto() + log = log_conectado(tmp_path, cliente) + log.begin(seed=1) + + log.snapshot(after_seq=0, view="top", png=b"x") + + assert "snapshots" not in cliente.tablas() + + +def test_end_solo_manda_lo_que_cambia_al_cerrar(tmp_path): + """API.md §3: seed, task, level y n_objects se fijaron en begin().""" + cliente = ClienteFalso() + log = log_conectado(tmp_path, cliente) + log.begin(seed=7, n_objects=10) + + log.end(episodio()) + + datos = [c for c in cliente.llamadas if c[0] == "patch"][0][3] + assert set(datos) == {"status", "duration_s", "n_placed", "score", "failure", + "metrics", "ended_at"} diff --git a/backend/tests/test_schema.py b/backend/tests/test_schema.py new file mode 100644 index 0000000..3e3fec8 --- /dev/null +++ b/backend/tests/test_schema.py @@ -0,0 +1,119 @@ +"""`EpisodeResult` y `RunWriter`: el vocabulario cerrado y el fichero en disco. + +El `episodes.jsonl` es la fuente de verdad (AGENTS.md §4), así que lo que se comprueba +aquí es que se escribe siempre y que se sigue leyendo aunque el formato haya cambiado +por el camino. +""" + +import json + +import pytest + +from theker_telemetry.schema import FAILURES, TASKS, EpisodeResult, RunWriter + + +def episodio(**cambios) -> EpisodeResult: + """Un episodio de paletizado válido, para retocarle un campo por test.""" + base = dict( + seed=7, level=2, n_objects=10, n_placed=7, n_misrouted=0, success=False, + duration_s=42.0, failure="stack_collapse", oracle=False, task="palletizing", + ) + return EpisodeResult(**{**base, **cambios}) + + +# ── vocabulario cerrado ────────────────────────────────────────────────────── + +def test_rechaza_una_causa_de_fallo_inventada(): + with pytest.raises(ValueError, match="fuera del vocabulario"): + episodio(failure="se_puso_triste") + + +def test_rechaza_una_tarea_desconocida(): + with pytest.raises(ValueError, match="tarea desconocida"): + episodio(task="soldadura") + + +def test_acepta_las_causas_vivas_y_la_ausencia_de_fallo(): + assert episodio(failure=None, success=True).failure is None + for causa in FAILURES: + assert episodio(failure=causa).failure == causa + + +def test_acepta_las_tareas_declaradas(): + for tarea in TASKS: + assert episodio(task=tarea).task == tarea + + +# ── frontera de serialización ──────────────────────────────────────────────── + +def test_los_escalares_se_normalizan_al_construir(): + """Los tipos de numpy que llegan del simulador no son JSON: mueren aquí.""" + r = episodio(seed=7.0, level=2.0, n_placed=7.0, duration_s=42) + + assert isinstance(r.seed, int) and r.seed == 7 + assert isinstance(r.level, int) + assert isinstance(r.n_placed, int) + assert isinstance(r.duration_s, float) + assert isinstance(r.oracle, bool) + + +def test_success_entero_se_vuelve_booleano_de_verdad(): + assert episodio(success=1).success is True + assert episodio(success=0).success is False + + +# ── el fichero en disco ────────────────────────────────────────────────────── + +def test_write_deja_una_linea_json_por_episodio(tmp_path): + writer = RunWriter(tmp_path) + writer.write(episodio(seed=1)) + writer.write(episodio(seed=2, failure=None, success=True)) + + lineas = writer.path.read_text(encoding="utf-8").splitlines() + assert len(lineas) == 2 + assert [json.loads(x)["seed"] for x in lineas] == [1, 2] + + +def test_write_rellena_el_sha_si_viene_vacio(tmp_path): + writer = RunWriter(tmp_path) + writer.sha = "abc1234" + writer.write(episodio()) + writer.write(episodio(seed=8, git_sha="otro999")) + + shas = [json.loads(x)["git_sha"] + for x in writer.path.read_text(encoding="utf-8").splitlines()] + assert shas == ["abc1234", "otro999"] + + +def test_summary_sin_fichero(tmp_path): + assert RunWriter(tmp_path).summary() == {"episodes": 0} + + +def test_summary_cuenta_y_ordena_las_causas(tmp_path): + writer = RunWriter(tmp_path) + writer.write(episodio(seed=1, failure=None, success=True, n_placed=10)) + writer.write(episodio(seed=2, failure="stack_collapse", n_placed=4)) + writer.write(episodio(seed=3, failure="stack_collapse", n_placed=5)) + writer.write(episodio(seed=4, failure="grasp_slip", n_placed=6)) + + s = writer.summary() + assert s["episodes"] == 4 + assert s["success_rate"] == 0.25 + assert s["objects_placed"] == 25 + assert s["objects_total"] == 40 + assert s["placement_rate"] == 0.625 + # Ordenado de más frecuente a menos: es lo que acaba en la diapositiva. + assert list(s["failures"].items()) == [("stack_collapse", 2), ("grasp_slip", 1)] + + +def test_summary_tolera_el_formato_viejo_sin_n_misrouted(tmp_path): + """Hay episodios en disco escritos antes de que existiera `n_misrouted`. Leerlos no + puede reventar, o el histórico deja de ser cargable.""" + writer = RunWriter(tmp_path) + viejo = {"seed": 1, "level": 1, "n_objects": 5, "n_placed": 5, "success": True, + "duration_s": 10.0, "failure": None, "oracle": False, "git_sha": "old1234"} + writer.path.write_text(json.dumps(viejo) + "\n", encoding="utf-8") + + s = writer.summary() + assert s["episodes"] == 1 + assert s["misrouted"] == 0 diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py new file mode 100644 index 0000000..3694d66 --- /dev/null +++ b/backend/tests/test_seed.py @@ -0,0 +1,170 @@ +"""El sembrado: el modelo de vuelco y la reproducibilidad del histórico. + +`stability_margin` es el indicador que define toda la interfaz —el diseño dedica un +tablero entero a sus tres estados— así que conviene que su signo signifique lo que dice +que significa: negativo = vuelca. +""" + +import os +import random +import subprocess +import sys +from pathlib import Path + +from seed.palletizing import CAMPAIGN, PACKAGES_BY_LEVEL, build_episode, episode_seed + +# La geometría ya no se define en el sembrado: vive en el paquete, que es de donde la +# importan también la simulación y los guiones de ejemplo. +from theker_telemetry.pallet import ( + TRANSPORT_ACCEL_G, + stability_margin, + support_polygon, +) + +# Dos cajas de 580x380 en los huecos A y B: la capa 1 típica del sembrado. +BASE = [(-0.30, 0.0, 0.580, 0.380), (0.30, 0.0, 0.580, 0.380)] + + +# ── el polígono de apoyo ───────────────────────────────────────────────────── + +def test_el_apoyo_es_la_envolvente_de_la_capa_1(): + """Lo que sostiene el montón es lo que toca el suelo, no el palé entero.""" + x0, x1, y0, y1 = support_polygon(BASE) + assert (round(x0, 3), round(x1, 3)) == (-0.590, 0.590) + assert (round(y0, 3), round(y1, 3)) == (-0.190, 0.190) + + +def test_una_sola_caja_en_una_esquina_apoya_poco(): + x0, x1, y0, y1 = support_polygon([(0.30, 0.0, 0.580, 0.380)]) + assert round(x1 - x0, 3) == 0.580 + assert round(y1 - y0, 3) == 0.380 + + +# ── el margen de estabilidad ───────────────────────────────────────────────── + +def test_sin_base_no_hay_margen(): + assert stability_margin(0.0, 0.0, 0.0, []) == 0.0 + + +def test_un_cog_centrado_y_bajo_es_estable(): + assert stability_margin(0.0, 0.0, 0.10, BASE) > 0 + + +def test_un_cog_fuera_del_apoyo_vuelca(): + """Fuera del polígono el margen tiene que ser negativo, no un cero optimista.""" + assert stability_margin(0.80, 0.0, 0.10, BASE) < 0 + + +def test_apilar_mas_alto_empeora_el_margen(): + bajo = stability_margin(0.0, 0.0, 0.10, BASE) + alto = stability_margin(0.0, 0.0, 0.50, BASE) + assert alto < bajo + + +def test_descentrar_empeora_el_margen(): + """En Y, que es la dimensión estrecha: el apoyo mide 1180x380, así que el borde que + manda es siempre el de Y y descentrar en X no acerca el CoG a caerse.""" + centrado = stability_margin(0.0, 0.0, 0.20, BASE) + torcido = stability_margin(0.0, 0.15, 0.20, BASE) + assert torcido < centrado + + +def test_en_el_lado_largo_manda_el_borde_estrecho(): + a_lo_ancho = stability_margin(0.15, 0.0, 0.20, BASE) + centrado = stability_margin(0.0, 0.0, 0.20, BASE) + assert a_lo_ancho == centrado + + +def test_el_margen_es_la_distancia_al_borde_menos_el_termino_de_transporte(): + """`d - (a/g)*h`: el listón es un giro de carretilla, no un palé quieto.""" + esperado = 0.19 - TRANSPORT_ACCEL_G * 0.30 + assert stability_margin(0.0, 0.0, 0.30, BASE) == round(esperado, 10) or \ + abs(stability_margin(0.0, 0.0, 0.30, BASE) - esperado) < 1e-12 + + +# ── la campaña sembrada ────────────────────────────────────────────────────── + +def test_todos_los_niveles_de_la_campana_tienen_paquetes(): + """Un nivel nuevo en CAMPAIGN sin entrada aquí revienta el sembrado entero.""" + niveles = {level for _, level, *_ in CAMPAIGN} + assert niveles <= set(PACKAGES_BY_LEVEL) + + +def test_la_campana_va_de_peor_a_mejor(): + """El histórico tiene que contar una curva de mejora, no ruido: la ventana del + planificador no puede encogerse por el camino.""" + ventanas = [fila[6] for fila in CAMPAIGN] + assert ventanas == sorted(ventanas) + + +# ── reproducibilidad ───────────────────────────────────────────────────────── + +def test_la_semilla_no_depende_de_pythonhashseed(): + """El bug que esto cierra: `hash()` de una cadena está aleatorizado por proceso + desde Python 3.3, así que cada ejecución del sembrado daba un histórico distinto + mientras el comentario prometía lo contrario. Hay que lanzarlo en dos procesos + con PYTHONHASHSEED distinto, porque dentro de uno solo el hash sí es estable y el + fallo no se ve.""" + guion = ("import sys; sys.path.insert(0, '.');" + "from seed.palletizing import episode_seed;" + "print(episode_seed('8c5cbf4', 37))") + + salidas = set() + for semilla_hash in ("0", "1", "random"): + entorno = {**os.environ, "PYTHONHASHSEED": semilla_hash} + salidas.add(subprocess.run( + [sys.executable, "-c", guion], capture_output=True, text=True, + cwd=Path(__file__).resolve().parents[1], env=entorno, check=True, + ).stdout.strip()) + + assert len(salidas) == 1, f"la semilla cambia entre procesos: {salidas}" + + +def test_semillas_distintas_para_episodios_distintos(): + """Determinista no puede significar constante, o todos los episodios de un run + saldrían calcados.""" + semillas = {episode_seed("8c5cbf4", s) for s in range(25)} + assert len(semillas) == 25 + assert episode_seed("8c5cbf4", 1) != episode_seed("6f0f534", 1) + + +def test_el_mismo_rng_da_el_mismo_episodio(): + """Sin esto el histórico sembrado cambia solo, y la curva de mejora deja de ser + comparable consigo misma.""" + a = build_episode(random.Random(1234), 0.011, 0.004, 2, False, 10) + b = build_episode(random.Random(1234), 0.011, 0.004, 2, False, 10) + + assert a["n_placed"] == b["n_placed"] + assert a["failure"] == b["failure"] + assert a["duration_s"] == b["duration_s"] + assert a["placements"] == b["placements"] + assert a["pallet_states"] == b["pallet_states"] + + +def test_un_episodio_tiene_traza_de_cog_por_paquete(): + """Una fila de `pallet_states` por paquete INTENTADO: eso ES la traza de CoG. El + intento que derrumba el montón también deja su fila, y es justo la que hace que + CogTrace pueda enseñar el margen cruzando el cero.""" + for seed in range(15): + ep = build_episode(random.Random(seed), 0.011, 0.004, 2, False, 10) + estados = ep["pallet_states"] + + assert [s["after_seq"] for s in estados] == list(range(len(estados))) + assert ep["n_placed"] <= len(estados) <= ep["n_placed"] + 1 + + +def test_un_episodio_fallido_nombra_una_causa_del_vocabulario(): + from theker_telemetry.schema import FAILURES + + for seed in range(30): + ep = build_episode(random.Random(seed), 0.020, 0.010, 1, False, 10) + assert ep["failure"] is None or ep["failure"] in FAILURES + + +def test_mirar_mas_lejos_coloca_al_menos_tantos_paquetes(): + """La ventana del planificador es la palanca grande: manda lo pesado abajo.""" + miope = sum(build_episode(random.Random(s), 0.011, 0.004, 1, False, 10)["n_placed"] + for s in range(40)) + previsor = sum(build_episode(random.Random(s), 0.011, 0.004, 4, True, 10)["n_placed"] + for s in range(40)) + assert previsor > miope diff --git a/backend/tests/test_vocabulario.py b/backend/tests/test_vocabulario.py new file mode 100644 index 0000000..ea66daa --- /dev/null +++ b/backend/tests/test_vocabulario.py @@ -0,0 +1,124 @@ +"""Los vocabularios cerrados, cuadrados entre SQL, Python y TypeScript. + +AGENTS.md §5 dice que añadir una causa de fallo obliga a tocar TRES sitios. Nada lo +comprobaba, y saltarse uno falla tarde y mal: el episodio se escribe en disco, la +subida entera se cae por el CHECK, y nadie se entera hasta ver la interfaz vacía. O +peor, la UI acaba enseñando el identificador crudo, que es justo lo que prohíben +AGENTS.md §4 y API.md §2.2. + +No hace falta base de datos: se leen los ficheros como texto. Los CHECK son listas +planas de literales, que es lo único que un regex aguanta sin mentir. +""" + +import re +from pathlib import Path + +from theker_telemetry.schema import FAILURES, TASKS + +RAIZ = Path(__file__).resolve().parents[2] +SQL = (RAIZ / "backend" / "sql" / "001_schema.sql").read_text(encoding="utf-8") +UI = (RAIZ / "frontend" / "lib" / "ui.ts").read_text(encoding="utf-8") +SUPABASE = (RAIZ / "frontend" / "lib" / "supabase.ts").read_text(encoding="utf-8") + + +def sin_comentarios_sql(sql: str) -> str: + """Los CHECK llevan comentarios `--` dentro del paréntesis.""" + return re.sub(r"--[^\n]*", "", sql) + + +def sin_comentarios_ts(ts: str) -> str: + return re.sub(r"//[^\n]*", "", ts) + + +def valores_check(columna: str) -> set[str]: + """Los literales de `check ( in ('a', 'b', ...))`. + + Se recorre el paréntesis contando profundidad en vez de cortar por la primera `)`, + porque el CHECK de `kind` parte en dos líneas y el de `failure` lleva comentarios. + """ + texto = sin_comentarios_sql(SQL) + inicio = re.search(rf"check\s*\(\s*{columna}\s+in\s*\(", texto) + assert inicio, f"no encuentro el CHECK de {columna}" + + profundidad, i = 1, inicio.end() + while profundidad: + profundidad += {"(": 1, ")": -1}.get(texto[i], 0) + i += 1 + # [\w-] y no \w: hay valores con guion, y comillas dobladas dentro de un + # literal SQL, que el findall salta solo. + return set(re.findall(r"'+([\w-]+)'+", texto[inicio.end():i])) + + +def claves_record(nombre: str) -> set[str]: + """Las claves de un `export const NOMBRE: Record<...> = { clave: "...", }`.""" + texto = sin_comentarios_ts(UI) + bloque = re.search(rf"export const {nombre}[^=]*=\s*\{{(.*?)\n\}};", texto, re.S) + assert bloque, f"no encuentro {nombre} en ui.ts" + # La clave va entre comillas si lleva un guion, que en TS no es un + # identificador válido. + return set(re.findall(r'^\s*"?([\w-]+)"?:', bloque.group(1), re.M)) + + +def union_ts(fuente: str, tipo: str, campo: str | None = None) -> set[str]: + """Los literales de una unión de cadenas, sea un `export type` o un campo.""" + if campo is None: + bloque = re.search(rf"export type {tipo}\s*=(.*?);", fuente, re.S) + else: + cuerpo = re.search(rf"export type {tipo} = \{{(.*?)\n\}};", fuente, re.S) + assert cuerpo, f"no encuentro el tipo {tipo}" + bloque = re.search(rf"{campo}:([^;]*);", cuerpo.group(1)) + assert bloque, f"no encuentro {tipo}.{campo}" + return set(re.findall(r'"(\w+)"', bloque.group(1))) + + +# ── causas de fallo: los tres sitios de AGENTS.md §5 ───────────────────────── + +def test_lo_que_se_produce_cabe_en_lo_que_se_almacena(): + """Subconjunto, NO igualdad, y la asimetría es deliberada (001_schema.sql:61): + el CHECK dice qué se puede almacenar e incluye el histórico; FAILURES dice qué se + puede producir e incluye solo lo vivo. Una causa producible que el CHECK rechace + tumbaría la subida del episodio entero.""" + assert FAILURES <= valores_check("failure") + + +def test_la_interfaz_sabe_traducir_todo_lo_almacenable(): + """Igualdad: cualquier causa que pueda salir de la base tiene que tener texto, o + la pantalla enseña el enum crudo. Y una traducción de más es una causa que alguien + borró del CHECK sin limpiar el front.""" + assert claves_record("FAILURE_TEXT") == valores_check("failure") + + +def test_el_tipo_failure_del_front_es_el_vocabulario_almacenable(): + assert union_ts(UI, "Failure") == valores_check("failure") + + +def test_el_historico_sigue_siendo_almacenable_pero_no_producible(): + """`place_inaccurate` lo midió una versión anterior. Tirarlo falsearía la curva de + mejora; producirlo otra vez sería resucitar un vocabulario muerto.""" + assert "place_inaccurate" in valores_check("failure") + assert "place_inaccurate" not in FAILURES + + +# ── el resto de vocabularios cerrados ──────────────────────────────────────── + +def test_las_tareas_cuadran_en_los_tres_sitios(): + assert valores_check("task") == set(TASKS) == claves_record("TASK_TEXT") + + +def test_los_tipos_de_evento_cuadran(): + """API.md §2.6 los lista como el contrato de `events.kind`.""" + assert valores_check("kind") == claves_record("EVENT_TEXT") + + +def test_los_estados_de_episodio_cuadran(): + """Si el front declarase un estado que el CHECK no permite, la rama que lo pinta + sería código muerto; al revés, un estado real se pintaría como desconocido.""" + esperado = valores_check("status") + assert union_ts(SUPABASE, "Episode", "status") == esperado + assert union_ts(SUPABASE, "SeedPoint", "status") == esperado + + +def test_running_es_uno_de_los_estados(): + """Live pregunta por `status=eq.running` (API.md §2.2). Sin este valor esa consulta + no devolvería nunca nada y la pantalla no podría estar en vivo.""" + assert "running" in valores_check("status") diff --git a/backend/theker_telemetry/__init__.py b/backend/theker_telemetry/__init__.py index aed70d7..e426ebe 100644 --- a/backend/theker_telemetry/__init__.py +++ b/backend/theker_telemetry/__init__.py @@ -16,9 +16,15 @@ """ from .core import RunLog, Supabase, episode_row, load_env +from .pallet import ( + TRANSPORT_ACCEL_G, + stability_margin, + support_polygon, +) from .schema import FAILURES, TASKS, EpisodeResult, RunWriter, git_sha __all__ = [ "EpisodeResult", "RunWriter", "RunLog", "Supabase", "FAILURES", "TASKS", "git_sha", "episode_row", "load_env", + "TRANSPORT_ACCEL_G", "stability_margin", "support_polygon", ] diff --git a/backend/theker_telemetry/core.py b/backend/theker_telemetry/core.py index ede416b..92ea81f 100644 --- a/backend/theker_telemetry/core.py +++ b/backend/theker_telemetry/core.py @@ -18,9 +18,10 @@ import os import urllib.error import urllib.request +from collections.abc import Iterable, Sequence from dataclasses import asdict from pathlib import Path -from typing import Any, Iterable, Sequence +from typing import Any from .schema import EpisodeResult, RunWriter, git_sha @@ -173,6 +174,37 @@ def patch(self, table: str, match: dict, data: dict) -> None: ) self._send(request, table) + def upload_png(self, path: str, data: bytes, *, + bucket: str = "snapshots") -> str: + """Sube un PNG a Storage y devuelve su URL pública. + + Storage no es PostgREST: cuelga de /storage/v1 y el cuerpo son los bytes, no + JSON. `x-upsert` para que repetir un episodio pise la foto anterior en vez de + dar 409 y dejar la traza a medias. + """ + destino = f"{bucket}/{path.lstrip('/')}" + cabeceras = { + "apikey": self.key, + "Authorization": f"Bearer {self.key}", + "Content-Type": "image/png", + "x-upsert": "true", + } + request = urllib.request.Request( + f"{self.base.removesuffix('/rest/v1')}/storage/v1/object/{destino}", + data=data, method="POST", headers=cabeceras) + self._send(request, bucket) + return f"{self.base.removesuffix('/rest/v1')}/storage/v1/object/public/{destino}" + + def delete(self, table: str, match: dict) -> None: + """DELETE con filtro de igualdad. Lo usa el sembrado para limpiar lo suyo.""" + query = "&".join(f"{k}=eq.{v}" for k, v in match.items()) + request = urllib.request.Request( + f"{self.base}/{table}?{query}", + method="DELETE", + headers=self._headers("return=minimal"), + ) + self._send(request, table) + # ───────────────────────────────────────────────────────────────────────────── # Conversión a filas @@ -219,15 +251,22 @@ class RunLog: def __init__(self, repo: Path, *, task: str = "induction", level: int = 0, oracle: bool = False, motion_speed: float = 1.0, tag: str | None = None, label: str | None = None, - n_episodes: int = 0, remote: bool = True): + n_episodes: int = 0, config: dict | None = None, + remote: bool = True): self.writer = RunWriter(repo, tag=tag) self.directory = self.writer.directory self.path = self.writer.path self.client = Supabase.from_env(repo) if remote else None self.run_id: str | None = None + # El episodio abierto por `begin()`, si se está usando el modo en vivo. + self.episode_id: str | None = None self._warned = False + # La identidad del run, para no repetirla en cada `begin()`. + self.task = task + self.level = int(level) + if self.client is None: return rows = self._try("runs", lambda: self.client.insert("runs", [{ @@ -238,9 +277,13 @@ def __init__(self, repo: Path, *, task: str = "induction", level: int = 0, "motion_speed": float(motion_speed), "label": label, "n_episodes": int(n_episodes), + # Todo lo que describe el montaje y no cabe en una columna. Lo primero + # que necesita: `pallet_size_m`. El palé real es una maqueta a escala + # —la pinza del Panda abre 80 mm y un europeo es inagarrable— y sin esto + # la pantalla lo dibuja a 1200x800 y TODAS las cotas salen mal. + "config": _json_safe(config or {}), }], returning=True)) - if rows: - self.run_id = rows[0]["id"] + self.run_id = self._id_of(rows) # ── uso normal ─────────────────────────────────────────────────────────── @@ -255,9 +298,9 @@ def episode(self, result: EpisodeResult, *, rows = self._try("episodes", lambda: self.client.insert( "episodes", [episode_row(result, self.run_id)], returning=True)) - if not rows: + episode_id = self._id_of(rows) + if not episode_id: return - episode_id = rows[0]["id"] for table, items in (("placements", placements), ("pallet_states", pallet_states), @@ -266,6 +309,94 @@ def episode(self, result: EpisodeResult, *, if batch: self._try(table, lambda t=table, b=batch: self.client.insert(t, b)) + # ── episodio en vivo ───────────────────────────────────────────────────── + # + # `episode()` sube el episodio YA terminado con todas sus filas hijas de golpe. + # Para un benchmark está bien, pero entonces la pantalla Live no tiene nada a lo + # que suscribirse: nunca existe una fila `running` ni llega un UPDATE de + # `episodes`, así que el jurado ve el palé ya hecho en vez de montándose + # (API.md §4). Con `begin()` / `event()` / `end()` el episodio se abre primero y + # las filas hijas van llegando, que es justo lo que Realtime reparte. + + def begin(self, seed: int, *, n_objects: int = 0, + task: str | None = None, level: int | None = None) -> None: + """Abre un episodio en curso. A partir de aquí `event()`, `pallet_state()` y + `placement()` van soltando filas según ocurren.""" + self.episode_id = None + if not self.run_id: + return + rows = self._try("episodes", lambda: self.client.insert("episodes", [{ + "run_id": self.run_id, + "seed": int(seed), + "task": task or self.task, + "level": self.level if level is None else int(level), + "status": "running", + "n_objects": int(n_objects), + "n_placed": 0, + }], returning=True)) + self.episode_id = self._id_of(rows) + + def event(self, **row: Any) -> None: + self._push("events", row) + + def pallet_state(self, **row: Any) -> None: + self._push("pallet_states", row) + + def placement(self, **row: Any) -> None: + self._push("placements", row) + + def snapshot(self, **row: Any) -> None: + """Una foto del palé. `png=` la sube a Storage y rellena `url` sola.""" + png = row.pop("png", None) + if png is not None and self.client and self.episode_id: + nombre = (f"{self.episode_id}/{row.get('after_seq', 0):03d}" + f"-{row.get('view', 'top')}.png") + url = self._try("snapshots", lambda: self.client.upload_png(nombre, png)) + if not url: + return # sin imagen no hay fila que apunte a ninguna parte + row["url"] = url + self._push("snapshots", row) + + def end(self, result: EpisodeResult) -> None: + """Cierra el episodio abierto con `begin()`. El disco primero, siempre. + + El PATCH es además el UPDATE de `episodes` al que Live está suscrita: es lo que + le dice a la pantalla que este episodio ha terminado y cómo.""" + self.writer.write(result) + episode_id, self.episode_id = self.episode_id, None + if not episode_id or not self.run_id: + return + + # Solo lo que cambia al cerrar. seed, task, level y n_objects se fijaron en + # `begin()` y no se reescriben: mandarlos otra vez ensuciaba el PATCH y + # contradecía lo que documenta API.md §3. + fila = episode_row(result, self.run_id) + row = {k: fila[k] for k in + ("status", "duration_s", "n_placed", "score", "failure", "metrics")} + row["ended_at"] = "now()" + self._try("episodes", lambda: self.client.patch( + "episodes", {"id": episode_id}, row)) + + @staticmethod + def _id_of(rows: Any) -> str | None: + """El id de la fila recién creada, o None si PostgREST devolvió otra cosa. + + Va aparte porque la lectura ocurre FUERA de `_try` —ya ha vuelto de la red— y + un `rows[0]["id"]` a pelo se llevaría por delante el episodio con un KeyError + justo después de haberlo blindado.""" + if not rows or not isinstance(rows[0], dict): + return None + return rows[0].get("id") + + def _push(self, table: str, row: dict) -> None: + """Una fila hija del episodio abierto. Sin `begin()` no hay dónde colgarla.""" + if not self.episode_id: + return + self._try(table, lambda: self.client.insert( + table, [{**row, "episode_id": self.episode_id}])) + + # ── cierre del run ─────────────────────────────────────────────────────── + def close(self) -> None: """Cierra el run. Sin esto `ended_at` queda a null y la interfaz no distingue un run terminado de uno que reventó a la mitad.""" @@ -292,7 +423,13 @@ def _try(self, what: str, call) -> Any: return None try: return call() - except RuntimeError as err: + # RuntimeError es lo que lanza el cliente a propósito. Los otros dos son el + # resto de formas en que una respuesta rara tumba la subida: un 200 con cuerpo + # que no es JSON revienta en `json.loads` (ValueError, del que hereda + # JSONDecodeError), y una fila devuelta sin `id` revienta al leerla + # (LookupError). Que el remoto falle nunca puede llevarse por delante el + # episodio: para eso existe este blindaje. + except (RuntimeError, ValueError, LookupError) as err: if not self._warned: print(f" aviso: Supabase no responde ({err}). " f"Se sigue escribiendo en {self.path}") diff --git a/backend/theker_telemetry/pallet.py b/backend/theker_telemetry/pallet.py new file mode 100644 index 0000000..d095b63 --- /dev/null +++ b/backend/theker_telemetry/pallet.py @@ -0,0 +1,52 @@ +"""Geometría del palé: polígono de apoyo y margen de estabilidad. + +Vive en el paquete y no en `seed/` porque es el indicador que define toda la interfaz +—el diseño le dedica un tablero entero a sus tres estados— y lo calculan dos repos: la +simulación y el sembrado. Dos implementaciones del mismo número acaban discrepando, y +entonces no se sabe cuál miente. + +Sin dependencias: sólo la stdlib. +""" + +from __future__ import annotations + +# Aceleración lateral que se exige aguantar, en g. El caso no es un palé quieto: un +# montón vuelca frenando o girando. Con aceleración lateral `a`, cae si +# +# a/g > d / h d = distancia del CoG al borde del apoyo, h = altura del CoG +# +# de donde el margen que queda es `d - (a/g)*h`. Esto es lo que hace que el indicador +# signifique algo: apilar alto y descentrar lo empeoran a la vez, que es exactamente el +# compromiso que el planificador tiene que resolver. +TRANSPORT_ACCEL_G = 0.28 + +# Caja = (x, y, ancho_x, ancho_y), en metros y en el frame del palé. +Caja = tuple[float, float, float, float] + + +def support_polygon(base: list[Caja]) -> tuple[float, float, float, float]: + """Rectángulo que apoya en el palé: la envolvente de las cajas de la capa 1. + + Lo que sostiene el montón es lo que toca el suelo, no el palé entero. Un palé con + una sola caja en una esquina tiene un apoyo pequeño aunque la tabla sea enorme. + """ + xs0 = [x - dx / 2 for x, _, dx, _ in base] + xs1 = [x + dx / 2 for x, _, dx, _ in base] + ys0 = [y - dy / 2 for _, y, _, dy in base] + ys1 = [y + dy / 2 for _, y, _, dy in base] + return min(xs0), max(xs1), min(ys0), max(ys1) + + +def stability_margin(cog_x: float, cog_y: float, cog_z: float, + base: list[Caja], + accel_g: float = TRANSPORT_ACCEL_G) -> float: + """Margen de estabilidad en metros. Negativo = vuelca en la primera frenada. + + Se mide contra el polígono de apoyo, NO contra el borde del palé: medirlo contra el + palé da números optimistas y la pantalla diría que todo va bien hasta el derrumbe. + """ + if not base: + return 0.0 + x0, x1, y0, y1 = support_polygon(base) + d_edge = min(cog_x - x0, x1 - cog_x, cog_y - y0, y1 - cog_y) + return d_edge - accel_g * cog_z diff --git a/backend/theker_telemetry/schema.py b/backend/theker_telemetry/schema.py index aa45d88..fc01f98 100644 --- a/backend/theker_telemetry/schema.py +++ b/backend/theker_telemetry/schema.py @@ -34,7 +34,7 @@ # Tareas que el repo sabe medir. La tarea es un campo, no una rama del código: así la # línea base de inducción sigue siendo comparable cuando entre el paletizado. -TASKS = frozenset({"induction", "palletizing"}) +TASKS = frozenset({"induction", "palletizing", "paletizado-guionizado"}) @dataclass diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..4d14bb8 --- /dev/null +++ b/dev.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# +# Levanta la plataforma entera con un solo comando: +# +# ./dev.sh +# +# No hay servidor propio que arrancar —Supabase ES el backend—, así que "todo" es: +# dependencias puestas, credenciales en su sitio, esquema al día, y el front escuchando. +# +# Lo que comprueba antes de arrancar no es ceremonia: el esquema se pega a mano en el +# editor de Supabase, así que es normal que el repo vaya por delante de la base. Que la +# interfaz salga vacía por eso, y descubrirlo delante del jurado, es el fallo caro. +# +# ./dev.sh --check solo comprueba, no arranca nada +# ./dev.sh --seed siembra datos de ejemplo antes de arrancar + +set -euo pipefail +cd "$(dirname "$0")" + +VERDE=$'\033[32m'; ROJO=$'\033[31m'; AMBAR=$'\033[33m'; FIN=$'\033[0m' +ok() { echo " ${VERDE}ok${FIN} $1"; } +aviso() { echo " ${AMBAR}aviso${FIN} $1"; } +error() { echo " ${ROJO}error${FIN} $1"; } + +SOLO_CHECK=false +SEMBRAR=false +for arg in "$@"; do + case "$arg" in + --check) SOLO_CHECK=true ;; + --seed) SEMBRAR=true ;; + -h|--help) sed -n '3,15p' "$0" | sed 's/^# \?//'; exit 0 ;; + *) error "opción desconocida: $arg"; exit 2 ;; + esac +done + +echo "── credenciales ──────────────────────────────────────────────" + +# Un solo fichero de credenciales, en la raíz: lo lee load_env() del lado Python y, +# a través de frontend/next.config.ts, también el front. Dos copias se desincronizan +# solas, y ya pasó una vez. +falta_env=false +if [[ ! -f .env ]]; then + error "falta .env" + falta_env=true +else + for clave in SUPABASE_URL SUPABASE_ANON_KEY SUPABASE_SERVICE_KEY; do + if grep -qE "^${clave}=[^[:space:]]" .env; then + ok ".env: $clave" + else + error ".env: $clave sin valor" + falta_env=true + fi + done +fi +if [[ -f frontend/.env.local ]]; then + aviso "frontend/.env.local existe y ya no hace falta; sus valores ganarían al .env" +fi +if $falta_env; then + echo + echo " Copia .env.example a .env y rellena los valores desde Supabase:" + echo " Project Settings -> Data API -> Project URL y API Keys." + exit 1 +fi + +echo "── dependencias ──────────────────────────────────────────────" + +# El SDK de Python solo hace falta para sembrar, subir episodios o correr los tests; +# el front no lo necesita. Si no hay venv se dice y se sigue. +if [[ ! -d .venv ]]; then + aviso "sin .venv (solo hace falta para backend/tests y los guiones de datos)" + echo " python3 -m venv .venv && .venv/bin/pip install -e './backend[dev]'" +else + if .venv/bin/python -c "import theker_telemetry" 2>/dev/null; then + ok "theker_telemetry instalado" + else + aviso "theker_telemetry no instalado; instalando" + .venv/bin/pip install -q -e './backend[dev]' + ok "instalado" + fi +fi + +# package-lock manda: si node_modules va por detrás, npm ci lo deja exacto. Pasa cada +# vez que alguien fusiona una rama que añade una dependencia. +if [[ ! -d frontend/node_modules ]] \ + || [[ frontend/package-lock.json -nt frontend/node_modules ]]; then + aviso "dependencias del front desactualizadas; npm ci" + (cd frontend && npm ci --silent) +fi +ok "node_modules al día" + +echo "── esquema en Supabase ───────────────────────────────────────" + +# Se comprueba lo que de verdad rompe la interfaz, no que "haya conexión": una columna +# que el front consulta y la vista no proyecta devuelve 400 y deja la pantalla vacía. +if [[ -d .venv ]]; then + .venv/bin/python - <<'PY' || exit 1 +import sys +sys.path.insert(0, "backend") +from pathlib import Path + +from theker_telemetry import Supabase + +VERDE, ROJO, FIN = "\033[32m", "\033[31m", "\033[0m" + +cliente = Supabase.from_env(Path(".")) +if cliente is None: + print(f" {ROJO}error{FIN} .env sin SUPABASE_URL o SUPABASE_SERVICE_KEY") + raise SystemExit(1) + +# Cada entrada es (relación, columna que el front necesita, fichero que la trae). Si +# falla alguna, ese .sql está sin pegar en el editor de Supabase. +ESPERADO = [ + ("v_episode_summary", "started_at", "002_design.sql"), + ("v_episode_summary", "config", "002_design.sql"), + ("v_run_summary", "config", "002_design.sql"), + ("snapshots", "url", "003_snapshots.sql"), +] + +faltan = set() +for relacion, columna, fichero in ESPERADO: + try: + cliente.get(relacion, select=columna, limit=1) + except RuntimeError as err: + print(f" {ROJO}error{FIN} {relacion}.{columna}: {str(err)[:90]}") + faltan.add(fichero) + else: + print(f" {VERDE}ok{FIN} {relacion}.{columna}") + +if faltan: + print() + print(" Pega en el SQL editor de Supabase, en orden:") + for fichero in sorted(faltan): + print(f" backend/sql/{fichero}") + raise SystemExit(1) + +runs = cliente.get("v_run_summary", select="task,synthetic,episodes") +medidos = sum(1 for r in runs if not r["synthetic"]) +print(f" {VERDE}ok{FIN} {len(runs)} ejecuciones " + f"({medidos} medidas, {len(runs) - medidos} sembradas), " + f"{sum(r['episodes'] for r in runs)} episodios") +if not runs: + print(" sin datos: ./dev.sh --seed los genera") +PY +else + aviso "sin .venv: no puedo comprobar el esquema" +fi + +if $SEMBRAR; then + echo "── datos de ejemplo ──────────────────────────────────────────" + .venv/bin/python backend/seed/ejemplo_contrato.py +fi + +if $SOLO_CHECK; then + echo + ok "todo listo (no se arranca nada con --check)" + exit 0 +fi + +echo "── front ─────────────────────────────────────────────────────" +echo " http://localhost:3000 (Ctrl+C para parar)" +echo +exec npm --prefix frontend run dev diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..d1d7c80 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,333 @@ +# API de la plataforma + +No hay servidor propio: **Supabase es el backend** (AGENTS.md §7). Los endpoints son los de +PostgREST, más los canales Realtime. Este documento sale de `backend/sql/001_schema.sql`, +`backend/sql/002_design.sql` y `backend/theker_telemetry/core.py`; las respuestas de ejemplo son +la forma de las columnas, no capturas de una base real. + +Los clientes del front que los consumen están en `frontend/api/clients/` y los hooks en +`frontend/api/hooks/`. Los tipos siguen en `frontend/lib/supabase.ts`, que es lo que +compara `backend/tests/test_contrato.py` contra las columnas de verdad. + +## 1. Convenciones + +| | | +|---|---| +| Base URL | `{SUPABASE_URL}/rest/v1` | +| Lectura | cabeceras `apikey: ` y `Authorization: Bearer `. Política RLS `lectura_publica` (`select` para `anon` y `authenticated`) | +| Escritura | igual, con `SUPABASE_SERVICE_KEY`. **Solo el lado Python**: la `service_role` se salta RLS y nunca va al front | +| Formato | JSON. Un `GET` devuelve un **array** de filas; PostgREST no envuelve la respuesta | +| Unidades | SI en el dato: metros (`_m`), radianes (`_rad`), segundos (`_s`), kilos (`_kg`). La conversión a mm/° vive solo en `frontend/lib/ui.ts` | +| `null` | significa «no medido», no cero. `cycle_time_s` es `null` si no se colocó nada | +| `numeric` | las vistas devuelven `numeric` redondeado; PostgREST lo serializa como número JSON | +| Filtros | `col=eq.valor`, orden `order=col.asc\|desc`, `limit=N`, columnas `select=a,b` | + +Errores de PostgREST (cuerpo JSON `{ "code", "message", "details", "hint" }`): + +| HTTP | Cuándo | +|---|---| +| 400 | columna inexistente (`42703`), violación de CHECK (`23514`), cuerpo inválido | +| 401 | clave ausente o inválida | +| 403 | RLS deniega (`42501`), p. ej. un `POST` con la `anon` | +| 404 | tabla o vista inexistente (`PGRST205`) | +| 406 | `.single()` sin exactamente una fila (`PGRST116`) | + +En el front, todo error llega como `ApiError` (`source`, `code`, `details`). + +## 2. Lectura (clave `anon`) + +### 2.1 `GET /v_run_summary`: ejecuciones + +Una fila por run, con los agregados ya calculados en SQL (medianas, no medias). + +- Lista: `?select=*&order=started_at.desc&limit=60` → `getRuns(limit)` / `useRuns(limit)` +- Una: `?select=*&id=eq.{uuid}` → `getRun(id)` / `useRun(id)` (`null` si no existe) + +```json +[{ + "id": "0b6f…-uuid", + "started_at": "2026-09-18T10:12:04.311+00:00", + "ended_at": "2026-09-18T10:31:40.002+00:00", + "task": "palletizing", + "level": 2, + "git_sha": "75f7187", + "oracle": false, + "synthetic": false, + "motion_speed": 1, + "label": "recentrado-v2", + "description": "planificador de capas con recentrado de CoG", + "episodes": 20, + "successes": 14, + "success_rate": 0.7, + "objects_placed": 168, + "objects_total": 200, + "placement_rate": 0.84, + "seed_min": 0, + "seed_max": 19, + "median_cycle_s": 8.412, + "median_stability_m": 0.0412, + "median_error_xy_m": 0.00382, + "mean_duration_s": 91.3, + "mean_score": 0.7123, + "dominant_failure": "stack_collapse", + "seed_series": [ + { "seed": 0, "status": "success", "failure": null, "placed": 10, "objects": 10, + "cycle_s": 8.1, "stability": 0.044 }, + { "seed": 1, "status": "failure", "failure": "stack_collapse", "placed": 6, "objects": 10, + "cycle_s": 9.9, "stability": -0.003 } + ] +}] +``` + +Notas: `ended_at` es `null` mientras el run sigue abierto (o si reventó sin `close()`). +`success_rate`, `placement_rate` y las medianas son `null` en un run sin episodios. +`dominant_failure` es `null` en un run sin fallos. `seed_series` es `[]` sin episodios y va +ordenada por semilla. `mean_score` está en la vista pero **no** en el tipo `Run` del front. +`status` ∈ `running | success | failure`. + +### 2.2 `GET /v_episode_summary`: episodios + +Una fila por episodio, con datos del run (`git_sha`, `oracle`, `synthetic`, `motion_speed`) y del +último `pallet_state`. + +- Por run: `?select=*&run_id=eq.{uuid}&order=seed.asc` → `getEpisodes(runId)` / `useEpisodes(runId)` +- Por run y semilla: `?select=*&run_id=eq.{uuid}&seed=eq.{n}` → `getEpisode(runId, seed)` / `useEpisode(runId, seed)` (`null` si no existe) +- En curso (`getRunningEpisode()` / `useRunningEpisode()`): `?select=*&status=eq.running&order=started_at.desc&limit=1`. `null` si no hay ninguno. Es lo único que enseña Live: **no** cae al último terminado. Con varios en curso, el más reciente. + +```json +[{ + "id": "5c1e…-uuid", + "run_id": "0b6f…-uuid", + "seed": 1, + "task": "palletizing", + "level": 2, + "status": "failure", + "duration_s": 61.4, + "n_objects": 10, + "n_placed": 6, + "score": 0.6, + "failure": "stack_collapse", + "metrics": { "cog_offset_xy": 0.031, "score": 0.6, "n_misrouted": 0 }, + "git_sha": "75f7187", + "oracle": false, + "synthetic": false, + "motion_speed": 1, + "cycle_time_s": 10.233, + "final_stability_m": -0.003, + "final_fill_ratio": 0.48, + "final_settle_drift_m": 0.041, + "mean_error_xy_m": 0.00412, + "n_layers": 3, + "load_height_m": 0.4 +}] +``` + +Notas: `duration_s` y `score` pueden ser `null` (episodio en curso). `cycle_time_s`, +`final_*`, `mean_error_xy_m`, `n_layers` y `load_height_m` son `null` si no hay +`pallet_states` / `placements` colocados. `final_stability_m` negativo = el montón vuelca. +`metrics` es libre (jsonb): en inducción lleva `n_misrouted`; en paletizado, `cog_offset_xy`, +etc. `failure` es el identificador crudo: la interfaz nunca lo muestra, usa `failureText()`. + +> **Resuelto: `v_episode_summary` ya tiene `started_at` y `ended_at`.** Se confirmó contra un +> Postgres real: `?order=started_at.desc` daba `400 42703`, y ese era el motivo de que Live se +> quedara en "todavía no hay episodios" siempre que no hubiera un episodio `running`. La vista de +> `002_design.sql` los proyecta desde el commit que arregló esto, así que el front ordena la vista +> directamente (`getRunningEpisode`) sin el rodeo por `GET /episodes`. Lo vigila +> `backend/tests/test_contrato.py`, que exige que toda columna usada en un `.order()`/`.eq()` exista +> en la relación de su `.from()`. + +### 2.3 `GET /v_failure_breakdown`: causas de fallo por run + +`?select=*&run_id=eq.{uuid}&order=n.desc` → `getFailureBreakdown(runId)` / `useFailureBreakdown(runId)` + +```json +[ + { "run_id": "0b6f…-uuid", "failure": "stack_collapse", "n": 4 }, + { "run_id": "0b6f…-uuid", "failure": "grasp_slip", "n": 2 } +] +``` + +Solo aparecen causas con al menos un episodio: un run sin fallos devuelve `[]`. + +### 2.4 `GET /placements`: paquetes colocados (o intentados) + +`?select=*&episode_id=eq.{uuid}&order=seq.asc` → `getPlacements(id)` / `usePlacements(id)` + +```json +[{ + "id": "…-uuid", "episode_id": "5c1e…-uuid", + "seq": 0, "package_id": "pkg_03", "package_type": "box_small", + "mass_kg": 1.2, "dims_m": [0.2, 0.3, 0.15], "layer": 0, + "planned_pose": { "x": 0.1, "y": 0.15, "z": 0.075, "yaw": 0 }, + "actual_pose": { "x": 0.102, "y": 0.149, "z": 0.075, "yaw": 0.01 }, + "error_xy_m": 0.0022, "error_yaw_rad": 0.01, + "support_ratio": 1, "overhang_m": 0, "placed": true +}] +``` + +`planned_pose`/`actual_pose` son jsonb y pueden ser `null`. `dims_m` es `[largo, ancho, alto]`. +Las tres poses/dimensiones están en metros y radianes. + +### 2.5 `GET /pallet_states`: traza del centro de gravedad + +`?select=*&episode_id=eq.{uuid}&order=after_seq.asc` → `getPalletStates(id)` / `usePalletStates(id)` + +```json +[{ + "id": "…-uuid", "episode_id": "5c1e…-uuid", "after_seq": 0, + "mass_kg": 1.2, "cog_x": 0.101, "cog_y": 0.149, "cog_z": 0.075, + "stability_margin_m": 0.052, "fill_ratio": 0.08, "settle_drift_m": 0.002 +}] +``` + +Una fila por paquete depositado (estado **tras** colocarlo). `stability_margin_m` negativo = vuelca. +`settle_drift_m` lo añade 002 y es el aviso previo al derrumbe. + +### 2.6 `GET /events`: cronología del episodio + +`?select=*&episode_id=eq.{uuid}&order=seq.asc` → `getEvents(id)` / `useEvents(id)` + +```json +[{ + "id": "…-uuid", "episode_id": "5c1e…-uuid", + "ts": 3.42, "seq": 4, "kind": "place", "package_id": "pkg_03", + "payload": { "target": [0.1, 0.15], "ok": true } +}] +``` + +`ts` son segundos desde el inicio del episodio. `kind` ∈ `perceive | plan | pick | place | settle | fail`. +`payload` es libre (jsonb) y depende de `kind`. + +### 2.7 Tablas `runs` y `episodes` en crudo + +Legibles con `anon` (`lectura_publica`) pero **el front no las usa**: lee las vistas. Columnas en AGENTS.md §6; la fila de `runs` lleva además `config` (jsonb) y `n_episodes` +(los episodios **pedidos**, no los reales). + +### 2.8 Totales: recuento sin filas + +`HEAD /v_run_summary?select=*` y `HEAD /episodes?select=*`, con la cabecera `Prefer: count=exact` +→ `getRunsCount()` / `getEpisodesCount()` / `useRunsTotals()`. + +No hay cuerpo: el total es el número que sigue a `/` en `Content-Range` (`*/0` si la tabla está vacía). + +``` +Content-Range: 0-53/54 +``` + +Alimentan «54 ejecuciones · 833 episodios» de la barra superior de Ejecuciones. Se cuentan en la base y no se +suman en el cliente (AGENTS.md §4): `sum(episodes)` de `v_run_summary` y `count(episodes)` coinciden hoy, pero +son dos cuentas distintas. + +### 2.9 Vocabularios cerrados + +`failure`: `no_detection`, `ik_unreachable`, `collision`, `grasp_slip`, `wrong_placement`, `timeout`, +`stack_collapse`, `overhang_violation`, y el histórico `place_inaccurate` (ya no se emite). Traducción en +`frontend/lib/ui.ts:FAILURE_TEXT`. `task`: `induction | palletizing`. + +## 3. Escritura (solo Python, `service_role`) + +Lo usa `RunLog` en `backend/theker_telemetry/core.py`. El front no debe llamarlos: con la `anon` devuelven `403`. +Todo fallo remoto avisa una vez y el episodio sigue: el `episodes.jsonl` en disco es la fuente de verdad. + +| Método y ruta | Cuerpo | `Prefer` | Respuesta | +|---|---|---|---| +| `POST /runs` | `[{ task, level, git_sha, oracle, motion_speed, label, n_episodes, config }]` | `return=representation` | `201` + `[fila creada]` (de ahí sale `run_id`) | +| `POST /episodes` | `[{ run_id, seed, task, level, status, duration_s, n_objects, n_placed, score, failure, metrics }]` | `return=representation` | `201` + `[fila creada]` (de ahí sale `episode_id`) | +| `POST /placements` | lote de filas con `episode_id` | `return=minimal` | `201` sin cuerpo | +| `POST /pallet_states` | ídem | `return=minimal` | `201` sin cuerpo | +| `POST /events` | ídem | `return=minimal` | `201` sin cuerpo | +| `PATCH /episodes?id=eq.{uuid}` | `{ status, duration_s, n_placed, score, failure, metrics, ended_at }` — y nada más: `seed`, `task`, `level` y `n_objects` se fijaron en `begin()` | `return=minimal` | `204` sin cuerpo | +| `PATCH /runs?id=eq.{uuid}` | `{ "ended_at": "now()" }` | `return=minimal` | `204` sin cuerpo | + +Restricciones que hacen fallar la escritura: `episodes.failure` fuera del CHECK (`23514`), `unique(run_id, seed)`, +`unique(episode_id, seq)` en `placements`/`events`, `unique(episode_id, after_seq)` en `pallet_states`. +`episodes.status` se calcula en el SDK: `success` o `failure`. `git_sha`/`oracle` **no** se guardan por episodio; +las vistas los recuperan del run. + +### 3.1 `runs.config`: el montaje + +`jsonb` libre que describe cómo estaba montado el banco. Lo manda `RunLog(config={...})`. +La clave que la interfaz **necesita** es `pallet_size_m`: + +```json +{ "pallet_size_m": [0.2105, 0.1404], "pallet_scale": 5.7 } +``` + +El palé puede ser una maqueta a escala —la pinza del Panda abre 80 mm y un europeo es +inagarrable—, y sin esto la pantalla lo dibuja a 1200x800 y **todas** las cotas salen mal +por el mismo factor. Viaja también en `v_episode_summary.config`, porque Live solo tiene +el episodio en la mano. El front lo lee con `palletSize()` de `lib/ui.ts`, que mira +`config`, luego `metrics` (donde estuvo mientras la columna no se podía escribir) y por +último cae al europeo. + +### 3.2 `GET /snapshots`: las fotos del palé + +`?select=*&episode_id=eq.{uuid}&order=after_seq.asc` + +```json +[{ "id": "…-uuid", "episode_id": "5c1e…-uuid", "after_seq": 3, "view": "top", + "url": "https://…/storage/v1/object/public/snapshots/5c1e…/003-top.png", + "width": 640, "height": 480, "created_at": "2026-09-19T11:04:04Z" }] +``` + +`after_seq` casa con `pallet_states.after_seq`: la foto y el punto de la traza de CoG son +el mismo instante, que es lo que permite que el scrubber enseñe el palé en el instante `t`. +`view` ∈ `top | side | iso | camera`. El bucket es público y solo se guarda la URL: los +binarios no entran en Postgres. + +`RunLog.snapshot(after_seq=…, view="top", png=)` sube el PNG y rellena `url` sola; +si ya tienes la URL, pásala y no sube nada. + +## 4. Realtime + +Publicación `supabase_realtime`, canal `postgres_changes` sobre el esquema `public`. La pantalla Live +(`app/page.tsx`) es la única que lo usa y se queda con su suscripción: no pasa por TanStack Query. + +| Tabla | Evento | Filtro | Payload (`payload.new`) | +|---|---|---|---| +| `events` | `INSERT` | `episode_id=eq.{id}` | fila de `events` (§2.6) | +| `pallet_states` | `INSERT` | `episode_id=eq.{id}` | fila de `pallet_states` (§2.5) | +| `episodes` | `UPDATE` | `id=eq.{id}` | fila de `episodes` **en crudo**, no de la vista: sin `git_sha`, `oracle`, ni columnas derivadas | +| `episodes` | `INSERT` | *(sin filtro)* | fila en crudo. Es lo que hace que Live se entere **al instante** de que arranca un episodio (`useRunningEpisodeRealtime`, canal `live-episodes`): no usa el payload, vuelve a preguntar cuál está `running`. El sondeo de 5 s queda de red de seguridad | +| `snapshots` | `INSERT` | `episode_id=eq.{id}` | fila de `snapshots` (§3.2) | + +Por eso, ante un `UPDATE` de `episodes` Live vuelve a consultar la vista en vez de usar el payload. Con el `UPDATE` que cierra un episodio, Live **no** lo retira al instante: lo mantiene 10 s con su resultado («Terminado») y solo entonces, si no ha arrancado otro, pasa a «No hay ninguna ejecución en directo». + +La suscripción sin filtro es aparte y hace falta: las otras tres van filtradas al episodio que se está +enseñando, así que un episodio **nuevo** no despertaría a nadie y la pantalla se quedaría en el anterior +hasta recargar a mano. Se filtra por `status = running` en el cliente porque un backfill inserta cientos +de episodios ya terminados y no hay por qué recargar con cada uno. + +### Qué enseña Live, exactamente + +Es un **episodio**, no una ejecución, y se elige en dos pasos: + +1. ¿Hay alguno con `status = running`? → ese, en vivo. +2. Si no → el de `started_at` más reciente: el último guardado. + +Ojo con el paso 2: `episodes.started_at` es `default now()`, así que para lo subido por `backfill.py` o por +el sembrado es la hora de **inserción**, no la de ejecución (los episodios de un mismo run backfilleado +comparten sello al segundo). Para lo que se produce con `begin()` sí es la hora real de arranque. Y tras +sembrar, lo sintético queda como "lo último" hasta que se mida algo nuevo: sale con su trama, pero +conviene saberlo antes de una demo. + +El SDK sabe producir las tres. `RunLog.episode()` sigue subiendo el episodio **ya terminado** con sus +filas hijas de golpe, que es lo que quieren el backfill y el sembrado; para el modo en vivo hay un ciclo +de vida aparte: + +| Llamada | Qué hace | Qué ve Live | +|---|---|---| +| `begin(seed, n_objects=…)` | `POST /episodes` con `status: "running"` | aparece el episodio en curso | +| `event(...)` / `pallet_state(...)` / `placement(...)` | una fila suelta con su `episode_id` | los `INSERT` de §4, según ocurren | +| `end(result)` | escribe el `jsonl` y hace el `PATCH` de arriba | el `UPDATE` que cierra el episodio | + +Quien produce episodios tiene que llamarlas, o Live seguirá enseñando el palé ya montado: sin una fila +`running` y sin `UPDATE`, dos de las tres suscripciones no se disparan nunca. + +## 5. Versiones del esquema + +`001_schema.sql` crea las tablas, los índices, el RLS y la publicación de Realtime; las tres vistas las +define solo `002_design.sql`; `003_snapshots.sql` añade las fotos y su bucket. Se pegan en orden. 002 además añade +`runs.description`, `runs.synthetic`, `pallet_states.settle_drift_m`, `seed_min/max`, medianas, `dominant_failure`, +`seed_series` y las columnas derivadas de `v_episode_summary`. Con solo 001 aplicado, las respuestas de arriba +no tienen esos campos. diff --git a/docs/img/episode-dark.png b/docs/img/episode-dark.png new file mode 100644 index 0000000..1bc4fbf Binary files /dev/null and b/docs/img/episode-dark.png differ diff --git a/docs/img/episode-light.png b/docs/img/episode-light.png new file mode 100644 index 0000000..4c7f67b Binary files /dev/null and b/docs/img/episode-light.png differ diff --git a/docs/img/mark-light.png b/docs/img/mark-light.png new file mode 100644 index 0000000..88014d2 Binary files /dev/null and b/docs/img/mark-light.png differ diff --git a/docs/img/runs-dark.png b/docs/img/runs-dark.png new file mode 100644 index 0000000..5082954 Binary files /dev/null and b/docs/img/runs-dark.png differ diff --git a/docs/img/runs-light.png b/docs/img/runs-light.png new file mode 100644 index 0000000..520f972 Binary files /dev/null and b/docs/img/runs-light.png differ diff --git a/docs/img/wordmark-light.png b/docs/img/wordmark-light.png new file mode 100644 index 0000000..9bfa7de Binary files /dev/null and b/docs/img/wordmark-light.png differ diff --git a/frontend/README.md b/frontend/README.md index e215bc4..9b9ddf3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,36 +1,21 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# frontend -## Getting Started +The Next.js 16 interface of the STACKSPECT palletizing observability platform. It reads +Supabase directly with the anon key, under RLS — there is no server of ours in between. -First, run the development server: +**Everything you need is in the [root README](../README.md):** requirements, +installation, development, usage and dependencies. Conventions and the checks CI runs +are in [`CONTRIBUTING.md`](../CONTRIBUTING.md). -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More +Three things that trip people up here, in short: -To learn more about Next.js, take a look at the following resources: +- Credentials come from the **root `.env`**, translated by + [`next.config.ts`](next.config.ts). There is no `.env.local`. +- `npm run lint` is eslint **plus** `scripts/check-colors.mjs` and + `scripts/check-contrast.mjs`. Colours only ever live in `styles/colors.css`. +- No Tailwind. CSS Modules next to each component. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +npm ci +npm run dev # http://localhost:3000 — or ../dev.sh, which checks the schema first +``` diff --git a/frontend/api/clients/episodeDetail/getEvents.ts b/frontend/api/clients/episodeDetail/getEvents.ts new file mode 100644 index 0000000..f64874c --- /dev/null +++ b/frontend/api/clients/episodeDetail/getEvents.ts @@ -0,0 +1,20 @@ +import { supabase, type RunEvent } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /events?episode_id=eq.{id}&order=seq.asc` */ +export async function getEvents(episodeId: string): Promise { + const source = "getEvents"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("events").select("*").eq("episode_id", episodeId) + .order("seq", { ascending: true }); + if (error) throw error; + return (data ?? []) as RunEvent[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/episodeDetail/getPalletStates.ts b/frontend/api/clients/episodeDetail/getPalletStates.ts new file mode 100644 index 0000000..c69a58a --- /dev/null +++ b/frontend/api/clients/episodeDetail/getPalletStates.ts @@ -0,0 +1,20 @@ +import { supabase, type PalletState } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /pallet_states?episode_id=eq.{id}&order=after_seq.asc`: la traza de CoG. */ +export async function getPalletStates(episodeId: string): Promise { + const source = "getPalletStates"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("pallet_states").select("*").eq("episode_id", episodeId) + .order("after_seq", { ascending: true }); + if (error) throw error; + return (data ?? []) as PalletState[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/episodeDetail/getPlacements.ts b/frontend/api/clients/episodeDetail/getPlacements.ts new file mode 100644 index 0000000..6024bc7 --- /dev/null +++ b/frontend/api/clients/episodeDetail/getPlacements.ts @@ -0,0 +1,20 @@ +import { supabase, type Placement } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /placements?episode_id=eq.{id}&order=seq.asc` */ +export async function getPlacements(episodeId: string): Promise { + const source = "getPlacements"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("placements").select("*").eq("episode_id", episodeId) + .order("seq", { ascending: true }); + if (error) throw error; + return (data ?? []) as Placement[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/episodeDetail/index.ts b/frontend/api/clients/episodeDetail/index.ts new file mode 100644 index 0000000..f172cd5 --- /dev/null +++ b/frontend/api/clients/episodeDetail/index.ts @@ -0,0 +1,3 @@ +export { getPlacements } from "./getPlacements"; +export { getPalletStates } from "./getPalletStates"; +export { getEvents } from "./getEvents"; diff --git a/frontend/api/clients/episodes/getEpisode.ts b/frontend/api/clients/episodes/getEpisode.ts new file mode 100644 index 0000000..f1ed35b --- /dev/null +++ b/frontend/api/clients/episodes/getEpisode.ts @@ -0,0 +1,20 @@ +import { supabase, type Episode } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /v_episode_summary?run_id=eq.{runId}&seed=eq.{seed}` → fila o `null`. */ +export async function getEpisode(runId: string, seed: number): Promise { + const source = "getEpisode"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("v_episode_summary").select("*") + .eq("run_id", runId).eq("seed", seed).maybeSingle(); + if (error) throw error; + return data as Episode | null; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/episodes/getEpisodes.ts b/frontend/api/clients/episodes/getEpisodes.ts new file mode 100644 index 0000000..51da271 --- /dev/null +++ b/frontend/api/clients/episodes/getEpisodes.ts @@ -0,0 +1,20 @@ +import { supabase, type Episode } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /v_episode_summary?run_id=eq.{runId}&order=seed.asc` */ +export async function getEpisodes(runId: string): Promise { + const source = "getEpisodes"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("v_episode_summary").select("*").eq("run_id", runId) + .order("seed", { ascending: true }); + if (error) throw error; + return (data ?? []) as Episode[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/episodes/getRunningEpisode.ts b/frontend/api/clients/episodes/getRunningEpisode.ts new file mode 100644 index 0000000..39b2867 --- /dev/null +++ b/frontend/api/clients/episodes/getRunningEpisode.ts @@ -0,0 +1,42 @@ +import { supabase, type Episode } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** + * El episodio que está corriendo AHORA, o `null` si no hay ninguno: es lo único que enseña + * Live. No cae al último terminado: una pantalla en directo que enseña algo viejo como si + * fuera actual es peor que una que dice que no hay nada. + * + * `GET /v_episode_summary?status=eq.running&order=started_at.desc&limit=1` + * + * Si hubiera varios en curso (dos benchmarks a la vez), el más reciente. + * + * Se descartan los que llevan demasiado abiertos. Un proceso que muere sin llamar a + * `end()` deja su episodio en `running` para siempre, y como aquí tiene prioridad + * absoluta, secuestraba Live: enseñaba un episodio muerto con el aviso de conexión + * perdida encima y ya no salía de ahí. Ha pasado. + */ + +/** Pasado este tiempo, un episodio «en curso» está abandonado, no corriendo. Los medidos + * duran entre 25 y 75 s de reloj, así que esto es un orden de magnitud por encima: solo + * descarta lo que de verdad se quedó colgado. + * ponytail: umbral fijo; si algún día hay episodios largos de verdad, mejor mirar la + * hora del último evento que la de arranque. */ +export const MAX_RUNNING_AGE_MS = 15 * 60 * 1000; +export async function getRunningEpisode(): Promise { + const source = "getRunningEpisode"; + const t0 = performance.now(); + try { + assertConfigured(source); + const desde = new Date(Date.now() - MAX_RUNNING_AGE_MS).toISOString(); + const { data, error } = await supabase + .from("v_episode_summary").select("*").eq("status", "running") + .gte("started_at", desde) + .order("started_at", { ascending: false }).limit(1); + if (error) throw error; + return ((data ?? []) as Episode[])[0] ?? null; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/episodes/index.ts b/frontend/api/clients/episodes/index.ts new file mode 100644 index 0000000..545f44f --- /dev/null +++ b/frontend/api/clients/episodes/index.ts @@ -0,0 +1,3 @@ +export { getEpisodes } from "./getEpisodes"; +export { getEpisode } from "./getEpisode"; +export { getRunningEpisode } from "./getRunningEpisode"; diff --git a/frontend/api/clients/errors/ApiError.ts b/frontend/api/clients/errors/ApiError.ts new file mode 100644 index 0000000..0b4e299 --- /dev/null +++ b/frontend/api/clients/errors/ApiError.ts @@ -0,0 +1,22 @@ +/* + * Error único de la capa `api/`. Los clientes capturan lo que sea que lance + * supabase-js (un PostgrestError, un fallo de red, un TypeError) y lo relanzan con + * esta forma, para que los hooks y las pantallas trabajen con un solo tipo. + */ + +export class ApiError extends Error { + /** Qué cliente falló, p. ej. "getRun". */ + readonly source: string; + /** Código de PostgREST (`PGRST116`, `42501`…) si lo hubo. */ + readonly code: string | null; + readonly details: string | null; + + constructor(source: string, message: string, + opts: { code?: string | null; details?: string | null; cause?: unknown } = {}) { + super(`${source}: ${message}`, { cause: opts.cause }); + this.name = "ApiError"; + this.source = source; + this.code = opts.code ?? null; + this.details = opts.details ?? null; + } +} diff --git a/frontend/api/clients/errors/assertConfigured.ts b/frontend/api/clients/errors/assertConfigured.ts new file mode 100644 index 0000000..2f1b5e8 --- /dev/null +++ b/frontend/api/clients/errors/assertConfigured.ts @@ -0,0 +1,13 @@ +import { configured } from "@/lib/supabase"; +import { ApiError } from "./ApiError"; + +/** Sin variables `NEXT_PUBLIC_SUPABASE_*` el cliente apunta a http://localhost: mejor + * fallar aquí con un mensaje claro que con un "Failed to fetch" opaco. */ +export function assertConfigured(source: string): void { + if (!configured) { + throw new ApiError( + source, + "falta configurar NEXT_PUBLIC_SUPABASE_URL y NEXT_PUBLIC_SUPABASE_ANON_KEY", + ); + } +} diff --git a/frontend/api/clients/errors/index.ts b/frontend/api/clients/errors/index.ts new file mode 100644 index 0000000..e223d9b --- /dev/null +++ b/frontend/api/clients/errors/index.ts @@ -0,0 +1,4 @@ +export { ApiError } from "./ApiError"; +export { toApiError } from "./toApiError"; +export { assertConfigured } from "./assertConfigured"; +export { trace } from "./trace"; diff --git a/frontend/api/clients/errors/toApiError.ts b/frontend/api/clients/errors/toApiError.ts new file mode 100644 index 0000000..0c30162 --- /dev/null +++ b/frontend/api/clients/errors/toApiError.ts @@ -0,0 +1,12 @@ +import { ApiError } from "./ApiError"; + +export function toApiError(err: unknown, source: string): ApiError { + if (err instanceof ApiError) return err; + if (typeof err === "object" && err !== null && "message" in err) { + const e = err as { message: string; code?: string; details?: string }; + return new ApiError(source, e.message, { + code: e.code ?? null, details: e.details ?? null, cause: err, + }); + } + return new ApiError(source, String(err), { cause: err }); +} diff --git a/frontend/api/clients/errors/trace.ts b/frontend/api/clients/errors/trace.ts new file mode 100644 index 0000000..54e615d --- /dev/null +++ b/frontend/api/clients/errors/trace.ts @@ -0,0 +1,6 @@ +/** Traza de duración, solo en desarrollo. Va en el `finally` de cada cliente. */ +export function trace(source: string, startedAt: number): void { + if (process.env.NODE_ENV === "development") { + console.debug(`[api] ${source} ${(performance.now() - startedAt).toFixed(0)} ms`); + } +} diff --git a/frontend/api/clients/failures/getFailureBreakdown.ts b/frontend/api/clients/failures/getFailureBreakdown.ts new file mode 100644 index 0000000..0fece80 --- /dev/null +++ b/frontend/api/clients/failures/getFailureBreakdown.ts @@ -0,0 +1,21 @@ +import { supabase } from "@/lib/supabase"; +import type { FailureBreakdownRow } from "../../types"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /v_failure_breakdown?run_id=eq.{runId}&order=n.desc`: alimenta FailureBreakdown. */ +export async function getFailureBreakdown(runId: string): Promise { + const source = "getFailureBreakdown"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("v_failure_breakdown").select("*").eq("run_id", runId) + .order("n", { ascending: false }); + if (error) throw error; + return (data ?? []) as FailureBreakdownRow[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/failures/index.ts b/frontend/api/clients/failures/index.ts new file mode 100644 index 0000000..8b65ed4 --- /dev/null +++ b/frontend/api/clients/failures/index.ts @@ -0,0 +1 @@ +export { getFailureBreakdown } from "./getFailureBreakdown"; diff --git a/frontend/api/clients/index.ts b/frontend/api/clients/index.ts new file mode 100644 index 0000000..d97f04f --- /dev/null +++ b/frontend/api/clients/index.ts @@ -0,0 +1,7 @@ +export { ApiError } from "./errors"; +export { getRuns, getRun } from "./runs"; +export { getEpisodes, getEpisode, getRunningEpisode } from "./episodes"; +export { getPlacements, getPalletStates, getEvents } from "./episodeDetail"; +export { getFailureBreakdown } from "./failures"; +export { getRunsCount, getEpisodesCount } from "./totals"; +export * from "./snapshots"; diff --git a/frontend/api/clients/runs/getRun.ts b/frontend/api/clients/runs/getRun.ts new file mode 100644 index 0000000..45cc137 --- /dev/null +++ b/frontend/api/clients/runs/getRun.ts @@ -0,0 +1,19 @@ +import { supabase, type Run } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /v_run_summary?id=eq.{id}` → la fila, o `null` si no existe. */ +export async function getRun(id: string): Promise { + const source = "getRun"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("v_run_summary").select("*").eq("id", id).maybeSingle(); + if (error) throw error; + return data as Run | null; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/runs/getRuns.ts b/frontend/api/clients/runs/getRuns.ts new file mode 100644 index 0000000..c7ad5e7 --- /dev/null +++ b/frontend/api/clients/runs/getRuns.ts @@ -0,0 +1,22 @@ +import { supabase, type Run } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /v_run_summary?order=started_at.desc&limit=N` */ +export async function getRuns(limit = 60): Promise { + const source = "getRuns"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("v_run_summary") + .select("*") + .order("started_at", { ascending: false }) + .limit(limit); + if (error) throw error; + return (data ?? []) as Run[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/runs/index.ts b/frontend/api/clients/runs/index.ts new file mode 100644 index 0000000..131a312 --- /dev/null +++ b/frontend/api/clients/runs/index.ts @@ -0,0 +1,2 @@ +export { getRuns } from "./getRuns"; +export { getRun } from "./getRun"; diff --git a/frontend/api/clients/snapshots/getSnapshots.ts b/frontend/api/clients/snapshots/getSnapshots.ts new file mode 100644 index 0000000..efc4821 --- /dev/null +++ b/frontend/api/clients/snapshots/getSnapshots.ts @@ -0,0 +1,23 @@ +import { supabase, type Snapshot } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `GET /snapshots?episode_id=eq.{id}&order=after_seq.asc`: las capturas del simulador. + * + * Son la prueba de que el dibujo no miente: al acabar el episodio se pone la foto real + * al lado del esquema y se ve si coinciden. */ +export async function getSnapshots(episodeId: string): Promise { + const source = "getSnapshots"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { data, error } = await supabase + .from("snapshots").select("*").eq("episode_id", episodeId) + .order("after_seq", { ascending: true }); + if (error) throw error; + return (data ?? []) as Snapshot[]; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/snapshots/index.ts b/frontend/api/clients/snapshots/index.ts new file mode 100644 index 0000000..19af427 --- /dev/null +++ b/frontend/api/clients/snapshots/index.ts @@ -0,0 +1 @@ +export { getSnapshots } from "./getSnapshots"; diff --git a/frontend/api/clients/totals/getEpisodesCount.ts b/frontend/api/clients/totals/getEpisodesCount.ts new file mode 100644 index 0000000..507cacc --- /dev/null +++ b/frontend/api/clients/totals/getEpisodesCount.ts @@ -0,0 +1,21 @@ +import { supabase } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `HEAD /episodes` con `Prefer: count=exact`. Se cuenta en la base y no se suma la + * columna `episodes` de cada run en el cliente: dos sitios que suman lo mismo acaban + * discrepando (AGENTS.md §4). */ +export async function getEpisodesCount(): Promise { + const source = "getEpisodesCount"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { count, error } = await supabase + .from("episodes").select("*", { count: "exact", head: true }); + if (error) throw error; + return count ?? 0; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/totals/getRunsCount.ts b/frontend/api/clients/totals/getRunsCount.ts new file mode 100644 index 0000000..5108869 --- /dev/null +++ b/frontend/api/clients/totals/getRunsCount.ts @@ -0,0 +1,20 @@ +import { supabase } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** `HEAD /v_run_summary` con `Prefer: count=exact`: el total sale de `Content-Range`, sin + * traer filas. Es lo que se enseña en la barra superior de Ejecuciones. */ +export async function getRunsCount(): Promise { + const source = "getRunsCount"; + const t0 = performance.now(); + try { + assertConfigured(source); + const { count, error } = await supabase + .from("v_run_summary").select("*", { count: "exact", head: true }); + if (error) throw error; + return count ?? 0; + } catch (err) { + throw toApiError(err, source); + } finally { + trace(source, t0); + } +} diff --git a/frontend/api/clients/totals/index.ts b/frontend/api/clients/totals/index.ts new file mode 100644 index 0000000..5f08e94 --- /dev/null +++ b/frontend/api/clients/totals/index.ts @@ -0,0 +1,2 @@ +export { getRunsCount } from "./getRunsCount"; +export { getEpisodesCount } from "./getEpisodesCount"; diff --git a/frontend/api/hooks/index.ts b/frontend/api/hooks/index.ts new file mode 100644 index 0000000..00ab9d1 --- /dev/null +++ b/frontend/api/hooks/index.ts @@ -0,0 +1,15 @@ +export { queryKeys } from "./queryKeys"; +export { useRuns } from "./useRuns"; +export { useRun } from "./useRun"; +export { useEpisodes } from "./useEpisodes"; +export { useEpisode } from "./useEpisode"; +export { useRunningEpisode } from "./useRunningEpisode"; +export { usePlacements } from "./usePlacements"; +export { usePalletStates } from "./usePalletStates"; +export { useEvents } from "./useEvents"; +export { useEpisodeDetail } from "./useEpisodeDetail"; +export { useFailureBreakdown } from "./useFailureBreakdown"; +export { useEpisodeRealtime } from "./useEpisodeRealtime"; +export { useRunsTotals } from "./useRunsTotals"; +export { useRunningEpisodeRealtime } from "./useRunningEpisodeRealtime"; +export { useSnapshots } from "./useSnapshots"; diff --git a/frontend/api/hooks/queryKeys.ts b/frontend/api/hooks/queryKeys.ts new file mode 100644 index 0000000..4f95ff8 --- /dev/null +++ b/frontend/api/hooks/queryKeys.ts @@ -0,0 +1,25 @@ +/* Fábrica de claves. Una sola fuente para que invalidar `queryKeys.runs.all` alcance + a todo lo que cuelga de runs, y para que dos hooks no inventen la misma clave distinta. */ + +export const queryKeys = { + totals: { + runs: ["totals", "runs"] as const, + episodes: ["totals", "episodes"] as const, + }, + runs: { + all: ["runs"] as const, + list: (limit: number) => ["runs", "list", limit] as const, + detail: (id: string) => ["runs", "detail", id] as const, + failures: (id: string) => ["runs", "detail", id, "failures"] as const, + }, + episodes: { + all: ["episodes"] as const, + byRun: (runId: string) => ["episodes", "run", runId] as const, + detail: (runId: string, seed: number) => ["episodes", "run", runId, seed] as const, + running: ["episodes", "running"] as const, + placements: (id: string) => ["episodes", id, "placements"] as const, + states: (id: string) => ["episodes", id, "pallet-states"] as const, + events: (id: string) => ["episodes", id, "events"] as const, + snapshots: (id: string) => ["episodes", id, "snapshots"] as const, + }, +}; diff --git a/frontend/api/hooks/useEpisode.ts b/frontend/api/hooks/useEpisode.ts new file mode 100644 index 0000000..702642e --- /dev/null +++ b/frontend/api/hooks/useEpisode.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getEpisode } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useEpisode(runId: string | undefined, seed: number | undefined) { + return useQuery({ + queryKey: queryKeys.episodes.detail(runId ?? "", seed ?? -1), + queryFn: () => getEpisode(runId as string, seed as number), + enabled: Boolean(runId) && seed !== undefined && Number.isFinite(seed), + }); +} diff --git a/frontend/api/hooks/useEpisodeDetail.ts b/frontend/api/hooks/useEpisodeDetail.ts new file mode 100644 index 0000000..fe6bbcc --- /dev/null +++ b/frontend/api/hooks/useEpisodeDetail.ts @@ -0,0 +1,17 @@ +import { useEvents } from "./useEvents"; +import { usePalletStates } from "./usePalletStates"; +import { usePlacements } from "./usePlacements"; + +/** Lo que necesita la pantalla de episodio: los tres hooks de detalle juntos. Cada + * uno mantiene su propia caché y su propio error, así que un fallo en `events` no + * tira abajo el alzado. */ +export function useEpisodeDetail(episodeId: string | undefined) { + const placements = usePlacements(episodeId); + const states = usePalletStates(episodeId); + const events = useEvents(episodeId); + return { + placements, states, events, + isPending: placements.isPending || states.isPending || events.isPending, + isError: placements.isError || states.isError || events.isError, + }; +} diff --git a/frontend/api/hooks/useEpisodeRealtime.ts b/frontend/api/hooks/useEpisodeRealtime.ts new file mode 100644 index 0000000..c3971b0 --- /dev/null +++ b/frontend/api/hooks/useEpisodeRealtime.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { configured, supabase, type PalletState, type RunEvent } from "@/lib/supabase"; +import { queryKeys } from "./queryKeys"; + +/** Añade una fila a una lista de la caché si no estaba ya (Realtime puede repetir). */ +function append(prev: T[] | undefined, row: T): T[] { + const list = prev ?? []; + return list.some((r) => r.id === row.id) ? list : [...list, row]; +} + +/** + * Mantiene al día la caché de un episodio con lo que llega por Realtime. Es la única + * fuente de esta vista además de la carga inicial: + * + * - INSERT en `events` y `pallet_states`: se añaden a su lista en la caché. + * - `placements` no está publicado en Realtime, así que cada `pallet_state` nuevo (uno + * por paquete depositado) lo invalida y se vuelve a pedir. + * - UPDATE en `episodes`: cambió el estado o la duración; se invalida todo lo que cuelga + * de `episodes` (el episodio "último", el listado del run y su detalle). + * + * Devuelve cuándo llegó lo último y si el canal está suscrito; con eso la pantalla + * decide si avisar de "conexión perdida". + */ +export function useEpisodeRealtime(episodeId: string | undefined) { + const queryClient = useQueryClient(); + const [lastReceivedAt, setLastReceivedAt] = useState(null); + const [subscribed, setSubscribed] = useState(false); + + useEffect(() => { + if (!configured || !episodeId) return; + const touch = () => setLastReceivedAt(Date.now()); + + const channel = supabase + .channel(`live-${episodeId}`) + .on("postgres_changes", + { event: "INSERT", schema: "public", table: "events", + filter: `episode_id=eq.${episodeId}` }, + (p) => { + queryClient.setQueryData( + queryKeys.episodes.events(episodeId), (prev) => append(prev, p.new as RunEvent)); + touch(); + }) + .on("postgres_changes", + { event: "INSERT", schema: "public", table: "pallet_states", + filter: `episode_id=eq.${episodeId}` }, + (p) => { + queryClient.setQueryData( + queryKeys.episodes.states(episodeId), (prev) => append(prev, p.new as PalletState)); + void queryClient.invalidateQueries({ + queryKey: queryKeys.episodes.placements(episodeId) }); + touch(); + }) + .on("postgres_changes", + { event: "UPDATE", schema: "public", table: "episodes", + filter: `id=eq.${episodeId}` }, + () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.episodes.all }); + touch(); + }) + .subscribe((status) => { + setSubscribed(status === "SUBSCRIBED"); + // Estar suscrito ya es una señal de vida: es la referencia del latido. + if (status === "SUBSCRIBED") touch(); + }); + + return () => { + setSubscribed(false); + void supabase.removeChannel(channel); + }; + }, [episodeId, queryClient]); + + return { lastReceivedAt, subscribed }; +} diff --git a/frontend/api/hooks/useEpisodes.ts b/frontend/api/hooks/useEpisodes.ts new file mode 100644 index 0000000..3bf7285 --- /dev/null +++ b/frontend/api/hooks/useEpisodes.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getEpisodes } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useEpisodes(runId: string | undefined) { + return useQuery({ + queryKey: queryKeys.episodes.byRun(runId ?? ""), + queryFn: () => getEpisodes(runId as string), + enabled: Boolean(runId), + }); +} diff --git a/frontend/api/hooks/useEvents.ts b/frontend/api/hooks/useEvents.ts new file mode 100644 index 0000000..0a0b079 --- /dev/null +++ b/frontend/api/hooks/useEvents.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getEvents } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useEvents(episodeId: string | undefined) { + return useQuery({ + queryKey: queryKeys.episodes.events(episodeId ?? ""), + queryFn: () => getEvents(episodeId as string), + enabled: Boolean(episodeId), + }); +} diff --git a/frontend/api/hooks/useFailureBreakdown.ts b/frontend/api/hooks/useFailureBreakdown.ts new file mode 100644 index 0000000..b805731 --- /dev/null +++ b/frontend/api/hooks/useFailureBreakdown.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getFailureBreakdown } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useFailureBreakdown(runId: string | undefined) { + return useQuery({ + queryKey: queryKeys.runs.failures(runId ?? ""), + queryFn: () => getFailureBreakdown(runId as string), + enabled: Boolean(runId), + }); +} diff --git a/frontend/api/hooks/usePalletStates.ts b/frontend/api/hooks/usePalletStates.ts new file mode 100644 index 0000000..267b921 --- /dev/null +++ b/frontend/api/hooks/usePalletStates.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPalletStates } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function usePalletStates(episodeId: string | undefined) { + return useQuery({ + queryKey: queryKeys.episodes.states(episodeId ?? ""), + queryFn: () => getPalletStates(episodeId as string), + enabled: Boolean(episodeId), + }); +} diff --git a/frontend/api/hooks/usePlacements.ts b/frontend/api/hooks/usePlacements.ts new file mode 100644 index 0000000..4cbe892 --- /dev/null +++ b/frontend/api/hooks/usePlacements.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPlacements } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function usePlacements(episodeId: string | undefined) { + return useQuery({ + queryKey: queryKeys.episodes.placements(episodeId ?? ""), + queryFn: () => getPlacements(episodeId as string), + enabled: Boolean(episodeId), + }); +} diff --git a/frontend/api/hooks/useRun.ts b/frontend/api/hooks/useRun.ts new file mode 100644 index 0000000..7a58a67 --- /dev/null +++ b/frontend/api/hooks/useRun.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getRun } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useRun(id: string | undefined) { + return useQuery({ + queryKey: queryKeys.runs.detail(id ?? ""), + queryFn: () => getRun(id as string), + enabled: Boolean(id), + }); +} diff --git a/frontend/api/hooks/useRunningEpisode.ts b/frontend/api/hooks/useRunningEpisode.ts new file mode 100644 index 0000000..f2299eb --- /dev/null +++ b/frontend/api/hooks/useRunningEpisode.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { getRunningEpisode } from "../clients"; +import { queryKeys } from "./queryKeys"; + +/** El episodio en curso, o `null`. `refetchInterval` es lo que descubre uno que acaba de + * arrancar: Realtime solo vigila el episodio que ya se está viendo. */ +export function useRunningEpisode(opts: { refetchInterval?: number | false } = {}) { + return useQuery({ + queryKey: queryKeys.episodes.running, + queryFn: getRunningEpisode, + refetchInterval: opts.refetchInterval ?? false, + }); +} diff --git a/frontend/api/hooks/useRunningEpisodeRealtime.ts b/frontend/api/hooks/useRunningEpisodeRealtime.ts new file mode 100644 index 0000000..0014c75 --- /dev/null +++ b/frontend/api/hooks/useRunningEpisodeRealtime.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { configured, supabase } from "@/lib/supabase"; +import { queryKeys } from "./queryKeys"; + +/** + * Se entera AL INSTANTE de que arranca un episodio nuevo (INSERT en `episodes`) y hace que + * se vuelva a preguntar cuál está en curso. + * + * Sin esto, con nada en pantalla Live solo descubriría un episodio nuevo cuando le tocara el + * sondeo (hasta 5 s): en un benchmark, con episodios seguidos, eso se vería como un hueco + * entre uno y otro. El sondeo se queda como red de seguridad por si Realtime se cae. + * + * Es un canal aparte del de `useEpisodeRealtime`, que vigila UN episodio ya elegido: este + * vigila el hueco en el que todavía no hay ninguno. + */ +export function useRunningEpisodeRealtime() { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!configured) return; + const channel = supabase + .channel("live-episodes") + .on("postgres_changes", + { event: "INSERT", schema: "public", table: "episodes" }, + () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.episodes.running }); + }) + .subscribe(); + + return () => { void supabase.removeChannel(channel); }; + }, [queryClient]); +} diff --git a/frontend/api/hooks/useRuns.ts b/frontend/api/hooks/useRuns.ts new file mode 100644 index 0000000..2a20210 --- /dev/null +++ b/frontend/api/hooks/useRuns.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getRuns } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useRuns(limit = 60) { + return useQuery({ + queryKey: queryKeys.runs.list(limit), + queryFn: () => getRuns(limit), + }); +} diff --git a/frontend/api/hooks/useRunsTotals.ts b/frontend/api/hooks/useRunsTotals.ts new file mode 100644 index 0000000..4d57308 --- /dev/null +++ b/frontend/api/hooks/useRunsTotals.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { getEpisodesCount, getRunsCount } from "../clients"; +import { queryKeys } from "./queryKeys"; + +/** «54 ejecuciones · 833 episodios». Con `enabled: false` no pide nada: la barra global + * solo lo necesita en Ejecuciones. Cada total tiene su propia consulta y su propio + * error: que falle uno no impide enseñar el otro. */ +export function useRunsTotals(enabled = true) { + const runs = useQuery({ queryKey: queryKeys.totals.runs, queryFn: getRunsCount, enabled }); + const episodes = useQuery({ + queryKey: queryKeys.totals.episodes, queryFn: getEpisodesCount, enabled, + }); + return { runs, episodes }; +} diff --git a/frontend/api/hooks/useSnapshots.ts b/frontend/api/hooks/useSnapshots.ts new file mode 100644 index 0000000..064a839 --- /dev/null +++ b/frontend/api/hooks/useSnapshots.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSnapshots } from "../clients"; +import { queryKeys } from "./queryKeys"; + +export function useSnapshots(episodeId: string | undefined) { + return useQuery({ + queryKey: queryKeys.episodes.snapshots(episodeId ?? ""), + queryFn: () => getSnapshots(episodeId as string), + enabled: Boolean(episodeId), + }); +} diff --git a/frontend/api/types.ts b/frontend/api/types.ts new file mode 100644 index 0000000..7e18bae --- /dev/null +++ b/frontend/api/types.ts @@ -0,0 +1,15 @@ +/* Tipos que no viven ya en `lib/supabase.ts` (Run, Episode, Placement, PalletState, + RunEvent). Se reexportan aquí para que la capa `api/` tenga un solo punto de + importación. */ + +export type { + Run, SeedPoint, Episode, Pose, Placement, PalletState, RunEvent, +} from "@/lib/supabase"; + +/** Fila de `v_failure_breakdown`: cuántos episodios de un run cayeron por cada causa. + * `failure` es el identificador crudo: se traduce con `failureText()` (lib/ui.ts). */ +export type FailureBreakdownRow = { + run_id: string; + failure: string; + n: number; +}; diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico index 718d6fe..581adec 100644 Binary files a/frontend/app/favicon.ico and b/frontend/app/favicon.ico differ diff --git a/frontend/app/globals.css b/frontend/app/globals.css index d5bada0..7762ed5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,55 +1,23 @@ /* - * Sistema visual de la plataforma. Los valores salen literales del tablero - * "Lenguaje visual" de docs/design/, no son aproximaciones. + * Estilos globales de la plataforma. Los colores NO se definen aquí: viven todos en + * styles/colors.css, y este archivo (como cualquier otro) solo los consume con `var(--…)`. * - * Sala de control industrial: fondo oscuro, sin adornos, sin degradados. La interfaz - * se compromete con un solo tema a propósito —es un panel de planta, no un documento— - * y por eso pinta todos sus colores explícitamente en vez de heredar ninguno. + * Lenguaje visual: limpio y amable. Superficies blancas (o casi negras) con esquinas + * suaves, mucho aire, poco borde y ninguna sombra que grite. Texto en una sans redondeada; + * magnitudes en una mono suave, con cifras de ancho fijo para que no bailen en vivo. */ :root { - /* Neutros */ - --bg: #0F1214; - --surface: #161A1E; - --topbar: #141A1E; - --border: #232B31; - --border-soft: #1F272C; - --border-hard: #3E4B56; - --row-alt: #171C21; - --row-hover: #1A2026; - - --text: #E6EAED; - --text-2: #9FAAB2; - --text-3: #8B979F; - --text-4: #6E7A82; - --text-5: #46525A; - - /* Azul acero: SOLO selección e interacción. Nunca significa estado. */ - --select: #5B9DD9; - --link: #6FA8D8; - - /* Semántica de color: tres significados y ninguno más. */ - --ok: #2FBF71; - --warn: #E8A93A; - --bad: #F2544B; - - /* Números que no son de verdad. Molestan a la vista a propósito. */ - --oracle: #E5399B; - --synthetic: #B77BF3; - - /* Una identidad de color por causa de fallo, constante en toda la aplicación. - Se reconocen de un vistazo al pasar de una pantalla a otra. */ - --f-no_detection: #7A8B99; - --f-grasp_slip: #C9A227; - --f-ik_unreachable: #5B9DD9; - --f-wrong_placement: #8C7AE6; - --f-collision: #B5485E; - --f-stack_collapse: #FF7A45; - --f-timeout: #4A5A66; - --f-overhang_violation: #00A9A5; - --f-place_inaccurate: #A0785A; - - color-scheme: dark; + /* Radios: tres tamaños y nada más. Los controles pequeños, las tarjetas y lo grande. */ + --radius-1: 8px; + --radius-2: 12px; + --radius-3: 16px; + --radius-pill: 999px; + + /* Espaciado de página: el aire que rodea las pantallas. */ + --page-gap: 20px; + --page-pad: 32px; + --topbar-h: 64px; } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -60,120 +28,59 @@ body { min-height: 100%; background: var(--bg); color: var(--text); - font-family: var(--font-plex-sans), system-ui, sans-serif; + font-family: var(--font-sans), system-ui, sans-serif; font-size: 14px; - line-height: 1.4; + line-height: 1.45; -webkit-font-smoothing: antialiased; } -a { color: var(--link); text-decoration: none; } -a:hover { color: #9CC6E8; } +/* Un cambio de tema se nota, pero no deslumbra: los colores se funden. */ +body, header, main, section, table, td, th, select, button, label { + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; +} + +a { + color: var(--link); + text-decoration: underline; + text-decoration-color: var(--border-hard); + text-underline-offset: 3px; +} +a:hover { color: var(--link-hover); text-decoration-color: currentColor; } button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } -:focus-visible { outline: 2px solid var(--select); outline-offset: 1px; } +:focus-visible { outline: 2px solid var(--select); outline-offset: 2px; border-radius: 4px; } -/* ── piezas compartidas ─────────────────────────────────────────────────── */ +/* ── piezas heredadas ──────────────────────────────────────────────────── + Las usan Episode.tsx y Runs.tsx, que esperan a las rutas de detalle. Los componentes + nuevos no las necesitan: llevan su propio módulo. */ .card { background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-3); + box-shadow: var(--elev-1); display: flex; flex-direction: column; min-width: 0; } -/* Etiquetas y cabeceras: condensada, versal, muy espaciada. */ .label { - font-family: var(--font-plex-condensed), var(--font-plex-sans), sans-serif; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.13em; - text-transform: uppercase; + font-family: var(--font-sans), system-ui, sans-serif; + font-size: 12.5px; + font-weight: 500; color: var(--text-3); } -.mono { font-family: var(--font-plex-mono), ui-monospace, monospace; } +.mono { font-family: var(--font-mono), ui-monospace, monospace; } -/* Toda magnitud física. Monoespaciada y con cifras de ancho fijo para que al - refrescarse en vivo las columnas no bailen. */ .num { - font-family: var(--font-plex-mono), ui-monospace, monospace; + font-family: var(--font-mono), ui-monospace, monospace; font-variant-numeric: tabular-nums; } .unit { color: var(--text-3); } -/* Franja diagonal de los datos que no son medidos. Se usa igual para oracle y para - sembrado: en los dos casos el mensaje es "esto no es un número de verdad". */ -.striped { - background: repeating-linear-gradient( - 135deg, rgba(229, 57, 155, 0.16) 0 7px, transparent 7px 14px); -} -.striped-synthetic { - background: repeating-linear-gradient( - 135deg, rgba(183, 123, 243, 0.16) 0 7px, transparent 7px 14px); -} - -/* ── Live ───────────────────────────────────────────────────────────────── */ - -/* El tablero mide 1440x900: 600 px de escena a la izquierda y el resto para estado. - Aquí la izquierda es fija y la derecha fluye, para que en pantallas mayores crezca - el feed de eventos y no el dibujo del palé, que ya está a escala. */ -.live { - display: flex; - flex-direction: column; - height: calc(100vh - 48px); - min-height: 0; -} -.live__body { - flex: 1; - min-height: 0; - display: flex; - gap: 12px; - padding: 12px; -} -.live__stage { - width: 600px; - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; -} -.live__side { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; - min-height: 0; -} -.live__kpis { - flex-shrink: 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -/* Portátil estrecho: la escena deja de ser fija para no comerse el estado. */ -@media (max-width: 1180px) { - .live__stage { width: 46%; } -} - -/* La demo puede acabar enseñándose en un móvil. Se apila, se sueltan las alturas - fijas y los indicadores pasan a una sola columna. */ -@media (max-width: 860px) { - .live { height: auto; } - .live__body { flex-direction: column; } - .live__stage { width: 100%; } - .live__stage .card { min-height: 280px; } -} -@media (max-width: 460px) { - .live__kpis { grid-template-columns: 1fr; } - .live__body { padding: 8px; gap: 8px; } -} - @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } diff --git a/frontend/app/icon.png b/frontend/app/icon.png new file mode 100644 index 0000000..556da8f Binary files /dev/null and b/frontend/app/icon.png differ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 56e85c9..10fb9f2 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,45 +1,56 @@ import type { Metadata } from "next"; -import { IBM_Plex_Mono, IBM_Plex_Sans, IBM_Plex_Sans_Condensed } from "next/font/google"; +import { Geist_Mono, Plus_Jakarta_Sans } from "next/font/google"; +import "@/styles/colors.css"; import "./globals.css"; -import TopBar from "@/components/TopBar"; +import { AppHeader } from "@/components/screens/AppHeader"; +import { Providers } from "./providers"; -/* Las tres voces del diseño, cada una con su trabajo: - Sans para el texto de interfaz, Condensed para etiquetas y cabeceras en versal, - y Mono para TODA magnitud física, commit y semilla. */ -const plexSans = IBM_Plex_Sans({ - variable: "--font-plex-sans", +/* Dos voces, cada una con su trabajo: + Plus Jakarta Sans para el texto de interfaz —redondeada y amable, sin perder seriedad— + y Geist Mono para TODA magnitud física, commit y semilla. */ +const sans = Plus_Jakarta_Sans({ + variable: "--font-sans", subsets: ["latin"], - weight: ["400", "500", "600"], -}); - -const plexCondensed = IBM_Plex_Sans_Condensed({ - variable: "--font-plex-condensed", - subsets: ["latin"], - weight: ["500", "600"], + weight: ["400", "500", "600", "700"], }); -const plexMono = IBM_Plex_Mono({ - variable: "--font-plex-mono", +const mono = Geist_Mono({ + variable: "--font-mono", subsets: ["latin"], - weight: ["400", "500", "600", "700"], + weight: ["400", "500", "600"], }); export const metadata: Metadata = { - title: "Observabilidad de paletizado", + title: "STACKSPECT · Observabilidad de paletizado", description: "Monitorización de ejecuciones del brazo paletizador: centro de gravedad, " + "estabilidad y curva de mejora entre commits.", }; +/* Fija el tema ANTES del primer pintado, para que no parpadee: el que eligió el usuario y, + si no eligió, el del sistema. Va como script en la cabecera y no como efecto de React, + que llegaría tarde. */ +const THEME_SCRIPT = `(function () { + var theme; + try { theme = localStorage.getItem("theme"); } catch (e) {} + if (theme !== "light" && theme !== "dark") { + theme = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" : "light"; + } + document.documentElement.dataset.theme = theme; +})();`; + export default function RootLayout({ children }: LayoutProps<"/">) { return ( - + + +