@goodandready/dsh-cron 0.1.15 → 0.1.17

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
@@ -116,6 +116,15 @@ Direct integration with the Telegram Bot API delivers execution reports and erro
116
116
  * **Markdown Formatting**: Messages are formatted with status badges (✅ / ❌), execution duration in milliseconds, schedule descriptions, and monospace code blocks.
117
117
  * **Test Dispatch Button**: Verify Telegram connectivity on the spot before scheduling critical production jobs.
118
118
 
119
+ ### 7. Overlap Policies & Execution Timeout Control (Issues #11, #17)
120
+ Prevent rogue processes from consuming server resources or stacking concurrent duplicate executions:
121
+
122
+ * **Execution Timeout (`timeoutSeconds`)**: Automatically cancels agent sessions or kills shell subprocesses when the configured run time limit is reached (default: 1800s / 30m). Prevents hung tasks and records a descriptive timeout failure in run logs.
123
+ * **Overlap Policy (`overlapPolicy`)**: Controls scheduler behavior when a scheduled tick fires while the previous execution is still running:
124
+ * **`skip`** (default): Drops the overlapping run and records a `skipped` status entry in the run history without spamming.
125
+ * **`queue`**: Queues the next execution and starts it automatically as soon as the active job completes.
126
+ * **`replace`**: Aborts the stuck/active run immediately via `AbortController` and launches the fresh execution.
127
+
119
128
  ### 5. Schedule Expression Syntax
120
129
  Powered by `croner`, supporting both standard 5-part/6-part cron expressions and user-friendly interval aliases:
121
130
 
@@ -178,3 +187,8 @@ npm test
178
187
  ## 📄 License
179
188
 
180
189
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
190
+
191
+ ### Kanban & Token Cost Integration (v0.1.17)
192
+ - **Automatic Kanban Card Creation**: Automatically creates task/bug cards in `dsh-kanban` on task failure (`on_failure`) or every run (`always`).
193
+ - **Token & Execution Cost Meter**: Tracks token consumption (input, output, cache tokens) for LLM executions and estimates USD expenses using current model pricing.
194
+ - **Aggregated Analytics Bar**: Live dashboard displaying active jobs count, total executions, total token consumption, and aggregate estimated dollar spend.
package/docs/README.ru.md CHANGED
@@ -170,3 +170,8 @@ npm test
170
170
  ## 📄 Лицензия
171
171
 
172
172
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
173
+
174
+ ### Интеграция с Kanban и счетчик стоимости токенов (v0.1.17)
175
+ - **Автосоздание карточек в Kanban**: Автоматическое заведение задач в бэклог `dsh-kanban` при обнаружении сбоев/ошибок (`on_failure`) или при каждом завершении (`always`).
176
+ - **Трекинг токенов и расчет расходов (USD)**: Подсчет потребления токенов (input, output, cacheRead) для каждой задачи и запуск с расчетом примерной стоимости в долларах США.
177
+ - **Сводная панель статистики**: Отображение общего числа запусков, активных задач, суммарного расхода токенов и совокупных затрат прямо в заголовке планировщика.
package/lib/client.js CHANGED
@@ -136,9 +136,17 @@ const React = window.React || require('react');
136
136
  .dsh-cron-history-status { font-weight: 600; font-size: 11.5px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
137
137
  .dsh-cron-history-status[data-status="success"] { background: rgba(16, 185, 129, 0.15); color: #10b981; }
138
138
  .dsh-cron-history-status[data-status="error"] { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
139
+ .dsh-cron-history-status[data-status="skipped"] { background: rgba(234, 179, 8, 0.15); color: #eab308; }
139
140
  .dsh-cron-history-time { color: #777; font-size: 12px; }
140
141
  .dsh-cron-history-output { background: #0c0c0c; border: 1px solid #222; border-radius: 6px; padding: 8px 10px; font-family: monospace; font-size: 12px; color: #bbb; max-height: 140px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 6px; }
141
142
  .dsh-cron-history-empty { text-align: center; padding: 36px 0; color: #666; font-size: 13.5px; }
143
+
144
+ .dsh-cron-stats-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; }
145
+ .dsh-cron-stat-card { background: rgba(255, 255, 255, 0.02); border: 1px solid #222; border-radius: 8px; padding: 10px 14px; }
146
+ .dsh-cron-stat-val { font-size: 16px; font-weight: 600; color: #fff; }
147
+ .dsh-cron-stat-lbl { font-size: 11.5px; color: #777; margin-top: 2px; }
148
+ .dsh-cron-cost-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: rgba(16, 185, 129, 0.12); color: #10b981; font-weight: 500; }
149
+ .dsh-cron-tokens-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: rgba(59, 130, 246, 0.12); color: #60a5fa; }
142
150
  .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer; user-select: none; }
143
151
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: #ededed; }
144
152
  .dsh-cron-hint { font-size: 12px; color: #888; margin-top: 4px; }
@@ -248,6 +256,10 @@ const React = window.React || require('react');
248
256
  const [historyLoading, setHistoryLoading] = React.useState(false);
249
257
  const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
250
258
  const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
259
+ const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
260
+ const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
261
+ const [formKanbanMode, setFormKanbanMode] = React.useState('none');
262
+ const [stats, setStats] = React.useState(null);
251
263
 
252
264
  // Settings modal state
253
265
  const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
@@ -288,6 +300,7 @@ const React = window.React || require('react');
288
300
  if (data && data.ok) {
289
301
  setTasks(data.tasks || []);
290
302
  setRecs(data.recommendations || []);
303
+ if (data.stats) setStats(data.stats);
291
304
  }
292
305
  } catch (err) {}
293
306
  };
@@ -401,6 +414,9 @@ const React = window.React || require('react');
401
414
  setFormDelivery('isolated');
402
415
  setFormNotifyTelegram(false);
403
416
  setFormOnlyOnFailure(false);
417
+ setFormTimeoutSeconds(1800);
418
+ setFormOverlapPolicy('skip');
419
+ setFormKanbanMode('none');
404
420
  if (preset) {
405
421
  setFormTitle(preset.title || '');
406
422
  setFormSchedule(preset.schedule || '');
@@ -448,6 +464,9 @@ const React = window.React || require('react');
448
464
  setFormDelivery(task.delivery || 'isolated');
449
465
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
450
466
  setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
467
+ setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
468
+ setFormOverlapPolicy(task.overlapPolicy || 'skip');
469
+ setFormKanbanMode(task.kanbanMode || 'none');
451
470
  if (task.provider) loadModels(task.provider);
452
471
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
453
472
  loadTaskHistory(task.id);
@@ -473,6 +492,11 @@ const React = window.React || require('react');
473
492
  delivery: formDelivery || 'isolated',
474
493
  provider: formType === 'script' ? undefined : (formProvider || undefined),
475
494
  model: formType === 'script' ? undefined : (formModel || undefined),
495
+ notifyTelegram: formNotifyTelegram,
496
+ onlyOnFailure: formOnlyOnFailure,
497
+ timeoutSeconds: Number(formTimeoutSeconds) || 1800,
498
+ overlapPolicy: formOverlapPolicy,
499
+ kanbanMode: formKanbanMode,
476
500
  };
477
501
  const res = await fetch('/dsh-cron/tasks', {
478
502
  method: 'POST',
@@ -575,6 +599,24 @@ const React = window.React || require('react');
575
599
  onChange: (e) => setQuery(e.target.value)
576
600
  })
577
601
  ),
602
+ stats && React.createElement('div', { className: 'dsh-cron-stats-bar' },
603
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
604
+ React.createElement('div', { className: 'dsh-cron-stat-val' }, stats.activeTasks || 0),
605
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Активных задач')
606
+ ),
607
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
608
+ React.createElement('div', { className: 'dsh-cron-stat-val' }, stats.totalRuns || 0),
609
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Всего запусков')
610
+ ),
611
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
612
+ React.createElement('div', { className: 'dsh-cron-stat-val' }, (stats.totalTokens || 0).toLocaleString()),
613
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Токенов израсходовано')
614
+ ),
615
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
616
+ React.createElement('div', { className: 'dsh-cron-stat-val', style: { color: '#10b981' } }, '$' + (stats.totalCostUsd || 0).toFixed(4)),
617
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Оценочная стоимость')
618
+ )
619
+ ),
578
620
  React.createElement('div', { className: 'dsh-cron-tabs' },
579
621
  ['all', 'active', 'paused', 'completed'].map((t) =>
580
622
  React.createElement('button', {
@@ -611,7 +653,9 @@ const React = window.React || require('react');
611
653
  color: task.type === 'script' ? '#60a5fa' : '#34d399'
612
654
  }
613
655
  }, task.type === 'script' ? 'Shell' : 'LLM'),
614
- task.model ? React.createElement('span', { className: 'dsh-cron-task-model-tag' }, task.model.split('/').pop()) : null
656
+ task.model ? React.createElement('span', { className: 'dsh-cron-task-model-tag' }, task.model.split('/').pop()) : null,
657
+ task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
658
+ task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null
615
659
  ),
616
660
  React.createElement('div', { className: 'dsh-cron-task-sched' },
617
661
  task.scheduleText || task.schedule,
@@ -718,10 +762,14 @@ const React = window.React || require('react');
718
762
  React.createElement('span', {
719
763
  className: 'dsh-cron-history-status',
720
764
  'data-status': run.status
721
- }, run.status === 'success' ? 'Успешно' : 'Ошибка'),
765
+ }, run.status === 'success' ? 'Успешно' : run.status === 'skipped' ? 'Пропущен' : 'Ошибка'),
722
766
  React.createElement('span', { className: 'dsh-cron-history-time' }, run.at ? new Date(run.at).toLocaleString() : '')
723
767
  ),
