@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 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-выражение (например, "0 9 * * *") или интервал ("every 2h", "каждый день в 15:00")' },
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
- const job = this.jobs.get(taskId);
275
- if (job) {
276
- const next = job.nextRun();
277
- task.nextRunAt = next ? next.getTime() : null;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",