@goodandready/dsh-cron 0.1.16 → 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
@@ -187,3 +187,8 @@ 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.
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
@@ -140,6 +140,13 @@ 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-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; }
143
150
  .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer; user-select: none; }
144
151
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: #ededed; }
145
152
  .dsh-cron-hint { font-size: 12px; color: #888; margin-top: 4px; }
@@ -251,6 +258,8 @@ const React = window.React || require('react');
251
258
  const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
252
259
  const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
253
260
  const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
261
+ const [formKanbanMode, setFormKanbanMode] = React.useState('none');
262
+ const [stats, setStats] = React.useState(null);
254
263
 
255
264
  // Settings modal state
256
265
  const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
@@ -291,6 +300,7 @@ const React = window.React || require('react');
291
300
  if (data && data.ok) {
292
301
  setTasks(data.tasks || []);
293
302
  setRecs(data.recommendations || []);
303
+ if (data.stats) setStats(data.stats);
294
304
  }
295
305
  } catch (err) {}
296
306
  };
@@ -406,6 +416,7 @@ const React = window.React || require('react');
406
416
  setFormOnlyOnFailure(false);
407
417
  setFormTimeoutSeconds(1800);
408
418
  setFormOverlapPolicy('skip');
419
+ setFormKanbanMode('none');
409
420
  if (preset) {
410
421
  setFormTitle(preset.title || '');
411
422
  setFormSchedule(preset.schedule || '');
@@ -455,6 +466,7 @@ const React = window.React || require('react');
455
466
  setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
456
467
  setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
457
468
  setFormOverlapPolicy(task.overlapPolicy || 'skip');
469
+ setFormKanbanMode(task.kanbanMode || 'none');
458
470
  if (task.provider) loadModels(task.provider);
459
471
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
460
472
  loadTaskHistory(task.id);
@@ -480,6 +492,11 @@ const React = window.React || require('react');
480
492
  delivery: formDelivery || 'isolated',
481
493
  provider: formType === 'script' ? undefined : (formProvider || undefined),
482
494
  model: formType === 'script' ? undefined : (formModel || undefined),
495
+ notifyTelegram: formNotifyTelegram,
496
+ onlyOnFailure: formOnlyOnFailure,
497
+ timeoutSeconds: Number(formTimeoutSeconds) || 1800,
498
+ overlapPolicy: formOverlapPolicy,
499
+ kanbanMode: formKanbanMode,
483
500
  };
484
501
  const res = await fetch('/dsh-cron/tasks', {
485
502
  method: 'POST',
@@ -582,6 +599,24 @@ const React = window.React || require('react');
582
599
  onChange: (e) => setQuery(e.target.value)
583
600
  })
584
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
+ ),
585
620
  React.createElement('div', { className: 'dsh-cron-tabs' },
586
621
  ['all', 'active', 'paused', 'completed'].map((t) =>
587
622
  React.createElement('button', {
@@ -618,7 +653,9 @@ const React = window.React || require('react');
618
653
  color: task.type === 'script' ? '#60a5fa' : '#34d399'
619
654
  }
620
655
  }, task.type === 'script' ? 'Shell' : 'LLM'),
621
- 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
622
659
  ),
623
660
  React.createElement('div', { className: 'dsh-cron-task-sched' },
624
661
  task.scheduleText || task.schedule,
@@ -728,7 +765,11 @@ const React = window.React || require('react');
728
765
  }, run.status === 'success' ? 'Успешно' : run.status === 'skipped' ? 'Пропущен' : 'Ошибка'),
729
766
  React.createElement('span', { className: 'dsh-cron-history-time' }, run.at ? new Date(run.at).toLocaleString() : '')
730
767
  ),
731
- 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
+ )
732
773
  ),
733
774
  run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
734
775
  run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: '#ef4444', borderColor: '#552222' } }, run.error)
@@ -802,6 +843,17 @@ const React = window.React || require('react');
802
843
  )
803
844
  )
804
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
+ ),
805
857
  formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' } },
