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