@goodandready/dsh-cron 0.2.38 → 0.2.40
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 +11 -3
- package/README.ru.md +11 -2
- package/README.zh.md +11 -2
- package/lib/api-import-export.js +12 -3
- package/lib/api.js +11 -2
- package/lib/client.js +57 -17
- package/lib/scheduler-execution.js +55 -11
- package/lib/scheduler-lifecycle.js +3 -0
- package/lib/scheduler.js +120 -14
- package/lib/settings.js +1 -1
- package/lib/store.js +92 -12
- package/lib/task-create.js +2 -0
- package/lib/task-patch.js +14 -4
- package/lib/task-transfer.js +23 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -488,7 +488,7 @@ dsh-cron:
|
|
|
488
488
|
onlyOnFailure: false # deliver reports only for failed runs
|
|
489
489
|
kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API base URL
|
|
490
490
|
defaultTimezone: "" # default IANA time zone for schedules (empty = server local)
|
|
491
|
-
maxConcurrent:
|
|
491
|
+
maxConcurrent: 2 # max parallel task runs (default 2, 0 = unlimited)
|
|
492
492
|
heartbeatUrl: "" # dead man's snitch URL pinged on the heartbeat interval
|
|
493
493
|
heartbeatIntervalSec: 0 # heartbeat ping interval in seconds (0 = off)
|
|
494
494
|
# --- delivery channels ---
|
|
@@ -513,7 +513,15 @@ dsh-cron:
|
|
|
513
513
|
apiToken: "" # bearer token for the external /dsh-cron/api/* surface (masked; empty = 503)
|
|
514
514
|
```
|
|
515
515
|
|
|
516
|
-
|
|
516
|
+
#### Timers, Intervals & Concurrency Groups Hardening (0.2.39)
|
|
517
|
+
|
|
518
|
+
- **32-Bit Timer Overflow Protection**: One-shot tasks scheduled >24.85 days in the future (exceeding Node's 32-bit signed integer `setTimeout` limit of 2,147,483,647 ms) are safely executed via bounded timer chunking, preventing immediate misfires.
|
|
519
|
+
- **Persistent Relative One-Shot Deadlines**: Relative one-shot tasks (`in 30m`, `in 2h`) maintain their absolute target timestamp across daemon restarts, store reloads, pause/resume, and metadata edits. Deadlines are only recalculated upon explicit schedule modifications.
|
|
520
|
+
- **Strict Interval Validation & Normalization**: Unsupported irregular intervals exceeding 59 minutes (e.g. `every 90m`) are validated and rejected upfront with HTTP 400 before persisting. Clean hourly multiples (e.g. `every 120m` -> `every 2h`, `every 24h` -> `every 1d`) are automatically converted to valid cron patterns.
|
|
521
|
+
- **Unified Safe Concurrency Defaults**: The Config schema and TaskScheduler now uniformly default `maxConcurrent` to safe cap `2` (0 explicitly denotes unlimited).
|
|
522
|
+
- **Concurrency Group Pool Isolation**: Tasks configured with `concurrencyGroup` have per-group execution limits enforced during admission and queue draining (default 1 concurrent run per named group). Tasks in separate groups run concurrently up to `maxConcurrent`, while queued tasks drain as soon as group capacity becomes available.
|
|
523
|
+
|
|
524
|
+
## Configuration Parameters
|
|
517
525
|
|
|
518
526
|
| Parameter | Type | Default | Description |
|
|
519
527
|
|:---|:---|:---|:---|
|
|
@@ -523,7 +531,7 @@ dsh-cron:
|
|
|
523
531
|
| `onlyOnFailure` | `boolean` | `false` | Global switch: deliver reports only for `error`/`timeout` runs |
|
|
524
532
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Base URL of the `dsh-kanban` HTTP API used for automatic card creation |
|
|
525
533
|
| `defaultTimezone` | `string` | `""` | Default IANA time zone for task schedules; empty = server local time |
|
|
526
|
-
| `maxConcurrent` | `number` | `
|
|
534
|
+
| `maxConcurrent` | `number` | `2` | Cap on parallel task runs; extra runs are recorded as `skipped` (default 2, 0 = unlimited) |
|
|
527
535
|
| `heartbeatUrl` | `string` | `""` | Dead man's snitch URL pinged every `heartbeatIntervalSec` while the scheduler is alive |
|
|
528
536
|
| `heartbeatIntervalSec` | `number` | `0` | Heartbeat ping interval in seconds (0 = disabled) |
|
|
529
537
|
| `botTokenRef` | `string` | `""` | Name of the DSH credential holding the Telegram bot token; resolved at send time (falls back to `botToken`, then the messenger-gateway settings, then the `CRON_TELEGRAM_BOT_TOKEN` environment variable) |
|
package/README.ru.md
CHANGED
|
@@ -353,7 +353,7 @@ dsh-cron:
|
|
|
353
353
|
onlyOnFailure: false # отправлять отчёты только при сбоях
|
|
354
354
|
kanbanBaseUrl: "http://127.0.0.1:3000" # базовый URL HTTP API dsh-kanban
|
|
355
355
|
defaultTimezone: "" # IANA-зона по умолчанию (пусто = серверное время)
|
|
356
|
-
maxConcurrent:
|
|
356
|
+
maxConcurrent: 2 # максимум параллельных запусков (по умолчанию 2, 0 = без лимита)
|
|
357
357
|
heartbeatUrl: "" # URL dead man's snitch, пингуется по интервалу
|
|
358
358
|
heartbeatIntervalSec: 0 # интервал heartbeat-пинга в секундах (0 = выключено)
|
|
359
359
|
# --- каналы доставки ---
|
|
@@ -513,7 +513,7 @@ bash deploy.sh verify [exact-version]
|
|
|
513
513
|
| `onlyOnFailure` | `boolean` | `false` | Глобальный режим «только при сбоях» (`error`/`timeout`) |
|
|
514
514
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Базовый URL HTTP API `dsh-kanban` для автоматических карточек |
|
|
515
515
|
| `defaultTimezone` | `string` | `""` | IANA-зона по умолчанию для расписаний; пусто = серверное время |
|
|
516
|
-
| `maxConcurrent` | `number` | `
|
|
516
|
+
| `maxConcurrent` | `number` | `2` | Лимит параллельных запусков; лишние помечаются `skipped` (по умолчанию 2, 0 = без лимита) |
|
|
517
517
|
| `heartbeatUrl` | `string` | `""` | URL dead man's snitch, пингуемый каждый `heartbeatIntervalSec`, пока жив планировщик |
|
|
518
518
|
| `heartbeatIntervalSec` | `number` | `0` | Интервал heartbeat-пинга в секундах (0 = выключено) |
|
|
519
519
|
| `botTokenRef` | `string` | `""` | Имя credential DSH с токеном Telegram-бота; резолвится при отправке (фолбэк: `botToken` → настройки messenger-gateway → переменная окружения `CRON_TELEGRAM_BOT_TOKEN`) |
|
|
@@ -591,3 +591,12 @@ node scripts/ci-preflight.mjs
|
|
|
591
591
|
## 📄 Лицензия
|
|
592
592
|
|
|
593
593
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
594
|
+
|
|
595
|
+
### Надёжность таймеров, интервалов и групп параллелизма (0.2.39)
|
|
596
|
+
|
|
597
|
+
- **Защита от 32-битного переполнения таймера**: Одноразовые задачи, запланированные более чем на 24.85 дня вперёд (превышающие лимит 32-битного целого `setTimeout` в 2 147 483 647 мс в Node.js), выполняются через порционный таймер и не запускаются раньше времени.
|
|
598
|
+
- **Сохранение первоначального дедлайна относительных задач**: Задачи вида `in 30m` или `через 2 часа` сохраняют первоначальный целевой таймстемп при перезапусках сервера, перезагрузке хранилища, паузе/возобновлении и изменении полей задачи (title, tags, channels). Новый дедлайн вычисляется только при явном изменении поля расписания.
|
|
599
|
+
- **Строгая валидация и нормализация интервалов**: Недопустимые минутные интервалы свыше 59 минут (например, `every 90m`) отклоняются на входе с кодом HTTP 400 без создания неработающей активной задачи. Кратные интервалы (например, `every 120m` -> `every 2h`, `every 24h` -> `every 1d`) корректно приводятся к валидным cron-паттернам.
|
|
600
|
+
- **Единый безопасный лимит maxConcurrent**: Схема Config и планировщик TaskScheduler теперь согласованно используют лимит по умолчанию `2` (0 остаётся явным выбором безлимитного режима).
|
|
601
|
+
- **Изоляция пулов через concurrencyGroup**: Поле `concurrencyGroup` теперь строго контролируется при допуске задач и опустошении очереди (по умолчанию 1 параллельный запуск на именованную группу). Задачи из разных групп исполняются параллельно в пределах общего `maxConcurrent`, а задачи с политикой `queue` стартуют сразу при освобождении слота своей группы.
|
|
602
|
+
|
package/README.zh.md
CHANGED
|
@@ -478,7 +478,7 @@ dsh-cron:
|
|
|
478
478
|
onlyOnFailure: false # 仅失败时投递报告
|
|
479
479
|
kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API 基础地址
|
|
480
480
|
defaultTimezone: "" # 默认 IANA 时区(空 = 服务器本地)
|
|
481
|
-
maxConcurrent:
|
|
481
|
+
maxConcurrent: 2 # 最大并行运行数(默认 2,0 = 不限)
|
|
482
482
|
heartbeatUrl: "" # 心跳上报 URL(dead man's snitch)
|
|
483
483
|
heartbeatIntervalSec: 0 # 心跳间隔秒数(0 = 关闭)
|
|
484
484
|
# --- 投递渠道 ---
|
|
@@ -513,7 +513,7 @@ dsh-cron:
|
|
|
513
513
|
| `onlyOnFailure` | `boolean` | `false` | 全局开关:仅对 `error`/`timeout` 运行投递报告 |
|
|
514
514
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | 用于自动卡片的 `dsh-kanban` HTTP API 基础地址 |
|
|
515
515
|
| `defaultTimezone` | `string` | `""` | 任务调度的默认 IANA 时区;空 = 服务器本地时间 |
|
|
516
|
-
| `maxConcurrent` | `number` | `
|
|
516
|
+
| `maxConcurrent` | `number` | `2` | 并行运行上限;超出的运行记录为 `skipped`(默认 2,0 = 不限) |
|
|
517
517
|
| `heartbeatUrl` | `string` | `""` | 心跳上报 URL,调度器存活期间按 `heartbeatIntervalSec` 间隔 GET |
|
|
518
518
|
| `heartbeatIntervalSec` | `number` | `0` | 心跳间隔秒数(0 = 关闭) |
|
|
519
519
|
| `botTokenRef` | `string` | `""` | 保存 Telegram bot token 的 DSH 凭据名称;发送时解析(回退顺序:`botToken` → messenger-gateway 设置 → 环境变量 `CRON_TELEGRAM_BOT_TOKEN`) |
|
|
@@ -591,3 +591,12 @@ node scripts/ci-preflight.mjs
|
|
|
591
591
|
## 📄 许可证
|
|
592
592
|
|
|
593
593
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
594
|
+
|
|
595
|
+
### 定时器、间隔与并发分组精度强化 (0.2.39)
|
|
596
|
+
|
|
597
|
+
- **32位定时器溢出防护**:计划在 24.85 天以后的单次任务(超出 Node.js `setTimeout` 32位有符号整数上限 2,147,483,647 毫秒)通过分段定时器安全挂起,杜绝立即误触发。
|
|
598
|
+
- **相对单次任务持久截止时间**:相对单次任务(如 `in 30m`、`in 2h`)在服务重启、存储重载、暂停/恢复以及修改元数据时完整保留初始目标绝对时间戳,仅在显式修改调度表达式时重新计算。
|
|
599
|
+
- **严格的时间间隔校验与转换**:对于无法用标准 Cron 分钟步进表示的超限分钟间隔(如 `every 90m`),在持久化前返回 HTTP 400 拦截;规整整倍数(如 `every 120m` -> `every 2h`、`every 24h` -> `every 1d`)自动转为合法 Cron 表达式。
|
|
600
|
+
- **统一安全并发默认值**:配置规范(Config)与调度器(TaskScheduler)全面对齐默认并发上限 `maxConcurrent = 2`(0 仅作为显式无限制选项)。
|
|
601
|
+
- **并发分组隔离调度**:任务的 `concurrencyGroup` 属性全面接入调度仲裁与队列排队(同名分组默认限流 1 个并发执行),不同分组可并发运行直至 `maxConcurrent`,队列任务随所属分组资源释放立即触发。
|
|
602
|
+
|
package/lib/api-import-export.js
CHANGED
|
@@ -15,9 +15,18 @@ import {
|
|
|
15
15
|
} from './http-utils.js';
|
|
16
16
|
import { MANAGED_BY_CONFIG } from './config-jobs.js';
|
|
17
17
|
|
|
18
|
-
export function handleTaskExport({ store, res }) {
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export function handleTaskExport({ store, req, res }) {
|
|
19
|
+
let sanitize = false;
|
|
20
|
+
if (req && req.url) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(req.url, 'http://127.0.0.1');
|
|
23
|
+
sanitize = parsed.searchParams.get('sanitize') === 'true' || parsed.searchParams.get('redact') === 'true';
|
|
24
|
+
} catch (urlErr) {
|
|
25
|
+
logger.debug('[dsh-cron] export url parse fallback:', urlErr.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const doc = buildTaskExport(store.list({ status: 'all' }), { sanitize });
|
|
29
|
+
sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length, sanitized: sanitize });
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
export async function handleTaskImport({ store, scheduler, req, res }) {
|
package/lib/api.js
CHANGED
|
@@ -101,7 +101,7 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
101
101
|
sendJson(res, 405, NOT_ALLOWED);
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
|
-
handleTaskExport({ store, res });
|
|
104
|
+
handleTaskExport({ store, req, res });
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
if (id === 'import') {
|
|
@@ -200,7 +200,13 @@ async function createOrUpdateTask({ store, scheduler, req, res, apiToken }) {
|
|
|
200
200
|
sendJson(res, 400, { ok: false, error: typeError });
|
|
201
201
|
return;
|
|
202
202
|
}
|
|
203
|
-
|
|
203
|
+
let parsed;
|
|
204
|
+
try {
|
|
205
|
+
parsed = parseScheduleExpression(body.schedule);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
sendJson(res, 400, { ok: false, error: err.message });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
204
210
|
// A client-supplied id must not shadow the collection routes under
|
|
205
211
|
// /dsh-cron/tasks, otherwise that task could never be fetched or deleted
|
|
206
212
|
// again (#42 review finding).
|
|
@@ -480,6 +486,9 @@ function mergeExecutionFields(current, body, parsed) {
|
|
|
480
486
|
skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
|
|
481
487
|
workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
|
|
482
488
|
oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
|
|
489
|
+
targetTimestamp: parsed && parsed.isOneShot
|
|
490
|
+
? (current && current.schedule === (parsed.cronPattern || body.schedule) ? (current.targetTimestamp || parsed.targetTimestamp) : parsed.targetTimestamp)
|
|
491
|
+
: (current ? current.targetTimestamp : undefined),
|
|
483
492
|
costLimitUsd: body.costLimitUsd !== undefined ? (body.costLimitUsd === null ? null : (Number(body.costLimitUsd) > 0 ? Number(body.costLimitUsd) : null)) : (current ? current.costLimitUsd : undefined),
|
|
484
493
|
dailyCostLimitUsd: body.dailyCostLimitUsd !== undefined ? (body.dailyCostLimitUsd === null ? null : (Number(body.dailyCostLimitUsd) > 0 ? Number(body.dailyCostLimitUsd) : null)) : (current ? current.dailyCostLimitUsd : undefined),
|
|
485
494
|
tokenLimit: body.tokenLimit !== undefined ? (body.tokenLimit === null ? null : (Number(body.tokenLimit) > 0 ? Number(body.tokenLimit) : null)) : (current ? current.tokenLimit : undefined),
|
package/lib/client.js
CHANGED
|
@@ -88,7 +88,8 @@ window.__ModuleLoader__.load({
|
|
|
88
88
|
'transfer.exported': 'Exported {count} task(s).',
|
|
89
89
|
'transfer.exportFailed': 'Export failed: {error}',
|
|
90
90
|
'transfer.importTitleModal': 'Import tasks',
|
|
91
|
-
'transfer.importHint': 'Exported task configuration only: no run history, no counters.
|
|
91
|
+
'transfer.importHint': 'Exported task configuration only: no run history, no counters. Channel configurations reference credentials by name; note that raw exports retain custom env variables and HTTP request headers.',
|
|
92
|
+
'transfer.exportPrompt': 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export',
|
|
92
93
|
'transfer.strategyAdd': 'Add as new tasks (new ids, existing ones stay untouched)',
|
|
93
94
|
'transfer.strategyReplace': 'Replace tasks with the same id',
|
|
94
95
|
'transfer.strategySkip': 'Skip tasks whose id already exists',
|
|
@@ -390,7 +391,8 @@ window.__ModuleLoader__.load({
|
|
|
390
391
|
'transfer.exported': '已成功导出 {count} 个任务。',
|
|
391
392
|
'transfer.exportFailed': '导出失败: {error}',
|
|
392
393
|
'transfer.importTitleModal': '导入任务',
|
|
393
|
-
'transfer.importHint': '
|
|
394
|
+
'transfer.importHint': '仅导出任务静态配置:不包含历史记录与计数器。渠道通过凭据名称引用;注意完整导出保留自定义环境变量和 HTTP 请求头。',
|
|
395
|
+
'transfer.exportPrompt': '请选择导出模式:\n确定 = 净化导出(移除自定义环境变量与请求头)\n取消 = 完整原始导出',
|
|
394
396
|
'transfer.strategyAdd': '作为新任务导入(分配新 ID)',
|
|
395
397
|
'transfer.strategyReplace': '覆盖同 ID 任务',
|
|
396
398
|
'transfer.strategySkip': '跳过已存在的同 ID 任务',
|
|
@@ -1383,12 +1385,16 @@ window.__ModuleLoader__.load({
|
|
|
1383
1385
|
setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (err) { /* node unmounted */ } }, 2500);
|
|
1384
1386
|
}, [tasks]);
|
|
1385
1387
|
|
|
1388
|
+
const fetchGenRef = React.useRef(0);
|
|
1389
|
+
|
|
1386
1390
|
const fetchTasks = async () => {
|
|
1391
|
+
const gen = ++fetchGenRef.current;
|
|
1387
1392
|
setLoading(true);
|
|
1388
1393
|
setFetchError(null);
|
|
1389
1394
|
try {
|
|
1390
1395
|
const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query));
|
|
1391
1396
|
const data = await res.json();
|
|
1397
|
+
if (gen !== fetchGenRef.current) return;
|
|
1392
1398
|
if (data && data.ok) {
|
|
1393
1399
|
setTasks(data.tasks || []);
|
|
1394
1400
|
setRecs(data.recommendations || []);
|
|
@@ -1397,10 +1403,13 @@ window.__ModuleLoader__.load({
|
|
|
1397
1403
|
setFetchError(data && data.error ? data.error : T_KEY(t, 'list.loadErrorPrefix'));
|
|
1398
1404
|
}
|
|
1399
1405
|
} catch (err) {
|
|
1406
|
+
if (gen !== fetchGenRef.current) return;
|
|
1400
1407
|
console.error('[dsh-cron] fetch tasks error:', err);
|
|
1401
1408
|
setFetchError(err.message || T_KEY(t, 'list.loadErrorPrefix'));
|
|
1402
1409
|
} finally {
|
|
1403
|
-
|
|
1410
|
+
if (gen === fetchGenRef.current) {
|
|
1411
|
+
setLoading(false);
|
|
1412
|
+
}
|
|
1404
1413
|
}
|
|
1405
1414
|
};
|
|
1406
1415
|
|
|
@@ -1488,22 +1497,50 @@ window.__ModuleLoader__.load({
|
|
|
1488
1497
|
}, [tab, query]);
|
|
1489
1498
|
React.useEffect(() => { loadModels(); }, []);
|
|
1490
1499
|
|
|
1500
|
+
const actionInProgressRef = React.useRef(new Set());
|
|
1501
|
+
|
|
1491
1502
|
const handleToggleTask = async (id) => {
|
|
1503
|
+
if (actionInProgressRef.current.has(id)) return;
|
|
1504
|
+
actionInProgressRef.current.add(id);
|
|
1492
1505
|
try {
|
|
1493
|
-
await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
|
|
1494
|
-
|
|
1506
|
+
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
|
|
1507
|
+
const data = await res.json().catch(() => ({}));
|
|
1508
|
+
if (!res.ok || (data && data.ok === false)) {
|
|
1509
|
+
const msg = (data && data.error) || `Toggle failed (HTTP ${res.status})`;
|
|
1510
|
+
alert(msg);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
await fetchTasks();
|
|
1495
1514
|
} catch (err) {
|
|
1496
|
-
console.
|
|
1515
|
+
console.error('[dsh-cron] toggle failed', err);
|
|
1516
|
+
alert('Toggle request failed: ' + err.message);
|
|
1517
|
+
} finally {
|
|
1518
|
+
actionInProgressRef.current.delete(id);
|
|
1497
1519
|
}
|
|
1498
1520
|
};
|
|
1499
1521
|
|
|
1500
1522
|
const handleRunNow = async (id) => {
|
|
1523
|
+
if (actionInProgressRef.current.has(id)) return;
|
|
1524
|
+
actionInProgressRef.current.add(id);
|
|
1525
|
+
const prevTask = tasks.find(t => t.id === id);
|
|
1526
|
+
const prevStatus = prevTask ? prevTask.lastStatus : null;
|
|
1527
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: 'running' } : t2));
|
|
1501
1528
|
try {
|
|
1502
|
-
|
|
1503
|
-
|
|
1529
|
+
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
|
|
1530
|
+
const data = await res.json().catch(() => ({}));
|
|
1531
|
+
if (!res.ok || (data && data.ok === false)) {
|
|
1532
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
|
1533
|
+
const msg = (data && data.error) || `Run failed (HTTP ${res.status})`;
|
|
1534
|
+
alert(msg);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1504
1537
|
setTimeout(() => fetchTasks(), 1500);
|
|
1505
1538
|
} catch (err) {
|
|
1506
1539
|
console.error('[dsh-cron] run now error:', err);
|
|
1540
|
+
setTasks(prev => prev.map(t2 => t2.id === id ? { ...t2, lastStatus: prevStatus } : t2));
|
|
1541
|
+
alert('Run request failed: ' + err.message);
|
|
1542
|
+
} finally {
|
|
1543
|
+
actionInProgressRef.current.delete(id);
|
|
1507
1544
|
}
|
|
1508
1545
|
};
|
|
1509
1546
|
|
|
@@ -1527,7 +1564,7 @@ window.__ModuleLoader__.load({
|
|
|
1527
1564
|
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/archive?limit=15&offset=' + offset);
|
|
1528
1565
|
const data = await res.json();
|
|
1529
1566
|
if (data.ok) {
|
|
1530
|
-
setArchiveRuns(data.runs || []);
|
|
1567
|
+
setArchiveRuns(prev => offset === 0 ? (data.runs || []) : [...prev, ...(data.runs || [])]);
|
|
1531
1568
|
setArchiveTotal(data.total || 0);
|
|
1532
1569
|
}
|
|
1533
1570
|
} catch (err) {
|
|
@@ -1592,11 +1629,14 @@ window.__ModuleLoader__.load({
|
|
|
1592
1629
|
return headers;
|
|
1593
1630
|
};
|
|
1594
1631
|
|
|
1595
|
-
/** #42: download the task configuration
|
|
1632
|
+
/** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
|
|
1596
1633
|
const handleExport = async () => {
|
|
1597
1634
|
setTransferBusy(true);
|
|
1598
1635
|
try {
|
|
1599
|
-
const
|
|
1636
|
+
const promptMsg = T_KEY(t, 'transfer.exportPrompt') || 'Choose export mode:\nOK = Sanitized (removes custom env vars & headers)\nCancel = Full raw export';
|
|
1637
|
+
const wantSanitized = confirm(promptMsg);
|
|
1638
|
+
const exportUrl = '/dsh-cron/tasks/export' + (wantSanitized ? '?sanitize=true' : '');
|
|
1639
|
+
const res = await fetch(exportUrl);
|
|
1600
1640
|
const data = await res.json();
|
|
1601
1641
|
if (!data.ok) {
|
|
1602
1642
|
alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
|
|
@@ -1607,7 +1647,7 @@ window.__ModuleLoader__.load({
|
|
|
1607
1647
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
1608
1648
|
const anchor = document.createElement('a');
|
|
1609
1649
|
anchor.href = url;
|
|
1610
|
-
anchor.download = `dsh-cron-tasks-${stamp}.json`;
|
|
1650
|
+
anchor.download = `dsh-cron-tasks-${stamp}${data.sanitized ? '-sanitized' : ''}.json`;
|
|
1611
1651
|
document.body.appendChild(anchor);
|
|
1612
1652
|
anchor.click();
|
|
1613
1653
|
document.body.removeChild(anchor);
|
|
@@ -3145,9 +3185,9 @@ window.__ModuleLoader__.load({
|
|
|
3145
3185
|
}, '✕')
|
|
3146
3186
|
),
|
|
3147
3187
|
React.createElement('div', { style: { marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '13px' } },
|
|
3148
|
-
React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.status') + ': '), React.createElement('span', { style: { color: dryRunResult.ok ? 'var(--dsh-cron-success)' : 'var(--dsh-cron-danger)' } }, dryRunResult.ok ? (dryRunResult.result && dryRunResult.result.status || 'success') : 'failed')),
|
|
3149
|
-
dryRunResult.result && dryRunResult.result.preflight && React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.preflight') + ': '), React.createElement('span', null, dryRunResult.result.preflight.passed ? '✓ Passed' : '✗ Failed (' + (dryRunResult.result.preflight.reason || '') + ')')),
|
|
3150
|
-
|
|
3188
|
+
React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.status') + ': '), React.createElement('span', { style: { color: dryRunResult.ok ? 'var(--dsh-cron-success)' : 'var(--dsh-cron-danger)' } }, dryRunResult.ok ? ((dryRunResult.result && dryRunResult.result.status) || dryRunResult.status || 'success') : 'failed')),
|
|
3189
|
+
((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight) && React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.preflight') + ': '), React.createElement('span', null, (((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight).passed) ? '✓ Passed' : '✗ Failed (' + ((((dryRunResult.result && dryRunResult.result.preflight) || dryRunResult.preflight).reason) || '') + ')')),
|
|
3190
|
+
React.createElement('div', null, React.createElement('strong', null, T_KEY(t, 'dryRun.duration') + ': '), React.createElement('span', null, (((dryRunResult.result && dryRunResult.result.durationMs) !== undefined ? dryRunResult.result.durationMs : dryRunResult.durationMs) || 0) + 'ms')),
|
|
3151
3191
|
React.createElement('div', { style: { marginTop: '6px' } }, React.createElement('strong', null, T_KEY(t, 'dryRun.output') + ':')),
|
|
3152
3192
|
React.createElement('pre', {
|
|
3153
3193
|
style: {
|
|
@@ -3162,7 +3202,7 @@ window.__ModuleLoader__.load({
|
|
|
3162
3202
|
wordBreak: 'break-word',
|
|
3163
3203
|
color: 'var(--dsw-alias-label-primary)'
|
|
3164
3204
|
}
|
|
3165
|
-
}, (dryRunResult.result && dryRunResult.result.output)
|
|
3205
|
+
}, ((dryRunResult.result && dryRunResult.result.output) !== undefined ? dryRunResult.result.output : (dryRunResult.output !== undefined ? dryRunResult.output : ((dryRunResult.result && dryRunResult.result.error) || dryRunResult.error || '(no output)'))))
|
|
3166
3206
|
),
|
|
3167
3207
|
React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '16px' } },
|
|
3168
3208
|
React.createElement('button', {
|
|
@@ -3229,7 +3269,7 @@ window.__ModuleLoader__.load({
|
|
|
3229
3269
|
type: 'button',
|
|
3230
3270
|
className: 'dsh-cron-btn-secondary',
|
|
3231
3271
|
onClick: () => {
|
|
3232
|
-
const nextOffset =
|
|
3272
|
+
const nextOffset = archiveRuns.length;
|
|
3233
3273
|
if (archiveTask) openArchiveModal(archiveTask, nextOffset);
|
|
3234
3274
|
}
|
|
3235
3275
|
}, T_KEY(t, 'archive.loadMore', { shown: archiveRuns.length, total: archiveTotal })) : React.createElement('div', null),
|
|
@@ -195,6 +195,28 @@ export function beginRun(scheduler, task, taskId, options = {}) {
|
|
|
195
195
|
return null;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
const group = task.concurrencyGroup && task.concurrencyGroup !== 'default' ? String(task.concurrencyGroup).trim() : null;
|
|
199
|
+
const groupLimit = scheduler.getGroupLimit ? scheduler.getGroupLimit(group) : (group ? 1 : 0);
|
|
200
|
+
if (group && groupLimit > 0 && !scheduler.running.has(taskId)) {
|
|
201
|
+
const runningInGroup = scheduler.getRunningGroupCount ? scheduler.getRunningGroupCount(group) : 0;
|
|
202
|
+
if (runningInGroup >= groupLimit) {
|
|
203
|
+
if (task.overlapPolicy === 'queue') {
|
|
204
|
+
logger.info(`[dsh-cron] Task "${task.title}" (${taskId}) queued by group "${group}" limit (${groupLimit})`);
|
|
205
|
+
scheduler.queue.push({
|
|
206
|
+
taskId,
|
|
207
|
+
options: { ...options },
|
|
208
|
+
priority: task.priority !== undefined ? task.priority : 5,
|
|
209
|
+
queuedAt: Date.now(),
|
|
210
|
+
});
|
|
211
|
+
scheduler.queue.sort((a, b) => (a.priority - b.priority) || (a.queuedAt - b.queuedAt));
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
logger.info(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency group "${group}" limit reached (${groupLimit})`);
|
|
215
|
+
scheduler.recordSkipped(taskId, `Skipped: concurrency group "${group}" limit reached (${groupLimit} parallel runs)`);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
const overlapPolicy = task.overlapPolicy || 'skip';
|
|
199
221
|
const active = scheduler.running.get(taskId);
|
|
200
222
|
if (active) {
|
|
@@ -225,6 +247,7 @@ export function beginRun(scheduler, task, taskId, options = {}) {
|
|
|
225
247
|
startedAt: Date.now(),
|
|
226
248
|
queuedRuns: pendingOverlap,
|
|
227
249
|
queueCount: pendingOverlap.length,
|
|
250
|
+
concurrencyGroup: group || 'default',
|
|
228
251
|
};
|
|
229
252
|
scheduler.running.set(taskId, currentRun);
|
|
230
253
|
return currentRun;
|
|
@@ -532,20 +555,41 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
532
555
|
const guard = await checkAndApplyBurnGuard(scheduler, taskId, outcome);
|
|
533
556
|
if (guard && guard.exceeded) return;
|
|
534
557
|
|
|
535
|
-
while (!scheduler.isStopped && scheduler.queue.length > 0 && scheduler.running.size < scheduler.maxConcurrent) {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
558
|
+
while (!scheduler.isStopped && scheduler.queue.length > 0 && (scheduler.maxConcurrent === 0 || scheduler.running.size < scheduler.maxConcurrent)) {
|
|
559
|
+
let admittedIndex = -1;
|
|
560
|
+
for (let i = 0; i < scheduler.queue.length; i++) {
|
|
561
|
+
const candidate = scheduler.queue[i];
|
|
562
|
+
const queuedTask = scheduler.store.get(candidate.taskId);
|
|
563
|
+
if (!queuedTask || queuedTask.status !== 'active') {
|
|
564
|
+
scheduler.queue.splice(i, 1);
|
|
565
|
+
i--;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const qGroup = queuedTask.concurrencyGroup && queuedTask.concurrencyGroup !== 'default'
|
|
569
|
+
? String(queuedTask.concurrencyGroup).trim()
|
|
570
|
+
: null;
|
|
571
|
+
const qLimit = scheduler.getGroupLimit ? scheduler.getGroupLimit(qGroup) : (qGroup ? 1 : 0);
|
|
572
|
+
if (qGroup && qLimit > 0) {
|
|
573
|
+
const inGroup = scheduler.getRunningGroupCount ? scheduler.getRunningGroupCount(qGroup) : 0;
|
|
574
|
+
if (inGroup >= qLimit) {
|
|
575
|
+
continue;
|
|
545
576
|
}
|
|
546
|
-
}
|
|
577
|
+
}
|
|
578
|
+
admittedIndex = i;
|
|
547
579
|
break;
|
|
548
580
|
}
|
|
581
|
+
|
|
582
|
+
if (admittedIndex === -1) break;
|
|
583
|
+
|
|
584
|
+
const nextItem = scheduler.queue.splice(admittedIndex, 1)[0];
|
|
585
|
+
setImmediate(() => {
|
|
586
|
+
if (!scheduler.isStopped) {
|
|
587
|
+
scheduler.runTask(nextItem.taskId, nextItem.options || {}).catch((runErr) => {
|
|
588
|
+
bestEffort('drain-queued-task', () => {}, scheduler.logger);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
break;
|
|
549
593
|
}
|
|
550
594
|
|
|
551
595
|
// 1. Structured LLM Actions (#137)
|
|
@@ -76,6 +76,9 @@ export function pauseTask(scheduler, taskId, reason = null) {
|
|
|
76
76
|
const task = scheduler.store.get(taskId);
|
|
77
77
|
if (!task) return null;
|
|
78
78
|
task.status = 'paused';
|
|
79
|
+
if (task.oneShot) {
|
|
80
|
+
task.targetTimestamp = task.targetTimestamp || task.nextRunAt;
|
|
81
|
+
}
|
|
79
82
|
task.nextRunAt = null;
|
|
80
83
|
if (reason) {
|
|
81
84
|
task.pausedReason = reason;
|
package/lib/scheduler.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { logger } from './logger.js';
|
|
2
2
|
import { bestEffort } from './best-effort.js';
|
|
3
3
|
import { Cron } from 'croner';
|
|
4
|
+
|
|
5
|
+
const MAX_TIMEOUT_MS = 2147483647; // 2^31 - 1 ms (~24.85 days)
|
|
4
6
|
import {
|
|
5
7
|
AGENT_TASK_TYPES,
|
|
6
8
|
addUsage,
|
|
@@ -128,14 +130,76 @@ function parseIntervalExpression(str) {
|
|
|
128
130
|
if (!match) return null;
|
|
129
131
|
const num = parseInt(match[1], 10);
|
|
130
132
|
const unit = (match[2] || 'm').toLowerCase();
|
|
133
|
+
let pattern = null;
|
|
134
|
+
let humanText = null;
|
|
135
|
+
|
|
131
136
|
if (unit.startsWith('m')) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
137
|
+
if (num < 1) {
|
|
138
|
+
throw new Error('Invalid interval: minute interval must be at least 1 minute');
|
|
139
|
+
}
|
|
140
|
+
if (num >= 60) {
|
|
141
|
+
if (num % 60 === 0) {
|
|
142
|
+
const hours = num / 60;
|
|
143
|
+
if (hours >= 24 && hours % 24 === 0) {
|
|
144
|
+
const days = hours / 24;
|
|
145
|
+
if (days > 31) {
|
|
146
|
+
throw new Error(`Invalid interval: minute interval "${num}m" (${days} days) exceeds 31 days`);
|
|
147
|
+
}
|
|
148
|
+
pattern = `0 0 */${days} * *`;
|
|
149
|
+
humanText = `Every ${days} days`;
|
|
150
|
+
} else if (hours > 23) {
|
|
151
|
+
throw new Error(`Invalid interval: minute interval "${num}m" (${hours} hours) cannot be expressed as a cron step`);
|
|
152
|
+
} else {
|
|
153
|
+
pattern = `0 */${hours} * * *`;
|
|
154
|
+
humanText = `Every ${hours} hours`;
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
throw new Error(`Invalid interval: minute interval "${num}m" exceeds 59 minutes and is not a multiple of 60. Use hours (e.g. every 2h) or a multiple of 60m`);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
pattern = `*/${num} * * * *`;
|
|
161
|
+
humanText = `Every ${num} minutes`;
|
|
162
|
+
}
|
|
163
|
+
} else if (unit.startsWith('h')) {
|
|
164
|
+
if (num < 1) {
|
|
165
|
+
throw new Error('Invalid interval: hour interval must be at least 1 hour');
|
|
166
|
+
}
|
|
167
|
+
if (num >= 24) {
|
|
168
|
+
if (num % 24 === 0) {
|
|
169
|
+
const days = num / 24;
|
|
170
|
+
if (days > 31) {
|
|
171
|
+
throw new Error(`Invalid interval: hour interval "${num}h" (${days} days) exceeds 31 days`);
|
|
172
|
+
}
|
|
173
|
+
pattern = `0 0 */${days} * *`;
|
|
174
|
+
humanText = `Every ${days} days`;
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error(`Invalid interval: hour interval "${num}h" exceeds 23 hours and is not a multiple of 24. Use days (e.g. every 2d) or a multiple of 24h`);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
pattern = `0 */${num} * * *`;
|
|
180
|
+
humanText = `Every ${num} hours`;
|
|
181
|
+
}
|
|
182
|
+
} else if (unit.startsWith('d')) {
|
|
183
|
+
if (num < 1 || num > 31) {
|
|
184
|
+
throw new Error(`Invalid interval: day interval "${num}d" must be between 1 and 31 days`);
|
|
185
|
+
}
|
|
186
|
+
pattern = `0 0 */${num} * *`;
|
|
187
|
+
humanText = `Every ${num} days`;
|
|
136
188
|
}
|
|
137
|
-
|
|
138
|
-
|
|
189
|
+
|
|
190
|
+
if (pattern) {
|
|
191
|
+
try {
|
|
192
|
+
const testJob = new Cron(pattern, { sloppyRanges: true });
|
|
193
|
+
const next = testJob.nextRun();
|
|
194
|
+
return {
|
|
195
|
+
cronPattern: pattern,
|
|
196
|
+
humanText,
|
|
197
|
+
isInterval: true,
|
|
198
|
+
nextRun: next ? next.getTime() : null,
|
|
199
|
+
};
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new Error(`Invalid interval pattern "${pattern}": ${err.message}`);
|
|
202
|
+
}
|
|
139
203
|
}
|
|
140
204
|
return null;
|
|
141
205
|
}
|
|
@@ -252,6 +316,7 @@ export class TaskScheduler {
|
|
|
252
316
|
this.running = new Map();
|
|
253
317
|
this.runCounters = {};
|
|
254
318
|
this.queue = [];
|
|
319
|
+
this.groupLimits = opts.groupLimits instanceof Map ? opts.groupLimits : new Map(Object.entries(opts.groupLimits || {}));
|
|
255
320
|
this.heartbeatTimer = null;
|
|
256
321
|
this.logger = opts.logger || null;
|
|
257
322
|
this.isStopped = false;
|
|
@@ -377,17 +442,34 @@ export class TaskScheduler {
|
|
|
377
442
|
|
|
378
443
|
scheduleOneShot(task, parsed) {
|
|
379
444
|
task.oneShot = true;
|
|
445
|
+
task.targetTimestamp = parsed.targetTimestamp;
|
|
380
446
|
task.nextRunAt = parsed.targetTimestamp;
|
|
381
447
|
this.store.set(task);
|
|
382
448
|
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
449
|
+
const armTimer = () => {
|
|
450
|
+
const remaining = parsed.targetTimestamp - Date.now();
|
|
451
|
+
if (remaining <= 0) {
|
|
452
|
+
this.timers.delete(task.id);
|
|
453
|
+
this.runTask(task.id).catch((err) => {
|
|
454
|
+
logger.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const delay = Math.min(remaining, MAX_TIMEOUT_MS);
|
|
459
|
+
const timer = setTimeout(() => {
|
|
460
|
+
if (Date.now() >= parsed.targetTimestamp) {
|
|
461
|
+
this.timers.delete(task.id);
|
|
462
|
+
this.runTask(task.id).catch((err) => {
|
|
463
|
+
logger.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
|
|
464
|
+
});
|
|
465
|
+
} else {
|
|
466
|
+
armTimer();
|
|
467
|
+
}
|
|
468
|
+
}, delay);
|
|
469
|
+
this.timers.set(task.id, timer);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
armTimer();
|
|
391
473
|
}
|
|
392
474
|
|
|
393
475
|
scheduleCron(task, parsed) {
|
|
@@ -416,6 +498,11 @@ export class TaskScheduler {
|
|
|
416
498
|
try {
|
|
417
499
|
const parsed = parseScheduleExpression(task.schedule);
|
|
418
500
|
if (parsed.isOneShot) {
|
|
501
|
+
const existingTarget = task.targetTimestamp || task.nextRunAt;
|
|
502
|
+
if (existingTarget && typeof existingTarget === 'number' && !isNaN(existingTarget)) {
|
|
503
|
+
parsed.targetTimestamp = existingTarget;
|
|
504
|
+
parsed.nextRun = existingTarget;
|
|
505
|
+
}
|
|
419
506
|
this.scheduleOneShot(task, parsed);
|
|
420
507
|
return;
|
|
421
508
|
}
|
|
@@ -425,6 +512,25 @@ export class TaskScheduler {
|
|
|
425
512
|
}
|
|
426
513
|
}
|
|
427
514
|
|
|
515
|
+
getGroupLimit(group) {
|
|
516
|
+
if (!group || group === 'default') return 0;
|
|
517
|
+
if (this.groupLimits && this.groupLimits.has(group)) {
|
|
518
|
+
return Number(this.groupLimits.get(group)) || 0;
|
|
519
|
+
}
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
getRunningGroupCount(group) {
|
|
524
|
+
if (!group || group === 'default') return 0;
|
|
525
|
+
let count = 0;
|
|
526
|
+
for (const [, run] of this.running.entries()) {
|
|
527
|
+
if (run.concurrencyGroup === group) {
|
|
528
|
+
count++;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return count;
|
|
532
|
+
}
|
|
533
|
+
|
|
428
534
|
beginRun(task, taskId, options = {}) {
|
|
429
535
|
return beginRun(this, task, taskId, options);
|
|
430
536
|
}
|
package/lib/settings.js
CHANGED
|
@@ -21,7 +21,7 @@ export const Config = z.object({
|
|
|
21
21
|
onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs').volatile(),
|
|
22
22
|
kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API').volatile(),
|
|
23
23
|
defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)').volatile(),
|
|
24
|
-
maxConcurrent: z.number().default(
|
|
24
|
+
maxConcurrent: z.number().default(2).description('Max parallel task runs (0 = unlimited, default 2)').volatile(),
|
|
25
25
|
heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval').volatile(),
|
|
26
26
|
heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)').volatile(),
|
|
27
27
|
// --- Delivery channels (#20-#23, #26, #28, #47) ---
|
package/lib/store.js
CHANGED
|
@@ -38,14 +38,17 @@ export class TaskStore {
|
|
|
38
38
|
this._lastBackupTime = 0;
|
|
39
39
|
this._saveTimer = null;
|
|
40
40
|
this._savePromise = Promise.resolve();
|
|
41
|
+
this._saveVersion = 0;
|
|
42
|
+
this._lastWrittenVersion = 0;
|
|
41
43
|
this.init();
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
init() {
|
|
45
47
|
const dir = path.dirname(this.filePath);
|
|
46
48
|
if (!fs.existsSync(dir)) {
|
|
47
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
50
|
}
|
|
51
|
+
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
49
52
|
const bakPath = `${this.filePath}.bak`;
|
|
50
53
|
|
|
51
54
|
const loadData = (raw) => {
|
|
@@ -84,22 +87,30 @@ export class TaskStore {
|
|
|
84
87
|
|
|
85
88
|
if (fs.existsSync(this.filePath)) {
|
|
86
89
|
try {
|
|
90
|
+
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
87
91
|
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
88
92
|
loadData(raw);
|
|
89
93
|
if (!fs.existsSync(bakPath)) {
|
|
90
|
-
bestEffort('backup-initial', () =>
|
|
94
|
+
bestEffort('backup-initial', () => {
|
|
95
|
+
fs.copyFileSync(this.filePath, bakPath);
|
|
96
|
+
fs.chmodSync(bakPath, 0o600);
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
91
100
|
}
|
|
92
101
|
} catch (err) {
|
|
93
102
|
logger.error('[dsh-cron] store read error (corrupted file):', err.message);
|
|
94
103
|
try {
|
|
95
104
|
const corruptedPath = `${this.filePath}.corrupted.${Date.now()}`;
|
|
96
105
|
fs.copyFileSync(this.filePath, corruptedPath);
|
|
106
|
+
bestEffort('chmod-corrupted', () => fs.chmodSync(corruptedPath, 0o600));
|
|
97
107
|
logger.warn(`[dsh-cron] corrupted store backed up to ${corruptedPath}`);
|
|
98
108
|
} catch (snapErr) {
|
|
99
109
|
logger.error('[dsh-cron] failed to snapshot corrupted store:', snapErr.message);
|
|
100
110
|
}
|
|
101
111
|
if (fs.existsSync(bakPath)) {
|
|
102
112
|
try {
|
|
113
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
103
114
|
const rawBak = fs.readFileSync(bakPath, 'utf-8');
|
|
104
115
|
loadData(rawBak);
|
|
105
116
|
logger.warn(`[dsh-cron] successfully recovered tasks store from ${bakPath}`);
|
|
@@ -110,9 +121,13 @@ export class TaskStore {
|
|
|
110
121
|
}
|
|
111
122
|
} else if (fs.existsSync(bakPath)) {
|
|
112
123
|
try {
|
|
124
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
113
125
|
const rawBak = fs.readFileSync(bakPath, 'utf-8');
|
|
114
126
|
loadData(rawBak);
|
|
115
|
-
bestEffort('restore-from-bak', () =>
|
|
127
|
+
bestEffort('restore-from-bak', () => {
|
|
128
|
+
fs.copyFileSync(bakPath, this.filePath);
|
|
129
|
+
fs.chmodSync(this.filePath, 0o600);
|
|
130
|
+
});
|
|
116
131
|
logger.warn(`[dsh-cron] restored missing tasks store from ${bakPath}`);
|
|
117
132
|
} catch (bakErr) {
|
|
118
133
|
logger.error('[dsh-cron] failed to restore tasks store from .bak:', bakErr.message);
|
|
@@ -121,17 +136,16 @@ export class TaskStore {
|
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
/**
|
|
124
|
-
* Schedule a debounced save
|
|
139
|
+
* Schedule a debounced save using the asynchronous write queue to avoid
|
|
140
|
+
* blocking the event loop on large task stores (#244).
|
|
125
141
|
*/
|
|
126
142
|
scheduleSave(delay = 50) {
|
|
127
143
|
if (this._saveTimer) return;
|
|
128
144
|
this._saveTimer = setTimeout(() => {
|
|
129
145
|
this._saveTimer = null;
|
|
130
|
-
|
|
131
|
-
this.save();
|
|
132
|
-
} catch (err) {
|
|
146
|
+
this.saveAsync().catch((err) => {
|
|
133
147
|
logger.error('[dsh-cron] debounced save failed:', err.message);
|
|
134
|
-
}
|
|
148
|
+
});
|
|
135
149
|
}, delay);
|
|
136
150
|
if (typeof this._saveTimer.unref === 'function') {
|
|
137
151
|
this._saveTimer.unref();
|
|
@@ -149,16 +163,72 @@ export class TaskStore {
|
|
|
149
163
|
this.save();
|
|
150
164
|
}
|
|
151
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Asynchronously serialize and write tasks store to disk using fs.promises queue (#244).
|
|
168
|
+
* Safe mode 0600 on files and 0700 on dir (#222).
|
|
169
|
+
*/
|
|
170
|
+
async saveAsync() {
|
|
171
|
+
const version = ++this._saveVersion;
|
|
172
|
+
const data = {
|
|
173
|
+
version: 1,
|
|
174
|
+
updatedAt: new Date().toISOString(),
|
|
175
|
+
tasks: Array.from(this.tasks.values()),
|
|
176
|
+
history: Object.fromEntries(this.history.entries()),
|
|
177
|
+
settings: this.settings || {},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const writeOp = async () => {
|
|
181
|
+
if (this._lastWrittenVersion >= version) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const dir = path.dirname(this.filePath);
|
|
185
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
186
|
+
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
187
|
+
|
|
188
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
189
|
+
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
190
|
+
const payload = JSON.stringify(data, null, 2);
|
|
191
|
+
await fs.promises.writeFile(tmp, payload, { encoding: 'utf-8', mode: 0o600 });
|
|
192
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
193
|
+
|
|
194
|
+
if (this._lastWrittenVersion >= version) {
|
|
195
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await fs.promises.rename(tmp, this.filePath);
|
|
200
|
+
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
201
|
+
this._lastWrittenVersion = version;
|
|
202
|
+
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
if (now - this._lastBackupTime > 60000) {
|
|
205
|
+
this._lastBackupTime = now;
|
|
206
|
+
try {
|
|
207
|
+
const bakPath = `${this.filePath}.bak`;
|
|
208
|
+
await fs.promises.copyFile(this.filePath, bakPath);
|
|
209
|
+
bestEffort('chmod-bak', () => fs.chmodSync(bakPath, 0o600));
|
|
210
|
+
} catch (bakErr) {
|
|
211
|
+
logger.warn('[dsh-cron] async backup copy failed:', bakErr.message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
this._savePromise = this._savePromise.then(writeOp, writeOp);
|
|
217
|
+
return this._savePromise;
|
|
218
|
+
}
|
|
219
|
+
|
|
152
220
|
save() {
|
|
153
221
|
if (this._saveTimer) {
|
|
154
222
|
clearTimeout(this._saveTimer);
|
|
155
223
|
this._saveTimer = null;
|
|
156
224
|
}
|
|
225
|
+
const version = ++this._saveVersion;
|
|
157
226
|
try {
|
|
158
227
|
const dir = path.dirname(this.filePath);
|
|
159
228
|
if (!fs.existsSync(dir)) {
|
|
160
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
229
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
161
230
|
}
|
|
231
|
+
bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
|
|
162
232
|
const data = {
|
|
163
233
|
version: 1,
|
|
164
234
|
updatedAt: new Date().toISOString(),
|
|
@@ -169,13 +239,21 @@ export class TaskStore {
|
|
|
169
239
|
// Unique tmp file per save call with process PID and random token to eliminate file collision
|
|
170
240
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
171
241
|
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
172
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
242
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
243
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
173
244
|
fs.renameSync(tmp, this.filePath);
|
|
245
|
+
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
246
|
+
this._lastWrittenVersion = version;
|
|
247
|
+
|
|
174
248
|
// Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
|
|
175
249
|
const now = Date.now();
|
|
176
250
|
if (now - this._lastBackupTime > 60000) {
|
|
177
251
|
this._lastBackupTime = now;
|
|
178
|
-
bestEffort('backup-on-save', () =>
|
|
252
|
+
bestEffort('backup-on-save', () => {
|
|
253
|
+
const bakPath = `${this.filePath}.bak`;
|
|
254
|
+
fs.copyFileSync(this.filePath, bakPath);
|
|
255
|
+
fs.chmodSync(bakPath, 0o600);
|
|
256
|
+
});
|
|
179
257
|
}
|
|
180
258
|
} catch (err) {
|
|
181
259
|
logger.error('[dsh-cron] store save error:', err.message);
|
|
@@ -505,8 +583,10 @@ export class TaskStore {
|
|
|
505
583
|
// Write atomically with tmp file using compact JSON (avoiding mega-lines and null, 2 serialization bloat)
|
|
506
584
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
507
585
|
const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
508
|
-
fs.writeFileSync(tmp, JSON.stringify(archive), 'utf-8');
|
|
586
|
+
fs.writeFileSync(tmp, JSON.stringify(archive), { encoding: 'utf-8', mode: 0o600 });
|
|
587
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
509
588
|
fs.renameSync(tmp, archivePath);
|
|
589
|
+
bestEffort('chmod-archive', () => fs.chmodSync(archivePath, 0o600));
|
|
510
590
|
} catch (archiveErr) {
|
|
511
591
|
logger.error('[dsh-cron] history archive error:', archiveErr.message);
|
|
512
592
|
}
|
package/lib/task-create.js
CHANGED
|
@@ -66,6 +66,8 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
66
66
|
skillName: args.skillName ? String(args.skillName).trim() : '',
|
|
67
67
|
workflowName: args.workflowName ? String(args.workflowName).trim() : '',
|
|
68
68
|
oneShot: Boolean(parsed.isOneShot),
|
|
69
|
+
targetTimestamp: parsed.isOneShot ? parsed.targetTimestamp : undefined,
|
|
70
|
+
nextRunAt: parsed.isOneShot ? parsed.targetTimestamp : (parsed.nextRun || undefined),
|
|
69
71
|
costLimitUsd: Number(args.costLimitUsd) > 0 ? Number(args.costLimitUsd) : undefined,
|
|
70
72
|
dailyCostLimitUsd: Number(args.dailyCostLimitUsd) > 0 ? Number(args.dailyCostLimitUsd) : undefined,
|
|
71
73
|
tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
|
package/lib/task-patch.js
CHANGED
|
@@ -67,10 +67,20 @@ export function buildTaskPatch(current, body) {
|
|
|
67
67
|
if (patch.schedule !== undefined) {
|
|
68
68
|
// An empty schedule would leave an armed task that can never fire.
|
|
69
69
|
if (!String(patch.schedule).trim()) return { ok: false, error: 'Schedule cannot be empty' };
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
try {
|
|
71
|
+
const parsed = parseScheduleExpression(patch.schedule);
|
|
72
|
+
const newSchedule = parsed.cronPattern || patch.schedule;
|
|
73
|
+
const scheduleChanged = newSchedule !== current.schedule;
|
|
74
|
+
patch.schedule = newSchedule;
|
|
75
|
+
patch.scheduleText = (body && body.scheduleText) || parsed.humanText;
|
|
76
|
+
patch.oneShot = Boolean(parsed.isOneShot || (body && body.oneShot));
|
|
77
|
+
if (scheduleChanged) {
|
|
78
|
+
patch.nextRunAt = parsed.isOneShot ? parsed.targetTimestamp : (parsed.nextRun || null);
|
|
79
|
+
patch.targetTimestamp = parsed.isOneShot ? parsed.targetTimestamp : undefined;
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { ok: false, error: err.message };
|
|
83
|
+
}
|
|
74
84
|
}
|
|
75
85
|
if (patch.costLimitUsd !== undefined) {
|
|
76
86
|
patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
|
package/lib/task-transfer.js
CHANGED
|
@@ -162,16 +162,18 @@ export const IMPORT_STRATEGIES = ['add', 'replace', 'skip'];
|
|
|
162
162
|
* `env` and headers can contain hand-typed secrets, so the caller must not
|
|
163
163
|
* claim the file is secret-free.
|
|
164
164
|
*/
|
|
165
|
-
export function buildTaskExport(tasks, { now = Date.now() } = {}) {
|
|
165
|
+
export function buildTaskExport(tasks, { now = Date.now(), sanitize = false } = {}) {
|
|
166
166
|
const list = Array.isArray(tasks) ? tasks : [];
|
|
167
167
|
return {
|
|
168
168
|
kind: TASK_EXPORT_KIND,
|
|
169
169
|
version: TASK_EXPORT_VERSION,
|
|
170
170
|
exportedAt: new Date(now).toISOString(),
|
|
171
|
+
sanitized: Boolean(sanitize),
|
|
171
172
|
tasks: list.map((task) => {
|
|
172
173
|
const out = {};
|
|
173
174
|
for (const key of EXPORT_TASK_FIELDS) {
|
|
174
175
|
if (task[key] === undefined || key === 'status') continue;
|
|
176
|
+
if (sanitize && EXPORT_SECRET_BEARING_FIELDS.includes(key)) continue;
|
|
175
177
|
out[key] = Array.isArray(task[key]) ? [...task[key]] : task[key];
|
|
176
178
|
}
|
|
177
179
|
return out;
|
|
@@ -215,6 +217,26 @@ export function validateImportDocument(doc) {
|
|
|
215
217
|
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
216
218
|
if (raw[key] !== undefined) clean[key] = Array.isArray(raw[key]) ? [...raw[key]] : raw[key];
|
|
217
219
|
}
|
|
220
|
+
if (clean.env && typeof clean.env === 'object') {
|
|
221
|
+
const filteredEnv = {};
|
|
222
|
+
for (const [k, v] of Object.entries(clean.env)) {
|
|
223
|
+
if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
|
|
224
|
+
continue; // Masked secret placeholder from UI/doc must never become literal secret (#240)
|
|
225
|
+
}
|
|
226
|
+
filteredEnv[k] = v;
|
|
227
|
+
}
|
|
228
|
+
clean.env = filteredEnv;
|
|
229
|
+
}
|
|
230
|
+
if (clean.httpHeaders && typeof clean.httpHeaders === 'object') {
|
|
231
|
+
const filteredHeaders = {};
|
|
232
|
+
for (const [k, v] of Object.entries(clean.httpHeaders)) {
|
|
233
|
+
if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
filteredHeaders[k] = v;
|
|
237
|
+
}
|
|
238
|
+
clean.httpHeaders = filteredHeaders;
|
|
239
|
+
}
|
|
218
240
|
if (id) clean.id = id;
|
|
219
241
|
if (channels) clean.channels = channels;
|
|
220
242
|
tasks.push(clean);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.40",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|