Skip to content

La web se explica a quien no sabe biología, y deja de ofrecer lo que no funciona - #996

Merged
github-actions[bot] merged 22 commits into
developfrom
fix/sidebar-anonymous-rail
Oct 7, 2026
Merged

github-actions[bot] merged 22 commits into
developfrom
fix/sidebar-anonymous-rail

Conversation

@frapercan

Copy link
Copy Markdown
Owner

Lo que veía un visitante

Comprobado contra la API viva el 2026-10-07, sin cabeceras, como un anónimo:

POST /v1/annotate  ->  409
{"detail":"No annotation sets available. Load GO annotations first."}

Y ese cuerpo se le renderizaba literal, porque lib/api.ts:1002-1007 hace throw new Error(await res.text()) y AnnotateForm.tsx:171-174 lo pinta. Quien evaluaba el proyecto leía una instrucción para el operador de la base de datos.

No es transitorio: la base viva tiene cero filas en annotation_set, go_term, protein_go_annotation y embedding_config. No hay nada contra lo que predecir.

Cambios

1. lib/campaign.ts (nuevo). Constantes fechadas y a mano. La página no debe preguntarle a la API cuántos datos hay: ahora mismo toda cuenta que pueda contestar es cero o está en movimiento, y una cifra que cambia bajo el lector es peor que una que dice cuándo se tomó.

LIVE_ANNOTATION_AVAILABLE lleva escrito que se invierte en el mismo cambio que pueda enseñar un PredictionSet con filas, no un job en verde: con el pool de referencia vacío la operación corta por lo sano (predict_go_terms/_coordinator.py:169-172), no crea conjunto, y el formulario reintenta ocho veces y deja al visitante en una página vacía.

2. El formulario, en pausa, se explica y no toca la red. Antes sondeaba /jobs/gpu-availability cada 30 s para alimentar un banner de cola que desde el panel en pausa no se puede alcanzar.

3. La portada abre en lenguaje llano. La frase de la tesis era el h1, así que lo primero que leía un visitante era «a taxonomy of orthogonal evidence, combined by a calibrated fusion». Conserva todas sus palabras una sección más abajo, como h2.

La única cifra de resultado que la portada afirma es CAFA 6, puesto 19 de 2.186: externa, fechada, de un tercero, comprobable. El tablero LAFA no sube a la cabecera — viene de la campaña borrada el 2026-09-14 y, aparte del borrado, 594 de sus 1.296 resultados se evaluaron pasada la marca de holdout v227. Se queda dentro del argumento, que ahora lleva encima un aviso fechado.

No se edita una palabra de la prosa de la tesis. Afirma LAFA en presente y era cierto cuando se escribió: lo honesto es fechar el registro, no reescribir las frases del investigador.

4. El menú deja de ofrecer destinos que no existen o no abren.

defecto antes ahora
/instrument/graph listado dos veces en "results", misma etiqueta e icono una vez
/maintenance ofrecido a todos; el edge contesta 403 de texto plano minRole: "operator", oculto al anónimo
CTA de annotate /<locale>#annotate-form, y ese ancla no existe en la portada /<locale>/annotate

NavItem gana minRole, que refleja la tabla PROTECTED del middleware. El edge sigue siendo la puerta; esto sólo deja de anunciar un destino que la puerta va a cerrar.

Cifras, contadas

No redondeadas a ojo: 136 operaciones sobre 117 rutas leídas del OpenAPI vivo, y 4.495 funciones test_ en 293 ficheros contadas con AST. «Más de 140 endpoints» circulaba y es falso.

Tests

Los cuatro flujos e2e del formulario vivo se saltan desde la misma constante que lee el componente, así que el cambio que devuelva el predictor los devuelve con él, y hay uno nuevo del estado en pausa que comprueba que el texto del 409 no aparece.

En unitarios: 6 del menú, 4 del panel (incluido que no se hace ninguna petición) y 6 de paridad de catálogos. Comprobado que fallan contra el código anterior: 4 de los 6 del menú fallan, y los 2 que pasan en ambos estados son guardas deliberadas contra el arreglo excesivo (que /maintenance sí se ofrezca a un operador, y que las páginas públicas sigan visibles).

La paridad encontró de paso una clave ausente en chino, functionalAnnotation.resultsTab.tableHeaders.k, añadida aquí. Una clave que falta no rompe el build: next-intl renderiza la ruta de la clave, así que la página enseña home.annotatePausedTitle a quien lea ese idioma.

Lint: 27 errores en AnnotateForm.tsx antes y 27 después, todos no-explicit-any preexistentes.

Desplegado y verificado

Ya está en producción. El despliegue de esta máquina borra y recrea .next bajo el proceso que sirve, y el fallo no es un 500 honesto sino un 200 con HTML rancio y chunks a 404 que pasa los health checks (scripts/deploy-check.sh:4-11, el incidente del 2026-08-20). Así que: copia de respaldo del build, build, reinicio explícito y verificación.

BUILD_ID  6H8lvlJPeEIwjCKJFFgLQ -> ASdREPg3l1L3BtbkESsBH
deploy-check: cwd vivo, 18 assets a 200, API contestando
https://protea.ngrok.app/en/          h1 = "PROTEA predicts what a protein does..."
https://protea.ngrok.app/en/annotate  "The predictor is paused"
"Load GO annotations first"            0 apariciones en publico
href="/en/maintenance/"                0 apariciones
href="/en/instrument/graph/"           2 (un enlace x dos layouts; antes 4)

Tres defectos del rail, los tres visibles para un visitante anonimo de
protea.ngrok.app:

1. /instrument/graph estaba listado DOS VECES en el grupo "results",
   misma etiqueta y mismo icono, con dos hints distintos. El rail
   renderizaba dos filas de aspecto identico apuntando a una sola ruta.
   Se queda la primera posicion y el hint que NO afirma recuentos: el
   descartado decia "ten nodes" y "nine panels", numeros que nada aqui
   verifica.

2. /maintenance se ofrecia a todo el mundo, y el edge lo contesta con un
   403 de texto plano ("Forbidden: this page requires role 'operator'",
   middleware.ts). Un visitante que lo seguia aterrizaba en esa pagina
   desnuda. NavItem gana un campo minRole que refleja la tabla PROTECTED
   del middleware, y un filtro central descarta los enlaces que el rol
   del visitante no puede abrir, mas los grupos que queden vacios.

   El edge sigue siendo la puerta de verdad; esto solo deja de anunciar
   un destino que la puerta va a cerrar. El comentario en el campo dice
   que las dos listas hay que mantenerlas a la par.

