@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 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.9, #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.
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", "@every 30m"), interval ("every 2h"), or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")',
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}} and bracket notation like {{prevOutputs['step1']}}.
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
- // 1. Bracket notation: {{prevOutputs['step1']}} or {prevOutputs['step1']} or with double quotes
100
- let result = text.replace(/\{\{?prevOutputs\[['"]([^'"]+)['"]\]\}?\}/g, (match, stepId) => {
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. Standard {{var}} and {var}
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 { cronPattern: '0 * * * *', humanText: 'Every hour' };
218
- if (lower === '@daily' || lower === '@midnight') return { cronPattern: '0 0 * * *', humanText: 'Every day at 00:00' };
219
- if (lower === '@weekly') return { cronPattern: '0 0 * * 0', humanText: 'Every week on Sunday at 00:00' };
220
- if (lower === '@monthly') return { cronPattern: '0 0 1 * *', humanText: 'Every month on the 1st at 00:00' };
221
- if (lower === '@yearly' || lower === '@annually') return { cronPattern: '0 0 1 1 *', humanText: 'Every year on Jan 1 at 00:00' };
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 { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
239
+ return makeAliasResult('0 9 * * *', 'Every day at 09:00');
228
240
  }
229
241
  if (lower === 'weekdays' || lower === 'в будние дни' || lower === 'по будням') {
230
- return { cronPattern: '0 9 * * 1-5', humanText: 'Weekdays at 09:00' };
242
+ return makeAliasResult('0 9 * * 1-5', 'Weekdays at 09:00');
231
243
  }
232
244
  if (lower === 'hourly' || lower === 'каждый час') {
233
- return { cronPattern: '0 * * * *', humanText: 'Every hour' };
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,
@@ -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
  }
@@ -34,6 +34,11 @@ export const PATCHABLE_TASK_FIELDS = [
34
34
  'targetSessionId',
35
35
  'targetSessionReset',
36
36
  'sessionTitle',
37
+ 'webhookEnabled',
38
+ 'webhookToken',
39
+ 'webhookSecret',
40
+ 'webhookDebounceMs',
41
+ 'webhookTrigger',
37
42
  'env',
38
43
  'cwd',
39
44
  'workspaceId',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.9",
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",