@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 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
- await sendTelegramMessage({ botToken, chatId, text, fetchFn: http, signal });
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
- await sendTelegramMessage({ botToken, chatId, text });
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: invocation.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
- const prep = await this._prepareAgentRun(task, runtime, signal);
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;
@@ -53,6 +53,8 @@ export const PATCHABLE_TASK_FIELDS = [
53
53
  'timeoutSeconds',
54
54
  'overlapPolicy',
55
55
  'kanbanMode',
56
+ 'onSuccess',
57
+ 'onFailure',
56
58
  'channels',
57
59
  'template',
58
60
  'status',
package/lib/telegram.js CHANGED
@@ -84,6 +84,25 @@ export function escapeMarkdown(str) {
84
84
  return String(str).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
85
85
  }
86
86
 
87
+ /**
88
+ * Create Telegram inline keyboard for interactive task controls (#137).
89
+ */
90
+ export function createTelegramTaskKeyboard(task, runInfo = {}) {
91
+ const isPaused = task && task.status === 'paused';
92
+ const taskId = task?.id || '';
93
+ return {
94
+ inline_keyboard: [
95
+ [
96
+ { text: '▶️ Run Now', callback_data: `cron:run:${taskId}` },
97
+ { text: isPaused ? '▶️ Resume' : '⏸ Pause', callback_data: `cron:pause:${taskId}` },
98
+ ],
99
+ [
100
+ { text: '📄 Last Output', callback_data: `cron:log:${taskId}` },
101
+ ]
102
+ ]
103
+ };
104
+ }
105
+
87
106
  /**
88
107
  * Format task execution report for Telegram.
89
108
  * Dynamic values are escaped so titles/schedules cannot break the markup (#93).
@@ -116,10 +135,32 @@ export function formatTaskTelegramMessage(task, runInfo) {
116
135
  return lines.join('\n');
117
136
  }
118
137
 
138
+ /**
139
+ * Answer a Telegram callback query (#137).
140
+ */
141
+ export async function answerTelegramCallbackQuery({ botToken, callbackQueryId, text = '', showAlert = false, fetchFn = globalThis.fetch }) {
142
+ if (!botToken || !callbackQueryId) return;
143
+ const url = `https://api.telegram.org/bot${botToken}/answerCallbackQuery`;
144
+ const body = {
145
+ callback_query_id: callbackQueryId,
146
+ text: text ? String(text).slice(0, 200) : undefined,
147
+ show_alert: Boolean(showAlert),
148
+ };
149
+ try {
150
+ await fetchFn(url, {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify(body),
154
+ });
155
+ } catch (err) {
156
+ console.warn('[dsh-cron] answerCallbackQuery failed:', err.message);
157
+ }
158
+ }
159
+
119
160
  /**
120
161
  * Send telegram message via Bot API.
121
162
  */
122
- export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown', fetchFn = globalThis.fetch, signal }) {
163
+ export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown', replyMarkup, fetchFn = globalThis.fetch, signal }) {
123
164
  if (!botToken) throw new Error('Telegram botToken is not configured');
124
165
  if (!chatId) throw new Error('Telegram chatId is not configured');
125
166
 
@@ -130,6 +171,7 @@ export async function sendTelegramMessage({ botToken, chatId, text, parseMode =
130
171
  disable_notification: false,
131
172
  };
132
173
  if (parseMode) body.parse_mode = parseMode;
174
+ if (replyMarkup) body.reply_markup = replyMarkup;
133
175
 
134
176
  const res = await fetchFn(url, {
135
177
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",