724
- React.createElement('span', { style: { color: '#888', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
768
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
769
+ run.costUsd > 0 && React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + run.costUsd.toFixed(5)),
770
+ run.usage && (run.usage.inputTokens > 0 || run.usage.outputTokens > 0) && React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (run.usage.inputTokens + run.usage.outputTokens) + ' tok'),
771
+ React.createElement('span', { style: { color: '#888', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
772
+ )
725
773
  ),
726
774
  run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
727
775
  run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: '#ef4444', borderColor: '#552222' } }, run.error)
@@ -772,6 +820,40 @@ const React = window.React || require('react');
772
820
  })
773
821
  )
774
822
  ),
823
+ React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
824
+ React.createElement('div', { className: 'dsh-cron-form-group' },
825
+ React.createElement('label', null, 'Таймаут выполнения (секунды)'),
826
+ React.createElement('input', {
827
+ type: 'number',
828
+ min: 1,
829
+ value: formTimeoutSeconds,
830
+ placeholder: '1800',
831
+ onChange: (e) => setFormTimeoutSeconds(e.target.value)
832
+ })
833
+ ),
834
+ React.createElement('div', { className: 'dsh-cron-form-group' },
835
+ React.createElement('label', null, 'Политика наложения (Overlap)'),
836
+ React.createElement('select', {
837
+ value: formOverlapPolicy,
838
+ onChange: (e) => setFormOverlapPolicy(e.target.value)
839
+ },
840
+ React.createElement('option', { value: 'skip' }, 'Пропустить новый запуск (skip)'),
841
+ React.createElement('option', { value: 'queue' }, 'Встать в очередь (queue)'),
842
+ React.createElement('option', { value: 'replace' }, 'Отменить текущий и перезапустить (replace)')
843
+ )
844
+ )
845
+ ),
846
+ React.createElement('div', { className: 'dsh-cron-form-group' },
847
+ React.createElement('label', null, 'Создание карточки в Kanban (dsh-kanban)'),
848
+ React.createElement('select', {
849
+ value: formKanbanMode,
850
+ onChange: (e) => setFormKanbanMode(e.target.value)
851
+ },
852
+ React.createElement('option', { value: 'none' }, 'Не создавать карточки (отключено)'),
853
+ React.createElement('option', { value: 'on_failure' }, 'Создавать тикет при ошибке/сбое (в Backlog)'),
854
+ React.createElement('option', { value: 'always' }, 'Создавать карточку при каждом завершении (Done / Backlog)')
855
+ )
856
+ ),
775
857
  formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
