@goodandready/dsh-cron 0.3.9 → 0.3.11
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 +24 -1
- package/README.ru.md +18 -0
- package/README.zh.md +2 -0
- package/lib/api-task-webhook.js +247 -0
- package/lib/api.js +21 -0
- package/lib/cron-tool.js +1 -1
- package/lib/http-utils.js +12 -0
- package/lib/nl-schedule.js +338 -0
- package/lib/prompt-interpolation.js +76 -4
- package/lib/routes.js +8 -0
- package/lib/scheduler.js +21 -8
- package/lib/store.js +6 -0
- package/lib/task-create.js +4 -0
- package/lib/task-patch.js +12 -0
- package/lib/task-transfer.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -588,6 +588,8 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Endpoints are
|
|
|
588
588
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | External token-guarded surface: list / create-or-update (#54) |
|
|
589
589
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | External token-guarded surface: read / delete (#54) |
|
|
590
590
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | External token-guarded surface: force a run (#54) |
|
|
591
|
+
| `POST` | `/dsh-cron/webhook/:id` | Incoming webhook trigger: trigger task with HMAC SHA-256 / Bearer token authentication, debouncing and JSON payload prompt interpolation (#348) |
|
|
592
|
+
| `POST` | `/dsh-cron/api/webhook/:id` | External token-guarded incoming webhook trigger (#348) |
|
|
591
593
|
|
|
592
594
|
---
|
|
593
595
|
|
|
@@ -609,7 +611,7 @@ Save LLM tokens and API quotas on watcher tasks by dynamically slowing down exec
|
|
|
609
611
|
|
|
610
612
|
### 🔄 Session Resumption & GUI Session Naming (#346, GH #9, #354, GH #10, Added in v0.3.8, Patched in v0.3.9)
|
|
611
613
|
|
|
612
|
-
- **HTTP API `sessionTitle` Persistence (v0.3.
|
|
614
|
+
- **HTTP API `sessionTitle` Persistence (v0.3.10, #354, GH #10, PR #11)**: Tasks created or updated via HTTP endpoints (`POST /dsh-cron/tasks` and `POST /dsh-cron/api/tasks`) now reliably forward and persist `sessionTitle` into the task store and runner, mirroring the agent-tool (`cron_create_task`) and PATCH pathways.
|
|
613
615
|
|
|
614
616
|
- **Universal Host Resumption (`resumeSessionId`)**: DeepSeek Harness host `0.2.0-rc.2+` specifies `readonly resumeSessionId: SessionId` on `agents.resume()`. The runner now passes both `resumeSessionId` and `sessionId`, guaranteeing seamless session resumption across all DSH core versions without falling back to recreating sessions that already exist.
|
|
615
617
|
- **Enhanced Diagnostics on Failure**: When an existing session cannot be resumed, `dsh-cron` logs an actionable warning and, if recreating the session fails, surfaces the underlying resume error in the task run output.
|
|
@@ -619,6 +621,27 @@ Save LLM tokens and API quotas on watcher tasks by dynamically slowing down exec
|
|
|
619
621
|
|
|
620
622
|
Connect to `GET /dsh-cron/tasks/:id/stream` using Server-Sent Events (SSE) to receive live stdout/stderr chunks and execution completion events with an automatic 500-line ring buffer.
|
|
621
623
|
|
|
624
|
+
### 🪝 Incoming HTTP Webhook Triggers (#348)
|
|
625
|
+
|
|
626
|
+
Execute tasks instantaneously on external events via incoming HTTP POST requests with enterprise-grade security and dynamic template interpolation:
|
|
627
|
+
|
|
628
|
+
- **Authentication & Verification**: Supports HMAC SHA-256 signatures (`x-hub-signature-256` or `x-signature-sha256`) using task-level `webhookSecret`, bearer tokens (`x-dsh-cron-webhook-token`, `Authorization: Bearer <token>`, or `?token=<token>`) against task `webhookToken` or master `apiToken`. Fail-closed security rejects unconfigured webhooks.
|
|
629
|
+
- **Deep JSON Payload Interpolation**: Dynamic prompt variables resolve incoming payload fields using dot notation (`{webhook.body.repository.full_name}`, `{webhook.headers.x_github_event}`, `{webhook.query.ref}`, `{webhook.body}`).
|
|
630
|
+
- **Script Environment Injection**: Script and shell tasks automatically receive the raw request body and headers in `DSH_CRON_WEBHOOK_BODY` and `DSH_CRON_WEBHOOK_HEADERS`.
|
|
631
|
+
- **Anti-Spam Debouncing**: Configurable per-task `webhookDebounceMs` (default 500ms) guarantees protection against webhook storms and burst duplicate executions.
|
|
632
|
+
|
|
633
|
+
### 🗣️ Multi-Lingual Natural Language Scheduling (#349)
|
|
634
|
+
|
|
635
|
+
Define schedules naturally in conversation, tool calls, and API inputs in English and Russian without wrestling with cron syntax:
|
|
636
|
+
|
|
637
|
+
- **Intervals**: `"every 15 minutes"`, `"каждые 30 минут"`, `"every 2 hours"`, `"каждые 4 часа"`.
|
|
638
|
+
- **Daily & Time-of-Day**: `"every day at 9:00"`, `"каждый день в 18:30"`, `"every morning at 8am"`, `"каждый вечер в 21:00"`, `"at midnight"`, `"в полночь"`, `"at noon"`, `"в полдень"`.
|
|
639
|
+
- **Weekdays & Weekends**: `"every weekday at 9:00"`, `"по будням в 10:00"`, `"on weekends at noon"`, `"по выходным в 12:00"`.
|
|
640
|
+
- **Days of the Week**: `"every Monday at 10am"`, `"каждую пятницу в 17:00"`, `"every Tuesday and Thursday at 14:00"`.
|
|
641
|
+
- **Relative One-Shots**: `"in 15 minutes"`, `"через 2 часа"`, `"in 3 days at 10:00"`, `"через 30 минут"`.
|
|
642
|
+
|
|
643
|
+
The dedicated parser (`lib/nl-schedule.js`) translates expressions into strict Croner expressions or deterministic one-shot execution timestamps seamlessly.
|
|
644
|
+
|
|
622
645
|
---
|
|
623
646
|
|
|
624
647
|
## 🧪 Testing & Preflight Verification
|
package/README.ru.md
CHANGED
|
@@ -569,6 +569,24 @@ bash deploy.sh verify [exact-version]
|
|
|
569
569
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | Внешняя поверхность под токеном: список / создание-обновление (#54) |
|
|
570
570
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | Внешняя поверхность под токеном: чтение / удаление (#54) |
|
|
571
571
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | Внешняя поверхность под токеном: принудительный запуск (#54) |
|
|
572
|
+
| `POST` | `/dsh-cron/webhook/:id` | Входящий HTTP Webhook: запуск задачи по событию с проверкой HMAC SHA-256 / токена, дебаунсом и подстановкой JSON payload в промпт (#348) |
|
|
573
|
+
| `POST` | `/dsh-cron/api/webhook/:id` | Внешний эндпоинт входящего вебхука под токеном (#348) |
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
577
|
+
### 32. Входящие HTTP Webhook триггеры и естественно-языковой парсер расписаний (v0.3.11, #348, #349)
|
|
578
|
+
|
|
579
|
+
- **Входящие Webhook триггеры (`/dsh-cron/webhook/:id`)**: Моментальный запуск задач по внешним событиям (GitHub, Gitea, CI/CD, сторонние сервисы).
|
|
580
|
+
- **Аутентификация и безопасность**: Поддержка HMAC SHA-256 подписи (`x-hub-signature-256`, `x-signature-sha256`) через `webhookSecret`, индивидуальных токенов задачи (`webhookToken`) через `x-dsh-cron-webhook-token`, Bearer или query-параметр, а также мастер-токена `apiToken`. Fail-closed архитектура отклоняет запросы к незащищенным задачам.
|
|
581
|
+
- **Глубокая интерполяция payload**: Шаблоны `{webhook.body.path.to.key}`, `{webhook.headers.key}`, `{webhook.query.param}` и `{webhook.body}` автоматически раскрываются в промпте задачи.
|
|
582
|
+
- **Проброс в окружение**: Скриптовым и shell-задачам передаются переменные `DSH_CRON_WEBHOOK_BODY` и `DSH_CRON_WEBHOOK_HEADERS`.
|
|
583
|
+
- **Дебаунс от штормов**: Настраиваемый `webhookDebounceMs` (по умолчанию 500 мс) защищает от дублирующих всплесков событий.
|
|
584
|
+
- **Естественно-языковой парсер расписаний (`lib/nl-schedule.js`)**: Умное распознавание расписаний на русском и английском языках в чате, туле агента и API:
|
|
585
|
+
- Интервалы: `"каждые 15 минут"`, `"every 2 hours"`, `"каждые 4 часа"`.
|
|
586
|
+
- Время суток: `"каждый день в 9:00"`, `"every morning at 8am"`, `"в полночь"`, `"в полдень"`.
|
|
587
|
+
- Будни и выходные: `"по будням в 10:00"`, `"every weekday at 9:00"`, `"по выходным в 12:00"`, `"on weekends at noon"`.
|
|
588
|
+
- Дни недели: `"каждую пятницу в 17:00"`, `"every Monday at 10am"`, `"по вторникам и четвергам в 14:00"`.
|
|
589
|
+
- Относительные отложенные задачи: `"через 15 минут"`, `"in 2 hours"`, `"через 3 дня в 10:00"`.
|
|
572
590
|
|
|
573
591
|
---
|
|
574
592
|
|
package/README.zh.md
CHANGED
|
@@ -569,6 +569,8 @@ dsh-cron:
|
|
|
569
569
|
| `GET` / `POST` | `/dsh-cron/api/tasks` | 令牌保护的外部接口:列表 / 创建或更新(#54) |
|
|
570
570
|
| `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | 令牌保护的外部接口:读取 / 删除(#54) |
|
|
571
571
|
| `POST` | `/dsh-cron/api/tasks/:id/run` | 令牌保护的外部接口:强制执行(#54) |
|
|
572
|
+
| `POST` | `/dsh-cron/webhook/:id` | 入站 HTTP Webhook 触发器:支持 HMAC SHA-256 / 令牌鉴权、防抖与 JSON 负载提示词变量插值(#348) |
|
|
573
|
+
| `POST` | `/dsh-cron/api/webhook/:id` | 令牌保护的入站 Webhook 外部接口(#348) |
|
|
572
574
|
|
|
573
575
|
---
|
|
574
576
|
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/api-task-webhook.js — Incoming HTTP Webhook Triggers (#348).
|
|
3
|
+
*
|
|
4
|
+
* Implements POST /dsh-cron/webhook/:taskId and POST /dsh-cron/api/webhook/:taskId.
|
|
5
|
+
* Authenticates via HMAC SHA-256 signature (GitHub/Stripe style), per-task token,
|
|
6
|
+
* or master apiToken, applies debouncing, parses JSON/text payload, and triggers
|
|
7
|
+
* the task with variable interpolation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { logger } from './logger.js';
|
|
12
|
+
import { sendJson } from './http-utils.js';
|
|
13
|
+
|
|
14
|
+
const MAX_WEBHOOK_BYTES = 512 * 1024; // 512 KiB limit
|
|
15
|
+
const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
|
|
16
|
+
const lastWebhookRuns = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Constant-time string/buffer comparison.
|
|
20
|
+
*/
|
|
21
|
+
export function safeCompare(a, b) {
|
|
22
|
+
if (!a || !b) return false;
|
|
23
|
+
const bufA = Buffer.from(String(a), 'utf8');
|
|
24
|
+
const bufB = Buffer.from(String(b), 'utf8');
|
|
25
|
+
if (bufA.length !== bufB.length) return false;
|
|
26
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify HMAC SHA-256 signature from headers.
|
|
31
|
+
* Supports x-hub-signature-256 (sha256=...), x-signature-256, or x-dsh-cron-signature.
|
|
32
|
+
*/
|
|
33
|
+
export function verifyHmacSignature(rawBuffer, secret, signatureHeader) {
|
|
34
|
+
if (!secret || !signatureHeader) return false;
|
|
35
|
+
const expectedHex = crypto.createHmac('sha256', secret).update(rawBuffer).digest('hex');
|
|
36
|
+
const provided = String(signatureHeader).replace(/^sha256=/i, '').trim();
|
|
37
|
+
return safeCompare(expectedHex, provided);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Verify token from header, Bearer auth, or query param.
|
|
42
|
+
*/
|
|
43
|
+
export function verifyToken(expectedToken, req, url) {
|
|
44
|
+
if (!expectedToken) return false;
|
|
45
|
+
const headers = req.headers || {};
|
|
46
|
+
const headerToken = headers['x-dsh-cron-webhook-token'] || headers['x-webhook-token'];
|
|
47
|
+
const authHeader = headers['authorization'];
|
|
48
|
+
let bearerToken = '';
|
|
49
|
+
if (authHeader && /^Bearer\s+/i.test(authHeader)) {
|
|
50
|
+
bearerToken = authHeader.replace(/^Bearer\s+/i, '').trim();
|
|
51
|
+
}
|
|
52
|
+
const queryToken = url?.searchParams?.get('token');
|
|
53
|
+
const candidate = headerToken || bearerToken || queryToken;
|
|
54
|
+
if (!candidate) return false;
|
|
55
|
+
return safeCompare(expectedToken, candidate);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read request body with raw Buffer preserved for signature verification.
|
|
60
|
+
*/
|
|
61
|
+
export function readRawBody(req, limit = MAX_WEBHOOK_BYTES) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let bytes = 0;
|
|
65
|
+
let settled = false;
|
|
66
|
+
|
|
67
|
+
const fail = (err) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
reject(err);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
req.on('data', (chunk) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
76
|
+
bytes += buf.length;
|
|
77
|
+
if (bytes > limit) {
|
|
78
|
+
fail(Object.assign(new Error('Webhook payload exceeds 512 KiB limit'), { statusCode: 413 }));
|
|
79
|
+
req.destroy();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
chunks.push(buf);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
req.on('end', () => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
const raw = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
89
|
+
let parsed = null;
|
|
90
|
+
const text = raw.toString('utf8').trim();
|
|
91
|
+
if (text) {
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
} catch {
|
|
95
|
+
parsed = text;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
parsed = {};
|
|
99
|
+
}
|
|
100
|
+
resolve({ raw, parsed });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
req.on('error', (err) => {
|
|
104
|
+
fail(Object.assign(err, { statusCode: 400 }));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Handle incoming webhook request for a task.
|
|
111
|
+
*/
|
|
112
|
+
export async function handleTaskWebhookRoute({ store, scheduler, req, res, taskId: explicitTaskId }) {
|
|
113
|
+
if (req.method !== 'POST') {
|
|
114
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
119
|
+
let taskId = explicitTaskId;
|
|
120
|
+
if (!taskId) {
|
|
121
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
122
|
+
// /dsh-cron/webhook/:taskId or /dsh-cron/api/webhook/:taskId
|
|
123
|
+
const webhookIdx = parts.indexOf('webhook');
|
|
124
|
+
if (webhookIdx !== -1 && parts[webhookIdx + 1]) {
|
|
125
|
+
taskId = parts[webhookIdx + 1];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!taskId) {
|
|
130
|
+
sendJson(res, 400, { ok: false, error: 'Task ID is required in webhook URL path' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const task = store.get(taskId);
|
|
135
|
+
if (!task) {
|
|
136
|
+
sendJson(res, 404, { ok: false, error: `Task "${taskId}" not found` });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (task.status !== 'active') {
|
|
141
|
+
sendJson(res, 400, { ok: false, error: `Task "${taskId}" is not active (current status: ${task.status})` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const isEnabled = Boolean(
|
|
146
|
+
task.webhookEnabled ||
|
|
147
|
+
(task.webhookTrigger && task.webhookTrigger.enabled)
|
|
148
|
+
);
|
|
149
|
+
if (!isEnabled) {
|
|
150
|
+
logger.warn(`[dsh-cron] Webhook received for task "${taskId}" but webhooks are not enabled for this task`);
|
|
151
|
+
sendJson(res, 403, { ok: false, error: `Webhooks are not enabled for task "${taskId}"` });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Read raw body
|
|
156
|
+
let bodyData;
|
|
157
|
+
try {
|
|
158
|
+
bodyData = await readRawBody(req);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { raw, parsed } = bodyData;
|
|
165
|
+
const headers = req.headers || {};
|
|
166
|
+
const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
167
|
+
|
|
168
|
+
// Authentication check (fail-closed)
|
|
169
|
+
const taskSecret = task.webhookSecret || (task.webhookTrigger && task.webhookTrigger.secret);
|
|
170
|
+
const taskToken = task.webhookToken || (task.webhookTrigger && task.webhookTrigger.token);
|
|
171
|
+
const masterToken = settings.apiToken;
|
|
172
|
+
|
|
173
|
+
let authenticated = false;
|
|
174
|
+
|
|
175
|
+
if (taskSecret) {
|
|
176
|
+
const sigHeader = headers['x-hub-signature-256'] || headers['x-signature-256'] || headers['x-dsh-cron-signature'];
|
|
177
|
+
if (sigHeader && verifyHmacSignature(raw, taskSecret, sigHeader)) {
|
|
178
|
+
authenticated = true;
|
|
179
|
+
} else {
|
|
180
|
+
logger.warn(`[dsh-cron] Invalid HMAC signature for webhook task "${taskId}"`);
|
|
181
|
+
sendJson(res, 401, { ok: false, error: 'Invalid HMAC signature' });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
} else if (taskToken) {
|
|
185
|
+
if (verifyToken(taskToken, req, url)) {
|
|
186
|
+
authenticated = true;
|
|
187
|
+
} else {
|
|
188
|
+
logger.warn(`[dsh-cron] Invalid webhook token for task "${taskId}"`);
|
|
189
|
+
sendJson(res, 401, { ok: false, error: 'Invalid webhook token' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
} else if (masterToken) {
|
|
193
|
+
if (verifyToken(masterToken, req, url)) {
|
|
194
|
+
authenticated = true;
|
|
195
|
+
} else {
|
|
196
|
+
logger.warn(`[dsh-cron] Invalid API token for webhook task "${taskId}"`);
|
|
197
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid token' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
logger.warn(`[dsh-cron] Webhook task "${taskId}" rejected: no secret or token configured (fail-closed)`);
|
|
202
|
+
sendJson(res, 403, { ok: false, error: 'Webhook secret or token must be configured for security' });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Debounce check
|
|
207
|
+
const debounceMs = Math.max(0, Number(task.webhookDebounceMs || (task.webhookTrigger && task.webhookTrigger.debounceMs) || 0));
|
|
208
|
+
if (debounceMs > 0) {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const last = lastWebhookRuns.get(taskId) || 0;
|
|
211
|
+
if (now - last < debounceMs) {
|
|
212
|
+
logger.info(`[dsh-cron] Webhook for task "${taskId}" debounced (${now - last}ms < ${debounceMs}ms)`);
|
|
213
|
+
sendJson(res, 200, { ok: true, debounced: true, message: `Webhook debounced (${debounceMs}ms cooldown)` });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
lastWebhookRuns.set(taskId, now);
|
|
217
|
+
} else {
|
|
218
|
+
lastWebhookRuns.set(taskId, Date.now());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
222
|
+
const webhookPayload = {
|
|
223
|
+
body: parsed,
|
|
224
|
+
raw: raw.toString('utf8'),
|
|
225
|
+
headers,
|
|
226
|
+
query,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
logger.info(`[dsh-cron] Webhook accepted for task "${taskId}", triggering execution`);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const runPromise = scheduler.triggerManualRun(taskId, {
|
|
233
|
+
webhook: webhookPayload,
|
|
234
|
+
triggeredBy: 'webhook',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
sendJson(res, 200, {
|
|
238
|
+
ok: true,
|
|
239
|
+
triggered: true,
|
|
240
|
+
taskId,
|
|
241
|
+
message: `Webhook received and task "${task.title}" triggered`,
|
|
242
|
+
});
|
|
243
|
+
} catch (runErr) {
|
|
244
|
+
logger.error(`[dsh-cron] Failed to trigger task "${taskId}" from webhook: ${runErr.message}`);
|
|
245
|
+
sendJson(res, 500, { ok: false, error: `Failed to trigger task: ${runErr.message}` });
|
|
246
|
+
}
|
|
247
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { handleTaskWebhookRoute } from './api-task-webhook.js';
|
|
1
2
|
import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
|
|
2
3
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
3
4
|
import { logStreamer } from './log-streamer.js';
|
|
@@ -83,6 +84,10 @@ export function createCronApiHandler(store, scheduler, collection) {
|
|
|
83
84
|
await handleTelegramWebhook({ store, scheduler, req, res });
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
87
|
+
if (scope === 'webhook' && id) {
|
|
88
|
+
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: id });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
86
91
|
|
|
87
92
|
if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
|
|
88
93
|
handleHeartbeatPing({ store, req, res, taskId: id });
|
|
@@ -640,6 +645,18 @@ function mergeExecutionFields(current, body, parsed) {
|
|
|
640
645
|
targetSessionId: body.targetSessionId !== undefined ? String(body.targetSessionId).trim() : (current ? (current.targetSessionId || '') : ''),
|
|
641
646
|
targetSessionReset: body.targetSessionReset !== undefined ? String(body.targetSessionReset).trim() : (current ? (current.targetSessionReset || 'never') : 'never'),
|
|
642
647
|
sessionTitle: body.sessionTitle !== undefined ? String(body.sessionTitle).trim() : (current ? (current.sessionTitle || '') : ''),
|
|
648
|
+
webhookEnabled: body.webhookEnabled !== undefined
|
|
649
|
+
? Boolean(body.webhookEnabled)
|
|
650
|
+
: (body.webhookTrigger?.enabled !== undefined ? Boolean(body.webhookTrigger.enabled) : (current ? Boolean(current.webhookEnabled || current.webhookTrigger?.enabled) : false)),
|
|
651
|
+
webhookToken: body.webhookToken !== undefined
|
|
652
|
+
? String(body.webhookToken).trim()
|
|
653
|
+
: (body.webhookTrigger?.token ? String(body.webhookTrigger.token).trim() : (current ? (current.webhookToken || current.webhookTrigger?.token || '') : '')),
|
|
654
|
+
webhookSecret: body.webhookSecret !== undefined
|
|
655
|
+
? String(body.webhookSecret).trim()
|
|
656
|
+
: (body.webhookTrigger?.secret ? String(body.webhookTrigger.secret).trim() : (current ? (current.webhookSecret || current.webhookTrigger?.secret || '') : '')),
|
|
657
|
+
webhookDebounceMs: body.webhookDebounceMs !== undefined
|
|
658
|
+
? (Number(body.webhookDebounceMs) || 0)
|
|
659
|
+
: (body.webhookTrigger?.debounceMs !== undefined ? (Number(body.webhookTrigger.debounceMs) || 0) : (current ? (Number(current.webhookDebounceMs || current.webhookTrigger?.debounceMs) || 0) : 0)),
|
|
643
660
|
onSuccess: body.onSuccess !== undefined ? String(body.onSuccess).trim() : (current ? (current.onSuccess || '') : ''),
|
|
644
661
|
onFailure: body.onFailure !== undefined ? String(body.onFailure).trim() : (current ? (current.onFailure || '') : ''),
|
|
645
662
|
heartbeatIntervalSeconds: body.heartbeatIntervalSeconds !== undefined ? Number(body.heartbeatIntervalSeconds) : (current ? (Number(current.heartbeatIntervalSeconds) || 0) : 0),
|
|
@@ -665,6 +682,10 @@ function mergeExecutionFields(current, body, parsed) {
|
|
|
665
682
|
*/
|
|
666
683
|
export async function handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken }) {
|
|
667
684
|
const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, [tasks|heartbeat|schedule], :id?, :action?
|
|
685
|
+
if (parts[2] === 'webhook' && parts[3]) {
|
|
686
|
+
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: parts[3] });
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
668
689
|
if (parts[2] === 'heartbeat' && parts[3]) {
|
|
669
690
|
handleHeartbeatPing({ store, req, res, taskId: parts[3], apiToken });
|
|
670
691
|
return;
|
package/lib/cron-tool.js
CHANGED
|
@@ -64,7 +64,7 @@ export const cronToolParameters = {
|
|
|
64
64
|
},
|
|
65
65
|
schedule: {
|
|
66
66
|
type: 'string',
|
|
67
|
-
description: 'Schedule expression: cron ("0 9 * * *"), shorthand ("@daily"
|
|
67
|
+
description: 'Schedule expression: natural language ("every day at 9am", "every weekday at 10am", "in 2 hours", Russian phrases supported), cron ("0 9 * * *"), shorthand ("@daily"), interval ("every 2h"), or one-shot ("in 30m")',
|
|
68
68
|
},
|
|
69
69
|
prompt: {
|
|
70
70
|
type: 'string',
|
package/lib/http-utils.js
CHANGED
|
@@ -224,6 +224,12 @@ export function redactTaskSecrets(task) {
|
|
|
224
224
|
if (clone.httpBody) {
|
|
225
225
|
clone.httpBody = REDACTED_SECRET;
|
|
226
226
|
}
|
|
227
|
+
if (clone.webhookToken) {
|
|
228
|
+
clone.webhookToken = REDACTED_SECRET;
|
|
229
|
+
}
|
|
230
|
+
if (clone.webhookSecret) {
|
|
231
|
+
clone.webhookSecret = REDACTED_SECRET;
|
|
232
|
+
}
|
|
227
233
|
|
|
228
234
|
return clone;
|
|
229
235
|
}
|
|
@@ -285,5 +291,11 @@ export function restoreTaskSecrets(incoming, current) {
|
|
|
285
291
|
restored.httpBody = current.httpBody;
|
|
286
292
|
}
|
|
287
293
|
|
|
294
|
+
if (restored.webhookToken === REDACTED_SECRET && current.webhookToken !== undefined) {
|
|
295
|
+
restored.webhookToken = current.webhookToken;
|
|
296
|
+
}
|
|
297
|
+
if (restored.webhookSecret === REDACTED_SECRET && current.webhookSecret !== undefined) {
|
|
298
|
+
restored.webhookSecret = current.webhookSecret;
|
|
299
|
+
}
|
|
288
300
|
return restored;
|
|
289
301
|
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
2
|
+
|
|
3
|
+
function pad(n) {
|
|
4
|
+
return String(n).padStart(2, '0');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse time string into { hour, minute }.
|
|
9
|
+
* Supports:
|
|
10
|
+
* - "9:00", "09:30", "18:45"
|
|
11
|
+
* - "9am", "9:30am", "6pm", "11:15pm"
|
|
12
|
+
* - "9 утра", "2 дня", "6 вечера", "11 вечера", "1 ночи"
|
|
13
|
+
* - Plain hour "9", "14"
|
|
14
|
+
*/
|
|
15
|
+
export function parseTimeString(raw) {
|
|
16
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
17
|
+
const str = raw.trim().toLowerCase();
|
|
18
|
+
|
|
19
|
+
// 1. English 12h: "9am", "9:30am", "6pm", "6:15pm"
|
|
20
|
+
const ampmMatch = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/);
|
|
21
|
+
if (ampmMatch) {
|
|
22
|
+
let hour = parseInt(ampmMatch[1], 10);
|
|
23
|
+
const minute = ampmMatch[2] ? parseInt(ampmMatch[2], 10) : 0;
|
|
24
|
+
const isPm = ampmMatch[3] === 'pm';
|
|
25
|
+
if (hour < 1 || hour > 12 || minute < 0 || minute > 59) return null;
|
|
26
|
+
if (isPm && hour !== 12) hour += 12;
|
|
27
|
+
if (!isPm && hour === 12) hour = 0;
|
|
28
|
+
return { hour, minute };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. Russian qualifiers: "9 утра", "2 дня", "6 вечера", "11 вечера", "1 ночи"
|
|
32
|
+
const ruMatch = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(утра|дня|вечера|ночи)$/);
|
|
33
|
+
if (ruMatch) {
|
|
34
|
+
let hour = parseInt(ruMatch[1], 10);
|
|
35
|
+
const minute = ruMatch[2] ? parseInt(ruMatch[2], 10) : 0;
|
|
36
|
+
const period = ruMatch[3];
|
|
37
|
+
if (minute < 0 || minute > 59) return null;
|
|
38
|
+
if (period === 'вечера' && hour < 12) hour += 12;
|
|
39
|
+
if (period === 'дня' && hour >= 1 && hour <= 11) hour += 12;
|
|
40
|
+
if (period === 'ночи' && hour === 12) hour = 0;
|
|
41
|
+
if (hour < 0 || hour > 23) return null;
|
|
42
|
+
return { hour, minute };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. 24h format: "14:30", "09:00", "9:00"
|
|
46
|
+
const colonMatch = str.match(/^(\d{1,2}):(\d{2})$/);
|
|
47
|
+
if (colonMatch) {
|
|
48
|
+
const hour = parseInt(colonMatch[1], 10);
|
|
49
|
+
const minute = parseInt(colonMatch[2], 10);
|
|
50
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
|
51
|
+
return { hour, minute };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 4. Plain hour: "9", "14", "22"
|
|
55
|
+
const plainMatch = str.match(/^(\d{1,2})$/);
|
|
56
|
+
if (plainMatch) {
|
|
57
|
+
const hour = parseInt(plainMatch[1], 10);
|
|
58
|
+
if (hour >= 0 && hour <= 23) {
|
|
59
|
+
return { hour, minute: 0 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DAY_OF_WEEK_MAP = {
|
|
67
|
+
// Sunday (0)
|
|
68
|
+
'воскресенье': 0, 'воскресеньям': 0, 'воскресенья': 0, 'вс': 0,
|
|
69
|
+
'sunday': 0, 'sundays': 0, 'sun': 0,
|
|
70
|
+
// Monday (1)
|
|
71
|
+
'понедельник': 1, 'понедельникам': 1, 'пн': 1,
|
|
72
|
+
'monday': 1, 'mondays': 1, 'mon': 1,
|
|
73
|
+
// Tuesday (2)
|
|
74
|
+
'вторник': 2, 'вторникам': 2, 'вт': 2,
|
|
75
|
+
'tuesday': 2, 'tuesdays': 2, 'tue': 2,
|
|
76
|
+
// Wednesday (3)
|
|
77
|
+
'среда': 3, 'среду': 3, 'средам': 3, 'ср': 3,
|
|
78
|
+
'wednesday': 3, 'wednesdays': 3, 'wed': 3,
|
|
79
|
+
// Thursday (4)
|
|
80
|
+
'четверг': 4, 'четвергам': 4, 'чт': 4,
|
|
81
|
+
'thursday': 4, 'thursdays': 4, 'thu': 4,
|
|
82
|
+
// Friday (5)
|
|
83
|
+
'пятница': 5, 'пятницу': 5, 'пятницам': 5, 'пт': 5,
|
|
84
|
+
'friday': 5, 'fridays': 5, 'fri': 5,
|
|
85
|
+
// Saturday (6)
|
|
86
|
+
'суббота': 6, 'субботу': 6, 'субботам': 6, 'сб': 6,
|
|
87
|
+
'saturday': 6, 'saturdays': 6, 'sat': 6,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const DAY_NAMES_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
91
|
+
|
|
92
|
+
function withNextRun(res) {
|
|
93
|
+
if (res && res.cronPattern && !res.nextRun) {
|
|
94
|
+
try {
|
|
95
|
+
const job = new Cron(res.cronPattern, { sloppyRanges: true });
|
|
96
|
+
const next = job.nextRun();
|
|
97
|
+
res.nextRun = next ? next.getTime() : null;
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return res;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Deterministically parse natural language schedule expression.
|
|
107
|
+
* Returns parsed schedule object compatible with parseScheduleExpression or null.
|
|
108
|
+
*/
|
|
109
|
+
export function parseNaturalSchedule(input) {
|
|
110
|
+
if (!input || typeof input !== 'string') return null;
|
|
111
|
+
const raw = input.trim();
|
|
112
|
+
if (!raw) return null;
|
|
113
|
+
|
|
114
|
+
// Normalize: lower case, single spaces, strip surrounding punctuation
|
|
115
|
+
const s = raw.toLowerCase()
|
|
116
|
+
.replace(/[.,;!]+$/, '')
|
|
117
|
+
.replace(/\s+/g, ' ')
|
|
118
|
+
.trim();
|
|
119
|
+
|
|
120
|
+
// A. Relative delay in Russian: "через 15 минут", "через 2 часа"
|
|
121
|
+
const delayMatch = s.match(/^через\s+(\d+)\s*(секунд[ыа]?|сек|s|минут[ы]?|мин|m|часа?|часов|h|дней|дня|день|d)$/);
|
|
122
|
+
if (delayMatch) {
|
|
123
|
+
const num = parseInt(delayMatch[1], 10);
|
|
124
|
+
const u = delayMatch[2];
|
|
125
|
+
let unit = 'm';
|
|
126
|
+
if (u.startsWith('сек') || u === 's') unit = 's';
|
|
127
|
+
else if (u.startsWith('час') || u === 'h') unit = 'h';
|
|
128
|
+
else if (u.startsWith('дн') || u === 'день' || u === 'd') unit = 'd';
|
|
129
|
+
let delayMs = num * 60 * 1000;
|
|
130
|
+
let unitText = `${num} min`;
|
|
131
|
+
if (unit === 's') { delayMs = num * 1000; unitText = `${num} sec`; }
|
|
132
|
+
else if (unit === 'h') { delayMs = num * 3600 * 1000; unitText = `${num} h`; }
|
|
133
|
+
else if (unit === 'd') { delayMs = num * 86400 * 1000; unitText = `${num} d`; }
|
|
134
|
+
const targetMs = Date.now() + delayMs;
|
|
135
|
+
return {
|
|
136
|
+
cronPattern: null,
|
|
137
|
+
isOneShot: true,
|
|
138
|
+
targetTimestamp: targetMs,
|
|
139
|
+
humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
|
|
140
|
+
nextRun: targetMs,
|
|
141
|
+
isNatural: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// B. Periodic intervals in Russian: "каждые 15 минут", "каждые 2 часа", "каждые полчаса"
|
|
146
|
+
if (s === 'каждые полчаса' || s === 'каждые пол-часа' || s === 'every half hour') {
|
|
147
|
+
return withNextRun({
|
|
148
|
+
cronPattern: '*/30 * * * *',
|
|
149
|
+
humanText: 'Every 30 minutes',
|
|
150
|
+
isInterval: true,
|
|
151
|
+
isNatural: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const intervalRu = s.match(/^кажды[еяй]\s+(\d+)\s*(минут[ы]?|мин|часа?|часов|дней|дня|день)$/);
|
|
156
|
+
if (intervalRu) {
|
|
157
|
+
const num = parseInt(intervalRu[1], 10);
|
|
158
|
+
const u = intervalRu[2];
|
|
159
|
+
if (u.startsWith('мин')) {
|
|
160
|
+
if (num < 1) return null;
|
|
161
|
+
if (num < 60) {
|
|
162
|
+
return withNextRun({
|
|
163
|
+
cronPattern: `*/${num} * * * *`,
|
|
164
|
+
humanText: `Every ${num} minutes`,
|
|
165
|
+
isInterval: true,
|
|
166
|
+
isNatural: true,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
} else if (u.startsWith('час')) {
|
|
170
|
+
if (num >= 1 && num < 24) {
|
|
171
|
+
return withNextRun({
|
|
172
|
+
cronPattern: `0 */${num} * * *`,
|
|
173
|
+
humanText: `Every ${num} hours`,
|
|
174
|
+
isInterval: true,
|
|
175
|
+
isNatural: true,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
} else if (u.startsWith('дн') || u === 'день') {
|
|
179
|
+
if (num >= 1 && num <= 31) {
|
|
180
|
+
return withNextRun({
|
|
181
|
+
cronPattern: `0 0 */${num} * *`,
|
|
182
|
+
humanText: `Every ${num} days`,
|
|
183
|
+
isInterval: true,
|
|
184
|
+
isNatural: true,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// C. Hourly / Midnight / Noon
|
|
191
|
+
if (s === 'каждый час' || s === 'в начале каждого часа' || s === 'в начале часа' || s === 'every hour' || s === 'hourly') {
|
|
192
|
+
return withNextRun({
|
|
193
|
+
cronPattern: '0 * * * *',
|
|
194
|
+
humanText: 'Every hour',
|
|
195
|
+
isNatural: true,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (s === 'в полночь' || s === 'at midnight' || s === 'midnight') {
|
|
199
|
+
return withNextRun({
|
|
200
|
+
cronPattern: '0 0 * * *',
|
|
201
|
+
humanText: 'Every day at 00:00',
|
|
202
|
+
isNatural: true,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (s === 'в полдень' || s === 'at noon' || s === 'noon') {
|
|
206
|
+
return withNextRun({
|
|
207
|
+
cronPattern: '0 12 * * *',
|
|
208
|
+
humanText: 'Every day at 12:00',
|
|
209
|
+
isNatural: true,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// D. Daily at specific time:
|
|
214
|
+
const dailyMatch = s.match(/^(?:каждый\s+день|ежедневно|every\s+day|daily)\s+(?:в|at)\s+(.+)$/);
|
|
215
|
+
if (dailyMatch) {
|
|
216
|
+
const t = parseTimeString(dailyMatch[1]);
|
|
217
|
+
if (t) {
|
|
218
|
+
return withNextRun({
|
|
219
|
+
cronPattern: `${t.minute} ${t.hour} * * *`,
|
|
220
|
+
humanText: `Every day at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
221
|
+
isNatural: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Reverse: "в 9:00 каждый день", "at 9am daily"
|
|
226
|
+
const dailyRevMatch = s.match(/^(?:в|at)\s+(.+?)\s+(?:каждый\s+день|ежедневно|every\s+day|daily)$/);
|
|
227
|
+
if (dailyRevMatch) {
|
|
228
|
+
const t = parseTimeString(dailyRevMatch[1]);
|
|
229
|
+
if (t) {
|
|
230
|
+
return withNextRun({
|
|
231
|
+
cronPattern: `${t.minute} ${t.hour} * * *`,
|
|
232
|
+
humanText: `Every day at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
233
|
+
isNatural: true,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// E. Weekdays at specific time:
|
|
239
|
+
const weekdayMatch = s.match(/^(?:по\s+будням|в\s+будние(?:\s+дни)?|с\s+понедельника\s+по\s+пятницу|every\s+weekday|weekdays|on\s+weekdays|mon-fri)\s+(?:в|at)\s+(.+)$/);
|
|
240
|
+
if (weekdayMatch) {
|
|
241
|
+
const t = parseTimeString(weekdayMatch[1]);
|
|
242
|
+
if (t) {
|
|
243
|
+
return withNextRun({
|
|
244
|
+
cronPattern: `${t.minute} ${t.hour} * * 1-5`,
|
|
245
|
+
humanText: `Weekdays at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
246
|
+
isNatural: true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Reverse: "в 10:00 по будням"
|
|
251
|
+
const weekdayRevMatch = s.match(/^(?:в|at)\s+(.+?)\s+(?:по\s+будням|в\s+будние(?:\s+дни)?|every\s+weekday|weekdays)$/);
|
|
252
|
+
if (weekdayRevMatch) {
|
|
253
|
+
const t = parseTimeString(weekdayRevMatch[1]);
|
|
254
|
+
if (t) {
|
|
255
|
+
return withNextRun({
|
|
256
|
+
cronPattern: `${t.minute} ${t.hour} * * 1-5`,
|
|
257
|
+
humanText: `Weekdays at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
258
|
+
isNatural: true,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// F. Weekends at specific time:
|
|
264
|
+
const weekendMatch = s.match(/^(?:по\s+выходным|в\s+выходные(?:\s+дни)?|по\s+субботам\s+и\s+воскресеньям|every\s+weekend|weekends|on\s+weekends)\s+(?:в|at)\s+(.+)$/);
|
|
265
|
+
if (weekendMatch) {
|
|
266
|
+
const t = parseTimeString(weekendMatch[1]);
|
|
267
|
+
if (t) {
|
|
268
|
+
return withNextRun({
|
|
269
|
+
cronPattern: `${t.minute} ${t.hour} * * 0,6`,
|
|
270
|
+
humanText: `Weekends at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
271
|
+
isNatural: true,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// G. Specific day of week:
|
|
277
|
+
const dowMatch = s.match(/^(?:по\s+|кажд(?:ый|ую|ое)\s+|every\s+|on\s+)?(понедельник(?:ам)?|вторник(?:ам)?|сред[ау]|средам|четверг(?:ам)?|пятниц[ау]|пятницам|суббот[ау]|субботам|воскресень[ея]|воскресеньям|monday|mondays|tuesday|tuesdays|wednesday|wednesdays|thursday|thursdays|friday|fridays|saturday|saturdays|sunday|sundays)\s+(?:в|at)\s+(.+)$/);
|
|
278
|
+
if (dowMatch) {
|
|
279
|
+
const dayWord = dowMatch[1];
|
|
280
|
+
const dow = DAY_OF_WEEK_MAP[dayWord];
|
|
281
|
+
if (dow !== undefined) {
|
|
282
|
+
const t = parseTimeString(dowMatch[2]);
|
|
283
|
+
if (t) {
|
|
284
|
+
return withNextRun({
|
|
285
|
+
cronPattern: `${t.minute} ${t.hour} * * ${dow}`,
|
|
286
|
+
humanText: `${DAY_NAMES_EN[dow]}s at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
287
|
+
isNatural: true,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// H. Monthly on specific day of month:
|
|
294
|
+
const monthlyRu = s.match(/^(?:каждое\s+(\d+)\s+число|(\d+)\s+числа\s+каждого\s+месяца)\s+(?:в|at)\s+(.+)$/);
|
|
295
|
+
if (monthlyRu) {
|
|
296
|
+
const day = parseInt(monthlyRu[1] || monthlyRu[2], 10);
|
|
297
|
+
if (day >= 1 && day <= 31) {
|
|
298
|
+
const t = parseTimeString(monthlyRu[3]);
|
|
299
|
+
if (t) {
|
|
300
|
+
return withNextRun({
|
|
301
|
+
cronPattern: `${t.minute} ${t.hour} ${day} * *`,
|
|
302
|
+
humanText: `Day ${day} of every month at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
303
|
+
isNatural: true,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const monthlyEn = s.match(/^(?:on\s+the\s+(\d+)(?:st|nd|rd|th)?\s+of\s+every\s+month|every\s+month\s+on\s+the\s+(\d+)(?:st|nd|rd|th)?)\s+at\s+(.+)$/);
|
|
310
|
+
if (monthlyEn) {
|
|
311
|
+
const day = parseInt(monthlyEn[1] || monthlyEn[2], 10);
|
|
312
|
+
if (day >= 1 && day <= 31) {
|
|
313
|
+
const t = parseTimeString(monthlyEn[3]);
|
|
314
|
+
if (t) {
|
|
315
|
+
return withNextRun({
|
|
316
|
+
cronPattern: `${t.minute} ${t.hour} ${day} * *`,
|
|
317
|
+
humanText: `Day ${day} of every month at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
318
|
+
isNatural: true,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// I. Standalone day of week without time (defaults to 09:00):
|
|
325
|
+
const standaloneDow = s.match(/^(?:по\s+|кажд(?:ый|ую|ое)\s+|every\s+|on\s+)(понедельник(?:ам)?|вторник(?:ам)?|сред[ау]|средам|четверг(?:ам)?|пятниц[ау]|пятницам|суббот[ау]|субботам|воскресень[ея]|воскресеньям|monday|mondays|tuesday|tuesdays|wednesday|wednesdays|thursday|thursdays|friday|fridays|saturday|saturdays|sunday|sundays)$/);
|
|
326
|
+
if (standaloneDow) {
|
|
327
|
+
const dow = DAY_OF_WEEK_MAP[standaloneDow[1]];
|
|
328
|
+
if (dow !== undefined) {
|
|
329
|
+
return withNextRun({
|
|
330
|
+
cronPattern: `0 9 * * ${dow}`,
|
|
331
|
+
humanText: `${DAY_NAMES_EN[dow]}s at 09:00`,
|
|
332
|
+
isNatural: true,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
@@ -14,6 +14,36 @@ function formatTime(d) {
|
|
|
14
14
|
return `${hours}:${minutes}:${seconds}`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Safely extract nested property from object by path.
|
|
19
|
+
* Supports dot and array bracket notation: "alerts[0].labels.container" or "issue.title".
|
|
20
|
+
*/
|
|
21
|
+
export function getNestedValue(obj, path) {
|
|
22
|
+
if (!obj || typeof obj !== 'object' || !path) return undefined;
|
|
23
|
+
// If property exists directly (e.g. headers with dashes like "x-github-event")
|
|
24
|
+
if (Object.prototype.hasOwnProperty.call(obj, path)) {
|
|
25
|
+
return obj[path];
|
|
26
|
+
}
|
|
27
|
+
// Try lowercase lookup for headers
|
|
28
|
+
const lowerPath = path.toLowerCase();
|
|
29
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
30
|
+
if (k.toLowerCase() === lowerPath) return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const normalized = path.replace(/\[(\w+)\]/g, '.$1');
|
|
34
|
+
const parts = normalized.split('.');
|
|
35
|
+
let current = obj;
|
|
36
|
+
for (const p of parts) {
|
|
37
|
+
if (current == null) return undefined;
|
|
38
|
+
if (typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, p)) {
|
|
39
|
+
current = current[p];
|
|
40
|
+
} else {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
|
|
17
47
|
/**
|
|
18
48
|
* Build dictionary of prompt interpolation variables for a task and run options.
|
|
19
49
|
*/
|
|
@@ -73,6 +103,16 @@ export function buildPromptContext(task = {}, options = {}) {
|
|
|
73
103
|
'task_title': task.title || '',
|
|
74
104
|
};
|
|
75
105
|
|
|
106
|
+
// Webhook context (#348)
|
|
107
|
+
if (options.webhook && typeof options.webhook === 'object') {
|
|
108
|
+
ctx.__webhook = options.webhook;
|
|
109
|
+
if (options.webhook.body !== undefined) {
|
|
110
|
+
ctx['webhook.body'] = typeof options.webhook.body === 'object'
|
|
111
|
+
? JSON.stringify(options.webhook.body)
|
|
112
|
+
: String(options.webhook.body);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
76
116
|
// DAG / Fan-In predecessor outputs (#328)
|
|
77
117
|
if (options.prevOutputs && typeof options.prevOutputs === 'object') {
|
|
78
118
|
for (const [k, v] of Object.entries(options.prevOutputs)) {
|
|
@@ -90,14 +130,17 @@ export function buildPromptContext(task = {}, options = {}) {
|
|
|
90
130
|
|
|
91
131
|
/**
|
|
92
132
|
* Replace {{var}} or {var} placeholders with values from context.
|
|
93
|
-
* Supports dot notation like {{prev.output}}
|
|
133
|
+
* Supports dot notation like {{prev.output}}, bracket notation like {{prevOutputs['step1']}},
|
|
134
|
+
* and nested webhook properties like {{webhook.body.alerts[0].labels.container}} (#348).
|
|
94
135
|
*/
|
|
95
136
|
export function interpolateTaskPrompt(text, context = {}) {
|
|
96
137
|
if (typeof text !== 'string') return text;
|
|
97
138
|
if (!text) return '';
|
|
98
139
|
|
|
99
|
-
|
|
100
|
-
|
|
140
|
+
let result = text;
|
|
141
|
+
|
|
142
|
+
// 1. Bracket notation for DAG: {{prevOutputs['step1']}} or {prevOutputs['step1']}
|
|
143
|
+
result = result.replace(/\{\{?prevOutputs\[['"]([^'"]+)['"]\]\}?\}/g, (match, stepId) => {
|
|
101
144
|
const key = `prevOutputs.${stepId}`;
|
|
102
145
|
if (Object.prototype.hasOwnProperty.call(context, key)) {
|
|
103
146
|
return context[key];
|
|
@@ -105,7 +148,21 @@ export function interpolateTaskPrompt(text, context = {}) {
|
|
|
105
148
|
return match;
|
|
106
149
|
});
|
|
107
150
|
|
|
108
|
-
// 2.
|
|
151
|
+
// 2. Webhook payload interpolation (#348): {webhook.body.path}, {webhook.headers.key}, {webhook.query.param}
|
|
152
|
+
if (context.__webhook && typeof context.__webhook === 'object') {
|
|
153
|
+
result = result.replace(/\{\{?webhook\.(body|headers|query)(?:\.([a-zA-Z0-9_.\[\]-]+))?\}?\}/g, (match, source, path) => {
|
|
154
|
+
const srcObj = context.__webhook[source];
|
|
155
|
+
if (!path) {
|
|
156
|
+
if (srcObj === undefined) return '';
|
|
157
|
+
return typeof srcObj === 'object' ? JSON.stringify(srcObj) : String(srcObj);
|
|
158
|
+
}
|
|
159
|
+
const val = getNestedValue(srcObj, path);
|
|
160
|
+
if (val === undefined) return '';
|
|
161
|
+
return typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 3. Standard {{var}} and {var}
|
|
109
166
|
result = result.replace(/\{\{([a-zA-Z0-9_.]+)\}\}/g, (match, varName) => {
|
|
110
167
|
if (Object.prototype.hasOwnProperty.call(context, varName)) {
|
|
111
168
|
const val = context[varName];
|
|
@@ -143,5 +200,20 @@ export function resolveTaskForExecution(task, options = {}) {
|
|
|
143
200
|
if (typeof resolved.url === 'string') {
|
|
144
201
|
resolved.url = interpolateTaskPrompt(resolved.url, context);
|
|
145
202
|
}
|
|
203
|
+
|
|
204
|
+
// Expose webhook payload in process environment for scripts (#348)
|
|
205
|
+
if (options.webhook && typeof options.webhook === 'object') {
|
|
206
|
+
const env = { ...(resolved.env || {}) };
|
|
207
|
+
if (options.webhook.body !== undefined) {
|
|
208
|
+
env.DSH_CRON_WEBHOOK_BODY = typeof options.webhook.body === 'object'
|
|
209
|
+
? JSON.stringify(options.webhook.body)
|
|
210
|
+
: String(options.webhook.body);
|
|
211
|
+
}
|
|
212
|
+
if (options.webhook.headers !== undefined) {
|
|
213
|
+
env.DSH_CRON_WEBHOOK_HEADERS = JSON.stringify(options.webhook.headers);
|
|
214
|
+
}
|
|
215
|
+
resolved.env = env;
|
|
216
|
+
}
|
|
217
|
+
|
|
146
218
|
return resolved;
|
|
147
219
|
}
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { handleTaskWebhookRoute } from './api-task-webhook.js';
|
|
1
2
|
import { calculateCostHeatmap } from './timeline.js';
|
|
2
3
|
import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
|
|
3
4
|
import { listVerifiedRecipes } from './recipes-catalog.js';
|
|
@@ -35,6 +36,13 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
35
36
|
handler: createCronApiHandler(store, scheduler, null)
|
|
36
37
|
}), 'dsh-cron: /telegram[...]');
|
|
37
38
|
|
|
39
|
+
// Incoming HTTP Webhook triggers (#348)
|
|
40
|
+
ctx.effect(() => ctx.webServer.register({
|
|
41
|
+
kind: 'prefix',
|
|
42
|
+
path: '/dsh-cron/webhook',
|
|
43
|
+
handler: (req, res) => handleTaskWebhookRoute({ store, scheduler, req, res })
|
|
44
|
+
}), 'dsh-cron: /webhook[...]');
|
|
45
|
+
|
|
38
46
|
ctx.effect(() => ctx.webServer.register({
|
|
39
47
|
kind: 'prefix',
|
|
40
48
|
path: '/dsh-cron/heartbeat-ping',
|
package/lib/scheduler.js
CHANGED
|
@@ -2,6 +2,7 @@ import { logger } from './logger.js';
|
|
|
2
2
|
import { bestEffort } from './best-effort.js';
|
|
3
3
|
import { Cron } from 'croner';
|
|
4
4
|
import { FileSystemWatcherManager } from './watcher.js';
|
|
5
|
+
import { parseNaturalSchedule } from './nl-schedule.js';
|
|
5
6
|
|
|
6
7
|
const MAX_TIMEOUT_MS = 2147483647; // 2^31 - 1 ms (~24.85 days)
|
|
7
8
|
import {
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
} from './scheduler-lifecycle.js';
|
|
32
33
|
|
|
33
34
|
export {
|
|
35
|
+
parseNaturalSchedule,
|
|
34
36
|
executePreflight,
|
|
35
37
|
AGENT_TASK_TYPES,
|
|
36
38
|
addUsage,
|
|
@@ -212,25 +214,35 @@ function parseIntervalExpression(str) {
|
|
|
212
214
|
* @-shorthands (#15) and the friendly aliases, including the Russian input
|
|
213
215
|
* aliases. Russian keywords are accepted input, not display strings.
|
|
214
216
|
*/
|
|
217
|
+
function makeAliasResult(cronPattern, humanText) {
|
|
218
|
+
try {
|
|
219
|
+
const job = new Cron(cronPattern, { sloppyRanges: true });
|
|
220
|
+
const next = job.nextRun();
|
|
221
|
+
return { cronPattern, humanText, nextRun: next ? next.getTime() : null };
|
|
222
|
+
} catch {
|
|
223
|
+
return { cronPattern, humanText };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
215
227
|
function parseAliasExpression(str) {
|
|
216
228
|
const lower = str.toLowerCase();
|
|
217
|
-
if (lower === '@hourly') return
|
|
218
|
-
if (lower === '@daily' || lower === '@midnight') return
|
|
219
|
-
if (lower === '@weekly') return
|
|
220
|
-
if (lower === '@monthly') return
|
|
221
|
-
if (lower === '@yearly' || lower === '@annually') return
|
|
229
|
+
if (lower === '@hourly') return makeAliasResult('0 * * * *', 'Every hour');
|
|
230
|
+
if (lower === '@daily' || lower === '@midnight') return makeAliasResult('0 0 * * *', 'Every day at 00:00');
|
|
231
|
+
if (lower === '@weekly') return makeAliasResult('0 0 * * 0', 'Every week on Sunday at 00:00');
|
|
232
|
+
if (lower === '@monthly') return makeAliasResult('0 0 1 * *', 'Every month on the 1st at 00:00');
|
|
233
|
+
if (lower === '@yearly' || lower === '@annually') return makeAliasResult('0 0 1 1 *', 'Every year on Jan 1 at 00:00');
|
|
222
234
|
const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
|
|
223
235
|
if (everyAlias) {
|
|
224
236
|
return parseIntervalExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
|
|
225
237
|
}
|
|
226
238
|
if (lower === 'daily' || lower === 'каждый день') {
|
|
227
|
-
return
|
|
239
|
+
return makeAliasResult('0 9 * * *', 'Every day at 09:00');
|
|
228
240
|
}
|
|
229
241
|
if (lower === 'weekdays' || lower === 'в будние дни' || lower === 'по будням') {
|
|
230
|
-
return
|
|
242
|
+
return makeAliasResult('0 9 * * 1-5', 'Weekdays at 09:00');
|
|
231
243
|
}
|
|
232
244
|
if (lower === 'hourly' || lower === 'каждый час') {
|
|
233
|
-
return
|
|
245
|
+
return makeAliasResult('0 * * * *', 'Every hour');
|
|
234
246
|
}
|
|
235
247
|
return null;
|
|
236
248
|
}
|
|
@@ -261,6 +273,7 @@ export function parseScheduleExpression(input) {
|
|
|
261
273
|
|| parseRelativeOneShot(str)
|
|
262
274
|
|| parseIntervalExpression(str)
|
|
263
275
|
|| parseAliasExpression(str)
|
|
276
|
+
|| parseNaturalSchedule(str)
|
|
264
277
|
|| parseCronExpression(str);
|
|
265
278
|
}
|
|
266
279
|
|
package/lib/store.js
CHANGED
|
@@ -629,6 +629,12 @@ export class TaskStore {
|
|
|
629
629
|
targetSessionId: task.targetSessionId !== undefined ? String(task.targetSessionId).trim() : (prev.targetSessionId || ''),
|
|
630
630
|
targetSessionReset: task.targetSessionReset !== undefined ? String(task.targetSessionReset).trim() : (prev.targetSessionReset || 'never'),
|
|
631
631
|
sessionTitle: task.sessionTitle !== undefined ? String(task.sessionTitle).trim() : (prev.sessionTitle || ''),
|
|
632
|
+
webhookEnabled: task.webhookEnabled !== undefined
|
|
633
|
+
? Boolean(task.webhookEnabled)
|
|
634
|
+
: (prev.webhookEnabled !== undefined ? Boolean(prev.webhookEnabled) : Boolean(task.webhookTrigger?.enabled || prev.webhookTrigger?.enabled || false)),
|
|
635
|
+
webhookToken: task.webhookToken !== undefined ? String(task.webhookToken).trim() : (prev.webhookToken || (task.webhookTrigger?.token ? String(task.webhookTrigger.token).trim() : '')),
|
|
636
|
+
webhookSecret: task.webhookSecret !== undefined ? String(task.webhookSecret).trim() : (prev.webhookSecret || (task.webhookTrigger?.secret ? String(task.webhookTrigger.secret).trim() : '')),
|
|
637
|
+
webhookDebounceMs: task.webhookDebounceMs !== undefined ? Number(task.webhookDebounceMs) : (prev.webhookDebounceMs !== undefined ? Number(prev.webhookDebounceMs) : (Number(task.webhookTrigger?.debounceMs || prev.webhookTrigger?.debounceMs) || 0)),
|
|
632
638
|
totalTokens: task.totalTokens !== undefined ? Number(task.totalTokens) : (prev.totalTokens || 0),
|
|
633
639
|
totalCostUsd: task.totalCostUsd !== undefined ? Number(task.totalCostUsd) : (prev.totalCostUsd || 0),
|
|
634
640
|
createdAt: prev.createdAt || task.createdAt || now,
|
package/lib/task-create.js
CHANGED
|
@@ -49,6 +49,10 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
49
49
|
targetSessionId: args.targetSessionId ? String(args.targetSessionId).trim() : '',
|
|
50
50
|
targetSessionReset: args.targetSessionReset ? String(args.targetSessionReset).trim() : 'never',
|
|
51
51
|
sessionTitle: args.sessionTitle ? String(args.sessionTitle).trim() : '',
|
|
52
|
+
webhookEnabled: args.webhookEnabled !== undefined ? Boolean(args.webhookEnabled) : (args.webhookTrigger?.enabled !== undefined ? Boolean(args.webhookTrigger.enabled) : false),
|
|
53
|
+
webhookToken: args.webhookToken ? String(args.webhookToken).trim() : (args.webhookTrigger?.token ? String(args.webhookTrigger.token).trim() : undefined),
|
|
54
|
+
webhookSecret: args.webhookSecret ? String(args.webhookSecret).trim() : (args.webhookTrigger?.secret ? String(args.webhookTrigger.secret).trim() : undefined),
|
|
55
|
+
webhookDebounceMs: args.webhookDebounceMs !== undefined ? Number(args.webhookDebounceMs) : (args.webhookTrigger?.debounceMs !== undefined ? Number(args.webhookTrigger.debounceMs) : undefined),
|
|
52
56
|
onSuccess: args.onSuccess ? String(args.onSuccess).trim() : '',
|
|
53
57
|
onFailure: args.onFailure ? String(args.onFailure).trim() : '',
|
|
54
58
|
dependsOn: args.dependsOn !== undefined ? sanitizeDependsOn(args.dependsOn) : undefined,
|
package/lib/task-patch.js
CHANGED
|
@@ -91,6 +91,18 @@ export function buildTaskPatch(current, body) {
|
|
|
91
91
|
return { ok: false, error: err.message };
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
if (patch.webhookEnabled !== undefined) {
|
|
95
|
+
patch.webhookEnabled = Boolean(patch.webhookEnabled);
|
|
96
|
+
}
|
|
97
|
+
if (patch.webhookToken !== undefined) {
|
|
98
|
+
patch.webhookToken = String(patch.webhookToken).trim();
|
|
99
|
+
}
|
|
100
|
+
if (patch.webhookSecret !== undefined) {
|
|
101
|
+
patch.webhookSecret = String(patch.webhookSecret).trim();
|
|
102
|
+
}
|
|
103
|
+
if (patch.webhookDebounceMs !== undefined) {
|
|
104
|
+
patch.webhookDebounceMs = Math.max(0, Number(patch.webhookDebounceMs) || 0);
|
|
105
|
+
}
|
|
94
106
|
if (patch.requireApproval !== undefined) {
|
|
95
107
|
patch.requireApproval = Boolean(patch.requireApproval);
|
|
96
108
|
}
|
package/lib/task-transfer.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
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",
|