@goodandready/dsh-cron 0.1.17 → 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 +5 -0
- package/docs/README.ru.md +5 -0
- package/lib/client.js +3 -1
- package/lib/index.js +8 -5
- package/lib/scheduler.js +101 -5
- package/lib/store.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -192,3 +192,8 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
|
192
192
|
- **Automatic Kanban Card Creation**: Automatically creates task/bug cards in `dsh-kanban` on task failure (`on_failure`) or every run (`always`).
|
|
193
193
|
- **Token & Execution Cost Meter**: Tracks token consumption (input, output, cache tokens) for LLM executions and estimates USD expenses using current model pricing.
|
|
194
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
|
@@ -175,3 +175,8 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
|
175
175
|
- **Автосоздание карточек в Kanban**: Автоматическое заведение задач в бэклог `dsh-kanban` при обнаружении сбоев/ошибок (`on_failure`) или при каждом завершении (`always`).
|
|
176
176
|
- **Трекинг токенов и расчет расходов (USD)**: Подсчет потребления токенов (input, output, cacheRead) для каждой задачи и запуск с расчетом примерной стоимости в долларах США.
|
|
177
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
|
@@ -145,6 +145,7 @@ const React = window.React || require('react');
|
|
|
145
145
|
.dsh-cron-stat-card { background: rgba(255, 255, 255, 0.02); border: 1px solid #222; border-radius: 8px; padding: 10px 14px; }
|
|
146
146
|
.dsh-cron-stat-val { font-size: 16px; font-weight: 600; color: #fff; }
|
|
147
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; }
|
|
148
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; }
|
|
149
150
|
.dsh-cron-tokens-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: rgba(59, 130, 246, 0.12); color: #60a5fa; }
|
|
150
151
|
.dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; cursor: pointer; user-select: none; }
|
|
@@ -654,6 +655,7 @@ const React = window.React || require('react');
|
|
|
654
655
|
}
|
|
655
656
|
}, task.type === 'script' ? 'Shell' : 'LLM'),
|
|
656
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,
|
|
657
659
|
task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
|
|
658
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
|
|
659
661
|
),
|
|
@@ -815,7 +817,7 @@ const React = window.React || require('react');
|
|
|
815
817
|
React.createElement('input', {
|
|
816
818
|
required: true,
|
|
817
819
|
value: formSchedule,
|
|
818
|
-
placeholder: '0 */6 * * * или every 2h',
|
|
820
|
+
placeholder: '0 */6 * * * или every 2h или in 30m или at: 2026-09-05T15:00:00Z',
|
|
819
821
|
onChange: (e) => setFormSchedule(e.target.value)
|
|
820
822
|
})
|
|
821
823
|
)
|
package/lib/index.js
CHANGED
|
@@ -101,7 +101,7 @@ export function apply(ctx, config) {
|
|
|
101
101
|
const task = store.set({
|
|
102
102
|
id: body.id,
|
|
103
103
|
title: body.title,
|
|
104
|
-
schedule: parsed.cronPattern,
|
|
104
|
+
schedule: parsed.cronPattern || body.schedule,
|
|
105
105
|
scheduleText: body.scheduleText || parsed.humanText,
|
|
106
106
|
prompt: body.prompt,
|
|
107
107
|
type: body.type || 'prompt',
|
|
@@ -114,6 +114,7 @@ export function apply(ctx, config) {
|
|
|
114
114
|
timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : 1800,
|
|
115
115
|
overlapPolicy: body.overlapPolicy || 'skip',
|
|
116
116
|
kanbanMode: body.kanbanMode || 'none',
|
|
117
|
+
oneShot: Boolean(parsed.isOneShot || body.oneShot),
|
|
117
118
|
});
|
|
118
119
|
scheduler.scheduleTask(task);
|
|
119
120
|
sendJson(res, 200, { ok: true, task });
|
|
@@ -316,12 +317,12 @@ export function apply(ctx, config) {
|
|
|
316
317
|
// 2. Tools
|
|
317
318
|
ctx.tools.register(defineTool({
|
|
318
319
|
name: 'cron_create_task',
|
|
319
|
-
description: 'Создать новую запланированную задачу cron (запуск по расписанию)',
|
|
320
|
+
description: 'Создать новую запланированную задачу cron (запуск по расписанию или разово через "at: ISO" или "in 20m")',
|
|
320
321
|
parameters: {
|
|
321
322
|
type: 'object',
|
|
322
323
|
properties: {
|
|
323
324
|
title: { type: 'string', description: 'Краткое название задачи' },
|
|
324
|
-
schedule: { type: 'string', description: 'Расписание: cron
|
|
325
|
+
schedule: { type: 'string', description: 'Расписание: cron ("0 9 * * *"), интервал ("every 2h") или разово ("in 30m", "at: 2026-09-05T15:00:00Z")' },
|
|
325
326
|
prompt: { type: 'string', description: 'Промпт/инструкция для агента при запуске' },
|
|
326
327
|
type: { type: 'string', enum: ['prompt', 'script'], description: 'Тип задачи' },
|
|
327
328
|
delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Режим запуска: current (в текущем чате) или isolated (изолированная сессия)' },
|
|
@@ -339,7 +340,7 @@ export function apply(ctx, config) {
|
|
|
339
340
|
const parsed = parseScheduleExpression(args.schedule);
|
|
340
341
|
const task = store.set({
|
|
341
342
|
title: args.title,
|
|
342
|
-
schedule: parsed.cronPattern,
|
|
343
|
+
schedule: parsed.cronPattern || args.schedule,
|
|
343
344
|
scheduleText: parsed.humanText,
|
|
344
345
|
prompt: args.prompt,
|
|
345
346
|
type: args.type || 'prompt',
|
|
@@ -352,6 +353,7 @@ export function apply(ctx, config) {
|
|
|
352
353
|
timeoutSeconds: Number(args.timeoutSeconds) || 1800,
|
|
353
354
|
overlapPolicy: args.overlapPolicy || 'skip',
|
|
354
355
|
kanbanMode: args.kanbanMode || 'none',
|
|
356
|
+
oneShot: Boolean(parsed.isOneShot),
|
|
355
357
|
});
|
|
356
358
|
scheduler.scheduleTask(task);
|
|
357
359
|
return {
|
|
@@ -368,7 +370,7 @@ export function apply(ctx, config) {
|
|
|
368
370
|
parameters: {
|
|
369
371
|
type: 'object',
|
|
370
372
|
properties: {
|
|
371
|
-
status: { type: 'string', enum: ['all', 'active', 'paused'], description: 'Фильтр по статусу' }
|
|
373
|
+
status: { type: 'string', enum: ['all', 'active', 'paused', 'completed'], description: 'Фильтр по статусу' }
|
|
372
374
|
}
|
|
373
375
|
},
|
|
374
376
|
execute: async (args) => {
|
|
@@ -384,6 +386,7 @@ export function apply(ctx, config) {
|
|
|
384
386
|
lastStatus: t.lastStatus,
|
|
385
387
|
totalTokens: t.totalTokens || 0,
|
|
386
388
|
totalCostUsd: t.totalCostUsd || 0,
|
|
389
|
+
oneShot: Boolean(t.oneShot),
|
|
387
390
|
}))
|
|
388
391
|
};
|
|
389
392
|
}
|
package/lib/scheduler.js
CHANGED
|
@@ -28,6 +28,49 @@ export function parseScheduleExpression(input) {
|
|
|
28
28
|
const str = String(input || '').trim();
|
|
29
29
|
if (!str) throw new Error('Строка расписания не должна быть пустой');
|
|
30
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"
|
|
31
74
|
const intervalMatch = str.match(/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i);
|
|
32
75
|
if (intervalMatch) {
|
|
33
76
|
const num = parseInt(intervalMatch[1], 10);
|
|
@@ -55,6 +98,7 @@ export function parseScheduleExpression(input) {
|
|
|
55
98
|
}
|
|
56
99
|
}
|
|
57
100
|
|
|
101
|
+
// 4. Friendly recurring aliases
|
|
58
102
|
const lower = str.toLowerCase();
|
|
59
103
|
if (lower === 'daily' || lower === 'каждый день') {
|
|
60
104
|
return { cronPattern: '0 9 * * *', humanText: 'Каждый день в 09:00' };
|
|
@@ -66,6 +110,7 @@ export function parseScheduleExpression(input) {
|
|
|
66
110
|
return { cronPattern: '0 * * * *', humanText: 'Каждый час' };
|
|
67
111
|
}
|
|
68
112
|
|
|
113
|
+
// 5. Standard 5-field cron expression
|
|
69
114
|
try {
|
|
70
115
|
const testJob = new Cron(str);
|
|
71
116
|
const next = testJob.nextRun();
|
|
@@ -83,7 +128,8 @@ export class TaskScheduler {
|
|
|
83
128
|
constructor(store, executeFn) {
|
|
84
129
|
this.store = store;
|
|
85
130
|
this.executeFn = executeFn;
|
|
86
|
-
this.jobs = new Map();
|
|
131
|
+
this.jobs = new Map(); // taskId -> Cron instance
|
|
132
|
+
this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
|
|
87
133
|
this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
|
|
88
134
|
}
|
|
89
135
|
|
|
@@ -104,6 +150,13 @@ export class TaskScheduler {
|
|
|
104
150
|
}
|
|
105
151
|
this.jobs.clear();
|
|
106
152
|
|
|
153
|
+
for (const [id, timer] of this.timers.entries()) {
|
|
154
|
+
try {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
this.timers.clear();
|
|
159
|
+
|
|
107
160
|
for (const [, run] of this.running.entries()) {
|
|
108
161
|
try {
|
|
109
162
|
run.controller.abort(new Error('Планировщик остановлен'));
|
|
@@ -113,15 +166,40 @@ export class TaskScheduler {
|
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
scheduleTask(task) {
|
|
169
|
+
// Clear any existing job or timer for this task
|
|
116
170
|
if (this.jobs.has(task.id)) {
|
|
117
171
|
this.jobs.get(task.id).stop();
|
|
118
172
|
this.jobs.delete(task.id);
|
|
119
173
|
}
|
|
174
|
+
if (this.timers.has(task.id)) {
|
|
175
|
+
clearTimeout(this.timers.get(task.id));
|
|
176
|
+
this.timers.delete(task.id);
|
|
177
|
+
}
|
|
120
178
|
|
|
121
179
|
if (task.status !== 'active') return;
|
|
122
180
|
|
|
123
181
|
try {
|
|
124
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
|
|
125
203
|
const job = new Cron(parsed.cronPattern, { protect: true }, async () => {
|
|
126
204
|
await this.runTask(task.id);
|
|
127
205
|
});
|
|
@@ -271,11 +349,18 @@ export class TaskScheduler {
|
|
|
271
349
|
console.error('[dsh-cron] kanban error:', kanbanErr.message);
|
|
272
350
|
}
|
|
273
351
|
|
|
274
|
-
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
task.nextRunAt =
|
|
352
|
+
// 3. Reschedule or mark completed for one-shot
|
|
353
|
+
if (task.oneShot) {
|
|
354
|
+
task.status = 'completed';
|
|
355
|
+
task.nextRunAt = null;
|
|
278
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
|
+
}
|
|
279
364
|
}
|
|
280
365
|
|
|
281
366
|
if (queuedCount > 0) {
|
|
@@ -295,6 +380,10 @@ export class TaskScheduler {
|
|
|
295
380
|
this.jobs.get(taskId).stop();
|
|
296
381
|
this.jobs.delete(taskId);
|
|
297
382
|
}
|
|
383
|
+
if (this.timers.has(taskId)) {
|
|
384
|
+
clearTimeout(this.timers.get(taskId));
|
|
385
|
+
this.timers.delete(taskId);
|
|
386
|
+
}
|
|
298
387
|
const running = this.running.get(taskId);
|
|
299
388
|
if (running) {
|
|
300
389
|
try {
|
|
@@ -320,6 +409,10 @@ export class TaskScheduler {
|
|
|
320
409
|
this.jobs.get(taskId).stop();
|
|
321
410
|
this.jobs.delete(taskId);
|
|
322
411
|
}
|
|
412
|
+
if (this.timers.has(taskId)) {
|
|
413
|
+
clearTimeout(this.timers.get(taskId));
|
|
414
|
+
this.timers.delete(taskId);
|
|
415
|
+
}
|
|
323
416
|
const running = this.running.get(taskId);
|
|
324
417
|
if (running) {
|
|
325
418
|
try {
|
|
@@ -337,6 +430,9 @@ export class TaskScheduler {
|
|
|
337
430
|
getNextRun(schedule) {
|
|
338
431
|
try {
|
|
339
432
|
const parsed = parseScheduleExpression(schedule);
|
|
433
|
+
if (parsed.isOneShot) {
|
|
434
|
+
return parsed.targetTimestamp;
|
|
435
|
+
}
|
|
340
436
|
const tempJob = new Cron(parsed.cronPattern);
|
|
341
437
|
const next = tempJob.nextRun();
|
|
342
438
|
return next ? next.getTime() : null;
|
package/lib/store.js
CHANGED
|
@@ -132,6 +132,7 @@ export class TaskStore {
|
|
|
132
132
|
timeoutSeconds: task.timeoutSeconds !== undefined ? Number(task.timeoutSeconds) : (prev.timeoutSeconds || 1800),
|
|
133
133
|
overlapPolicy: task.overlapPolicy !== undefined ? String(task.overlapPolicy) : (prev.overlapPolicy || 'skip'),
|
|
134
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),
|
|
135
136
|
totalTokens: prev.totalTokens || 0,
|
|
136
137
|
totalCostUsd: prev.totalCostUsd || 0,
|
|
137
138
|
createdAt: prev.createdAt || task.createdAt || now,
|