3. El CTA de annotate apuntaba a `/<locale>#annotate-form` cuando el
   visitante estaba en la portada, y ese ancla NO existe alli:
   id="annotate-form" vive solo en la pagina de annotate. O sea que la
   llamada a la accion de la primera pantalla no llevaba a ningun sitio.
   Ahora va a /<locale>/annotate en todos los casos.

El filtro usa useRole, que devuelve "viewer" en SSR y antes de la
hidratacion, asi que el HTML del servidor es el rail anonimo y una fila
restringida no parpadea antes de leerse la cookie.

Los tests: cuatro fallan contra el codigo anterior (duplicado, grafo una
sola vez, maintenance oculto, CTA) y dos pasan en ambos estados a
proposito, porque son guardas contra el arreglo excesivo: que maintenance
SI se ofrezca a un operador, y que las paginas publicas del instrumento
sigan visibles para un anonimo. Comprobado quitando el cambio y
volviendolo a poner.

Un detalle del andamiaje que merece quedar escrito: el componente monta
el rail dos veces, escritorio y cajon movil, y el cajon se queda en el
DOM oculto por transform. Lleva aria-hidden cuando esta cerrado, asi que
en el arbol de accesibilidad hay exactamente uno y por eso una
comprobacion de duplicados sobre todo el rail significa algo.

No tocado: el error preexistente de react-hooks/set-state-in-effect en
Sidebar.tsx:295, que es de otro cambio.
Lo que veia un visitante de protea.ngrok.app esta semana, comprobado
contra la API viva el 2026-10-07 sin cabeceras, como un anonimo:

    POST /v1/annotate -> 409
    {"detail":"No annotation sets available. Load GO annotations first."}

y ese cuerpo se le renderizaba literal, porque lib/api.ts hace
`throw new Error(await res.text())` y AnnotateForm lo pinta. O sea que
quien evaluaba el proyecto leia una instruccion para el operador de la
base de datos. La base viva tiene cero filas en annotation_set, go_term,
protein_go_annotation y embedding_config, asi que no es un fallo
transitorio: no hay nada contra lo que predecir.

Tres cambios.

1. lib/campaign.ts, nuevo. Constantes fechadas y a mano, a proposito: la
   pagina NO debe preguntarle a la API cuantos datos hay, porque ahora
   mismo toda cuenta que pueda contestar es cero o esta en movimiento, y
   una cifra que cambia bajo el lector es peor que una que dice cuando se
   tomo. LIVE_ANNOTATION_AVAILABLE lleva escrito que se invierte en el
   mismo cambio que pueda ensenar un PredictionSet CON FILAS, no un job
   en verde: con el pool de referencia vacio la operacion corta por lo
   sano, no crea conjunto, y el formulario reintenta ocho veces y deja al
   visitante en una pagina vacia.

2. El formulario, en pausa, se explica en el idioma del lector y NO toca
   la red. Antes sondeaba /jobs/gpu-availability cada 30 s para alimentar
   un banner de cola que desde el panel en pausa no se puede alcanzar.

3. La portada abre en lenguaje llano. La frase de la tesis era el h1, asi
   que lo primero que leia un visitante era "a taxonomy of orthogonal
   evidence, combined by a calibrated fusion". Es la frase correcta para
   el argumento y la equivocada para una entrada: conserva todas sus
   palabras una seccion mas abajo, como h2.

   La unica cifra de resultado que la portada afirma es CAFA 6, puesto 19
   de 2.186 equipos: externa, fechada, de un tercero y comprobable por
   cualquiera. El tablero LAFA ("first in seven of nine cells") NO sube a
   la cabecera: viene de la campana que se borro el 2026-09-14 y, aparte
   del borrado, 594 de sus 1.296 resultados se habian evaluado pasada la
   marca de holdout v227. Se queda donde estaba, dentro del argumento,
   que ahora lleva encima un aviso fechado que lo enmarca.

   No se edita una palabra de la prosa de la tesis. Afirma LAFA en
   presente en varios sitios y era cierto cuando se escribio, asi que lo
   honesto es fechar el registro entero, no reescribir las frases del
   investigador.

Las cifras del bloque de bienvenida estan contadas, no redondeadas a
ojo: 136 operaciones sobre 117 rutas leidas del OpenAPI vivo, y 4.495
funciones test_ en 293 ficheros contadas con AST. "Mas de 140 endpoints"
circulaba y es falso.

Tests. Los cuatro flujos e2e del formulario vivo se saltan desde la misma
constante que lee el componente, asi que el cambio que devuelva el
predictor los devuelve con el; y hay uno nuevo del estado en pausa que
comprueba, entre otras cosas, que el texto del 409 NO aparece. En
unitarios, cuatro del panel (incluido que no se hace ninguna peticion) y
seis de paridad de catalogos.

Esa paridad encontro de paso una clave que faltaba en chino,
functionalAnnotation.resultsTab.tableHeaders.k, anadida aqui. Una clave
ausente no rompe el build: next-intl renderiza la ruta de la clave, asi
que la pagina ensena "home.annotatePausedTitle" a quien lea ese idioma.
Comprobado que el test lo detecta quitandola y volviendola a poner.

Lint: 27 errores antes en AnnotateForm.tsx y 27 despues, todos
no-explicit-any preexistentes. Los `as any` de las claves nuevas no
hacian falta y no estan.
@github-actions
github-actions Bot enabled auto-merge (squash) October 7, 2026 11:26
@codecov

codecov Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.79%. Comparing base (d8bc801) to head (09e4410).
⚠️ Report is 95 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop     #996      +/-   ##
===========================================
- Coverage    87.85%   87.79%   -0.06%     
===========================================
  Files          306      328      +22     
  Lines        23414    25287    +1873     
===========================================
+ Hits         20570    22201    +1631     
- Misses        2844     3086     +242     
Files with missing lines Coverage Δ
protea/api/routers/support.py 95.12% <100.00%> (ø)

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Tres correcciones a mi propio cambio anterior, las tres de Francisco
mirando la pagina.

1. EL BENCHMARK SE QUEDABA CARGANDO PARA SIEMPRE. Era un error de
   verdad, no una falta de datos. Con el corpus vacio el endpoint
   contesta perfectamente {"rows":[],"stages":[],...}, pero la pagina
   resolvia su `stage` de matrix.stages, asi que stage se quedaba en null
   y la puerta `if (!embeddings || !matrix || stage === null)` retenia la
   rama de carga indefinidamente. Lo curioso es que la pagina YA tenia
   estado vacio de verdad mas abajo (emptyKind, hasData); nunca llegaba.

   Una herramienta sin datos no es una herramienta cargando, y no es una
   herramienta rota, pero detras de un esqueleto las tres se ven igual.
   Ahora hay una rama explicita, ANTES de la de carga, que dice que esta
   construido y esperando.

