@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 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
- sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
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
- 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);
@@ -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
- const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
1041
- fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
1042
- .then(res => res.json())
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
- }, 8000);
1052
- return () => clearInterval(pollInterval);
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
- await sendTelegramMessage({ botToken, chatId, text });
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
- const res = await fetch(request.url, {
105
- method: request.method,
106
- headers: request.headers,
107
- body: request.body,
108
- signal,
109
- });
110
- const text = await res.text().catch(() => '');
111
- const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
112
- if (!res.ok) {
113
- throw new Error(`${summary}\n${String(text).slice(0, 500)}`);
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 { exec, execFile } = await import('node:child_process');
210
+ const { spawn } = await import('node:child_process');
176
211
  return new Promise((resolve, reject) => {
177
- const done = (err, stdout, stderr) => {
178
- if (err) {
179
- if (signal && signal.aborted) {
180
- return reject(signal.reason || new Error('Command aborted'));
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
- return reject(new Error(detail ? `${err.message}\n${detail}` : err.message));
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
- 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);
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) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
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
- if (runs.length > 50) runs.length = 50;
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;
@@ -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.8",
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",