@goodandready/dsh-cron 0.1.17 → 0.1.19
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 +9 -3
- package/lib/index.js +85 -38
- 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
|
)
|
|
@@ -1199,8 +1201,12 @@ const React = window.React || require('react');
|
|
|
1199
1201
|
container.setAttribute(VIEW_ATTR, '');
|
|
1200
1202
|
container.setAttribute('data-dsh-plugin', 'cron');
|
|
1201
1203
|
column.appendChild(container);
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
+
if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
|
|
1205
|
+
root = ReactDOM.createRoot(container);
|
|
1206
|
+
root.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false) }));
|
|
1207
|
+
} else if (ReactDOM && typeof ReactDOM.render === 'function') {
|
|
1208
|
+
ReactDOM.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false) }), container);
|
|
1209
|
+
}
|
|
1204
1210
|
};
|
|
1205
1211
|
|
|
1206
1212
|
const applyActive = () => {
|
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,30 +317,38 @@ 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
|
-
type: '
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
322
|
+
title: { type: 'string', description: 'Краткое название задачи', required: true },
|
|
323
|
+
schedule: { type: 'string', description: 'Расписание: cron ("0 9 * * *"), интервал ("every 2h") или разово ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
|
|
324
|
+
prompt: { type: 'string', description: 'Промпт/инструкция для агента при запуске', required: true },
|
|
325
|
+
type: { type: 'string', enum: ['prompt', 'script'], description: 'Тип задачи' },
|
|
326
|
+
delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Режим запуска: current (в текущем чате) или isolated (изолированная сессия)' },
|
|
327
|
+
provider: { type: 'string', description: 'Провайдер модели (опционально)' },
|
|
328
|
+
model: { type: 'string', description: 'Модель (опционально)' },
|
|
329
|
+
notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
|
|
330
|
+
onlyOnFailure: { type: 'boolean', description: 'Отправлять отчет только при сбоях' },
|
|
331
|
+
timeoutSeconds: { type: 'number', description: 'Лимит времени выполнения в секундах' },
|
|
332
|
+
overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Политика перекрытия' },
|
|
333
|
+
kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Создавать карточку в Kanban' },
|
|
334
|
+
},
|
|
335
|
+
output: {
|
|
336
|
+
schema: {
|
|
337
|
+
type: 'object',
|
|
338
|
+
additionalProperties: true,
|
|
339
|
+
properties: {
|
|
340
|
+
success: { type: 'boolean' },
|
|
341
|
+
message: { type: 'string' },
|
|
342
|
+
task: { type: 'object', additionalProperties: true }
|
|
343
|
+
}
|
|
335
344
|
},
|
|
336
|
-
|
|
345
|
+
render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
|
|
337
346
|
},
|
|
338
347
|
execute: async (args) => {
|
|
339
348
|
const parsed = parseScheduleExpression(args.schedule);
|
|
340
349
|
const task = store.set({
|
|
341
350
|
title: args.title,
|
|
342
|
-
schedule: parsed.cronPattern,
|
|
351
|
+
schedule: parsed.cronPattern || args.schedule,
|
|
343
352
|
scheduleText: parsed.humanText,
|
|
344
353
|
prompt: args.prompt,
|
|
345
354
|
type: args.type || 'prompt',
|
|
@@ -352,6 +361,7 @@ export function apply(ctx, config) {
|
|
|
352
361
|
timeoutSeconds: Number(args.timeoutSeconds) || 1800,
|
|
353
362
|
overlapPolicy: args.overlapPolicy || 'skip',
|
|
354
363
|
kanbanMode: args.kanbanMode || 'none',
|
|
364
|
+
oneShot: Boolean(parsed.isOneShot),
|
|
355
365
|
});
|
|
356
366
|
scheduler.scheduleTask(task);
|
|
357
367
|
return {
|
|
@@ -366,10 +376,18 @@ export function apply(ctx, config) {
|
|
|
366
376
|
name: 'cron_list_tasks',
|
|
367
377
|
description: 'Получить список всех запланированных задач cron',
|
|
368
378
|
parameters: {
|
|
369
|
-
type: '
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
379
|
+
status: { type: 'string', enum: ['all', 'active', 'paused', 'completed'], description: 'Фильтр по статусу' }
|
|
380
|
+
},
|
|
381
|
+
output: {
|
|
382
|
+
schema: {
|
|
383
|
+
type: 'object',
|
|
384
|
+
additionalProperties: true,
|
|
385
|
+
properties: {
|
|
386
|
+
count: { type: 'number' },
|
|
387
|
+
tasks: { type: 'array', items: { type: 'object', additionalProperties: true } }
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
render: (_args, val) => [{ type: 'text', text: `Всего задач: ${val.count}` }]
|
|
373
391
|
},
|
|
374
392
|
execute: async (args) => {
|
|
375
393
|
const tasks = store.list({ status: args.status || 'all' });
|
|
@@ -384,6 +402,7 @@ export function apply(ctx, config) {
|
|
|
384
402
|
lastStatus: t.lastStatus,
|
|
385
403
|
totalTokens: t.totalTokens || 0,
|
|
386
404
|
totalCostUsd: t.totalCostUsd || 0,
|
|
405
|
+
oneShot: Boolean(t.oneShot),
|
|
387
406
|
}))
|
|
388
407
|
};
|
|
389
408
|
}
|
|
@@ -393,11 +412,18 @@ export function apply(ctx, config) {
|
|
|
393
412
|
name: 'cron_pause_task',
|
|
394
413
|
description: 'Приостановить выполнение задачи по её ID',
|
|
395
414
|
parameters: {
|
|
396
|
-
type: '
|
|
397
|
-
|
|
398
|
-
|
|
415
|
+
id: { type: 'string', description: 'Идентификатор задачи', required: true }
|
|
416
|
+
},
|
|
417
|
+
output: {
|
|
418
|
+
schema: {
|
|
419
|
+
type: 'object',
|
|
420
|
+
additionalProperties: true,
|
|
421
|
+
properties: {
|
|
422
|
+
success: { type: 'boolean' },
|
|
423
|
+
message: { type: 'string' }
|
|
424
|
+
}
|
|
399
425
|
},
|
|
400
|
-
|
|
426
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
401
427
|
},
|
|
402
428
|
execute: async (args) => {
|
|
403
429
|
const task = scheduler.pauseTask(args.id);
|
|
@@ -410,11 +436,18 @@ export function apply(ctx, config) {
|
|
|
410
436
|
name: 'cron_resume_task',
|
|
411
437
|
description: 'Возобновить выполнение приостановленной задачи',
|
|
412
438
|
parameters: {
|
|
413
|
-
type: '
|
|
414
|
-
|
|
415
|
-
|
|
439
|
+
id: { type: 'string', description: 'Идентификатор задачи', required: true }
|
|
440
|
+
},
|
|
441
|
+
output: {
|
|
442
|
+
schema: {
|
|
443
|
+
type: 'object',
|
|
444
|
+
additionalProperties: true,
|
|
445
|
+
properties: {
|
|
446
|
+
success: { type: 'boolean' },
|
|
447
|
+
message: { type: 'string' }
|
|
448
|
+
}
|
|
416
449
|
},
|
|
417
|
-
|
|
450
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
418
451
|
},
|
|
419
452
|
execute: async (args) => {
|
|
420
453
|
const task = scheduler.resumeTask(args.id);
|
|
@@ -427,11 +460,18 @@ export function apply(ctx, config) {
|
|
|
427
460
|
name: 'cron_delete_task',
|
|
428
461
|
description: 'Удалить задачу по её ID',
|
|
429
462
|
parameters: {
|
|
430
|
-
type: '
|
|
431
|
-
|
|
432
|
-
|
|
463
|
+
id: { type: 'string', description: 'Идентификатор задачи', required: true }
|
|
464
|
+
},
|
|
465
|
+
output: {
|
|
466
|
+
schema: {
|
|
467
|
+
type: 'object',
|
|
468
|
+
additionalProperties: true,
|
|
469
|
+
properties: {
|
|
470
|
+
success: { type: 'boolean' },
|
|
471
|
+
message: { type: 'string' }
|
|
472
|
+
}
|
|
433
473
|
},
|
|
434
|
-
|
|
474
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
435
475
|
},
|
|
436
476
|
execute: async (args) => {
|
|
437
477
|
scheduler.pauseTask(args.id);
|
|
@@ -444,11 +484,18 @@ export function apply(ctx, config) {
|
|
|
444
484
|
name: 'cron_run_task',
|
|
445
485
|
description: 'Запустить задачу немедленно вне очереди',
|
|
446
486
|
parameters: {
|
|
447
|
-
type: '
|
|
448
|
-
|
|
449
|
-
|
|
487
|
+
id: { type: 'string', description: 'Идентификатор задачи', required: true }
|
|
488
|
+
},
|
|
489
|
+
output: {
|
|
490
|
+
schema: {
|
|
491
|
+
type: 'object',
|
|
492
|
+
additionalProperties: true,
|
|
493
|
+
properties: {
|
|
494
|
+
success: { type: 'boolean' },
|
|
495
|
+
message: { type: 'string' }
|
|
496
|
+
}
|
|
450
497
|
},
|
|
451
|
-
|
|
498
|
+
render: (_args, val) => [{ type: 'text', text: val.message }]
|
|
452
499
|
},
|
|
453
500
|
execute: async (args) => {
|
|
454
501
|
const task = store.get(args.id);
|
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,
|