2. ESCONDER EL FORMULARIO ESCONDIA LA HERRAMIENTA. En el cambio anterior
   sustitui el formulario de annotate por un aviso, y eso borraba de la
   vista algo que esta programado y funciona. Ahora el formulario SE VE,
   deshabilitado, con el aviso encima. Reutiliza el camino que el
   componente ya tenia para "cola saturada" en vez de inventar otro, y
   las dos causas conservan banners distintos porque dicen cosas
   distintas.

3. LA PORTADA SE VEIA MAL, Y ERA MIO. Puse `bg-[var(--muted)]` como
   fondo, y --muted es #57534E: un color de TEXTO de la familia stone, no
   una superficie. Encima el texto iba en --muted-foreground, que NO
   EXISTE como token. Era gris oscuro sobre gris oscuro. Y los botones
   rellenos de azul eran ajenos a una pagina editorial en serif que no
   usa ni uno.

   Rehecho en el lenguaje de la pagina: familia stone, cuerpo en serif, y
   el enlace subrayado que usa en todas partes.

   Tambien hacia que la tesis pareciera retenida en vez de publicada. El
   aviso ahora dice que el argumento se publica entero y contra que se
   midio, en vez de sugerir que esta bloqueado.

ENLACES EXTERNOS, los cinco comprobados con una peticion real antes de
escribirlos, porque un enlace muerto en una pagina que alguien esta
evaluando es peor que ningun enlace:

  kaggle.com/competitions/cafa-6-protein-function-prediction  200
  github.com/frapercan/cafaeval-protea                        200
  cafaeval-protea.readthedocs.io                              200
  github.com/claradepaolis/CAFA-evaluator-PK                  200
  biofunctionprediction.org/cafa                              200

La portada enlaza la competicion, nuestro despliegue del evaluador
oficial y el upstream del que parte, que es lo que hace que las cifras
las pueda regenerar un tercero. La validacion externa se explica como es:
el metodo se envia a LAFA como contenedor en ejecucion, no como un
fichero de predicciones.

NO HAY ENLACE A LAFA, deliberadamente. No encontre ninguna URL suya en el
repositorio y no me la invento. Si existe, la anado.

Test de regresion del benchmark: comprueba que con la respuesta vacia
EXACTA de la API viva la pagina dice que esta esperando y deja de
declararse aria-busy. Los tres fallan contra el codigo que tenia el
error. Y no se puede comprobar con curl: next-intl inyecta el catalogo de
mensajes entero en el HTML, asi que toda cadena aparece en el documento
se haya renderizado o no. Caí en eso antes de darme cuenta.

El test del formulario cambia de contrato con el codigo: ahora afirma que
el textarea ESTA y esta deshabilitado, y que todos los botones de envio
lo estan, en vez de que el formulario no exista.
Tres defectos visibles en cuanto estuvo en pantalla, mas el enlace que
faltaba.

1. LA MEDIDA ERA INCONSISTENTE. El h1 y la linea divisoria ocupaban el
   contenedor (max-w-3xl) mientras cada parrafo cortaba en max-w-prose,
   asi que el cuerpo se veia mas estrecho que todo lo que lo rodeaba y la
   columna bailaba. Una sola medida ahora, la del contenedor.

2. EL RESULTADO ESTABA ENTERRADO. "19 de 2.186 equipos en CAFA 6" iba de
   tercer parrafo, con menos peso visual que el recuadro de la campana
   que tenia debajo. Es la afirmacion mas fuerte y mas comprobable de la
   pagina: externa, fechada, puntuada por otros y verificable en un clic.
   Ahora encabeza, en su propio bloque con etiqueta, y la nota de la
   campana baja al final y se vuelve mas tenue.

3. LAS CIFRAS DE INGENIERIA ESTABAN DENTRO DE LA PROSA, que es donde no
   se pueden escanear. Son una fila de tres ahora: API REST, tests de
   backend, datos de anotacion leidos. Todas contadas, no estimadas, y
   PLATFORM lleva escrito como se contaron, porque son justo las cifras
   que un entrevistador comprueba.

Y el enlace que faltaba: LAFA es https://functionbench.net/ (titulo
comprobado: "CAFA Forever"). En el cambio anterior deje escrito que no lo
ponia porque no encontraba la URL en el repositorio y no me la inventaba;
Francisco la dio. Con ella son cuatro enlaces externos en la cabecera,
los cuatro con una peticion real detras antes de escribirlos: el banco al
que se envia el contenedor, la competicion, nuestro despliegue del
evaluador oficial y el upstream del que parte.
Dos cosas que Francisco vio en la pagina desplegada.

