@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/README.md +27 -2
- package/README.ru.md +481 -0
- package/README.zh.md +481 -0
- package/docs/README.ru.md +34 -1
- package/docs/README.zh.md +34 -1
- package/lib/api.js +224 -2
- package/lib/channels.js +3 -2
- package/lib/client.js +594 -3
- package/lib/index.js +21 -2
- package/lib/llm-actions.js +141 -0
- package/lib/metrics.js +24 -1
- package/lib/runner.js +24 -11
- package/lib/scheduler.js +302 -13
- package/lib/store.js +90 -0
- package/lib/task-transfer.js +10 -0
- package/lib/telegram.js +43 -1
- package/package.json +1 -1
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;
|
package/lib/task-transfer.js
CHANGED
|
@@ -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',
|