776
858
  React.createElement('div', { className: 'dsh-cron-form-group' },
777
859
  React.createElement('label', null, 'Провайдер LLM'),
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { SessionRunner } from './runner.js';
4
4
  import { getModelsHandler } from './models-handler.js';
5
5
  import { chatStartHandler } from './chat-start.js';
6
6
  import { sendTelegramMessage } from './telegram.js';
7
+ import { createKanbanCard } from './integrations.js';
7
8
  import { defineTool } from '@deepseek-ai/dsh-tools';
8
9
  import { randomUUID } from 'node:crypto';
9
10
 
@@ -33,7 +34,7 @@ function sendJson(res, statusCode, data) {
33
34
  export function apply(ctx, config) {
34
35
  const store = new TaskStore();
35
36
  const runner = new SessionRunner(ctx);
36
- const scheduler = new TaskScheduler(store, (task) => runner.execute(task));
37
+ const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
37
38
 
38
39
  scheduler.start();
39
40
 
@@ -85,7 +86,8 @@ export function apply(ctx, config) {
85
86
  const status = url.searchParams.get('status') || 'all';
86
87
  const query = url.searchParams.get('query') || '';
87
88
  const list = store.list({ status, query });
88
- sendJson(res, 200, { ok: true, tasks: list, recommendations });
89
+ const stats = store.getAggregatedStats();
90
+ sendJson(res, 200, { ok: true, tasks: list, recommendations, stats });
89
91
  return;
90
92
  }
91
93
 
@@ -109,6 +111,9 @@ export function apply(ctx, config) {
109
111
  model: body.model || undefined,
110
112
  notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : false,
111
113
  onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : false,
114
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : 1800,
115
+ overlapPolicy: body.overlapPolicy || 'skip',
116
+ kanbanMode: body.kanbanMode || 'none',
112
117
  });
113
118
  scheduler.scheduleTask(task);
114
119
  sendJson(res, 200, { ok: true, task });
@@ -174,276 +179,282 @@ export function apply(ctx, config) {
174
179
  return;
175
180
  }
176
181
  const body = await parseJsonBody(req);
177
- const currentSettings = store.getSettings();
178
- const botToken = (body.botToken || currentSettings.botToken || '').trim();
179
- const chatId = (body.chatId || currentSettings.chatId || '').trim();
182
+ const settings = store.getSettings();
183
+ const botToken = body.botToken || settings.botToken;
184
+ const chatId = body.chatId || settings.chatId;
180
185
 
181
- if (!botToken) {
182
- sendJson(res, 400, { ok: false, error: 'Укажите Telegram Bot Token' });
186
+ if (!botToken || !chatId) {
187
+ sendJson(res, 400, { ok: false, error: 'Не указан botToken или chatId' });
183
188
  return;
184
189
  }
185
- if (!chatId) {
186
- sendJson(res, 400, { ok: false, error: 'Укажите Telegram Chat ID' });
190
+
191
+ const text = `🔔 *Тестовое оповещение dsh-cron*\n\nСвязь между DSH Cron и Telegram успешно настроена! Проверка времени: \`${new Date().toISOString()}\``;
192
+ await sendTelegramMessage({ botToken, chatId, text });
193
+ sendJson(res, 200, { ok: true, message: 'Тестовое сообщение успешно отправлено' });
194
+ } catch (err) {
195
+ sendJson(res, 500, { ok: false, error: err.message });
196
+ }
197
+ }
198
+ }), 'dsh-cron: /telegram/test');
199
+
200
+ // POST /dsh-cron/kanban/test
201
+ ctx.effect(() => ctx.webServer.register({
202
+ kind: 'exact',
203
+ path: '/dsh-cron/kanban/test',
204
+ handler: async (req, res) => {
205
+ try {
206
+ if (req.method !== 'POST') {
207
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
187
208
  return;
188
209
  }
210
+ const body = await parseJsonBody(req);
211
+ const settings = store.getSettings();
212
+ const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
189
213
 
190
- await sendTelegramMessage({
191
- botToken,
192
- chatId,
193
- text: '⏰ *DSH Cron — Проверка связи*\nТестовое уведомление успешно доставлено! Планировщик готов к отправке отчётов.',
194
- parseMode: 'Markdown'
214
+ const result = await createKanbanCard({
215
+ title: `[Тест] Проверка связи DSH Cron -> Kanban`,
216
+ body: `Тестовая карточка создана автоматически из модуля \`dsh-cron\` в ${new Date().toLocaleString('ru-RU')}.`,
217
+ board: 'main',
218
+ column: 'backlog',
219
+ labels: ['cron', 'test'],
220
+ kanbanBaseUrl
195
221
  });
196
222
 
197
- sendJson(res, 200, { ok: true, message: 'Сообщение успешно отправлено в Telegram' });
223
+ if (result.success) {
224
+ sendJson(res, 200, { ok: true, task: result.task });
225
+ } else {
226
+ sendJson(res, 500, { ok: false, error: result.error });
227
+ }
198
228
  } catch (err) {
199
229
  sendJson(res, 500, { ok: false, error: err.message });
200
230
  }
201
231
  }
202
- }), 'dsh-cron: /telegram/test');
232
+ }), 'dsh-cron: /kanban/test');
203
233
 
204
- // Prefix routes for actions (/dsh-cron/action/*)
234
+ // PATCH & DELETE /dsh-cron/tasks/:id
205
235
  ctx.effect(() => ctx.webServer.register({
206
236
  kind: 'prefix',
207
- path: '/dsh-cron/action',
237
+ path: '/dsh-cron/tasks/',
208
238
  handler: async (req, res) => {
209
239
  try {
210
240
  const url = new URL(req.url, 'http://127.0.0.1');
211
- const pathname = url.pathname;
241
+ const parts = url.pathname.split('/').filter(Boolean);
242
+ const id = parts[2];
243
+ const action = parts[3];
212
244
 
213
- const toggleMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/toggle$/);
214
- if (req.method === 'POST' && toggleMatch) {
215
- const taskId = toggleMatch[1];
216
- const task = store.get(taskId);
245
+ if (!id) {
246
+ sendJson(res, 400, { ok: false, error: 'ID задачи не указан' });
247
+ return;
248
+ }
249
+
250
+ if (req.method === 'GET' && action === 'history') {
251
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
252
+ const history = store.getHistory(id, limit);
253
+ sendJson(res, 200, { ok: true, history });
254
+ return;
255
+ }
256
+
257
+ if (req.method === 'POST' && action === 'run') {
258
+ await scheduler.triggerManualRun(id);
259
+ const task = store.get(id);
260
+ sendJson(res, 200, { ok: true, task });
261
+ return;
262
+ }
263
+
264
+ if (req.method === 'POST' && action === 'pause') {
265
+ const task = scheduler.pauseTask(id);
217
266
  if (!task) {
218
- sendJson(res, 404, { ok: false, error: 'Task not found' });
267
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
219
268
  return;
220
269
  }
221
- const updated = task.status === 'active'
222
- ? scheduler.pauseTask(taskId)
223
- : scheduler.resumeTask(taskId);
224
- sendJson(res, 200, { ok: true, task: updated });
270
+ sendJson(res, 200, { ok: true, task });
225
271
  return;
226
272
  }
227
273
 
228
- const runMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/run$/);
229
- if (req.method === 'POST' && runMatch) {
230
- const taskId = runMatch[1];
231
- scheduler.runTask(taskId);
232
- sendJson(res, 200, { ok: true });
274
+ if (req.method === 'POST' && action === 'resume') {
275
+ const task = scheduler.resumeTask(id);
276
+ if (!task) {
277
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
278
+ return;
279
+ }
280
+ sendJson(res, 200, { ok: true, task });
233
281
  return;
234
282
  }
235
283
 
236
- const deleteMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/delete$/);
237
- if (req.method === 'POST' && deleteMatch) {
238
- const taskId = deleteMatch[1];
239
- const ok = scheduler.removeTask(taskId);
240
- sendJson(res, 200, { ok });
284
+ if (req.method === 'PATCH') {
285
+ const body = await parseJsonBody(req);
286
+ const current = store.get(id);
287
+ if (!current) {
288
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
289
+ return;
290
+ }
291
+ const task = store.set({ ...current, ...body, id });
292
+ if (task.status === 'active') {
293
+ scheduler.scheduleTask(task);
294
+ }
295
+ sendJson(res, 200, { ok: true, task });
241
296
  return;
242
297
  }
243
298
 
244
- const historyMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/history$/);
245
- if (req.method === 'GET' && historyMatch) {
246
- const taskId = historyMatch[1];
247
- const history = store.getHistory(taskId, 30);
248
- sendJson(res, 200, { ok: true, history });
299
+ if (req.method === 'DELETE') {
300
+ const current = store.get(id);
301
+ if (current) {
302
+ scheduler.pauseTask(id);
303
+ store.delete(id);
304
+ }
305
+ sendJson(res, 200, { ok: true });
249
306
  return;
250
307
  }
251
308
 
252
- sendJson(res, 404, { ok: false, error: 'Action not found' });
309
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
253
310
  } catch (err) {
254
311
  sendJson(res, 500, { ok: false, error: err.message });
255
312
  }
256
313
  }
257
- }), 'dsh-cron: /action');
258
-
259
- // 2. AI Tools for Model Interaction
260
- if (ctx.tools && typeof ctx.tools.register === 'function') {
261
- ctx.tools.register(defineTool({
262
- name: 'cron_schedule_task',
263
- description: 'Запланировать автоматическую задачу по расписанию (cron).',
264
- parameters: {
265
- title: { type: 'string', required: true, description: 'Краткое название задачи' },
266
- schedule: { type: 'string', required: true, description: 'Выражение cron или интервал (every 30m, 0 9 * * 1-5, daily)' },
267
- prompt: { type: 'string', required: true, description: 'Инструкция для агента при запуске' },
268
- delivery: { type: 'string', description: 'Режим запуска: current или isolated' },
269
- type: { type: 'string', description: 'Тип задачи: "llm" (агент с LLM) или "script" (прямой запуск shell без LLM)' },
270
- provider: { type: 'string', description: 'ID провайдера LLM для выполнения задачи' },
271
- model: { type: 'string', description: 'ID модели LLM для выполнения задачи' },
314
+ }), 'dsh-cron: /tasks/:id');
315
+
316
+ // 2. Tools
317
+ ctx.tools.register(defineTool({
318
+ name: 'cron_create_task',
319
+ description: 'Создать новую запланированную задачу cron (запуск по расписанию)',
320
+ parameters: {
321
+ type: 'object',
322
+ properties: {
323
+ title: { type: 'string', description: 'Краткое название задачи' },
324
+ schedule: { type: 'string', description: 'Расписание: cron-выражение (например, "0 9 * * *") или интервал ("every 2h", "каждый день в 15:00")' },
325
+ prompt: { type: 'string', description: 'Промпт/инструкция для агента при запуске' },
326
+ type: { type: 'string', enum: ['prompt', 'script'], description: 'Тип задачи' },
327
+ delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Режим запуска: current (в текущем чате) или isolated (изолированная сессия)' },
328
+ provider: { type: 'string', description: 'Провайдер модели (опционально)' },
329
+ model: { type: 'string', description: 'Модель (опционально)' },
272
330
  notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
273
- onlyOnFailure: { type: 'boolean', description: 'Отправлять в Telegram только при ошибках' }
331
+ onlyOnFailure: { type: 'boolean', description: 'Отправлять отчет только при сбоях' },
332
+ timeoutSeconds: { type: 'number', description: 'Лимит времени выполнения в секундах' },
333
+ overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Политика перекрытия' },
334
+ kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Создавать карточку в Kanban' },
274
335
  },
275
- output: {
276
- schema: {
277
- type: 'object',
278
- additionalProperties: false,
279
- properties: {
280
- success: { type: 'boolean' },
281
- message: { type: 'string' }
282
- }
283
- },
284
- render(_args, value) {
285
- return [{ type: 'text', text: value.message }];
286
- }
287
- },
288
- execute: async ({ title, schedule, prompt, delivery, provider, model, type, notifyTelegram, onlyOnFailure }) => {
289
- try {
290
- const parsed = parseScheduleExpression(schedule);
291
- const task = store.set({
292
- title,
293
- schedule: parsed.cronPattern,
294
- scheduleText: parsed.humanText,
295
- prompt,
296
- type: type || (provider || model ? 'llm' : 'llm'),
297
- delivery: delivery || 'isolated',
298
- provider: provider || undefined,
299
- model: model || undefined,
300
- notifyTelegram: Boolean(notifyTelegram),
301
- onlyOnFailure: Boolean(onlyOnFailure),
302
- status: 'active'
303
- });
304
- scheduler.scheduleTask(task);
305
- return {
306
- success: true,
307
- message: `Задача "${task.title}" запланирована (${parsed.humanText}). Следующий запуск: ${task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : 'скоро'}.`
308
- };
309
- } catch (err) {
310
- return { success: false, message: 'Ошибка планирования: ' + err.message };
311
- }
312
- }
313
- }));
336
+ required: ['title', 'schedule', 'prompt']
337
+ },
338
+ execute: async (args) => {
339
+ const parsed = parseScheduleExpression(args.schedule);
340
+ const task = store.set({
341
+ title: args.title,
342
+ schedule: parsed.cronPattern,
343
+ scheduleText: parsed.humanText,
344
+ prompt: args.prompt,
345
+ type: args.type || 'prompt',
346
+ delivery: args.delivery || 'isolated',
347
+ status: 'active',
348
+ provider: args.provider,
349
+ model: args.model,
350
+ notifyTelegram: Boolean(args.notifyTelegram),
351
+ onlyOnFailure: Boolean(args.onlyOnFailure),
352
+ timeoutSeconds: Number(args.timeoutSeconds) || 1800,
353
+ overlapPolicy: args.overlapPolicy || 'skip',
354
+ kanbanMode: args.kanbanMode || 'none',
355
+ });
356
+ scheduler.scheduleTask(task);
357
+ return {
358
+ success: true,
359
+ message: `Задача "${task.title}" успешно запланирована (${task.scheduleText})`,
360
+ task
361
+ };
362
+ }
363
+ }));
314
364
 
