@goodandready/dsh-cron 0.2.9 → 0.2.11

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/lib/scheduler.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { Cron } from 'croner';
2
+ import { exec } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
2
5
  import { applySilentRule } from './silent-rule.js';
3
6
  import { inspectFailure } from './failure-inspector.js';
4
7
 
@@ -165,6 +168,98 @@ export function parseScheduleExpression(input) {
165
168
  || parseCronExpression(str);
166
169
  }
167
170
 
171
+ /**
172
+ * Preview future execution timestamps for a cron or interval schedule.
173
+ */
174
+ export function previewSchedule(scheduleStr, options = {}) {
175
+ const str = String(scheduleStr || '').trim();
176
+ if (!str) throw new Error('Schedule expression must not be empty');
177
+ const parsed = parseScheduleExpression(str);
178
+ const count = Math.min(Math.max(Number(options.count) || 5, 1), 10);
179
+ const timezone = options.timezone || undefined;
180
+
181
+ if (parsed.isOneShot) {
182
+ return {
183
+ isOneShot: true,
184
+ humanText: parsed.humanText,
185
+ runs: [new Date(parsed.nextRun).toISOString()],
186
+ };
187
+ }
188
+
189
+ const job = new Cron(parsed.cronPattern, {
190
+ timezone,
191
+ sloppyRanges: true,
192
+ });
193
+ const runs = [];
194
+ let current = new Date();
195
+ for (let i = 0; i < count; i++) {
196
+ const next = job.nextRun(current);
197
+ if (!next) break;
198
+ runs.push(next.toISOString());
199
+ current = new Date(next.getTime() + 1000);
200
+ }
201
+ return {
202
+ isOneShot: false,
203
+ cronPattern: parsed.cronPattern,
204
+ humanText: describeCron(parsed.cronPattern),
205
+ runs,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Execute pre-flight check gate before running task body.
211
+ */
212
+ export async function executePreflight(task) {
213
+ const type = String(task.preflightType || 'none').toLowerCase();
214
+ const target = String(task.preflightTarget || '').trim();
215
+ if (!type || type === 'none' || !target) {
216
+ return { ok: true };
217
+ }
218
+
219
+ if (type === 'http') {
220
+ try {
221
+ const controller = new AbortController();
222
+ const timeout = setTimeout(() => controller.abort(), 5000);
223
+ const res = await fetch(target, { signal: controller.signal });
224
+ clearTimeout(timeout);
225
+ if (res.ok) return { ok: true };
226
+ return { ok: false, reason: `HTTP check returned status ${res.status}` };
227
+ } catch (err) {
228
+ return { ok: false, reason: `HTTP check failed: ${err.message}` };
229
+ }
230
+ }
231
+
232
+ if (type === 'command') {
233
+ return new Promise((resolve) => {
234
+ exec(target, { timeout: 5000 }, (err, stdout, stderr) => {
235
+ if (err) {
236
+ resolve({ ok: false, reason: `Preflight command failed (code ${err.code || 1}): ${(stderr || stdout || err.message).trim().slice(0, 150)}` });
237
+ } else {
238
+ resolve({ ok: true });
239
+ }
240
+ });
241
+ });
242
+ }
243
+
244
+ if (type === 'disk') {
245
+ const requiredMb = Number(target) || 100;
246
+ try {
247
+ if (typeof fs.statfsSync === 'function') {
248
+ const stats = fs.statfsSync(process.cwd());
249
+ const freeMb = Math.round((stats.bavail * stats.bsize) / (1024 * 1024));
250
+ if (freeMb < requiredMb) {
251
+ return { ok: false, reason: `Free disk space (${freeMb} MB) is below required ${requiredMb} MB` };
252
+ }
253
+ }
254
+ return { ok: true };
255
+ } catch {
256
+ return { ok: true }; // Fail-open
257
+ }
258
+ }
259
+
260
+ return { ok: true };
261
+ }
262
+
168
263
  export class TaskScheduler {
169
264
  constructor(store, executeFn, options = {}) {
170
265
  this.store = store;
@@ -178,6 +273,8 @@ export class TaskScheduler {
178
273
  this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
179
274
  this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
180
275
  this.runCounters = {}; // status -> finished runs since start (#53, /dsh-cron/metrics)
276
+ this.queue = []; // [{ taskId, priority, queuedAt, options }]
277
+ this.heartbeatTimer = null;
181
278
  }
182
279
 
183
280
  isRunning(taskId) {
@@ -201,6 +298,67 @@ export class TaskScheduler {
201
298
  }
202
299
  }
203
300
 
301
+ startHeartbeatWatcher(intervalMs = 30000) {
302
+ this.stopHeartbeatWatcher();
303
+ this.heartbeatTimer = setInterval(() => this.checkHeartbeats(), intervalMs);
304
+ if (this.heartbeatTimer && typeof this.heartbeatTimer.unref === 'function') {
305
+ this.heartbeatTimer.unref();
306
+ }
307
+ }
308
+
309
+ stopHeartbeatWatcher() {
310
+ if (this.heartbeatTimer) {
311
+ clearInterval(this.heartbeatTimer);
312
+ this.heartbeatTimer = null;
313
+ }
314
+ }
315
+
316
+ async checkHeartbeats() {
317
+ const now = Date.now();
318
+ const tasks = typeof this.store.list === 'function' ? this.store.list({ status: 'active' }) : Array.from(this.store.tasks.values());
319
+ for (const task of tasks) {
320
+ if (task.status !== 'active' || !task.heartbeatIntervalSeconds || task.heartbeatIntervalSeconds <= 0) {
321
+ continue;
322
+ }
323
+ const grace = task.gracePeriodSeconds !== undefined ? task.gracePeriodSeconds : 300;
324
+ const last = task.lastPingAt || task.createdAt || now;
325
+ const deadline = last + (task.heartbeatIntervalSeconds + grace) * 1000;
326
+
327
+ if (now > deadline && !task.heartbeatAlerted) {
328
+ task.heartbeatAlerted = true;
329
+ task.lastStatus = 'missed';
330
+ task.updatedAt = now;
331
+ this.store.save();
332
+
333
+ console.warn(`[dsh-cron] Heartbeat missed for "${task.title}" (${task.id})!`);
334
+ const runInfo = {
335
+ at: now,
336
+ status: 'missed',
337
+ durationMs: 0,
338
+ output: `Heartbeat missed: expected ping within ${task.heartbeatIntervalSeconds + grace}s, last ping was at ${new Date(last).toISOString()}`,
339
+ error: 'Heartbeat deadline exceeded',
340
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
341
+ costUsd: 0,
342
+ };
343
+
344
+ try {
345
+ this.store.recordRun(task.id, runInfo);
346
+ } catch {}
347
+
348
+ await this.deliverNotifications(task, runInfo);
349
+
350
+ if (task.onFailure && String(task.onFailure).trim()) {
351
+ const targetId = task.onFailure.trim();
352
+ this.runNow(targetId, {
353
+ chainDepth: 1,
354
+ prevOutput: runInfo.output,
355
+ prevTaskId: task.id,
356
+ }).catch((err) => console.warn(`[dsh-cron] heartbeat onFailure trigger error:`, err.message));
357
+ }
358
+ }
359
+ }
360
+ }
361
+
204
362
  start() {
205
363
  const tasks = this.store.list();
206
364
  const now = Date.now();
@@ -240,9 +398,11 @@ export class TaskScheduler {
240
398
  }
241
399
  this.scheduleTask(task);
242
400
  }
401
+ this.startHeartbeatWatcher();
243
402
  }
244
403
 
245
404
  stopAll() {
405
+ this.stopHeartbeatWatcher();
246
406
  for (const [id, job] of this.jobs.entries()) {
247
407
  try {
248
408
  job.stop();
@@ -347,6 +507,12 @@ export class TaskScheduler {
347
507
  // skipped and recorded; already-running tasks are never throttled against
348
508
  // themselves.
349
509
  if (!this.running.has(taskId) && this.maxConcurrent > 0 && this.running.size >= this.maxConcurrent) {
510
+ if (task.overlapPolicy === 'queue') {
511
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) queued by concurrency limit (${this.maxConcurrent})`);
512
+ this.queue.push({ taskId, priority: task.priority !== undefined ? task.priority : 5, queuedAt: Date.now() });
513
+ this.queue.sort((a, b) => (a.priority - b.priority) || (a.queuedAt - b.queuedAt));
514
+ return null;
515
+ }
350
516
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
351
517
  this.recordSkipped(taskId, `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`);
352
518
  return null;
@@ -403,7 +569,7 @@ export class TaskScheduler {
403
569
  }
404
570
 
405
571
  /** Run the task body once and normalise whatever the executor returned. */
406
- async executeOnce(task, signal) {
572
+ async executeOnce(task, signal, options = {}) {
407
573
  const result = {
408
574
  status: 'success',
409
575
  output: '',
@@ -416,7 +582,7 @@ export class TaskScheduler {
416
582
  };
417
583
  if (typeof this.executeFn !== 'function') return result;
418
584
  try {
419
- const res = await this.executeFn(task, { signal });
585
+ const res = await this.executeFn(task, { signal, ...options });
420
586
  if (typeof res === 'object' && res !== null) {
421
587
  result.output = res.output || '';
422
588
  result.usage = res.usage || result.usage;
@@ -444,8 +610,8 @@ export class TaskScheduler {
444
610
  * afterwards — and usage and cost of BOTH attempts are summed, because both
445
611
  * were really spent.
446
612
  */
447
- async executeWithFallback(task, signal) {
448
- const primary = await this.executeOnce(task, signal);
613
+ async executeWithFallback(task, signal, options = {}) {
614
+ const primary = await this.executeOnce(task, signal, options);
449
615
  if (!this.shouldUseFallback(task, primary)) return primary;
450
616
 
451
617
  const fallbackTask = {
@@ -454,7 +620,7 @@ export class TaskScheduler {
454
620
  model: task.fallbackModel,
455
621
  };
456
622
  console.log(`[dsh-cron] task "${task.title}" failed on ${task.model || 'the default model'}, retrying once on ${task.fallbackModel}`);
457
- const secondary = await this.executeOnce(fallbackTask, signal);
623
+ const secondary = await this.executeOnce(fallbackTask, signal, options);
458
624
  return {
459
625
  ...secondary,
460
626
  fallback: true,
@@ -472,23 +638,50 @@ export class TaskScheduler {
472
638
  }
473
639
 
474
640
 
475
- async runTask(taskId) {
641
+ async runTask(taskId, options = {}) {
476
642
  const task = this.store.get(taskId);
477
- if (!task) return;
643
+ if (!task) return null;
644
+
645
+ if (options.dryRun) {
646
+ const start = Date.now();
647
+ const outcome = await this.executeWithFallback(task, AbortSignal.timeout(Math.min((task.timeoutSeconds || 1800) * 1000, 30000)), options);
648
+ return {
649
+ ok: true,
650
+ dryRun: true,
651
+ status: outcome.status,
652
+ output: outcome.output,
653
+ error: outcome.error,
654
+ durationMs: Date.now() - start,
655
+ usage: outcome.usage,
656
+ costUsd: outcome.costUsd,
657
+ model: outcome.model || task.model || '',
658
+ };
659
+ }
478
660
 
479
661
  const currentRun = this.beginRun(task, taskId);
480
- if (currentRun === null) return;
662
+ if (currentRun === null) return null;
663
+
664
+ const preflight = await executePreflight(task);
665
+ if (!preflight.ok) {
666
+ console.log(`[dsh-cron] task "${task.title}" (${taskId}) skipped: ${preflight.reason}`);
667
+ this.running.delete(taskId);
668
+ this.recordSkipped(taskId, `Preflight check skipped: ${preflight.reason}`);
669
+ return null;
670
+ }
481
671
 
482
672
  const start = Date.now();
483
- const outcome = await this.executeWithFallback(task, currentRun.controller.signal);
484
- await this.completeRun(task, taskId, currentRun, outcome, start);
673
+ const outcome = await this.executeWithFallback(task, currentRun.controller.signal, options);
674
+ await this.completeRun(task, taskId, currentRun, outcome, start, options);
675
+ return outcome;
485
676
  }
486
677
 
487
678
  /**
488
679
  * Record the run, release the run slot and hand the result to delivery and
489
680
  * bookkeeping. Kept separate from runTask so the run flow reads in steps.
490
681
  */
491
- async completeRun(task, taskId, currentRun, outcome, start) {
682
+ async completeRun(task, taskId, currentRun, outcome, start, options = {}) {
683
+ const isSuccess = outcome.status === 'success';
684
+ const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
492
685
  const runInfo = {
493
686
  at: start,
494
687
  status: outcome.status,
@@ -506,6 +699,40 @@ export class TaskScheduler {
506
699
 
507
700
  // #43/#44 decide before the run is recorded, so the single history entry
508
701
  // already carries the diagnosis and the silence verdict.
702
+ // 3. Self-healing & Auto-diagnose (#139)
703
+ if (isFailure && task.selfHealingCommand) {
704
+ console.log(`[dsh-cron] running self-healing command for task "${task.title}": ${task.selfHealingCommand}`);
705
+ try {
706
+ await new Promise((resolve) => {
707
+ exec(task.selfHealingCommand, { timeout: 30000 }, (shErr, stdout, stderr) => {
708
+ runInfo.selfHealing = {
709
+ command: task.selfHealingCommand,
710
+ success: !shErr,
711
+ output: (stdout + (stderr ? '\n' + stderr : '')).trim().slice(0, 500),
712
+ };
713
+ resolve();
714
+ });
715
+ });
716
+ } catch (shErr) {
717
+ runInfo.selfHealing = { command: task.selfHealingCommand, success: false, error: shErr.message };
718
+ }
719
+ }
720
+
721
+ if (isFailure && task.autoDiagnose && typeof this.executeFn === 'function') {
722
+ try {
723
+ const diagRes = await this.executeFn({
724
+ type: 'llm',
725
+ prompt: `Diagnose this task failure in 1-2 concise sentences. Error: ${outcome.error || 'unknown'}. Recent output: ${(outcome.output || '').slice(-300)}`,
726
+ model: task.fallbackModel || task.model,
727
+ }, { signal: AbortSignal.timeout(10000) });
728
+ if (diagRes && diagRes.output) {
729
+ runInfo.autoDiagnosis = diagRes.output.trim();
730
+ }
731
+ } catch {
732
+ // Fail-open
733
+ }
734
+ }
735
+
509
736
  const silent = await this.applyModelAssists(task, runInfo);
510
737
 
511
738
  try {
@@ -522,6 +749,64 @@ export class TaskScheduler {
522
749
 
523
750
  if (!silent.skipped) await this.deliverNotifications(task, runInfo);
524
751
  this.finishRun(task, taskId, outcome.status, queuedCount);
752
+
753
+ if (this.queue.length > 0 && this.running.size < this.maxConcurrent) {
754
+ const nextItem = this.queue.shift();
755
+ if (nextItem) {
756
+ setImmediate(() => {
757
+ this.runTask(nextItem.taskId).catch(() => {});
758
+ });
759
+ }
760
+ }
761
+
762
+ // 1. Structured LLM Actions (#137)
763
+ if (task.type === 'llm' && outcome.status === 'success' && outcome.output) {
764
+ try {
765
+ const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
766
+ if (settings.llmActionsEnabled) {
767
+ const directives = parseLlmActionDirectives(outcome.output);
768
+ if (directives.length > 0) {
769
+ await executeLlmActionDirectives({
770
+ directives,
771
+ task,
772
+ runInfo,
773
+ scheduler: this,
774
+ store: this.store,
775
+ settings,
776
+ });
777
+ }
778
+ }
779
+ } catch (actErr) {
780
+ console.warn(`[dsh-cron] structured action error for ${taskId}:`, actErr.message);
781
+ }
782
+ }
783
+
784
+
785
+
786
+ // 2. Task Chaining (#137)
787
+ const nextTaskId = isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null);
788
+
789
+ if (nextTaskId && typeof nextTaskId === 'string' && nextTaskId.trim()) {
790
+ const currentDepth = options.chainDepth || 0;
791
+ if (currentDepth < 4) {
792
+ const targetId = nextTaskId.trim();
793
+ const nextTask = this.store.get(targetId);
794
+ if (nextTask) {
795
+ console.log(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
796
+ this.runNow(targetId, {
797
+ chainDepth: currentDepth + 1,
798
+ prevOutput: outcome.output || outcome.error || '',
799
+ prevTaskId: task.id,
800
+ }).catch(err => {
801
+ console.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
802
+ });
803
+ } else {
804
+ console.warn(`[dsh-cron] chained task target not found: ${targetId}`);
805
+ }
806
+ } else {
807
+ console.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id} -> ${nextTaskId}. Loop prevented.`);
808
+ }
809
+ }
525
810
  }
526
811
 
527
812
  /**
@@ -725,7 +1010,11 @@ export class TaskScheduler {
725
1010
  }
726
1011
  }
727
1012
 
728
- triggerManualRun(taskId) {
729
- return this.runTask(taskId);
1013
+ triggerManualRun(taskId, options = {}) {
1014
+ return this.runTask(taskId, options);
1015
+ }
1016
+
1017
+ runNow(taskId, options = {}) {
1018
+ return this.runTask(taskId, options);
730
1019
  }
731
1020
  }
package/lib/store.js CHANGED
@@ -98,6 +98,7 @@ export class TaskStore {
98
98
  base.onlyOnFailure = Boolean(base.onlyOnFailure);
99
99
  base.kanbanBaseUrl = base.kanbanBaseUrl || 'http://127.0.0.1:3000';
100
100
  base.hasDefaultCredentials = Boolean(defaults.botToken && defaults.chatId);
101
+ base.llmActionsEnabled = Boolean(base.llmActionsEnabled);
101
102
  return base;
102
103
  }
103
104
 
@@ -187,6 +188,7 @@ export class TaskStore {
187
188
  : (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
188
189
  notifyTelegram: patch.notifyTelegram !== undefined ? Boolean(patch.notifyTelegram) : Boolean(this.settings.notifyTelegram),
189
190
  onlyOnFailure: patch.onlyOnFailure !== undefined ? Boolean(patch.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
191
+ llmActionsEnabled: patch.llmActionsEnabled !== undefined ? Boolean(patch.llmActionsEnabled) : Boolean(this.settings.llmActionsEnabled),
190
192
  };
191
193
  this.save();
192
194
  return this.getSettings();
@@ -234,7 +236,19 @@ export class TaskStore {
234
236
  timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
235
237
  overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
236
238
  kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
239
+ onSuccess: task.onSuccess !== undefined ? String(task.onSuccess).trim() : (prev.onSuccess || ''),
240
+ onFailure: task.onFailure !== undefined ? String(task.onFailure).trim() : (prev.onFailure || ''),
237
241
  oneShot: task.oneShot !== undefined ? Boolean(task.oneShot) : Boolean(prev.oneShot),
242
+ heartbeatIntervalSeconds: task.heartbeatIntervalSeconds !== undefined ? Number(task.heartbeatIntervalSeconds) : (prev.heartbeatIntervalSeconds || 0),
243
+ gracePeriodSeconds: task.gracePeriodSeconds !== undefined ? Number(task.gracePeriodSeconds) : (prev.gracePeriodSeconds || 300),
244
+ lastPingAt: task.lastPingAt !== undefined ? task.lastPingAt : (prev.lastPingAt || null),
245
+ heartbeatAlerted: task.heartbeatAlerted !== undefined ? Boolean(task.heartbeatAlerted) : (prev.heartbeatAlerted || false),
246
+ preflightType: task.preflightType !== undefined ? String(task.preflightType) : (prev.preflightType || 'none'),
247
+ preflightTarget: task.preflightTarget !== undefined ? String(task.preflightTarget).trim() : (prev.preflightTarget || ''),
248
+ concurrencyGroup: task.concurrencyGroup !== undefined ? String(task.concurrencyGroup).trim() : (prev.concurrencyGroup || 'default'),
249
+ priority: task.priority !== undefined ? Number(task.priority) : (prev.priority || 5),
250
+ selfHealingCommand: task.selfHealingCommand !== undefined ? String(task.selfHealingCommand).trim() : (prev.selfHealingCommand || ''),
251
+ autoDiagnose: task.autoDiagnose !== undefined ? Boolean(task.autoDiagnose) : (prev.autoDiagnose || false),
238
252
  totalTokens: prev.totalTokens || 0,
239
253
  totalCostUsd: prev.totalCostUsd || 0,
240
254
  createdAt: prev.createdAt || task.createdAt || now,
@@ -249,6 +263,27 @@ export class TaskStore {
249
263
  return record;
250
264
  }
251
265
 
266
+ recordHeartbeat(id) {
267
+ const task = this.tasks.get(id);
268
+ if (!task) return null;
269
+ const now = Date.now();
270
+ task.lastPingAt = now;
271
+ task.heartbeatAlerted = false;
272
+ task.updatedAt = now;
273
+ if (task.lastStatus === 'missed') {
274
+ task.lastStatus = 'active';
275
+ }
276
+ this.save();
277
+ const interval = (task.heartbeatIntervalSeconds || 0) + (task.gracePeriodSeconds || 300);
278
+ return {
279
+ ok: true,
280
+ id,
281
+ taskId: id,
282
+ lastPingAt: now,
283
+ nextDeadline: now + interval * 1000,
284
+ };
285
+ }
286
+
252
287
  delete(id) {
253
288
  const existed = this.tasks.delete(id);
254
289
  this.history.delete(id);
@@ -293,6 +328,8 @@ export class TaskStore {
293
328
  // #43: the failure diagnosis and the prompt change it proposes.
294
329
  diagnosis: runInfo.diagnosis ? String(runInfo.diagnosis).slice(0, 1000) : '',
295
330
  suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
331
+ selfHealing: runInfo.selfHealing || null,
332
+ autoDiagnosis: runInfo.autoDiagnosis ? String(runInfo.autoDiagnosis).slice(0, 1000) : '',
296
333
  confidence: runInfo.confidence || '',
297
334
  });
298
335
  // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
@@ -335,6 +372,59 @@ export class TaskStore {
335
372
  return runs.slice(0, limit);
336
373
  }
337
374
 
375
+ getArchivedRuns(id, { limit = 50, offset = 0, search = '' } = {}) {
376
+ try {
377
+ const dir = path.dirname(this.filePath);
378
+ const archivePath = path.join(dir, 'tasks-history-archive.json');
379
+ if (!fs.existsSync(archivePath)) return { runs: [], total: 0 };
380
+ const archive = JSON.parse(fs.readFileSync(archivePath, 'utf-8')) || {};
381
+ let runs = Array.isArray(archive[id]) ? archive[id] : [];
382
+ if (search && search.trim()) {
383
+ const q = search.toLowerCase();
384
+ runs = runs.filter(r => (r.output && r.output.toLowerCase().includes(q)) || (r.error && r.error.toLowerCase().includes(q)));
385
+ }
386
+ const total = runs.length;
387
+ const paged = runs.slice(offset, offset + limit);
388
+ return { runs: paged, total };
389
+ } catch (err) {
390
+ return { runs: [], total: 0, error: err.message };
391
+ }
392
+ }
393
+
394
+ getTaskStats(id) {
395
+ const runs = this.history.get(id) || [];
396
+ const total = runs.length;
397
+ let success = 0;
398
+ let error = 0;
399
+ let timeout = 0;
400
+ const recentDurations = [];
401
+
402
+ for (let i = 0; i < runs.length; i++) {
403
+ const r = runs[i];
404
+ if (r.status === 'success') success++;
405
+ else if (r.status === 'error') error++;
406
+ else if (r.status === 'timeout') timeout++;
407
+
408
+ if (recentDurations.length < 10 && r.durationMs !== undefined) {
409
+ recentDurations.push({
410
+ at: r.at,
411
+ durationMs: r.durationMs,
412
+ status: r.status,
413
+ });
414
+ }
415
+ }
416
+
417
+ const successRate = total > 0 ? Number((success / total).toFixed(2)) : 1.0;
418
+ return {
419
+ totalRuns: total,
420
+ successRuns: success,
421
+ errorRuns: error,
422
+ timeoutRuns: timeout,
423
+ successRate,
424
+ recentDurations,
425
+ };
426
+ }
427
+
338
428
  getAggregatedStats() {
339
429
  let totalRuns = 0;
340
430
  let totalTokens = 0;
@@ -53,6 +53,16 @@ export const PATCHABLE_TASK_FIELDS = [
53
53
  'timeoutSeconds',
54
54
  'overlapPolicy',
55
55
  'kanbanMode',
56
+ 'onSuccess',
57
+ 'onFailure',
58
+ 'heartbeatIntervalSeconds',
59
+ 'gracePeriodSeconds',
60
+ 'preflightType',
61
+ 'preflightTarget',
62
+ 'concurrencyGroup',
63
+ 'priority',
64
+ 'selfHealingCommand',
65
+ 'autoDiagnose',
56
66
  'channels',
57
67
  'template',
58
68
  'status',
package/lib/telegram.js CHANGED
@@ -84,6 +84,25 @@ export function escapeMarkdown(str) {
84
84
  return String(str).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
85
85
  }
86
86
 
87
+ /**
88
+ * Create Telegram inline keyboard for interactive task controls (#137).
89
+ */
90
+ export function createTelegramTaskKeyboard(task, runInfo = {}) {
91
+ const isPaused = task && task.status === 'paused';
92
+ const taskId = task?.id || '';
93
+ return {
94
+ inline_keyboard: [
95
+ [
96
+ { text: '▶️ Run Now', callback_data: `cron:run:${taskId}` },
97
+ { text: isPaused ? '▶️ Resume' : '⏸ Pause', callback_data: `cron:pause:${taskId}` },
98
+ ],
99
+ [
100
+ { text: '📄 Last Output', callback_data: `cron:log:${taskId}` },
101
+ ]
102
+ ]
103
+ };
104
+ }
105
+
87
106
  /**
88
107
  * Format task execution report for Telegram.
89
108
  * Dynamic values are escaped so titles/schedules cannot break the markup (#93).
@@ -116,10 +135,32 @@ export function formatTaskTelegramMessage(task, runInfo) {
116
135
  return lines.join('\n');
117
136
  }
118
137
 
138
+ /**
139
+ * Answer a Telegram callback query (#137).
140
+ */
141
+ export async function answerTelegramCallbackQuery({ botToken, callbackQueryId, text = '', showAlert = false, fetchFn = globalThis.fetch }) {
142
+ if (!botToken || !callbackQueryId) return;
143
+ const url = `https://api.telegram.org/bot${botToken}/answerCallbackQuery`;
144
+ const body = {
145
+ callback_query_id: callbackQueryId,
146
+ text: text ? String(text).slice(0, 200) : undefined,
147
+ show_alert: Boolean(showAlert),
148
+ };
149
+ try {
150
+ await fetchFn(url, {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify(body),
154
+ });
155
+ } catch (err) {
156
+ console.warn('[dsh-cron] answerCallbackQuery failed:', err.message);
157
+ }
158
+ }
159
+
119
160
  /**
120
161
  * Send telegram message via Bot API.
121
162
  */
122
- export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown', fetchFn = globalThis.fetch, signal }) {
163
+ export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown', replyMarkup, fetchFn = globalThis.fetch, signal }) {
123
164
  if (!botToken) throw new Error('Telegram botToken is not configured');
124
165
  if (!chatId) throw new Error('Telegram chatId is not configured');
125
166
 
@@ -130,6 +171,7 @@ export async function sendTelegramMessage({ botToken, chatId, text, parseMode =
130
171
  disable_notification: false,
131
172
  };
132
173
  if (parseMode) body.parse_mode = parseMode;
174
+ if (replyMarkup) body.reply_markup = replyMarkup;
133
175
 
134
176
  const res = await fetchFn(url, {
135
177
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",