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/lib/api/clients/ y los hooks en
frontend/lib/api/hooks/. Los tipos siguen en frontend/lib/supabase.ts, que es lo que
compara backend/tests/test_contrato.py contra las columnas de verdad.
| Base URL | {SUPABASE_URL}/rest/v1 |
| Lectura | cabeceras apikey: <ANON_KEY> y Authorization: Bearer <ANON_KEY>. 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).
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)(nullsi no existe)
[{
"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.
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)(nullsi no existe) - En curso (
getRunningEpisode()/useRunningEpisode()):?select=*&status=eq.running&order=started_at.desc&limit=1.nullsi no hay ninguno. Es lo único que enseña Live: no cae al último terminado. Con varios en curso, el más reciente.
[{
"id": "5c1e…-uuid",
"run_id": "0b6f…-uuid",
"seed": 1,
"task": "palletizing",
"level": 2,
"status": "failure",
"duration_s": 61.4,
"n_objects": 10,
"n_placed": 6,
"score": 0.6,
"failure": "stack_collapse",
"metrics": { "cog_offset_xy": 0.031, "score": 0.6, "n_misrouted": 0 },
"git_sha": "75f7187",
"oracle": false,
"synthetic": false,
"motion_speed": 1,
"cycle_time_s": 10.233,
"final_stability_m": -0.003,
"final_fill_ratio": 0.48,
"final_settle_drift_m": 0.041,
"mean_error_xy_m": 0.00412,
"n_layers": 3,
"load_height_m": 0.4
}]Notas: duration_s y score pueden ser null (episodio en curso). cycle_time_s,
final_*, mean_error_xy_m, n_layers y load_height_m son null si no hay
pallet_states / placements colocados. final_stability_m negativo = el montón vuelca.
metrics es libre (jsonb): en inducción lleva n_misrouted; en paletizado, cog_offset_xy,
etc. failure es el identificador crudo: la interfaz nunca lo muestra, usa failureText().
Resuelto:
v_episode_summaryya tienestarted_atyended_at. Se confirmó contra un Postgres real:?order=started_at.descdaba400 42703, y ese era el motivo de que Live se quedara en "todavía no hay episodios" siempre que no hubiera un episodiorunning. La vista de002_design.sqllos proyecta desde el commit que arregló esto, así que el front ordena la vista directamente (getRunningEpisode) sin el rodeo porGET /episodes. Lo vigilabackend/tests/test_contrato.py, que exige que toda columna usada en un.order()/.eq()exista en la relación de su.from().
?select=*&run_id=eq.{uuid}&order=n.desc → getFailureBreakdown(runId) / useFailureBreakdown(runId)
[
{ "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 [].
?select=*&episode_id=eq.{uuid}&order=seq.asc → getPlacements(id) / usePlacements(id)
[{
"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.
El yaw de la pose se dibuja: dims_m va en el eje del paquete, no en el del palé, así que
un paquete girado 90° ocupa ancho en X y largo en Y. Quien produce episodios no tiene que
premasticarlo —no hay que intercambiar dims_m—: basta con mandar el yaw de verdad.
?select=*&episode_id=eq.{uuid}&order=after_seq.asc → getPalletStates(id) / usePalletStates(id)
[{
"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.
?select=*&episode_id=eq.{uuid}&order=seq.asc → getEvents(id) / useEvents(id)
[{
"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.
Legibles con anon (lectura_publica) pero el front no las usa: lee las vistas. Columnas en AGENTS.md §6; la fila de runs lleva además config (jsonb) y n_episodes
(los episodios pedidos, no los reales).
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.
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.
Lo usa RunLog en backend/theker_telemetry/core.py. El front no debe llamarlos: con la anon devuelven 403.
Todo fallo remoto avisa una vez y el episodio sigue: el episodes.jsonl en disco es la fuente de verdad.
| Método y ruta | Cuerpo | Prefer |
Respuesta |
|---|---|---|---|
POST /runs |
[{ task, level, git_sha, oracle, motion_speed, label, n_episodes, config }] |
return=representation |
201 + [fila creada] (de ahí sale run_id) |
POST /episodes |
[{ run_id, seed, task, level, status, duration_s, n_objects, n_placed, score, failure, metrics }] |
return=representation |
201 + [fila creada] (de ahí sale episode_id) |
POST /placements |
lote de filas con episode_id |
return=minimal |
201 sin cuerpo |
POST /pallet_states |
ídem | return=minimal |
201 sin cuerpo |
POST /events |
ídem | return=minimal |
201 sin cuerpo |
PATCH /episodes?id=eq.{uuid} |
{ status, duration_s, n_placed, score, failure, metrics, ended_at } — y nada más: seed, task, level y n_objects se fijaron en begin() |
return=minimal |
204 sin cuerpo |
PATCH /runs?id=eq.{uuid} |
{ "ended_at": "now()" } |
return=minimal |
204 sin cuerpo |
Restricciones que hacen fallar la escritura: episodes.failure fuera del CHECK (23514), unique(run_id, seed),
unique(episode_id, seq) en placements/events, unique(episode_id, after_seq) en pallet_states.
episodes.status se calcula en el SDK: success o failure. git_sha/oracle no se guardan por episodio;
las vistas los recuperan del run.
jsonb libre que describe cómo estaba montado el banco. Lo manda RunLog(config={...}).
La clave que la interfaz necesita es pallet_size_m:
{ "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.
?select=*&episode_id=eq.{uuid}&order=after_seq.asc
[{ "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=<bytes>) sube el PNG y rellena url sola;
si ya tienes la URL, pásala y no sube nada.
Publicación supabase_realtime, canal postgres_changes sobre el esquema public. La pantalla Live
(app/page.tsx) es la única que lo usa y se queda con su suscripción: no pasa por TanStack Query.
| Tabla | Evento | Filtro | Payload (payload.new) |
|---|---|---|---|
events |
INSERT |
episode_id=eq.{id} |
fila de events (§2.6) |
pallet_states |
INSERT |
episode_id=eq.{id} |
fila de pallet_states (§2.5) |
episodes |
UPDATE |
id=eq.{id} |
fila de episodes en crudo, no de la vista: sin git_sha, oracle, ni columnas derivadas |
episodes |
INSERT |
(sin filtro) | fila en crudo. Es lo que hace que Live se entere al instante de que arranca un episodio (useRunningEpisodeRealtime, canal live-episodes): no usa el payload, vuelve a preguntar cuál está running. El sondeo de 5 s queda de red de seguridad |
snapshots |
INSERT |
episode_id=eq.{id} |
fila de snapshots (§3.2) |
Por eso, ante un UPDATE de episodes Live vuelve a consultar la vista en vez de usar el payload. Con el UPDATE que cierra un episodio, Live no lo retira al instante: lo mantiene 10 s con su resultado («Terminado») y solo entonces, si no ha arrancado otro, pasa a «No hay ninguna ejecución en directo».
La suscripción sin filtro es aparte y hace falta: las otras tres van filtradas al episodio que se está
enseñando, así que un episodio nuevo no despertaría a nadie y la pantalla se quedaría en el anterior
hasta recargar a mano. Se filtra por status = running en el cliente porque un backfill inserta cientos
de episodios ya terminados y no hay por qué recargar con cada uno.
Es un episodio, no una ejecución, y se elige en dos pasos:
- ¿Hay alguno con
status = running? → ese, en vivo. - Si no → el de
started_atmás reciente: el último guardado.
Ojo con el paso 2: episodes.started_at es default now(), así que para lo subido por backfill.py o por
el sembrado es la hora de inserción, no la de ejecución (los episodios de un mismo run backfilleado
comparten sello al segundo). Para lo que se produce con begin() sí es la hora real de arranque. Y tras
sembrar, lo sintético queda como "lo último" hasta que se mida algo nuevo: sale con su trama, pero
conviene saberlo antes de una demo.
El SDK sabe producir las tres. RunLog.episode() sigue subiendo el episodio ya terminado con sus
filas hijas de golpe, que es lo que quieren el backfill y el sembrado; para el modo en vivo hay un ciclo
de vida aparte:
| Llamada | Qué hace | Qué ve Live |
|---|---|---|
begin(seed, n_objects=…) |
POST /episodes con status: "running" |
aparece el episodio en curso |
event(...) / pallet_state(...) / placement(...) |
una fila suelta con su episode_id |
los INSERT de §4, según ocurren |
end(result) |
escribe el jsonl y hace el PATCH de arriba |
el UPDATE que cierra el episodio |
Quien produce episodios tiene que llamarlas, o Live seguirá enseñando el palé ya montado: sin una fila
running y sin UPDATE, dos de las tres suscripciones no se disparan nunca.
001_schema.sql crea las tablas, los índices, el RLS y la publicación de Realtime; las tres vistas las
define solo 002_design.sql; 003_snapshots.sql añade las fotos y su bucket. Se pegan en orden. 002 además añade
runs.description, runs.synthetic, pallet_states.settle_drift_m, seed_min/max, medianas, dominant_failure,
seed_series y las columnas derivadas de v_episode_summary. Con solo 001 aplicado, las respuestas de arriba
no tienen esos campos.