@goodandready/dsh-cron 0.2.9 → 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 +7 -0
- package/lib/api.js +131 -0
- package/lib/channels.js +3 -2
- package/lib/client.js +19 -0
- package/lib/index.js +3 -2
- package/lib/llm-actions.js +141 -0
- package/lib/metrics.js +24 -1
- package/lib/runner.js +24 -11
- package/lib/scheduler.js +65 -11
- package/lib/store.js +57 -0
- package/lib/task-transfer.js +2 -0
- package/lib/telegram.js +43 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -325,6 +325,13 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
|
|
|
325
325
|
- **History Rotation & Archival**: Active store holds the latest 100 runs per task; older entries are automatically archived into `tasks-history-archive.json`.
|
|
326
326
|
- **Autonomous PR Reviewer Recipe (#33)**: Preconfigured recipe in Template Hub with optional toggle `prReviewerEnabled` in settings.
|
|
327
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
|
+
|
|
328
335
|
---
|
|
329
336
|
|
|
330
337
|
## 📦 Installation
|
package/lib/api.js
CHANGED
|
@@ -12,6 +12,7 @@ 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;
|
|
@@ -346,6 +352,21 @@ function applyImportPlan({ store, scheduler, plan }) {
|
|
|
346
352
|
// ------------------------------------------------------------ single task
|
|
347
353
|
|
|
348
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
|
+
}
|
|
349
370
|
if (req.method === 'GET' && (action === 'history' || !action)) {
|
|
350
371
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
351
372
|
sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
|
|
@@ -550,3 +571,113 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
|
|
|
550
571
|
}
|
|
551
572
|
sendJson(res, 405, NOT_ALLOWED);
|
|
552
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);
|
|
@@ -1465,6 +1467,8 @@ window.__ModuleLoader__.load({
|
|
|
1465
1467
|
setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
|
|
1466
1468
|
setFormOverlapPolicy(task.overlapPolicy || 'skip');
|
|
1467
1469
|
setFormKanbanMode(task.kanbanMode || 'none');
|
|
1470
|
+
setFormOnSuccess(task.onSuccess || '');
|
|
1471
|
+
setFormOnFailure(task.onFailure || '');
|
|
1468
1472
|
setFormTimezone(task.timezone || '');
|
|
1469
1473
|
setFormMisfirePolicy(task.misfirePolicy || 'skip');
|
|
1470
1474
|
setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
|
|
@@ -1527,6 +1531,8 @@ window.__ModuleLoader__.load({
|
|
|
1527
1531
|
maxRetries: Number(formMaxRetries) || 0,
|
|
1528
1532
|
permissionPreset: formPermissionPreset,
|
|
1529
1533
|
kanbanMode: formKanbanMode,
|
|
1534
|
+
onSuccess: formOnSuccess.trim() || undefined,
|
|
1535
|
+
onFailure: formOnFailure.trim() || undefined,
|
|
1530
1536
|
env: parseEnvText(formEnv),
|
|
1531
1537
|
cwd: formCwd.trim(),
|
|
1532
1538
|
workspaceId: formWorkspaceId.trim(),
|
|
@@ -1832,6 +1838,8 @@ window.__ModuleLoader__.load({
|
|
|
1832
1838
|
className: 'dsh-cron-config-tag',
|
|
1833
1839
|
title: T_KEY(t, 'list.configManagedHint')
|
|
1834
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,
|
|
1835
1843
|
task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
|
|
1836
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
|
|
1837
1845
|
),
|
|
@@ -2653,6 +2661,7 @@ window.__ModuleLoader__.load({
|
|
|
2653
2661
|
const [chatId, setChatId] = React.useState('');
|
|
2654
2662
|
const [notify, setNotify] = React.useState(false);
|
|
2655
2663
|
const [onlyFailure, setOnlyFailure] = React.useState(false);
|
|
2664
|
+
const [llmActionsEnabled, setLlmActionsEnabled] = React.useState(false);
|
|
2656
2665
|
const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
|
|
2657
2666
|
const [delivery, setDelivery] = React.useState({});
|
|
2658
2667
|
const [sections, setSections] = React.useState({ channels: true });
|
|
@@ -2679,6 +2688,7 @@ window.__ModuleLoader__.load({
|
|
|
2679
2688
|
setChatId(data.settings.chatId || '');
|
|
2680
2689
|
setNotify(Boolean(data.settings.notifyTelegram));
|
|
2681
2690
|
setOnlyFailure(Boolean(data.settings.onlyOnFailure));
|
|
2691
|
+
setLlmActionsEnabled(Boolean(data.settings.llmActionsEnabled));
|
|
2682
2692
|
setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
|
|
2683
2693
|
setDelivery(pickDeliverySettings(data.settings));
|
|
2684
2694
|
setHasDefault(Boolean(data.settings.hasDefaultCredentials));
|
|
@@ -2702,6 +2712,7 @@ window.__ModuleLoader__.load({
|
|
|
2702
2712
|
chatId,
|
|
2703
2713
|
notifyTelegram: notify,
|
|
2704
2714
|
onlyOnFailure: onlyFailure,
|
|
2715
|
+
llmActionsEnabled: llmActionsEnabled,
|
|
2705
2716
|
kanbanBaseUrl: kanbanUrl
|
|
2706
2717
|
}, delivery))
|
|
2707
2718
|
});
|
|
@@ -2831,6 +2842,14 @@ window.__ModuleLoader__.load({
|
|
|
2831
2842
|
onChange: (e) => setOnlyFailure(e.target.checked)
|
|
2832
2843
|
}),
|
|
2833
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)')
|
|
2834
2853
|
)
|
|
2835
2854
|
),
|
|
2836
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';
|
|
@@ -527,7 +527,8 @@ export function apply(ctx, config) {
|
|
|
527
527
|
}
|
|
528
528
|
|
|
529
529
|
const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
|
|
530
|
-
|
|
530
|
+
const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
|
|
531
|
+
await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
|
|
531
532
|
sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
|
|
532
533
|
} catch (err) {
|
|
533
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/runner.js
CHANGED
|
@@ -54,7 +54,7 @@ export class SessionRunner {
|
|
|
54
54
|
}, timeoutMs);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
const executionPromise = this._run(task, controller.signal);
|
|
57
|
+
const executionPromise = this._run(task, controller.signal, options);
|
|
58
58
|
|
|
59
59
|
try {
|
|
60
60
|
return await Promise.race([executionPromise, timeoutPromise]);
|
|
@@ -195,7 +195,7 @@ export class SessionRunner {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
|
|
198
|
-
async _runExternal(task, runtime, signal) {
|
|
198
|
+
async _runExternal(task, runtime, signal, execOptions = {}) {
|
|
199
199
|
if (runtime === 'ssh' && task.sshProfileId) {
|
|
200
200
|
const viaService = await this._runViaRemoteWorkspace(task, signal);
|
|
201
201
|
if (viaService) return viaService;
|
|
@@ -239,9 +239,13 @@ export class SessionRunner {
|
|
|
239
239
|
signal.addEventListener('abort', abortHandler, { once: true });
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
const runEnv = { ...invocation.env };
|
|
243
|
+
if (execOptions?.prevOutput !== undefined) {
|
|
244
|
+
runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
|
|
245
|
+
}
|
|
242
246
|
const options = {
|
|
243
247
|
cwd,
|
|
244
|
-
env:
|
|
248
|
+
env: runEnv,
|
|
245
249
|
detached: isPosix,
|
|
246
250
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
247
251
|
};
|
|
@@ -293,27 +297,27 @@ export class SessionRunner {
|
|
|
293
297
|
});
|
|
294
298
|
}
|
|
295
299
|
|
|
296
|
-
async _run(task, signal) {
|
|
300
|
+
async _run(task, signal, options = {}) {
|
|
297
301
|
console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
|
|
298
302
|
|
|
299
303
|
// 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
|
|
300
304
|
const runtime = normalizeTaskType(task.type);
|
|
301
305
|
if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
|
|
302
|
-
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
|
|
306
|
+
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal, options);
|
|
303
307
|
}
|
|
304
308
|
|
|
305
309
|
// 2. Agent session, when the harness exposes one
|
|
306
310
|
if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
|
|
307
|
-
return this._runAgentSession(task, runtime, signal);
|
|
311
|
+
return this._runAgentSession(task, runtime, signal, options);
|
|
308
312
|
}
|
|
309
313
|
|
|
310
314
|
// 3. Legacy session service
|
|
311
315
|
if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
|
|
312
|
-
return this._runLegacySession(task);
|
|
316
|
+
return this._runLegacySession(task, options);
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
// 4. Nothing to run through: report the prompt as triggered
|
|
316
|
-
return this._runBare(task);
|
|
320
|
+
return this._runBare(task, options);
|
|
317
321
|
}
|
|
318
322
|
|
|
319
323
|
/** Legacy session service path; falls back to the bare report. */
|
|
@@ -336,7 +340,7 @@ export class SessionRunner {
|
|
|
336
340
|
return this._runBare(task);
|
|
337
341
|
}
|
|
338
342
|
|
|
339
|
-
_runBare(task) {
|
|
343
|
+
_runBare(task, execOptions = {}) {
|
|
340
344
|
return {
|
|
341
345
|
output: `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`,
|
|
342
346
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
@@ -344,9 +348,18 @@ export class SessionRunner {
|
|
|
344
348
|
};
|
|
345
349
|
}
|
|
346
350
|
|
|
347
|
-
async _runAgentSession(task, runtime, signal) {
|
|
351
|
+
async _runAgentSession(task, runtime, signal, execOptions = {}) {
|
|
348
352
|
try {
|
|
349
|
-
|
|
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);
|
|
350
363
|
return await this._executeAgentTurn(task, prep);
|
|
351
364
|
} catch (err) {
|
|
352
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
|
|
|
@@ -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,
|
|
@@ -335,6 +339,59 @@ export class TaskStore {
|
|
|
335
339
|
return runs.slice(0, limit);
|
|
336
340
|
}
|
|
337
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
|
+
|
|
338
395
|
getAggregatedStats() {
|
|
339
396
|
let totalRuns = 0;
|
|
340
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',
|