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