1. "LA THESIS ESTA BLOQUEADA". Lo estaba: GET /thesis.pdf contestaba 404
   en los tres sitios (API, frontend y la URL publica) con "thesis PDF
   not available", mientras la portada enlazaba a ella. Y el enlace lo
   habia puesto yo en la cabecera nueva sin probarlo, justo despues de
   escribir que un enlace muerto es peor que ninguno.

   La ruta estaba bien. Lo que faltaba era el fichero: el repositorio
   thesis/ tiene las fuentes LaTeX pero su unico PDF era un
   project_document, que es otro documento y no lo publico como si fuera
   la tesis. Construida con `make all` (212 paginas, titulo "PROTEA:
   PROtein funcTional Embedding-based Annotation") y colocada en
   static/thesis.pdf, que es la segunda ruta que mira el resolvedor y se
   lee en cada peticion, asi que la ruta quedo arreglada sin reiniciar la
   API. Ahora: 200 application/pdf, 1.114.070 bytes, por protea.ngrok.app.

   Anotado en el runbook de despliegue, incluido que es un artefacto de
   build: si cambian las fuentes nadie lo reconstruye, y un git clean se
   lo lleva.

2. EL TABLERO SELLADO ERA LO PRIMERO Y SE LEIA DESCONECTADO. El
   formulario de annotate vuelve a la portada, justo despues de la
   cabecera. No es una idea nueva: el CTA del menu apuntaba a
   `/<locale>#annotate-form` y ese ancla ya no existia alli, que es el
   fosil de este bloque habiendo vivido en la portada antes. Es lo que
   arregle esta manana sin saber que arreglaba el sintoma y no la causa.

   Y se lee mejor: quien no sabe lo que es una anotacion de proteina
   aprende mas de un formulario que de una tabla de cifras retiradas. El
   formulario se pinta deshabilitado, con su propia explicacion.

   Orden de la portada ahora: resultado externo, cifras de la plataforma,
   la herramienta, el argumento fechado, y el tablero mas abajo.
SUPPORT ESTABA ROTO DE LA PEOR MANERA: MINTIENDO.

POST /support exigia ROLE_VIEWER, y PROTEA_AUTHN_REQUIRED vale True por
defecto, asi que todo visitante anonimo que pulsaba "apoyar" recibia

    401 {"detail": "Missing API key or bearer token"}

Pero SupportButton.handleSubmit no miraba res.ok. Leia el cuerpo, hacia
setSubmitted(true) pasara lo que pasara, y le daba las gracias. O sea que
el visitante veia "gracias", el contador no se movia y no se guardaba
nada. Un boton que miente sobre haber escrito es peor que uno que se
niega.

Tres arreglos:

1. POST /support pasa a require_anon_quota, el mismo patron que
   /annotate: abierto a anonimos bajo cuota diaria por hash de IP. Es
   coherente con que GET /support sea publico y con que el boton viva en
   una pagina publica. Un pulgar arriba no es una escritura privilegiada:
   crea una fila con un comentario ya acotado por el validador, y nada lo
   lee de vuelta como instruccion. La cuota es el control que sustituye
   al rol.

2. El boton comprueba res.ok y, si falla, lo dice en el idioma del lector
   en vez de dar las gracias.

3. La pagina de apoyo enseñaba, con cero apoyos, un pulgar arriba de 60px
   sobre un "0" gigante y debajo "0 con comentarios · 0 anonimos". Una
   pagina cuya razon de ser es la prueba social, probando lo contrario.
   A cero, invita.

   De paso, su error de carga era un "Could not load support data." en
   rojo, en ingles y sin traducir, para cualquier idioma.

Verificado contra la API viva: POST anonimo devuelve 201. La fila que
creo esa comprobacion la he borrado, porque un apoyo inventado en una
pagina publica es una afirmacion falsa a los visitantes.

SPHINX DABA 404 EN LOS TRES SITIOS, igual que la tesis y por la misma
razon: la ruta existia, la documentacion no estaba construida. Y el menu
se la ofrecia a los anonimos, asi que era el tercer enlace muerto.

Construida (30 MB en docs/build/html, que esta gitignored). Aqui hay una
trampa que conviene dejar escrita: las dependencias de Sphinx NO estan en
el .venv del proyecto, y ese .venv es el que usan la API y el worker
vivos, asi que instalarlas ahi podia moverles jinja2 o pygments por
debajo. En vez de eso, un venv aparte enlazado al del proyecto con un
.pth: autodoc importa protea, pip escribe solo en la capa de arriba, y el
entorno vivo queda intacto. Comprobado despues: jinja2 y pygments sin
tocar, sphinx ausente del venv vivo.

El montaje de /sphinx se decide al crear la app, asi que hubo que
reiniciar la API. Se hizo en la ventana limpia: sin ningun job QUEUED ni
RUNNING, con el driver dentro de una descarga de ~1 h en la que no habla
con la API.

EL TABLERO SE REPETIA. "first in seven of nine" se leia tres veces: la
cursiva del pie, la frase de cabecera y el capitulo cero. La cursiva
estaba pegada a la frase que repetia, asi que se queda con la mitad que
no era repeticion, la que admite las dos celdas que no se ganan. Las dos
restantes viven en secciones distintas. No se toca la prosa de la tesis.
… rotas

Ocho agentes recorrieron las 14 paginas que el menu ofrece a un anonimo,
con la base vacia, fetcheando en vivo y leyendo el codigo. 79 hallazgos:
1 bloqueante, 22 graves. El bloqueante era Sphinx, arreglado mientras
corrian. De los graves, dos clases compartian una sola raiz.

1. "ApiError: {json}" EN PANTALLA, OCHO VECES. lib/api.ts aplicaba sus
   dos mensajes escritos para personas solo a las LECTURAS; una escritura
   caia al cuerpo crudo de la respuesta. Asi que un visitante que pulsaba
   "Launch Job" en /instrument/proteins?tab=insert leia, literal y
   durante tres segundos y medio:

     ApiError: {"type":"/problems/unauthorized","title":"Unauthorized",
                "status":401,"detail":"Missing API key or bearer token",
                "instance":"/api-proxy/jobs"}

   Dos arreglos en la raiz: los mensajes de 401 y 403 se aplican tambien
   a las escrituras, con su propia redaccion ("no se ha enviado" en vez
   de "se retuvo el dato"), y un problemDetail() saca la frase `detail`
   del documento RFC 9457 en lugar de enseñar el documento. Y un
   errorText() sustituye a String(err) en los 24 sitios que lo pintaban,
   porque String sobre un Error antepone el nombre de la clase.

2. LOS RECIBOS CITABAN ONCE FICHEROS Y NINGUNO EXISTE. La funcion
   caracteristica de los capitulos es tirar del marcador y ver "el recibo
   y la operacion que regenera el numero". Los once artefactos que nombra
   son de la campana borrada. En una pagina cuyo lema es que una
   afirmacion que no puedes regenerar no es una afirmacion, es el detalle
   mas dañino que podia estar mal.

   El tipo Receipt ya tenia el campo para esto, con su comentario
   exacto: "True when the plan names this receipt but it was absent from
   the storage snapshot". Dos recibos lo usaban y cuatro no. Ahora los
   seis avisan. No se toca la prosa.

3. LA MIGA DE PAN LLEVABA AL 404 PELADO DE NEXT. /en/pillar/1 pintaba
   "Home > pillar > 1" con el "pillar" enlazado, y /en/pillar no es una
   ruta: app/[locale]/pillar solo tiene un hijo [n]. El visitante
   aterrizaba en el 404 interno de Next, sin barra lateral, sin idioma y
   sin vuelta atras. Los segmentos que solo son espacio de nombres dejan
   de ser enlaces.

4. LA PAGINA DE PROTEINAS DECIA "NO PROTEINS FOUND" CON 745.421 EN LA
   BASE. Su flag de carga empezaba en false y la peticion solo arranca en
   un efecto, asi que el SSR y el pintado previo a la hidratacion tomaban
   la rama vacia: "0 proteins" y, dos veces, "No proteins found. Use the
   Insert Proteins tab to import from UniProt." La hermana de
   /instrument/annotations ya lo hacia bien, empezando sus flags en true.

   Al verificar el arreglo volvi a caer en la trampa de medicion de hoy:
   el texto seguia apareciendo en el HTML. Separando los <script> se ve
   que en el DOM real estaba a cero y lo que quedaba era el catalogo de
   mensajes que next-intl inyecta entero.

El test de contrato de lib/api afirmaba el comportamiento viejo (que un
401 en POST llevase el cuerpo crudo). Actualizado a lo que ahora
garantiza, mas dos guardas nuevas: que el documento problem+json NO se
filtre, y que errorText no anteponga el nombre de la clase.

Lint: 115 errores antes y 115 despues, todos preexistentes.
… solo

Hallazgos de una auditoria externa con navegador de verdad. Estos cuatro
son comprobables desde aqui, y los cuatro los introduje yo.

1. UNA CIFRA FALSA, EN LA PAGINA CUYO ARGUMENTO ES QUE SUS CIFRAS SE
   PUEDEN COMPROBAR. El texto decia que la campana "has read 75 releases
   ... 802.1 GB of files". 75 y 802,1 GB son el tamano de la SERIE; leidas
   van 60. Lo escribi yo esta manana, el mismo dia que me he pasado
   corrigiendo exageraciones ajenas.

   CAMPAIGN_STATUS separa ahora releasesRead de goaReleases, y las tres
   cadenas que lo dicen (portada, aviso de annotate y vacio del benchmark)
   pasan a "X de Y", en los cinco idiomas.

2. CADA VISITA A LA PORTADA SE DESCARGABA LA TESIS ENTERA. Mi QuietLink
   mandaba por next/link todo lo que no fuera externo, y next/link
   PREFETCHEA. /thesis.pdf no es una ruta de Next, lo sirve la API, asi
   que cada visita se traia 1.114.070 bytes de fondo. Los destinos con
   extension van ahora por un <a> normal.

3. LA IMAGEN DE VISTA PREVIA RESPONDIA 308. openGraph.images no estaba
   declarado, asi que Next la derivaba de app/[locale]/opengraph-image.tsx,
   y con trailingSlash: true esa URL contesta 308 antes de contestar 200.
   Los crawlers de vista previa no siempre siguen una redireccion en la
   imagen, y LinkedIn no podia generar la tarjeta.

   El PNG se captura una vez a public/og/ y se declara absoluto. Declararlo
   no bastaba: la convencion de fichero de Next gana, asi que el generador
   dinamico se retira (sigue en el historial, en 12b3c55, que es de donde
   se capturo el PNG). Ahora: 200 image/png al primer intento, cero
   redirecciones.

4. robots.txt emitia "Sitemap: /sitemap.xml". Tiene que ser absoluta; Next
   no la resuelve contra metadataBase.

LO QUE NO SE PUEDE ARREGLAR AQUI, y queda medido para no volver a
intentarlo: toda respuesta de protea.ngrok.app lleva
X-Robots-Tag: noindex, nofollow. No es del codigo (una peticion directa al
puerto 3000 no la trae) ni de la config local de ngrok. Probado con una
politica de trafico que anadia una cabecera de prueba y quitaba esa:

    x-protea-policy: aplicada        <- mi cabecera llego
    x-robots-tag: noindex, nofollow  <- sobrevivio al remove-headers

La politica se ejecuta; la cabecera se inyecta despues, en el borde de
ngrok. Es comportamiento de la plataforma en *.ngrok.app y bloquea las
vistas previas de LinkedIn. Solo lo arregla un dominio propio u otro
alojamiento. La maquinaria que escribi para intentarlo la he retirado,
para no dejar nada inerte.
La auditoria externa, que recorrio el sitio en los cinco idiomas, señalo
que escribi "hasta que termine esa corrida" en la portada en castellano.
En Espana "corrida" tiene un sentido vulgar, y la plaza a la que opta
Francisco es en Barcelona. Ahora dice "hasta que termine la
reconstruccion".

Con ella, otros calcos del ingles que venian de la misma mano o de antes:
"banco LAFA" (falso amigo de benchmark), "tablero LAFA" por tabla
cerrada, y tres cadenas viejas sin tildes: proteinas, automaticamente y
un "obtiene" donde iba un imperativo.
Cuatro cosas de la auditoria externa, con la decision de Francisco sobre
LAFA: se muestra, y se dice que el evaluador corre en local y que ahi es
donde se validan los resultados.

1. "being recomputed" SE LEIA EN INGLES EN LOS CUATRO IDIOMAS. El valor
   retirado se interpolaba con un literal ingles escrito a pelo en
   page.tsx, dentro de una frase traducida: "el resultado sellado es being
   recomputed". Ahora es una clave, traducida en los cinco.

2. CUATRO RECUENTOS DE PROTEINAS EN EL MISMO MINUTO (745.421, 715.602,
   713.318 y 754673). No se contradecian: eran cantidades distintas sin
   etiquetar. /v1/proteins/stats las separa, total = canonicas +
   isoformas, y esta pagina citaba una instantanea escrita a mano del
   total que ya se habia quedado vieja.

   La portada lo lee ahora en vivo con dos segundos de limite y la
   constante fechada como respaldo, y dice QUE cuenta: "entradas de
   proteina, canonicas mas isoformas". Es ademas lo unico vivo de verdad
   en la pagina, asi que es la cifra que mejor demuestra que hay algo
   funcionando detras.

3. EL NOMBRE NO ESTABA EN NINGUNA PARTE, mientras el aviso legal decia
   que el servicio se opera "personalmente" sin decir por quien. Es lo
   primero que busca quien evalua. Sacado del frontmatter de la tesis y
   del autor de git, no inventado, y enlazado al perfil. Mas un enlace al
   repositorio, que tampoco estaba en la portada.

   NO pongo el correo: publicar una direccion personal es decision suya,
   no de este fichero. Tampoco LinkedIn, que no tengo.

4. LA FRASE DE LAFA dice ahora exactamente que: el metodo se valida
   frente al benchmark enviando un contenedor en ejecucion; el evaluador
   oficial lo ejecutamos en nuestra propia maquina y ahi es donde se
   validan los resultados antes de afirmar nada; y es un fork con la
   puntuacion sin tocar. Antes decia "el evaluador oficial que
   desplegamos aqui" y en la misma frase "el evaluador del que parte", lo
   que la auditoria leyo, con razon, como una contradiccion.
… entrada

1. LA FIRMA, corregida por el propio Francisco: no "doctoral researcher"
   sino estudiante de doctorado, y la adscripcion es doble, Universidad de
   Sevilla y el Centro Andaluz de Biologia del Desarrollo (CSIC). Si se
   pone, se pone entera.

   Con enlace al ORCID ademas del de GitHub. El ORCID no lo escribi hasta
   tenerlo: el unico con esa forma en los repositorios era el marcador
   0000-0000-0000-0000, y un ORCID equivocado en una pagina publica
   apunta a otro investigador. Lo dio el, y el registro publico dice
   "Francisco M. Perez-Canales".

2. EL MURO LEGAL TAPABA EL h1 EN LOS PRIMEROS DIEZ SEGUNDOS, y en movil
   se abria a mitad de texto. Existe para recoger el consentimiento antes
   de enviar una secuencia, y con el predictor en pausa nadie puede
   enviar ninguna: hoy no protege nada. Atado a la misma constante que el
   formulario, asi que vuelve con el.

   El texto NO queda inalcanzable, que seria peor: se abre con ?policy=1
   y la portada lleva un enlace discreto "Terms and privacy".

3. EL MENU PASA DE 22 ENTRADAS A LAS QUE TIENEN ALGO QUE ENSENAR. Diez
   superficies se ocultan mientras el corpus este vacio: embeddings,
   anotaciones, query sets, scoring, reranker, datasets, feature
   registry, grafo, benchmark y evaluacion. No se borran ni dejan de
   funcionar; cuelgan de DATA_SURFACES_HAVE_DATA y vuelven con ella.

   Se quedan las que tienen algo: proteinas (754.862 filas), jobs (la
   campana en vivo) y stack (la arquitectura, que es a lo que viene un
   lector tecnico). Mas una entrada nueva al codigo, que no necesita
   datos y no estaba en ningun sitio.

   Dos de los tests del menu fallaron con este cambio, y era su trabajo:
   eran las guardas que escribi contra esconder de mas. Reescritos al
   contrato nuevo, y uno de ellos afirma ahora exactamente eso, que las
   superficies con datos NO desaparecen con la bandera.

4. Y una incoherencia que cree yo al ocultarlas: la portada seguia
   mandando a benchmark y al grafo desde sus llamadas a la accion.
   Ahora van al hub del instrumento y a la cola de trabajos, que es lo
   unico vivo de verdad.
A Francisco la anterior le parecia artificial, y lo era: fondo azul
marino con "PROTEA" en degradado morado y rosa, que no se parece a nada
de este sitio. La nueva usa los tokens de la propia pagina.

  fondo      #FAFAF9   --background
  texto      #0C0A09   --foreground
  antetitulo #1E40AF   --primary, en versales con tracking
  filete     #E7E5E4   --border, el mismo separador de la portada
  secundaria #57534E   --muted

Con el logo de verdad, /protea-mark.png, y el titular en Newsreader 400,
que es la serif que la portada usa en su h1. Debajo, la unica cifra
comprobable y la URL: "19th of 2,186 teams in CAFA 6 ·
protea.ngrok.app". Nada de LAFA ni de cifras retiradas, que en una
miniatura no se pueden matizar.

COMO SE HIZO, porque importa para la proxima. Se propuso capturarla con
Playwright, pero en esta maquina no hay navegadores instalados y bajarlos
son ~400 MB. No hacia falta: next/og renderiza sin navegador. Se
reconstruyo el generador con el diseno nuevo, leyendo el logo y las dos
tipografias de disco en runtime nodejs, se capturo su salida UNA vez, y
el generador se retiro otra vez.

Se retira a proposito: la convencion de fichero de Next gana sobre
metadata.openGraph.images, y con trailingSlash: true esa ruta contesta
308 antes que 200. Esa redireccion es justo lo que impedia a LinkedIn
dibujar la tarjeta. Lo que se publica es el PNG.

Los TTF se bajaron a un temporal y no quedan en el arbol, asi que el
build sigue sin tocar la red.

Verificado como LinkedInBot: 200 image/png, 1200x630, 48.170 bytes, cero
redirecciones al primer intento, y twitter:image apunta al mismo fichero.
En una pantalla de 1830 px el contenedor max-w-3xl dejaba unos 370 px de
hueco muerto a cada lado de una columna de 768. Francisco lo vio en
cuanto estuvo desplegada.

Ensanchar el texto habria sido el arreglo equivocado: unos 65 caracteres
es la medida que se lee bien, y el h1 y el argumento la conservan. Lo que
compra el espacio es un sitio DONDE PONER cosas.

Asi que la cabecera pasa a dos columnas desde xl: a la izquierda la
entrada en prosa, y a la derecha un rail con las tres cifras contadas,
los cinco enlaces de accion y la nota de la campana. Eso resuelve de paso
otra cosa que señalaba la auditoria externa: esas tres cifras y los
enlaces estaban por debajo del pliegue.

En movil y tableta no cambia nada, se apila como antes. Y cada seccion
por debajo de la cabecera queda anclada a max-w-3xl, para que la tesis,
el capitulo cero, el tablero y los pilares conserven su medida de lectura
en vez de estirarse a lo ancho del contenedor nuevo.
Segunda pasada del auditor externo. Lo primero que trae es un destrozo
mio: al sacar el literal ingles "being recomputed" a una clave traducida,
lo meti en una plantilla que tiene un hueco para un NUMERO, no para un
verbo. Las cuatro paginas traducidas quedaron asi:

  es  "el resultado sellado es recalculandose"
  de  "lautet das versiegelte Ergebnis wird neu berechnet bei f_micro_w"
  pt  "o resultado selado e sendo recalculado"
  zh  "封存结果为 正在重新计算(f_micro_w)"

El estado retenido necesitaba su propia frase, no un hueco. Ahora son dos
mensajes y cada uno esta escrito para ser gramatical en su idioma.

Lo demas de esta tanda, tambien mio:

- "frente al tabla cerrada de LAFA". Lo dejo un reemplazo a ciegas que
  hice ayer cambiando "tablero" por "tabla cerrada" sin mirar el articulo.

- CINCO RECUENTOS DE PROTEINAS donde deberia haber uno. La portada ya lo
  lee en vivo, pero el 745.421 estatico seguia en el aviso de annotate y
  en el vacio del benchmark. En vez de poner una peticion en cada sitio,
  esos mensajes dejan de citar el recuento: la cifra viva vive donde se
  lee viva, y los demas hablan de releases y GB, que no se mueven.

- "Annotation data read 802.1 GB" en la fila de cifras. Otra vez lo
  mismo: 802,1 GB es la serie entera, no lo leido. La etiqueta pasa a
  "Annotation series".

- "It placed 19 of 2,186" -> "19th". Y en castellano, "Quedo en el puesto
  19 de 2.186".

- UNA CONTRADICCION QUE METI AYER en el parrafo de LAFA: decia "validated
  externally" y en la frase siguiente "which is where these results are
  validated", hablando de nuestra propia maquina. Ahora separa las dos
  cosas, que es lo que Francisco describio: el metodo se ENVIA fuera,
  como contenedor en ejecucion; y antes de afirmar nada aqui,
  reproducimos la puntuacion nosotros con el evaluador desplegado en esta
  maquina.
He estado desplegando a produccion sin mirar CI. Cinco comprobaciones en
rojo, y las cuatro causas son mias.

1. UN BARRIDO DE SEGURIDAD CAZO EL CAMBIO DE /support.
   tests/test_endpoint_gating_sweep.py mantiene dos listas explicitas,
   _VIEWER_PATHS y _ANON_OPEN_PATHS, y esta construido para que abrir una
   escritura a anonimos tenga que ser un acto deliberado y escrito, no un
   efecto colateral. Funciono exactamente asi.

   /support se mueve de una lista a la otra con el motivo al lado: GET
   /support ya era publico, el boton vive en una pagina publica, la
   escritura crea una fila con un comentario ya acotado por su validador,
   nada la lee de vuelta como instruccion, y la cuota diaria por hash de
   IP es el control que sustituye al rol, igual que en /annotate.

   Y se le añade su afirmacion positiva: un test nuevo que comprueba que
   un anonimo NO es rechazado en la puerta. La ruta no deja de estar
   vigilada, cambia lo que se vigila.

2. docs/openapi.json no reflejaba el cambio de guard. Regenerado.

3. check_openapi_refs fallaba por una ruta fantasma que escribi yo: cite
   "GET /thesis.pdf" en el runbook, y esa puerta exige que toda ruta
   citada exista en el esquema. /thesis.pdf es un fichero montado, no una
   operacion. Reformulado.

4. UN FALLO LATENTE QUE DESTAPE AL CONSTRUIR LA TESIS.
   test_thesis_pdf_url_reflects_filesystem apuntaba la variable de
   entorno a un fichero inexistente y esperaba None. Pero por contrato el
   resolvedor NO devuelve None ahi: cae al siguiente candidato,
   <root>/static/thesis.pdf. El test pasaba solo porque nadie habia
   construido nunca la tesis; colocarla lo rompio.

   Ahora el caso ausente se le pregunta al resolvedor con una raiz que el
   test controla, que es lo que de verdad afirma el contrato.

Local: ruff y mypy limpios en el ambito de CI, las nueve puertas de lint
en verde y la suite entera con codigo de salida 0.
…hidratacion

1. CINCO BOTONES "LAUNCH JOB" QUE UN VISITANTE PODIA PULSAR. Las pestañas
   de insercion de proteinas y de carga de anotaciones se renderizaban
   enteras y prerrellenadas, con el boton azul vivo. POST /v1/jobs exige
   rol de operador, asi que el visitante recibia el documento de error
   del servidor por haber pulsado algo que la pagina le ofrecia.

   Los cinco quedan deshabilitados para quien no sea operador, con una
   frase al lado que dice por que. El formulario SIGUE A LA VISTA, que es
   la misma decision que con annotate: esconderlo esconderia una parte de
   la plataforma que existe y funciona. Lo restringido es el envio, no la
   existencia.

   El hook devuelve la respuesta anonima antes de la hidratacion, asi que
   el boton no parpadea habilitado para quien no puede usarlo.

2. EL BENCHMARK NO TENIA h1 EN NINGUNA DE LAS DOS RAMAS QUE UN VISITANTE
   VE HOY. El encabezado existia, pero por debajo de los dos retornos
   tempranos, asi que la pagina de carga y la de "esperando datos" no
   tenian encabezado de primer nivel. Las tres ramas lo comparten ahora,
   y de paso el titulo deja de ser una cadena inglesa a pelo.

3. REACT #418 EN DATASETS, Y LA CAUSA EXPLICA ALGO MAS.
   `toLocaleString()` sin locale, y `toLocaleString([])`, toman el del
   ENTORNO. Node resuelve aqui a es-ES / Europe/Madrid y el navegador del
   visitante resuelve al suyo, asi que el servidor pintaba "754.862" y un
   navegador en ingles pintaba "754,862". React se niega a reconciliar
   eso, y es ademas por lo que el separador de miles no coincidia entre
   paginas.

   Los dos ayudantes de datasets pasan a formateadores fijos, en en-GB y
   UTC. En UTC a proposito: una marca de tiempo en una pagina de
   operaciones vale mas si todos los lectores ven el mismo instante, y
   ahora lo dice.

   OJO, esto NO esta arreglado en el resto: quedan 71 llamadas a
   `toLocaleString()` sin argumentos y 12 con `[]`. Mismo defecto, misma
   causa. No barro los 71 sitios en el mismo cambio a estas horas y con
   la campana corriendo.
La auditoria externa conto nueve bloques sin traducir en cada uno de los
cuatro idiomas. No son todos la misma cosa, y mezclarlos habria sido un
error.

LO QUE SI ES CROMO, y pasa a traducirse:

- "carried" y "frontier" dentro de las nueve celdas. Son los VEREDICTOS
  de la tabla: un lector que no los entiende no entiende la figura
  entera. Ademas, en corchetes y en monoespaciado, "[frontier]" se lee
  como un marcador sin renderizar.
- La explicacion al pie, que decia de donde vienen las posiciones y por
  que no hay cifras detras. Era prosa inglesa incrustada en el
  componente.

LO QUE NO TRADUZCO, y digo por que: la tesis, el capitulo cero y las
cuatro tarjetas de pilares son 66 cadenas largas en lib/book.ts, y eso es
el argumento cientifico de Francisco. Traducirlo yo cambiaria
afirmaciones medidas en cuatro idiomas a la vez, y una afirmacion
cientifica mal traducida es peor que una en ingles.

Asi que se dice en voz alta: en los cuatro idiomas no ingleses, la
seccion del argumento lleva ahora una linea que avisa de que lo que sigue
se publica en el idioma en que se escribio. Quien entra en /es y se topa
con un muro de ingles merece saber por que, en vez de concluir que la
traduccion esta rota.

Si Francisco quiere la prosa traducida, es decision suya y se hace
aparte, con el tiempo que merece.
A 390 px la columna CC queda en x=475, fuera de pantalla. El contenedor
ya tenia overflow-x-auto, asi que se podia desplazar, pero no habia
ninguna señal de que hubiera algo mas a la derecha: la auditoria externa
lo leyo como un tablero de dos columnas en vez de tres.

Una linea de pista, visible solo por debajo de sm, que es donde el
desbordamiento ocurre de verdad. Traducida en los cinco idiomas, como el
resto del cromo del tablero.
Dos de las tres pegas de RGPD que levanto la auditoria externa, y que se
pueden arreglar sin inventar nada.

1. EL RESPONSABLE NO ESTABA IDENTIFICADO. El aviso decia que el servicio
   se opera "personalmente e independientemente" sin decir por quien, lo
   que es justo lo que un aviso de este tipo tiene que decir. Ahora lo
   nombra. El dato ya es publico en la propia portada, asi que no se
   publica nada nuevo.

2. NO SE MENCIONABA NGROK. Todo el trafico llega al servidor a traves de
   ngrok, un proveedor de Estados Unidos, que por tanto ve los metadatos
   de la conexion. Es un hecho comprobable con solo mirar el dominio, y
   callarlo en el apartado de privacidad era una omision.

LA TERCERA NO LA TOCO, y es la peor: las solicitudes de acceso, supresion
y rectificacion siguen yendo a una incidencia PUBLICA de GitHub, lo que
obliga a quien ejerce un derecho a publicar su peticion y a tener cuenta
en GitHub.

Cambiarla sin tener una direccion privada donde apuntarla la dejaria
peor, no mejor: un aviso de proteccion de datos sin via de contacto no
sirve de nada. Hace falta que Francisco decida que direccion se publica.
Yo no publico su correo por mi cuenta.
"critical user flows" fallaba, y era el unico rojo que quedaba:

    > 214 |  await expect(page.getByRole("textbox")).toHaveCount(0);
            Expected: 0
            9 x locator resolved to 1 element

Esa linea la escribi cuando el estado en pausa SUSTITUIA al formulario.
Francisco dijo despues que el formulario tenia que verse, porque
esconderlo escondia una herramienta que esta programada y funciona. Lo
cambie, actualice el test unitario... y me deje el e2e.

Ahora afirma lo que de verdad garantiza la pagina: la caja de secuencia
esta, y esta deshabilitada.

No lo detecte en local porque en esta maquina no hay navegadores de
Playwright instalados y bajarlos son unos 400 MB, cosa que dije al
cambiar el spec. CI es quien corre esa suite, y ha hecho exactamente su
trabajo.
El e2e de landing seguia en rojo, y esta vez sin excusa: el primer
auditor externo me listo estas tres aserciones esta mañana, antes de que
yo tocara nada, y no las arregle.

  - el h1 tenia que ser /Protein function is predictable/. Lo cambie a la
    frase llana y movi la tesis a un h2.
  - la cursiva del tablero tenia que decir "the two we are not, we
    explain". La reescribi para que dejara de repetir el puesto que la
    frase de al lado ya dice.
  - "Open the instrument" tenia que aterrizar en /benchmark. Lo cambie al
    hub, justamente porque el benchmark esta oculto del menu mientras no
    haya datos y mandar ahi la llamada principal contradecia la
    navegacion.

Los tres alineados con lo que la pagina hace hoy. Y el primero gana una
asercion que antes no existia: que la tesis SIGUE publicada, como h2. Sin
ella, el argumento podria desaparecer de la portada sin que ningun test
se quejara, que es exactamente el riesgo de haberlo movido.

Un test nuevo para lo que se añadio hoy y no vigilaba nadie: que el
nombre y el ORCID estan en la portada. Es lo primero que busca quien
evalua el proyecto y hasta hoy no estaba en ningun sitio.
Playwright se nego a elegir entre ellos ("strict mode violation") y al
contarlos resulto que el problema no era el test:

  /en/instrument/            el del rail, que anadi yo hoy
  /en/instrument/jobs/       el del aviso de annotate, que cambie hoy
  /en/instrument/benchmark/  el del pie, que ya estaba

Tres enlaces que leen "Open the instrument" y llevan a tres destinos. El
primer auditor externo ya lo habia señalado esta mañana; yo lo empeore
añadiendo el tercero.

Ahora los dos que comparten etiqueta comparten destino, el hub, que es
ademas lo coherente con que el benchmark este oculto del menu mientras no
haya datos. Y el de annotate, que apunta a la cola de trabajos, deja de
mentir sobre a donde va: dice "Watch the campaign run", en los cinco
idiomas.

Repetir la llamada a la accion arriba y abajo es normal en una portada.
Que digan lo mismo y lleven a sitios distintos no lo es.

El test fija ahora las dos cosas, el numero Y el destino de cada uno, en
vez de esconder la duplicacion detras de un .first() sin decirlo.
@github-actions
github-actions Bot merged commit 16834a1 into develop Oct 7, 2026
21 checks passed
github-actions Bot pushed a commit that referenced this pull request Oct 8, 2026
…997)

