Registra marcas de asistencia de forma programada, sin intervención manual. Corre solo en Val.town.
- 🔐 Se autentica contra un proveedor de identidad (OIDC) y obtiene un token fresco en cada corrida.
- 🕒 Envía la marca al endpoint del sistema a la hora programada.
- 🎲 Hora humana, no robótica: en vez de marcar al segundo exacto, espera un tiempo aleatorio (jitter) antes de marcar, para que no caiga siempre igual.
- ♻️ Idempotente: antes de marcar consulta el estado del día. Si esa marca ya existe, no la repite (evita duplicados y errores de secuencia).
- 📧 Alerta por correo: si una corrida falla, envía un correo con el error.
- 🔒 Sin secretos en el código: todas las credenciales viven en Environment Variables, nunca en el código.
Cron dispara ─► espera aleatoria (jitter) ─► login OIDC (token fresco)
│ │
└───────────────────────────────────► ¿ya marcó hoy? ─sí─► fin
│no
▼
POST marca ✅
El token se pide después de la espera, porque los tokens duran pocos minutos. Así siempre es válido al momento de marcar.
Una carpeta por día laboral, y dentro las cuatro marcas del día. Cada archivo es un cron independiente (Val.town permite un solo horario por archivo).
Lunes/ → 4 marcas
Martes/ → 4 marcas
Miercoles/ → 4 marcas
Jueves/ → 4 marcas
Viernes/ → 4 marcas
Toda la lógica (login, chequeo de ausencia, idempotencia, POST y alerta por
correo) vive en lib/marcar.ts. Cada uno de los 20 archivos es solo un cron que
llama correrMarca(ACTION, FIELD) con su tipo de marca; lo único propio de cada
archivo es esa línea y su expresión cron. El horario varía por día de la semana;
sumado al jitter de segundos, la marca nunca cae exactamente a la misma hora.
lib/marcar.ts → lógica compartida por las 20 marcas
lib/ausencias.ts → chequeo de vacaciones/permisos
Se definen una sola vez y las comparten todos los archivos. Los valores son privados; aquí solo se documentan los nombres.
| Variable | Descripción |
|---|---|
API_BASE | Base URL del sistema (sin / final) |
KC_URL | Servidor del proveedor de identidad |
KC_REALM | Realm |
KC_CLIENT | Client ID |
KC_USER | Usuario |
KC_PASS | Contraseña |
EMPLOYEE_ID | Identificador interno |
FACE_DESCRIPTOR | Descriptor facial: array JSON de 128 números |
SUPERVISOR_TOKEN | Token que protege Supervisor/endpoint.ts (ver § Supervisor) |
HC_PING_URL | Dead man's switch de healthchecks.io (opcional) |
FACE_DESCRIPTOR: desde el 2026-09-04 el endpoint de marca exige verificación facial y rechaza el POST con"Esta marca requiere verificación facial"si no lo mandas. Es un dato biométrico, así que vive en Environment Variables y no en el código (este val es público). Para cambiarlo: abre el marcador en el navegador, copia elfaceDescriptordel payload que envía y pega el array completo (con corchetes) en la variable. Se valida al arrancar: si no son 128 números, la corrida falla con un mensaje claro en vez de un 400 del servidor.
Para que un día no se marque (vacaciones, incapacidad, permiso) no hay que
desactivar los 4 crons a mano. Basta con agregar un registro a la tabla
ausencias. Antes de marcar, cada cron consulta si el día de hoy (hora local
UTC−6) cae dentro de algún rango registrado; si es así, no marca y lo deja en el
log.
La lógica del chequeo vive en un solo lugar (lib/ausencias.ts) y la comparten
las 20 marcas.
| Columna | Tipo | Formato / valores | Descripción |
|---|---|---|---|
id | INTEGER PK AUTOINCREMENT | automático | Identificador |
fecha_inicio | TEXT NOT NULL | 'YYYY-MM-DD' | Primer día de la ausencia |
fecha_fin | TEXT NOT NULL | 'YYYY-MM-DD' | Último día, inclusive (día suelto = inicio) |
tipo | TEXT NOT NULL | 'vacacion' | 'incapacidad' | 'permiso' | Tipo de ausencia |
comentario | TEXT | texto libre (opcional) | Nota |
created_at | TEXT | autocompletado | Fecha/hora de creación (UTC) |
Las fechas se comparan como texto
'YYYY-MM-DD', que en ese formato ordena igual cronológicamente, así el chequeo es un simpleBETWEEN.
created_atse autocompleta solo: si lo omites usa elDEFAULT, y si lo dejas enNULLun trigger (ausencias_set_created_at) lo rellena con la hora actual.
Desde la consola SQL del val:
-- Un rango de vacaciones (una sola fila cubre todos los días):
INSERT INTO ausencias (fecha_inicio, fecha_fin, tipo, comentario)
VALUES ('2026-08-03', '2026-08-07', 'vacacion', 'Vacaciones de agosto');
-- Un día suelto (inicio = fin):
INSERT INTO ausencias (fecha_inicio, fecha_fin, tipo, comentario)
VALUES ('2026-08-15', '2026-08-15', 'permiso', 'Cita médica');
Para cancelar una ausencia, borra su fila (DELETE FROM ausencias WHERE id = ?).
Los 20 crons marcan; el supervisor verifica que se haya marcado y repara
si no. Existe porque los crons del plan gratuito son best effort: el
2026-09-07 el end_lunch no se ejecutó (cero traces, cero logs) y, al no haber
ejecución, tampoco hubo excepción ni correo de alerta.
Corre una vez por marca, a una hora fija posterior al último horario posible de esa marca en la semana. Sin jitter: no simula comportamiento humano, es una reparación que ya va tarde por definición.
| Marca | Objetivo (UTC) | Supervisor | Local | Límite |
|---|---|---|---|---|
start_shift | 13:57 | 0 14 * * 1-5 | 8:00 am | 40 min |
start_lunch | 19:02 | 5 19 * * 1-5 | 1:05 pm | 30 min |
end_lunch | 19:56 | 0 20 * * 1-5 | 2:00 pm | 20 min |
end_shift | 23:07 | 10 23 * * 1-5 | 5:10 pm | 45 min |
El objetivo es la corrida más tardía de la semana para esa marca; así bastan
4 constantes en lib/supervisor.ts en vez de duplicar los 20 horarios.
El límite es lo que impide reparaciones absurdas. Si la marca lleva más
retraso que su límite, el supervisor no marca y solo alerta: reponer un
end_lunch con hora y media de retraso crea un almuerzo de más de dos horas,
que dispara anomalías en las métricas. El end_lunch tiene el límite más corto
por eso mismo. Y si todavía no llegó la hora objetivo, tampoco marca: el
supervisor solo repara el pasado.
El plan gratuito permite 10 crons por val y este ya tiene 20 (creados antes
de que se aplicara el tope: siguen corriendo, pero no admiten uno más). Los
supervisores viven en el val elgmg/marcador_ECP_supervisor, que estrena su
propio cupo.
marcador_ECP_supervisor marcador_ECP
4 crons ──── HTTP + token ────► Supervisor/endpoint.ts
└─► lib/supervisor.ts
La lógica se ejecuta aquí, no allá: así las credenciales, el
FACE_DESCRIPTOR y la tabla ausencias (privada de cada val) no se duplican.
El val de supervisores no sabe nada del marcador, solo dispara. Los archivos
http no consumen cupo de crons.
SUPERVISOR_TOKEN protege el endpoint (es público) y debe ser idéntico en los
dos vals.
El correo solo avisa de lo que sí se ejecutó. El caso que dejó a la vista el
2026-09-07 es el contrario: no corrió nada, así que nadie avisó. Para eso está
HC_PING_URL (healthchecks.io): cada corrida del supervisor hace ping, y si el
ping no llega, healthchecks manda el aviso.
El ping sale desde este val, no desde el de crons, porque así cubre la cadena completa: tanto si Val Town no ejecuta el val supervisor como si este val no responde, el ping falta igual.
- Corrida normal →
POSTa la URL con una línea de detalle (end_lunch: verificada). - Corrida fallida →
POSTa<URL>/fail. - Sin
HC_PING_URLconfigurada, el ping se omite y todo lo demás funciona igual. El ping nunca lanza ni bloquea: tiene 5 s de timeout y su fallo solo se anota en el log.
Ojo al configurar el check: es un solo check para las 4 marcas, así que detecta que el supervisor dejó de correr, no cuál de los cuatro faltó. Y los crons son de lunes a viernes: un schedule de "1 día" daría falsa alarma cada sábado. Con un único check, lo razonable es modo cron
10 23 * * 1-5(el último supervisor del día) con una hora de gracia. Para cobertura por marca harían falta 4 checks.
Los logs de Val Town se retienen 3 días en el plan gratuito, así que esta tabla es el único histórico de cuántas veces la plataforma se salta un cron. Es el dato que hoy no existe y que haría falta para decidir si algún día conviene mover el marcador fuera de Val Town.
Se registran todas las corridas, no solo las incidencias: sin el total no hay denominador para calcular una tasa de fallo. Son 4 filas por día hábil.
| Columna | Contenido |
|---|---|
fecha | 'YYYY-MM-DD' local (UTC−6) |
hora_utc | Instante ISO de la corrida |
accion | start_shift | start_lunch | end_lunch | end_shift |
resultado | verificada | recuperada | fuera_de_ventana | aun_no_es_hora | ausencia | jornada_repuesta | error |
retraso_min | Minutos respecto a la hora objetivo |
detalle | Texto libre (máx. 500 caracteres) |
En verificada, retraso_min mide cuánto tardó realmente el cron de ese
día: es la medición continua de la puntualidad de la plataforma. Puede salir
negativo, porque el objetivo es el peor caso de la semana y no el horario de
hoy.
La tabla se crea sola (CREATE TABLE IF NOT EXISTS en la primera corrida del
worker), y si el INSERT falla solo se anota en el log: la bitácora nunca debe
impedir que una marca se recupere.
-- ¿Cuántas veces falló la plataforma este mes?
SELECT resultado, COUNT(*) FROM supervisor_eventos
WHERE fecha >= '2026-09-01' GROUP BY resultado;
-- Puntualidad de los crons de marca: retraso medio y peor caso
SELECT accion, ROUND(AVG(retraso_min), 1) AS medio, MAX(retraso_min) AS peor
FROM supervisor_eventos WHERE resultado = 'verificada' GROUP BY accion;
-- Todo lo que no fue rutina
SELECT * FROM supervisor_eventos
WHERE resultado NOT IN ('verificada', 'ausencia') ORDER BY id DESC;
| Situación | Qué hace |
|---|---|
| La marca está | Log ✅ verificada. Sin correo: si escribiera cada día, dejarías de leerlo |
| La misma situación se repite | Log 🔁 ya se avisó hoy. Máximo un correo por marca y desenlace al día: la bitácora es la memoria que lo impide |
| Falta, dentro de la ventana | La marca y avisa: 🛟 recuperada |
| Falta, fuera de ventana | No marca, avisa para que decidas |
| Aún no es hora | No hace nada |
| Falta la jornada completa | Repone start_shift y avisa: la otra marca no se puede encadenar (cooldown de 5 min del backend) |
| Rostro rechazado | Correo distinto: hay que renovar FACE_DESCRIPTOR, reintentar no sirve |
| En los logs verás… | Significa |
|---|---|
✅ … registrada | Marcó correctamente 🎉 |
↩️ Ya existe … hoy; no se marca | Ya estaba marcado; no duplicó (correcto) |
🌴 Ausencia hoy (…); no se marca | Hoy cae en un rango de la tabla ausencias (correcto) |
Login … falló … unauthorized_client | El proveedor no acepta ese tipo de login |
Marca … falló (HTTP 4xx) | Token o permisos incorrectos |
… requiere verificación facial | Falta o está mal FACE_DESCRIPTOR |
- Jitter: el código espera 0–50 segundos aleatorios antes de marcar. En el plan gratuito de Val.town cada corrida está limitada a ~1 minuto, por lo que la ventana de variación se mantiene dentro de ese margen.
- Orden secuencial: el sistema valida la secuencia de marcas del día; los horarios están configurados para respetarla.
- Zona horaria: Val.town corre en UTC; los crons ya están convertidos a la zona local (UTC−6).
Automatización personal. Usa credenciales y datos propios.