@saulwade/swl-ses 1.3.8 → 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 +108 -0
- 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 +20 -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/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 +34 -34
- 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,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
|
+
};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cron Scheduler — Tick loop para ejecución de jobs programados.
|
|
5
|
+
*
|
|
6
|
+
* Inspirado en Hermes Agent (cron/scheduler.py):
|
|
7
|
+
* - File lock exclusivo para prevenir ejecución concurrente
|
|
8
|
+
* - Tick cada 60 segundos
|
|
9
|
+
* - Grace windows para jobs retrasados
|
|
10
|
+
* - Entrega de resultados local o vía gateway
|
|
11
|
+
*
|
|
12
|
+
* Uso:
|
|
13
|
+
* node gateway/cron/scheduler.js [baseDir]
|
|
14
|
+
*
|
|
15
|
+
* También exporta funciones para uso programático desde otros módulos.
|
|
16
|
+
*
|
|
17
|
+
* @module gateway/cron/scheduler
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { execSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const { loadJobs, markExecuted, updateAgentState } = require('./store');
|
|
25
|
+
const { isEligible } = require('./jobs');
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Constantes
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Intervalo entre ticks en milisegundos. */
|
|
32
|
+
const TICK_INTERVAL_MS = 60000;
|
|
33
|
+
|
|
34
|
+
/** Nombre del archivo de lock. */
|
|
35
|
+
const LOCK_FILENAME = '.tick.lock';
|
|
36
|
+
|
|
37
|
+
/** Timeout de ejecución de un job individual (5 minutos). */
|
|
38
|
+
const JOB_TIMEOUT_MS = 300000;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// File lock (cross-platform)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
let _lockFd = null;
|
|
45
|
+
let _lockPath = null;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Adquiere un lock exclusivo basado en archivo.
|
|
49
|
+
* Previene múltiples instancias del scheduler ejecutándose simultáneamente.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} baseDir
|
|
52
|
+
* @returns {boolean} true si se adquirió el lock, false si ya está tomado.
|
|
53
|
+
*/
|
|
54
|
+
function acquireLock(baseDir) {
|
|
55
|
+
const cronDir = path.join(baseDir, '.planning', 'cron');
|
|
56
|
+
if (!fs.existsSync(cronDir)) fs.mkdirSync(cronDir, { recursive: true });
|
|
57
|
+
|
|
58
|
+
_lockPath = path.join(cronDir, LOCK_FILENAME);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// O_WRONLY | O_CREAT | O_EXCL — falla si el archivo ya existe
|
|
62
|
+
_lockFd = fs.openSync(_lockPath, 'wx');
|
|
63
|
+
fs.writeSync(_lockFd, `${process.pid}\n${new Date().toISOString()}\n`);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === 'EEXIST') {
|
|
67
|
+
// Lock ya existe — verificar si el proceso que lo creó sigue vivo
|
|
68
|
+
try {
|
|
69
|
+
const content = fs.readFileSync(_lockPath, 'utf8');
|
|
70
|
+
const pid = parseInt(content.split('\n')[0], 10);
|
|
71
|
+
if (pid && _isProcessAlive(pid)) {
|
|
72
|
+
return false; // Otro proceso lo tiene
|
|
73
|
+
}
|
|
74
|
+
// Proceso muerto — robar el lock
|
|
75
|
+
fs.unlinkSync(_lockPath);
|
|
76
|
+
return acquireLock(baseDir); // Reintentar
|
|
77
|
+
} catch (_) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Libera el lock.
|
|
87
|
+
*/
|
|
88
|
+
function releaseLock() {
|
|
89
|
+
if (_lockFd !== null) {
|
|
90
|
+
try { fs.closeSync(_lockFd); } catch (e) { /* fd ya cerrado */ }
|
|
91
|
+
_lockFd = null;
|
|
92
|
+
}
|
|
93
|
+
if (_lockPath) {
|
|
94
|
+
try { fs.unlinkSync(_lockPath); } catch (e) { /* lock ya eliminado */ }
|
|
95
|
+
_lockPath = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _isProcessAlive(pid) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(pid, 0); // Signal 0 = check existence
|
|
102
|
+
return true;
|
|
103
|
+
} catch (_) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Ejecución de jobs
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ejecuta un job individual.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} job - Job a ejecutar.
|
|
116
|
+
* @param {string} baseDir
|
|
117
|
+
* @returns {{ status: string, output: string }}
|
|
118
|
+
*/
|
|
119
|
+
function executeJob(job, baseDir) {
|
|
120
|
+
const command = job.command;
|
|
121
|
+
if (!command) {
|
|
122
|
+
return { status: 'error', output: 'Job sin comando definido' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const output = execSync(command, {
|
|
127
|
+
cwd: baseDir,
|
|
128
|
+
encoding: 'utf8',
|
|
129
|
+
timeout: JOB_TIMEOUT_MS,
|
|
130
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
131
|
+
env: {
|
|
132
|
+
...process.env,
|
|
133
|
+
SWL_CRON_JOB_ID: job.id,
|
|
134
|
+
SWL_CRON_JOB_NAME: job.name,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return { status: 'completed', output: output.trim() };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const output = err.stdout ? err.stdout.toString() : err.message;
|
|
141
|
+
const stderr = err.stderr ? err.stderr.toString() : '';
|
|
142
|
+
return {
|
|
143
|
+
status: 'error',
|
|
144
|
+
output: `${output}\n${stderr}`.trim().substring(0, 2000),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Entrega el resultado de un job al destino configurado.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} job - Job ejecutado.
|
|
153
|
+
* @param {object} result - Resultado de executeJob.
|
|
154
|
+
* @param {string} baseDir
|
|
155
|
+
*/
|
|
156
|
+
function deliverResult(job, result, baseDir) {
|
|
157
|
+
const deliver = job.deliver || 'local';
|
|
158
|
+
|
|
159
|
+
if (deliver === 'local') {
|
|
160
|
+
// Guardar en .planning/cron/output/{jobId}/
|
|
161
|
+
const outputDir = path.join(baseDir, '.planning', 'cron', 'output', job.id);
|
|
162
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
165
|
+
const filePath = path.join(outputDir, `${timestamp}.md`);
|
|
166
|
+
|
|
167
|
+
const content = [
|
|
168
|
+
`# Cron: ${job.name}`,
|
|
169
|
+
``,
|
|
170
|
+
`**Job ID**: ${job.id}`,
|
|
171
|
+
`**Ejecutado**: ${new Date().toISOString()}`,
|
|
172
|
+
`**Comando**: \`${job.command}\``,
|
|
173
|
+
`**Estado**: ${result.status}`,
|
|
174
|
+
``,
|
|
175
|
+
'## Output',
|
|
176
|
+
'```',
|
|
177
|
+
result.output || '(sin output)',
|
|
178
|
+
'```',
|
|
179
|
+
].join('\n');
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const { atomicWriteSync } = require('../../hooks/lib/atomic-write');
|
|
183
|
+
atomicWriteSync(filePath, content);
|
|
184
|
+
} catch (_) {
|
|
185
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Entrega vía gateway (telegram, discord, webhook)
|
|
191
|
+
// Escribir en agent-comms para que el gateway lo recoja
|
|
192
|
+
try {
|
|
193
|
+
const { enviarMensaje } = require('../../hooks/lib/agent-comms');
|
|
194
|
+
enviarMensaje(baseDir, {
|
|
195
|
+
type: 'gateway_notification',
|
|
196
|
+
from: 'cron-scheduler',
|
|
197
|
+
to: deliver,
|
|
198
|
+
payload: {
|
|
199
|
+
jobId: job.id,
|
|
200
|
+
jobName: job.name,
|
|
201
|
+
status: result.status,
|
|
202
|
+
output: result.output?.substring(0, 1000),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
} catch (_) {
|
|
206
|
+
// Si agent-comms falla, guardar localmente como fallback
|
|
207
|
+
deliverResult({ ...job, deliver: 'local' }, result, baseDir);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Tick
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Ejecuta un tick del scheduler: evalúa y ejecuta jobs elegibles.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} baseDir
|
|
219
|
+
* @returns {{ executed: number, errors: number }}
|
|
220
|
+
*/
|
|
221
|
+
function tick(baseDir) {
|
|
222
|
+
const jobs = loadJobs(baseDir);
|
|
223
|
+
const now = new Date();
|
|
224
|
+
let executed = 0;
|
|
225
|
+
let errors = 0;
|
|
226
|
+
|
|
227
|
+
for (const job of jobs) {
|
|
228
|
+
if (!isEligible(job, now)) continue;
|
|
229
|
+
|
|
230
|
+
const result = executeJob(job, baseDir);
|
|
231
|
+
markExecuted(baseDir, job.id, result);
|
|
232
|
+
updateAgentState(baseDir, job.id, result); // state-per-agent: consecutiveErrors, runCount
|
|
233
|
+
deliverResult(job, result, baseDir);
|
|
234
|
+
|
|
235
|
+
if (result.status === 'completed') {
|
|
236
|
+
executed++;
|
|
237
|
+
} else {
|
|
238
|
+
errors++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { executed, errors };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Scheduler loop
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
let _intervalId = null;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Inicia el scheduler loop.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} baseDir
|
|
255
|
+
* @returns {boolean} true si se inició, false si ya hay uno corriendo.
|
|
256
|
+
*/
|
|
257
|
+
function startScheduler(baseDir) {
|
|
258
|
+
if (!acquireLock(baseDir)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Registrar cleanup
|
|
263
|
+
const cleanup = () => {
|
|
264
|
+
stopScheduler();
|
|
265
|
+
process.exit(0);
|
|
266
|
+
};
|
|
267
|
+
process.on('SIGTERM', cleanup);
|
|
268
|
+
process.on('SIGINT', cleanup);
|
|
269
|
+
|
|
270
|
+
// Tick inicial
|
|
271
|
+
tick(baseDir);
|
|
272
|
+
|
|
273
|
+
// Loop
|
|
274
|
+
_intervalId = setInterval(() => tick(baseDir), TICK_INTERVAL_MS);
|
|
275
|
+
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Detiene el scheduler y libera el lock.
|
|
281
|
+
*/
|
|
282
|
+
function stopScheduler() {
|
|
283
|
+
if (_intervalId !== null) {
|
|
284
|
+
clearInterval(_intervalId);
|
|
285
|
+
_intervalId = null;
|
|
286
|
+
}
|
|
287
|
+
releaseLock();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Entrypoint CLI
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
if (require.main === module) {
|
|
295
|
+
const baseDir = process.argv[2] || process.cwd();
|
|
296
|
+
|
|
297
|
+
console.log(`[cron-scheduler] Iniciando en ${baseDir}`);
|
|
298
|
+
console.log(`[cron-scheduler] Tick interval: ${TICK_INTERVAL_MS / 1000}s`);
|
|
299
|
+
|
|
300
|
+
const started = startScheduler(baseDir);
|
|
301
|
+
if (!started) {
|
|
302
|
+
console.error('[cron-scheduler] Otro scheduler ya está corriendo. Saliendo.');
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log('[cron-scheduler] Scheduler activo. Ctrl+C para detener.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Exports
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
module.exports = {
|
|
314
|
+
tick,
|
|
315
|
+
startScheduler,
|
|
316
|
+
stopScheduler,
|
|
317
|
+
executeJob,
|
|
318
|
+
deliverResult,
|
|
319
|
+
acquireLock,
|
|
320
|
+
releaseLock,
|
|
321
|
+
TICK_INTERVAL_MS,
|
|
322
|
+
};
|