From 847689ecb3188f193c0327d4facf3a9d2779fe6f Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:02:04 +0200 Subject: [PATCH 01/26] docs: contrato de la API tal como lo entrega el front Lo entrega el equipo de front y sirve de especificacion para endurecer el backend. Se commitea sin tocar, para que los cambios que lo invaliden se lean en el diff y no se confundan con lo que ya venia escrito. --- API.md | 254 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 API.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..74b9dde --- /dev/null +++ b/API.md @@ -0,0 +1,254 @@ +# 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/`. + +## 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: `?select=*&status=eq.running&limit=1` +- Último (`getLatestEpisode()` / `useLatestEpisode()`): el episodio en curso si lo hay; si no, el más reciente. **Ver la nota de abajo.** + +```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()`. + +> **Aviso: `v_episode_summary` no tiene `started_at`.** La vista de 002 no lo selecciona, así que +> `?order=started_at.desc` sobre ella da `400 42703`. `fetchLatestEpisode` de `lib/supabase.ts` +> hace exactamente eso en su segunda consulta (cuando no hay ningún episodio `running`). El cliente +> nuevo `getLatestEpisode` lo evita pidiendo el id a `GET /episodes?select=id&order=started_at.desc&limit=1` +> y luego la fila de la vista con `?id=eq.{id}`. No lo he podido confirmar contra una base real (no había +> credenciales); si se prefiere arreglarlo de raíz, basta añadir `e.started_at` a la vista en +> `002_design.sql`. + +### 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** salvo `episodes?select=id&order=started_at.desc` +en `getLatestEpisode`. 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 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 }]` | `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 /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. + +## 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 | + +Por eso, ante un `UPDATE` de `episodes` Live vuelve a consultar la vista en vez de usar el payload. + +Ojo: `RunLog.episode()` (§3) inserta el episodio **ya terminado** (`success`/`failure`) y con sus filas hijas +de golpe; no hay ningún `UPDATE` de `episodes` ni un episodio `running` que salga del SDK actual. Live solo verá +`INSERT`s en vivo si el productor escribe el episodio al empezar y sus `events`/`pallet_states` según avanzan. + +## 5. Versiones del esquema + +`001_schema.sql` crea las tablas y unas vistas básicas; `002_design.sql` las **tira y rehace** y 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. From 3c78d5087efb0b7c0a589564afcb40adbd64903d Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:04:56 +0200 Subject: [PATCH 02/26] chore(backend): pytest y ruff como extra dev El SDK sigue con cero dependencias de ejecucion: pytest y ruff van en el extra [dev], que solo instalan CI y quien desarrolla. La simulacion importa theker_telemetry sin arrastrar nada nuevo. select explicito (E, F, I, UP) a proposito: el conjunto por defecto de ruff cambia entre versiones y CI se pondria rojo por algo que nadie ha tocado. line-length alto para que E501 solo cace lineas desbocadas y no obligue a romper la tabla alineada de CAMPAIGN, donde la alineacion es lo que deja leer la curva de mejora de un vistazo. Los arreglos automaticos son los que pedia UP: datetime.UTC (ya exigimos Python 3.11) y Iterable/Sequence desde collections.abc. --- .gitignore | 3 +++ backend/backfill.py | 4 ++-- backend/pyproject.toml | 22 ++++++++++++++++++++++ backend/seed/__init__.py | 0 backend/seed/palletizing.py | 6 +++--- backend/theker_telemetry/core.py | 3 ++- 6 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 backend/seed/__init__.py diff --git a/.gitignore b/.gitignore index f2ec80c..a755c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ Thumbs.db # Logs y volcados *.log core + +# CodeGraph +.codegraph/ 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..3dfe850 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,5 +11,27 @@ 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. +[project.optional-dependencies] +dev = ["pytest>=8", "ruff>=0.6"] + [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/palletizing.py b/backend/seed/palletizing.py index d779c47..0169918 100644 --- a/backend/seed/palletizing.py +++ b/backend/seed/palletizing.py @@ -28,14 +28,14 @@ import random import sys import urllib.request -from datetime import datetime, timedelta, timezone +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 # 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. @@ -350,7 +350,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/theker_telemetry/core.py b/backend/theker_telemetry/core.py index 67557ae..c6b4263 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 From 04f034d956cadd82ce0d47d6b63fea60e409a94a Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:11:17 +0200 Subject: [PATCH 03/26] test(telemetry): suite del SDK, backfill y sembrado 70 tests, ninguno toca la red. Cubren lo que hasta ahora solo se habia probado a mano, empezando por la promesa dura de AGENTS.md 4: el disco manda, un fallo de red avisa una vez y el episodio sigue. Sin eso, un benchmark de cincuenta episodios se cae por el wifi de la sala y no queda ni el jsonl. Tambien queda cubierto el formato viejo de episodes.jsonl (sin n_misrouted) y la clasificacion de claves del backfill, que es lo que permite cargar los 665 episodios historicos sin perder las metricas de la rama de ordenacion. Dos supuestos mios eran falsos y mando el codigo, no al reves: el apoyo mide 1180x380, asi que el borde que manda es el de Y y descentrar en X no cambia el margen; y pallet_states guarda una fila por paquete INTENTADO, de modo que el intento que derrumba el monton tambien deja traza. --- backend/tests/test_backfill.py | 136 ++++++++++++++ backend/tests/test_core.py | 328 +++++++++++++++++++++++++++++++++ backend/tests/test_schema.py | 119 ++++++++++++ backend/tests/test_seed.py | 136 ++++++++++++++ 4 files changed, 719 insertions(+) create mode 100644 backend/tests/test_backfill.py create mode 100644 backend/tests/test_core.py create mode 100644 backend/tests/test_schema.py create mode 100644 backend/tests/test_seed.py 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_core.py b/backend/tests/test_core.py new file mode 100644 index 0000000..6d98ffb --- /dev/null +++ b/backend/tests/test_core.py @@ -0,0 +1,328 @@ +"""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_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" 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..53c0a95 --- /dev/null +++ b/backend/tests/test_seed.py @@ -0,0 +1,136 @@ +"""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 random + +from seed.palletizing import ( + CAMPAIGN, + PACKAGES_BY_LEVEL, + TRANSPORT_ACCEL_G, + build_episode, + 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_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 From 95da2da4743e4563db85358797777b33d203e16a Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:13:07 +0200 Subject: [PATCH 04/26] test(contrato): vocabulario de fallos cuadrado en los tres sitios AGENTS.md 5 dice que anadir una causa obliga a tocar tres sitios: FAILURES, el CHECK de episodes.failure y FAILURE_TEXT. Nada lo comprobaba. 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 vacia; o la UI acaba ensenando el identificador crudo, que es justo lo prohibido. La asimetria entre lo almacenable (9, con el historico place_inaccurate) y lo producible (8) es deliberada y queda fijada como subconjunto, no como igualdad, para que no la 'arregle' nadie por error. Sin base de datos: se leen los ficheros como texto. Comprobado por mutacion que falla de verdad al quitar una traduccion. --- backend/tests/test_vocabulario.py | 120 ++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 backend/tests/test_vocabulario.py diff --git a/backend/tests/test_vocabulario.py b/backend/tests/test_vocabulario.py new file mode 100644 index 0000000..daf558c --- /dev/null +++ b/backend/tests/test_vocabulario.py @@ -0,0 +1,120 @@ +"""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 + 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" + 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") From fcfc2928866d05b696333b16cf0eb2bf8aea984e Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:15:26 +0200 Subject: [PATCH 05/26] feat(telemetry): ciclo de vida del episodio para que Live sea en vivo episode() sube el episodio YA terminado, con todas sus filas hijas de golpe. Para un benchmark esta bien, pero entonces la pantalla Live no tiene nada a lo que suscribirse: nunca existe una fila con status running ni llega un UPDATE de episodes, que son dos de las tres senales que escucha (API.md 4). El jurado veria el pale ya hecho, aparecido de golpe, en vez de montandose capa a capa. Es la pantalla que justifica la plataforma entera. Ahora begin() abre el episodio en curso, event()/pallet_state()/placement() van soltando filas segun ocurren, y end() escribe el jsonl y cierra con un PATCH, que es ademas el UPDATE al que Live esta suscrita y la primera vez que alguien rellena episodes.ended_at. episode() se queda igual: backfill, sembrado y la simulacion de hoy siguen funcionando sin tocar una linea. Todo pasa por _try, asi que un Supabase caido sigue degradando a solo-disco. Pendiente en el repo de la simulacion: el productor tiene que llamar begin() y end() o esto queda construido y sin usar. --- backend/tests/test_core.py | 123 +++++++++++++++++++++++++++++++ backend/theker_telemetry/core.py | 68 +++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/backend/tests/test_core.py b/backend/tests/test_core.py index 6d98ffb..a484996 100644 --- a/backend/tests/test_core.py +++ b/backend/tests/test_core.py @@ -307,6 +307,129 @@ def test_sin_run_id_no_se_intenta_subir_nada(tmp_path): assert len(log.path.read_text(encoding="utf-8").splitlines()) == 1 +# ── 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) diff --git a/backend/theker_telemetry/core.py b/backend/theker_telemetry/core.py index c6b4263..1ada382 100644 --- a/backend/theker_telemetry/core.py +++ b/backend/theker_telemetry/core.py @@ -214,8 +214,14 @@ def __init__(self, repo: Path, *, task: str = "induction", level: int = 0, 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", [{ @@ -254,6 +260,68 @@ 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)) + if rows: + self.episode_id = rows[0]["id"] + + 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 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 + + row = episode_row(result, self.run_id) + row.pop("run_id") + row["ended_at"] = "now()" + self._try("episodes", lambda: self.client.patch( + "episodes", {"id": episode_id}, row)) + + 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.""" From af17f3a7e784bcce6bbe38a1726be8bf0ea34b4b Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:17:04 +0200 Subject: [PATCH 06/26] fix(sql): v_episode_summary expone started_at y ended_at fetchLatestEpisode ordena por started_at sobre la vista, y la vista no proyectaba esa columna: PostgREST devuelve 400 42703, load() en page.tsx no tiene catch y Live cae a 'todavia no hay episodios'. Solo se salvaba si habia un episodio running, que es justo lo que no hay antes de arrancar la demo. Lo detecto el equipo de front y lo esquivo con una consulta en dos pasos (API.md 2.2); esto lo arregla de raiz, asi que ese rodeo ya puede colapsarse a una sola consulta. De paso, indice parcial sobre episodes(started_at) where status='running': es la otra consulta que Live hace en cada recarga. --- backend/sql/001_schema.sql | 91 +++++--------------------------------- backend/sql/002_design.sql | 5 +++ 2 files changed, 17 insertions(+), 79 deletions(-) diff --git a/backend/sql/001_schema.sql b/backend/sql/001_schema.sql index 146131f..408fb69 100644 --- a/backend/sql/001_schema.sql +++ b/backend/sql/001_schema.sql @@ -140,81 +140,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 +177,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..3b1d856 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, From fae7928f2dd770cbc456399a3910193da92d0e44 Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:19:04 +0200 Subject: [PATCH 07/26] fix(telemetry): ninguna respuesta malformada tumba un episodio _try solo capturaba RuntimeError, que es lo que lanza el cliente a proposito. Pero habia dos caminos por los que una respuesta rara se llevaba por delante el benchmark entero, justo lo que este modulo existe para evitar: - Un 200 con cuerpo que no es JSON (el HTML de un proxy, por ejemplo) revienta en json.loads con un ValueError. - Una fila devuelta sin 'id' reventaba al leerla con un KeyError. Y esta ocurria FUERA de _try, ya de vuelta de la red, asi que ampliar el except no bastaba: la lectura pasa ahora por _id_of, donde confluyen los tres sitios que sacaban rows[0]['id']. Un episodio cuya fila no se sabe identificar deja de subir sus hijas pero no apaga el remoto: el siguiente episodio puede ir bien, y el jsonl esta escrito de todas formas. --- backend/tests/test_core.py | 36 ++++++++++++++++++++++++++++++++ backend/theker_telemetry/core.py | 29 ++++++++++++++++++------- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_core.py b/backend/tests/test_core.py index a484996..64115fb 100644 --- a/backend/tests/test_core.py +++ b/backend/tests/test_core.py @@ -307,6 +307,42 @@ def test_sin_run_id_no_se_intenta_subir_nada(tmp_path): 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): diff --git a/backend/theker_telemetry/core.py b/backend/theker_telemetry/core.py index 1ada382..7981e31 100644 --- a/backend/theker_telemetry/core.py +++ b/backend/theker_telemetry/core.py @@ -233,8 +233,7 @@ def __init__(self, repo: Path, *, task: str = "induction", level: int = 0, "label": label, "n_episodes": int(n_episodes), }], returning=True)) - if rows: - self.run_id = rows[0]["id"] + self.run_id = self._id_of(rows) # ── uso normal ─────────────────────────────────────────────────────────── @@ -249,9 +248,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), @@ -285,8 +284,7 @@ def begin(self, seed: int, *, n_objects: int = 0, "n_objects": int(n_objects), "n_placed": 0, }], returning=True)) - if rows: - self.episode_id = rows[0]["id"] + self.episode_id = self._id_of(rows) def event(self, **row: Any) -> None: self._push("events", row) @@ -313,6 +311,17 @@ def end(self, result: EpisodeResult) -> None: 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: @@ -348,7 +357,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}") From 855b449ac5a133d69c434854547d2a92a3553478 Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:20:00 +0200 Subject: [PATCH 08/26] fix(seed): el sembrado vuelve a ser determinista entre procesos rng.seed(hash((sha, seed))) no era determinista: CPython aleatoriza el hash de las cadenas por proceso desde la 3.3, asi que cada ejecucion del sembrado producia un historico distinto mientras el comentario de encima prometia que relanzarlo daba el mismo. Un historico que cambia solo no sirve para ensenar una curva de mejora, que es exactamente para lo que existe. Medido con PYTHONHASHSEED 0/1/2 sobre ('8c5cbf4', 37): antes 1069116207, 3997445009, 1533800031 ahora 3273588617, 3273588617, 3273588617 El test lo comprueba lanzando tres procesos de verdad: dentro de uno solo el hash es estable y el fallo no se ve. Va tambien Supabase.delete, para que wipe() deje de construir el DELETE a mano con client._headers y urlopen: asi respeta el timeout de la clase y un borrado fallido cuenta lo que dijo PostgREST en vez de un traceback pelado. --- backend/seed/palletizing.py | 19 ++++++++++++------ backend/tests/test_seed.py | 34 ++++++++++++++++++++++++++++++++ backend/theker_telemetry/core.py | 10 ++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/backend/seed/palletizing.py b/backend/seed/palletizing.py index 0169918..4cfedfc 100644 --- a/backend/seed/palletizing.py +++ b/backend/seed/palletizing.py @@ -27,7 +27,7 @@ import math import random import sys -import urllib.request +import zlib from datetime import UTC, datetime, timedelta from pathlib import Path @@ -273,6 +273,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 +298,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 +335,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) diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 53c0a95..d405a56 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -5,13 +5,18 @@ que significa: negativo = vuelca. """ +import os import random +import subprocess +import sys +from pathlib import Path from seed.palletizing import ( CAMPAIGN, PACKAGES_BY_LEVEL, TRANSPORT_ACCEL_G, build_episode, + episode_seed, stability_margin, support_polygon, ) @@ -94,6 +99,35 @@ def test_la_campana_va_de_peor_a_mejor(): # ── 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.""" diff --git a/backend/theker_telemetry/core.py b/backend/theker_telemetry/core.py index 7981e31..c64f773 100644 --- a/backend/theker_telemetry/core.py +++ b/backend/theker_telemetry/core.py @@ -161,6 +161,16 @@ def patch(self, table: str, match: dict, data: dict) -> None: ) self._send(request, table) + 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 From 100060159eb00b045ce0a064abfc68ac4e638025 Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:23:30 +0200 Subject: [PATCH 09/26] test(contrato): relaciones y consultas verificadas contra Postgres 19 tests que levantan el esquema de verdad en postgres:16 y comprueban lo que ningun parser de SQL puede: que los .sql aplican, que aplican dos veces y en cualquier orden, y que las vistas calculan lo que dicen con filas dentro (1, 2 y 90 segundos dan mediana 2, no media 31). El assert que importa es otro y conviene no confundirlo: comparar columnas contra tipos TS NO caza el bug de Live, porque Episode tampoco declaraba started_at y los dos lados coincidian. Lo que lo caza es recorrer los .from() del front y exigir que cada columna de un .order()/.eq() exista en su relacion. Comprobado por mutacion: al quitar e.started_at de la vista, falla diciendo 'columnas que no existen: v_episode_summary.started_at'. Los tipos se comparan como subconjunto, no igualdad: mean_score esta en la vista y no en Run a proposito (API.md 2.1). Lo que rompe la UI es una columna que falta, que llega como undefined y se pinta mal en silencio. Trabaja en un esquema propio y solo contra localhost: apuntar DATABASE_URL a un Supabase de verdad no puede llevarse por delante los datos. Sin DATABASE_URL se salta entero. --- backend/tests/test_contrato.py | 312 +++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 backend/tests/test_contrato.py diff --git a/backend/tests/test_contrato.py b/backend/tests/test_contrato.py new file mode 100644 index 0000000..141252c --- /dev/null +++ b/backend/tests/test_contrato.py @@ -0,0 +1,312 @@ +"""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 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. + """ + 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.""" + psql_file(SQL_DIR / "001_schema.sql") + psql_file(SQL_DIR / "002_design.sql") + + +# ── 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.""" + for fichero in ("001_schema.sql", "002_design.sql", + "001_schema.sql", "002_design.sql"): + psql_file(SQL_DIR / 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") == "" + + con_politica = set(psql( + f"select tablename from pg_policies where schemaname = '{ESQUEMA}'").splitlines()) + assert con_politica == {"runs", "episodes", "placements", "pallet_states", "events"} From 1a1e4039fc535e47354325d84fd6c3b34d523b75 Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:29:00 +0200 Subject: [PATCH 10/26] ci: ruff, pytest con Postgres y build del front en cada push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos jobs. El backend levanta postgres:16 como servicio, porque el Postgres no es decoracion: el ultimo bug de esquema que se colo (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. El montaje del esquema lo hace el propio test, asi que en local y en CI se corre exactamente lo mismo. El front hace lint y build. build y no tsc --noEmit porque Next 16 genera durante el build los tipos de PageProps<'/runs/[id]'>, que es justo el gate que van a necesitar las rutas pendientes. Van dos arreglos de lint en app/page.tsx sin los que el CI nacia en rojo, y los dos eran reales aunque menores: - useRef(Date.now()) lee el reloj durante el render, que es impuro: al re-renderizar daba otro valor y el latido medía contra un instante que nunca ocurrio. Ahora arranca a null y no marca 'sin datos nuevos' hasta que ha llegado algo de verdad. - La carga inicial silencia react-hooks/set-state-in-effect con su porque: sincronizar con Supabase al montar es lo que se quiere, y load tiene dependencias vacias, asi que no hay cascada de renders. --- .github/workflows/ci.yml | 80 ++++++++++++++++++++++++++++++++++++++++ frontend/app/page.tsx | 13 ++++++- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml 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/frontend/app/page.tsx b/frontend/app/page.tsx index c362db2..c01f496 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -26,7 +26,10 @@ export default function LivePage() { const [loading, setLoading] = useState(true); const [lastSeen, setLastSeen] = useState(null); const [stale, setStale] = useState(false); - const lastRef = useRef(Date.now()); + /* null = todavía no ha llegado nada. No se inicializa con Date.now() porque leer el + reloj durante el render es impuro: al re-renderizar daría un valor distinto y el + latido acabaría midiendo contra un instante que nunca ocurrió. */ + const lastRef = useRef(null); const load = useCallback(async () => { if (!configured) { setLoading(false); return; } @@ -47,6 +50,10 @@ export default function LivePage() { } }, []); + /* Carga inicial. La regla avisa de que `load` toca estado dentro del efecto, y aquí + es justo lo que se quiere: sincronizar con Supabase al montar. No hay cascada de + renders porque `load` tiene dependencias vacías y solo se ejecuta una vez. */ + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { void load(); }, [load]); /* Realtime: esta vista se alimenta de estas suscripciones y de nada más. */ @@ -83,7 +90,9 @@ export default function LivePage() { useEffect(() => { const t = setInterval(() => { const running = episode?.status === "running"; - setStale(Boolean(running) && Date.now() - lastRef.current > HEARTBEAT_MS); + const visto = lastRef.current; + setStale(Boolean(running) && visto !== null + && Date.now() - visto > HEARTBEAT_MS); }, 1000); return () => clearInterval(t); }, [episode]); From e5d95e4a946116e0af12d9c60cf0a39d15351d6e Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:30:48 +0200 Subject: [PATCH 11/26] docs: API.md al dia y referencias muertas al repo de simulacion API.md describia dos cosas que este trabajo ha dejado de ser ciertas: - El aviso de que v_episode_summary no tiene started_at. Ya la tiene, y el rodeo en dos consultas de getLatestEpisode puede colapsarse a una. - El parrafo de que el SDK no produce episodios running ni UPDATEs. Ahora si: se documenta el ciclo begin/event/end y se anade el PATCH /episodes a la tabla de escritura. Referencias muertas al repo de la simulacion, que mandaban a editar ficheros que aqui no existen: AGENTS.md decia que el CHECK de episodes.failure estaba en 002_design.sql cuando esta en 001, y 001 citaba scripts/backfill.py y src/metrics.py. Quien siguiera esas instrucciones para anadir una causa de fallo editaba el fichero equivocado. AGENTS.md 3 documenta como lanzar los tests, y 9 avisa de lo unico que queda pendiente fuera de este repo: que la simulacion llame a begin()/end(). --- AGENTS.md | 21 +++++++++++++++++++-- API.md | 33 ++++++++++++++++++++++----------- backend/sql/001_schema.sql | 14 ++++++++------ 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad304fb..475086d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,13 @@ python backend/backfill.py --runs-dir /simulation/runs # 5. Front 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 @@ -122,10 +129,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 | @@ -202,7 +212,14 @@ evolución en vez de borrón y cuenta nueva. ## 9. Estado -- **Backend: completo.** Esquema, SDK, backfill y sembrado, todos probados. +- **Backend: completo y con red.** Esquema, SDK, backfill y sembrado, con 110 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 `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:** 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: diff --git a/API.md b/API.md index 74b9dde..a77948d 100644 --- a/API.md +++ b/API.md @@ -128,13 +128,13 @@ Notas: `duration_s` y `score` pueden ser `null` (episodio en curso). `cycle_time `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()`. -> **Aviso: `v_episode_summary` no tiene `started_at`.** La vista de 002 no lo selecciona, así que -> `?order=started_at.desc` sobre ella da `400 42703`. `fetchLatestEpisode` de `lib/supabase.ts` -> hace exactamente eso en su segunda consulta (cuando no hay ningún episodio `running`). El cliente -> nuevo `getLatestEpisode` lo evita pidiendo el id a `GET /episodes?select=id&order=started_at.desc&limit=1` -> y luego la fila de la vista con `?id=eq.{id}`. No lo he podido confirmar contra una base real (no había -> credenciales); si se prefiere arreglarlo de raíz, basta añadir `e.started_at` a la vista en -> `002_design.sql`. +> **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 `getLatestEpisode` puede +> volver a una sola consulta sobre la vista y tirar 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 @@ -222,6 +222,7 @@ Todo fallo remoto avisa una vez y el episodio sigue: el `episodes.jsonl` en disc | `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 }` | `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)`, @@ -242,13 +243,23 @@ Publicación `supabase_realtime`, canal `postgres_changes` sobre el esquema `pub Por eso, ante un `UPDATE` de `episodes` Live vuelve a consultar la vista en vez de usar el payload. -Ojo: `RunLog.episode()` (§3) inserta el episodio **ya terminado** (`success`/`failure`) y con sus filas hijas -de golpe; no hay ningún `UPDATE` de `episodes` ni un episodio `running` que salga del SDK actual. Live solo verá -`INSERT`s en vivo si el productor escribe el episodio al empezar y sus `events`/`pallet_states` según avanzan. +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 y unas vistas básicas; `002_design.sql` las **tira y rehace** y añade +`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`, que 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/backend/sql/001_schema.sql b/backend/sql/001_schema.sql index 408fb69..1c0e985 100644 --- a/backend/sql/001_schema.sql +++ b/backend/sql/001_schema.sql @@ -8,13 +8,13 @@ -- 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 ( @@ -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 From af7ec998fd9148654f398a1bbef9fb704ce8e9c5 Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 12:39:47 +0200 Subject: [PATCH 12/26] fix(live): la pantalla detecta un episodio nuevo sin recargar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las tres suscripciones de Realtime van filtradas al episodio que se esta enseñando, y no hay polling. La unica forma de que Live volviera a preguntar cual es el ultimo era recibir un UPDATE del episodio que YA tenia en pantalla. Consecuencia en la demo: abres Live, te enseña el ultimo terminado, arrancas la simulacion y Live no se entera. El episodio nuevo nace running pero nadie escucha INSERT sobre episodes, asi que la pantalla se queda en el anterior hasta recargar a mano. Con el ciclo begin()/end() recien añadido esto pasaba de ser teorico a ser lo que iba a ocurrir delante del jurado. Se añade una suscripcion a INSERT sobre episodes, sin filtro, que solo recarga si la fila nueva viene con status running: un backfill inserta cientos de episodios ya terminados y no hay por que recargar con cada uno. API.md documenta ademas que Live elige un EPISODIO y no una ejecucion, y que started_at es la hora de insercion para lo backfilleado y la de arranque real solo para lo que produce begin(). --- API.md | 19 +++++++++++++++++++ frontend/app/page.tsx | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/API.md b/API.md index a77948d..662bcd5 100644 --- a/API.md +++ b/API.md @@ -240,9 +240,28 @@ Publicación `supabase_realtime`, canal `postgres_changes` sobre el esquema `pub | `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; Live solo reacciona si `status` es `running` | Por eso, ante un `UPDATE` de `episodes` Live vuelve a consultar la vista en vez de usar el payload. +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: diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c01f496..c6bd3de 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -56,6 +56,25 @@ export default function LivePage() { // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { void load(); }, [load]); + /* Vigía de episodios nuevos. Las suscripciones de abajo van filtradas al episodio + que se está enseñando, así que sin esto arrancar la simulación no despertaría a + nadie: el episodio nuevo nace y la pantalla se queda en el anterior hasta que + alguien recargue a mano. Se mira `status` porque un backfill inserta cientos de + episodios ya terminados y no hay por qué recargar con cada uno. */ + useEffect(() => { + if (!configured) return; + const canal = supabase + .channel("live-nuevos") + .on("postgres_changes", + { event: "INSERT", schema: "public", table: "episodes" }, + (p) => { + if ((p.new as { status?: string }).status === "running") void load(); + }) + .subscribe(); + + return () => { void supabase.removeChannel(canal); }; + }, [load]); + /* Realtime: esta vista se alimenta de estas suscripciones y de nada más. */ useEffect(() => { if (!configured || !episode) return; From 46fde4cbd3d4bb9ba9d9168ecadc54b661b8fbec Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 13:06:45 +0200 Subject: [PATCH 13/26] fix(live): los KPIs en curso salen de los datos que ya estan llegando MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Medido contra Supabase real: a mitad de un episodio en vivo, la pantalla enseñaba 'Colocados 0 ud de 10' con tres paquetes ya en el pale, y el tiempo de ciclo en blanco. La causa es que episodes.n_placed vale 0 mientras el episodio corre: lo deja asi begin() y solo lo actualiza end(). Los otros dos KPIs (margen y utilizacion) no lo notaban porque leen de pallet_states, que si va llegando. Se cuenta de las colocaciones ya recibidas, que estan en el cliente por Realtime, en vez de añadir un PATCH por paquete. Tras el arreglo, el mismo instante enseña 'Colocados 3 ud de 10' y 'Ciclo 2.20 s/paquete'. Dos de los cuatro KPIs clavados en cero justo mientras el pale se monta es exactamente cuando el jurado esta mirando. --- frontend/app/page.tsx | 18 +++++++++++++++--- runs/20260919-130335-humo/episodes.jsonl | 1 + runs/20260919-130558-humo/episodes.jsonl | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 runs/20260919-130335-humo/episodes.jsonl create mode 100644 runs/20260919-130558-humo/episodes.jsonl diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c6bd3de..761a7e2 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -140,6 +140,18 @@ export default function LivePage() { const last = states.length ? states[states.length - 1] : null; const margin = last?.stability_margin_m ?? episode.final_stability_m; + /* Mientras el episodio está en curso, `episodes.n_placed` vale 0: lo deja así + `begin()` y solo lo actualiza `end()`. Contarlo de las colocaciones que ya han + llegado evita que estos dos KPIs se queden clavados en cero justo mientras el palé + se monta, que es cuando el jurado está mirando. */ + const colocados = running + ? placements.filter((p) => p.placed).length + : episode.n_placed; + const transcurrido = events.length ? events[events.length - 1].ts : 0; + const ciclo = running + ? (colocados ? transcurrido / colocados : null) + : episode.cycle_time_s; + return (
@@ -170,11 +182,11 @@ export default function LivePage() {
- - diff --git a/runs/20260919-130335-humo/episodes.jsonl b/runs/20260919-130335-humo/episodes.jsonl new file mode 100644 index 0000000..220f5c7 --- /dev/null +++ b/runs/20260919-130335-humo/episodes.jsonl @@ -0,0 +1 @@ +{"seed": 37, "level": 2, "n_objects": 10, "n_placed": 8, "n_misrouted": 0, "success": false, "duration_s": 19.8, "failure": "stack_collapse", "oracle": false, "git_sha": "af7ec99", "task": "palletizing", "metrics": {"score": 0.8, "cog_offset_xy": 0.0514}} diff --git a/runs/20260919-130558-humo/episodes.jsonl b/runs/20260919-130558-humo/episodes.jsonl new file mode 100644 index 0000000..220f5c7 --- /dev/null +++ b/runs/20260919-130558-humo/episodes.jsonl @@ -0,0 +1 @@ +{"seed": 37, "level": 2, "n_objects": 10, "n_placed": 8, "n_misrouted": 0, "success": false, "duration_s": 19.8, "failure": "stack_collapse", "oracle": false, "git_sha": "af7ec99", "task": "palletizing", "metrics": {"score": 0.8, "cog_offset_xy": 0.0514}} From f3dcc69b79f47836cf900f89d12980fc966d9d8c Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 13:36:17 +0200 Subject: [PATCH 14/26] feat(seed): guiones de datos de ejemplo y de prueba en vivo ejemplo_contrato.py deja UNA entrada que recorre el contrato entero de API.md: las cinco tablas, los seis tipos de evento con las claves exactas que lee EventFeed, cuatro causas de fallo con stack_collapse dominante, y los casos limite que el front tiene que saber pintar (0 colocados, cycle_time_s nulo, overhang positivo, planned_pose distinta de actual_pose). Sirve de fixture para las rutas pendientes sin depender de la simulacion. Va marcada synthetic=true, y no es negociable: son numeros inventados y la regla del proyecto es que nunca se confundan con medidos ni entren en una comparacion. humo_live.py monta un pale de verdad con pausas, usando begin()/event()/ end(). Es lo que hay que lanzar antes de una demo para saber si Live esta viva; con el salieron los dos fallos de la pantalla que se arreglaron en af7ec99 y 46fde4c. Va tambien runs/ al .gitignore, y se sacan del control de versiones dos episodes.jsonl que se me colaron en 46fde4c con un git add -A. Son datos que produce RunLog en la maquina que lo ejecuta, no codigo. --- .gitignore | 4 + AGENTS.md | 4 +- backend/seed/ejemplo_contrato.py | 244 +++++++++++++++++++++++ backend/seed/humo_live.py | 138 +++++++++++++ runs/20260919-130335-humo/episodes.jsonl | 1 - runs/20260919-130558-humo/episodes.jsonl | 1 - 6 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 backend/seed/ejemplo_contrato.py create mode 100644 backend/seed/humo_live.py delete mode 100644 runs/20260919-130335-humo/episodes.jsonl delete mode 100644 runs/20260919-130558-humo/episodes.jsonl diff --git a/.gitignore b/.gitignore index a755c4e..0915329 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ 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. +runs/ diff --git a/AGENTS.md b/AGENTS.md index 475086d..b483062 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,9 @@ 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 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/runs/20260919-130335-humo/episodes.jsonl b/runs/20260919-130335-humo/episodes.jsonl deleted file mode 100644 index 220f5c7..0000000 --- a/runs/20260919-130335-humo/episodes.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"seed": 37, "level": 2, "n_objects": 10, "n_placed": 8, "n_misrouted": 0, "success": false, "duration_s": 19.8, "failure": "stack_collapse", "oracle": false, "git_sha": "af7ec99", "task": "palletizing", "metrics": {"score": 0.8, "cog_offset_xy": 0.0514}} diff --git a/runs/20260919-130558-humo/episodes.jsonl b/runs/20260919-130558-humo/episodes.jsonl deleted file mode 100644 index 220f5c7..0000000 --- a/runs/20260919-130558-humo/episodes.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"seed": 37, "level": 2, "n_objects": 10, "n_placed": 8, "n_misrouted": 0, "success": false, "duration_s": 19.8, "failure": "stack_collapse", "oracle": false, "git_sha": "af7ec99", "task": "palletizing", "metrics": {"score": 0.8, "cog_offset_xy": 0.0514}} From e5bc6e3fbe094ad23f6c3e3aa621ba69a5617d71 Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 14:03:37 +0200 Subject: [PATCH 15/26] feat(sdk): config del run, snapshots y geometria compartida Los cinco puntos que pide la simulacion, por gravedad. 1. RunLog acepta config y lo escribe en runs.config, y las dos vistas lo exponen. El pale es una maqueta 1:5.7 (la pinza del Panda abre 80 mm, un europeo es inagarrable) y sin el tamano real la pantalla lo dibuja a 1200x800: todas las cotas salen 5.7 veces mal. Pallet.tsx deja de tener PALLET_X a fuego y recibe el tamano; palletSize() en ui.ts lo busca en config, luego en metrics (donde iba mientras tanto) y cae al europeo. 2. Imagenes: tabla snapshots en 003, bucket publico, RunLog.snapshot() sobre _push y Supabase.upload_png(). Los dos avisos, atendidos: test_contrato aplica ahora sorted(glob('*.sql')) en vez de 001 y 002 a mano, y el bucket va guardado con to_regclass, comprobado contra un postgres:16 pelado. 3. stability_margin y support_polygon se mueven a theker_telemetry/pallet.py. Estaban duplicadas porque seed/ no es importable; ahora hay UNA implementacion y el sembrado la reexporta. Es el indicador que define toda la interfaz: dos versiones acabarian discrepando. 4. Se fusiona main, que traia 30cfc2b (_aligned). Sin el, seed y backfill estaban rotos en esta rama: PostgREST rechaza un lote cuyas filas no tienen las mismas claves. 5. end() manda solo lo que cambia al cerrar; seed, task, level y n_objects se fijaron en begin(). API.md deja de citar frontend/api/clients y getRuns, que nunca existieron, y documenta config, snapshots y el PATCH. 116 tests en verde con Postgres. --- API.md | 46 +++++++++++++-- backend/seed/palletizing.py | 45 ++------------- backend/sql/002_design.sql | 5 ++ backend/sql/003_snapshots.sql | 69 +++++++++++++++++++++++ backend/tests/test_contrato.py | 18 ++++-- backend/tests/test_core.py | 84 ++++++++++++++++++++++++++++ backend/tests/test_seed.py | 10 ++-- backend/theker_telemetry/__init__.py | 6 ++ backend/theker_telemetry/core.py | 49 +++++++++++++++- backend/theker_telemetry/pallet.py | 52 +++++++++++++++++ frontend/app/page.tsx | 9 ++- frontend/components/Pallet.tsx | 38 +++++++++---- frontend/lib/supabase.ts | 2 + frontend/lib/ui.ts | 23 ++++++++ 14 files changed, 383 insertions(+), 73 deletions(-) create mode 100644 backend/sql/003_snapshots.sql create mode 100644 backend/theker_telemetry/pallet.py diff --git a/API.md b/API.md index 662bcd5..53e26b9 100644 --- a/API.md +++ b/API.md @@ -5,8 +5,9 @@ PostgREST, más los canales Realtime. Este documento sale de `backend/sql/001_sc `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 consume `frontend/lib/supabase.ts` (`fetchRuns`, `fetchRun`, `fetchEpisodes`, +`fetchEpisode`, `fetchEpisodeDetail`, `fetchLatestEpisode`). La capa `frontend/api/` +que describía una versión anterior de este documento no llegó a existir. ## 1. Convenciones @@ -217,12 +218,12 @@ Todo fallo remoto avisa una vez y el episodio sigue: el `episodes.jsonl` en disc | Método y ruta | Cuerpo | `Prefer` | Respuesta | |---|---|---|---| -| `POST /runs` | `[{ task, level, git_sha, oracle, motion_speed, label, n_episodes }]` | `return=representation` | `201` + `[fila creada]` (de ahí sale `run_id`) | +| `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 }` | `return=minimal` | `204` 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)`, @@ -230,6 +231,40 @@ Restricciones que hacen fallar la escritura: `episodes.failure` fuera del CHECK `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 @@ -241,6 +276,7 @@ Publicación `supabase_realtime`, canal `postgres_changes` sobre el esquema `pub | `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; Live solo reacciona si `status` es `running` | +| `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. @@ -278,7 +314,7 @@ Quien produce episodios tiene que llamarlas, o Live seguirá enseñando el palé ## 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`, que además añade +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/backend/seed/palletizing.py b/backend/seed/palletizing.py index 4cfedfc..4108cbe 100644 --- a/backend/seed/palletizing.py +++ b/backend/seed/palletizing.py @@ -37,24 +37,16 @@ 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. diff --git a/backend/sql/002_design.sql b/backend/sql/002_design.sql index 3b1d856..d6fe7b9 100644 --- a/backend/sql/002_design.sql +++ b/backend/sql/002_design.sql @@ -62,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, @@ -108,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_contrato.py b/backend/tests/test_contrato.py index 141252c..30c0fe8 100644 --- a/backend/tests/test_contrato.py +++ b/backend/tests/test_contrato.py @@ -94,8 +94,8 @@ def esquema(): @pytest.fixture(scope="module", autouse=True) def aplicado(esquema): """El esquema, ya aplicado, para el resto de los tests.""" - psql_file(SQL_DIR / "001_schema.sql") - psql_file(SQL_DIR / "002_design.sql") + for fichero in sorted(SQL_DIR.glob("*.sql")): + psql_file(fichero) # ── 1. el esquema aplica, y aplica dos veces ───────────────────────────────── @@ -104,9 +104,10 @@ 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.""" - for fichero in ("001_schema.sql", "002_design.sql", - "001_schema.sql", "002_design.sql"): - psql_file(SQL_DIR / fichero) + 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 ──────────────────────────── @@ -307,6 +308,11 @@ def test_todo_lo_legible_tiene_rls_y_politica_de_lectura(): 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 con_politica == {"runs", "episodes", "placements", "pallet_states", "events"} + 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 index 64115fb..d699436 100644 --- a/backend/tests/test_core.py +++ b/backend/tests/test_core.py @@ -485,3 +485,87 @@ def test_close_sin_remoto_no_hace_nada(tmp_path): 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_seed.py b/backend/tests/test_seed.py index d405a56..3694d66 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -11,12 +11,12 @@ import sys from pathlib import Path -from seed.palletizing import ( - CAMPAIGN, - PACKAGES_BY_LEVEL, +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, - build_episode, - episode_seed, stability_margin, support_polygon, ) 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 99f81f6..92ea81f 100644 --- a/backend/theker_telemetry/core.py +++ b/backend/theker_telemetry/core.py @@ -174,6 +174,27 @@ 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()) @@ -230,7 +251,8 @@ 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 @@ -255,6 +277,11 @@ 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)) self.run_id = self._id_of(rows) @@ -318,6 +345,18 @@ def pallet_state(self, **row: Any) -> None: 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. @@ -328,8 +367,12 @@ def end(self, result: EpisodeResult) -> None: if not episode_id or not self.run_id: return - row = episode_row(result, self.run_id) - row.pop("run_id") + # 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)) 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/frontend/app/page.tsx b/frontend/app/page.tsx index 761a7e2..986e01a 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -7,7 +7,8 @@ import { type Episode, type PalletState, type Placement, type RunEvent, } from "@/lib/supabase"; import { - TASK_TEXT, clockTime, failureText, mm, pct, seconds, signedMm, stabilityState, + TASK_TEXT, clockTime, failureText, mm, palletSize, pct, seconds, signedMm, + stabilityState, } from "@/lib/ui"; import { PalletSideView, PalletTopView } from "@/components/Pallet"; import { EventFeed } from "@/components/Episode"; @@ -166,7 +167,8 @@ export default function LivePage() { : failed ? `instante del fallo · ${seconds(episode.duration_s)}` : `estado final · ${placements.length} paquetes`} > - + - +
diff --git a/frontend/components/Pallet.tsx b/frontend/components/Pallet.tsx index cc34045..68d3486 100644 --- a/frontend/components/Pallet.tsx +++ b/frontend/components/Pallet.tsx @@ -5,9 +5,17 @@ import { signedMm, stabilityState } from "@/lib/ui"; /* Palé europeo, en metros. El diseño insiste en dibujarlo a escala real y con sus medidas: un esquemático genérico no transmite que esto modela un proceso físico. */ +/* Palé europeo, y solo el valor POR DEFECTO. El palé de verdad puede ser una maqueta a + escala —la pinza del Panda abre 80 mm y un europeo es inagarrable—, así que la medida + viaja con la ejecución en `config.pallet_size_m` y estas dos constantes son lo que se + usa cuando no llega. Dibujar 1200x800 un palé de 210x140 deja TODAS las cotas mal por + el mismo factor, y el diseño exige que el palé se pinte a escala real con sus medidas. */ export const PALLET_X = 1.2; export const PALLET_Y = 0.8; +export type PalletSize = readonly [number, number]; +export const PALLET_DEFAULT: PalletSize = [PALLET_X, PALLET_Y]; + /* ── vista cenital ──────────────────────────────────────────────────────── */ /** @@ -18,18 +26,21 @@ export const PALLET_Y = 0.8; * nadie se la explique, tiene que ser ésta. Por eso la cruz lleva el color del estado * de estabilidad y el polígono se dibuja también cuando el montón va bien. */ -export function PalletTopView({ placements, state, height = 380 }: { +export function PalletTopView({ placements, state, height = 380, + size = PALLET_DEFAULT }: { placements: Placement[]; state: PalletState | null; height?: number; + size?: PalletSize; }) { const pad = 26; - const scale = (height - pad * 2) / PALLET_Y; - const w = PALLET_X * scale + pad * 2; + const [px, py] = size; + const scale = (height - pad * 2) / py; + const w = px * scale + pad * 2; // De metros respecto al centro del palé a píxeles. +Y del mundo sube en pantalla. - const X = (x: number) => pad + (x + PALLET_X / 2) * scale; - const Y = (y: number) => pad + (PALLET_Y / 2 - y) * scale; + const X = (x: number) => pad + (x + px / 2) * scale; + const Y = (y: number) => pad + (py / 2 - y) * scale; const base = placements.filter((p) => p.layer === 1 && p.actual_pose && p.dims_m); const support = base.length ? bounds(base) : null; @@ -40,8 +51,8 @@ export function PalletTopView({ placements, state, height = 380 }: { return ( - {/* huecos planificados, en trazo fino */} @@ -103,7 +114,7 @@ export function PalletTopView({ placements, state, height = 380 }: { {/* medidas reales: es un criterio de puntuación, no decoración */} - 1200 × 800 mm + {Math.round(px * 1000)} × {Math.round(py * 1000)} mm {margin != null && ( Math.max(h, (p.actual_pose?.z ?? 0) + (p.dims_m?.[2] ?? 0) / 2), 0); // Un 25 % de aire por encima de la carga para que la cota no toque el borde. const zMax = Math.max(top * 1.25, 0.5); const scale = (height - pad * 2) / zMax; - const w = PALLET_X * scale + pad * 2; + const w = px * scale + pad * 2; - const X = (x: number) => pad + (x + PALLET_X / 2) * scale; + const X = (x: number) => pad + (x + px / 2) * scale; const Z = (z: number) => height - pad - z * scale; const cogColor = `var(--${stabilityState(state?.stability_margin_m)})`; @@ -153,7 +167,7 @@ export function PalletSideView({ placements, state, height = 275 }: { return ( - {placements.map((p) => diff --git a/frontend/lib/supabase.ts b/frontend/lib/supabase.ts index 54c39a3..b66b66d 100644 --- a/frontend/lib/supabase.ts +++ b/frontend/lib/supabase.ts @@ -46,6 +46,7 @@ export type Run = { motion_speed: number; label: string | null; description: string | null; + config: Record; episodes: number; successes: number; success_rate: number | null; @@ -79,6 +80,7 @@ export type Episode = { oracle: boolean; synthetic: boolean; motion_speed: number; + config: Record; cycle_time_s: number | null; final_stability_m: number | null; final_fill_ratio: number | null; diff --git a/frontend/lib/ui.ts b/frontend/lib/ui.ts index 8d9d85b..6228dc3 100644 --- a/frontend/lib/ui.ts +++ b/frontend/lib/ui.ts @@ -134,3 +134,26 @@ export const EVENT_TEXT: Record = { settle: "asentado", fail: "fallo", }; + +/* Tamaño real del palé, en metros. + * + * El palé puede ser una maqueta a escala: la pinza del Panda abre 80 mm y un europeo es + * inagarrable. Dibujarlo siempre a 1200x800 deja todas las cotas mal por el mismo + * factor, y el diseño exige que se pinte a escala real con sus medidas. + * + * Se mira primero `runs.config`, que es su sitio, y después `episodes.metrics`, donde + * la simulación lo metió mientras la columna no se podía escribir. El europeo es el + * último recurso. */ +export function palletSize( + fuente: { config?: Record; metrics?: Record } | null, +): readonly [number, number] { + for (const bolsa of [fuente?.config, fuente?.metrics]) { + const v = bolsa?.["pallet_size_m"]; + if (Array.isArray(v) && v.length >= 2 + && typeof v[0] === "number" && typeof v[1] === "number" + && v[0] > 0 && v[1] > 0) { + return [v[0], v[1]]; + } + } + return [1.2, 0.8]; +} From d9a40f187630159e3f756159e677c7beadc9fc9e Mon Sep 17 00:00:00 2001 From: manuamest Date: Sat, 19 Sep 2026 14:07:42 +0200 Subject: [PATCH 16/26] test(contrato): mensaje claro si falta psql Con DATABASE_URL puesto y sin el binario salian veinte FileNotFoundError identicos. Es la dependencia del job de backend en CI que no he podido verificar hasta que corra alli, asi que conviene que falle diciendo que hacer. --- backend/tests/test_contrato.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/tests/test_contrato.py b/backend/tests/test_contrato.py index 30c0fe8..26088dd 100644 --- a/backend/tests/test_contrato.py +++ b/backend/tests/test_contrato.py @@ -20,6 +20,7 @@ import os import re +import shutil import subprocess from pathlib import Path from urllib.parse import urlparse @@ -68,6 +69,12 @@ def esquema(): 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, " From 8a8c33d8c8648aa598b2d28df5b271b34dd9debf Mon Sep 17 00:00:00 2001 From: Ander Varela Martin <91603993+AnderVarela@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:10:24 +0200 Subject: [PATCH 17/26] =?UTF-8?q?feat(front):=20primera=20versi=C3=B3n=20d?= =?UTF-8?q?el=20front=20con=20Live=20y=20Ejecuciones=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API.md | 269 +++++++++++++++ .../api/clients/episodeDetail/getEvents.ts | 20 ++ .../clients/episodeDetail/getPalletStates.ts | 20 ++ .../clients/episodeDetail/getPlacements.ts | 20 ++ frontend/api/clients/episodeDetail/index.ts | 3 + frontend/api/clients/episodes/getEpisode.ts | 20 ++ frontend/api/clients/episodes/getEpisodes.ts | 20 ++ .../api/clients/episodes/getLatestEpisode.ts | 40 +++ frontend/api/clients/episodes/index.ts | 3 + frontend/api/clients/errors/ApiError.ts | 22 ++ .../api/clients/errors/assertConfigured.ts | 13 + frontend/api/clients/errors/index.ts | 4 + frontend/api/clients/errors/toApiError.ts | 12 + frontend/api/clients/errors/trace.ts | 6 + .../clients/failures/getFailureBreakdown.ts | 21 ++ frontend/api/clients/failures/index.ts | 1 + frontend/api/clients/index.ts | 6 + frontend/api/clients/runs/getRun.ts | 19 ++ frontend/api/clients/runs/getRuns.ts | 22 ++ frontend/api/clients/runs/index.ts | 2 + .../api/clients/totals/getEpisodesCount.ts | 21 ++ frontend/api/clients/totals/getRunsCount.ts | 20 ++ frontend/api/clients/totals/index.ts | 2 + frontend/api/hooks/index.ts | 13 + frontend/api/hooks/queryKeys.ts | 24 ++ frontend/api/hooks/useEpisode.ts | 11 + frontend/api/hooks/useEpisodeDetail.ts | 17 + frontend/api/hooks/useEpisodeRealtime.ts | 74 +++++ frontend/api/hooks/useEpisodes.ts | 11 + frontend/api/hooks/useEvents.ts | 11 + frontend/api/hooks/useFailureBreakdown.ts | 11 + frontend/api/hooks/useLatestEpisode.ts | 12 + frontend/api/hooks/usePalletStates.ts | 11 + frontend/api/hooks/usePlacements.ts | 11 + frontend/api/hooks/useRun.ts | 11 + frontend/api/hooks/useRuns.ts | 10 + frontend/api/hooks/useRunsTotals.ts | 14 + frontend/api/types.ts | 15 + frontend/app/globals.css | 70 ---- frontend/app/layout.tsx | 9 +- frontend/app/page.tsx | 311 +----------------- frontend/app/providers.tsx | 31 ++ frontend/app/runs/page.tsx | 5 + frontend/components/Episode.tsx | 93 +----- frontend/components/Pallet.tsx | 182 ---------- frontend/components/Runs.tsx | 278 +--------------- frontend/components/TopBar.tsx | 60 ---- .../components/atoms/Badge/Badge.module.css | 21 ++ frontend/components/atoms/Badge/Badge.tsx | 11 + frontend/components/atoms/Badge/index.ts | 1 + .../components/atoms/Button/Button.module.css | 20 ++ frontend/components/atoms/Button/Button.tsx | 11 + frontend/components/atoms/Button/index.ts | 1 + .../components/atoms/Card/Card.module.css | 10 + frontend/components/atoms/Card/Card.tsx | 15 + frontend/components/atoms/Card/index.ts | 1 + .../atoms/Checkbox/Checkbox.module.css | 38 +++ .../components/atoms/Checkbox/Checkbox.tsx | 33 ++ frontend/components/atoms/Checkbox/index.ts | 1 + .../atoms/CogMarker/CogMarker.module.css | 6 + .../components/atoms/CogMarker/CogMarker.tsx | 25 ++ frontend/components/atoms/CogMarker/index.ts | 1 + .../atoms/Divider/Divider.module.css | 3 + frontend/components/atoms/Divider/Divider.tsx | 8 + frontend/components/atoms/Divider/index.ts | 1 + frontend/components/atoms/Dot/Dot.module.css | 14 + frontend/components/atoms/Dot/Dot.tsx | 8 + frontend/components/atoms/Dot/index.ts | 2 + .../atoms/GuideLine/GuideLine.module.css | 6 + .../components/atoms/GuideLine/GuideLine.tsx | 13 + frontend/components/atoms/GuideLine/index.ts | 1 + frontend/components/atoms/Icon/Icon.tsx | 26 ++ frontend/components/atoms/Icon/index.ts | 2 + .../atoms/SceneLabel/SceneLabel.module.css | 10 + .../atoms/SceneLabel/SceneLabel.tsx | 22 ++ frontend/components/atoms/SceneLabel/index.ts | 1 + .../atoms/SceneRect/SceneRect.module.css | 11 + .../components/atoms/SceneRect/SceneRect.tsx | 17 + frontend/components/atoms/SceneRect/index.ts | 2 + .../components/atoms/Select/Select.module.css | 26 ++ frontend/components/atoms/Select/Select.tsx | 28 ++ frontend/components/atoms/Select/index.ts | 2 + .../atoms/Sparkline/Sparkline.helper.ts | 16 + .../atoms/Sparkline/Sparkline.module.css | 5 + .../components/atoms/Sparkline/Sparkline.tsx | 23 ++ frontend/components/atoms/Sparkline/index.ts | 1 + .../components/atoms/Swatch/Swatch.module.css | 1 + frontend/components/atoms/Swatch/Swatch.tsx | 10 + frontend/components/atoms/Swatch/index.ts | 1 + .../components/atoms/Table/Table.module.css | 41 +++ frontend/components/atoms/Table/Table.tsx | 40 +++ frontend/components/atoms/Table/index.ts | 1 + .../components/atoms/Text/Text.module.css | 39 +++ frontend/components/atoms/Text/Text.tsx | 31 ++ frontend/components/atoms/Text/index.ts | 2 + .../components/atoms/Value/Value.module.css | 25 ++ frontend/components/atoms/Value/Value.tsx | 21 ++ frontend/components/atoms/Value/index.ts | 1 + frontend/components/atoms/index.ts | 22 ++ .../molecules/Banner/Banner.module.css | 11 + .../components/molecules/Banner/Banner.tsx | 18 + frontend/components/molecules/Banner/index.ts | 1 + .../BlockerTable/BlockerTable.module.css | 3 + .../molecules/BlockerTable/BlockerTable.tsx | 24 ++ .../molecules/BlockerTable/index.ts | 1 + .../ColorLegendItem.module.css | 1 + .../ColorLegendItem/ColorLegendItem.tsx | 12 + .../molecules/ColorLegendItem/index.ts | 1 + .../molecules/DeltaStat/DeltaStat.module.css | 1 + .../molecules/DeltaStat/DeltaStat.tsx | 23 ++ .../components/molecules/DeltaStat/index.ts | 1 + .../molecules/EventRow/EventRow.helper.ts | 82 +++++ .../molecules/EventRow/EventRow.module.css | 18 + .../molecules/EventRow/EventRow.tsx | 31 ++ .../components/molecules/EventRow/index.ts | 3 + .../FailureCell/FailureCell.module.css | 1 + .../molecules/FailureCell/FailureCell.tsx | 14 + .../components/molecules/FailureCell/index.ts | 1 + .../FailureStackBar.module.css | 5 + .../FailureStackBar/FailureStackBar.tsx | 21 ++ .../molecules/FailureStackBar/index.ts | 1 + .../FilterField/FilterField.module.css | 1 + .../molecules/FilterField/FilterField.tsx | 15 + .../components/molecules/FilterField/index.ts | 1 + .../InfoMessage/InfoMessage.module.css | 3 + .../molecules/InfoMessage/InfoMessage.tsx | 15 + .../components/molecules/InfoMessage/index.ts | 1 + .../molecules/KpiCard/KpiCard.module.css | 4 + .../components/molecules/KpiCard/KpiCard.tsx | 23 ++ .../components/molecules/KpiCard/index.ts | 1 + .../LegendItem/LegendItem.module.css | 5 + .../molecules/LegendItem/LegendItem.tsx | 15 + .../components/molecules/LegendItem/index.ts | 1 + .../molecules/MetaItem/MetaItem.module.css | 2 + .../molecules/MetaItem/MetaItem.tsx | 16 + .../components/molecules/MetaItem/index.ts | 1 + .../molecules/NavTab/NavTab.module.css | 8 + .../components/molecules/NavTab/NavTab.tsx | 15 + frontend/components/molecules/NavTab/index.ts | 1 + .../molecules/Notice/Notice.module.css | 3 + .../components/molecules/Notice/Notice.tsx | 13 + frontend/components/molecules/Notice/index.ts | 1 + .../PanelHeader/PanelHeader.module.css | 11 + .../molecules/PanelHeader/PanelHeader.tsx | 14 + .../components/molecules/PanelHeader/index.ts | 1 + .../ResultCard/ResultCard.module.css | 2 + .../molecules/ResultCard/ResultCard.tsx | 22 ++ .../components/molecules/ResultCard/index.ts | 1 + .../molecules/RunFlag/RunFlag.module.css | 3 + .../components/molecules/RunFlag/RunFlag.tsx | 19 ++ .../components/molecules/RunFlag/index.ts | 1 + .../StatusIndicator/StatusIndicator.helper.ts | 15 + .../StatusIndicator.module.css | 2 + .../StatusIndicator/StatusIndicator.tsx | 14 + .../molecules/StatusIndicator/index.ts | 1 + .../ToggleField/ToggleField.module.css | 2 + .../molecules/ToggleField/ToggleField.tsx | 17 + .../components/molecules/ToggleField/index.ts | 1 + frontend/components/molecules/index.ts | 20 ++ .../CompareBlocked/CompareBlocked.module.css | 8 + .../CompareBlocked/CompareBlocked.tsx | 22 ++ .../organisms/CompareBlocked/index.ts | 1 + .../ComparePanel/ComparePanel.helper.ts | 127 +++++++ .../ComparePanel/ComparePanel.module.css | 16 + .../organisms/ComparePanel/ComparePanel.tsx | 52 +++ .../organisms/ComparePanel/index.ts | 2 + .../CompareResult/CompareResult.module.css | 17 + .../organisms/CompareResult/CompareResult.tsx | 31 ++ .../organisms/CompareResult/index.ts | 1 + .../organisms/EventFeed/EventFeed.helper.ts | 7 + .../organisms/EventFeed/EventFeed.module.css | 3 + .../organisms/EventFeed/EventFeed.tsx | 36 ++ .../components/organisms/EventFeed/index.ts | 1 + .../organisms/FilterBar/FilterBar.helper.ts | 64 ++++ .../organisms/FilterBar/FilterBar.module.css | 8 + .../organisms/FilterBar/FilterBar.tsx | 36 ++ .../components/organisms/FilterBar/index.ts | 3 + .../IdentityBar/IdentityBar.helper.ts | 54 +++ .../IdentityBar/IdentityBar.module.css | 12 + .../organisms/IdentityBar/IdentityBar.tsx | 22 ++ .../components/organisms/IdentityBar/index.ts | 3 + .../organisms/KpiGrid/KpiGrid.helper.ts | 49 +++ .../organisms/KpiGrid/KpiGrid.module.css | 11 + .../components/organisms/KpiGrid/KpiGrid.tsx | 11 + .../components/organisms/KpiGrid/index.ts | 3 + .../PalletSideView/PalletSideView.helper.ts | 74 +++++ .../PalletSideView/PalletSideView.module.css | 1 + .../PalletSideView/PalletSideView.tsx | 41 +++ .../organisms/PalletSideView/index.ts | 1 + .../PalletTopView/PalletTopView.helper.ts | 90 +++++ .../PalletTopView/PalletTopView.module.css | 1 + .../organisms/PalletTopView/PalletTopView.tsx | 37 +++ .../organisms/PalletTopView/index.ts | 1 + .../organisms/Panel/Panel.module.css | 3 + frontend/components/organisms/Panel/Panel.tsx | 25 ++ frontend/components/organisms/Panel/index.ts | 1 + .../organisms/RunRow/RunRow.helper.ts | 62 ++++ .../organisms/RunRow/RunRow.module.css | 2 + .../components/organisms/RunRow/RunRow.tsx | 54 +++ frontend/components/organisms/RunRow/index.ts | 3 + .../organisms/RunsTable/RunsTable.helper.ts | 7 + .../organisms/RunsTable/RunsTable.module.css | 2 + .../organisms/RunsTable/RunsTable.tsx | 56 ++++ .../components/organisms/RunsTable/index.ts | 1 + .../organisms/RunsTotals/RunsTotals.helper.ts | 18 + .../organisms/RunsTotals/RunsTotals.tsx | 8 + .../components/organisms/RunsTotals/index.ts | 1 + .../organisms/TopBar/TopBar.module.css | 18 + .../components/organisms/TopBar/TopBar.tsx | 22 ++ frontend/components/organisms/TopBar/index.ts | 1 + frontend/components/organisms/index.ts | 16 + frontend/components/primitives.tsx | 167 ---------- .../screens/AppHeader/AppHeader.helper.ts | 4 + .../screens/AppHeader/AppHeader.tsx | 20 ++ .../components/screens/AppHeader/index.ts | 1 + .../screens/LiveScreen/LiveScreen.helper.ts | 67 ++++ .../screens/LiveScreen/LiveScreen.module.css | 43 +++ .../screens/LiveScreen/LiveScreen.tsx | 78 +++++ .../components/screens/LiveScreen/index.ts | 1 + .../screens/LiveScreen/useLiveScreen.ts | 62 ++++ .../screens/RunsScreen/RunsScreen.helper.ts | 17 + .../screens/RunsScreen/RunsScreen.module.css | 30 ++ .../screens/RunsScreen/RunsScreen.tsx | 53 +++ .../components/screens/RunsScreen/index.ts | 1 + .../screens/RunsScreen/useRunsScreen.ts | 48 +++ frontend/hooks/useNow.ts | 15 + frontend/lib/cx.ts | 4 + frontend/lib/pallet.ts | 50 +++ frontend/lib/ui.ts | 15 + frontend/package-lock.json | 27 ++ frontend/package.json | 1 + 231 files changed, 3817 insertions(+), 1157 deletions(-) create mode 100644 docs/API.md create mode 100644 frontend/api/clients/episodeDetail/getEvents.ts create mode 100644 frontend/api/clients/episodeDetail/getPalletStates.ts create mode 100644 frontend/api/clients/episodeDetail/getPlacements.ts create mode 100644 frontend/api/clients/episodeDetail/index.ts create mode 100644 frontend/api/clients/episodes/getEpisode.ts create mode 100644 frontend/api/clients/episodes/getEpisodes.ts create mode 100644 frontend/api/clients/episodes/getLatestEpisode.ts create mode 100644 frontend/api/clients/episodes/index.ts create mode 100644 frontend/api/clients/errors/ApiError.ts create mode 100644 frontend/api/clients/errors/assertConfigured.ts create mode 100644 frontend/api/clients/errors/index.ts create mode 100644 frontend/api/clients/errors/toApiError.ts create mode 100644 frontend/api/clients/errors/trace.ts create mode 100644 frontend/api/clients/failures/getFailureBreakdown.ts create mode 100644 frontend/api/clients/failures/index.ts create mode 100644 frontend/api/clients/index.ts create mode 100644 frontend/api/clients/runs/getRun.ts create mode 100644 frontend/api/clients/runs/getRuns.ts create mode 100644 frontend/api/clients/runs/index.ts create mode 100644 frontend/api/clients/totals/getEpisodesCount.ts create mode 100644 frontend/api/clients/totals/getRunsCount.ts create mode 100644 frontend/api/clients/totals/index.ts create mode 100644 frontend/api/hooks/index.ts create mode 100644 frontend/api/hooks/queryKeys.ts create mode 100644 frontend/api/hooks/useEpisode.ts create mode 100644 frontend/api/hooks/useEpisodeDetail.ts create mode 100644 frontend/api/hooks/useEpisodeRealtime.ts create mode 100644 frontend/api/hooks/useEpisodes.ts create mode 100644 frontend/api/hooks/useEvents.ts create mode 100644 frontend/api/hooks/useFailureBreakdown.ts create mode 100644 frontend/api/hooks/useLatestEpisode.ts create mode 100644 frontend/api/hooks/usePalletStates.ts create mode 100644 frontend/api/hooks/usePlacements.ts create mode 100644 frontend/api/hooks/useRun.ts create mode 100644 frontend/api/hooks/useRuns.ts create mode 100644 frontend/api/hooks/useRunsTotals.ts create mode 100644 frontend/api/types.ts create mode 100644 frontend/app/providers.tsx create mode 100644 frontend/app/runs/page.tsx delete mode 100644 frontend/components/Pallet.tsx delete mode 100644 frontend/components/TopBar.tsx create mode 100644 frontend/components/atoms/Badge/Badge.module.css create mode 100644 frontend/components/atoms/Badge/Badge.tsx create mode 100644 frontend/components/atoms/Badge/index.ts create mode 100644 frontend/components/atoms/Button/Button.module.css create mode 100644 frontend/components/atoms/Button/Button.tsx create mode 100644 frontend/components/atoms/Button/index.ts create mode 100644 frontend/components/atoms/Card/Card.module.css create mode 100644 frontend/components/atoms/Card/Card.tsx create mode 100644 frontend/components/atoms/Card/index.ts create mode 100644 frontend/components/atoms/Checkbox/Checkbox.module.css create mode 100644 frontend/components/atoms/Checkbox/Checkbox.tsx create mode 100644 frontend/components/atoms/Checkbox/index.ts create mode 100644 frontend/components/atoms/CogMarker/CogMarker.module.css create mode 100644 frontend/components/atoms/CogMarker/CogMarker.tsx create mode 100644 frontend/components/atoms/CogMarker/index.ts create mode 100644 frontend/components/atoms/Divider/Divider.module.css create mode 100644 frontend/components/atoms/Divider/Divider.tsx create mode 100644 frontend/components/atoms/Divider/index.ts create mode 100644 frontend/components/atoms/Dot/Dot.module.css create mode 100644 frontend/components/atoms/Dot/Dot.tsx create mode 100644 frontend/components/atoms/Dot/index.ts create mode 100644 frontend/components/atoms/GuideLine/GuideLine.module.css create mode 100644 frontend/components/atoms/GuideLine/GuideLine.tsx create mode 100644 frontend/components/atoms/GuideLine/index.ts create mode 100644 frontend/components/atoms/Icon/Icon.tsx create mode 100644 frontend/components/atoms/Icon/index.ts create mode 100644 frontend/components/atoms/SceneLabel/SceneLabel.module.css create mode 100644 frontend/components/atoms/SceneLabel/SceneLabel.tsx create mode 100644 frontend/components/atoms/SceneLabel/index.ts create mode 100644 frontend/components/atoms/SceneRect/SceneRect.module.css create mode 100644 frontend/components/atoms/SceneRect/SceneRect.tsx create mode 100644 frontend/components/atoms/SceneRect/index.ts create mode 100644 frontend/components/atoms/Select/Select.module.css create mode 100644 frontend/components/atoms/Select/Select.tsx create mode 100644 frontend/components/atoms/Select/index.ts create mode 100644 frontend/components/atoms/Sparkline/Sparkline.helper.ts create mode 100644 frontend/components/atoms/Sparkline/Sparkline.module.css create mode 100644 frontend/components/atoms/Sparkline/Sparkline.tsx create mode 100644 frontend/components/atoms/Sparkline/index.ts create mode 100644 frontend/components/atoms/Swatch/Swatch.module.css create mode 100644 frontend/components/atoms/Swatch/Swatch.tsx create mode 100644 frontend/components/atoms/Swatch/index.ts create mode 100644 frontend/components/atoms/Table/Table.module.css create mode 100644 frontend/components/atoms/Table/Table.tsx create mode 100644 frontend/components/atoms/Table/index.ts create mode 100644 frontend/components/atoms/Text/Text.module.css create mode 100644 frontend/components/atoms/Text/Text.tsx create mode 100644 frontend/components/atoms/Text/index.ts create mode 100644 frontend/components/atoms/Value/Value.module.css create mode 100644 frontend/components/atoms/Value/Value.tsx create mode 100644 frontend/components/atoms/Value/index.ts create mode 100644 frontend/components/atoms/index.ts create mode 100644 frontend/components/molecules/Banner/Banner.module.css create mode 100644 frontend/components/molecules/Banner/Banner.tsx create mode 100644 frontend/components/molecules/Banner/index.ts create mode 100644 frontend/components/molecules/BlockerTable/BlockerTable.module.css create mode 100644 frontend/components/molecules/BlockerTable/BlockerTable.tsx create mode 100644 frontend/components/molecules/BlockerTable/index.ts create mode 100644 frontend/components/molecules/ColorLegendItem/ColorLegendItem.module.css create mode 100644 frontend/components/molecules/ColorLegendItem/ColorLegendItem.tsx create mode 100644 frontend/components/molecules/ColorLegendItem/index.ts create mode 100644 frontend/components/molecules/DeltaStat/DeltaStat.module.css create mode 100644 frontend/components/molecules/DeltaStat/DeltaStat.tsx create mode 100644 frontend/components/molecules/DeltaStat/index.ts create mode 100644 frontend/components/molecules/EventRow/EventRow.helper.ts create mode 100644 frontend/components/molecules/EventRow/EventRow.module.css create mode 100644 frontend/components/molecules/EventRow/EventRow.tsx create mode 100644 frontend/components/molecules/EventRow/index.ts create mode 100644 frontend/components/molecules/FailureCell/FailureCell.module.css create mode 100644 frontend/components/molecules/FailureCell/FailureCell.tsx create mode 100644 frontend/components/molecules/FailureCell/index.ts create mode 100644 frontend/components/molecules/FailureStackBar/FailureStackBar.module.css create mode 100644 frontend/components/molecules/FailureStackBar/FailureStackBar.tsx create mode 100644 frontend/components/molecules/FailureStackBar/index.ts create mode 100644 frontend/components/molecules/FilterField/FilterField.module.css create mode 100644 frontend/components/molecules/FilterField/FilterField.tsx create mode 100644 frontend/components/molecules/FilterField/index.ts create mode 100644 frontend/components/molecules/InfoMessage/InfoMessage.module.css create mode 100644 frontend/components/molecules/InfoMessage/InfoMessage.tsx create mode 100644 frontend/components/molecules/InfoMessage/index.ts create mode 100644 frontend/components/molecules/KpiCard/KpiCard.module.css create mode 100644 frontend/components/molecules/KpiCard/KpiCard.tsx create mode 100644 frontend/components/molecules/KpiCard/index.ts create mode 100644 frontend/components/molecules/LegendItem/LegendItem.module.css create mode 100644 frontend/components/molecules/LegendItem/LegendItem.tsx create mode 100644 frontend/components/molecules/LegendItem/index.ts create mode 100644 frontend/components/molecules/MetaItem/MetaItem.module.css create mode 100644 frontend/components/molecules/MetaItem/MetaItem.tsx create mode 100644 frontend/components/molecules/MetaItem/index.ts create mode 100644 frontend/components/molecules/NavTab/NavTab.module.css create mode 100644 frontend/components/molecules/NavTab/NavTab.tsx create mode 100644 frontend/components/molecules/NavTab/index.ts create mode 100644 frontend/components/molecules/Notice/Notice.module.css create mode 100644 frontend/components/molecules/Notice/Notice.tsx create mode 100644 frontend/components/molecules/Notice/index.ts create mode 100644 frontend/components/molecules/PanelHeader/PanelHeader.module.css create mode 100644 frontend/components/molecules/PanelHeader/PanelHeader.tsx create mode 100644 frontend/components/molecules/PanelHeader/index.ts create mode 100644 frontend/components/molecules/ResultCard/ResultCard.module.css create mode 100644 frontend/components/molecules/ResultCard/ResultCard.tsx create mode 100644 frontend/components/molecules/ResultCard/index.ts create mode 100644 frontend/components/molecules/RunFlag/RunFlag.module.css create mode 100644 frontend/components/molecules/RunFlag/RunFlag.tsx create mode 100644 frontend/components/molecules/RunFlag/index.ts create mode 100644 frontend/components/molecules/StatusIndicator/StatusIndicator.helper.ts create mode 100644 frontend/components/molecules/StatusIndicator/StatusIndicator.module.css create mode 100644 frontend/components/molecules/StatusIndicator/StatusIndicator.tsx create mode 100644 frontend/components/molecules/StatusIndicator/index.ts create mode 100644 frontend/components/molecules/ToggleField/ToggleField.module.css create mode 100644 frontend/components/molecules/ToggleField/ToggleField.tsx create mode 100644 frontend/components/molecules/ToggleField/index.ts create mode 100644 frontend/components/molecules/index.ts create mode 100644 frontend/components/organisms/CompareBlocked/CompareBlocked.module.css create mode 100644 frontend/components/organisms/CompareBlocked/CompareBlocked.tsx create mode 100644 frontend/components/organisms/CompareBlocked/index.ts create mode 100644 frontend/components/organisms/ComparePanel/ComparePanel.helper.ts create mode 100644 frontend/components/organisms/ComparePanel/ComparePanel.module.css create mode 100644 frontend/components/organisms/ComparePanel/ComparePanel.tsx create mode 100644 frontend/components/organisms/ComparePanel/index.ts create mode 100644 frontend/components/organisms/CompareResult/CompareResult.module.css create mode 100644 frontend/components/organisms/CompareResult/CompareResult.tsx create mode 100644 frontend/components/organisms/CompareResult/index.ts create mode 100644 frontend/components/organisms/EventFeed/EventFeed.helper.ts create mode 100644 frontend/components/organisms/EventFeed/EventFeed.module.css create mode 100644 frontend/components/organisms/EventFeed/EventFeed.tsx create mode 100644 frontend/components/organisms/EventFeed/index.ts create mode 100644 frontend/components/organisms/FilterBar/FilterBar.helper.ts create mode 100644 frontend/components/organisms/FilterBar/FilterBar.module.css create mode 100644 frontend/components/organisms/FilterBar/FilterBar.tsx create mode 100644 frontend/components/organisms/FilterBar/index.ts create mode 100644 frontend/components/organisms/IdentityBar/IdentityBar.helper.ts create mode 100644 frontend/components/organisms/IdentityBar/IdentityBar.module.css create mode 100644 frontend/components/organisms/IdentityBar/IdentityBar.tsx create mode 100644 frontend/components/organisms/IdentityBar/index.ts create mode 100644 frontend/components/organisms/KpiGrid/KpiGrid.helper.ts create mode 100644 frontend/components/organisms/KpiGrid/KpiGrid.module.css create mode 100644 frontend/components/organisms/KpiGrid/KpiGrid.tsx create mode 100644 frontend/components/organisms/KpiGrid/index.ts create mode 100644 frontend/components/organisms/PalletSideView/PalletSideView.helper.ts create mode 100644 frontend/components/organisms/PalletSideView/PalletSideView.module.css create mode 100644 frontend/components/organisms/PalletSideView/PalletSideView.tsx create mode 100644 frontend/components/organisms/PalletSideView/index.ts create mode 100644 frontend/components/organisms/PalletTopView/PalletTopView.helper.ts create mode 100644 frontend/components/organisms/PalletTopView/PalletTopView.module.css create mode 100644 frontend/components/organisms/PalletTopView/PalletTopView.tsx create mode 100644 frontend/components/organisms/PalletTopView/index.ts create mode 100644 frontend/components/organisms/Panel/Panel.module.css create mode 100644 frontend/components/organisms/Panel/Panel.tsx create mode 100644 frontend/components/organisms/Panel/index.ts create mode 100644 frontend/components/organisms/RunRow/RunRow.helper.ts create mode 100644 frontend/components/organisms/RunRow/RunRow.module.css create mode 100644 frontend/components/organisms/RunRow/RunRow.tsx create mode 100644 frontend/components/organisms/RunRow/index.ts create mode 100644 frontend/components/organisms/RunsTable/RunsTable.helper.ts create mode 100644 frontend/components/organisms/RunsTable/RunsTable.module.css create mode 100644 frontend/components/organisms/RunsTable/RunsTable.tsx create mode 100644 frontend/components/organisms/RunsTable/index.ts create mode 100644 frontend/components/organisms/RunsTotals/RunsTotals.helper.ts create mode 100644 frontend/components/organisms/RunsTotals/RunsTotals.tsx create mode 100644 frontend/components/organisms/RunsTotals/index.ts create mode 100644 frontend/components/organisms/TopBar/TopBar.module.css create mode 100644 frontend/components/organisms/TopBar/TopBar.tsx create mode 100644 frontend/components/organisms/TopBar/index.ts create mode 100644 frontend/components/organisms/index.ts delete mode 100644 frontend/components/primitives.tsx create mode 100644 frontend/components/screens/AppHeader/AppHeader.helper.ts create mode 100644 frontend/components/screens/AppHeader/AppHeader.tsx create mode 100644 frontend/components/screens/AppHeader/index.ts create mode 100644 frontend/components/screens/LiveScreen/LiveScreen.helper.ts create mode 100644 frontend/components/screens/LiveScreen/LiveScreen.module.css create mode 100644 frontend/components/screens/LiveScreen/LiveScreen.tsx create mode 100644 frontend/components/screens/LiveScreen/index.ts create mode 100644 frontend/components/screens/LiveScreen/useLiveScreen.ts create mode 100644 frontend/components/screens/RunsScreen/RunsScreen.helper.ts create mode 100644 frontend/components/screens/RunsScreen/RunsScreen.module.css create mode 100644 frontend/components/screens/RunsScreen/RunsScreen.tsx create mode 100644 frontend/components/screens/RunsScreen/index.ts create mode 100644 frontend/components/screens/RunsScreen/useRunsScreen.ts create mode 100644 frontend/hooks/useNow.ts create mode 100644 frontend/lib/cx.ts create mode 100644 frontend/lib/pallet.ts diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..0b04e03 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,269 @@ +# 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/`. + +## 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: `?select=*&status=eq.running&limit=1` +- Último (`getLatestEpisode()` / `useLatestEpisode()`): el episodio en curso si lo hay; si no, el más reciente. **Ver la nota de abajo.** + +```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()`. + +> **Aviso: `v_episode_summary` no tiene `started_at`.** La vista de 002 no lo selecciona, así que +> `?order=started_at.desc` sobre ella da `400 42703`. `fetchLatestEpisode` de `lib/supabase.ts` +> hace exactamente eso en su segunda consulta (cuando no hay ningún episodio `running`). El cliente +> nuevo `getLatestEpisode` lo evita pidiendo el id a `GET /episodes?select=id&order=started_at.desc&limit=1` +> y luego la fila de la vista con `?id=eq.{id}`. Confirmado contra la base real: `400 42703 column +> v_episode_summary.started_at does not exist`. Si se prefiere arreglarlo de raíz, basta añadir `e.started_at` a la vista en +> `002_design.sql`. + +### 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** salvo `episodes?select=id&order=started_at.desc` +en `getLatestEpisode`. 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 }]` | `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 /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. + +## 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 | + +Por eso, ante un `UPDATE` de `episodes` Live vuelve a consultar la vista en vez de usar el payload. + +Ojo: `RunLog.episode()` (§3) inserta el episodio **ya terminado** (`success`/`failure`) y con sus filas hijas +de golpe; no hay ningún `UPDATE` de `episodes` ni un episodio `running` que salga del SDK actual. Live solo verá +`INSERT`s en vivo si el productor escribe el episodio al empezar y sus `events`/`pallet_states` según avanzan. + +## 5. Versiones del esquema + +`001_schema.sql` crea las tablas y unas vistas básicas; `002_design.sql` las **tira y rehace** y 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/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/getLatestEpisode.ts b/frontend/api/clients/episodes/getLatestEpisode.ts new file mode 100644 index 0000000..b664165 --- /dev/null +++ b/frontend/api/clients/episodes/getLatestEpisode.ts @@ -0,0 +1,40 @@ +import { supabase, type Episode } from "@/lib/supabase"; +import { assertConfigured, toApiError, trace } from "../errors"; + +/** + * Lo que enseña Live: un episodio en curso si lo hay y, si no, el último terminado. + * Devuelve `null` solo cuando no hay ni un episodio en toda la base. + * + * 1. `GET /v_episode_summary?status=eq.running&limit=1` + * 2. `GET /episodes?select=id&order=started_at.desc&limit=1`, y luego + * `GET /v_episode_summary?id=eq.{id}`. + * + * El paso 2 pasa por la tabla porque `v_episode_summary` NO expone `started_at` + * (002_design.sql): ordenar la vista por esa columna da `42703`. + */ +export async function getLatestEpisode(): Promise { + const source = "getLatestEpisode"; + const t0 = performance.now(); + try { + assertConfigured(source); + const running = await supabase + .from("v_episode_summary").select("*").eq("status", "running").limit(1); + if (running.error) throw running.error; + if (running.data?.length) return running.data[0] as Episode; + + const latest = await supabase + .from("episodes").select("id").order("started_at", { ascending: false }).limit(1); + if (latest.error) throw latest.error; + const id = latest.data?.[0]?.id as string | undefined; + if (!id) return null; + + const { data, error } = await supabase + .from("v_episode_summary").select("*").eq("id", id).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/index.ts b/frontend/api/clients/episodes/index.ts new file mode 100644 index 0000000..77839bd --- /dev/null +++ b/frontend/api/clients/episodes/index.ts @@ -0,0 +1,3 @@ +export { getEpisodes } from "./getEpisodes"; +export { getEpisode } from "./getEpisode"; +export { getLatestEpisode } from "./getLatestEpisode"; 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..ed144ba --- /dev/null +++ b/frontend/api/clients/index.ts @@ -0,0 +1,6 @@ +export { ApiError } from "./errors"; +export { getRuns, getRun } from "./runs"; +export { getEpisodes, getEpisode, getLatestEpisode } from "./episodes"; +export { getPlacements, getPalletStates, getEvents } from "./episodeDetail"; +export { getFailureBreakdown } from "./failures"; +export { getRunsCount, getEpisodesCount } from "./totals"; 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/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..93f6e7f --- /dev/null +++ b/frontend/api/hooks/index.ts @@ -0,0 +1,13 @@ +export { queryKeys } from "./queryKeys"; +export { useRuns } from "./useRuns"; +export { useRun } from "./useRun"; +export { useEpisodes } from "./useEpisodes"; +export { useEpisode } from "./useEpisode"; +export { useLatestEpisode } from "./useLatestEpisode"; +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"; diff --git a/frontend/api/hooks/queryKeys.ts b/frontend/api/hooks/queryKeys.ts new file mode 100644 index 0000000..0061a2e --- /dev/null +++ b/frontend/api/hooks/queryKeys.ts @@ -0,0 +1,24 @@ +/* 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, + latest: ["episodes", "latest"] 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, + }, +}; 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/useLatestEpisode.ts b/frontend/api/hooks/useLatestEpisode.ts new file mode 100644 index 0000000..8e3547e --- /dev/null +++ b/frontend/api/hooks/useLatestEpisode.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { getLatestEpisode } from "../clients"; +import { queryKeys } from "./queryKeys"; + +/** `refetchInterval` es opcional: Live se alimenta de Realtime y no lo necesita. */ +export function useLatestEpisode(opts: { refetchInterval?: number | false } = {}) { + return useQuery({ + queryKey: queryKeys.episodes.latest, + queryFn: getLatestEpisode, + refetchInterval: opts.refetchInterval ?? false, + }); +} 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/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/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/globals.css b/frontend/app/globals.css index d5bada0..3a3b2d6 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -104,76 +104,6 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .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/layout.tsx b/frontend/app/layout.tsx index 56e85c9..cb51e18 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import { IBM_Plex_Mono, IBM_Plex_Sans, IBM_Plex_Sans_Condensed } from "next/font/google"; 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, @@ -38,8 +39,10 @@ export default function RootLayout({ children }: LayoutProps<"/">) { className={`${plexSans.variable} ${plexCondensed.variable} ${plexMono.variable}`} > - - {children} + + + {children} + ); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c362db2..fe95a47 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,310 +1,5 @@ -"use client"; +import { LiveScreen } from "@/components/screens/LiveScreen"; -import { useCallback, useEffect, useRef, useState } from "react"; -import Link from "next/link"; -import { - configured, fetchEpisodeDetail, fetchLatestEpisode, supabase, - type Episode, type PalletState, type Placement, type RunEvent, -} from "@/lib/supabase"; -import { - TASK_TEXT, clockTime, failureText, mm, pct, seconds, signedMm, stabilityState, -} from "@/lib/ui"; -import { PalletSideView, PalletTopView } from "@/components/Pallet"; -import { EventFeed } from "@/components/Episode"; -import { Dot, Kpi, Meta, OracleBadge, SyntheticBadge } from "@/components/primitives"; - -/* Si no llega nada nuevo en este tiempo teniendo un episodio en curso, se da la - conexión por perdida. Generoso a propósito: un aviso en falso durante la demo es - peor que enterarse dos segundos más tarde. */ -const HEARTBEAT_MS = 8000; - -export default function LivePage() { - const [episode, setEpisode] = useState(null); - const [placements, setPlacements] = useState([]); - const [states, setStates] = useState([]); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [lastSeen, setLastSeen] = useState(null); - const [stale, setStale] = useState(false); - const lastRef = useRef(Date.now()); - - const load = useCallback(async () => { - if (!configured) { setLoading(false); return; } - try { - const ep = await fetchLatestEpisode(); - setEpisode(ep); - if (ep) { - const d = await fetchEpisodeDetail(ep.id); - setPlacements(d.placements); - setStates(d.states); - setEvents(d.events); - } - lastRef.current = Date.now(); - setLastSeen(new Date()); - setStale(false); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { void load(); }, [load]); - - /* Realtime: esta vista se alimenta de estas suscripciones y de nada más. */ - useEffect(() => { - if (!configured || !episode) return; - const touch = () => { - lastRef.current = Date.now(); - setLastSeen(new Date()); - setStale(false); - }; - - const channel = supabase - .channel(`live-${episode.id}`) - .on("postgres_changes", - { event: "INSERT", schema: "public", table: "events", - filter: `episode_id=eq.${episode.id}` }, - (p) => { setEvents((prev) => [...prev, p.new as RunEvent]); touch(); }) - .on("postgres_changes", - { event: "INSERT", schema: "public", table: "pallet_states", - filter: `episode_id=eq.${episode.id}` }, - (p) => { setStates((prev) => [...prev, p.new as PalletState]); touch(); }) - .on("postgres_changes", - { event: "UPDATE", schema: "public", table: "episodes", - filter: `id=eq.${episode.id}` }, - () => { void load(); }) - .subscribe(); - - return () => { void supabase.removeChannel(channel); }; - }, [episode, load]); - - /* Latido. Lo que NO se hace aquí es vaciar la pantalla: lo último recibido se queda - congelado y visible. Quedarse en blanco delante del jurado es lo peor que puede - hacer esta vista. */ - useEffect(() => { - const t = setInterval(() => { - const running = episode?.status === "running"; - setStale(Boolean(running) && Date.now() - lastRef.current > HEARTBEAT_MS); - }, 1000); - return () => clearInterval(t); - }, [episode]); - - if (!configured) { - return ( - - Crea platform/frontend/.env.local con{" "} - NEXT_PUBLIC_SUPABASE_URL y{" "} - NEXT_PUBLIC_SUPABASE_ANON_KEY. - - ); - } - if (loading) return Buscando el último episodio.; - if (!episode) { - return ( - - Lanza un benchmark con --telemetry, o siembra un histórico con{" "} - platform/backend/seed/palletizing.py. - - ); - } - - const running = episode.status === "running"; - const failed = episode.status === "failure"; - const last = states.length ? states[states.length - 1] : null; - const margin = last?.stability_margin_m ?? episode.final_stability_m; - - return ( -
- - - {stale && void load()} />} - -
-
- - - - - - -
- -
-
- - - - -
- - {failed && } - {!running && !failed && } - - -
-
-
- ); -} - -/* ── barra de identidad ─────────────────────────────────────────────────── */ - -function IdentityBar({ episode, running, stale }: { - episode: Episode; running: boolean; stale: boolean; -}) { - const state = stale ? { text: "Sin datos nuevos", color: "var(--warn)" } - : running ? { text: "En vivo", color: "var(--bad)" } - : episode.status === "success" ? { text: "Último", color: "var(--ok)" } - : { text: "Último", color: "var(--text-3)" }; - - return ( -
- - - - {state.text} - - - - - - - - - - - - - - {episode.oracle && } - {episode.synthetic && } - - - - {(episode.duration_s ?? 0).toFixed(1)} - - s - -
- ); -} - -/* ── estados que no son el feliz ────────────────────────────────────────── */ - -function StaleBanner({ lastSeen, onRetry }: { - lastSeen: Date | null; onRetry: () => void; -}) { - return ( -
- - Conexión perdida. Reintentando. - - lo que se ve es el último dato recibido · {clockTime(lastSeen?.toISOString())} - - - -
- ); -} - -function FailureCard({ episode }: { episode: Episode }) { - return ( -
- - {failureText(episode.failure)} - - - {episode.n_placed} de {episode.n_objects} paquetes colocados · margen final{" "} - {signedMm(episode.final_stability_m)} - - - Ver episodio → - -
- ); -} - -function DoneCard({ episode }: { episode: Episode }) { - return ( -
- Episodio completado - - {seconds(episode.duration_s)} · {episode.n_placed} de {episode.n_objects} paquetes - · sin derrumbe - - - Ver detalle → - -
- ); -} - -function Panel({ title, note, children }: { - title: string; note?: string; children: React.ReactNode; -}) { - return ( -
-
- {title} - {note && {note}} -
-
- {children} -
-
- ); -} - -function Notice({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

-

{children}

-
- ); +export default function Page() { + return ; } diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx new file mode 100644 index 0000000..45c6026 --- /dev/null +++ b/frontend/app/providers.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +let browserQueryClient: QueryClient | undefined; + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // Los datos son de ejecuciones ya terminadas salvo Live, que va por Realtime: + // no hace falta volver a pedirlos cada vez que se enfoca la ventana. + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }); +} + +function getQueryClient() { + // En servidor, un cliente por render; en el navegador, uno reutilizado. + if (typeof window === "undefined") return makeClient(); + browserQueryClient ??= makeClient(); + return browserQueryClient; +} + +export function Providers({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/frontend/app/runs/page.tsx b/frontend/app/runs/page.tsx new file mode 100644 index 0000000..5137c99 --- /dev/null +++ b/frontend/app/runs/page.tsx @@ -0,0 +1,5 @@ +import { RunsScreen } from "@/components/screens/RunsScreen"; + +export default function Page() { + return ; +} diff --git a/frontend/components/Episode.tsx b/frontend/components/Episode.tsx index f1f5bca..667f1af 100644 --- a/frontend/components/Episode.tsx +++ b/frontend/components/Episode.tsx @@ -3,11 +3,10 @@ import { useState } from "react"; import type { Placement, PalletState, RunEvent } from "@/lib/supabase"; import { - EVENT_TEXT, deg, failureText, kg, mm, pct, seconds, signedMm, stabilityState, + EVENT_TEXT, deg, kg, mm, pct, seconds, signedMm, stabilityState, } from "@/lib/ui"; -/* ── feed de eventos ────────────────────────────────────────────────────── */ - +/* Color de cada tipo de evento, para la leyenda de la línea de tiempo. */ const EVENT_COLOR: Record = { perceive: "var(--text-4)", plan: "var(--select)", @@ -17,94 +16,6 @@ const EVENT_COLOR: Record = { fail: "var(--bad)", }; -/** El detalle de cada evento, ya con sus unidades. Es lo que convierte el feed en - * información en vez de en una lista de verbos. */ -function detail(e: RunEvent): string { - const p = (e.payload ?? {}) as Record; - switch (e.kind) { - case "perceive": - return [ - p.seen != null ? `${p.seen} paquete${p.seen === 1 ? "" : "s"}` : null, - p.confidence != null ? `confianza ${p.confidence}` : null, - ].filter(Boolean).join(" · "); - case "plan": - return [p.layer != null ? `capa ${p.layer}` : null, - p.slot ? `hueco ${p.slot}` : null].filter(Boolean).join(" · "); - case "pick": - return p.mass_kg != null ? kg(Number(p.mass_kg)) : ""; - case "place": - return [ - p.error_xy_mm != null ? `error ${p.error_xy_mm} mm` : null, - p.error_yaw_deg != null ? `${p.error_yaw_deg}°` : null, - Number(p.overhang_mm) > 0 ? `fuera del palé ${p.overhang_mm} mm` : null, - ].filter(Boolean).join(" · "); - case "settle": - return [p.layer != null ? `capa ${p.layer}` : null, - p.drift_mm != null ? `deriva ${p.drift_mm} mm` : null] - .filter(Boolean).join(" · "); - case "fail": - return failureText(String(p.cause ?? "")); - default: - return ""; - } -} - -export function EventFeed({ events, limit = 12, title = "Eventos", note }: { - events: RunEvent[]; limit?: number; title?: string; note?: string; -}) { - const rows = [...events].sort((a, b) => b.seq - a.seq).slice(0, limit); - - return ( -
-
- {title} - - {note ?? `últimos ${limit} · t desde el inicio del episodio`} - -
-
- {rows.map((e, i) => ( -
- - {seconds(e.ts)} - - - {EVENT_TEXT[e.kind] ?? e.kind} - - {e.package_id ?? ""} - - - {detail(e)} - -
- ))} - {!rows.length && ( -
- Todavía no hay eventos de este episodio. -
- )} -
-
- ); -} - /* ── traza del centro de gravedad ───────────────────────────────────────── */ /** diff --git a/frontend/components/Pallet.tsx b/frontend/components/Pallet.tsx deleted file mode 100644 index cc34045..0000000 --- a/frontend/components/Pallet.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -import type { Placement, PalletState } from "@/lib/supabase"; -import { signedMm, stabilityState } from "@/lib/ui"; - -/* Palé europeo, en metros. El diseño insiste en dibujarlo a escala real y con sus - medidas: un esquemático genérico no transmite que esto modela un proceso físico. */ -export const PALLET_X = 1.2; -export const PALLET_Y = 0.8; - -/* ── vista cenital ──────────────────────────────────────────────────────── */ - -/** - * El palé visto desde arriba, con la cruz del centro de gravedad sobre el polígono - * de soporte. - * - * Es el dibujo que define la interfaz: si el jurado solo entiende una cosa sin que - * nadie se la explique, tiene que ser ésta. Por eso la cruz lleva el color del estado - * de estabilidad y el polígono se dibuja también cuando el montón va bien. - */ -export function PalletTopView({ placements, state, height = 380 }: { - placements: Placement[]; - state: PalletState | null; - height?: number; -}) { - const pad = 26; - const scale = (height - pad * 2) / PALLET_Y; - const w = PALLET_X * scale + pad * 2; - - // De metros respecto al centro del palé a píxeles. +Y del mundo sube en pantalla. - const X = (x: number) => pad + (x + PALLET_X / 2) * scale; - const Y = (y: number) => pad + (PALLET_Y / 2 - y) * scale; - - const base = placements.filter((p) => p.layer === 1 && p.actual_pose && p.dims_m); - const support = base.length ? bounds(base) : null; - const last = placements[placements.length - 1]; - const margin = state?.stability_margin_m ?? null; - const cogColor = `var(--${stabilityState(margin)})`; - - return ( - - - - {/* huecos planificados, en trazo fino */} - {placements.map((p) => - p.planned_pose && p.dims_m ? ( - - ) : null - )} - - {/* paquetes colocados; el último, resaltado */} - {placements.map((p) => - p.actual_pose && p.dims_m ? ( - - - - {p.layer} - - - ) : null - )} - - {/* polígono de soporte: lo que de verdad sostiene el montón */} - {support && ( - - )} - - {/* la cruz del centro de gravedad */} - {state && ( - - - - - - )} - - {/* medidas reales: es un criterio de puntuación, no decoración */} - - 1200 × 800 mm - - {margin != null && ( - - margen {signedMm(margin)} - - )} - - ); -} - -function bounds(ps: Placement[]) { - const x0 = Math.min(...ps.map((p) => p.actual_pose!.x - p.dims_m![0] / 2)); - const x1 = Math.max(...ps.map((p) => p.actual_pose!.x + p.dims_m![0] / 2)); - const y0 = Math.min(...ps.map((p) => p.actual_pose!.y - p.dims_m![1] / 2)); - const y1 = Math.max(...ps.map((p) => p.actual_pose!.y + p.dims_m![1] / 2)); - return { x0, x1, y0, y1 }; -} - -/* ── alzado ─────────────────────────────────────────────────────────────── */ - -/** - * Las capas apiladas, de perfil, con la altura del centro de gravedad marcada. - * - * Hace visible lo que la vista cenital no puede: que apilar alto es lo que mata la - * estabilidad. Un montón perfectamente centrado puede volcar igual si es demasiado - * alto, y sin este dibujo esa mitad del problema no se ve. - */ -export function PalletSideView({ placements, state, height = 275 }: { - placements: Placement[]; - state: PalletState | null; - height?: number; -}) { - const pad = 26; - const top = placements.reduce( - (h, p) => Math.max(h, (p.actual_pose?.z ?? 0) + (p.dims_m?.[2] ?? 0) / 2), 0); - // Un 25 % de aire por encima de la carga para que la cota no toque el borde. - const zMax = Math.max(top * 1.25, 0.5); - const scale = (height - pad * 2) / zMax; - const w = PALLET_X * scale + pad * 2; - - const X = (x: number) => pad + (x + PALLET_X / 2) * scale; - const Z = (z: number) => height - pad - z * scale; - - const cogColor = `var(--${stabilityState(state?.stability_margin_m)})`; - - return ( - - - - {placements.map((p) => - p.actual_pose && p.dims_m ? ( - - ) : null - )} - - {state && ( - - - - CoG {(state.cog_z * 1000).toFixed(0)} mm - - - )} - - ); -} diff --git a/frontend/components/Runs.tsx b/frontend/components/Runs.tsx index e4db039..e2259a8 100644 --- a/frontend/components/Runs.tsx +++ b/frontend/components/Runs.tsx @@ -1,240 +1,8 @@ "use client"; import Link from "next/link"; -import { comparability, commonSeeds, type Run, type SeedPoint } from "@/lib/supabase"; -import { - TASK_TEXT, failureColor, failureText, pct, relativeTime, seconds, signedMm, - stabilityState, -} from "@/lib/ui"; -import { Sparkline } from "@/components/primitives"; - -/* ── tabla de ejecuciones ───────────────────────────────────────────────── */ - -export function RunTable({ runs, selected, onToggle }: { - runs: Run[]; - selected: string[]; - onToggle: (id: string) => void; -}) { - const th: React.CSSProperties = { - textAlign: "left", padding: "10px 12px", whiteSpace: "nowrap", - borderBottom: "1px solid var(--border)", position: "sticky", top: 0, - background: "var(--surface)", zIndex: 1, - }; - const td: React.CSSProperties = { - padding: "9px 12px", borderBottom: "1px solid var(--border-soft)", whiteSpace: "nowrap", - }; - - return ( -
-
- - - - - - - - - - - - - - - - - {runs.map((r, i) => { - const on = selected.includes(r.id); - return ( - - - - - - - - - - - - - - ); - })} - -
- CommitTareaNivelVelEpisodiosÉxitoÉxito por episodioCicloCausa dominanteCuándo
- onToggle(r.id)} - aria-label={`Seleccionar ${r.git_sha} para comparar`} - style={{ accentColor: "var(--select)" }} - /> - - {r.git_sha || "—"} - {r.oracle && } - {r.synthetic && } - {TASK_TEXT[r.task] ?? r.task}{r.level}x{r.motion_speed}{r.episodes} - {pct(r.success_rate)} - - {seconds(r.median_cycle_s)} - - {r.dominant_failure ? ( - - - {failureText(r.dominant_failure)} - - ) : sin fallos} - - {relativeTime(r.started_at)} -
-
-
- ); -} - -function Flag({ color, title }: { color: string; title: string }) { - return ( - - ); -} - -/* ── comparador ─────────────────────────────────────────────────────────── */ - -/** - * El delta entre dos ejecuciones, o el motivo por el que no se puede calcular. - * - * Bloquear la comparación no es una validación de formulario: es la pieza que impide - * enseñar un gráfico bonito y falso. Comparar dos runs que no miden lo mismo da un - * número que parece una mejora y no lo es, y ése es justo el error que el jurado va a - * buscar. - */ -export function ComparePanel({ a, b, onClear }: { - a: Run; b: Run; onClear: () => void; -}) { - const blockers = comparability(a, b); - const valid = blockers.length === 0; - - return ( -
-
- Comparativa - {a.git_sha} - vs - {b.git_sha} - - - {valid ? "Comparación válida" : "No comparable"} - - -
- - {valid ? ( -
- - {TASK_TEXT[a.task] ?? a.task} · nivel {a.level} · semillas {a.seed_min}–{a.seed_max} - {" "}· x{a.motion_speed} · sin oracle ·{" "} - - mismas {commonSeeds(a, b)} semillas en ambas - - - - - -
- ) : ( -
- Estas dos ejecuciones no miden lo mismo -
- - - {blockers.map((x) => ( - - - - - - - ))} - -
{x.field}{x.a}{x.b}{x.why}
-
-
- La comparación se bloquea cuando - {[ - "la tarea o el nivel no coinciden: el criterio de éxito es otro", - "el rango de semillas no coincide: se compara suerte, no código", - "una de las dos lleva oracle: esos números no son los de verdad", - "una de las dos está sembrada: esos números no se midieron", - ].map((t) => ( - — {t} - ))} - - Con las semillas en común se puede ver el subconjunto, nunca el delta agregado. - -
-
- )} -
- ); -} - -export function Delta({ label, value, unit, digits, detail, lowerIsBetter }: { - label: string; value: number; unit: string; digits: number; - detail: string; lowerIsBetter?: boolean; -}) { - const better = lowerIsBetter ? value < 0 : value > 0; - const color = Math.abs(value) < 1e-9 ? "var(--text-3)" - : better ? "var(--ok)" : "var(--bad)"; - return ( -
- {label} - - - {value >= 0 ? "+" : "−"}{Math.abs(value).toFixed(digits)} - - {unit} - - {detail} -
- ); -} +import type { SeedPoint } from "@/lib/supabase"; +import { failureColor, failureText, seconds, signedMm, stabilityState } from "@/lib/ui"; /* ── rejilla de semillas ────────────────────────────────────────────────── */ @@ -271,45 +39,3 @@ export function SeedGrid({ series, runId }: { series: SeedPoint[]; runId: string
); } - -/* ── desglose de causas ─────────────────────────────────────────────────── */ - -export function FailureBars({ series, total }: { series: SeedPoint[]; total: number }) { - const counts = new Map(); - for (const s of series) { - if (s.failure) counts.set(s.failure, (counts.get(s.failure) ?? 0) + 1); - } - const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]); - if (!rows.length) { - return ( -
- Ningún episodio falló. -
- ); - } - const max = Math.max(...rows.map((r) => r[1])); - - return ( -
- - Causas de fallo · {rows.reduce((n, r) => n + r[1], 0)} de {total} - - {rows.map(([failure, n]) => ( -
- - {failureText(failure)} - -
-
-
- - {n} - -
- ))} -
- ); -} diff --git a/frontend/components/TopBar.tsx b/frontend/components/TopBar.tsx deleted file mode 100644 index 3335b21..0000000 --- a/frontend/components/TopBar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -/** - * Barra global. Dos destinos y nada más: la demo y el análisis. - * - * El diseño tiene cuatro pantallas y aquí solo salen dos porque Run y Episodio se - * alcanzan desde la tabla: son detalle, no secciones. - */ -export default function TopBar() { - const path = usePathname(); - const onRuns = path?.startsWith("/runs") ?? false; - - return ( -
- - Observabilidad de paletizado - - -
- ); -} - -function Tab({ href, active, children }: { - href: string; active: boolean; children: React.ReactNode; -}) { - return ( - - {children} - - ); -} diff --git a/frontend/components/atoms/Badge/Badge.module.css b/frontend/components/atoms/Badge/Badge.module.css new file mode 100644 index 0000000..55fd810 --- /dev/null +++ b/frontend/components/atoms/Badge/Badge.module.css @@ -0,0 +1,21 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 9px; + border: 1px solid currentColor; + font-family: var(--font-plex-condensed), sans-serif; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.13em; + text-transform: uppercase; + white-space: nowrap; +} +.neutral { color: var(--text-3); } +.ok { color: var(--ok); } +.bad { color: var(--bad); } +.oracle { color: var(--oracle); } +.synthetic { color: var(--synthetic); } + +.oracle { background: repeating-linear-gradient(135deg, rgba(229, 57, 155, 0.16) 0 7px, transparent 7px 14px); } +.synthetic { background: repeating-linear-gradient(135deg, rgba(183, 123, 243, 0.16) 0 7px, transparent 7px 14px); } diff --git a/frontend/components/atoms/Badge/Badge.tsx b/frontend/components/atoms/Badge/Badge.tsx new file mode 100644 index 0000000..6bd0a5b --- /dev/null +++ b/frontend/components/atoms/Badge/Badge.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import { cx } from "@/lib/cx"; +import styles from "./Badge.module.css"; + +/** `oracle` y `synthetic` van con trama diagonal a propósito: son números que no son de + * verdad y tienen que molestar a la vista. */ +export function Badge({ tone = "neutral", children }: { + tone?: "neutral" | "oracle" | "synthetic" | "ok" | "bad"; children: ReactNode; +}) { + return {children}; +} diff --git a/frontend/components/atoms/Badge/index.ts b/frontend/components/atoms/Badge/index.ts new file mode 100644 index 0000000..5c70427 --- /dev/null +++ b/frontend/components/atoms/Badge/index.ts @@ -0,0 +1 @@ +export { Badge } from "./Badge"; diff --git a/frontend/components/atoms/Button/Button.module.css b/frontend/components/atoms/Button/Button.module.css new file mode 100644 index 0000000..2044d1a --- /dev/null +++ b/frontend/components/atoms/Button/Button.module.css @@ -0,0 +1,20 @@ +.button { + font: inherit; + font-size: 13px; + cursor: pointer; + background: none; + border: 0; + padding: 0; +} +.button:disabled { cursor: not-allowed; opacity: 0.5; } + +/* El azul acero marca interacción, y esto es interacción. */ +.link { color: var(--select); } +.link:hover:not(:disabled) { color: var(--link); text-decoration: underline; } + +.outline { + color: var(--text); + border: 1px solid var(--border-hard); + padding: 6px 12px; +} +.outline:hover:not(:disabled) { border-color: var(--select); color: var(--select); } diff --git a/frontend/components/atoms/Button/Button.tsx b/frontend/components/atoms/Button/Button.tsx new file mode 100644 index 0000000..2fdab72 --- /dev/null +++ b/frontend/components/atoms/Button/Button.tsx @@ -0,0 +1,11 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cx } from "@/lib/cx"; +import styles from "./Button.module.css"; + +type Props = ButtonHTMLAttributes & { + variant?: "link" | "outline"; +}; + +export function Button({ variant = "link", className, type = "button", ...rest }: Props) { + return } +
+ ); +} diff --git a/frontend/components/molecules/Banner/index.ts b/frontend/components/molecules/Banner/index.ts new file mode 100644 index 0000000..06353f1 --- /dev/null +++ b/frontend/components/molecules/Banner/index.ts @@ -0,0 +1 @@ +export { Banner } from "./Banner"; diff --git a/frontend/components/molecules/BlockerTable/BlockerTable.module.css b/frontend/components/molecules/BlockerTable/BlockerTable.module.css new file mode 100644 index 0000000..192188e --- /dev/null +++ b/frontend/components/molecules/BlockerTable/BlockerTable.module.css @@ -0,0 +1,3 @@ +.scroll { overflow-x: auto; } +.table { border-collapse: collapse; } +.table td { padding: 6px 24px 6px 0; white-space: nowrap; } diff --git a/frontend/components/molecules/BlockerTable/BlockerTable.tsx b/frontend/components/molecules/BlockerTable/BlockerTable.tsx new file mode 100644 index 0000000..688a467 --- /dev/null +++ b/frontend/components/molecules/BlockerTable/BlockerTable.tsx @@ -0,0 +1,24 @@ +import { Text } from "../../atoms"; +import styles from "./BlockerTable.module.css"; + +type Blocker = { field: string; a: string; b: string; why: string }; + +/** Por qué dos ejecuciones no miden lo mismo: qué campo, qué valen y por qué importa. */ +export function BlockerTable({ blockers }: { blockers: Blocker[] }) { + return ( +
+ + + {blockers.map((b) => ( + + + + + + + ))} + +
{b.field}{b.a}{b.b}{b.why}
+
+ ); +} diff --git a/frontend/components/molecules/BlockerTable/index.ts b/frontend/components/molecules/BlockerTable/index.ts new file mode 100644 index 0000000..24277a7 --- /dev/null +++ b/frontend/components/molecules/BlockerTable/index.ts @@ -0,0 +1 @@ +export { BlockerTable } from "./BlockerTable"; diff --git a/frontend/components/molecules/ColorLegendItem/ColorLegendItem.module.css b/frontend/components/molecules/ColorLegendItem/ColorLegendItem.module.css new file mode 100644 index 0000000..9697ee7 --- /dev/null +++ b/frontend/components/molecules/ColorLegendItem/ColorLegendItem.module.css @@ -0,0 +1 @@ +.item { display: inline-flex; align-items: center; gap: 8px; } diff --git a/frontend/components/molecules/ColorLegendItem/ColorLegendItem.tsx b/frontend/components/molecules/ColorLegendItem/ColorLegendItem.tsx new file mode 100644 index 0000000..1e89e9f --- /dev/null +++ b/frontend/components/molecules/ColorLegendItem/ColorLegendItem.tsx @@ -0,0 +1,12 @@ +import { Swatch, Text } from "../../atoms"; +import styles from "./ColorLegendItem.module.css"; + +/** Leyenda de un color de causa de fallo. */ +export function ColorLegendItem({ label, color }: { label: string; color: string }) { + return ( + + + {label} + + ); +} diff --git a/frontend/components/molecules/ColorLegendItem/index.ts b/frontend/components/molecules/ColorLegendItem/index.ts new file mode 100644 index 0000000..61c0667 --- /dev/null +++ b/frontend/components/molecules/ColorLegendItem/index.ts @@ -0,0 +1 @@ +export { ColorLegendItem } from "./ColorLegendItem"; diff --git a/frontend/components/molecules/DeltaStat/DeltaStat.module.css b/frontend/components/molecules/DeltaStat/DeltaStat.module.css new file mode 100644 index 0000000..03ef6c0 --- /dev/null +++ b/frontend/components/molecules/DeltaStat/DeltaStat.module.css @@ -0,0 +1 @@ +.stat { display: flex; flex-direction: column; gap: 14px; } diff --git a/frontend/components/molecules/DeltaStat/DeltaStat.tsx b/frontend/components/molecules/DeltaStat/DeltaStat.tsx new file mode 100644 index 0000000..351af6d --- /dev/null +++ b/frontend/components/molecules/DeltaStat/DeltaStat.tsx @@ -0,0 +1,23 @@ +import { Text, Value } from "../../atoms"; +import styles from "./DeltaStat.module.css"; + +type Props = { + label: string; + /** Ya formateado con su signo: «+23». */ + value: string; + unit: string; + /** «41 % → 64 %». */ + detail: string; + /** Verde si mejora, rojo si empeora, neutro si no cambia. */ + tone: "ok" | "bad" | "muted"; +}; + +export function DeltaStat({ label, value, unit, detail, tone }: Props) { + return ( +
+ {label} + + {detail} +
+ ); +} diff --git a/frontend/components/molecules/DeltaStat/index.ts b/frontend/components/molecules/DeltaStat/index.ts new file mode 100644 index 0000000..579d807 --- /dev/null +++ b/frontend/components/molecules/DeltaStat/index.ts @@ -0,0 +1 @@ +export { DeltaStat } from "./DeltaStat"; diff --git a/frontend/components/molecules/EventRow/EventRow.helper.ts b/frontend/components/molecules/EventRow/EventRow.helper.ts new file mode 100644 index 0000000..8cbbc2d --- /dev/null +++ b/frontend/components/molecules/EventRow/EventRow.helper.ts @@ -0,0 +1,82 @@ +import type { RunEvent } from "@/lib/supabase"; +import { EVENT_TEXT, deg, failureText, kg, mm, seconds } from "@/lib/ui"; +import type { IconName, TextTone } from "../../atoms"; + +/** Lo que pinta una fila del feed, ya formateado. EventRow no sabe qué es un RunEvent. */ +export type EventRowProps = { + time: string; + icon: IconName; + iconTone: TextTone; + label: string; + target: string; + detail: string; + failed: boolean; +}; + +const ICON: Record = { + perceive: "circleDot", plan: "crosshair", pick: "arrowDown", + place: "check", settle: "wave", fail: "x", +}; + +const TONE: Record = { + perceive: "muted", plan: "muted", pick: "muted", + place: "ok", settle: "muted", fail: "bad", +}; + +const join = (parts: Array) => parts.filter(Boolean).join(" · "); + +/* Los payloads son jsonb libre: `mm`/`deg` del payload vienen ya en milímetros y grados, + así que se devuelven a SI para que pase por los formateadores de lib/ui, que son el + único sitio que decide unidades. */ +const mmOf = (v: unknown) => mm(Number(v) / 1000); +const degOf = (v: unknown) => deg((Number(v) * Math.PI) / 180); + +/** Qué va en la columna «qué» y en la de «detalle» de cada tipo de evento. */ +function describe(e: RunEvent): { target: string; detail: string } { + const p = (e.payload ?? {}) as Record; + const pkg = e.package_id ?? ""; + switch (e.kind) { + case "perceive": + return { + target: p.seen != null ? `${p.seen} paquete${p.seen === 1 ? "" : "s"}` : "", + detail: p.confidence != null ? `confianza ${p.confidence}` : "", + }; + case "plan": + return { + target: join([p.layer != null ? `capa ${p.layer}` : null, + p.slot ? `hueco ${p.slot}` : null]), + detail: "", + }; + case "pick": + return { target: pkg, detail: p.mass_kg != null ? kg(Number(p.mass_kg)) : "" }; + case "place": + return { + target: pkg, + detail: join([ + p.error_xy_mm != null ? `error ${mmOf(p.error_xy_mm)}` : null, + p.error_yaw_deg != null ? degOf(p.error_yaw_deg) : null, + Number(p.overhang_mm) > 0 ? `fuera del palé ${mmOf(p.overhang_mm)}` : null, + ]), + }; + case "settle": + return { + target: p.layer != null ? `capa ${p.layer}` : "", + detail: p.drift_mm != null ? `deriva ${mmOf(p.drift_mm)}` : "", + }; + case "fail": + return { target: pkg, detail: failureText(String(p.cause ?? "")) }; + default: + return { target: pkg, detail: "" }; + } +} + +export function toEventRowProps(e: RunEvent): EventRowProps { + return { + time: seconds(e.ts), + icon: ICON[e.kind] ?? "circleDot", + iconTone: TONE[e.kind] ?? "muted", + label: EVENT_TEXT[e.kind] ?? e.kind, + failed: e.kind === "fail", + ...describe(e), + }; +} diff --git a/frontend/components/molecules/EventRow/EventRow.module.css b/frontend/components/molecules/EventRow/EventRow.module.css new file mode 100644 index 0000000..53f9050 --- /dev/null +++ b/frontend/components/molecules/EventRow/EventRow.module.css @@ -0,0 +1,18 @@ +.row { + list-style: none; + min-height: 38px; + padding: 0 28px; + font-size: 13px; + display: grid; + grid-template-columns: 72px 20px 110px minmax(90px, auto) minmax(0, 1fr); + align-items: center; + column-gap: 14px; +} +.highlighted { background: var(--row-hover); } +.detail { text-align: right; } + +/* Estrecho: se suelta la columna de detalle bajo el objetivo en vez de recortarla. */ +@media (max-width: 720px) { + .row { grid-template-columns: 60px 20px 1fr; padding: 8px 16px; row-gap: 2px; } + .target, .detail { grid-column: 3; text-align: left; } +} diff --git a/frontend/components/molecules/EventRow/EventRow.tsx b/frontend/components/molecules/EventRow/EventRow.tsx new file mode 100644 index 0000000..9a13636 --- /dev/null +++ b/frontend/components/molecules/EventRow/EventRow.tsx @@ -0,0 +1,31 @@ +import { Icon, Text, type IconName, type TextTone } from "../../atoms"; +import { cx } from "@/lib/cx"; +import styles from "./EventRow.module.css"; + +type Props = { + time: string; + icon: IconName; + iconTone: TextTone; + label: string; + target: string; + detail: string; + failed?: boolean; + /** La fila más reciente se destaca con un fondo más claro. */ + highlighted?: boolean; +}; + +export function EventRow({ + time, icon, iconTone, label, target, detail, failed, highlighted, +}: Props) { + return ( +
  • + {time} + + {label} + {target} + + {detail} + +
  • + ); +} diff --git a/frontend/components/molecules/EventRow/index.ts b/frontend/components/molecules/EventRow/index.ts new file mode 100644 index 0000000..e64bc6e --- /dev/null +++ b/frontend/components/molecules/EventRow/index.ts @@ -0,0 +1,3 @@ +export { EventRow } from "./EventRow"; +export { toEventRowProps } from "./EventRow.helper"; +export type { EventRowProps } from "./EventRow.helper"; diff --git a/frontend/components/molecules/FailureCell/FailureCell.module.css b/frontend/components/molecules/FailureCell/FailureCell.module.css new file mode 100644 index 0000000..9d77431 --- /dev/null +++ b/frontend/components/molecules/FailureCell/FailureCell.module.css @@ -0,0 +1 @@ +.cell { display: inline-flex; align-items: center; gap: 10px; } diff --git a/frontend/components/molecules/FailureCell/FailureCell.tsx b/frontend/components/molecules/FailureCell/FailureCell.tsx new file mode 100644 index 0000000..f4951e9 --- /dev/null +++ b/frontend/components/molecules/FailureCell/FailureCell.tsx @@ -0,0 +1,14 @@ +import { Swatch, Text } from "../../atoms"; +import { failureColor, failureText } from "@/lib/ui"; +import styles from "./FailureCell.module.css"; + +/** La causa de fallo con su color de identidad, o «sin fallos». Nunca el identificador crudo. */ +export function FailureCell({ failure }: { failure: string | null }) { + if (!failure) return sin fallos; + return ( + + + {failureText(failure)} + + ); +} diff --git a/frontend/components/molecules/FailureCell/index.ts b/frontend/components/molecules/FailureCell/index.ts new file mode 100644 index 0000000..88aceb1 --- /dev/null +++ b/frontend/components/molecules/FailureCell/index.ts @@ -0,0 +1 @@ +export { FailureCell } from "./FailureCell"; diff --git a/frontend/components/molecules/FailureStackBar/FailureStackBar.module.css b/frontend/components/molecules/FailureStackBar/FailureStackBar.module.css new file mode 100644 index 0000000..ae7a330 --- /dev/null +++ b/frontend/components/molecules/FailureStackBar/FailureStackBar.module.css @@ -0,0 +1,5 @@ +.row { display: flex; align-items: center; gap: 20px; } +.label { width: 80px; flex-shrink: 0; font-size: 14px; } +.bar { flex: 1; min-width: 0; height: 32px; display: flex; background: var(--row-alt); } +.segment { flex-basis: 0; min-width: 2px; } +.none { align-self: center; padding-left: 14px; } diff --git a/frontend/components/molecules/FailureStackBar/FailureStackBar.tsx b/frontend/components/molecules/FailureStackBar/FailureStackBar.tsx new file mode 100644 index 0000000..9e7765e --- /dev/null +++ b/frontend/components/molecules/FailureStackBar/FailureStackBar.tsx @@ -0,0 +1,21 @@ +import { Text } from "../../atoms"; +import styles from "./FailureStackBar.module.css"; + +type Segment = { key: string; n: number; color: string; title: string }; + +/** Una barra apilada: cada causa ocupa lo que pesa dentro de los fallos de ese run. + * Los anchos son `flex-grow: n`, así que no hace falta sumar nada en el cliente. */ +export function FailureStackBar({ label, segments }: { label: string; segments: Segment[] }) { + return ( +
    + {label} +
    + {!segments.length && sin fallos} + {segments.map((s) => ( + + ))} +
    +
    + ); +} diff --git a/frontend/components/molecules/FailureStackBar/index.ts b/frontend/components/molecules/FailureStackBar/index.ts new file mode 100644 index 0000000..8524992 --- /dev/null +++ b/frontend/components/molecules/FailureStackBar/index.ts @@ -0,0 +1 @@ +export { FailureStackBar } from "./FailureStackBar"; diff --git a/frontend/components/molecules/FilterField/FilterField.module.css b/frontend/components/molecules/FilterField/FilterField.module.css new file mode 100644 index 0000000..f565154 --- /dev/null +++ b/frontend/components/molecules/FilterField/FilterField.module.css @@ -0,0 +1 @@ +.field { display: inline-flex; align-items: center; gap: 10px; } diff --git a/frontend/components/molecules/FilterField/FilterField.tsx b/frontend/components/molecules/FilterField/FilterField.tsx new file mode 100644 index 0000000..ec9a972 --- /dev/null +++ b/frontend/components/molecules/FilterField/FilterField.tsx @@ -0,0 +1,15 @@ +import { Select, Text, type SelectOption } from "../../atoms"; +import styles from "./FilterField.module.css"; + +/** Un filtro de la barra: etiqueta en versal y su selector. */ +export function FilterField({ label, value, options, onChange }: { + label: string; value: string; options: SelectOption[]; onChange: (v: string) => void; +}) { + return ( + + {label} +