806
858
  React.createElement('div', { className: 'dsh-cron-form-group' },
807
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
 
@@ -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
 
@@ -111,6 +113,7 @@ 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',
114
117
  });
115
118
  scheduler.scheduleTask(task);
116
119
  sendJson(res, 200, { ok: true, task });
@@ -176,280 +179,282 @@ export function apply(ctx, config) {
176
179
  return;
177
180
  }
178
181
  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();
182
+ const settings = store.getSettings();
183
+ const botToken = body.botToken || settings.botToken;
184
+ const chatId = body.chatId || settings.chatId;
182
185
 
183
- if (!botToken) {
184
- sendJson(res, 400, { ok: false, error: 'Укажите Telegram Bot Token' });
186
+ if (!botToken || !chatId) {
187
+ sendJson(res, 400, { ok: false, error: 'Не указан botToken или chatId' });
185
188
  return;
186
189
  }
187
- if (!chatId) {
188
- 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' });
189
208
  return;
190
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';
191
213
 
192
- await sendTelegramMessage({
193
- botToken,
194
- chatId,
195
- text: '⏰ *DSH Cron — Проверка связи*\nТестовое уведомление успешно доставлено! Планировщик готов к отправке отчётов.',
196
- 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
197
221
  });
198
222
 
199
- 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
+ }
200
228
  } catch (err) {
201
229
  sendJson(res, 500, { ok: false, error: err.message });
202
230
  }
203
231
  }
204
- }), 'dsh-cron: /telegram/test');
232
+ }), 'dsh-cron: /kanban/test');
205
233
 
206
- // Prefix routes for actions (/dsh-cron/action/*)
234
+ // PATCH & DELETE /dsh-cron/tasks/:id
207
235
  ctx.effect(() => ctx.webServer.register({
208
236
  kind: 'prefix',
209
- path: '/dsh-cron/action',
237
+ path: '/dsh-cron/tasks/',
210
238
  handler: async (req, res) => {
211
239
  try {
212
240
  const url = new URL(req.url, 'http://127.0.0.1');
213
- const pathname = url.pathname;
241
+ const parts = url.pathname.split('/').filter(Boolean);
242
+ const id = parts[2];
243
+ const action = parts[3];
214
244
 
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);
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);
219
266
  if (!task) {
220
- sendJson(res, 404, { ok: false, error: 'Task not found' });
267
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
221
268
  return;
222
269
  }
223
- const updated = task.status === 'active'
224
- ? scheduler.pauseTask(taskId)
225
- : scheduler.resumeTask(taskId);
226
- sendJson(res, 200, { ok: true, task: updated });
270
+ sendJson(res, 200, { ok: true, task });
227
271
  return;
228
272
  }
229
273
 
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 });
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 });
235
281
  return;
236
282
  }
237
283
 
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 });
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 });
243
296
  return;
244
297
  }
245
298
 
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 });
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 });
251
306
  return;
252
307
  }
253
308
 
254
- sendJson(res, 404, { ok: false, error: 'Action not found' });
309
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
255
310
  } catch (err) {
256
311
  sendJson(res, 500, { ok: false, error: err.message });
257
312
  }
258
313
  }
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 для выполнения задачи' },
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: 'Модель (опционально)' },
274
330
  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"' }
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' },
278
335
  },
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
- }
291
- },
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
- }));
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
+ }));
320
364
 
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
- };
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: 'Фильтр по статусу' }
349
372
  }
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' }
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: 'Идентификатор задачи' }
358
399
  },
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
- }
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: 'Идентификатор задачи' }
371
416
  },
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;
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})` };
415
423
  }
424
+ }));
416
425
 
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;
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 ? 'Задача удалена' : 'Задача не найдена' };
426
440
  }
441
+ }));
427
442
 
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;
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}"` };
453
458
  }
454
- });
459
+ }));
455
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
  */