## Por qué

El consumer confirmaba el mensaje **antes** de ejecutar el job, para
esquivar el
`consumer_timeout` del broker. Eso tenía dos efectos medidos durante la
fase 2
de la campaña GOA:

1. **El segundo nodo se quedaba sin trabajo.** Con el ack inmediato el
canal
queda con 0 sin confirmar, así que RabbitMQ asigna el siguiente mensaje
al
mismo consumidor, que lo deja en su buffer local durante la hora que
dura la
   carga en curso. El sobremesa no veía nada que tomar.
2. **La conexión se moría en cada carga.** El `consumer_timeout` no era
el
temporizador que mandaba: lo era el heartbeat AMQP. `handle_job` bloquea
el
bucle de E/S de pika, pika deja de enviar heartbeats, y el broker cierra
a
los 2x600 s. Medido en el log del worker: 58 `StreamLostError` y 75
avisos de
`missed heartbeats` en el log del broker, uno por carga, a los ~20-26
min.

## Qué cambia

El job pasa a un hilo propio y el hilo de E/S se queda bombeando la
conexión
cada 5 s mientras dura (`_run_job_while_pumping`). El ack se mueve al
`finally`,
detrás del job, de modo que el mensaje sigue sin confirmar mientras se
ejecuta y
el prefetch de 1 reserva el nodo. Las dos mitades son inseparables:
mover el ack
sin bombear deja que el broker cierre la conexión igual, y bombear sin
mover el
ack no reparte trabajo.

