@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.
Files changed (129) hide show
  1. package/CLAUDE.md +12 -4
  2. package/README.md +1 -1
  3. package/bin/swl-mcp-server.js +187 -187
  4. package/bin/swl-webhook-server.js +198 -0
  5. package/comandos/swl/.evolved.json +22 -22
  6. package/comandos/swl/adoptar-proyecto.md +21 -1
  7. package/comandos/swl/claudemd.md +14 -1
  8. package/comandos/swl/contribuir.md +233 -233
  9. package/comandos/swl/exportar-vault.md +207 -7
  10. package/comandos/swl/nuevo-proyecto.md +24 -2
  11. package/gateway/adapters/base.js +109 -0
  12. package/gateway/adapters/discord.js +167 -0
  13. package/gateway/adapters/email.js +221 -0
  14. package/gateway/adapters/slack.js +192 -0
  15. package/gateway/adapters/telegram.js +183 -0
  16. package/gateway/adapters/webhook.js +113 -0
  17. package/gateway/adapters/whatsapp.js +214 -0
  18. package/gateway/agent-executor.js +322 -0
  19. package/gateway/command-relay.js +271 -0
  20. package/gateway/cron/jobs.js +263 -0
  21. package/gateway/cron/scheduler.js +322 -0
  22. package/gateway/cron/store.js +335 -0
  23. package/gateway/index.js +320 -0
  24. package/gateway/lib/event-channel.js +191 -0
  25. package/gateway/session.js +131 -0
  26. package/gateway/webhook-server.js +324 -0
  27. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  28. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  29. package/habilidades/build-errors-nextjs/SKILL.md +55 -1
  30. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  31. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  32. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  33. package/habilidades/eval-framework/SKILL.md +212 -212
  34. package/habilidades/extractor-de-aprendizajes/SKILL.md +24 -10
  35. package/habilidades/harness-claude-code/SKILL.md +299 -299
  36. package/habilidades/infra-github-actions/SKILL.md +166 -166
  37. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  38. package/habilidades/manejo-errores/.evolved.json +8 -8
  39. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  40. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  41. package/habilidades/nextjs-testing/SKILL.md +89 -5
  42. package/habilidades/node-experto/SKILL.md +37 -1
  43. package/habilidades/patrones-python/SKILL.md +229 -229
  44. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  45. package/habilidades/planear-fase/SKILL.md +319 -319
  46. package/habilidades/react-experto/SKILL.md +45 -4
  47. package/habilidades/release-semver/.evolved.json +8 -8
  48. package/habilidades/swl-claudemd/SKILL.md +15 -1
  49. package/habilidades/tdd-workflow/SKILL.md +36 -4
  50. package/habilidades/testing-python/SKILL.md +340 -340
  51. package/hooks/claudemd-bloat-detector.js +161 -161
  52. package/hooks/inyeccion-contexto.js +8 -3
  53. package/hooks/lib/agent-routing.js +107 -107
  54. package/hooks/lib/auto-consolidator.js +335 -335
  55. package/hooks/lib/error-classifier.js +308 -308
  56. package/hooks/lib/merkle-audit.js +96 -96
  57. package/hooks/lib/provenance-tracker.js +191 -191
  58. package/hooks/lib/rate-limit-ip.js +177 -0
  59. package/hooks/lib/rate-limit-tracker.js +253 -253
  60. package/hooks/lib/resource-quota.js +122 -122
  61. package/hooks/lib/retry-jitter.js +165 -165
  62. package/hooks/lib/skill-auditor.js +588 -588
  63. package/hooks/lib/sync-status.js +228 -228
  64. package/hooks/lib/taint-tracker.js +107 -107
  65. package/hooks/lib/text-similarity.js +241 -241
  66. package/hooks/lib/toon-compressor.js +245 -245
  67. package/hooks/lib/webhook-dedup.js +184 -0
  68. package/hooks/lib/webhook-verify.js +123 -0
  69. package/hooks/proteccion-rutas.js +120 -15
  70. package/hooks/registro-turnos.js +209 -209
  71. package/hooks/sugerir-regenerar-inventario.js +170 -170
  72. package/hooks/validar-formato-post-subagente.js +140 -140
  73. package/hooks/validar-memoria-hook.js +218 -218
  74. package/instintos/prompt-appendices.yaml +57 -57
  75. package/manifiestos/agent-output-schemas.json +57 -57
  76. package/manifiestos/modulos.json +1 -0
  77. package/manifiestos/skills-lock.json +37 -37
  78. package/package.json +5 -3
  79. package/plantillas/auditor-veto-template.md +105 -105
  80. package/plantillas/github-workflows/README.md +47 -47
  81. package/plantillas/github-workflows/release-please.yml +44 -44
  82. package/plantillas/github-workflows/swl-ci.yml +107 -107
  83. package/plantillas/github-workflows/swl-security.yml +51 -51
  84. package/plugin.json +1 -1
  85. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  86. package/reglas/arreglar-al-detectar.md +147 -147
  87. package/reglas/fragmentos-compartidos.md +152 -152
  88. package/reglas/harness-claude-code.md +213 -213
  89. package/reglas/usar-context7.md +226 -226
  90. package/reglas/usar-sistema-swl.md +251 -0
  91. package/schemas/diary-entry.schema.json +80 -80
  92. package/scripts/benchmark-memoria.js +167 -167
  93. package/scripts/comandos/skills.js +251 -2
  94. package/scripts/configurar-branch-protection.js +418 -418
  95. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  96. package/scripts/field-report.js +199 -199
  97. package/scripts/generar-checklists-consolidados.js +273 -273
  98. package/scripts/generar-inventario.js +420 -420
  99. package/scripts/generar-matriz-lenguajes.js +271 -271
  100. package/scripts/lib/artefactos-python.js +43 -43
  101. package/scripts/lib/benchmark-metrics.js +160 -160
  102. package/scripts/lib/budget-enforcer.js +252 -252
  103. package/scripts/lib/configurar-ci.js +380 -380
  104. package/scripts/lib/contadores-inventario.js +217 -217
  105. package/scripts/lib/detectar-stack-detallado.js +307 -307
  106. package/scripts/lib/diary-entry.js +234 -234
  107. package/scripts/lib/eval-metrics-store.js +218 -218
  108. package/scripts/lib/eval-quality.js +171 -171
  109. package/scripts/lib/eval-schemas.js +144 -144
  110. package/scripts/lib/eval-self-correct.js +106 -106
  111. package/scripts/lib/eval-validator.js +185 -185
  112. package/scripts/lib/jaccard-similarity.js +98 -98
  113. package/scripts/lib/longmemeval-runner.js +125 -125
  114. package/scripts/lib/npm-version.js +261 -261
  115. package/scripts/lib/paquetes-conocidos.js +50 -50
  116. package/scripts/lib/prompt-builder.js +264 -264
  117. package/scripts/lib/rrf-fusion.js +175 -175
  118. package/scripts/lib/scoring-instintos.js +277 -277
  119. package/scripts/lib/semantic-search.js +252 -252
  120. package/scripts/limpiar-artefactos-python.js +131 -131
  121. package/scripts/mcp-server/README.md +128 -128
  122. package/scripts/mcp-server/handlers.js +206 -206
  123. package/scripts/migrar-csv-a-array.js +168 -168
  124. package/scripts/migrar-fase-dominio.js +201 -201
  125. package/scripts/publicar.js +511 -511
  126. package/scripts/run-eval.js +141 -141
  127. package/scripts/validar-manifest.js +195 -195
  128. package/scripts/validar-userland-vacio.js +110 -110
  129. 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
+ };