@saulwade/swl-ses 1.3.7 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +12 -4
- package/README.md +1 -1
- package/bin/swl-mcp-server.js +187 -187
- package/bin/swl-webhook-server.js +198 -0
- package/comandos/swl/.evolved.json +22 -22
- package/comandos/swl/adoptar-proyecto.md +21 -1
- package/comandos/swl/claudemd.md +14 -1
- package/comandos/swl/contribuir.md +233 -233
- package/comandos/swl/exportar-vault.md +207 -7
- package/comandos/swl/nuevo-proyecto.md +24 -2
- package/gateway/adapters/base.js +109 -0
- package/gateway/adapters/discord.js +167 -0
- package/gateway/adapters/email.js +221 -0
- package/gateway/adapters/slack.js +192 -0
- package/gateway/adapters/telegram.js +183 -0
- package/gateway/adapters/webhook.js +113 -0
- package/gateway/adapters/whatsapp.js +214 -0
- package/gateway/agent-executor.js +322 -0
- package/gateway/command-relay.js +271 -0
- package/gateway/cron/jobs.js +263 -0
- package/gateway/cron/scheduler.js +322 -0
- package/gateway/cron/store.js +335 -0
- package/gateway/index.js +320 -0
- package/gateway/lib/event-channel.js +191 -0
- package/gateway/session.js +131 -0
- package/gateway/webhook-server.js +324 -0
- package/habilidades/backend-production-resilience/SKILL.md +288 -288
- package/habilidades/benchmark-memoria/SKILL.md +186 -186
- package/habilidades/build-errors-nextjs/SKILL.md +55 -1
- package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
- package/habilidades/doubt-driven-review/SKILL.md +171 -171
- package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
- package/habilidades/eval-framework/SKILL.md +212 -212
- package/habilidades/extractor-de-aprendizajes/SKILL.md +24 -10
- package/habilidades/harness-claude-code/SKILL.md +299 -299
- package/habilidades/infra-github-actions/SKILL.md +166 -166
- package/habilidades/legacy-code-rescue/SKILL.md +267 -267
- package/habilidades/manejo-errores/.evolved.json +8 -8
- package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
- package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
- package/habilidades/nextjs-testing/SKILL.md +89 -5
- package/habilidades/node-experto/SKILL.md +37 -1
- package/habilidades/patrones-python/SKILL.md +229 -229
- package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
- package/habilidades/planear-fase/SKILL.md +319 -319
- package/habilidades/react-experto/SKILL.md +45 -4
- package/habilidades/release-semver/.evolved.json +8 -8
- package/habilidades/swl-claudemd/SKILL.md +15 -1
- package/habilidades/tdd-workflow/SKILL.md +36 -4
- package/habilidades/testing-python/SKILL.md +340 -340
- package/hooks/claudemd-bloat-detector.js +161 -161
- package/hooks/inyeccion-contexto.js +8 -3
- package/hooks/lib/agent-routing.js +107 -107
- package/hooks/lib/auto-consolidator.js +335 -335
- package/hooks/lib/error-classifier.js +308 -308
- package/hooks/lib/merkle-audit.js +96 -96
- package/hooks/lib/provenance-tracker.js +191 -191
- package/hooks/lib/rate-limit-ip.js +177 -0
- package/hooks/lib/rate-limit-tracker.js +253 -253
- package/hooks/lib/resource-quota.js +122 -122
- package/hooks/lib/retry-jitter.js +165 -165
- package/hooks/lib/skill-auditor.js +588 -588
- package/hooks/lib/sync-status.js +228 -228
- package/hooks/lib/taint-tracker.js +107 -107
- package/hooks/lib/text-similarity.js +241 -241
- package/hooks/lib/toon-compressor.js +245 -245
- package/hooks/lib/webhook-dedup.js +184 -0
- package/hooks/lib/webhook-verify.js +123 -0
- package/hooks/proteccion-rutas.js +120 -15
- package/hooks/registro-turnos.js +209 -209
- package/hooks/sugerir-regenerar-inventario.js +170 -170
- package/hooks/validar-formato-post-subagente.js +140 -140
- package/hooks/validar-memoria-hook.js +218 -218
- package/instintos/prompt-appendices.yaml +57 -57
- package/manifiestos/agent-output-schemas.json +57 -57
- package/manifiestos/modulos.json +1 -0
- package/manifiestos/skills-lock.json +37 -37
- package/package.json +5 -3
- package/plantillas/auditor-veto-template.md +105 -105
- package/plantillas/github-workflows/README.md +47 -47
- package/plantillas/github-workflows/release-please.yml +44 -44
- package/plantillas/github-workflows/swl-ci.yml +107 -107
- package/plantillas/github-workflows/swl-security.yml +51 -51
- package/plugin.json +1 -1
- package/reglas/analisis-previo-tareas-grandes.md +172 -172
- package/reglas/arreglar-al-detectar.md +147 -147
- package/reglas/fragmentos-compartidos.md +152 -152
- package/reglas/harness-claude-code.md +213 -213
- package/reglas/usar-context7.md +226 -226
- package/reglas/usar-sistema-swl.md +251 -0
- package/schemas/diary-entry.schema.json +80 -80
- package/scripts/benchmark-memoria.js +167 -167
- package/scripts/comandos/skills.js +251 -2
- package/scripts/configurar-branch-protection.js +418 -418
- package/scripts/detectar-aprendizajes-duplicados.js +151 -151
- package/scripts/field-report.js +199 -199
- package/scripts/generar-checklists-consolidados.js +273 -273
- package/scripts/generar-inventario.js +420 -420
- package/scripts/generar-matriz-lenguajes.js +271 -271
- package/scripts/lib/artefactos-python.js +43 -43
- package/scripts/lib/benchmark-metrics.js +160 -160
- package/scripts/lib/budget-enforcer.js +252 -252
- package/scripts/lib/configurar-ci.js +380 -380
- package/scripts/lib/contadores-inventario.js +217 -217
- package/scripts/lib/detectar-stack-detallado.js +307 -307
- package/scripts/lib/diary-entry.js +234 -234
- package/scripts/lib/eval-metrics-store.js +218 -218
- package/scripts/lib/eval-quality.js +171 -171
- package/scripts/lib/eval-schemas.js +144 -144
- package/scripts/lib/eval-self-correct.js +106 -106
- package/scripts/lib/eval-validator.js +185 -185
- package/scripts/lib/jaccard-similarity.js +98 -98
- package/scripts/lib/longmemeval-runner.js +125 -125
- package/scripts/lib/npm-version.js +261 -261
- package/scripts/lib/paquetes-conocidos.js +50 -50
- package/scripts/lib/prompt-builder.js +264 -264
- package/scripts/lib/rrf-fusion.js +175 -175
- package/scripts/lib/scoring-instintos.js +277 -277
- package/scripts/lib/semantic-search.js +252 -252
- package/scripts/limpiar-artefactos-python.js +131 -131
- package/scripts/mcp-server/README.md +128 -128
- package/scripts/mcp-server/handlers.js +206 -206
- package/scripts/migrar-csv-a-array.js +168 -168
- package/scripts/migrar-fase-dominio.js +201 -201
- package/scripts/publicar.js +511 -511
- package/scripts/run-eval.js +141 -141
- package/scripts/validar-manifest.js +195 -195
- package/scripts/validar-userland-vacio.js +110 -110
- package/scripts/verificar-release.js +110 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command Relay — Componente que recibe comandos entrantes desde cualquier
|
|
5
|
+
* adaptador del gateway (Telegram, Discord, Webhook, etc.) y los encola en
|
|
6
|
+
* `.planning/inbox/` para su procesamiento por Claude Code.
|
|
7
|
+
*
|
|
8
|
+
* Complementa a GatewayRunner._handleIncoming() añadiendo:
|
|
9
|
+
* - Whitelist estricta de usuarios (allowedUsers)
|
|
10
|
+
* - Whitelist de plataformas con relay habilitado
|
|
11
|
+
* - Sanitización de texto (previene payload injection)
|
|
12
|
+
* - Audit trail en .planning/inbox/audit.jsonl
|
|
13
|
+
* - Rate limiting por usuario (opcional)
|
|
14
|
+
* - Dedup por content hash en ventana corta
|
|
15
|
+
*
|
|
16
|
+
* Inspirado en Claude-Code-Remote (smart-injector.js) pero portable
|
|
17
|
+
* Windows/Linux/macOS: no depende de AppleScript ni tmux. El consumo se
|
|
18
|
+
* realiza vía el comando /swl:inbox o un daemon tmux opt-in.
|
|
19
|
+
*
|
|
20
|
+
* Uso (desde gateway/index.js):
|
|
21
|
+
* const relay = new CommandRelay(baseDir, config);
|
|
22
|
+
* relay.recibirComando(message);
|
|
23
|
+
*
|
|
24
|
+
* @module gateway/command-relay
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
const { atomicWriteJSON } = require('../hooks/lib/atomic-write');
|
|
31
|
+
const { EventChannel, EVENTS } = require('./lib/event-channel');
|
|
32
|
+
|
|
33
|
+
const INBOX_DIR = '.planning/inbox';
|
|
34
|
+
const AUDIT_FILE = 'audit.jsonl';
|
|
35
|
+
const MAX_TEXT_LEN = 4000;
|
|
36
|
+
const DEDUP_WINDOW_MS = 30 * 1000; // 30 segundos
|
|
37
|
+
|
|
38
|
+
// Patrones prohibidos en el texto (prevención de payload injection básica)
|
|
39
|
+
const PATRONES_PROHIBIDOS = [
|
|
40
|
+
/<\s*script/i,
|
|
41
|
+
/javascript:/i,
|
|
42
|
+
/data:text\/html/i,
|
|
43
|
+
// Referencias directas a archivos sensibles
|
|
44
|
+
/\.env\b/,
|
|
45
|
+
/id_rsa\b/,
|
|
46
|
+
/\.ssh\/\b/,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
class CommandRelay {
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} baseDir - Raíz del proyecto
|
|
52
|
+
* @param {object} config - Configuración del gateway (gateway-config.json)
|
|
53
|
+
*/
|
|
54
|
+
constructor(baseDir, config = {}) {
|
|
55
|
+
this.baseDir = baseDir;
|
|
56
|
+
this.relayConfig = config.relay || {};
|
|
57
|
+
this._dedupCache = new Map(); // hash → ts
|
|
58
|
+
this._rateCache = new Map(); // userId → { ts, count }
|
|
59
|
+
this.events = new EventChannel(); // pub/sub: cmd:received|rejected|queued|processed
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Suscribir a eventos del relay. Retorna función de unsubscribe.
|
|
64
|
+
* Tipos disponibles en EVENTS (gateway/lib/event-channel.js):
|
|
65
|
+
* 'cmd:received' | 'cmd:rejected' | 'cmd:queued' | 'cmd:processed' | '*'
|
|
66
|
+
*/
|
|
67
|
+
on(eventType, callback) {
|
|
68
|
+
return this.events.on(eventType, callback);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verifica si el relay está habilitado globalmente.
|
|
73
|
+
*/
|
|
74
|
+
habilitado() {
|
|
75
|
+
return this.relayConfig.enabled === true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Verifica si un usuario está autorizado para enviar comandos.
|
|
80
|
+
* @param {string} platform - nombre del adaptador (telegram, discord, etc.)
|
|
81
|
+
* @param {string} userId - ID del usuario en esa plataforma
|
|
82
|
+
*/
|
|
83
|
+
usuarioAutorizado(platform, userId) {
|
|
84
|
+
if (!this.habilitado()) return false;
|
|
85
|
+
const platforms = this.relayConfig.platforms || {};
|
|
86
|
+
const pconf = platforms[platform];
|
|
87
|
+
if (!pconf || pconf.enabled !== true) return false;
|
|
88
|
+
const allowed = pconf.allowedUsers || [];
|
|
89
|
+
if (allowed.length === 0) return false; // sin whitelist, bloquear
|
|
90
|
+
return allowed.includes(String(userId));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sanitiza un texto entrante. Retorna null si es inválido.
|
|
95
|
+
*/
|
|
96
|
+
sanitizar(texto) {
|
|
97
|
+
if (typeof texto !== 'string') return null;
|
|
98
|
+
const t = texto.trim();
|
|
99
|
+
if (!t) return null;
|
|
100
|
+
if (t.length > MAX_TEXT_LEN) return null;
|
|
101
|
+
for (const re of PATRONES_PROHIBIDOS) {
|
|
102
|
+
if (re.test(t)) return null;
|
|
103
|
+
}
|
|
104
|
+
return t;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Rate limit simple: máx N mensajes por usuario cada M segundos.
|
|
109
|
+
*/
|
|
110
|
+
dentroDeRateLimit(userId) {
|
|
111
|
+
const lim = this.relayConfig.rateLimit || { maxPerMinute: 10 };
|
|
112
|
+
const ventanaMs = 60 * 1000;
|
|
113
|
+
const ahora = Date.now();
|
|
114
|
+
const estado = this._rateCache.get(userId) || { resetAt: ahora + ventanaMs, count: 0 };
|
|
115
|
+
if (ahora > estado.resetAt) {
|
|
116
|
+
estado.resetAt = ahora + ventanaMs;
|
|
117
|
+
estado.count = 0;
|
|
118
|
+
}
|
|
119
|
+
estado.count += 1;
|
|
120
|
+
this._rateCache.set(userId, estado);
|
|
121
|
+
return estado.count <= (lim.maxPerMinute || 10);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Dedup por hash de contenido en ventana corta.
|
|
126
|
+
*/
|
|
127
|
+
esDuplicado(platform, userId, texto) {
|
|
128
|
+
const hash = crypto.createHash('sha1')
|
|
129
|
+
.update(`${platform}:${userId}:${texto}`)
|
|
130
|
+
.digest('hex');
|
|
131
|
+
const ahora = Date.now();
|
|
132
|
+
// Limpiar entradas viejas
|
|
133
|
+
for (const [h, ts] of this._dedupCache.entries()) {
|
|
134
|
+
if (ahora - ts > DEDUP_WINDOW_MS) this._dedupCache.delete(h);
|
|
135
|
+
}
|
|
136
|
+
if (this._dedupCache.has(hash)) return true;
|
|
137
|
+
this._dedupCache.set(hash, ahora);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Recibe un comando entrante y lo encola en .planning/inbox/ si pasa
|
|
143
|
+
* todas las validaciones. Retorna { success: boolean, reason?: string, id?: string }.
|
|
144
|
+
*/
|
|
145
|
+
recibirComando(message) {
|
|
146
|
+
if (!this.habilitado()) {
|
|
147
|
+
return { success: false, reason: 'relay-disabled' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const platform = message.platform || 'unknown';
|
|
151
|
+
const userId = String(message.userId || 'unknown');
|
|
152
|
+
const userName = message.userName || 'unknown';
|
|
153
|
+
|
|
154
|
+
this.events.emit({ type: EVENTS.CMD_RECEIVED, platform, userId, userName, textoPreview: (message.text || '').slice(0, 80) });
|
|
155
|
+
|
|
156
|
+
if (!this.usuarioAutorizado(platform, userId)) {
|
|
157
|
+
this._auditar({ platform, userId, userName, accion: 'rechazado', razon: 'usuario-no-autorizado', textoPreview: (message.text || '').slice(0, 80) });
|
|
158
|
+
this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'user-not-authorized' });
|
|
159
|
+
return { success: false, reason: 'user-not-authorized' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const texto = this.sanitizar(message.text || message.args || '');
|
|
163
|
+
if (!texto) {
|
|
164
|
+
this._auditar({ platform, userId, userName, accion: 'rechazado', razon: 'texto-invalido' });
|
|
165
|
+
this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'invalid-text' });
|
|
166
|
+
return { success: false, reason: 'invalid-text' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!this.dentroDeRateLimit(userId)) {
|
|
170
|
+
this._auditar({ platform, userId, userName, accion: 'rechazado', razon: 'rate-limit', textoPreview: texto.slice(0, 80) });
|
|
171
|
+
this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'rate-limit-exceeded' });
|
|
172
|
+
return { success: false, reason: 'rate-limit-exceeded' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (this.esDuplicado(platform, userId, texto)) {
|
|
176
|
+
this._auditar({ platform, userId, userName, accion: 'dedup', textoPreview: texto.slice(0, 80) });
|
|
177
|
+
this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'duplicate' });
|
|
178
|
+
return { success: false, reason: 'duplicate' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Encolar
|
|
182
|
+
const id = `cmd-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
183
|
+
const inboxDir = path.join(this.baseDir, INBOX_DIR);
|
|
184
|
+
if (!fs.existsSync(inboxDir)) fs.mkdirSync(inboxDir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
const cmd = {
|
|
187
|
+
id,
|
|
188
|
+
platform,
|
|
189
|
+
userId,
|
|
190
|
+
userName,
|
|
191
|
+
texto,
|
|
192
|
+
recibidoEn: new Date().toISOString(),
|
|
193
|
+
estado: 'pending',
|
|
194
|
+
chatId: message.chatId || null,
|
|
195
|
+
comando: message.command || null,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
atomicWriteJSON(path.join(inboxDir, `${id}.json`), cmd);
|
|
200
|
+
this._auditar({ platform, userId, userName, accion: 'encolado', id, textoPreview: texto.slice(0, 80) });
|
|
201
|
+
this.events.emit({ type: EVENTS.CMD_QUEUED, id, platform, userId, userName, textoPreview: texto.slice(0, 80) });
|
|
202
|
+
return { success: true, id };
|
|
203
|
+
} catch (err) {
|
|
204
|
+
this._auditar({ platform, userId, userName, accion: 'error-escritura', razon: err.message });
|
|
205
|
+
this.events.emit({ type: EVENTS.CMD_REJECTED, platform, userId, userName, reason: 'write-error', error: err.message });
|
|
206
|
+
return { success: false, reason: 'write-error' };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Lista comandos pendientes en el inbox.
|
|
212
|
+
* @param {object} [opts]
|
|
213
|
+
* @param {number} [opts.limit=20]
|
|
214
|
+
* @returns {Array<object>}
|
|
215
|
+
*/
|
|
216
|
+
listarPendientes(opts = {}) {
|
|
217
|
+
const limit = opts.limit || 20;
|
|
218
|
+
const inboxDir = path.join(this.baseDir, INBOX_DIR);
|
|
219
|
+
if (!fs.existsSync(inboxDir)) return [];
|
|
220
|
+
const archivos = fs.readdirSync(inboxDir)
|
|
221
|
+
.filter(f => f.startsWith('cmd-') && f.endsWith('.json'));
|
|
222
|
+
const items = [];
|
|
223
|
+
for (const a of archivos.sort()) {
|
|
224
|
+
try {
|
|
225
|
+
const obj = JSON.parse(fs.readFileSync(path.join(inboxDir, a), 'utf8'));
|
|
226
|
+
if (obj.estado === 'pending') items.push(obj);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
// Archivo malformado o concurrencia con marcarProcesado: ignorar este
|
|
229
|
+
// archivo y continuar con el resto del inbox. No bloquear la lista.
|
|
230
|
+
}
|
|
231
|
+
if (items.length >= limit) break;
|
|
232
|
+
}
|
|
233
|
+
return items;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Marca un comando como procesado.
|
|
238
|
+
*/
|
|
239
|
+
marcarProcesado(id, resultado = {}) {
|
|
240
|
+
const inboxDir = path.join(this.baseDir, INBOX_DIR);
|
|
241
|
+
const filePath = path.join(inboxDir, `${id}.json`);
|
|
242
|
+
if (!fs.existsSync(filePath)) return false;
|
|
243
|
+
try {
|
|
244
|
+
const obj = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
245
|
+
obj.estado = 'processed';
|
|
246
|
+
obj.procesadoEn = new Date().toISOString();
|
|
247
|
+
obj.resultado = resultado;
|
|
248
|
+
atomicWriteJSON(filePath, obj);
|
|
249
|
+
this._auditar({ platform: obj.platform, userId: obj.userId, accion: 'procesado', id });
|
|
250
|
+
this.events.emit({ type: EVENTS.CMD_PROCESSED, id, platform: obj.platform, userId: obj.userId, resultado });
|
|
251
|
+
return true;
|
|
252
|
+
} catch (_) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Escribe una entrada al audit trail del inbox.
|
|
259
|
+
* @private
|
|
260
|
+
*/
|
|
261
|
+
_auditar(entrada) {
|
|
262
|
+
try {
|
|
263
|
+
const inboxDir = path.join(this.baseDir, INBOX_DIR);
|
|
264
|
+
if (!fs.existsSync(inboxDir)) fs.mkdirSync(inboxDir, { recursive: true });
|
|
265
|
+
const linea = JSON.stringify({ ts: new Date().toISOString(), ...entrada }) + '\n';
|
|
266
|
+
fs.appendFileSync(path.join(inboxDir, AUDIT_FILE), linea);
|
|
267
|
+
} catch (_) { /* silencioso */ }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = CommandRelay;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cron Jobs — Parse de schedules y gestión de estado de jobs.
|
|
5
|
+
*
|
|
6
|
+
* Soporta 4 formatos de schedule (adoptados de Hermes Agent cron/jobs.py):
|
|
7
|
+
* 1. Duración única: "30m", "2h", "1d" → ejecutar una vez en N minutos
|
|
8
|
+
* 2. Intervalo recurrente: "every 30m", "every 2h" → ejecutar cada N minutos
|
|
9
|
+
* 3. Expresión cron: "0 9 * * 1-5" → cron estándar (5 campos)
|
|
10
|
+
* 4. Timestamp ISO: "2026-04-15T09:00" → una vez a hora exacta
|
|
11
|
+
*
|
|
12
|
+
* Grace windows (adoptados de Hermes):
|
|
13
|
+
* - Jobs one-shot: 120s de tolerancia para creación retrasada
|
|
14
|
+
* - Jobs recurrentes: grace = period/2, clamped [120s, 7200s]
|
|
15
|
+
*
|
|
16
|
+
* @module gateway/cron/jobs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Constantes
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const ONESHOT_GRACE_SECONDS = 120;
|
|
24
|
+
const MIN_GRACE_SECONDS = 120;
|
|
25
|
+
const MAX_GRACE_SECONDS = 7200; // 2 horas
|
|
26
|
+
|
|
27
|
+
/** Regex para parse de duraciones: "30m", "2h", "1d" */
|
|
28
|
+
const DURATION_RE = /^(\d+)\s*(m|min|h|hr|d)$/i;
|
|
29
|
+
|
|
30
|
+
/** Regex para intervalo recurrente: "every 30m", "every 2h" */
|
|
31
|
+
const INTERVAL_RE = /^every\s+(\d+)\s*(m|min|h|hr|d)$/i;
|
|
32
|
+
|
|
33
|
+
/** Regex para expresión cron: "0 9 * * 1-5" (5 campos) */
|
|
34
|
+
const CRON_RE = /^(\S+\s+){4}\S+$/;
|
|
35
|
+
|
|
36
|
+
/** Multiplicadores de duración a minutos */
|
|
37
|
+
const DURATION_MULTIPLIERS = {
|
|
38
|
+
m: 1, min: 1,
|
|
39
|
+
h: 60, hr: 60,
|
|
40
|
+
d: 1440,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Parse de schedules
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parsea un string de schedule en un objeto estructurado.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} schedule - String del schedule.
|
|
51
|
+
* @returns {{ kind: 'once'|'interval'|'cron', minutes?: number, runAt?: string, expr?: string }}
|
|
52
|
+
* @throws {Error} Si el formato no es reconocido.
|
|
53
|
+
*/
|
|
54
|
+
function parseSchedule(schedule) {
|
|
55
|
+
if (!schedule || typeof schedule !== 'string') {
|
|
56
|
+
throw new Error(`Schedule inválido: "${schedule}"`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const s = schedule.trim();
|
|
60
|
+
|
|
61
|
+
// 1. Intervalo recurrente: "every 30m"
|
|
62
|
+
const intervalMatch = s.match(INTERVAL_RE);
|
|
63
|
+
if (intervalMatch) {
|
|
64
|
+
const value = parseInt(intervalMatch[1], 10);
|
|
65
|
+
const unit = intervalMatch[2].toLowerCase();
|
|
66
|
+
const mult = DURATION_MULTIPLIERS[unit] || DURATION_MULTIPLIERS[unit.charAt(0)];
|
|
67
|
+
return { kind: 'interval', minutes: value * mult };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Duración única: "30m", "2h"
|
|
71
|
+
const durationMatch = s.match(DURATION_RE);
|
|
72
|
+
if (durationMatch) {
|
|
73
|
+
const value = parseInt(durationMatch[1], 10);
|
|
74
|
+
const unit = durationMatch[2].toLowerCase();
|
|
75
|
+
const mult = DURATION_MULTIPLIERS[unit] || DURATION_MULTIPLIERS[unit.charAt(0)];
|
|
76
|
+
const runAt = new Date(Date.now() + value * mult * 60000);
|
|
77
|
+
return { kind: 'once', minutes: value * mult, runAt: runAt.toISOString() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Timestamp ISO: "2026-04-15T09:00", "2026-04-15T09:00:00Z"
|
|
81
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) {
|
|
82
|
+
const dt = new Date(s);
|
|
83
|
+
if (isNaN(dt.getTime())) throw new Error(`Timestamp ISO inválido: "${s}"`);
|
|
84
|
+
return { kind: 'once', runAt: dt.toISOString() };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 4. Expresión cron: "0 9 * * 1-5"
|
|
88
|
+
if (CRON_RE.test(s)) {
|
|
89
|
+
return { kind: 'cron', expr: s };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
throw new Error(`Formato de schedule no reconocido: "${s}". Formatos válidos: "30m", "every 2h", "0 9 * * 1-5", "2026-04-15T09:00"`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Cálculo de próxima ejecución
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Calcula la próxima ejecución de un job.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} job - Objeto del job con schedule parseado y lastRunAt.
|
|
103
|
+
* @returns {string|null} ISO timestamp de la próxima ejecución, o null si completado.
|
|
104
|
+
*/
|
|
105
|
+
function computeNextRun(job) {
|
|
106
|
+
const schedule = job.parsedSchedule || parseSchedule(job.schedule);
|
|
107
|
+
const now = new Date();
|
|
108
|
+
|
|
109
|
+
switch (schedule.kind) {
|
|
110
|
+
case 'once': {
|
|
111
|
+
// Si ya se ejecutó, no hay próxima
|
|
112
|
+
if (job.lastRunAt) return null;
|
|
113
|
+
return schedule.runAt;
|
|
114
|
+
}
|
|
115
|
+
case 'interval': {
|
|
116
|
+
const intervalMs = schedule.minutes * 60000;
|
|
117
|
+
if (job.lastRunAt) {
|
|
118
|
+
return new Date(new Date(job.lastRunAt).getTime() + intervalMs).toISOString();
|
|
119
|
+
}
|
|
120
|
+
// Primera ejecución: ahora + intervalo
|
|
121
|
+
return new Date(now.getTime() + intervalMs).toISOString();
|
|
122
|
+
}
|
|
123
|
+
case 'cron': {
|
|
124
|
+
// Cálculo simplificado de next cron (sin dependencia croniter)
|
|
125
|
+
// Para cron complejo, usar computeNextCronRun()
|
|
126
|
+
return _computeSimpleCronNext(schedule.expr, now);
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Calcula grace seconds para un job según su periodicidad.
|
|
135
|
+
* Jobs más frecuentes tienen grace más corto; diarios más largo.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} job
|
|
138
|
+
* @returns {number} Segundos de tolerancia.
|
|
139
|
+
*/
|
|
140
|
+
function computeGraceSeconds(job) {
|
|
141
|
+
const schedule = job.parsedSchedule || parseSchedule(job.schedule);
|
|
142
|
+
|
|
143
|
+
if (schedule.kind === 'once') return ONESHOT_GRACE_SECONDS;
|
|
144
|
+
|
|
145
|
+
if (schedule.kind === 'interval') {
|
|
146
|
+
const periodSeconds = schedule.minutes * 60;
|
|
147
|
+
const grace = Math.floor(periodSeconds / 2);
|
|
148
|
+
return Math.max(MIN_GRACE_SECONDS, Math.min(grace, MAX_GRACE_SECONDS));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Cron: asumir grace de 2 minutos por defecto
|
|
152
|
+
return MIN_GRACE_SECONDS;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Verifica si un job es elegible para ejecución.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} job
|
|
159
|
+
* @param {Date} [now=new Date()]
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
function isEligible(job, now = new Date()) {
|
|
163
|
+
if (job.status !== 'scheduled') return false;
|
|
164
|
+
if (!job.nextRun) return false;
|
|
165
|
+
|
|
166
|
+
// Verificar límite de repeticiones
|
|
167
|
+
if (job.repeat && job.repeat.times !== null && job.repeat.times !== undefined) {
|
|
168
|
+
if ((job.repeat.completed || 0) >= job.repeat.times) return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const nextRunDate = new Date(job.nextRun);
|
|
172
|
+
const graceMs = computeGraceSeconds(job) * 1000;
|
|
173
|
+
|
|
174
|
+
// Elegible si: nextRun ya pasó pero dentro de la ventana de grace
|
|
175
|
+
// (now - grace) <= nextRun <= now
|
|
176
|
+
// Un job con nextRun más allá del grace se considera perdido (fast-forward)
|
|
177
|
+
const nextRunMs = nextRunDate.getTime();
|
|
178
|
+
const nowMs = now.getTime();
|
|
179
|
+
return nextRunMs <= nowMs && nextRunMs >= (nowMs - graceMs);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Cron simple (sin dependencia externa)
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Cálculo simplificado de próxima ejecución cron.
|
|
188
|
+
* Soporta: minuto, hora, día del mes, mes, día de la semana.
|
|
189
|
+
* Para expresiones complejas (rangos, listas), busca fuerza bruta en 48h.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} expr - Expresión cron de 5 campos.
|
|
192
|
+
* @param {Date} from - Fecha desde la cual buscar.
|
|
193
|
+
* @returns {string|null} ISO timestamp o null si no se encuentra en 48h.
|
|
194
|
+
*/
|
|
195
|
+
function _computeSimpleCronNext(expr, from) {
|
|
196
|
+
const fields = expr.split(/\s+/);
|
|
197
|
+
if (fields.length !== 5) return null;
|
|
198
|
+
|
|
199
|
+
const [minF, hourF, domF, monF, dowF] = fields;
|
|
200
|
+
|
|
201
|
+
// Buscar en los próximos 2880 minutos (48 horas)
|
|
202
|
+
const candidate = new Date(from);
|
|
203
|
+
candidate.setSeconds(0, 0);
|
|
204
|
+
candidate.setMinutes(candidate.getMinutes() + 1); // Empezar desde el próximo minuto
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < 2880; i++) {
|
|
207
|
+
if (_cronFieldMatches(minF, candidate.getMinutes()) &&
|
|
208
|
+
_cronFieldMatches(hourF, candidate.getHours()) &&
|
|
209
|
+
_cronFieldMatches(domF, candidate.getDate()) &&
|
|
210
|
+
_cronFieldMatches(monF, candidate.getMonth() + 1) &&
|
|
211
|
+
_cronFieldMatches(dowF, candidate.getDay())) {
|
|
212
|
+
return candidate.toISOString();
|
|
213
|
+
}
|
|
214
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null; // No encontrado en 48h
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Verifica si un valor coincide con un campo cron.
|
|
222
|
+
* Soporta: *, N, N-M, N/step, listas (N,M,O).
|
|
223
|
+
*/
|
|
224
|
+
function _cronFieldMatches(field, value) {
|
|
225
|
+
if (field === '*') return true;
|
|
226
|
+
|
|
227
|
+
// Lista: "1,3,5"
|
|
228
|
+
if (field.includes(',')) {
|
|
229
|
+
return field.split(',').some(f => _cronFieldMatches(f.trim(), value));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Step: "*/5" o "1-10/2"
|
|
233
|
+
if (field.includes('/')) {
|
|
234
|
+
const [range, step] = field.split('/');
|
|
235
|
+
const stepN = parseInt(step, 10);
|
|
236
|
+
if (range === '*') return value % stepN === 0;
|
|
237
|
+
const [start] = range.split('-').map(Number);
|
|
238
|
+
return value >= start && (value - start) % stepN === 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Rango: "1-5"
|
|
242
|
+
if (field.includes('-')) {
|
|
243
|
+
const [start, end] = field.split('-').map(Number);
|
|
244
|
+
return value >= start && value <= end;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Valor exacto
|
|
248
|
+
return parseInt(field, 10) === value;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Exports
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
parseSchedule,
|
|
257
|
+
computeNextRun,
|
|
258
|
+
computeGraceSeconds,
|
|
259
|
+
isEligible,
|
|
260
|
+
ONESHOT_GRACE_SECONDS,
|
|
261
|
+
MIN_GRACE_SECONDS,
|
|
262
|
+
MAX_GRACE_SECONDS,
|
|
263
|
+
};
|