@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 +5 -0
- package/docs/README.ru.md +5 -0
- package/lib/client.js +54 -2
- package/lib/index.js +233 -228
- package/lib/integrations.js +95 -0
- package/lib/runner.js +41 -5
- package/lib/scheduler.js +60 -4
- package/lib/store.js +37 -0
- package/package.json +1 -1
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('
|
|
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
|
-
|
|
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
|
|
180
|
-
const botToken =
|
|
181
|
-
const chatId =
|
|
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: '
|
|
186
|
+
if (!botToken || !chatId) {
|
|
187
|
+
sendJson(res, 400, { ok: false, error: 'Не указан botToken или chatId' });
|
|
185
188
|
return;
|
|
186
189
|
}
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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: /
|
|
232
|
+
}), 'dsh-cron: /kanban/test');
|
|
205
233
|
|
|
206
|
-
//
|
|
234
|
+
// PATCH & DELETE /dsh-cron/tasks/:id
|
|
207
235
|
ctx.effect(() => ctx.webServer.register({
|
|
208
236
|
kind: 'prefix',
|
|
209
|
-
path: '/dsh-cron/
|
|
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
|
|
241
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
242
|
+
const id = parts[2];
|
|
243
|
+
const action = parts[3];
|
|
214
244
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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: '
|
|
267
|
+
sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
|
|
221
268
|
return;
|
|
222
269
|
}
|
|
223
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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,
|
|
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: /
|
|
260
|
-
|
|
261
|
-
// 2.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
type: { type: 'string',
|
|
272
|
-
|
|
273
|
-
|
|
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: 'Отправлять
|
|
276
|
-
timeoutSeconds: { type: '
|
|
277
|
-
overlapPolicy: { type: 'string',
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|