@goodandready/dsh-cron 0.2.8 → 0.2.10
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 +15 -0
- package/lib/api.js +154 -4
- package/lib/channels.js +3 -2
- package/lib/client.js +70 -6
- package/lib/index.js +5 -3
- package/lib/llm-actions.js +141 -0
- package/lib/metrics.js +24 -1
- package/lib/recipes.js +13 -0
- package/lib/runner.js +147 -40
- package/lib/scheduler.js +66 -12
- package/lib/store.js +88 -1
- package/lib/task-transfer.js +2 -0
- package/lib/telegram.js +43 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -317,6 +317,21 @@ Environment used by the check: `DSH_WEB_BASE` (default `http://127.0.0.1:3080`),
|
|
|
317
317
|
### 20. Internal Refactor: Schedule Parsing and Arming (#97)
|
|
318
318
|
Developer-facing, no behaviour change. `parseScheduleExpression` was split into small functions that keep the same branch order — `parseAtExpression`, `parseRelativeOneShot`, `parseIntervalExpression`, `parseAliasExpression`, `parseCronExpression` — and `scheduleTask` into `clearScheduled`, `scheduleOneShot` and `scheduleCron`. The existing test suite passed unchanged and targeted tests were added for branch precedence and error messages.
|
|
319
319
|
|
|
320
|
+
### 21. Performance & Process Isolation Pack (Added in v0.2.9, #134)
|
|
321
|
+
- **Process-Tree Termination**: Shell and script tasks run in an isolated process group (`detached: true` on POSIX); aborts and timeouts send `SIGTERM` followed by `SIGKILL` to the entire group (`-child.pid`) to eliminate orphan and zombie processes.
|
|
322
|
+
- **Concurrency Throttling**: Default safe limit `maxConcurrent = 2` prevents CPU and memory spikes during overlapping scheduled runs.
|
|
323
|
+
- **Transient Error Retry**: Exponential backoff retry (up to 3 attempts) for transient network and provider errors (`429`, `502`, `503`, `504`, `ECONNRESET`).
|
|
324
|
+
- **Network & UI Optimization**: `GET /dsh-cron/tasks` provides `ETag` and responds with `304 Not Modified`; client UI adapts polling frequency (`visibilitychange`: 30s in background tabs, 8s in active tabs).
|
|
325
|
+
- **History Rotation & Archival**: Active store holds the latest 100 runs per task; older entries are automatically archived into `tasks-history-archive.json`.
|
|
326
|
+
- **Autonomous PR Reviewer Recipe (#33)**: Preconfigured recipe in Template Hub with optional toggle `prReviewerEnabled` in settings.
|
|
327
|
+
|
|
328
|
+
### 22. Automation, Task Chaining & Observability Pack (Added in v0.2.10, #137)
|
|
329
|
+
- **Two-Way Telegram Interactive Controls**: Run completion notifications include inline keyboard buttons (`🚀 Run Now`, `⏸️ Pause` / `▶️ Resume`, `📋 Last Output`). Actions are securely routed via `POST /dsh-cron/telegram/webhook` with Chat ID authorization matching plugin settings or harness defaults.
|
|
330
|
+
- **Task Chaining & Pipelines**: Tasks can declare `onSuccess` and `onFailure` downstream task triggers. Upstream output is automatically forwarded to child tasks via `$DSH_PREV_OUTPUT` environment variable for shell/script tasks and `{{prevOutput}}` variable interpolation in LLM prompts. Infinite execution loops are strictly prevented with a recursion depth limit (max 5 consecutive executions).
|
|
331
|
+
- **Structured LLM Actions**: Autonomous model runs can output structured JSON directives to trigger secondary tasks, dispatch channel notifications, or open issues. Controlled via `llmActionsEnabled: false` settings toggle (strictly disabled by default).
|
|
332
|
+
- **History Archival & Latency Insights**: Active task store retains the most recent 100 runs for instant performance, while older runs are archived in `tasks_archive.json`. New REST endpoints `GET /dsh-cron/tasks/:id/archive` and `GET /dsh-cron/tasks/:id/stats` expose historical records and aggregated latency statistics. Task UI displays execution duration latency badges with color thresholds (<5s green, <30s yellow, >=30s red).
|
|
333
|
+
- **Enriched Prometheus Observability**: The `/dsh-cron/metrics` endpoint exports the active concurrency gauge `dsh_cron_concurrent_running`, per-task prompt/completion token consumption counters `dsh_cron_task_tokens_total{task,model,type}`, and per-task cost estimation counters `dsh_cron_task_cost_usd_total{task,model}`.
|
|
334
|
+
|
|
320
335
|
---
|
|
321
336
|
|
|
322
337
|
## 📦 Installation
|
package/lib/api.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* unchanged.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
9
9
|
import { parseScheduleExpression } from './scheduler.js';
|
|
10
10
|
import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
|
|
11
11
|
import { CHANNEL_IDS, unknownChannelIds } from './channels.js';
|
|
12
12
|
import { applyTaskPatch } from './task-patch.js';
|
|
13
13
|
import { MANAGED_BY_CONFIG, configOwnedMessage } from './config-jobs.js';
|
|
14
14
|
import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER } from './http-utils.js';
|
|
15
|
+
import { answerTelegramCallbackQuery, sendTelegramMessage, getDshDefaultTelegramCredentials } from './telegram.js';
|
|
15
16
|
import {
|
|
16
17
|
validateTaskType,
|
|
17
18
|
buildDuplicateTask,
|
|
@@ -54,6 +55,11 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
54
55
|
const id = parts[2];
|
|
55
56
|
const action = parts[3];
|
|
56
57
|
|
|
58
|
+
if (scope === 'telegram' && id === 'webhook') {
|
|
59
|
+
await handleTelegramWebhook({ store, scheduler, req, res });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
if (!id) {
|
|
58
64
|
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope });
|
|
59
65
|
return;
|
|
@@ -88,7 +94,7 @@ async function handleTaskCollection({ store, scheduler, collection, req, res, ur
|
|
|
88
94
|
return;
|
|
89
95
|
}
|
|
90
96
|
if (req.method === 'GET') {
|
|
91
|
-
listTasks({ store, scheduler, collection, res, url });
|
|
97
|
+
listTasks({ store, scheduler, collection, req, res, url });
|
|
92
98
|
return;
|
|
93
99
|
}
|
|
94
100
|
if (req.method === 'POST') {
|
|
@@ -98,7 +104,7 @@ async function handleTaskCollection({ store, scheduler, collection, req, res, ur
|
|
|
98
104
|
sendJson(res, 405, NOT_ALLOWED);
|
|
99
105
|
}
|
|
100
106
|
|
|
101
|
-
function listTasks({ store, scheduler, collection, res, url }) {
|
|
107
|
+
function listTasks({ store, scheduler, collection, req, res, url }) {
|
|
102
108
|
const status = url.searchParams.get('status') || 'all';
|
|
103
109
|
const query = url.searchParams.get('query') || '';
|
|
104
110
|
const list = store.list({ status, query }).map((t) => ({
|
|
@@ -107,7 +113,26 @@ function listTasks({ store, scheduler, collection, res, url }) {
|
|
|
107
113
|
runningSince: scheduler.runningSince(t.id),
|
|
108
114
|
}));
|
|
109
115
|
const stats = store.getAggregatedStats();
|
|
110
|
-
|
|
116
|
+
const payload = { ok: true, tasks: list, recommendations: collection.recommendations, stats };
|
|
117
|
+
|
|
118
|
+
// Calculate ETag based on payload JSON representation (#134)
|
|
119
|
+
const bodyString = JSON.stringify(payload);
|
|
120
|
+
const etag = 'W/"' + createHash('sha1').update(bodyString).digest('hex') + '"';
|
|
121
|
+
|
|
122
|
+
if (req && req.headers) {
|
|
123
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
124
|
+
if (ifNoneMatch && (ifNoneMatch === etag || ifNoneMatch === etag.slice(2))) {
|
|
125
|
+
res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'no-cache' });
|
|
126
|
+
res.end();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof res.setHeader === 'function') {
|
|
132
|
+
res.setHeader('ETag', etag);
|
|
133
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
134
|
+
}
|
|
135
|
+
sendJson(res, 200, payload);
|
|
111
136
|
}
|
|
112
137
|
|
|
113
138
|
async function createOrUpdateTask({ store, scheduler, req, res }) {
|
|
@@ -327,6 +352,21 @@ function applyImportPlan({ store, scheduler, plan }) {
|
|
|
327
352
|
// ------------------------------------------------------------ single task
|
|
328
353
|
|
|
329
354
|
async function handleTaskItem({ store, scheduler, req, res, url, id, action }) {
|
|
355
|
+
if (req.method === 'GET' && action === 'archive') {
|
|
356
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
357
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
358
|
+
const search = url.searchParams.get('search') || '';
|
|
359
|
+
const result = typeof store.getArchivedRuns === 'function'
|
|
360
|
+
? store.getArchivedRuns(id, { limit, offset, search })
|
|
361
|
+
: { runs: [], total: 0 };
|
|
362
|
+
sendJson(res, 200, { ok: true, taskId: id, ...result });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (req.method === 'GET' && action === 'stats') {
|
|
366
|
+
const stats = typeof store.getTaskStats === 'function' ? store.getTaskStats(id) : null;
|
|
367
|
+
sendJson(res, 200, { ok: true, taskId: id, stats });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
330
370
|
if (req.method === 'GET' && (action === 'history' || !action)) {
|
|
331
371
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
332
372
|
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
@@ -531,3 +571,113 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
|
|
|
531
571
|
}
|
|
532
572
|
sendJson(res, 405, NOT_ALLOWED);
|
|
533
573
|
}
|
|
574
|
+
|
|
575
|
+
export async function handleTelegramWebhook({ store, scheduler, req, res }) {
|
|
576
|
+
if (req.method !== 'POST') {
|
|
577
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const { body, error } = await readBody(req, res);
|
|
581
|
+
if (error || !body) return;
|
|
582
|
+
|
|
583
|
+
const callbackQuery = body.callback_query;
|
|
584
|
+
if (!callbackQuery) {
|
|
585
|
+
sendJson(res, 200, { ok: true });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const queryId = callbackQuery.id;
|
|
590
|
+
const data = String(callbackQuery.data || '');
|
|
591
|
+
const fromId = String(callbackQuery.from?.id || '');
|
|
592
|
+
const chatId = String(callbackQuery.message?.chat?.id || fromId);
|
|
593
|
+
|
|
594
|
+
const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
595
|
+
const defaults = getDshDefaultTelegramCredentials();
|
|
596
|
+
const botToken = settings.botToken || defaults.botToken;
|
|
597
|
+
const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
|
|
598
|
+
|
|
599
|
+
if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
|
|
600
|
+
console.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId}`);
|
|
601
|
+
if (botToken) {
|
|
602
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unauthorized', showAlert: true });
|
|
603
|
+
}
|
|
604
|
+
sendJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const parts = data.split(':');
|
|
609
|
+
if (parts[0] !== 'cron' || parts.length < 3) {
|
|
610
|
+
if (botToken) {
|
|
611
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Unknown action' });
|
|
612
|
+
}
|
|
613
|
+
sendJson(res, 200, { ok: true, ignored: true });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const verb = parts[1];
|
|
618
|
+
const taskId = parts.slice(2).join(':');
|
|
619
|
+
const task = store.get(taskId);
|
|
620
|
+
|
|
621
|
+
if (!task) {
|
|
622
|
+
if (botToken) {
|
|
623
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: 'Task not found', showAlert: true });
|
|
624
|
+
}
|
|
625
|
+
sendJson(res, 200, { ok: false, error: 'Task not found' });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (verb === 'run') {
|
|
630
|
+
try {
|
|
631
|
+
await scheduler.triggerManualRun(taskId);
|
|
632
|
+
if (botToken) {
|
|
633
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `🚀 Run triggered: ${task.title}` });
|
|
634
|
+
}
|
|
635
|
+
} catch (runErr) {
|
|
636
|
+
if (botToken) {
|
|
637
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `Run error: ${runErr.message}`, showAlert: true });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
} else if (verb === 'pause') {
|
|
641
|
+
const isPaused = task.status === 'paused';
|
|
642
|
+
const newStatus = isPaused ? 'active' : 'paused';
|
|
643
|
+
if (typeof store.update === 'function') {
|
|
644
|
+
store.update(taskId, { status: newStatus });
|
|
645
|
+
} else {
|
|
646
|
+
store.set({ ...task, status: newStatus });
|
|
647
|
+
}
|
|
648
|
+
if (isPaused) {
|
|
649
|
+
scheduler.resume(taskId);
|
|
650
|
+
if (botToken) {
|
|
651
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `▶️ Resumed: ${task.title}` });
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
scheduler.pause(taskId);
|
|
655
|
+
if (botToken) {
|
|
656
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `⏸ Paused: ${task.title}` });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
} else if (verb === 'log') {
|
|
660
|
+
const history = store.getHistory(taskId, 1);
|
|
661
|
+
const lastRun = history && history[0];
|
|
662
|
+
if (!lastRun) {
|
|
663
|
+
if (botToken) {
|
|
664
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `No runs recorded for ${task.title}`, showAlert: true });
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
667
|
+
const summary = `[${task.title}] Last run: ${lastRun.status} (${lastRun.durationMs}ms)\n${lastRun.output || lastRun.error || 'Empty output'}`;
|
|
668
|
+
if (botToken) {
|
|
669
|
+
await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: summary.slice(0, 190) });
|
|
670
|
+
if (summary.length > 190 && chatId) {
|
|
671
|
+
await sendTelegramMessage({
|
|
672
|
+
botToken,
|
|
673
|
+
chatId,
|
|
674
|
+
text: `*Full Output for ${task.title}:*\n\`\`\`\n${summary.slice(0, 3000)}\n\`\`\``,
|
|
675
|
+
parseMode: 'Markdown',
|
|
676
|
+
}).catch(() => {});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
sendJson(res, 200, { ok: true, verb, taskId });
|
|
683
|
+
}
|
package/lib/channels.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* collected so one broken channel cannot hide another.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
|
|
10
|
+
import { formatTaskTelegramMessage, sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
|
|
11
11
|
import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
|
|
12
12
|
import { resolveTemplateText, truncateText } from './templates.js';
|
|
13
13
|
|
|
@@ -256,7 +256,8 @@ export const CHANNEL_HANDLERS = {
|
|
|
256
256
|
? messageTextFor('telegram', task, runInfo, settings)
|
|
257
257
|
: formatTaskTelegramMessage(task, runInfo);
|
|
258
258
|
try {
|
|
259
|
-
|
|
259
|
+
const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
|
|
260
|
+
await sendTelegramMessage({ botToken, chatId, text, replyMarkup, fetchFn: http, signal });
|
|
260
261
|
} catch (err) {
|
|
261
262
|
if (isAbort(err)) throw new Error(`telegram: timed out after ${timeoutMs} ms`);
|
|
262
263
|
throw err;
|
package/lib/client.js
CHANGED
|
@@ -875,6 +875,8 @@ window.__ModuleLoader__.load({
|
|
|
875
875
|
const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
|
|
876
876
|
const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
|
|
877
877
|
const [formKanbanMode, setFormKanbanMode] = React.useState('none');
|
|
878
|
+
const [formOnSuccess, setFormOnSuccess] = React.useState('');
|
|
879
|
+
const [formOnFailure, setFormOnFailure] = React.useState('');
|
|
878
880
|
const [formTimezone, setFormTimezone] = React.useState('');
|
|
879
881
|
const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
|
|
880
882
|
const [formMaxRetries, setFormMaxRetries] = React.useState(0);
|
|
@@ -1035,11 +1037,29 @@ window.__ModuleLoader__.load({
|
|
|
1035
1037
|
}
|
|
1036
1038
|
};
|
|
1037
1039
|
|
|
1040
|
+
const lastEtagRef = React.useRef('');
|
|
1041
|
+
|
|
1038
1042
|
React.useEffect(() => {
|
|
1039
1043
|
fetchTasks();
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1044
|
+
|
|
1045
|
+
let timerId = null;
|
|
1046
|
+
let isDestroyed = false;
|
|
1047
|
+
|
|
1048
|
+
const doPoll = () => {
|
|
1049
|
+
if (isDestroyed) return;
|
|
1050
|
+
const headers = {};
|
|
1051
|
+
if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
|
|
1052
|
+
|
|
1053
|
+
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), { headers })
|
|
1054
|
+
.then(res => {
|
|
1055
|
+
if (res.status === 304) {
|
|
1056
|
+
// Not modified: skip parsing JSON
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
const etag = res.headers.get('ETag');
|
|
1060
|
+
if (etag) lastEtagRef.current = etag;
|
|
1061
|
+
return res.json();
|
|
1062
|
+
})
|
|
1043
1063
|
.then(data => {
|
|
1044
1064
|
if (data && data.ok) {
|
|
1045
1065
|
setTasks(data.tasks || []);
|
|
@@ -1047,9 +1067,36 @@ window.__ModuleLoader__.load({
|
|
|
1047
1067
|
setStats(data.stats || null);
|
|
1048
1068
|
}
|
|
1049
1069
|
})
|
|
1050
|
-
.catch(() => {})
|
|
1051
|
-
|
|
1052
|
-
|
|
1070
|
+
.catch(() => {})
|
|
1071
|
+
.finally(() => {
|
|
1072
|
+
if (isDestroyed) return;
|
|
1073
|
+
// Adaptive interval: 30s when tab is hidden, 8s when active (#134)
|
|
1074
|
+
const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
|
|
1075
|
+
timerId = setTimeout(doPoll, intervalMs);
|
|
1076
|
+
});
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
// Trigger immediate fetch when visibility returns
|
|
1080
|
+
const onVisibilityChange = () => {
|
|
1081
|
+
if (typeof document !== 'undefined' && !document.hidden) {
|
|
1082
|
+
if (timerId) clearTimeout(timerId);
|
|
1083
|
+
doPoll();
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
if (typeof document !== 'undefined') {
|
|
1088
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
timerId = setTimeout(doPoll, 8000);
|
|
1092
|
+
|
|
1093
|
+
return () => {
|
|
1094
|
+
isDestroyed = true;
|
|
1095
|
+
if (timerId) clearTimeout(timerId);
|
|
1096
|
+
if (typeof document !== 'undefined') {
|
|
1097
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1053
1100
|
}, [tab, query]);
|
|
1054
1101
|
React.useEffect(() => { loadModels(); }, []);
|
|
1055
1102
|
|
|
@@ -1420,6 +1467,8 @@ window.__ModuleLoader__.load({
|
|
|
1420
1467
|
setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
|
|
1421
1468
|
setFormOverlapPolicy(task.overlapPolicy || 'skip');
|
|
1422
1469
|
setFormKanbanMode(task.kanbanMode || 'none');
|
|
1470
|
+
setFormOnSuccess(task.onSuccess || '');
|
|
1471
|
+
setFormOnFailure(task.onFailure || '');
|
|
1423
1472
|
setFormTimezone(task.timezone || '');
|
|
1424
1473
|
setFormMisfirePolicy(task.misfirePolicy || 'skip');
|
|
1425
1474
|
setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
|
|
@@ -1482,6 +1531,8 @@ window.__ModuleLoader__.load({
|
|
|
1482
1531
|
maxRetries: Number(formMaxRetries) || 0,
|
|
1483
1532
|
permissionPreset: formPermissionPreset,
|
|
1484
1533
|
kanbanMode: formKanbanMode,
|
|
1534
|
+
onSuccess: formOnSuccess.trim() || undefined,
|
|
1535
|
+
onFailure: formOnFailure.trim() || undefined,
|
|
1485
1536
|
env: parseEnvText(formEnv),
|
|
1486
1537
|
cwd: formCwd.trim(),
|
|
1487
1538
|
workspaceId: formWorkspaceId.trim(),
|
|
@@ -1787,6 +1838,8 @@ window.__ModuleLoader__.load({
|
|
|
1787
1838
|
className: 'dsh-cron-config-tag',
|
|
1788
1839
|
title: T_KEY(t, 'list.configManagedHint')
|
|
1789
1840
|
}, T_KEY(t, 'list.configManaged')) : null,
|
|
1841
|
+
task.lastDurationMs > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag', title: 'Last execution duration' }, task.lastDurationMs + 'ms') : null,
|
|
1842
|
+
task.onSuccess ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'On success: triggers ' + task.onSuccess, style: { background: 'rgba(56, 189, 248, 0.15)', color: '#38bdf8' } }, '➜ ' + task.onSuccess) : null,
|
|
1790
1843
|
task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
|
|
1791
1844
|
task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null
|
|
1792
1845
|
),
|
|
@@ -2608,6 +2661,7 @@ window.__ModuleLoader__.load({
|
|
|
2608
2661
|
const [chatId, setChatId] = React.useState('');
|
|
2609
2662
|
const [notify, setNotify] = React.useState(false);
|
|
2610
2663
|
const [onlyFailure, setOnlyFailure] = React.useState(false);
|
|
2664
|
+
const [llmActionsEnabled, setLlmActionsEnabled] = React.useState(false);
|
|
2611
2665
|
const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
|
|
2612
2666
|
const [delivery, setDelivery] = React.useState({});
|
|
2613
2667
|
const [sections, setSections] = React.useState({ channels: true });
|
|
@@ -2634,6 +2688,7 @@ window.__ModuleLoader__.load({
|
|
|
2634
2688
|
setChatId(data.settings.chatId || '');
|
|
2635
2689
|
setNotify(Boolean(data.settings.notifyTelegram));
|
|
2636
2690
|
setOnlyFailure(Boolean(data.settings.onlyOnFailure));
|
|
2691
|
+
setLlmActionsEnabled(Boolean(data.settings.llmActionsEnabled));
|
|
2637
2692
|
setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
|
|
2638
2693
|
setDelivery(pickDeliverySettings(data.settings));
|
|
2639
2694
|
setHasDefault(Boolean(data.settings.hasDefaultCredentials));
|
|
@@ -2657,6 +2712,7 @@ window.__ModuleLoader__.load({
|
|
|
2657
2712
|
chatId,
|
|
2658
2713
|
notifyTelegram: notify,
|
|
2659
2714
|
onlyOnFailure: onlyFailure,
|
|
2715
|
+
llmActionsEnabled: llmActionsEnabled,
|
|
2660
2716
|
kanbanBaseUrl: kanbanUrl
|
|
2661
2717
|
}, delivery))
|
|
2662
2718
|
});
|
|
@@ -2786,6 +2842,14 @@ window.__ModuleLoader__.load({
|
|
|
2786
2842
|
onChange: (e) => setOnlyFailure(e.target.checked)
|
|
2787
2843
|
}),
|
|
2788
2844
|
React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
|
|
2845
|
+
),
|
|
2846
|
+
React.createElement('label', { className: 'dsh-cron-checkbox-row' },
|
|
2847
|
+
React.createElement('input', {
|
|
2848
|
+
type: 'checkbox',
|
|
2849
|
+
checked: llmActionsEnabled,
|
|
2850
|
+
onChange: (e) => setLlmActionsEnabled(e.target.checked)
|
|
2851
|
+
}),
|
|
2852
|
+
React.createElement('span', null, 'Enable Structured LLM Actions (trigger tasks, notify, create issues)')
|
|
2789
2853
|
)
|
|
2790
2854
|
),
|
|
2791
2855
|
...renderDeliverySettings({
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
|
|
|
3
3
|
import { SessionRunner } from './runner.js';
|
|
4
4
|
import { getModelsHandler } from './models-handler.js';
|
|
5
5
|
import { chatStartHandler } from './chat-start.js';
|
|
6
|
-
import { sendTelegramMessage } from './telegram.js';
|
|
6
|
+
import { sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
|
|
7
7
|
import { createKanbanCard } from './integrations.js';
|
|
8
8
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
@@ -100,6 +100,7 @@ export const Config = z.object({
|
|
|
100
100
|
giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
|
|
101
101
|
giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
|
|
102
102
|
giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
|
|
103
|
+
prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)'),
|
|
103
104
|
// --- Declarative jobs (#50, ADR-0001) ---
|
|
104
105
|
// The array is intentionally permissive: each entry is validated on its own
|
|
105
106
|
// with a precise message (see lib/config-jobs.js), so one broken entry cannot
|
|
@@ -122,7 +123,7 @@ export const SETTINGS_SYNC_KEYS = [
|
|
|
122
123
|
'pushplusTokenRef',
|
|
123
124
|
'pushplusUrl',
|
|
124
125
|
'ttsBaseUrl',
|
|
125
|
-
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
|
|
126
|
+
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'prReviewerEnabled',
|
|
126
127
|
'apiToken',
|
|
127
128
|
];
|
|
128
129
|
|
|
@@ -526,7 +527,8 @@ export function apply(ctx, config) {
|
|
|
526
527
|
}
|
|
527
528
|
|
|
528
529
|
const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
|
|
529
|
-
|
|
530
|
+
const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
|
|
531
|
+
await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
|
|
530
532
|
sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
|
|
531
533
|
} catch (err) {
|
|
532
534
|
sendJson(res, 500, { ok: false, error: err.message });
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract structured action directives from LLM output text (#137).
|
|
5
|
+
* Supports JSON code fences ```json ... ``` and inline { "dsh_action": ... } blocks.
|
|
6
|
+
*/
|
|
7
|
+
export function parseLlmActionDirectives(text) {
|
|
8
|
+
if (!text || typeof text !== 'string') return [];
|
|
9
|
+
const directives = [];
|
|
10
|
+
|
|
11
|
+
// 1. Check for ```json ... ``` or ``` ... ``` blocks
|
|
12
|
+
const blockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/gi;
|
|
13
|
+
let match;
|
|
14
|
+
while ((match = blockRegex.exec(text)) !== null) {
|
|
15
|
+
const raw = match[1].trim();
|
|
16
|
+
if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
collectDirectives(parsed, directives);
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2. Check for inline JSON object with dsh_action or dsh_actions if nothing found in fences
|
|
25
|
+
if (directives.length === 0) {
|
|
26
|
+
const inlineMatch = /\{(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*"dsh_action[s]?"(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*\}/gi;
|
|
27
|
+
let im;
|
|
28
|
+
while ((im = inlineMatch.exec(text)) !== null) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(im[0]);
|
|
31
|
+
collectDirectives(parsed, directives);
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return directives;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collectDirectives(obj, list) {
|
|
40
|
+
if (!obj || typeof obj !== 'object') return;
|
|
41
|
+
if (Array.isArray(obj)) {
|
|
42
|
+
for (const item of obj) collectDirectives(item, list);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(obj.dsh_actions)) {
|
|
46
|
+
for (const item of obj.dsh_actions) collectDirectives(item, list);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(obj.actions)) {
|
|
50
|
+
for (const item of obj.actions) collectDirectives(item, list);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const type = obj.dsh_action || obj.action || obj.type;
|
|
54
|
+
if (typeof type === 'string' && type.trim()) {
|
|
55
|
+
list.push({
|
|
56
|
+
type: type.trim(),
|
|
57
|
+
payload: obj.payload || {},
|
|
58
|
+
...obj,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Execute parsed LLM action directives if enabled in plugin settings (#137).
|
|
65
|
+
*/
|
|
66
|
+
export async function executeLlmActionDirectives({ directives, task, runInfo, scheduler, store, settings = {}, fetchFn = globalThis.fetch }) {
|
|
67
|
+
if (!Array.isArray(directives) || directives.length === 0) return [];
|
|
68
|
+
if (!settings.llmActionsEnabled) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const dir of directives) {
|
|
74
|
+
const type = dir.type || dir.action;
|
|
75
|
+
try {
|
|
76
|
+
if (type === 'trigger_task' || type === 'run_task') {
|
|
77
|
+
const targetId = dir.taskId || dir.payload?.taskId;
|
|
78
|
+
if (targetId && typeof scheduler.runNow === 'function') {
|
|
79
|
+
await scheduler.runNow(targetId, {
|
|
80
|
+
prevOutput: runInfo?.output || '',
|
|
81
|
+
prevTaskId: task?.id || '',
|
|
82
|
+
});
|
|
83
|
+
results.push({ type, targetId, status: 'triggered' });
|
|
84
|
+
}
|
|
85
|
+
} else if (type === 'notify') {
|
|
86
|
+
const msg = dir.message || dir.text || dir.payload?.message;
|
|
87
|
+
const botToken = settings.botToken;
|
|
88
|
+
const chatId = settings.chatId;
|
|
89
|
+
if (msg && botToken && chatId) {
|
|
90
|
+
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
|
91
|
+
await fetchFn(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ chat_id: chatId, text: String(msg) }),
|
|
95
|
+
});
|
|
96
|
+
results.push({ type, status: 'sent' });
|
|
97
|
+
}
|
|
98
|
+
} else if (type === 'create_issue') {
|
|
99
|
+
const title = dir.title || dir.payload?.title || `Issue from task ${task?.title || 'Cron'}`;
|
|
100
|
+
const body = dir.body || dir.payload?.body || String(runInfo?.output || '');
|
|
101
|
+
const giteaRes = await tryCreateGiteaIssue({ title, body, fetchFn });
|
|
102
|
+
results.push({ type, status: giteaRes ? 'created' : 'skipped', details: giteaRes });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.warn(`[dsh-cron] action directive ${type} execution failed:`, err.message);
|
|
106
|
+
results.push({ type, status: 'error', error: err.message });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Best-effort helper to post an issue to local Gitea if credentials are present.
|
|
115
|
+
*/
|
|
116
|
+
async function tryCreateGiteaIssue({ title, body, repo = 'goodandready/dsh-cron', fetchFn }) {
|
|
117
|
+
try {
|
|
118
|
+
let token = process.env.GITEA_TOKEN;
|
|
119
|
+
const credPath = '/mnt/external/Project/DEV/.gitea-agent-credentials.json';
|
|
120
|
+
if (!token && fs.existsSync(credPath)) {
|
|
121
|
+
const data = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
|
|
122
|
+
token = data?.agents?.antigravity?.token || data?.token;
|
|
123
|
+
}
|
|
124
|
+
if (!token) return null;
|
|
125
|
+
|
|
126
|
+
const url = `http://127.0.0.1:3005/api/v1/repos/${repo}/issues`;
|
|
127
|
+
const res = await fetchFn(url, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: {
|
|
130
|
+
'Authorization': `token ${token}`,
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({ title, body }),
|
|
134
|
+
});
|
|
135
|
+
if (res.ok) {
|
|
136
|
+
const json = await res.json().catch(() => ({}));
|
|
137
|
+
return { number: json.number, url: json.html_url };
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
package/lib/metrics.js
CHANGED
|
@@ -36,7 +36,7 @@ function sample(name, labels, value) {
|
|
|
36
36
|
* Render the current scheduler state as Prometheus text exposition.
|
|
37
37
|
* Pure function over plain data so it is testable without a store.
|
|
38
38
|
*/
|
|
39
|
-
export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {}) {
|
|
39
|
+
export function renderMetrics({ tasks = [], stats = {}, runCounters = {}, scheduler } = {}) {
|
|
40
40
|
const lines = [];
|
|
41
41
|
const byStatus = {};
|
|
42
42
|
for (const task of tasks) {
|
|
@@ -72,6 +72,28 @@ export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {})
|
|
|
72
72
|
lines.push('# TYPE dsh_cron_run_records gauge');
|
|
73
73
|
lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
|
|
74
74
|
|
|
75
|
+
if (scheduler && typeof scheduler.runningCount === 'function') {
|
|
76
|
+
lines.push('# HELP dsh_cron_concurrent_running Number of currently executing tasks.');
|
|
77
|
+
lines.push('# TYPE dsh_cron_concurrent_running gauge');
|
|
78
|
+
lines.push('dsh_cron_concurrent_running ' + (Number(scheduler.runningCount()) || 0));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
lines.push('# HELP dsh_cron_task_tokens_total Cumulative tokens consumed by task.');
|
|
82
|
+
lines.push('# TYPE dsh_cron_task_tokens_total counter');
|
|
83
|
+
for (const task of tasks) {
|
|
84
|
+
if (Number(task.totalTokens) > 0) {
|
|
85
|
+
lines.push(sample('dsh_cron_task_tokens_total', { task: task.id }, task.totalTokens));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lines.push('# HELP dsh_cron_task_cost_usd_total Estimated cumulative LLM cost in USD.');
|
|
90
|
+
lines.push('# TYPE dsh_cron_task_cost_usd_total counter');
|
|
91
|
+
for (const task of tasks) {
|
|
92
|
+
if (Number(task.totalCostUsd) > 0) {
|
|
93
|
+
lines.push(sample('dsh_cron_task_cost_usd_total', { task: task.id }, task.totalCostUsd));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
return lines.join(NL) + NL;
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -90,6 +112,7 @@ export function createMetricsHandler({ store, scheduler }) {
|
|
|
90
112
|
tasks: store.list({ status: 'all' }),
|
|
91
113
|
stats: store.getAggregatedStats(),
|
|
92
114
|
runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
|
|
115
|
+
scheduler,
|
|
93
116
|
});
|
|
94
117
|
res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
|
|
95
118
|
res.end(req.method === 'HEAD' ? undefined : body);
|
package/lib/recipes.js
CHANGED
|
@@ -19,6 +19,7 @@ export const RECIPE_CATEGORIES = [
|
|
|
19
19
|
{ id: 'storage', title: 'Storage' },
|
|
20
20
|
{ id: 'security', title: 'Security' },
|
|
21
21
|
{ id: 'maintenance', title: 'Maintenance windows' },
|
|
22
|
+
{ id: 'ci', title: 'CI & Code review' },
|
|
22
23
|
];
|
|
23
24
|
|
|
24
25
|
/** Commands a recipe must never contain, checked by the test suite. */
|
|
@@ -190,6 +191,18 @@ export const RECIPES = [
|
|
|
190
191
|
prompt: 'uptime && echo "---" && uname -r && echo "---" && df -h / | tail -n 1',
|
|
191
192
|
channels: ['telegram'],
|
|
192
193
|
},
|
|
194
|
+
{
|
|
195
|
+
id: 'recipe_pr_reviewer',
|
|
196
|
+
category: 'ci',
|
|
197
|
+
title: 'Autonomous PR Reviewer (#33)',
|
|
198
|
+
description: 'Periodic inspection of open Pull Requests in Gitea with agent review analysis.',
|
|
199
|
+
type: 'llm',
|
|
200
|
+
schedule: '*/30 * * * *',
|
|
201
|
+
scheduleText: 'Every 30 minutes',
|
|
202
|
+
prompt: 'Check open Pull Requests in Gitea repository. Inspect changed diffs, evaluate code quality, verify test coverage and post a concise review summary.',
|
|
203
|
+
channels: ['telegram', 'gitea'],
|
|
204
|
+
silentRule: 'Stay silent when there are no open PRs or no new commits to review.',
|
|
205
|
+
},
|
|
193
206
|
];
|
|
194
207
|
|
|
195
208
|
/** Detached copy of the catalog, safe to hand to the UI. */
|
package/lib/runner.js
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper to test if an error code or status is transient and eligible for retry (#134).
|
|
3
|
+
*/
|
|
4
|
+
export function isTransientError(errOrStatus) {
|
|
5
|
+
if (typeof errOrStatus === 'number') {
|
|
6
|
+
return errOrStatus === 429 || errOrStatus === 502 || errOrStatus === 503 || errOrStatus === 504;
|
|
7
|
+
}
|
|
8
|
+
const str = String(errOrStatus?.message || errOrStatus || '').toLowerCase();
|
|
9
|
+
return str.includes('429') || str.includes('too many requests') ||
|
|
10
|
+
str.includes('502') || str.includes('bad gateway') ||
|
|
11
|
+
str.includes('503') || str.includes('service unavailable') ||
|
|
12
|
+
str.includes('504') || str.includes('gateway timeout') ||
|
|
13
|
+
str.includes('econnreset') || str.includes('etimedout');
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
import { estimateTokenCost } from './integrations.js';
|
|
2
17
|
import {
|
|
3
18
|
EXTERNAL_RUNTIMES,
|
|
@@ -39,7 +54,7 @@ export class SessionRunner {
|
|
|
39
54
|
}, timeoutMs);
|
|
40
55
|
});
|
|
41
56
|
|
|
42
|
-
const executionPromise = this._run(task, controller.signal);
|
|
57
|
+
const executionPromise = this._run(task, controller.signal, options);
|
|
43
58
|
|
|
44
59
|
try {
|
|
45
60
|
return await Promise.race([executionPromise, timeoutPromise]);
|
|
@@ -99,24 +114,44 @@ export class SessionRunner {
|
|
|
99
114
|
});
|
|
100
115
|
}
|
|
101
116
|
|
|
102
|
-
/** HTTP trigger runtime (#7). */
|
|
117
|
+
/** HTTP trigger runtime (#7) with transient retry (#134). */
|
|
103
118
|
async _runHttp(task, request, signal) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
let attempts = 0;
|
|
120
|
+
const maxAttempts = 3;
|
|
121
|
+
while (attempts < maxAttempts) {
|
|
122
|
+
attempts++;
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(request.url, {
|
|
125
|
+
method: request.method,
|
|
126
|
+
headers: request.headers,
|
|
127
|
+
body: request.body,
|
|
128
|
+
signal,
|
|
129
|
+
});
|
|
130
|
+
const text = await res.text().catch(() => '');
|
|
131
|
+
const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
const err = new Error(`${summary}\n${String(text).slice(0, 500)}`);
|
|
134
|
+
if (isTransientError(res.status) && attempts < maxAttempts && !signal?.aborted) {
|
|
135
|
+
console.warn(`[dsh-cron] HTTP request returned ${res.status}, retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
136
|
+
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
143
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
144
|
+
costUsd: 0,
|
|
145
|
+
};
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (isTransientError(err) && attempts < maxAttempts && !signal?.aborted) {
|
|
148
|
+
console.warn(`[dsh-cron] HTTP request failed (${err.message}), retrying attempt ${attempts + 1}/${maxAttempts}...`);
|
|
149
|
+
await new Promise((r) => setTimeout(r, attempts * 1000));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
114
154
|
}
|
|
115
|
-
return {
|
|
116
|
-
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
117
|
-
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
118
|
-
costUsd: 0,
|
|
119
|
-
};
|
|
120
155
|
}
|
|
121
156
|
|
|
122
157
|
/**
|
|
@@ -160,7 +195,7 @@ export class SessionRunner {
|
|
|
160
195
|
}
|
|
161
196
|
|
|
162
197
|
/** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
|
|
163
|
-
async _runExternal(task, runtime, signal) {
|
|
198
|
+
async _runExternal(task, runtime, signal, execOptions = {}) {
|
|
164
199
|
if (runtime === 'ssh' && task.sshProfileId) {
|
|
165
200
|
const viaService = await this._runViaRemoteWorkspace(task, signal);
|
|
166
201
|
if (viaService) return viaService;
|
|
@@ -172,54 +207,117 @@ export class SessionRunner {
|
|
|
172
207
|
const invocation = buildInvocation({ ...task, type: runtime }, cwd);
|
|
173
208
|
if (invocation.http) return this._runHttp(task, invocation.http, signal);
|
|
174
209
|
|
|
175
|
-
const {
|
|
210
|
+
const { spawn } = await import('node:child_process');
|
|
176
211
|
return new Promise((resolve, reject) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
212
|
+
let child = null;
|
|
213
|
+
let settled = false;
|
|
214
|
+
let stdout = '';
|
|
215
|
+
let stderr = '';
|
|
216
|
+
const maxBuffer = 10 * 1024 * 1024;
|
|
217
|
+
const isPosix = process.platform !== 'win32';
|
|
218
|
+
|
|
219
|
+
const killProcessTree = (sig = 'SIGTERM') => {
|
|
220
|
+
if (!child || child.killed) return;
|
|
221
|
+
try {
|
|
222
|
+
if (isPosix && child.pid) {
|
|
223
|
+
try { process.kill(-child.pid, sig); } catch {}
|
|
224
|
+
} else if (child.pid) {
|
|
225
|
+
child.kill(sig);
|
|
181
226
|
}
|
|
227
|
+
} catch {}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const abortHandler = () => {
|
|
231
|
+
killProcessTree('SIGTERM');
|
|
232
|
+
setTimeout(() => killProcessTree('SIGKILL'), 300);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (signal) {
|
|
236
|
+
if (signal.aborted) {
|
|
237
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
238
|
+
}
|
|
239
|
+
signal.addEventListener('abort', abortHandler, { once: true });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const runEnv = { ...invocation.env };
|
|
243
|
+
if (execOptions?.prevOutput !== undefined) {
|
|
244
|
+
runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
|
|
245
|
+
}
|
|
246
|
+
const options = {
|
|
247
|
+
cwd,
|
|
248
|
+
env: runEnv,
|
|
249
|
+
detached: isPosix,
|
|
250
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
if (invocation.file) {
|
|
254
|
+
child = spawn(invocation.file, invocation.args || [], options);
|
|
255
|
+
} else {
|
|
256
|
+
const shellPath = isPosix ? (process.env.SHELL || '/bin/bash') : (process.env.ComSpec || 'cmd.exe');
|
|
257
|
+
const shellArgs = isPosix ? ['-c', invocation.shellCommand] : ['/d', '/s', '/c', invocation.shellCommand];
|
|
258
|
+
child = spawn(shellPath, shellArgs, options);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
child.stdout.on('data', (chunk) => {
|
|
262
|
+
if (stdout.length < maxBuffer) stdout += chunk;
|
|
263
|
+
});
|
|
264
|
+
child.stderr.on('data', (chunk) => {
|
|
265
|
+
if (stderr.length < maxBuffer) stderr += chunk;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
child.on('error', (err) => {
|
|
269
|
+
if (settled) return;
|
|
270
|
+
settled = true;
|
|
271
|
+
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
272
|
+
reject(err);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
child.on('close', (code, sig) => {
|
|
276
|
+
if (settled) return;
|
|
277
|
+
settled = true;
|
|
278
|
+
if (signal) signal.removeEventListener('abort', abortHandler);
|
|
279
|
+
|
|
280
|
+
if (signal && signal.aborted) {
|
|
281
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (code !== 0 && code !== null) {
|
|
182
285
|
const detail = String(stderr || '').trim();
|
|
183
|
-
|
|
286
|
+
const msg = `${runtime} task exited with code ${code}`;
|
|
287
|
+
return reject(new Error(detail ? `${msg}\n${detail}` : msg));
|
|
184
288
|
}
|
|
289
|
+
|
|
185
290
|
const out = (stdout || stderr || '').trim();
|
|
186
291
|
resolve({
|
|
187
292
|
output: out || `${runtime} task completed with code 0`,
|
|
188
293
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
189
294
|
costUsd: 0,
|
|
190
295
|
});
|
|
191
|
-
};
|
|
192
|
-
const options = { cwd, signal, maxBuffer: 10 * 1024 * 1024, env: invocation.env };
|
|
193
|
-
if (invocation.file) {
|
|
194
|
-
execFile(invocation.file, invocation.args || [], options, done);
|
|
195
|
-
} else {
|
|
196
|
-
const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
|
|
197
|
-
exec(invocation.shellCommand, { ...options, shell: shellPath }, done);
|
|
198
|
-
}
|
|
296
|
+
});
|
|
199
297
|
});
|
|
200
298
|
}
|
|
201
299
|
|
|
202
|
-
async _run(task, signal) {
|
|
300
|
+
async _run(task, signal, options = {}) {
|
|
203
301
|
console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
|
|
204
302
|
|
|
205
303
|
// 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
|
|
206
304
|
const runtime = normalizeTaskType(task.type);
|
|
207
305
|
if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
|
|
208
|
-
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
|
|
306
|
+
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal, options);
|
|
209
307
|
}
|
|
210
308
|
|
|
211
309
|
// 2. Agent session, when the harness exposes one
|
|
212
310
|
if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
|
|
213
|
-
return this._runAgentSession(task, runtime, signal);
|
|
311
|
+
return this._runAgentSession(task, runtime, signal, options);
|
|
214
312
|
}
|
|
215
313
|
|
|
216
314
|
// 3. Legacy session service
|
|
217
315
|
if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
|
|
218
|
-
return this._runLegacySession(task);
|
|
316
|
+
return this._runLegacySession(task, options);
|
|
219
317
|
}
|
|
220
318
|
|
|
221
319
|
// 4. Nothing to run through: report the prompt as triggered
|
|
222
|
-
return this._runBare(task);
|
|
320
|
+
return this._runBare(task, options);
|
|
223
321
|
}
|
|
224
322
|
|
|
225
323
|
/** Legacy session service path; falls back to the bare report. */
|
|
@@ -242,7 +340,7 @@ export class SessionRunner {
|
|
|
242
340
|
return this._runBare(task);
|
|
243
341
|
}
|
|
244
342
|
|
|
245
|
-
_runBare(task) {
|
|
343
|
+
_runBare(task, execOptions = {}) {
|
|
246
344
|
return {
|
|
247
345
|
output: `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`,
|
|
248
346
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
@@ -250,9 +348,18 @@ export class SessionRunner {
|
|
|
250
348
|
};
|
|
251
349
|
}
|
|
252
350
|
|
|
253
|
-
async _runAgentSession(task, runtime, signal) {
|
|
351
|
+
async _runAgentSession(task, runtime, signal, execOptions = {}) {
|
|
254
352
|
try {
|
|
255
|
-
|
|
353
|
+
let effectiveTask = task;
|
|
354
|
+
if (execOptions?.prevOutput) {
|
|
355
|
+
const prev = String(execOptions.prevOutput);
|
|
356
|
+
const prompt = task.prompt || '';
|
|
357
|
+
const newPrompt = prompt.includes('{{prevOutput}}')
|
|
358
|
+
? prompt.replaceAll('{{prevOutput}}', prev)
|
|
359
|
+
: `[Previous Output]:\n${prev}\n\n${prompt}`;
|
|
360
|
+
effectiveTask = { ...task, prompt: newPrompt };
|
|
361
|
+
}
|
|
362
|
+
const prep = await this._prepareAgentRun(effectiveTask, runtime, signal);
|
|
256
363
|
return await this._executeAgentTurn(task, prep);
|
|
257
364
|
} catch (err) {
|
|
258
365
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|
package/lib/scheduler.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Cron } from 'croner';
|
|
2
|
+
import { parseLlmActionDirectives, executeLlmActionDirectives } from './llm-actions.js';
|
|
2
3
|
import { applySilentRule } from './silent-rule.js';
|
|
3
4
|
import { inspectFailure } from './failure-inspector.js';
|
|
4
5
|
|
|
@@ -169,7 +170,7 @@ export class TaskScheduler {
|
|
|
169
170
|
constructor(store, executeFn, options = {}) {
|
|
170
171
|
this.store = store;
|
|
171
172
|
this.executeFn = executeFn;
|
|
172
|
-
this.maxConcurrent = Number(options.maxConcurrent)
|
|
173
|
+
this.maxConcurrent = options.maxConcurrent !== undefined && Number(options.maxConcurrent) >= 0 ? Number(options.maxConcurrent) : 2; // default 2 parallel runs (#134, #52)
|
|
173
174
|
this.defaultTimezone = options.defaultTimezone || ''; // #13
|
|
174
175
|
this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
|
|
175
176
|
this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
|
|
@@ -403,7 +404,7 @@ export class TaskScheduler {
|
|
|
403
404
|
}
|
|
404
405
|
|
|
405
406
|
/** Run the task body once and normalise whatever the executor returned. */
|
|
406
|
-
async executeOnce(task, signal) {
|
|
407
|
+
async executeOnce(task, signal, options = {}) {
|
|
407
408
|
const result = {
|
|
408
409
|
status: 'success',
|
|
409
410
|
output: '',
|
|
@@ -416,7 +417,7 @@ export class TaskScheduler {
|
|
|
416
417
|
};
|
|
417
418
|
if (typeof this.executeFn !== 'function') return result;
|
|
418
419
|
try {
|
|
419
|
-
const res = await this.executeFn(task, { signal });
|
|
420
|
+
const res = await this.executeFn(task, { signal, ...options });
|
|
420
421
|
if (typeof res === 'object' && res !== null) {
|
|
421
422
|
result.output = res.output || '';
|
|
422
423
|
result.usage = res.usage || result.usage;
|
|
@@ -444,8 +445,8 @@ export class TaskScheduler {
|
|
|
444
445
|
* afterwards — and usage and cost of BOTH attempts are summed, because both
|
|
445
446
|
* were really spent.
|
|
446
447
|
*/
|
|
447
|
-
async executeWithFallback(task, signal) {
|
|
448
|
-
const primary = await this.executeOnce(task, signal);
|
|
448
|
+
async executeWithFallback(task, signal, options = {}) {
|
|
449
|
+
const primary = await this.executeOnce(task, signal, options);
|
|
449
450
|
if (!this.shouldUseFallback(task, primary)) return primary;
|
|
450
451
|
|
|
451
452
|
const fallbackTask = {
|
|
@@ -454,7 +455,7 @@ export class TaskScheduler {
|
|
|
454
455
|
model: task.fallbackModel,
|
|
455
456
|
};
|
|
456
457
|
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);
|
|
458
|
+
const secondary = await this.executeOnce(fallbackTask, signal, options);
|
|
458
459
|
return {
|
|
459
460
|
...secondary,
|
|
460
461
|
fallback: true,
|
|
@@ -472,7 +473,7 @@ export class TaskScheduler {
|
|
|
472
473
|
}
|
|
473
474
|
|
|
474
475
|
|
|
475
|
-
async runTask(taskId) {
|
|
476
|
+
async runTask(taskId, options = {}) {
|
|
476
477
|
const task = this.store.get(taskId);
|
|
477
478
|
if (!task) return;
|
|
478
479
|
|
|
@@ -480,15 +481,15 @@ export class TaskScheduler {
|
|
|
480
481
|
if (currentRun === null) return;
|
|
481
482
|
|
|
482
483
|
const start = Date.now();
|
|
483
|
-
const outcome = await this.executeWithFallback(task, currentRun.controller.signal);
|
|
484
|
-
await this.completeRun(task, taskId, currentRun, outcome, start);
|
|
484
|
+
const outcome = await this.executeWithFallback(task, currentRun.controller.signal, options);
|
|
485
|
+
await this.completeRun(task, taskId, currentRun, outcome, start, options);
|
|
485
486
|
}
|
|
486
487
|
|
|
487
488
|
/**
|
|
488
489
|
* Record the run, release the run slot and hand the result to delivery and
|
|
489
490
|
* bookkeeping. Kept separate from runTask so the run flow reads in steps.
|
|
490
491
|
*/
|
|
491
|
-
async completeRun(task, taskId, currentRun, outcome, start) {
|
|
492
|
+
async completeRun(task, taskId, currentRun, outcome, start, options = {}) {
|
|
492
493
|
const runInfo = {
|
|
493
494
|
at: start,
|
|
494
495
|
status: outcome.status,
|
|
@@ -522,6 +523,55 @@ export class TaskScheduler {
|
|
|
522
523
|
|
|
523
524
|
if (!silent.skipped) await this.deliverNotifications(task, runInfo);
|
|
524
525
|
this.finishRun(task, taskId, outcome.status, queuedCount);
|
|
526
|
+
|
|
527
|
+
// 1. Structured LLM Actions (#137)
|
|
528
|
+
if (task.type === 'llm' && outcome.status === 'success' && outcome.output) {
|
|
529
|
+
try {
|
|
530
|
+
const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
|
|
531
|
+
if (settings.llmActionsEnabled) {
|
|
532
|
+
const directives = parseLlmActionDirectives(outcome.output);
|
|
533
|
+
if (directives.length > 0) {
|
|
534
|
+
await executeLlmActionDirectives({
|
|
535
|
+
directives,
|
|
536
|
+
task,
|
|
537
|
+
runInfo,
|
|
538
|
+
scheduler: this,
|
|
539
|
+
store: this.store,
|
|
540
|
+
settings,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
} catch (actErr) {
|
|
545
|
+
console.warn(`[dsh-cron] structured action error for ${taskId}:`, actErr.message);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 2. Task Chaining (#137)
|
|
550
|
+
const isSuccess = outcome.status === 'success';
|
|
551
|
+
const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
|
|
552
|
+
const nextTaskId = isSuccess ? task.onSuccess : (isFailure ? task.onFailure : null);
|
|
553
|
+
|
|
554
|
+
if (nextTaskId && typeof nextTaskId === 'string' && nextTaskId.trim()) {
|
|
555
|
+
const currentDepth = options.chainDepth || 0;
|
|
556
|
+
if (currentDepth < 4) {
|
|
557
|
+
const targetId = nextTaskId.trim();
|
|
558
|
+
const nextTask = this.store.get(targetId);
|
|
559
|
+
if (nextTask) {
|
|
560
|
+
console.log(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
|
|
561
|
+
this.runNow(targetId, {
|
|
562
|
+
chainDepth: currentDepth + 1,
|
|
563
|
+
prevOutput: outcome.output || outcome.error || '',
|
|
564
|
+
prevTaskId: task.id,
|
|
565
|
+
}).catch(err => {
|
|
566
|
+
console.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
|
|
567
|
+
});
|
|
568
|
+
} else {
|
|
569
|
+
console.warn(`[dsh-cron] chained task target not found: ${targetId}`);
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
console.warn(`[dsh-cron] chained task depth limit (5) reached for task ${task.id} -> ${nextTaskId}. Loop prevented.`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
525
575
|
}
|
|
526
576
|
|
|
527
577
|
/**
|
|
@@ -725,7 +775,11 @@ export class TaskScheduler {
|
|
|
725
775
|
}
|
|
726
776
|
}
|
|
727
777
|
|
|
728
|
-
triggerManualRun(taskId) {
|
|
729
|
-
return this.runTask(taskId);
|
|
778
|
+
triggerManualRun(taskId, options = {}) {
|
|
779
|
+
return this.runTask(taskId, options);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
runNow(taskId, options = {}) {
|
|
783
|
+
return this.runTask(taskId, options);
|
|
730
784
|
}
|
|
731
785
|
}
|
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,6 +236,8 @@ 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),
|
|
238
242
|
totalTokens: prev.totalTokens || 0,
|
|
239
243
|
totalCostUsd: prev.totalCostUsd || 0,
|
|
@@ -295,16 +299,99 @@ export class TaskStore {
|
|
|
295
299
|
suggestion: runInfo.suggestion ? String(runInfo.suggestion).slice(0, 2000) : '',
|
|
296
300
|
confidence: runInfo.confidence || '',
|
|
297
301
|
});
|
|
298
|
-
|
|
302
|
+
// Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
|
|
303
|
+
if (runs.length > 100) {
|
|
304
|
+
const overflow = runs.splice(100);
|
|
305
|
+
this._archiveHistoryRuns(id, overflow);
|
|
306
|
+
}
|
|
299
307
|
this.history.set(id, runs);
|
|
300
308
|
this.save();
|
|
301
309
|
}
|
|
302
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Append rotated overflow history entries into tasks-history-archive.json (#134).
|
|
313
|
+
*/
|
|
314
|
+
_archiveHistoryRuns(id, overflowRuns) {
|
|
315
|
+
if (!Array.isArray(overflowRuns) || overflowRuns.length === 0) return;
|
|
316
|
+
try {
|
|
317
|
+
const dir = path.dirname(this.filePath);
|
|
318
|
+
const archivePath = path.join(dir, 'tasks-history-archive.json');
|
|
319
|
+
let archive = {};
|
|
320
|
+
if (fs.existsSync(archivePath)) {
|
|
321
|
+
try {
|
|
322
|
+
archive = JSON.parse(fs.readFileSync(archivePath, 'utf-8')) || {};
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
if (!Array.isArray(archive[id])) archive[id] = [];
|
|
326
|
+
archive[id].push(...overflowRuns);
|
|
327
|
+
// Write atomically with tmp file
|
|
328
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
329
|
+
const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
330
|
+
fs.writeFileSync(tmp, JSON.stringify(archive, null, 2), 'utf-8');
|
|
331
|
+
fs.renameSync(tmp, archivePath);
|
|
332
|
+
} catch (archiveErr) {
|
|
333
|
+
console.error('[dsh-cron] history archive error:', archiveErr.message);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
303
337
|
getHistory(id, limit = 20) {
|
|
304
338
|
const runs = this.history.get(id) || [];
|
|
305
339
|
return runs.slice(0, limit);
|
|
306
340
|
}
|
|
307
341
|
|
|
342
|
+
getArchivedRuns(id, { limit = 50, offset = 0, search = '' } = {}) {
|
|
343
|
+
try {
|
|
344
|
+
const dir = path.dirname(this.filePath);
|
|
345
|
+
const archivePath = path.join(dir, 'tasks-history-archive.json');
|
|
346
|
+
if (!fs.existsSync(archivePath)) return { runs: [], total: 0 };
|
|
347
|
+
const archive = JSON.parse(fs.readFileSync(archivePath, 'utf-8')) || {};
|
|
348
|
+
let runs = Array.isArray(archive[id]) ? archive[id] : [];
|
|
349
|
+
if (search && search.trim()) {
|
|
350
|
+
const q = search.toLowerCase();
|
|
351
|
+
runs = runs.filter(r => (r.output && r.output.toLowerCase().includes(q)) || (r.error && r.error.toLowerCase().includes(q)));
|
|
352
|
+
}
|
|
353
|
+
const total = runs.length;
|
|
354
|
+
const paged = runs.slice(offset, offset + limit);
|
|
355
|
+
return { runs: paged, total };
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return { runs: [], total: 0, error: err.message };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
getTaskStats(id) {
|
|
362
|
+
const runs = this.history.get(id) || [];
|
|
363
|
+
const total = runs.length;
|
|
364
|
+
let success = 0;
|
|
365
|
+
let error = 0;
|
|
366
|
+
let timeout = 0;
|
|
367
|
+
const recentDurations = [];
|
|
368
|
+
|
|
369
|
+
for (let i = 0; i < runs.length; i++) {
|
|
370
|
+
const r = runs[i];
|
|
371
|
+
if (r.status === 'success') success++;
|
|
372
|
+
else if (r.status === 'error') error++;
|
|
373
|
+
else if (r.status === 'timeout') timeout++;
|
|
374
|
+
|
|
375
|
+
if (recentDurations.length < 10 && r.durationMs !== undefined) {
|
|
376
|
+
recentDurations.push({
|
|
377
|
+
at: r.at,
|
|
378
|
+
durationMs: r.durationMs,
|
|
379
|
+
status: r.status,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const successRate = total > 0 ? Number((success / total).toFixed(2)) : 1.0;
|
|
385
|
+
return {
|
|
386
|
+
totalRuns: total,
|
|
387
|
+
successRuns: success,
|
|
388
|
+
errorRuns: error,
|
|
389
|
+
timeoutRuns: timeout,
|
|
390
|
+
successRate,
|
|
391
|
+
recentDurations,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
308
395
|
getAggregatedStats() {
|
|
309
396
|
let totalRuns = 0;
|
|
310
397
|
let totalTokens = 0;
|
package/lib/task-transfer.js
CHANGED
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',
|