Un fallo del worker sigue confirmando el mensaje, sin reencolar: eso no
cambia,
y los tests que lo fijaban siguen ahí con el nombre corregido
(`test_worker_failure_still_acks`). Se añaden dos guardas de orden:
`test_the_ack_waits_for_the_job` y
`test_a_failing_job_is_still_acked_last`.

## Medido en producción

Desplegado en el portátil el 2026-10-08 a las 15:49. En las 35 min
siguientes y
en la carga completa de la release 168 (63,6 min):

- `StreamLostError` nuevos: **0** (antes: uno por carga)
- `missed heartbeats` nuevos: **0** (antes: uno por carga)
- `messages_ready=0, messages_unacknowledged=1` — el nodo retiene su
mensaje
  durante toda la carga, que es el efecto buscado
- el log muestra `Job finished` y `Job acked` en el mismo segundo, y ya
no
  `Job acked` junto al `Dispatching`

La 168 tardó 63,6 min contra los 62,7 de la 166 en solitario: correr dos
nodos
no le cuesta nada al que ya iba.

## Lo otro que lleva

El commit de la deuda de formato (`toLocaleString` en 83 sitios, la
guarda
`noAmbientLocale` y `scripts/deploy-frontend.sh`) se quedó fuera de #996
porque
entró después del merge. Va aquí sin cambios.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant