@goodandready/dsh-cron 0.1.16 → 0.1.18

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
@@ -187,3 +187,13 @@ npm test
187
187
  ## 📄 License
188
188
 
189
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.
195
+
196
+ ### One-Shot Delayed Tasks (v0.1.18)
197
+ - **Precise Timing & Relative Delays**: Supports one-time tasks triggered at exact ISO 8601 timestamps (`at: 2026-09-05T12:00:00Z`) or human-friendly relative delays (`in 20m`, `in 2h`, `через 15 минут`).
198
+ - **Auto-Completion Lifecycle**: One-shot jobs transition automatically to `completed` status after their single run, preventing unexpected repeats.
199
+ - **Dedicated Filter**: View historical and pending one-shot executions under the «Completed» (`completed`) tab.
package/docs/README.ru.md CHANGED
@@ -170,3 +170,13 @@ 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
+ - **Сводная панель статистики**: Отображение общего числа запусков, активных задач, суммарного расхода токенов и совокупных затрат прямо в заголовке планировщика.
178
+
179
+ ### Разовые отложенные задачи (v0.1.18)
180
+ - **Точное время и относительные задержки**: Поддержка однократных задач, запускаемых в конкретный момент времени по стандарту ISO 8601 (`at: 2026-09-05T12:00:00Z`) или через заданный интервал (`in 20m`, `in 2h`, `через 15 минут`).
181
+ - **Автоматический перевод в «Завершенные»**: После единичного выполнения задача автоматически переходит в статус `completed`, исключая повторные старты.
182
+ - **Индикация в интерфейсе**: Разовые задачи помечаются специальным бейджем «Разовая» и фильтруются по вкладке «Завершенные».
package/lib/client.js CHANGED
@@ -140,6 +140,14 @@ const React = window.React || require('react');
140
140
  .dsh-cron-history-time { color: #777; font-size: 12px; }
141
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; }
142
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-oneshot-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: rgba(168, 85, 247, 0.15); color: #c084fc; font-weight: 500; }
149
+ .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; }
150
+ .dsh-cron-tokens-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: rgba(59, 130, 246, 0.12); color: #60a5fa; }
143
151
  .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer; user-select: none; }
144
152
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: #ededed; }
145
153
  .dsh-cron-hint { font-size: 12px; color: #888; margin-top: 4px; }
@@ -251,6 +259,8 @@ const React = window.React || require('react');
251
259
  const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
252
260
  const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
253
261
  const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
262
+ const [formKanbanMode, setFormKanbanMode] = React.useState('none');
263
+ const [stats, setStats] = React.useState(null);
254
264
 
255
265
  // Settings modal state
256
266
  const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
@@ -291,6 +301,7 @@ const React = window.React || require('react');
291
301
  if (data && data.ok) {
292
302
  setTasks(data.tasks || []);
293
303
  setRecs(data.recommendations || []);
304
+ if (data.stats) setStats(data.stats);
294
305
  }
295
306
  } catch (err) {}
296
307
  };
@@ -406,6 +417,7 @@ const React = window.React || require('react');
406
417
  setFormOnlyOnFailure(false);
407
418
  setFormTimeoutSeconds(1800);
408
419
  setFormOverlapPolicy('skip');
420
+ setFormKanbanMode('none');
409
421
  if (preset) {
410
422
  setFormTitle(preset.title || '');
411
423
  setFormSchedule(preset.schedule || '');
@@ -455,6 +467,7 @@ const React = window.React || require('react');
455
467
  setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
456
468
  setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
457
469
  setFormOverlapPolicy(task.overlapPolicy || 'skip');
470
+ setFormKanbanMode(task.kanbanMode || 'none');
458
471
  if (task.provider) loadModels(task.provider);
459
472
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
460
473
  loadTaskHistory(task.id);
@@ -480,6 +493,11 @@ const React = window.React || require('react');
480
493
  delivery: formDelivery || 'isolated',
481
494
  provider: formType === 'script' ? undefined : (formProvider || undefined),
482
495
  model: formType === 'script' ? undefined : (formModel || undefined),
496
+ notifyTelegram: formNotifyTelegram,
497
+ onlyOnFailure: formOnlyOnFailure,
498
+ timeoutSeconds: Number(formTimeoutSeconds) || 1800,
499
+ overlapPolicy: formOverlapPolicy,
500
+ kanbanMode: formKanbanMode,
483
501
  };
484
502
  const res = await fetch('/dsh-cron/tasks', {
485
503
  method: 'POST',
@@ -582,6 +600,24 @@ const React = window.React || require('react');
582
600
  onChange: (e) => setQuery(e.target.value)
583
601
  })
584
602
  ),
603
+ stats && React.createElement('div', { className: 'dsh-cron-stats-bar' },
604
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
605
+ React.createElement('div', { className: 'dsh-cron-stat-val' }, stats.activeTasks || 0),
606
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Активных задач')
607
+ ),
608
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
609
+ React.createElement('div', { className: 'dsh-cron-stat-val' }, stats.totalRuns || 0),
610
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Всего запусков')
611
+ ),
612
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
613
+ React.createElement('div', { className: 'dsh-cron-stat-val' }, (stats.totalTokens || 0).toLocaleString()),
614
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Токенов израсходовано')
615
+ ),
616
+ React.createElement('div', { className: 'dsh-cron-stat-card' },
617
+ React.createElement('div', { className: 'dsh-cron-stat-val', style: { color: '#10b981' } }, '$' + (stats.totalCostUsd || 0).toFixed(4)),
618
+ React.createElement('div', { className: 'dsh-cron-stat-lbl' }, 'Оценочная стоимость')
619
+ )
620
+ ),
585
621
  React.createElement('div', { className: 'dsh-cron-tabs' },
586
622
  ['all', 'active', 'paused', 'completed'].map((t) =>
587
623
  React.createElement('button', {
@@ -618,7 +654,10 @@ const React = window.React || require('react');
618
654
  color: task.type === 'script' ? '#60a5fa' : '#34d399'
619
655
  }
620
656
  }, task.type === 'script' ? 'Shell' : 'LLM'),
621
- task.model ? React.createElement('span', { className: 'dsh-cron-task-model-tag' }, task.model.split('/').pop()) : null
657
+ task.model ? React.createElement('span', { className: 'dsh-cron-task-model-tag' }, task.model.split('/').pop()) : null,
658
+ task.oneShot ? React.createElement('span', { className: 'dsh-cron-oneshot-tag' }, 'Разовая') : null,
659
+ task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
660
+ task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null
622
661
  ),
623
662
  React.createElement('div', { className: 'dsh-cron-task-sched' },
624
663
  task.scheduleText || task.schedule,
@@ -728,7 +767,11 @@ const React = window.React || require('react');
728
767
  }, run.status === 'success' ? 'Успешно' : run.status === 'skipped' ? 'Пропущен' : 'Ошибка'),
729
768
  React.createElement('span', { className: 'dsh-cron-history-time' }, run.at ? new Date(run.at).toLocaleString() : '')
730
769
  ),
731
- React.createElement('span', { style: { color: '#888', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
770
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
771
+ run.costUsd > 0 && React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + run.costUsd.toFixed(5)),
772
+ 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'),
773
+ React.createElement('span', { style: { color: '#888', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
774
+ )
732
775
  ),
733
776
  run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
734
777
  run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: '#ef4444', borderColor: '#552222' } }, run.error)
@@ -774,7 +817,7 @@ const React = window.React || require('react');
774
817
  React.createElement('input', {
775
818
  required: true,
776
819
  value: formSchedule,
777
- placeholder: '0 */6 * * * или every 2h',
820
+ placeholder: '0 */6 * * * или every 2h или in 30m или at: 2026-09-05T15:00:00Z',
778
821
  onChange: (e) => setFormSchedule(e.target.value)
779
822
  })
780
823
  )
@@ -802,6 +845,17 @@ const React = window.React || require('react');
802
845
  )
803
846
  )
804
847
  ),
848
+ React.createElement('div', { className: 'dsh-cron-form-group' },
849
+ React.createElement('label', null, 'Создание карточки в Kanban (dsh-kanban)'),
850
+ React.createElement('select', {
851
+ value: formKanbanMode,
852
+ onChange: (e) => setFormKanbanMode(e.target.value)
853
+ },
854
+ React.createElement('option', { value: 'none' }, 'Не создавать карточки (отключено)'),
855
+ React.createElement('option', { value: 'on_failure' }, 'Создавать тикет при ошибке/сбое (в Backlog)'),
856
+ React.createElement('option', { value: 'always' }, 'Создавать карточку при каждом завершении (Done / Backlog)')
857
+ )
858
+ ),
805
859
  formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
806
860
  React.createElement('div', { className: 'dsh-cron-form-group' },
807
861
  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
 
@@ -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
 
@@ -99,7 +101,7 @@ export function apply(ctx, config) {
99
101
  const task = store.set({
100
102
  id: body.id,
101
103
  title: body.title,
102
- schedule: parsed.cronPattern,
104
+ schedule: parsed.cronPattern || body.schedule,
103
105
  scheduleText: body.scheduleText || parsed.humanText,
104
106
  prompt: body.prompt,
105
107
  type: body.type || 'prompt',
@@ -111,6 +113,8 @@ export function apply(ctx, config) {
111
113
  onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : false,
112
114
  timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : 1800,
113
115
  overlapPolicy: body.overlapPolicy || 'skip',
116
+ kanbanMode: body.kanbanMode || 'none',
117
+ oneShot: Boolean(parsed.isOneShot || body.oneShot),
114
118
  });
115
119
  scheduler.scheduleTask(task);
116
120
  sendJson(res, 200, { ok: true, task });
@@ -176,280 +180,284 @@ export function apply(ctx, config) {
176
180
  return;
177
181
  }
178
182
  const body = await parseJsonBody(req);
179
- const currentSettings = store.getSettings();
180
- const botToken = (body.botToken || currentSettings.botToken || '').trim();
181
- const chatId = (body.chatId || currentSettings.chatId || '').trim();
183
+ const settings = store.getSettings();
184
+ const botToken = body.botToken || settings.botToken;
185
+ const chatId = body.chatId || settings.chatId;
182
186
 
183
- if (!botToken) {
184
- sendJson(res, 400, { ok: false, error: 'Укажите Telegram Bot Token' });
187
+ if (!botToken || !chatId) {
188
+ sendJson(res, 400, { ok: false, error: 'Не указан botToken или chatId' });
185
189
  return;
186
190
  }
187
- if (!chatId) {
188
- sendJson(res, 400, { ok: false, error: 'Укажите Telegram Chat ID' });
191
+
192
+ const text = `🔔 *Тестовое оповещение dsh-cron*\n\nСвязь между DSH Cron и Telegram успешно настроена! Проверка времени: \`${new Date().toISOString()}\``;
193
+ await sendTelegramMessage({ botToken, chatId, text });
194
+ sendJson(res, 200, { ok: true, message: 'Тестовое сообщение успешно отправлено' });
195
+ } catch (err) {
196
+ sendJson(res, 500, { ok: false, error: err.message });
197
+ }
198
+ }
199
+ }), 'dsh-cron: /telegram/test');
200
+
201
+ // POST /dsh-cron/kanban/test
202
+ ctx.effect(() => ctx.webServer.register({
203
+ kind: 'exact',
204
+ path: '/dsh-cron/kanban/test',
205
+ handler: async (req, res) => {
206
+ try {
207
+ if (req.method !== 'POST') {
208
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
189
209
  return;
190
210
  }
211
+ const body = await parseJsonBody(req);
212
+ const settings = store.getSettings();
213
+ const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
191
214
 
192
- await sendTelegramMessage({
193
- botToken,
194
- chatId,
195
- text: '⏰ *DSH Cron — Проверка связи*\nТестовое уведомление успешно доставлено! Планировщик готов к отправке отчётов.',
196
- parseMode: 'Markdown'
215
+ const result = await createKanbanCard({
216
+ title: `[Тест] Проверка связи DSH Cron -> Kanban`,
217
+ body: `Тестовая карточка создана автоматически из модуля \`dsh-cron\` в ${new Date().toLocaleString('ru-RU')}.`,
218
+ board: 'main',
219
+ column: 'backlog',
220
+ labels: ['cron', 'test'],
221
+ kanbanBaseUrl
197
222
  });
198
223
 
199
- sendJson(res, 200, { ok: true, message: 'Сообщение успешно отправлено в Telegram' });
224
+ if (result.success) {
225
+ sendJson(res, 200, { ok: true, task: result.task });
226
+ } else {
227
+ sendJson(res, 500, { ok: false, error: result.error });
228
+ }
200
229
  } catch (err) {
201
230
  sendJson(res, 500, { ok: false, error: err.message });
202
231
  }
203
232
  }
204
- }), 'dsh-cron: /telegram/test');
233
+ }), 'dsh-cron: /kanban/test');
205
234
 
206
- // Prefix routes for actions (/dsh-cron/action/*)
235
+ // PATCH & DELETE /dsh-cron/tasks/:id
207
236
  ctx.effect(() => ctx.webServer.register({
208
237
  kind: 'prefix',
209
- path: '/dsh-cron/action',
238
+ path: '/dsh-cron/tasks/',
210
239
  handler: async (req, res) => {
211
240
  try {
212
241
  const url = new URL(req.url, 'http://127.0.0.1');
213
- const pathname = url.pathname;
242
+ const parts = url.pathname.split('/').filter(Boolean);
243
+ const id = parts[2];
244
+ const action = parts[3];
245
+
246
+ if (!id) {
247
+ sendJson(res, 400, { ok: false, error: 'ID задачи не указан' });
248
+ return;
249
+ }
250
+
251
+ if (req.method === 'GET' && action === 'history') {
252
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
253
+ const history = store.getHistory(id, limit);
254
+ sendJson(res, 200, { ok: true, history });
255
+ return;
256
+ }
257
+
258
+ if (req.method === 'POST' && action === 'run') {
259
+ await scheduler.triggerManualRun(id);
260
+ const task = store.get(id);
261
+ sendJson(res, 200, { ok: true, task });
262
+ return;
263
+ }
214
264
 
215
- const toggleMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/toggle$/);
216
- if (req.method === 'POST' && toggleMatch) {
217
- const taskId = toggleMatch[1];
218
- const task = store.get(taskId);
265
+ if (req.method === 'POST' && action === 'pause') {
266
+ const task = scheduler.pauseTask(id);
219
267
  if (!task) {
220
- sendJson(res, 404, { ok: false, error: 'Task not found' });
268
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
221
269
  return;
222
270
  }
223
- const updated = task.status === 'active'
224
- ? scheduler.pauseTask(taskId)
225
- : scheduler.resumeTask(taskId);
226
- sendJson(res, 200, { ok: true, task: updated });
271
+ sendJson(res, 200, { ok: true, task });
227
272
  return;
228
273
  }
229
274
 
230
- const runMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/run$/);
231
- if (req.method === 'POST' && runMatch) {
232
- const taskId = runMatch[1];
233
- scheduler.runTask(taskId);
234
- sendJson(res, 200, { ok: true });
275
+ if (req.method === 'POST' && action === 'resume') {
276
+ const task = scheduler.resumeTask(id);
277
+ if (!task) {
278
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
279
+ return;
280
+ }
281
+ sendJson(res, 200, { ok: true, task });
235
282
  return;
236
283
  }
237
284
 
238
- const deleteMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/delete$/);
239
- if (req.method === 'POST' && deleteMatch) {
240
- const taskId = deleteMatch[1];
241
- const ok = scheduler.removeTask(taskId);
242
- sendJson(res, 200, { ok });
285
+ if (req.method === 'PATCH') {
286
+ const body = await parseJsonBody(req);
287
+ const current = store.get(id);
288
+ if (!current) {
289
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
290
+ return;
291
+ }
292
+ const task = store.set({ ...current, ...body, id });
293
+ if (task.status === 'active') {
294
+ scheduler.scheduleTask(task);
295
+ }
296
+ sendJson(res, 200, { ok: true, task });
243
297
  return;
244
298
  }
245
299
 
246
- const historyMatch = pathname.match(/^\/dsh-cron\/action\/([^/]+)\/history$/);
247
- if (req.method === 'GET' && historyMatch) {
248
- const taskId = historyMatch[1];
249
- const history = store.getHistory(taskId, 30);
250
- sendJson(res, 200, { ok: true, history });
300
+ if (req.method === 'DELETE') {
301
+ const current = store.get(id);
302
+ if (current) {
303
+ scheduler.pauseTask(id);
304
+ store.delete(id);
305
+ }
306
+ sendJson(res, 200, { ok: true });
251
307
  return;
252
308
  }
253
309
 
254
- sendJson(res, 404, { ok: false, error: 'Action not found' });
310
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
255
311
  } catch (err) {
256
312
  sendJson(res, 500, { ok: false, error: err.message });
257
313
  }
258
314
  }
259
- }), 'dsh-cron: /action');
260
-
261
- // 2. AI Tools for Model Interaction
262
- if (ctx.tools && typeof ctx.tools.register === 'function') {
263
- ctx.tools.register(defineTool({
264
- name: 'cron_schedule_task',
265
- description: 'Запланировать автоматическую задачу по расписанию (cron).',
266
- parameters: {
267
- title: { type: 'string', required: true, description: 'Краткое название задачи' },
268
- schedule: { type: 'string', required: true, description: 'Выражение cron или интервал (every 30m, 0 9 * * 1-5, daily)' },
269
- prompt: { type: 'string', required: true, description: 'Инструкция для агента при запуске' },
270
- delivery: { type: 'string', description: 'Режим запуска: current или isolated' },
271
- type: { type: 'string', description: 'Тип задачи: "llm" (агент с LLM) или "script" (прямой запуск shell без LLM)' },
272
- provider: { type: 'string', description: 'ID провайдера LLM для выполнения задачи' },
273
- model: { type: 'string', description: 'ID модели LLM для выполнения задачи' },
315
+ }), 'dsh-cron: /tasks/:id');
316
+
317
+ // 2. Tools
318
+ ctx.tools.register(defineTool({
319
+ name: 'cron_create_task',
320
+ description: 'Создать новую запланированную задачу cron (запуск по расписанию или разово через "at: ISO" или "in 20m")',
321
+ parameters: {
322
+ type: 'object',
323
+ properties: {
324
+ title: { type: 'string', description: 'Краткое название задачи' },
325
+ schedule: { type: 'string', description: 'Расписание: cron ("0 9 * * *"), интервал ("every 2h") или разово ("in 30m", "at: 2026-09-05T15:00:00Z")' },
326
+ prompt: { type: 'string', description: 'Промпт/инструкция для агента при запуске' },
327
+ type: { type: 'string', enum: ['prompt', 'script'], description: 'Тип задачи' },
328
+ delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Режим запуска: current (в текущем чате) или isolated (изолированная сессия)' },
329
+ provider: { type: 'string', description: 'Провайдер модели (опционально)' },
330
+ model: { type: 'string', description: 'Модель (опционально)' },
274
331
  notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
275
- onlyOnFailure: { type: 'boolean', description: 'Отправлять в Telegram только при ошибках' },
276
- timeoutSeconds: { type: 'integer', description: 'Таймаут выполнения задачи в секундах (по умолчанию 1800 = 30 мин)' },
277
- overlapPolicy: { type: 'string', description: 'Политика наложения: "skip" (по умолчанию), "queue", "replace"' }
278
- },
279
- output: {
280
- schema: {
281
- type: 'object',
282
- additionalProperties: false,
283
- properties: {
284
- success: { type: 'boolean' },
285
- message: { type: 'string' }
286
- }
287
- },
288
- render(_args, value) {
289
- return [{ type: 'text', text: value.message }];
290
- }
332
+ onlyOnFailure: { type: 'boolean', description: 'Отправлять отчет только при сбоях' },
333
+ timeoutSeconds: { type: 'number', description: 'Лимит времени выполнения в секундах' },
334
+ overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Политика перекрытия' },
335
+ kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Создавать карточку в Kanban' },
291
336
  },
292
- execute: async ({ title, schedule, prompt, delivery, provider, model, type, notifyTelegram, onlyOnFailure, timeoutSeconds, overlapPolicy }) => {
293
- try {
294
- const parsed = parseScheduleExpression(schedule);
295
- const task = store.set({
296
- title,
297
- schedule: parsed.cronPattern,
298
- scheduleText: parsed.humanText,
299
- prompt,
300
- type: type || (provider || model ? 'llm' : 'llm'),
301
- delivery: delivery || 'isolated',
302
- provider: provider || undefined,
303
- model: model || undefined,
304
- notifyTelegram: Boolean(notifyTelegram),
305
- onlyOnFailure: Boolean(onlyOnFailure),
306
- timeoutSeconds: timeoutSeconds !== undefined ? Number(timeoutSeconds) : 1800,
307
- overlapPolicy: overlapPolicy || 'skip',
308
- status: 'active'
309
- });
310
- scheduler.scheduleTask(task);
311
- return {
312
- success: true,
313
- message: `Задача "${task.title}" запланирована (${parsed.humanText}). Следующий запуск: ${task.nextRunAt ? new Date(task.nextRunAt).toLocaleString() : 'скоро'}.`
314
- };
315
- } catch (err) {
316
- return { success: false, message: 'Ошибка планирования: ' + err.message };
317
- }
318
- }
319
- }));
337
+ required: ['title', 'schedule', 'prompt']
338
+ },
339
+ execute: async (args) => {
340
+ const parsed = parseScheduleExpression(args.schedule);
341
+ const task = store.set({
342
+ title: args.title,
343
+ schedule: parsed.cronPattern || args.schedule,
344
+ scheduleText: parsed.humanText,
345
+ prompt: args.prompt,
346
+ type: args.type || 'prompt',
347
+ delivery: args.delivery || 'isolated',
348
+ status: 'active',
349
+ provider: args.provider,
350
+ model: args.model,
351
+ notifyTelegram: Boolean(args.notifyTelegram),
352
+ onlyOnFailure: Boolean(args.onlyOnFailure),
353
+ timeoutSeconds: Number(args.timeoutSeconds) || 1800,
354
+ overlapPolicy: args.overlapPolicy || 'skip',
355
+ kanbanMode: args.kanbanMode || 'none',
356
+ oneShot: Boolean(parsed.isOneShot),
357
+ });
358
+ scheduler.scheduleTask(task);
359
+ return {
360
+ success: true,
361
+ message: `Задача "${task.title}" успешно запланирована (${task.scheduleText})`,
362
+ task
363
+ };
364
+ }
365
+ }));
320
366
 
321
- ctx.tools.register(defineTool({
322
- name: 'cron_list_tasks',
323
- description: 'Получить список текущих запланированных cron-задач.',
324
- parameters: {
325
- status: { type: 'string', description: 'Фильтр статуса: all, active, paused, completed' }
326
- },
327
- output: {
328
- schema: {
329
- type: 'object',
330
- additionalProperties: false,
331
- properties: {
332
- total: { type: 'integer' },
333
- text: { type: 'string' }
334
- }
335
- },
336
- render(_args, value) {
337
- return [{ type: 'text', text: value.text }];
338
- }
339
- },
340
- execute: async ({ status }) => {
341
- const tasks = store.list({ status: status || 'all' });
342
- const text = tasks.length === 0
343
- ? 'Нет запланированных задач.'
344
- : tasks.map(t => `• ${t.title} [${t.status}] — ${t.scheduleText}`).join('\n');
345
- return {
346
- total: tasks.length,
347
- text
348
- };
367
+ ctx.tools.register(defineTool({
368
+ name: 'cron_list_tasks',
369
+ description: 'Получить список всех запланированных задач cron',
370
+ parameters: {
371
+ type: 'object',
372
+ properties: {
373
+ status: { type: 'string', enum: ['all', 'active', 'paused', 'completed'], description: 'Фильтр по статусу' }
349
374
  }
350
- }));
351
-
352
- ctx.tools.register(defineTool({
353
- name: 'cron_toggle_task',
354
- description: 'Приостановить, возобновить или запустить cron-задачу.',
355
- parameters: {
356
- taskId: { type: 'string', required: true, description: 'Идентификатор задачи' },
357
- action: { type: 'string', required: true, description: 'pause, resume, run_now или delete' }
375
+ },
376
+ execute: async (args) => {
377
+ const tasks = store.list({ status: args.status || 'all' });
378
+ return {
379
+ count: tasks.length,
380
+ tasks: tasks.map(t => ({
381
+ id: t.id,
382
+ title: t.title,
383
+ schedule: t.scheduleText || t.schedule,
384
+ status: t.status,
385
+ nextRunAt: t.nextRunAt ? new Date(t.nextRunAt).toISOString() : null,
386
+ lastStatus: t.lastStatus,
387
+ totalTokens: t.totalTokens || 0,
388
+ totalCostUsd: t.totalCostUsd || 0,
389
+ oneShot: Boolean(t.oneShot),
390
+ }))
391
+ };
392
+ }
393
+ }));
394
+
395
+ ctx.tools.register(defineTool({
396
+ name: 'cron_pause_task',
397
+ description: 'Приостановить выполнение задачи по её ID',
398
+ parameters: {
399
+ type: 'object',
400
+ properties: {
401
+ id: { type: 'string', description: 'Идентификатор задачи' }
358
402
  },
359
- output: {
360
- schema: {
361
- type: 'object',
362
- additionalProperties: false,
363
- properties: {
364
- success: { type: 'boolean' },
365
- message: { type: 'string' }
366
- }
367
- },
368
- render(_args, value) {
369
- return [{ type: 'text', text: value.message }];
370
- }
403
+ required: ['id']
404
+ },
405
+ execute: async (args) => {
406
+ const task = scheduler.pauseTask(args.id);
407
+ if (!task) return { success: false, message: 'Задача не найдена' };
408
+ return { success: true, message: `Задача "${task.title}" приостановлена` };
409
+ }
410
+ }));
411
+
412
+ ctx.tools.register(defineTool({
413
+ name: 'cron_resume_task',
414
+ description: 'Возобновить выполнение приостановленной задачи',
415
+ parameters: {
416
+ type: 'object',
417
+ properties: {
418
+ id: { type: 'string', description: 'Идентификатор задачи' }
371
419
  },
372
- execute: async ({ taskId, action }) => {
373
- if (action === 'pause') {
374
- const t = scheduler.pauseTask(taskId);
375
- return { success: !!t, message: t ? `Задача "${t.title}" приостановлена` : 'Задача не найдена' };
376
- }
377
- if (action === 'resume') {
378
- const t = scheduler.resumeTask(taskId);
379
- return { success: !!t, message: t ? `Задача "${t.title}" возобновлена` : 'Задача не найдена' };
380
- }
381
- if (action === 'run_now') {
382
- scheduler.runTask(taskId);
383
- return { success: true, message: `Задача ${taskId} запущена` };
384
- }
385
- if (action === 'delete') {
386
- const ok = scheduler.removeTask(taskId);
387
- return { success: ok, message: ok ? 'Задача удалена' : 'Задача не найдена' };
388
- }
389
- return { success: false, message: 'Неизвестное действие' };
390
- }
391
- }));
392
- }
393
-
394
- // 3. /cron Slash Command Handler
395
- ctx.on('user/message', async (event) => {
396
- const text = (event && event.text || event && event.message && event.message.content || '').trim();
397
- if (!text.startsWith('/cron')) return;
398
-
399
- const parts = text.slice(5).trim().split(/\s+/);
400
- const subCmd = parts[0] ? parts[0].toLowerCase() : '';
401
-
402
- if (!subCmd || subCmd === 'help') {
403
- const helpMsg = `**⏰ DSH Cron — Управление запланированными задачами**\n\n` +
404
- `• \`/cron list\` — показать активные задачи\n` +
405
- `• \`/cron add <расписание> <промпт>\` — создать новую задачу\n` +
406
- `• \`/cron pause <id>\` — приостановить задачу\n` +
407
- `• \`/cron resume <id>\` — возобновить задачу\n` +
408
- `• \`/cron run <id>\` — запустить сейчас\n\n` +
409
- `_Открыть графический экран управления можно в боковой панели._`;
410
-
411
- if (typeof event.reply === 'function') {
412
- event.reply(helpMsg);
413
- }
414
- return;
420
+ required: ['id']
421
+ },
422
+ execute: async (args) => {
423
+ const task = scheduler.resumeTask(args.id);
424
+ if (!task) return { success: false, message: 'Задача не найдена' };
425
+ return { success: true, message: `Задача "${task.title}" возобновлена (${task.scheduleText})` };
415
426
  }
427
+ }));
416
428
 
417
- if (subCmd === 'list') {
418
- const list = store.list();
419
- if (!list.length) {
420
- if (typeof event.reply === 'function') event.reply('Нет запланированных задач. Создайте через `/cron add` или в UI.');
421
- return;
422
- }
423
- const lines = list.map(t => `• **${t.title}** (${t.id}) — ${t.scheduleText} [${t.status}]`);
424
- if (typeof event.reply === 'function') event.reply(`**Запланированные задачи (${list.length}):**\n\n` + lines.join('\n'));
425
- return;
429
+ ctx.tools.register(defineTool({
430
+ name: 'cron_delete_task',
431
+ description: 'Удалить задачу по её ID',
432
+ parameters: {
433
+ type: 'object',
434
+ properties: {
435
+ id: { type: 'string', description: 'Идентификатор задачи' }
436
+ },
437
+ required: ['id']
438
+ },
439
+ execute: async (args) => {
440
+ scheduler.pauseTask(args.id);
441
+ const ok = store.delete(args.id);
442
+ return { success: ok, message: ok ? 'Задача удалена' : 'Задача не найдена' };
426
443
  }
444
+ }));
427
445
 
428
- if (subCmd === 'add') {
429
- const rest = text.slice(text.indexOf('add') + 3).trim();
430
- const match = rest.match(/^"([^"]+)"\s+(.+)$/) || rest.match(/^(\S+)\s+(.+)$/);
431
- if (!match) {
432
- if (typeof event.reply === 'function') event.reply('Формат: `/cron add "<расписание>" <промпт>`');
433
- return;
434
- }
435
- const [, scheduleStr, promptStr] = match;
436
- try {
437
- const parsed = parseScheduleExpression(scheduleStr);
438
- const task = store.set({
439
- title: promptStr.slice(0, 40),
440
- schedule: parsed.cronPattern,
441
- scheduleText: parsed.humanText,
442
- prompt: promptStr,
443
- status: 'active'
444
- });
445
- scheduler.scheduleTask(task);
446
- if (typeof event.reply === 'function') {
447
- event.reply(`✅ Создана задача: **${task.title}** (${parsed.humanText})`);
448
- }
449
- } catch (err) {
450
- if (typeof event.reply === 'function') event.reply(`❌ Ошибка: ${err.message}`);
451
- }
452
- return;
446
+ ctx.tools.register(defineTool({
447
+ name: 'cron_run_task',
448
+ description: 'Запустить задачу немедленно вне очереди',
449
+ parameters: {
450
+ type: 'object',
451
+ properties: {
452
+ id: { type: 'string', description: 'Идентификатор задачи' }
453
+ },
454
+ required: ['id']
455
+ },
456
+ execute: async (args) => {
457
+ const task = store.get(args.id);
458
+ if (!task) return { success: false, message: 'Задача не найдена' };
459
+ scheduler.triggerManualRun(args.id);
460
+ return { success: true, message: `Запущен внеплановый старт задачи "${task.title}"` };
453
461
  }
454
- });
462
+ }));
455
463
  }
@@ -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
  */
@@ -59,7 +61,11 @@ export class SessionRunner {
59
61
  return reject(err);
60
62
  }
61
63
  const out = (stdout || stderr || '').trim();
62
- resolve(out || 'Script completed with code 0');
64
+ resolve({
65
+ output: out || 'Script completed with code 0',
66
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
67
+ costUsd: 0
68
+ });
63
69
  });
64
70
  });
65
71
  }
@@ -87,7 +93,7 @@ export class SessionRunner {
87
93
 
88
94
  const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
89
95
  const provider = task.provider || defaultSel?.provider;
90
- const model = task.model || defaultSel?.model;
96
+ const model = task.model || defaultSel?.model || 'deepseek-v4-flash';
91
97
 
92
98
  const cwd = process.cwd();
93
99
  const handle = await this.ctx.agents.create({
@@ -106,7 +112,26 @@ export class SessionRunner {
106
112
  source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
107
113
  }));
108
114
 
109
- 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
+ };
110
135
  } catch (err) {
111
136
  console.error(`[dsh-cron] agent execution failed:`, err.message);
112
137
  throw err;
@@ -123,7 +148,14 @@ export class SessionRunner {
123
148
 
124
149
  if (session && typeof session.send === 'function') {
125
150
  const result = await session.send(task.prompt);
126
- 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
+ };
127
159
  }
128
160
  } catch (err) {
129
161
  console.error(`[dsh-cron] session execution failed:`, err.message);
@@ -131,6 +163,10 @@ export class SessionRunner {
131
163
  }
132
164
  }
133
165
 
134
- 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
+ };
135
171
  }
136
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');
@@ -27,6 +28,49 @@ export function parseScheduleExpression(input) {
27
28
  const str = String(input || '').trim();
28
29
  if (!str) throw new Error('Строка расписания не должна быть пустой');
29
30
 
31
+ // 1. One-shot ISO 8601 timestamp or explicit "at: <ISO/datetime>"
32
+ const atPrefixMatch = str.match(/^(?:at:\s*|at\s+)(.+)$/i);
33
+ const candidateIso = atPrefixMatch ? atPrefixMatch[1].trim() : str;
34
+ const parsedDate = new Date(candidateIso);
35
+ if (!isNaN(parsedDate.getTime()) && (candidateIso.includes('-') || candidateIso.includes('T') || atPrefixMatch)) {
36
+ const targetMs = parsedDate.getTime();
37
+ return {
38
+ cronPattern: null,
39
+ isOneShot: true,
40
+ targetTimestamp: targetMs,
41
+ humanText: `Разово: ${parsedDate.toLocaleString('ru-RU')}`,
42
+ nextRun: targetMs
43
+ };
44
+ }
45
+
46
+ // 2. Relative one-shot: "in 20m", "in 2h", "in 30s", "in 1d", "через 15 минут"
47
+ const inRelMatch = str.match(/^(?:in\s+|через\s+)(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|мин|минут|часа?|часов)?$/i);
48
+ if (inRelMatch) {
49
+ const num = parseInt(inRelMatch[1], 10);
50
+ const unit = (inRelMatch[2] || 'm').toLowerCase();
51
+ let delayMs = num * 60 * 1000;
52
+ let unitText = `${num} мин`;
53
+ if (unit.startsWith('s')) {
54
+ delayMs = num * 1000;
55
+ unitText = `${num} сек`;
56
+ } else if (unit.startsWith('h') || unit.startsWith('час')) {
57
+ delayMs = num * 3600 * 1000;
58
+ unitText = `${num} ч`;
59
+ } else if (unit.startsWith('d')) {
60
+ delayMs = num * 86400 * 1000;
61
+ unitText = `${num} дн`;
62
+ }
63
+ const targetMs = Date.now() + delayMs;
64
+ return {
65
+ cronPattern: null,
66
+ isOneShot: true,
67
+ targetTimestamp: targetMs,
68
+ humanText: `Разово через ${unitText} (${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
69
+ nextRun: targetMs
70
+ };
71
+ }
72
+
73
+ // 3. Repeating Interval: "every 5m", "every 2h", "every 1d"
30
74
  const intervalMatch = str.match(/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i);
31
75
  if (intervalMatch) {
32
76
  const num = parseInt(intervalMatch[1], 10);
@@ -54,6 +98,7 @@ export function parseScheduleExpression(input) {
54
98
  }
55
99
  }
56
100
 
101
+ // 4. Friendly recurring aliases
57
102
  const lower = str.toLowerCase();
58
103
  if (lower === 'daily' || lower === 'каждый день') {
59
104
  return { cronPattern: '0 9 * * *', humanText: 'Каждый день в 09:00' };
@@ -65,6 +110,7 @@ export function parseScheduleExpression(input) {
65
110
  return { cronPattern: '0 * * * *', humanText: 'Каждый час' };
66
111
  }
67
112
 
113
+ // 5. Standard 5-field cron expression
68
114
  try {
69
115
  const testJob = new Cron(str);
70
116
  const next = testJob.nextRun();
@@ -82,7 +128,8 @@ export class TaskScheduler {
82
128
  constructor(store, executeFn) {
83
129
  this.store = store;
84
130
  this.executeFn = executeFn;
85
- this.jobs = new Map();
131
+ this.jobs = new Map(); // taskId -> Cron instance
132
+ this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
86
133
  this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
87
134
  }
88
135
 
@@ -103,6 +150,13 @@ export class TaskScheduler {
103
150
  }
104
151
  this.jobs.clear();
105
152
 
153
+ for (const [id, timer] of this.timers.entries()) {
154
+ try {
155
+ clearTimeout(timer);
156
+ } catch {}
157
+ }
158
+ this.timers.clear();
159
+
106
160
  for (const [, run] of this.running.entries()) {
107
161
  try {
108
162
  run.controller.abort(new Error('Планировщик остановлен'));
@@ -112,15 +166,40 @@ export class TaskScheduler {
112
166
  }
113
167
 
114
168
  scheduleTask(task) {
169
+ // Clear any existing job or timer for this task
115
170
  if (this.jobs.has(task.id)) {
116
171
  this.jobs.get(task.id).stop();
117
172
  this.jobs.delete(task.id);
118
173
  }
174
+ if (this.timers.has(task.id)) {
175
+ clearTimeout(this.timers.get(task.id));
176
+ this.timers.delete(task.id);
177
+ }
119
178
 
120
179
  if (task.status !== 'active') return;
121
180
 
122
181
  try {
123
182
  const parsed = parseScheduleExpression(task.schedule);
183
+
184
+ // Handle One-shot task
185
+ if (parsed.isOneShot) {
186
+ task.oneShot = true;
187
+ const now = Date.now();
188
+ const delay = Math.max(0, parsed.targetTimestamp - now);
189
+ task.nextRunAt = parsed.targetTimestamp;
190
+ this.store.set(task);
191
+
192
+ // If target is in the past, or when delay triggers:
193
+ const timer = setTimeout(async () => {
194
+ this.timers.delete(task.id);
195
+ await this.runTask(task.id);
196
+ }, delay);
197
+
198
+ this.timers.set(task.id, timer);
199
+ return;
200
+ }
201
+
202
+ // Handle Recurring Cron job
124
203
  const job = new Cron(parsed.cronPattern, { protect: true }, async () => {
125
204
  await this.runTask(task.id);
126
205
  });
@@ -153,6 +232,8 @@ export class TaskScheduler {
153
232
  durationMs: 0,
154
233
  output: 'Запуск пропущен: предыдущий процесс ещё не завершился (overlapPolicy: skip)',
155
234
  error: null,
235
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
236
+ costUsd: 0,
156
237
  });
157
238
  return;
158
239
  }
@@ -179,10 +260,19 @@ export class TaskScheduler {
179
260
  let status = 'success';
180
261
  let output = '';
181
262
  let error = null;
263
+ let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
264
+ let costUsd = 0;
182
265
 
183
266
  try {
184
267
  if (typeof this.executeFn === 'function') {
185
- output = await this.executeFn(task, { signal: controller.signal });
268
+ const res = await this.executeFn(task, { signal: controller.signal });
269
+ if (typeof res === 'object' && res !== null) {
270
+ output = res.output || '';
271
+ usage = res.usage || usage;
272
+ costUsd = res.costUsd || 0;
273
+ } else {
274
+ output = String(res || '');
275
+ }
186
276
  }
187
277
  } catch (err) {
188
278
  status = 'error';
@@ -195,6 +285,8 @@ export class TaskScheduler {
195
285
  durationMs,
196
286
  output,
197
287
  error,
288
+ usage,
289
+ costUsd,
198
290
  };
199
291
  this.store.recordRun(taskId, runInfo);
200
292
 
@@ -206,7 +298,7 @@ export class TaskScheduler {
206
298
 
207
299
  const queuedCount = finishedRun?.queueCount || 0;
208
300
 
209
- // Telegram notification delivery
301
+ // 1. Telegram notification delivery
210
302
  try {
211
303
  const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
212
304
  if (shouldNotifyTask(task, runInfo, settings)) {
@@ -223,11 +315,52 @@ export class TaskScheduler {
223
315
  console.error('[dsh-cron] notification error:', notifyErr.message);
224
316
  }
225
317
 
226
- const job = this.jobs.get(taskId);
227
- if (job) {
228
- const next = job.nextRun();
229
- task.nextRunAt = next ? next.getTime() : null;
318
+ // 2. Kanban card creation
319
+ try {
320
+ if (shouldCreateKanbanCard(task, runInfo)) {
321
+ const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
322
+ const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
323
+ const cardTitle = isErr ? `[Сбой cron] ${task.title}` : `[Cron завершен] ${task.title}`;
324
+ const cardBody = [
325
+ `**Задача cron:** ${task.title} (\`${task.id}\`)`,
326
+ `**Расписание:** ${task.scheduleText || task.schedule}`,
327
+ `**Статус:** ${runInfo.status}`,
328
+ `**Время запуска:** ${new Date(runInfo.at).toLocaleString('ru-RU')}`,
329
+ runInfo.error ? `\n**Ошибка:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
330
+ runInfo.output ? `\n**Лог / Вывод:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
331
+ ].filter(Boolean).join('\n\n');
332
+
333
+ createKanbanCard({
334
+ title: cardTitle,
335
+ body: cardBody,
336
+ board: 'main',
337
+ column: isErr ? 'backlog' : 'done',
338
+ labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
339
+ kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000'
340
+ }).then((res) => {
341
+ if (res.success) {
342
+ console.log(`[dsh-cron] created kanban card for task "${task.title}"`);
343
+ }
344
+ }).catch((kErr) => {
345
+ console.error('[dsh-cron] kanban card error:', kErr.message);
346
+ });
347
+ }
348
+ } catch (kanbanErr) {
349
+ console.error('[dsh-cron] kanban error:', kanbanErr.message);
350
+ }
351
+
352
+ // 3. Reschedule or mark completed for one-shot
353
+ if (task.oneShot) {
354
+ task.status = 'completed';
355
+ task.nextRunAt = null;
230
356
  this.store.set(task);
357
+ } else {
358
+ const job = this.jobs.get(taskId);
359
+ if (job) {
360
+ const next = job.nextRun();
361
+ task.nextRunAt = next ? next.getTime() : null;
362
+ this.store.set(task);
363
+ }
231
364
  }
232
365
 
233
366
  if (queuedCount > 0) {
@@ -247,9 +380,15 @@ export class TaskScheduler {
247
380
  this.jobs.get(taskId).stop();
248
381
  this.jobs.delete(taskId);
249
382
  }
383
+ if (this.timers.has(taskId)) {
384
+ clearTimeout(this.timers.get(taskId));
385
+ this.timers.delete(taskId);
386
+ }
250
387
  const running = this.running.get(taskId);
251
388
  if (running) {
252
- running.controller.abort(new Error('Задача приостановлена'));
389
+ try {
390
+ running.controller.abort(new Error('Задача приостановлена'));
391
+ } catch {}
253
392
  this.running.delete(taskId);
254
393
  }
255
394
  this.store.set(task);
@@ -270,17 +409,30 @@ export class TaskScheduler {
270
409
  this.jobs.get(taskId).stop();
271
410
  this.jobs.delete(taskId);
272
411
  }
412
+ if (this.timers.has(taskId)) {
413
+ clearTimeout(this.timers.get(taskId));
414
+ this.timers.delete(taskId);
415
+ }
273
416
  const running = this.running.get(taskId);
274
417
  if (running) {
275
- running.controller.abort(new Error('Задача удалена'));
418
+ try {
419
+ running.controller.abort(new Error('Задача удалена'));
420
+ } catch {}
276
421
  this.running.delete(taskId);
277
422
  }
278
423
  return this.store.delete(taskId);
279
424
  }
280
425
 
426
+ triggerManualRun(taskId) {
427
+ return this.runTask(taskId);
428
+ }
429
+
281
430
  getNextRun(schedule) {
282
431
  try {
283
432
  const parsed = parseScheduleExpression(schedule);
433
+ if (parsed.isOneShot) {
434
+ return parsed.targetTimestamp;
435
+ }
284
436
  const tempJob = new Cron(parsed.cronPattern);
285
437
  const next = tempJob.nextRun();
286
438
  return next ? next.getTime() : null;
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();
@@ -129,6 +131,10 @@ export class TaskStore {
129
131
  onlyOnFailure: task.onlyOnFailure !== undefined ? Boolean(task.onlyOnFailure) : (prev.onlyOnFailure || false),
130
132
  timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
131
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
+ oneShot: task.oneShot !== undefined ? Boolean(task.oneShot) : Boolean(prev.oneShot),
136
+ totalTokens: prev.totalTokens || 0,
137
+ totalCostUsd: prev.totalCostUsd || 0,
132
138
  createdAt: prev.createdAt || task.createdAt || now,
133
139
  updatedAt: now,
134
140
  lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
@@ -154,6 +160,14 @@ export class TaskStore {
154
160
  task.lastRunAt = runInfo.at || Date.now();
155
161
  task.lastStatus = runInfo.status || 'success';
156
162
  task.lastDurationMs = runInfo.durationMs || 0;
163
+
164
+ // Track usage and cost
165
+ const usage = runInfo.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
166
+ const costUsd = Number(runInfo.costUsd) || 0;
167
+ const runTokens = (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0);
168
+
169
+ task.totalTokens = (task.totalTokens || 0) + runTokens;
170
+ task.totalCostUsd = Number(((task.totalCostUsd || 0) + costUsd).toFixed(6));
157
171
  task.updatedAt = Date.now();
158
172
 
159
173
  const runs = this.history.get(id) || [];
@@ -164,6 +178,8 @@ export class TaskStore {
164
178
  durationMs: task.lastDurationMs,
165
179
  output: (runInfo.output || '').slice(0, 4000),
166
180
  error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
181
+ usage,
182
+ costUsd,
167
183
  });
168
184
  if (runs.length > 50) runs.length = 50;
169
185
  this.history.set(id, runs);
@@ -174,4 +190,26 @@ export class TaskStore {
174
190
  const runs = this.history.get(id) || [];
175
191
  return runs.slice(0, limit);
176
192
  }
193
+
194
+ getAggregatedStats() {
195
+ let totalRuns = 0;
196
+ let totalTokens = 0;
197
+ let totalCostUsd = 0;
198
+ let activeTasks = 0;
199
+
200
+ for (const t of this.tasks.values()) {
201
+ if (t.status === 'active') activeTasks++;
202
+ totalTokens += (t.totalTokens || 0);
203
+ totalCostUsd += (t.totalCostUsd || 0);
204
+ const h = this.history.get(t.id) || [];
205
+ totalRuns += h.length;
206
+ }
207
+
208
+ return {
209
+ activeTasks,
210
+ totalRuns,
211
+ totalTokens,
212
+ totalCostUsd: Number(totalCostUsd.toFixed(4)),
213
+ };
214
+ }
177
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",