315
- ctx.tools.register(defineTool({
316
- name: 'cron_list_tasks',
317
- description: 'Получить список текущих запланированных cron-задач.',
318
- parameters: {
319
- status: { type: 'string', description: 'Фильтр статуса: all, active, paused, completed' }
320
- },
321
- output: {
322
- schema: {
323
- type: 'object',
324
- additionalProperties: false,
325
- properties: {
326
- total: { type: 'integer' },
327
- text: { type: 'string' }
328
- }
329
- },
330
- render(_args, value) {
331
- return [{ type: 'text', text: value.text }];
332
- }
333
- },
334
- execute: async ({ status }) => {
335
- const tasks = store.list({ status: status || 'all' });
336
- const text = tasks.length === 0
337
- ? 'Нет запланированных задач.'
338
- : tasks.map(t => `• ${t.title} [${t.status}] — ${t.scheduleText}`).join('\n');
339
- return {
340
- total: tasks.length,
341
- text
342
- };
365
+ ctx.tools.register(defineTool({
366
+ name: 'cron_list_tasks',
367
+ description: 'Получить список всех запланированных задач cron',
368
+ parameters: {
369
+ type: 'object',
370
+ properties: {
371
+ status: { type: 'string', enum: ['all', 'active', 'paused'], description: 'Фильтр по статусу' }
343
372
  }
344
- }));
345
-
346
- ctx.tools.register(defineTool({
347
- name: 'cron_toggle_task',
348
- description: 'Приостановить, возобновить или запустить cron-задачу.',
349
- parameters: {
350
- taskId: { type: 'string', required: true, description: 'Идентификатор задачи' },
351
- action: { type: 'string', required: true, description: 'pause, resume, run_now или delete' }
373
+ },
374
+ execute: async (args) => {
375
+ const tasks = store.list({ status: args.status || 'all' });
376
+ return {
377
+ count: tasks.length,
378
+ tasks: tasks.map(t => ({
379
+ id: t.id,
380
+ title: t.title,
381
+ schedule: t.scheduleText || t.schedule,
382
+ status: t.status,
383
+ nextRunAt: t.nextRunAt ? new Date(t.nextRunAt).toISOString() : null,
384
+ lastStatus: t.lastStatus,
385
+ totalTokens: t.totalTokens || 0,
386
+ totalCostUsd: t.totalCostUsd || 0,
387
+ }))
388
+ };
389
+ }
390
+ }));
391
+
392
+ ctx.tools.register(defineTool({
393
+ name: 'cron_pause_task',
394
+ description: 'Приостановить выполнение задачи по её ID',
395
+ parameters: {
396
+ type: 'object',
397
+ properties: {
398
+ id: { type: 'string', description: 'Идентификатор задачи' }
352
399
  },
353
- output: {
354
- schema: {
355
- type: 'object',
356
- additionalProperties: false,
357
- properties: {
358
- success: { type: 'boolean' },
359
- message: { type: 'string' }
360
- }
361
- },
362
- render(_args, value) {
363
- return [{ type: 'text', text: value.message }];
364
- }
400
+ required: ['id']
401
+ },
402
+ execute: async (args) => {
403
+ const task = scheduler.pauseTask(args.id);
404
+ if (!task) return { success: false, message: 'Задача не найдена' };
405
+ return { success: true, message: `Задача "${task.title}" приостановлена` };
406
+ }
407
+ }));
408
+
409
+ ctx.tools.register(defineTool({
410
+ name: 'cron_resume_task',
411
+ description: 'Возобновить выполнение приостановленной задачи',
412
+ parameters: {
413
+ type: 'object',
414
+ properties: {
415
+ id: { type: 'string', description: 'Идентификатор задачи' }
365
416
  },
366
- execute: async ({ taskId, action }) => {
367
- if (action === 'pause') {
368
- const t = scheduler.pauseTask(taskId);
369
- return { success: !!t, message: t ? `Задача "${t.title}" приостановлена` : 'Задача не найдена' };
370
- }
371
- if (action === 'resume') {
372
- const t = scheduler.resumeTask(taskId);
373
- return { success: !!t, message: t ? `Задача "${t.title}" возобновлена` : 'Задача не найдена' };
374
- }
375
- if (action === 'run_now') {
376
- scheduler.runTask(taskId);
377
- return { success: true, message: `Задача ${taskId} запущена` };
378
- }
379
- if (action === 'delete') {
380
- const ok = scheduler.removeTask(taskId);
381
- return { success: ok, message: ok ? 'Задача удалена' : 'Задача не найдена' };
382
- }
383
- return { success: false, message: 'Неизвестное действие' };
384
- }
385
- }));
386
- }
387
-
388
- // 3. /cron Slash Command Handler
389
- ctx.on('user/message', async (event) => {
390
- const text = (event && event.text || event && event.message && event.message.content || '').trim();
391
- if (!text.startsWith('/cron')) return;
392
-
393
- const parts = text.slice(5).trim().split(/\s+/);
394
- const subCmd = parts[0] ? parts[0].toLowerCase() : '';
395
-
396
- if (!subCmd || subCmd === 'help') {
397
- const helpMsg = `**⏰ DSH Cron — Управление запланированными задачами**\n\n` +
398
- `• \`/cron list\` — показать активные задачи\n` +
399
- `• \`/cron add <расписание> <промпт>\` — создать новую задачу\n` +
400
- `• \`/cron pause <id>\` — приостановить задачу\n` +
401
- `• \`/cron resume <id>\` — возобновить задачу\n` +
402
- `• \`/cron run <id>\` — запустить сейчас\n\n` +
403
- `_Открыть графический экран управления можно в боковой панели._`;
404
-
405
- if (typeof event.reply === 'function') {
406
- event.reply(helpMsg);
407
- }
408
- return;
417
+ required: ['id']
418
+ },
419
+ execute: async (args) => {
420
+ const task = scheduler.resumeTask(args.id);
421
+ if (!task) return { success: false, message: 'Задача не найдена' };
422
+ return { success: true, message: `Задача "${task.title}" возобновлена (${task.scheduleText})` };
409
423
  }
424
+ }));
410
425
 
411
- if (subCmd === 'list') {
412
- const list = store.list();
413
- if (!list.length) {
414
- if (typeof event.reply === 'function') event.reply('Нет запланированных задач. Создайте через `/cron add` или в UI.');
415
- return;
416
- }
417
- const lines = list.map(t => `• **${t.title}** (${t.id}) — ${t.scheduleText} [${t.status}]`);
418
- if (typeof event.reply === 'function') event.reply(`**Запланированные задачи (${list.length}):**\n\n` + lines.join('\n'));
419
- return;
426
+ ctx.tools.register(defineTool({
427
+ name: 'cron_delete_task',
428
+ description: 'Удалить задачу по её ID',
429
+ parameters: {
430
+ type: 'object',
431
+ properties: {
432
+ id: { type: 'string', description: 'Идентификатор задачи' }
433
+ },
434
+ required: ['id']
435
+ },
436
+ execute: async (args) => {
437
+ scheduler.pauseTask(args.id);
438
+ const ok = store.delete(args.id);
439
+ return { success: ok, message: ok ? 'Задача удалена' : 'Задача не найдена' };
420
440
  }
441
+ }));
421
442
 
422
- if (subCmd === 'add') {
423
- const rest = text.slice(text.indexOf('add') + 3).trim();
424
- const match = rest.match(/^"([^"]+)"\s+(.+)$/) || rest.match(/^(\S+)\s+(.+)$/);
425
- if (!match) {
426
- if (typeof event.reply === 'function') event.reply('Формат: `/cron add "<расписание>" <промпт>`');
427
- return;
428
- }
429
- const [, scheduleStr, promptStr] = match;
430
- try {
431
- const parsed = parseScheduleExpression(scheduleStr);
432
- const task = store.set({
433
- title: promptStr.slice(0, 40),
434
- schedule: parsed.cronPattern,
435
- scheduleText: parsed.humanText,
436
- prompt: promptStr,
437
- status: 'active'
438
- });
439
- scheduler.scheduleTask(task);
440
- if (typeof event.reply === 'function') {
441
- event.reply(`✅ Создана задача: **${task.title}** (${parsed.humanText})`);
442
- }
443
- } catch (err) {
444
- if (typeof event.reply === 'function') event.reply(`❌ Ошибка: ${err.message}`);
445
- }
446
- return;
443
+ ctx.tools.register(defineTool({
444
+ name: 'cron_run_task',
445
+ description: 'Запустить задачу немедленно вне очереди',
446
+ parameters: {
447
+ type: 'object',
448
+ properties: {
449
+ id: { type: 'string', description: 'Идентификатор задачи' }
450
+ },
451
+ required: ['id']
452
+ },
453
+ execute: async (args) => {
454
+ const task = store.get(args.id);
455
+ if (!task) return { success: false, message: 'Задача не найдена' };
456
+ scheduler.triggerManualRun(args.id);
457
+ return { success: true, message: `Запущен внеплановый старт задачи "${task.title}"` };
447
458
  }
448
- });
459
+ }));
449
460
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Integration module for dsh-kanban and LLM token cost estimation.
3
+ */
4
+
5
+ /**
6
+ * Model pricing per 1M tokens in USD: [promptPrice, completionPrice, cachePrice]
7
+ */
8
+ export const MODEL_PRICING = {
9
+ 'deepseek-chat': { prompt: 0.14, completion: 0.28, cacheRead: 0.014 },
10
+ 'deepseek-reasoner': { prompt: 0.55, completion: 2.19, cacheRead: 0.14 },
11
+ 'deepseek-v4-flash': { prompt: 0.10, completion: 0.20, cacheRead: 0.01 },
12
+ 'deepseek-v4-pro': { prompt: 0.50, completion: 2.00, cacheRead: 0.10 },
13
+ 'gpt-4o': { prompt: 2.50, completion: 10.00, cacheRead: 1.25 },
14
+ 'gpt-4o-mini': { prompt: 0.15, completion: 0.60, cacheRead: 0.075 },
15
+ 'claude-3-5-sonnet': { prompt: 3.00, completion: 15.00, cacheRead: 0.30 },
16
+ 'claude-3-5-haiku': { prompt: 0.80, completion: 4.00, cacheRead: 0.08 },
17
+ 'gemini-1.5-flash': { prompt: 0.075, completion: 0.30, cacheRead: 0.01875 },
18
+ 'gemini-1.5-pro': { prompt: 1.25, completion: 5.00, cacheRead: 0.3125 },
19
+ };
20
+
21
+ /**
22
+ * Calculate token cost in USD based on model and token counts.
23
+ */
24
+ export function estimateTokenCost(model, usage = {}) {
25
+ if (!usage) return 0;
26
+ const inputTokens = Number(usage.inputTokens) || 0;
27
+ const outputTokens = Number(usage.outputTokens) || 0;
28
+ const cacheReadTokens = Number(usage.cacheReadTokens) || 0;
29
+
30
+ const modelKey = Object.keys(MODEL_PRICING).find(k => model && model.toLowerCase().includes(k.toLowerCase())) || 'deepseek-v4-flash';
31
+ const pricing = MODEL_PRICING[modelKey] || { prompt: 0.14, completion: 0.28, cacheRead: 0.014 };
32
+
33
+ const inputCost = (inputTokens / 1_000_000) * pricing.prompt;
34
+ const outputCost = (outputTokens / 1_000_000) * pricing.completion;
35
+ const cacheCost = (cacheReadTokens / 1_000_000) * pricing.cacheRead;
36
+
37
+ const total = inputCost + outputCost + cacheCost;
38
+ return Number(total.toFixed(6));
39
+ }
40
+
41
+ /**
42
+ * Create an issue card in dsh-kanban.
43
+ * Can use ctx.webServer or direct HTTP to http://127.0.0.1:3000/dsh-kanban/task
44
+ */
45
+ export async function createKanbanCard({
46
+ title,
47
+ body,
48
+ board = 'main',
49
+ column = 'backlog',
50
+ labels = ['cron', 'alert'],
51
+ fetchFn = globalThis.fetch,
52
+ kanbanBaseUrl = 'http://127.0.0.1:3000'
53
+ }) {
54
+ try {
55
+ const url = `${kanbanBaseUrl.replace(/\/$/, '')}/dsh-kanban/task`;
56
+ const res = await fetchFn(url, {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ 'Sec-Fetch-Site': 'same-origin',
61
+ },
62
+ body: JSON.stringify({
63
+ title,
64
+ body,
65
+ board,
66
+ column,
67
+ labels,
68
+ })
69
+ });
70
+
71
+ if (!res.ok) {
72
+ const errText = await res.text().catch(() => '');
73
+ throw new Error(`HTTP ${res.status}: ${errText}`);
74
+ }
75
+
76
+ const data = await res.json().catch(() => ({}));
77
+ return { success: true, task: data.task };
78
+ } catch (err) {
79
+ console.error('[dsh-cron] failed to create kanban card:', err.message);
80
+ return { success: false, error: err.message };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Check if kanban card should be created for this run.
86
+ */
87
+ export function shouldCreateKanbanCard(task, runInfo) {
88
+ const mode = task.kanbanMode || 'none'; // 'none' | 'on_failure' | 'always'
89
+ if (mode === 'none') return false;
90
+ if (mode === 'always') return true;
91
+ if (mode === 'on_failure') {
92
+ return runInfo.status === 'error' || runInfo.status === 'timeout';
93
+ }
94
+ return false;
95
+ }
package/lib/runner.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { estimateTokenCost } from './integrations.js';
2
+
1
3
  /**
2
4
  * Runner executing scheduled tasks in DeepSeek Harness sessions.
3
5
  */
@@ -6,25 +8,66 @@ export class SessionRunner {
6
8
  this.ctx = ctx;
7
9
  }
8
10
 
9
- async execute(task) {
11
+ async execute(task, options = {}) {
12
+ const timeoutSeconds = Number(task.timeoutSeconds) > 0 ? Number(task.timeoutSeconds) : 1800;
13
+ const timeoutMs = timeoutSeconds * 1000;
14
+ const externalSignal = options.signal;
15
+
16
+ // Internal abort controller combining external signal + timeout
17
+ const controller = new AbortController();
18
+ let timeoutId = null;
19
+
20
+ if (externalSignal) {
21
+ if (externalSignal.aborted) {
22
+ throw new Error(externalSignal.reason?.message || 'Task aborted before execution');
23
+ }
24
+ externalSignal.addEventListener('abort', () => {
25
+ controller.abort(externalSignal.reason || new Error('Task aborted by overlap policy'));
26
+ }, { once: true });
27
+ }
28
+
29
+ const timeoutPromise = new Promise((_, reject) => {
30
+ timeoutId = setTimeout(() => {
31
+ const timeoutErr = new Error(`Task execution timed out after ${timeoutSeconds} seconds`);
32
+ controller.abort(timeoutErr);
33
+ reject(timeoutErr);
34
+ }, timeoutMs);
35
+ });
36
+
37
+ const executionPromise = this._run(task, controller.signal);
38
+
39
+ try {
40
+ return await Promise.race([executionPromise, timeoutPromise]);
41
+ } finally {
42
+ if (timeoutId) clearTimeout(timeoutId);
43
+ }
44
+ }
45
+
46
+ async _run(task, signal) {
10
47
  console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
11
48
 
12
49
  // 1. Direct shell / script task fallback (NO-LLM)
13
50
  if (task.type === 'script' || task.mode === 'no-llm') {
14
51
  const { exec } = await import('node:child_process');
15
- const { promisify } = await import('node:util');
16
- const execAsync = promisify(exec);
17
- try {
18
- const { stdout, stderr } = await execAsync(task.prompt, {
19
- timeout: 60000,
20
- cwd: process.cwd()
52
+ return new Promise((resolve, reject) => {
53
+ const child = exec(task.prompt, {
54
+ cwd: process.cwd(),
55
+ signal,
56
+ }, (err, stdout, stderr) => {
57
+ if (err) {
58
+ if (signal && signal.aborted) {
59
+ return reject(signal.reason || new Error('Command aborted'));
60
+ }
61
+ return reject(err);
62
+ }
63
+ const out = (stdout || stderr || '').trim();
64
+ resolve({
65
+ output: out || 'Script completed with code 0',
66
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
67
+ costUsd: 0
68
+ });
21
69
  });
22
- const out = (stdout || stderr || '').trim();
23
- return out || 'Script completed with code 0';
24
- } catch (err) {
25
- console.error(`[dsh-cron] script task failed:`, err.message);
26
- throw err;
27
- }
70
+ });
28
71
  }
29
72
 
30
73
  // 2. If ctx.agents is available, run through agent session (LLM)
@@ -50,7 +93,7 @@ export class SessionRunner {
50
93
 
51
94
  const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
52
95
  const provider = task.provider || defaultSel?.provider;
53
- const model = task.model || defaultSel?.model;
96
+ const model = task.model || defaultSel?.model || 'deepseek-v4-flash';
54
97
 
55
98
  const cwd = process.cwd();
56
99
  const handle = await this.ctx.agents.create({
@@ -59,13 +102,36 @@ export class SessionRunner {
59
102
  agentOptions: provider && model ? { provider, model } : undefined,
60
103
  });
61
104
 
105
+ if (signal && signal.aborted) {
106
+ throw (signal.reason || new Error('Aborted'));
107
+ }
108
+
62
109
  await handle.agent.whenIdle();
63
110
  handle.agent.followup(createUserMessage({
64
111
  content: [{ type: 'text', text: task.prompt }],
65
112
  source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
66
113
  }));
67
114
 
68
- return `[dsh-cron] Agent started session ${handle.agent.session?.id || 'cron'}`;
115
+ // Try extracting token usage from session history if available
116
+ let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
117
+ try {
118
+ const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
119
+ if (sessionUsage) {
120
+ usage = {
121
+ inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
122
+ outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
123
+ cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
124
+ };
125
+ }
126
+ } catch {}
127
+
128
+ const costUsd = estimateTokenCost(model, usage);
129
+
130
+ return {
131
+ output: `[dsh-cron] Agent started session ${handle.agent.session?.id || 'cron'}`,
132
+ usage,
133
+ costUsd
134
+ };
69
135
  } catch (err) {
70
136
  console.error(`[dsh-cron] agent execution failed:`, err.message);
71
137
  throw err;
@@ -82,7 +148,14 @@ export class SessionRunner {
82
148
 
83
149
  if (session && typeof session.send === 'function') {
84
150
  const result = await session.send(task.prompt);
85
- return typeof result === 'string' ? result : JSON.stringify(result);
151
+ const out = typeof result === 'string' ? result : JSON.stringify(result);
152
+ const usage = session.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
153
+ const costUsd = estimateTokenCost(task.model, usage);
154
+ return {
155
+ output: out,
156
+ usage,
157
+ costUsd
158
+ };
86
159
  }
87
160
  } catch (err) {
88
161
  console.error(`[dsh-cron] session execution failed:`, err.message);
@@ -90,6 +163,10 @@ export class SessionRunner {
90
163
  }
91
164
  }
92
165
 
93
- return `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`;
166
+ return {
167
+ output: `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`,
168
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
169
+ costUsd: 0
170
+ };
94
171
  }
95
- }
172
+ }
package/lib/scheduler.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Cron } from 'croner';
2
2
  import { shouldNotifyTask, formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
3
+ import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
3
4
 
4
5
  function pad(v) {
5
6
  return String(v).padStart(2, '0');
@@ -83,6 +84,7 @@ export class TaskScheduler {
83
84
  this.store = store;
84
85
  this.executeFn = executeFn;
85
86
  this.jobs = new Map();
87
+ this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
86
88
  }
87
89
 
88
90
  start() {
@@ -101,6 +103,13 @@ export class TaskScheduler {
101
103
  } catch {}
102
104
  }
103
105
  this.jobs.clear();
106
+
107
+ for (const [, run] of this.running.entries()) {
108
+ try {
109
+ run.controller.abort(new Error('Планировщик остановлен'));
110
+ } catch {}
111
+ }
112
+ this.running.clear();
104
113
  }
105
114
 
106
115
  scheduleTask(task) {
@@ -132,14 +141,60 @@ export class TaskScheduler {
132
141
  const task = this.store.get(taskId);
133
142
  if (!task) return;
134
143
 
144
+ const overlapPolicy = task.overlapPolicy || 'skip';
145
+
146
+ // Check overlap with currently running task
147
+ const active = this.running.get(taskId);
148
+ if (active) {
149
+ if (overlapPolicy === 'skip') {
150
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, skipping run (overlapPolicy: skip)`);
151
+ this.store.recordRun(taskId, {
152
+ at: Date.now(),
153
+ status: 'skipped',
154
+ durationMs: 0,
155
+ output: 'Запуск пропущен: предыдущий процесс ещё не завершился (overlapPolicy: skip)',
156
+ error: null,
157
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
158
+ costUsd: 0,
159
+ });
160
+ return;
161
+ }
162
+ if (overlapPolicy === 'replace') {
163
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
164
+ active.controller.abort(new Error('Прервано из-за политики overlap: replace'));
165
+ }
166
+ if (overlapPolicy === 'queue') {
167
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
168
+ active.queueCount = (active.queueCount || 0) + 1;
169
+ return;
170
+ }
171
+ }
172
+
173
+ const controller = new AbortController();
174
+ const currentRun = {
175
+ controller,
176
+ startedAt: Date.now(),
177
+ queueCount: 0,
178
+ };
179
+ this.running.set(taskId, currentRun);
180
+
135
181
  const start = Date.now();
136
182
  let status = 'success';
137
183
  let output = '';
138
184
  let error = null;
185
+ let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
186
+ let costUsd = 0;
139
187
 
140
188
  try {
141
189
  if (typeof this.executeFn === 'function') {
142
- output = await this.executeFn(task);
190
+ const res = await this.executeFn(task, { signal: controller.signal });
191
+ if (typeof res === 'object' && res !== null) {
192
+ output = res.output || '';
193
+ usage = res.usage || usage;
194
+ costUsd = res.costUsd || 0;
195
+ } else {
196
+ output = String(res || '');
197
+ }
143
198
  }
144
199
  } catch (err) {
145
200
  status = 'error';
@@ -152,10 +207,20 @@ export class TaskScheduler {
152
207
  durationMs,
153
208
  output,
154
209
  error,
210
+ usage,
211
+ costUsd,
155
212
  };
156
213
  this.store.recordRun(taskId, runInfo);
157
214
 
158
- // Telegram notification delivery
215
+ // Clean up running map
216
+ const finishedRun = this.running.get(taskId);
217
+ if (finishedRun === currentRun) {
218
+ this.running.delete(taskId);
219
+ }
220
+
221
+ const queuedCount = finishedRun?.queueCount || 0;
222
+
223
+ // 1. Telegram notification delivery
159
224
  try {
160
225
  const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
161
226
  if (shouldNotifyTask(task, runInfo, settings)) {
@@ -172,12 +237,52 @@ export class TaskScheduler {
172
237
  console.error('[dsh-cron] notification error:', notifyErr.message);
173
238
  }
174
239
 
240
+ // 2. Kanban card creation
241
+ try {
242
+ if (shouldCreateKanbanCard(task, runInfo)) {
243
+ const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
244
+ const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
245
+ const cardTitle = isErr ? `[Сбой cron] ${task.title}` : `[Cron завершен] ${task.title}`;
246
+ const cardBody = [
247
+ `**Задача cron:** ${task.title} (\`${task.id}\`)`,
248
+ `**Расписание:** ${task.scheduleText || task.schedule}`,
249
+ `**Статус:** ${runInfo.status}`,
250
+ `**Время запуска:** ${new Date(runInfo.at).toLocaleString('ru-RU')}`,
251
+ runInfo.error ? `\n**Ошибка:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
252
+ runInfo.output ? `\n**Лог / Вывод:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
253
+ ].filter(Boolean).join('\n\n');
254
+
255
+ createKanbanCard({
256
+ title: cardTitle,
257
+ body: cardBody,
258
+ board: 'main',
259
+ column: isErr ? 'backlog' : 'done',
260
+ labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
261
+ kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000'
262
+ }).then((res) => {
263
+ if (res.success) {
264
+ console.log(`[dsh-cron] created kanban card for task "${task.title}"`);
265
+ }
266
+ }).catch((kErr) => {
267
+ console.error('[dsh-cron] kanban card error:', kErr.message);
268
+ });
269
+ }
270
+ } catch (kanbanErr) {
271
+ console.error('[dsh-cron] kanban error:', kanbanErr.message);
272
+ }
273
+
175
274
  const job = this.jobs.get(taskId);
176
275
  if (job) {
177
276
  const next = job.nextRun();
178
277
  task.nextRunAt = next ? next.getTime() : null;
179
278
  this.store.set(task);
180
279
  }
280
+
281
+ if (queuedCount > 0) {
282
+ setImmediate(() => {
283
+ this.runTask(taskId);
284
+ });
285
+ }
181
286
  }
182
287
  }
183
288
 
@@ -190,6 +295,13 @@ export class TaskScheduler {
190
295
  this.jobs.get(taskId).stop();
191
296
  this.jobs.delete(taskId);
192
297
  }
298
+ const running = this.running.get(taskId);
299
+ if (running) {
300
+ try {
301
+ running.controller.abort(new Error('Задача приостановлена'));
302
+ } catch {}
303
+ this.running.delete(taskId);
304
+ }
193
305
  this.store.set(task);
194
306
  return task;
195
307
  }
@@ -208,9 +320,20 @@ export class TaskScheduler {
208
320
  this.jobs.get(taskId).stop();
209
321
  this.jobs.delete(taskId);
210
322
  }
323
+ const running = this.running.get(taskId);
324
+ if (running) {
325
+ try {
326
+ running.controller.abort(new Error('Задача удалена'));
327
+ } catch {}
328
+ this.running.delete(taskId);
329
+ }
211
330
  return this.store.delete(taskId);
212
331
  }
213
332
 
333
+ triggerManualRun(taskId) {
334
+ return this.runTask(taskId);
335
+ }
336
+
214
337
  getNextRun(schedule) {
215
338
  try {
216
339
  const parsed = parseScheduleExpression(schedule);
package/lib/store.js CHANGED
@@ -73,6 +73,7 @@ export class TaskStore {
73
73
  notifyTelegram: Boolean(this.settings.notifyTelegram),
74
74
  onlyOnFailure: Boolean(this.settings.onlyOnFailure),
75
75
  hasDefaultCredentials: Boolean(defaults.botToken && defaults.chatId),
76
+ kanbanBaseUrl: this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000',
76
77
  };
77
78
  }
78
79
 
@@ -83,6 +84,7 @@ export class TaskStore {
83
84
  chatId: newSettings.chatId !== undefined ? String(newSettings.chatId).trim() : (this.settings.chatId || ''),
84
85
  notifyTelegram: newSettings.notifyTelegram !== undefined ? Boolean(newSettings.notifyTelegram) : Boolean(this.settings.notifyTelegram),
85
86
  onlyOnFailure: newSettings.onlyOnFailure !== undefined ? Boolean(newSettings.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
87
+ kanbanBaseUrl: newSettings.kanbanBaseUrl !== undefined ? String(newSettings.kanbanBaseUrl).trim() : (this.settings.kanbanBaseUrl || 'http://127.0.0.1:3000'),
86
88
  };
87
89
  this.save();
88
90
  return this.getSettings();
@@ -127,6 +129,11 @@ export class TaskStore {
127
129
  model: task.model !== undefined ? task.model : prev.model,
128
130
  notifyTelegram: task.notifyTelegram !== undefined ? Boolean(task.notifyTelegram) : (prev.notifyTelegram || false),
129
131
  onlyOnFailure: task.onlyOnFailure !== undefined ? Boolean(task.onlyOnFailure) : (prev.onlyOnFailure || false),
132
+ timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
133
+ overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
134
+ kanbanMode: task.kanbanMode !== undefined ? String(task.kanbanMode) : (prev.kanbanMode || 'none'), // 'none' | 'on_failure' | 'always'
135
+ totalTokens: prev.totalTokens || 0,
136
+ totalCostUsd: prev.totalCostUsd || 0,
130
137
  createdAt: prev.createdAt || task.createdAt || now,
131
138
  updatedAt: now,
132
139
  lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
@@ -152,6 +159,14 @@ export class TaskStore {
152
159
  task.lastRunAt = runInfo.at || Date.now();
153
160
  task.lastStatus = runInfo.status || 'success';
154
161
  task.lastDurationMs = runInfo.durationMs || 0;
162
+
163
+ // Track usage and cost
164
+ const usage = runInfo.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
165
+ const costUsd = Number(runInfo.costUsd) || 0;
166
+ const runTokens = (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0);
167
+
168
+ task.totalTokens = (task.totalTokens || 0) + runTokens;
169
+ task.totalCostUsd = Number(((task.totalCostUsd || 0) + costUsd).toFixed(6));
155
170
  task.updatedAt = Date.now();
156
171
 
157
172
  const runs = this.history.get(id) || [];
@@ -162,6 +177,8 @@ export class TaskStore {
162
177
  durationMs: task.lastDurationMs,
163
178
  output: (runInfo.output || '').slice(0, 4000),
164
179
  error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
180
+ usage,
181
+ costUsd,
165
182
  });
166
183
  if (runs.length > 50) runs.length = 50;
167
184
  this.history.set(id, runs);
@@ -172,4 +189,26 @@ export class TaskStore {
172
189
  const runs = this.history.get(id) || [];
173
190
  return runs.slice(0, limit);
174
191
  }
192
+
193
+ getAggregatedStats() {
194
+ let totalRuns = 0;
195
+ let totalTokens = 0;
196
+ let totalCostUsd = 0;
197
+ let activeTasks = 0;
198
+
199
+ for (const t of this.tasks.values()) {
200
+ if (t.status === 'active') activeTasks++;
201
+ totalTokens += (t.totalTokens || 0);
202
+ totalCostUsd += (t.totalCostUsd || 0);
203
+ const h = this.history.get(t.id) || [];
204
+ totalRuns += h.length;
205
+ }
206
+
207
+ return {
208
+ activeTasks,
209
+ totalRuns,
210
+ totalTokens,
211
+ totalCostUsd: Number(totalCostUsd.toFixed(4)),
212
+ };
213
+ }
175
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",