@@ -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');
@@ -153,6 +154,8 @@ export class TaskScheduler {
153
154
  durationMs: 0,
154
155
  output: 'Запуск пропущен: предыдущий процесс ещё не завершился (overlapPolicy: skip)',
155
156
  error: null,
157
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
158
+ costUsd: 0,
156
159
  });
157
160
  return;
158
161
  }
@@ -179,10 +182,19 @@ export class TaskScheduler {
179
182
  let status = 'success';
180
183
  let output = '';
181
184
  let error = null;
185
+ let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
186
+ let costUsd = 0;
182
187
 
183
188
  try {
184
189
  if (typeof this.executeFn === 'function') {
185
- output = await this.executeFn(task, { signal: controller.signal });
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
+ }
186
198
  }
187
199
  } catch (err) {
188
200
  status = 'error';
@@ -195,6 +207,8 @@ export class TaskScheduler {
195
207
  durationMs,
196
208
  output,
197
209
  error,
210
+ usage,
211
+ costUsd,
198
212
  };
199
213
  this.store.recordRun(taskId, runInfo);
200
214
 
@@ -206,7 +220,7 @@ export class TaskScheduler {
206
220
 
207
221
  const queuedCount = finishedRun?.queueCount || 0;
208
222
 
209
- // Telegram notification delivery
223
+ // 1. Telegram notification delivery
210
224
  try {
211
225
  const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
212
226
  if (shouldNotifyTask(task, runInfo, settings)) {
@@ -223,6 +237,40 @@ export class TaskScheduler {
223
237
  console.error('[dsh-cron] notification error:', notifyErr.message);
224
238
  }
225
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
+
226
274
  const job = this.jobs.get(taskId);
227
275
  if (job) {
228
276
  const next = job.nextRun();
@@ -249,7 +297,9 @@ export class TaskScheduler {
249
297
  }
250
298
  const running = this.running.get(taskId);
251
299
  if (running) {
252
- running.controller.abort(new Error('Задача приостановлена'));
300
+ try {
301
+ running.controller.abort(new Error('Задача приостановлена'));
302
+ } catch {}
253
303
  this.running.delete(taskId);
254
304
  }
255
305
  this.store.set(task);
@@ -272,12 +322,18 @@ export class TaskScheduler {
272
322
  }
273
323
  const running = this.running.get(taskId);
274
324
  if (running) {
275
- running.controller.abort(new Error('Задача удалена'));
325
+ try {
326
+ running.controller.abort(new Error('Задача удалена'));
327
+ } catch {}
276
328
  this.running.delete(taskId);
277
329
  }
278
330
  return this.store.delete(taskId);
279
331
  }
280
332
 
333
+ triggerManualRun(taskId) {
334
+ return this.runTask(taskId);
335
+ }
336
+
281
337
  getNextRun(schedule) {
282
338
  try {
283
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();
@@ -129,6 +131,9 @@ 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
+ totalTokens: prev.totalTokens || 0,
136
+ totalCostUsd: prev.totalCostUsd || 0,
132
137
  createdAt: prev.createdAt || task.createdAt || now,
133
138
  updatedAt: now,
134
139
  lastRunAt: task.lastRunAt !== undefined ? task.lastRunAt : (prev.lastRunAt || null),
@@ -154,6 +159,14 @@ export class TaskStore {
154
159
  task.lastRunAt = runInfo.at || Date.now();
155
160
  task.lastStatus = runInfo.status || 'success';
156
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));
157
170
  task.updatedAt = Date.now();
158
171
 
159
172
  const runs = this.history.get(id) || [];
@@ -164,6 +177,8 @@ export class TaskStore {
164
177
  durationMs: task.lastDurationMs,
165
178
  output: (runInfo.output || '').slice(0, 4000),
166
179
  error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
180
+ usage,
181
+ costUsd,
167
182
  });
168
183
  if (runs.length > 50) runs.length = 50;
169
184
  this.history.set(id, runs);
@@ -174,4 +189,26 @@ export class TaskStore {
174
189
  const runs = this.history.get(id) || [];
175
190
  return runs.slice(0, limit);
176
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
+ }
177
214
  }
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.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",