@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,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
|
+
};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cron Store — Persistencia de jobs CRON en JSON.
|
|
5
|
+
*
|
|
6
|
+
* Almacena jobs en .planning/cron/jobs.json con escritura atómica.
|
|
7
|
+
* Cada job tiene: id, name, schedule, command, deliver, status, nextRun, lastRunAt.
|
|
8
|
+
*
|
|
9
|
+
* @module gateway/cron/store
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const { atomicWriteJSON } = require('../../hooks/lib/atomic-write');
|
|
16
|
+
const { parseSchedule, computeNextRun } = require('./jobs');
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Constantes
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const CRON_DIR = '.planning/cron';
|
|
23
|
+
const JOBS_FILE = 'jobs.json';
|
|
24
|
+
const LOG_FILE = 'log.json';
|
|
25
|
+
const AGENT_STATE_FILE = 'agent-state.json';
|
|
26
|
+
const MAX_LOG_ENTRIES = 500;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function ensureDir(baseDir) {
|
|
33
|
+
const dir = path.join(baseDir, CRON_DIR);
|
|
34
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function jobsPath(baseDir) {
|
|
39
|
+
return path.join(ensureDir(baseDir), JOBS_FILE);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function logPath(baseDir) {
|
|
43
|
+
return path.join(ensureDir(baseDir), LOG_FILE);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function agentStatePath(baseDir) {
|
|
47
|
+
return path.join(ensureDir(baseDir), AGENT_STATE_FILE);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// CRUD de jobs
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Carga todos los jobs.
|
|
56
|
+
* @param {string} baseDir
|
|
57
|
+
* @returns {object[]}
|
|
58
|
+
*/
|
|
59
|
+
function loadJobs(baseDir) {
|
|
60
|
+
const p = jobsPath(baseDir);
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(p)) return [];
|
|
63
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
64
|
+
} catch (_) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Persiste todos los jobs atómicamente.
|
|
71
|
+
* @param {string} baseDir
|
|
72
|
+
* @param {object[]} jobs
|
|
73
|
+
*/
|
|
74
|
+
function saveJobs(baseDir, jobs) {
|
|
75
|
+
atomicWriteJSON(jobsPath(baseDir), jobs);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Agrega un job nuevo.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} baseDir
|
|
82
|
+
* @param {object} opts
|
|
83
|
+
* @param {string} opts.name - Nombre descriptivo.
|
|
84
|
+
* @param {string} opts.schedule - Schedule string (ej: "every 2h", "0 9 * * 1-5").
|
|
85
|
+
* @param {string} opts.command - Comando a ejecutar.
|
|
86
|
+
* @param {string} [opts.deliver='local'] - Destino: "local", "telegram", "discord".
|
|
87
|
+
* @param {boolean} [opts.enabled=true]
|
|
88
|
+
* @returns {object} Job creado.
|
|
89
|
+
*/
|
|
90
|
+
function addJob(baseDir, opts) {
|
|
91
|
+
const jobs = loadJobs(baseDir);
|
|
92
|
+
|
|
93
|
+
const parsed = parseSchedule(opts.schedule);
|
|
94
|
+
const id = `cron-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
95
|
+
|
|
96
|
+
const job = {
|
|
97
|
+
id,
|
|
98
|
+
name: opts.name,
|
|
99
|
+
schedule: opts.schedule,
|
|
100
|
+
parsedSchedule: parsed,
|
|
101
|
+
command: opts.command,
|
|
102
|
+
deliver: opts.deliver || 'local',
|
|
103
|
+
status: opts.enabled !== false ? 'scheduled' : 'paused',
|
|
104
|
+
nextRun: null,
|
|
105
|
+
lastRunAt: null,
|
|
106
|
+
createdAt: new Date().toISOString(),
|
|
107
|
+
repeat: parsed.kind === 'once' ? { times: 1, completed: 0 } : null,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Calcular próxima ejecución
|
|
111
|
+
job.nextRun = computeNextRun(job);
|
|
112
|
+
|
|
113
|
+
jobs.push(job);
|
|
114
|
+
saveJobs(baseDir, jobs);
|
|
115
|
+
return job;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Elimina un job por ID.
|
|
120
|
+
* @param {string} baseDir
|
|
121
|
+
* @param {string} jobId
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
function removeJob(baseDir, jobId) {
|
|
125
|
+
const jobs = loadJobs(baseDir);
|
|
126
|
+
const idx = jobs.findIndex(j => j.id === jobId);
|
|
127
|
+
if (idx < 0) return false;
|
|
128
|
+
jobs.splice(idx, 1);
|
|
129
|
+
saveJobs(baseDir, jobs);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pausa un job.
|
|
135
|
+
* @param {string} baseDir
|
|
136
|
+
* @param {string} jobId
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
function pauseJob(baseDir, jobId) {
|
|
140
|
+
const jobs = loadJobs(baseDir);
|
|
141
|
+
const job = jobs.find(j => j.id === jobId);
|
|
142
|
+
if (!job) return false;
|
|
143
|
+
job.status = 'paused';
|
|
144
|
+
saveJobs(baseDir, jobs);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reanuda un job pausado.
|
|
150
|
+
* @param {string} baseDir
|
|
151
|
+
* @param {string} jobId
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
function resumeJob(baseDir, jobId) {
|
|
155
|
+
const jobs = loadJobs(baseDir);
|
|
156
|
+
const job = jobs.find(j => j.id === jobId);
|
|
157
|
+
if (!job) return false;
|
|
158
|
+
job.status = 'scheduled';
|
|
159
|
+
if (!job.nextRun) job.nextRun = computeNextRun(job);
|
|
160
|
+
saveJobs(baseDir, jobs);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Marca un job como ejecutado y calcula su próxima ejecución.
|
|
166
|
+
* @param {string} baseDir
|
|
167
|
+
* @param {string} jobId
|
|
168
|
+
* @param {object} [result={}] - Resultado de la ejecución.
|
|
169
|
+
*/
|
|
170
|
+
function markExecuted(baseDir, jobId, result = {}) {
|
|
171
|
+
const jobs = loadJobs(baseDir);
|
|
172
|
+
const job = jobs.find(j => j.id === jobId);
|
|
173
|
+
if (!job) return;
|
|
174
|
+
|
|
175
|
+
job.lastRunAt = new Date().toISOString();
|
|
176
|
+
|
|
177
|
+
// Actualizar repeticiones
|
|
178
|
+
if (job.repeat) {
|
|
179
|
+
job.repeat.completed = (job.repeat.completed || 0) + 1;
|
|
180
|
+
if (job.repeat.times !== null && job.repeat.completed >= job.repeat.times) {
|
|
181
|
+
job.status = 'completed';
|
|
182
|
+
job.nextRun = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Calcular próxima ejecución si aún está activo
|
|
187
|
+
if (job.status === 'scheduled') {
|
|
188
|
+
job.nextRun = computeNextRun(job);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
saveJobs(baseDir, jobs);
|
|
192
|
+
|
|
193
|
+
// Registrar en log
|
|
194
|
+
_appendLog(baseDir, {
|
|
195
|
+
jobId,
|
|
196
|
+
jobName: job.name,
|
|
197
|
+
executedAt: job.lastRunAt,
|
|
198
|
+
status: result.status || 'completed',
|
|
199
|
+
output: (result.output || '').substring(0, 1000),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Log de ejecuciones
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function _appendLog(baseDir, entry) {
|
|
208
|
+
const p = logPath(baseDir);
|
|
209
|
+
let log = [];
|
|
210
|
+
try {
|
|
211
|
+
if (fs.existsSync(p)) {
|
|
212
|
+
log = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
213
|
+
}
|
|
214
|
+
} catch (e) { /* log malformado — resetear */ }
|
|
215
|
+
|
|
216
|
+
log.push(entry);
|
|
217
|
+
|
|
218
|
+
// Mantener solo las últimas MAX_LOG_ENTRIES entradas
|
|
219
|
+
if (log.length > MAX_LOG_ENTRIES) {
|
|
220
|
+
log = log.slice(-MAX_LOG_ENTRIES);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
atomicWriteJSON(p, log);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Lee el log de ejecuciones.
|
|
228
|
+
* @param {string} baseDir
|
|
229
|
+
* @param {number} [limit=20]
|
|
230
|
+
* @returns {object[]}
|
|
231
|
+
*/
|
|
232
|
+
function readLog(baseDir, limit = 20) {
|
|
233
|
+
const p = logPath(baseDir);
|
|
234
|
+
try {
|
|
235
|
+
if (!fs.existsSync(p)) return [];
|
|
236
|
+
const log = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
237
|
+
return log.slice(-limit);
|
|
238
|
+
} catch (_) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// State per agent (portado de rowboat agent-schedule/state-repo.ts)
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Carga el estado histórico de un agente cron.
|
|
249
|
+
* Rastrea: lastRunAt, consecutiveErrors, runCount, lastError.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} baseDir
|
|
252
|
+
* @param {string} jobId
|
|
253
|
+
* @returns {{ lastRunAt: string|null, consecutiveErrors: number, runCount: number, lastError: string|null, lastStatus: string|null }}
|
|
254
|
+
*/
|
|
255
|
+
function getAgentState(baseDir, jobId) {
|
|
256
|
+
const p = agentStatePath(baseDir);
|
|
257
|
+
const DEFAULT = { lastRunAt: null, consecutiveErrors: 0, runCount: 0, lastError: null, lastStatus: null };
|
|
258
|
+
try {
|
|
259
|
+
if (!fs.existsSync(p)) return { ...DEFAULT };
|
|
260
|
+
const all = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
261
|
+
return all[jobId] ? { ...DEFAULT, ...all[jobId] } : { ...DEFAULT };
|
|
262
|
+
} catch (_) {
|
|
263
|
+
return { ...DEFAULT };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Actualiza el estado de un agente cron tras una ejecución.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} baseDir
|
|
271
|
+
* @param {string} jobId
|
|
272
|
+
* @param {{ status: 'completed'|'failed'|'skipped', error?: string }} result
|
|
273
|
+
*/
|
|
274
|
+
function updateAgentState(baseDir, jobId, result) {
|
|
275
|
+
const p = agentStatePath(baseDir);
|
|
276
|
+
let all = {};
|
|
277
|
+
try {
|
|
278
|
+
if (fs.existsSync(p)) all = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
279
|
+
} catch (e) { /* agent-state malformado — resetear */ }
|
|
280
|
+
|
|
281
|
+
const prev = all[jobId] || { lastRunAt: null, consecutiveErrors: 0, runCount: 0, lastError: null, lastStatus: null };
|
|
282
|
+
|
|
283
|
+
all[jobId] = {
|
|
284
|
+
lastRunAt: new Date().toISOString(),
|
|
285
|
+
runCount: (prev.runCount || 0) + 1,
|
|
286
|
+
lastStatus: result.status,
|
|
287
|
+
lastError: result.status === 'failed' ? (result.error || 'unknown error') : null,
|
|
288
|
+
consecutiveErrors: result.status === 'failed'
|
|
289
|
+
? (prev.consecutiveErrors || 0) + 1
|
|
290
|
+
: 0, // reset en éxito
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
atomicWriteJSON(p, all);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Lee el historial resumido de todos los agentes cron.
|
|
298
|
+
* Útil para diagnóstico: ver qué jobs tienen errores consecutivos.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} baseDir
|
|
301
|
+
* @returns {Array<{ jobId: string, jobName: string, state: object }>}
|
|
302
|
+
*/
|
|
303
|
+
function getAllAgentStates(baseDir) {
|
|
304
|
+
const p = agentStatePath(baseDir);
|
|
305
|
+
try {
|
|
306
|
+
if (!fs.existsSync(p)) return [];
|
|
307
|
+
const all = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
308
|
+
const jobs = loadJobs(baseDir);
|
|
309
|
+
return Object.entries(all).map(([jobId, state]) => {
|
|
310
|
+
const job = jobs.find(j => j.id === jobId);
|
|
311
|
+
return { jobId, jobName: job?.name || jobId, state };
|
|
312
|
+
});
|
|
313
|
+
} catch (_) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Exports
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
module.exports = {
|
|
323
|
+
loadJobs,
|
|
324
|
+
saveJobs,
|
|
325
|
+
addJob,
|
|
326
|
+
removeJob,
|
|
327
|
+
pauseJob,
|
|
328
|
+
resumeJob,
|
|
329
|
+
markExecuted,
|
|
330
|
+
readLog,
|
|
331
|
+
getAgentState,
|
|
332
|
+
updateAgentState,
|
|
333
|
+
getAllAgentStates,
|
|
334
|
+
CRON_DIR,
|
|
335
|
+
};
|