@goodandready/dsh-cron 0.2.4 → 0.2.5
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 +9 -15
- package/docs/README.ru.md +10 -15
- package/docs/README.zh.md +10 -15
- package/docs/design/DESIGN.md +8 -4
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/lib/channels.js +51 -61
- package/lib/client.js +573 -40
- package/lib/index.js +159 -80
- package/lib/store.js +5 -5
- package/lib/task-transfer.js +226 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,12 +31,12 @@ Autonomous AI agents often need to perform recurring duties: generating daily mo
|
|
|
31
31
|
|
|
32
32
|
**`@goodandready/dsh-cron`** is a native full-stack scheduling and background automation plugin for DeepSeek Harness. It bridges standard cron expressions and natural interval syntax with autonomous agent execution, providing:
|
|
33
33
|
|
|
34
|
-
1. **Rich Visual Task Manager** — a sidebar button and a full
|
|
34
|
+
1. **Rich Visual Task Manager** — a sidebar button with a collapsible list of active jobs (next run or live state, capped and persisted), plus a full panel to inspect, filter by type/model/channel, pause, trigger, duplicate, export/import and create tasks.
|
|
35
35
|
2. **Interactive "Create with DSH" Workflow** — chat with your agent to translate high-level requirements into a well-formed scheduled task.
|
|
36
36
|
3. **Autonomous AI Tool Calling** — native `cron_*` tools let agents schedule their own follow-up executions during conversations.
|
|
37
37
|
4. **Robust Scheduler & Atomic Storage** — built on `croner` with interval aliases, one-shot delays, atomic file persistence, run histories, and cost tracking.
|
|
38
38
|
5. **Six Execution Runtimes** — shell, Node.js, Python, HTTP/webhook, remote SSH and Docker, plus per-task environment variables, workspace binding and isolated git worktrees for code-modifying agent tasks.
|
|
39
|
-
6. **Multi-Channel Delivery With Templates** — one run fans out to Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus,
|
|
39
|
+
6. **Multi-Channel Delivery With Templates** — one run fans out to Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus, voice (`dsh-tts`) and Gitea, with `{variable}` message templates and secrets referenced by DSH credential name.
|
|
40
40
|
|
|
41
41
|
---
|
|
42
42
|
|
|
@@ -59,7 +59,7 @@ graph TD
|
|
|
59
59
|
Store["Atomic TaskStore<br/>(tasks.json with atomic write)"]
|
|
60
60
|
AgentRunner["Agent Session Dispatcher<br/>(Executes prompt with chosen model)"]
|
|
61
61
|
Runtimes["Execution Runtimes<br/>(shell, node, python, http, ssh, docker)"]
|
|
62
|
-
Notify["Delivery Router<br/>(templates +
|
|
62
|
+
Notify["Delivery Router<br/>(templates + 9 channels)"]
|
|
63
63
|
Secrets["Credential References<br/>(DSH credentials / ENV)"]
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -153,18 +153,17 @@ Every task picks its own runtime; non-LLM runtimes need no model and consume no
|
|
|
153
153
|
* **History → session navigation** — every LLM run records its session; open it straight from the run history entry.
|
|
154
154
|
|
|
155
155
|
### 8. Notification Channels & Message Templates
|
|
156
|
-
A finished run is delivered to every channel configured for the task — Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus,
|
|
156
|
+
A finished run is delivered to every channel configured for the task — Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus, voice via `dsh-tts`, and Gitea issues:
|
|
157
157
|
|
|
158
158
|
* **Per-task channels** — tick the channels in the task form; an explicit selection overrides the legacy `notifyTelegram` / `kanbanMode` switches, and an empty selection falls back to them.
|
|
159
159
|
* **Failure isolation** — one unreachable channel is reported in the scheduler log with the other channels still delivered; a broken webhook never swallows the rest of the report.
|
|
160
160
|
* **Message templates** — a global template, per-channel overrides, or a per-task template rendered from `{title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}`. Unknown placeholders are left intact, failed runs default to a failure template.
|
|
161
161
|
* **`onlyOnFailure`** — globally or per task, clean runs stay silent and only `error`/`timeout` runs are dispatched.
|
|
162
|
-
* **Credentials by reference** — webhook tokens
|
|
163
|
-
* **Delivery timeout** — every channel request is bounded (`deliveryTimeoutMs`, default 15000 ms, editable in the settings panel or `settings.yaml`) and channels are dispatched concurrently, so one unresponsive endpoint is recorded as a failure and cannot delay the other channels or the next scheduled tick. The bound is enforced around the whole channel handler, which also covers credential resolution
|
|
162
|
+
* **Credentials by reference** — webhook tokens and the Telegram bot token are entered as the NAME of a DSH credential (`botTokenRef`, `ntfyTokenRef`, `pushplusTokenRef`, `giteaTokenRef`); the value is resolved at send time through the DSH credentials service with an environment-variable fallback, and never travels through plugin settings. Webhook URLs and the Bark device key do embed a secret, so they are stored in the plugin settings file but are always returned masked to the browser and a masked value echoed back by the UI never overwrites the stored one.
|
|
163
|
+
* **Delivery timeout** — every channel request is bounded (`deliveryTimeoutMs`, default 15000 ms, editable in the settings panel or `settings.yaml`) and channels are dispatched concurrently, so one unresponsive endpoint is recorded as a failure and cannot delay the other channels or the next scheduled tick. The bound is enforced around the whole channel handler, which also covers credential resolution, which does not support abort signals.
|
|
164
164
|
* **Telegram** — Markdown report with status badges (✅ / ❌), duration, schedule description and monospace output; dynamic values are escaped so odd titles cannot break the message. Credentials may be entered directly, or inherited from the `dsh-messenger-gateway` section of your DSH `settings.yaml` (best-effort fallback).
|
|
165
165
|
* **Discord / Slack** — webhook delivery; Discord carries an embed coloured by run status, Slack a plain text body.
|
|
166
166
|
* **ntfy / Bark / PushPlus** — mobile push with a topic/device key and an optional bearer token; the Bark title and text travel in the request path.
|
|
167
|
-
* **Email** — SMTP with host, port, TLS, user, `smtpFrom` and a comma-separated recipient list; requires `nodemailer` in the harness runtime and reports a clear error when it is missing. The transport inherits the delivery deadline, so a stalled SMTP server cannot hold the run.
|
|
168
167
|
* **Voice** — `dsh-tts` speaks the report through its HTTP route (`ttsBaseUrl`, default `http://127.0.0.1:3080`).
|
|
169
168
|
* **Gitea** — opens an issue with the run report (`giteaBaseUrl`, `giteaRepo`, token credential); failures are labelled `cron`, `bug`, `alert`.
|
|
170
169
|
* **Test dispatch button** — verify Telegram connectivity on the spot before scheduling critical jobs.
|
|
@@ -232,13 +231,6 @@ dsh-cron:
|
|
|
232
231
|
barkKey: ""
|
|
233
232
|
pushplusUrl: "https://www.pushplus.plus/send" # pushplusTokenRef
|
|
234
233
|
pushplusTokenRef: ""
|
|
235
|
-
smtpHost: "" # smtpPort / smtpSecure / smtpUser / smtpFrom / smtpTo
|
|
236
|
-
smtpPort: 587
|
|
237
|
-
smtpSecure: false
|
|
238
|
-
smtpUser: ""
|
|
239
|
-
smtpPasswordRef: "" # credential NAME for the SMTP password
|
|
240
|
-
smtpFrom: ""
|
|
241
|
-
smtpTo: ""
|
|
242
234
|
ttsBaseUrl: "http://127.0.0.1:3080" # dsh-tts base URL
|
|
243
235
|
giteaBaseUrl: "" # giteaRepo = owner/repo, giteaTokenRef = credential NAME
|
|
244
236
|
giteaRepo: ""
|
|
@@ -266,7 +258,6 @@ dsh-cron:
|
|
|
266
258
|
| `ntfyUrl` / `ntfyTopic` / `ntfyTokenRef` | `string` | `"https://ntfy.sh"` / `""` / `""` | ntfy server, topic and an optional token credential name (sent as `Authorization: Bearer …`) |
|
|
267
259
|
| `barkServerUrl` / `barkKey` | `string` | `"https://api.day.app"` / `""` | Bark server and device key (key, title and text travel in the request path) |
|
|
268
260
|
| `pushplusUrl` / `pushplusTokenRef` | `string` | `"https://www.pushplus.plus/send"` / `""` | PushPlus endpoint (override for a self-hosted proxy) and token credential name |
|
|
269
|
-
| `smtpHost` / `smtpPort` / `smtpSecure` / `smtpUser` / `smtpPasswordRef` / `smtpFrom` / `smtpTo` | `string`/`number`/`boolean` | `""` / `587` / `false` / `""` / `""` / `""` / `""` | Email channel; the password is referenced by credential name and email requires `nodemailer` in the harness runtime |
|
|
270
261
|
| `ttsBaseUrl` | `string` | `"http://127.0.0.1:3080"` | Base URL of the `dsh-tts` plugin used for voice announcements |
|
|
271
262
|
| `giteaBaseUrl` / `giteaRepo` / `giteaTokenRef` | `string` | `""` | Gitea channel: base URL, `owner/repo`, and the credential name of the API token |
|
|
272
263
|
|
|
@@ -291,6 +282,9 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
|
|
|
291
282
|
| `POST` | `/dsh-cron/tasks/:id/pause` | Pause the schedule |
|
|
292
283
|
| `POST` | `/dsh-cron/tasks/:id/resume` | Resume the schedule |
|
|
293
284
|
| `POST` | `/dsh-cron/tasks/:id/toggle` | Toggle active/paused |
|
|
285
|
+
| `POST` | `/dsh-cron/tasks/:id/duplicate` | Creates a paused copy of a task: configuration copied, run state (history, counters, last run) reset |
|
|
286
|
+
| `GET` | `/dsh-cron/tasks/export` | Versioned JSON document with task configuration only — no history or counters. Channels reference credentials by name, but a task-level `env` map or HTTP headers you typed in yourself are part of the configuration and therefore appear in the file |
|
|
287
|
+
| `POST` | `/dsh-cron/tasks/import` | Validates a document and applies it with `add`, `replace` or `skip`; supports a `dryRun` summary. Imported tasks always start **paused**, so a restore never fires until reviewed |
|
|
294
288
|
| `PATCH` | `/dsh-cron/tasks/:id` | Partial update (whitelisted fields only: `title`, `schedule`, `prompt`, `type`, `delivery`, `provider`, `model`, runtime settings, `channels`, `template`, notification/timeout/overlap/kanban settings, `status`, `oneShot`) |
|
|
295
289
|
| `DELETE` | `/dsh-cron/tasks/:id` | Delete the task |
|
|
296
290
|
| `GET` | `/dsh-cron/models` | List LLM providers; `?provider=<id>` lists models |
|
package/docs/README.ru.md
CHANGED
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
|
|
32
32
|
**`@goodandready/dsh-cron`** — нативный полноформатный плагин планирования и фоновой автоматизации для DeepSeek Harness. Он связывает стандартные cron-выражения и естественные интервалы с автономным исполнением агентами:
|
|
33
33
|
|
|
34
|
-
1. **Развитый визуальный менеджер задач** — кнопка в сайдбаре и полноценная панель:
|
|
34
|
+
1. **Развитый визуальный менеджер задач** — кнопка в сайдбаре со сворачиваемым списком активных задач (следующий запуск или живой статус, с ограничением и запоминанием состояния) и полноценная панель: фильтры по типу, модели и каналу, пауза, немедленный запуск, дублирование, экспорт/импорт и создание задач.
|
|
35
35
|
2. **Интерактивный сценарий «Создать с DSH»** — опишите задачу словами, агент уточнит детали и оформит расписание.
|
|
36
36
|
3. **Автономный tool calling** — нативные инструменты `cron_*` позволяют агентам планировать собственные последующие действия прямо в диалоге.
|
|
37
37
|
4. **Надёжный планировщик и атомарное хранилище** — на базе `croner`: интервалы, разовые задачи с задержкой, атомарная запись, история запусков, учёт стоимости.
|
|
38
38
|
5. **Шесть рантаймов исполнения** — shell, Node.js, Python, HTTP/webhook, удалённый SSH и Docker, плюс переменные окружения на задачу, привязка workspace и изолированные git worktree для изменяющих код агентских задач.
|
|
39
|
-
6. **Многоканальная доставка с шаблонами** — один запуск расходится в Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus,
|
|
39
|
+
6. **Многоканальная доставка с шаблонами** — один запуск расходится в Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus, голос (`dsh-tts`) и Gitea, с шаблонами сообщений `{переменные}` и секретами по имени credential в DSH.
|
|
40
40
|
|
|
41
41
|
---
|
|
42
42
|
|
|
@@ -59,7 +59,7 @@ graph TD
|
|
|
59
59
|
Store["Атомарный TaskStore<br/>(tasks.json, атомарная запись)"]
|
|
60
60
|
AgentRunner["Диспетчер агентских сессий<br/>(запуск промпта выбранной моделью)"]
|
|
61
61
|
Runtimes["Рантаймы исполнения<br/>(shell, node, python, http, ssh, docker)"]
|
|
62
|
-
Notify["Маршрутизатор доставки<br/>(шаблоны +
|
|
62
|
+
Notify["Маршрутизатор доставки<br/>(шаблоны + 9 каналов)"]
|
|
63
63
|
Secrets["Credential-ссылки<br/>(DSH credentials / ENV)"]
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -152,18 +152,18 @@ cron_create_task({
|
|
|
152
152
|
* **История → сессия** — каждый LLM-запуск хранит свою сессию; открыть диалог можно прямо из записи истории.
|
|
153
153
|
|
|
154
154
|
### 8. Каналы доставки и шаблоны сообщений
|
|
155
|
-
Отчёт о завершённом запуске уходит во все каналы, выбранные для задачи — Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus,
|
|
155
|
+
Отчёт о завершённом запуске уходит во все каналы, выбранные для задачи — Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus, голос через `dsh-tts` и issue в Gitea:
|
|
156
156
|
|
|
157
|
+
* **Перенос задач** — экспорт всей конфигурации в версионированный JSON и импорт с предварительной сводкой; импортированные задачи приходят на паузе.
|
|
157
158
|
* **Каналы на задачу** — отметьте каналы в форме задачи; явный выбор перекрывает legacy-переключатели `notifyTelegram`/`kanbanMode`, а пустой выбор возвращается к ним.
|
|
158
159
|
* **Изоляция сбоев** — недоступный канал фиксируется в логе планировщика, остальные каналы получают отчёт; сломанный webhook не поглощает доставку целиком.
|
|
159
160
|
* **Шаблоны сообщений** — глобальный шаблон, переопределения по каналам или шаблон на задачу с переменными `{title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}`. Неизвестные плейсхолдеры остаются как есть, для сбойных запусков по умолчанию используется шаблон ошибки.
|
|
160
161
|
* **`onlyOnFailure`** — глобально или на задачу: успешные запуски молчат, уходят только `error`/`timeout`.
|
|
161
|
-
* **Креденшелы по ссылке** — токены webhook'
|
|
162
|
-
* **Таймаут доставки** — каждый запрос канала ограничен (`deliveryTimeoutMs`, по умолчанию 15000 мс, задаётся в панели настроек или `settings.yaml`), каналы отправляются параллельно: недоступный endpoint фиксируется как сбой и не задерживает остальные каналы и следующий тик расписания. Ограничение действует на весь обработчик канала, включая резолв credential'
|
|
162
|
+
* **Креденшелы по ссылке** — токены webhook'ов и токен Telegram вводятся как ИМЯ credential в DSH (`botTokenRef`, `ntfyTokenRef`, `pushplusTokenRef`, `giteaTokenRef`); значение резолвится в момент отправки через credentials-сервис DSH с фолбэком на переменную окружения и никогда не проходит через настройки плагина. Webhook-URL и ключ устройства Bark содержат секрет внутри, поэтому хранятся в настройках плагина, но всегда отдаются в браузер замаскированными, а замаскированное значение из UI никогда не перезаписывает сохранённое.
|
|
163
|
+
* **Таймаут доставки** — каждый запрос канала ограничен (`deliveryTimeoutMs`, по умолчанию 15000 мс, задаётся в панели настроек или `settings.yaml`), каналы отправляются параллельно: недоступный endpoint фиксируется как сбой и не задерживает остальные каналы и следующий тик расписания. Ограничение действует на весь обработчик канала, включая резолв credential'ов, который не поддерживает abort-сигнал.
|
|
163
164
|
* **Telegram** — Markdown-отчёт со статусными значками (✅ / ❌), длительностью, описанием расписания и monospace-блоком вывода; динамические значения экранируются. Креденшелы можно ввести напрямую или унаследовать из секции `dsh-messenger-gateway` вашего DSH `settings.yaml` (best-effort).
|
|
164
165
|
* **Discord / Slack** — доставка через webhook: Discord получает embed с цветом по статусу запуска, Slack — обычный текст.
|
|
165
166
|
* **ntfy / Bark / PushPlus** — мобильные пуши: тема/ключ устройства и опциональный bearer-токен; у Bark заголовок и текст идут в пути запроса, у PushPlus endpoint настраивается (self-hosted прокси).
|
|
166
|
-
* **Email** — SMTP с хостом, портом, TLS, пользователем, `smtpFrom` и списком получателей; требует `nodemailer` в рантайме харнесса и сообщает понятную ошибку, если его нет. Транспорт наследует дедлайн доставки, поэтому зависший SMTP-сервер не удерживает запуск.
|
|
167
167
|
* **Голос** — `dsh-tts` озвучивает отчёт через свой HTTP-маршрут (`ttsBaseUrl`, по умолчанию `http://127.0.0.1:3080`).
|
|
168
168
|
* **Gitea** — создаёт issue с отчётом (`giteaBaseUrl`, `giteaRepo`, credential токена); сбойные запуски помечаются метками `cron`, `bug`, `alert`.
|
|
169
169
|
* **Кнопка проверки** — проверьте доставку в Telegram до запуска критичных задач.
|
|
@@ -224,13 +224,6 @@ dsh-cron:
|
|
|
224
224
|
barkKey: ""
|
|
225
225
|
pushplusUrl: "https://www.pushplus.plus/send" # pushplusTokenRef
|
|
226
226
|
pushplusTokenRef: ""
|
|
227
|
-
smtpHost: "" # smtpPort / smtpSecure / smtpUser / smtpFrom / smtpTo
|
|
228
|
-
smtpPort: 587
|
|
229
|
-
smtpSecure: false
|
|
230
|
-
smtpUser: ""
|
|
231
|
-
smtpPasswordRef: "" # ИМЯ credential для пароля SMTP
|
|
232
|
-
smtpFrom: ""
|
|
233
|
-
smtpTo: ""
|
|
234
227
|
ttsBaseUrl: "http://127.0.0.1:3080" # базовый URL dsh-tts
|
|
235
228
|
giteaBaseUrl: "" # giteaRepo = owner/repo, giteaTokenRef = ИМЯ credential
|
|
236
229
|
giteaRepo: ""
|
|
@@ -258,7 +251,6 @@ dsh-cron:
|
|
|
258
251
|
| `ntfyUrl` / `ntfyTopic` / `ntfyTokenRef` | `string` | `"https://ntfy.sh"` / `""` / `""` | Сервер ntfy, тема и опциональное имя credential токена (`Authorization: Bearer …`) |
|
|
259
252
|
| `barkServerUrl` / `barkKey` | `string` | `"https://api.day.app"` / `""` | Сервер Bark и ключ устройства (ключ, заголовок и текст идут в пути запроса) |
|
|
260
253
|
| `pushplusUrl` / `pushplusTokenRef` | `string` | `"https://www.pushplus.plus/send"` / `""` | Endpoint PushPlus (переопределяется для self-hosted прокси) и имя credential токена |
|
|
261
|
-
| `smtpHost` / `smtpPort` / `smtpSecure` / `smtpUser` / `smtpPasswordRef` / `smtpFrom` / `smtpTo` | `string`/`number`/`boolean` | `""` / `587` / `false` / `""` / `""` / `""` / `""` | Канал email; пароль задаётся ссылкой на credential, для отправки нужен `nodemailer` в рантайме харнесса |
|
|
262
254
|
| `ttsBaseUrl` | `string` | `"http://127.0.0.1:3080"` | Базовый URL плагина `dsh-tts` для голосовых объявлений |
|
|
263
255
|
| `giteaBaseUrl` / `giteaRepo` / `giteaTokenRef` | `string` | `""` | Канал Gitea: базовый URL, `owner/repo` и имя credential API-токена |
|
|
264
256
|
|
|
@@ -283,6 +275,9 @@ dsh-cron:
|
|
|
283
275
|
| `POST` | `/dsh-cron/tasks/:id/pause` | Пауза расписания |
|
|
284
276
|
| `POST` | `/dsh-cron/tasks/:id/resume` | Возобновление расписания |
|
|
285
277
|
| `POST` | `/dsh-cron/tasks/:id/toggle` | Переключение активна/на паузе |
|
|
278
|
+
| `POST` | `/dsh-cron/tasks/:id/duplicate` | Копия задачи в статусе «на паузе»: настройки копируются, история и счётчики сбрасываются |
|
|
279
|
+
| `GET` | `/dsh-cron/tasks/export` | Версионированный JSON только с конфигурацией задач — без истории и счётчиков. Каналы ссылаются на credential по имени, но введённые вручную `env` и HTTP-заголовки задачи являются частью конфигурации и попадают в файл |
|
|
280
|
+
| `POST` | `/dsh-cron/tasks/import` | Проверяет документ и применяет его стратегией `add`, `replace` или `skip`; поддерживает `dryRun`. Импортированные задачи всегда приходят **на паузе** — восстановление не сработает само |
|
|
286
281
|
| `PATCH` | `/dsh-cron/tasks/:id` | Частичное обновление (только whitelisted-поля: `title`, `schedule`, `prompt`, `type`, `delivery`, `provider`, `model`, настройки уведомлений/таймаута/overlap/kanban, `status`, `oneShot`) |
|
|
287
282
|
| `DELETE` | `/dsh-cron/tasks/:id` | Удаление задачи |
|
|
288
283
|
| `GET` | `/dsh-cron/models` | Список LLM-провайдеров; `?provider=<id>` — модели |
|
package/docs/README.zh.md
CHANGED
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
|
|
32
32
|
**`@goodandready/dsh-cron`** 是 DeepSeek Harness 的原生全栈调度与后台自动化插件。它将标准 cron 表达式、自然语言间隔语法与自主智能体执行连接起来:
|
|
33
33
|
|
|
34
|
-
1. **完善的可视化任务管理器** ——
|
|
34
|
+
1. **完善的可视化任务管理器** —— 侧边栏按钮带可折叠的活跃任务列表(下次运行时间或实时状态,行数有上限且状态可记忆),以及功能齐全的面板:按类型、模型、渠道筛选,暂停、立即运行、复制、导出/导入与创建任务。
|
|
35
35
|
2. **交互式“由 DSH 创建”流程** —— 与智能体对话,把高层需求转化为规范的定时任务。
|
|
36
36
|
3. **自主工具调用** —— 原生 `cron_*` 工具让智能体在会话中自行安排后续执行。
|
|
37
37
|
4. **健壮的调度器与原子存储** —— 基于 `croner`:间隔别名、一次性延时任务、原子写入、运行历史与成本追踪。
|
|
38
38
|
5. **六种执行运行时** —— shell、Node.js、Python、HTTP/webhook、远程 SSH 与 Docker,并支持按任务的环境变量、工作区绑定以及面向代码修改任务的隔离 git worktree。
|
|
39
|
-
6. **多渠道路由与模板** —— 一次运行可投递到 Telegram、dsh-kanban、Discord、Slack、ntfy、Bark、PushPlus
|
|
39
|
+
6. **多渠道路由与模板** —— 一次运行可投递到 Telegram、dsh-kanban、Discord、Slack、ntfy、Bark、PushPlus、语音(`dsh-tts`)与 Gitea,支持 `{变量}` 消息模板与按 DSH 凭据名称引用的密钥。
|
|
40
40
|
|
|
41
41
|
---
|
|
42
42
|
|
|
@@ -59,7 +59,7 @@ graph TD
|
|
|
59
59
|
Store["原子 TaskStore<br/>(tasks.json 原子写入)"]
|
|
60
60
|
AgentRunner["智能体会话调度器<br/>(以指定模型执行提示词)"]
|
|
61
61
|
Runtimes["执行运行时<br/>(shell、node、python、http、ssh、docker)"]
|
|
62
|
-
Notify["投递路由<br/>(模板 +
|
|
62
|
+
Notify["投递路由<br/>(模板 + 9 个渠道)"]
|
|
63
63
|
Secrets["凭据引用<br/>(DSH credentials / ENV)"]
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -152,18 +152,18 @@ cron_create_task({
|
|
|
152
152
|
* **历史 → 会话** —— 每次 LLM 运行都会记录会话,可直接从历史记录打开对话。
|
|
153
153
|
|
|
154
154
|
### 8. 通知渠道与消息模板
|
|
155
|
-
运行完成后,报告会发送到该任务配置的所有渠道 —— Telegram、dsh-kanban、Discord、Slack、ntfy、Bark、PushPlus
|
|
155
|
+
运行完成后,报告会发送到该任务配置的所有渠道 —— Telegram、dsh-kanban、Discord、Slack、ntfy、Bark、PushPlus、语音(`dsh-tts`)以及 Gitea issue:
|
|
156
156
|
|
|
157
|
+
* **任务迁移** —— 将全部配置导出为版本化 JSON,并在别处导入(含预览摘要);导入的任务处于暂停状态。
|
|
157
158
|
* **按任务选择渠道** —— 在任务表单中勾选渠道;显式选择会覆盖旧版 `notifyTelegram`/`kanbanMode` 开关,留空则回退到它们。
|
|
158
159
|
* **故障隔离** —— 某个渠道不可用会记录在调度器日志中,其余渠道仍会收到报告;失效的 webhook 不会吞掉整份报告。
|
|
159
160
|
* **消息模板** —— 支持全局模板、按渠道覆盖或按任务模板,变量为 `{title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}`。未知占位符保持原样,失败运行默认使用失败模板。
|
|
160
161
|
* **`onlyOnFailure`** —— 全局或按任务生效:成功运行静默,仅发送 `error`/`timeout`。
|
|
161
|
-
* **凭据按名称引用** —— webhook token
|
|
162
|
-
* **投递超时** —— 每个渠道请求都有上限(`deliveryTimeoutMs`,默认 15000 毫秒,可在设置面板或 `settings.yaml`
|
|
162
|
+
* **凭据按名称引用** —— webhook token 与 Telegram bot token 填写 DSH 凭据的名称(`botTokenRef`、`ntfyTokenRef`、`pushplusTokenRef`、`giteaTokenRef`),发送时通过 DSH credentials 服务解析,并可回退到环境变量,且绝不会经过插件设置。webhook URL 与 Bark 设备键本身内嵌密钥,因此保存在插件设置文件中,但返回浏览器时始终为掩码,界面回传的掩码值也不会覆盖已保存的值。
|
|
163
|
+
* **投递超时** —— 每个渠道请求都有上限(`deliveryTimeoutMs`,默认 15000 毫秒,可在设置面板或 `settings.yaml` 中调整),且各渠道并发发送:无响应的端点只记录为失败,不会拖慢其他渠道或下一次调度。限制作用于整个渠道处理过程,也覆盖凭据解析——它不支持 abort 信号。
|
|
163
164
|
* **Telegram** —— 带状态徽标(✅ / ❌)、耗时、调度描述与等宽输出块的 Markdown 报告;动态值会被转义。凭据可直接填写,或从 DSH `settings.yaml` 的 `dsh-messenger-gateway` 段继承(尽力而为)。
|
|
164
165
|
* **Discord / Slack** —— 通过 webhook 投递:Discord 使用按运行状态着色的 embed,Slack 使用纯文本正文。
|
|
165
166
|
* **ntfy / Bark / PushPlus** —— 移动推送,支持主题/设备键与可选 bearer token;Bark 的标题与正文放在请求路径中,PushPlus 端点可指向自建代理。
|
|
166
|
-
* **邮件** —— SMTP(host、port、TLS、user、`smtpFrom` 与逗号分隔的收件人);需要 Harness 运行时安装 `nodemailer`,缺少时会给出明确错误。传输会继承投递截止时间,因此无响应的 SMTP 服务器不会拖住运行。
|
|
167
167
|
* **语音** —— `dsh-tts` 通过其 HTTP 路由朗读报告(`ttsBaseUrl`,默认 `http://127.0.0.1:3080`)。
|
|
168
168
|
* **Gitea** —— 创建包含运行报告的 issue(`giteaBaseUrl`、`giteaRepo`、token 凭据);失败运行标记为 `cron`、`bug`、`alert`。
|
|
169
169
|
* **测试发送按钮** —— 在安排关键任务前现场验证 Telegram 连通性。
|
|
@@ -228,13 +228,6 @@ dsh-cron:
|
|
|
228
228
|
barkKey: ""
|
|
229
229
|
pushplusUrl: "https://www.pushplus.plus/send" # pushplusTokenRef
|
|
230
230
|
pushplusTokenRef: ""
|
|
231
|
-
smtpHost: "" # smtpPort / smtpSecure / smtpUser / smtpFrom / smtpTo
|
|
232
|
-
smtpPort: 587
|
|
233
|
-
smtpSecure: false
|
|
234
|
-
smtpUser: ""
|
|
235
|
-
smtpPasswordRef: "" # SMTP 密码的凭据名称
|
|
236
|
-
smtpFrom: ""
|
|
237
|
-
smtpTo: ""
|
|
238
231
|
ttsBaseUrl: "http://127.0.0.1:3080" # dsh-tts 基础地址
|
|
239
232
|
giteaBaseUrl: "" # giteaRepo = owner/repo,giteaTokenRef = 凭据名称
|
|
240
233
|
giteaRepo: ""
|
|
@@ -262,7 +255,6 @@ dsh-cron:
|
|
|
262
255
|
| `ntfyUrl` / `ntfyTopic` / `ntfyTokenRef` | `string` | `"https://ntfy.sh"` / `""` / `""` | ntfy 服务器、主题与可选的 token 凭据名称(以 `Authorization: Bearer …` 发送) |
|
|
263
256
|
| `barkServerUrl` / `barkKey` | `string` | `"https://api.day.app"` / `""` | Bark 服务器与设备键(键、标题和正文位于请求路径中) |
|
|
264
257
|
| `pushplusUrl` / `pushplusTokenRef` | `string` | `"https://www.pushplus.plus/send"` / `""` | PushPlus 端点(可指向自建代理)与 token 凭据名称 |
|
|
265
|
-
| `smtpHost` / `smtpPort` / `smtpSecure` / `smtpUser` / `smtpPasswordRef` / `smtpFrom` / `smtpTo` | `string`/`number`/`boolean` | `""` / `587` / `false` / `""` / `""` / `""` / `""` | 邮件渠道;密码以凭据名称引用,发送需要 Harness 运行时安装 `nodemailer` |
|
|
266
258
|
| `ttsBaseUrl` | `string` | `"http://127.0.0.1:3080"` | 用于语音播报的 `dsh-tts` 基础地址 |
|
|
267
259
|
| `giteaBaseUrl` / `giteaRepo` / `giteaTokenRef` | `string` | `""` | Gitea 渠道:基础地址、`owner/repo` 与 API token 的凭据名称 |
|
|
268
260
|
|
|
@@ -287,6 +279,9 @@ dsh-cron:
|
|
|
287
279
|
| `POST` | `/dsh-cron/tasks/:id/pause` | 暂停调度 |
|
|
288
280
|
| `POST` | `/dsh-cron/tasks/:id/resume` | 恢复调度 |
|
|
289
281
|
| `POST` | `/dsh-cron/tasks/:id/toggle` | 切换活跃/暂停 |
|
|
282
|
+
| `POST` | `/dsh-cron/tasks/:id/duplicate` | 创建暂停状态的副本:复制配置,重置运行历史与计数 |
|
|
283
|
+
| `GET` | `/dsh-cron/tasks/export` | 仅含任务配置的版本化 JSON —— 不含历史与计数。渠道按名称引用凭据,但手动填写在任务中的 `env` 与 HTTP 请求头属于配置,会出现在文件里 |
|
|
284
|
+
| `POST` | `/dsh-cron/tasks/import` | 校验文档并以 `add`、`replace` 或 `skip` 策略导入;支持 `dryRun` 预览。导入的任务始终为**暂停**状态,恢复不会自动触发 |
|
|
290
285
|
| `PATCH` | `/dsh-cron/tasks/:id` | 部分更新(仅白名单字段:`title`、`schedule`、`prompt`、`type`、`delivery`、`provider`、`model`、通知/超时/重叠/Kanban 设置、`status`、`oneShot`) |
|
|
291
286
|
| `DELETE` | `/dsh-cron/tasks/:id` | 删除任务 |
|
|
292
287
|
| `GET` | `/dsh-cron/models` | 列出 LLM 提供方;`?provider=<id>` 列出模型 |
|
package/docs/design/DESIGN.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
## User Surfaces
|
|
9
9
|
- Web/UI:
|
|
10
10
|
- Экран «Запланированные задачи» (полноэкранный оверлей в центральной колонке интерфейса DSH, аналогично dsh-kanban).
|
|
11
|
+
- Раздел «Активные задачи» в сайдбаре под кнопкой плагина: сворачиваемый (свёрнут по умолчанию, состояние запоминается), до пяти строк с названием и временем следующего запуска или живым статусом, строка «и ещё N»; клик открывает панель и подсвечивает задачу (#34).
|
|
12
|
+
- Панель фильтров в списке задач: тип, модель, канал; фильтрация мгновенная на клиенте, есть сброс, счётчик «показано N из M» и понятное пустое состояние (#40).
|
|
11
13
|
- Кнопка вызова в боковой панели (sidebar-entry) рядом с новой сессией + иконка в шапке сессии (utilities slot).
|
|
12
14
|
- Кнопка «Создать ⌄» с дропдауном:
|
|
13
15
|
- 💬 «Создать с DSH» (запуск интерактивного диалога постановки задачи агенту).
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
- Блок «Рекомендуемые задачи»: готовые шаблоны (Daily digest, Weekly review, Follow-up monitor) в один клик.
|
|
19
21
|
- Карточка настроек в слоте settings.plugin.item, key = namespace `dsh-cron`; отдельный раздел настроек не используется (#102).
|
|
20
22
|
- LLM Tools: cron_create_task (+ alias cron_schedule_task), cron_list_tasks, cron_pause_task, cron_resume_task, cron_delete_task, cron_run_task. Параметры задачи включают рантайм (`type`), `channels` (список каналов доставки) и `template` (шаблон сообщения).
|
|
21
|
-
- API: HTTP эндпоинты /dsh-cron/* (tasks, models, chat/start, settings, telegram/test, kanban/test, tasks/:id/actions, legacy action/:id/:action). Мутирующие эндпоинты отклоняют cross-origin запросы; script-задачи по HTTP требуют заголовок x-dsh-cron-confirm. Настройки доставки принимаются как плоские ключи (webhook URL, топики,
|
|
23
|
+
- API: HTTP эндпоинты /dsh-cron/* (tasks, models, chat/start, settings, telegram/test, kanban/test, tasks/:id/actions, legacy action/:id/:action). Мутирующие эндпоинты отклоняют cross-origin запросы; script-задачи по HTTP требуют заголовок x-dsh-cron-confirm. Настройки доставки принимаются как плоские ключи (webhook URL, топики, base URL) и `channelTemplates` — карта шаблонов по каналам.
|
|
22
24
|
- Chat / Slash Commands: отсутствуют (ранее заявленные /cron-команды не были реализованы и удалены из документации; решение 2026-09-09).
|
|
23
25
|
|
|
24
26
|
## Visual Direction
|
|
@@ -42,10 +44,11 @@
|
|
|
42
44
|
- CronSidebarButton: кнопка в левом сайдбаре DSH.
|
|
43
45
|
- CronScreen: основной оверлей со списком, табами, статистикой и рекомендациями.
|
|
44
46
|
- CreateDropdown: всплывающее меню выбора способа создания.
|
|
45
|
-
- ManualTaskModal: модальная форма создания/редактирования (вкладки «Параметры» / «История запусков»); блок каналов доставки — сетка чекбоксов (Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus,
|
|
47
|
+
- ManualTaskModal: модальная форма создания/редактирования (вкладки «Параметры» / «История запусков»); блок каналов доставки — сетка чекбоксов (Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus, Voice, Gitea) и поле шаблона сообщения с подсказкой по переменным.
|
|
46
48
|
- SettingsModal: настройки доставки с тестами; три сворачиваемые секции — «Credentials (references)», «Delivery channels», «Message templates» (шапка-кнопка, aria-expanded, шеврон).
|
|
47
49
|
- DeliverySettingsForm: общая форма настроек доставки, одна реализация для SettingsModal и CronSettingsCard; секреты вводятся только по имени credential-ссылки.
|
|
48
|
-
- TaskItem: строка задачи с переключателем состояния и
|
|
50
|
+
- TaskItem: строка задачи с переключателем состояния и действиями (запуск, редактирование, дублирование, удаление); адресуется атрибутом data-task-id для подсветки из сайдбара.
|
|
51
|
+
- ImportModal: сводка по файлу (сколько добавится, заменится, пропустится) и выбор стратегии add/replace/skip до применения (#42).
|
|
49
52
|
- RecommendationCard: плашка с готовым шаблоном.
|
|
50
53
|
- CronSettingsCard: карточка параметров плагина в настройках; свёрнута по умолчанию, шапка-кнопка с шевроном разворачивает тело (aria-expanded), кнопка «Открыть панель задач» — внутри раскрытого тела (решение 2026-09-10, #100).
|
|
51
54
|
- States:
|
|
@@ -58,12 +61,13 @@
|
|
|
58
61
|
## User Flows
|
|
59
62
|
1. Создание через DSH-чат: «напоминай каждый день в 9 утра...» → «Создать с DSH» → агент уточняет тип (LLM/NO-LLM), расписание, модель, Silent Rule → после подтверждения вызывает cron_create_task → задача появляется на экране.
|
|
60
63
|
2. Создание вручную: кнопка сайдбара → «Создать ⌄» → «Настроить вручную» → форма → сохранение.
|
|
61
|
-
3. Выполнение по расписанию: croner/таймер one-shot → запуск по выбранному рантайму (агентская сессия, shell, node, python, http, ssh, docker) → запись в историю → доставка отчёта в выбранные каналы (Telegram, Kanban, Discord, Slack, ntfy, Bark, PushPlus,
|
|
64
|
+
3. Выполнение по расписанию: croner/таймер one-shot → запуск по выбранному рантайму (агентская сессия, shell, node, python, http, ssh, docker) → запись в историю → доставка отчёта в выбранные каналы (Telegram, Kanban, Discord, Slack, ntfy, Bark, PushPlus, Voice, Gitea) с учётом `onlyOnFailure`.
|
|
62
65
|
4. Разбор инцидента: история запусков в карточке задачи → статус, длительность, вывод/ошибка.
|
|
63
66
|
|
|
64
67
|
## Locked Design Decisions
|
|
65
68
|
- 2026-09-11 — Доставка вынесена в отдельный слой: сообщение рендерится шаблоном `{var}` (#25), транспорт — адаптеры каналов с чистым builder'ом payload и инжектируемым fetch, маршрутизатор собирает ошибки каналов и не роняет запуск (#26, #20–#23, #28, #47). Явно выбранные каналы задачи перекрывают legacy-флаги `notifyTelegram`/`kanbanMode`.
|
|
66
69
|
- 2026-09-11 — Доставка не может заблокировать планировщик: каждый канал ограничен таймаутом (`deliveryTimeoutMs`, по умолчанию 15 с), каналы отправляются параллельно, а ошибки (включая таймаут) собираются в `failures`. Причина: `protect: true` в croner пропускал бы следующие тики, пока висит незавершённая доставка (находка независимого review PR #114).
|
|
70
|
+
- 2026-09-11 — Блок A (0.2.5): список активных задач в сайдбаре (#34), фильтры по типу/модели/каналу (#40), адаптив до 375 px без скрытия информации (#39), дублирование задачи серверным маршрутом с принудительной паузой и сбросом состояния (#41), экспорт/импорт конфигурации задач в JSON со стратегиями и dry-run (#42), нормализация и нижняя граница таймаута доставки (#115). Экспорт — только JSON: YAML-парсера в проекте нет, а зависимость ради формата не добавляется.
|
|
67
71
|
- 2026-09-11 — Значения с секретом внутри (webhook-URL Discord/Slack, ключ Bark) маскируются при отдаче в браузер, а замаскированное значение, вернувшееся от UI, не перезаписывает сохранённое; сырые credential-ключи отклоняются и в store, и на входе `/dsh-cron/settings`.
|
|
68
72
|
- 2026-09-11 — Секреты доставки хранятся только как credential-ссылки (#51): настройки содержат имя credential, значение резолвится в момент отправки через DSH credentials-сервис с фолбэком на ENV; store отказывается сохранять сырые secret-ключи.
|
|
69
73
|
- 2026-09-11 — Каталог данных плагина: `DSH_DATA_DIR` → `DSH_HOME/data` → `~/.dsh/data`. Причина: изолированный профиль не должен писать в чужой домашний каталог (issue #112, найдено на приёмке в тест-контуре).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# План: блок A — пользовательские поверхности (0.2.5)
|
|
2
|
+
|
|
3
|
+
Живой план блока. Обновляется по факту работы; источник истины по задачам —
|
|
4
|
+
Gitea (`goodandready/dsh-cron`), milestone `0.2.5`.
|
|
5
|
+
|
|
6
|
+
## Состав блока (согласован владельцем)
|
|
7
|
+
|
|
8
|
+
| Issue | Задача | Сложность |
|
|
9
|
+
|:--|:--|:--|
|
|
10
|
+
| #34 | Сворачиваемый раздел активных задач в сайдбаре | H |
|
|
11
|
+
| #40 | Поиск и фильтры списка задач | M |
|
|
12
|
+
| #39 | Адаптив под узкие экраны | M |
|
|
13
|
+
| #41 | Клонирование задачи | L |
|
|
14
|
+
| #42 | Экспорт/импорт задач (JSON/YAML) | L |
|
|
15
|
+
| #115 | Поле «Delivery timeout»: сброс и нижняя граница | L |
|
|
16
|
+
|
|
17
|
+
Дальше по согласованию: блок B (0.2.6) — #45, #44, #43, #49, #48;
|
|
18
|
+
блок C (0.2.7) — #54, #50, #53, #97, #121.
|
|
19
|
+
|
|
20
|
+
## Границы и инварианты
|
|
21
|
+
|
|
22
|
+
- Один блок → одна ветка `feat/0.2.5-ui-block` → один PR → один релиз 0.2.5.
|
|
23
|
+
- В релиз 0.2.5 также входит уже смёрженное удаление email-канала (#23, PR #120).
|
|
24
|
+
- Клиентская половина остаётся single-file (`lib/client.js`) — требование загрузчика DSH.
|
|
25
|
+
- Новых зависимостей блок не добавляет; экспорт/импорт использует существующие средства разбора.
|
|
26
|
+
- Публичные экспорты `lib/index.js` и контракты маршрутов не ломаются: новые поля — только additive.
|
|
27
|
+
- Секреты в экспортируемом файле отсутствуют: переносятся лишь имена credential-ссылок.
|
|
28
|
+
|
|
29
|
+
## Вне scope
|
|
30
|
+
|
|
31
|
+
- Серверная авторизация и внешний API (блок C).
|
|
32
|
+
- Изменения в путях исполнения и работе с моделью (блок B).
|
|
33
|
+
- `#117` (мажор croner), `#95` (scope токена), `#113` (инфраструктура тест-контура) — вне блоков.
|
|
34
|
+
|
|
35
|
+
## План проверки блока
|
|
36
|
+
|
|
37
|
+
- `npm test` + новые тесты на каждый пункт (контракт разметки, фильтрация, сериализация, клонирование, клампинг таймаута).
|
|
38
|
+
- `bash deploy.sh check` — тесты, гейт размера пакета, сборка кандидата.
|
|
39
|
+
- Приёмка на изолированном MiniPC: установка кандидата, проверка списка/фильтров/дублей/экспорта-импорта и геометрии на 375/768/1280 в браузере.
|
|
40
|
+
- Независимое ревью PR перед merge.
|
|
41
|
+
|
|
42
|
+
## Журнал
|
|
43
|
+
|
|
44
|
+
- 2026-09-11 — блок согласован (A=0.2.5, B=0.2.6, C=0.2.7), созданы milestones и ТЗ по каждой задаче.
|
|
45
|
+
- 2026-09-11 — реализовано: #115 (клампинг таймаута), #41 (дублирование серверным маршрутом), #42 (экспорт/импорт JSON), #40 (фильтры), #39 (адаптив), #34 (аккордеон в сайдбаре). 123/123 тестов, гейт размера 23 файла.
|
|
46
|
+
- Приёмка на MiniPC (изолированный профиль, кандидат из ветки): дубликат — копия `disk-check (copy)` в статусе paused, настройки скопированы, история и счётчики пусты; экспорт — 4 задачи, поля только конфигурации (нет status/totalTokens/lastRunAt/nextRunAt); dry-run импорта — `{add:0, replace:0, skip:4}`, импорт со стратегией add — 4 задачи, все на паузе; битый документ → 400 без изменений в хранилище. В браузере: секция «Active jobs» свёрнута по умолчанию, раскрывается, показывает 3 активные задачи с временем следующего запуска, состояние сохраняется; клик по задаче открывает панель и подсвечивает её; фильтры — 8 → 6 (type=script) → 2 (+channel=ntfy) с подписью «Showing N of 8» и сбросом; дублирование из строки — 9 задач и подтверждение «copy created and paused»; поле таймаута доставки: ввод 4000 сохраняется, очистка возвращает 15000.
|
|
47
|
+
- Ограничение приёмки: смена ширины окна в текущем браузерном транспорте недоступна, поэтому адаптив (#39) проверен наличием и разбором media-query-правил и тестами, а не визуально на 375 px. Это единственный пункт блока без живой визуальной проверки.
|
|
48
|
+
- 2026-09-11 — после независимого ревью (FAIL): импорт переведён на жёсткий whitelist (статус из файла игнорируется, лишние ключи отбрасываются), добавлен confirm-заголовок для документов с code-задачами, зарезервированы id `export`/`import` на создании и импорте, добавлен откат при частичном сбое записи, сайдбар и панель разведены разными атрибутами, добавлен 16px для полей на телефоне, `mountSidebarJobs` разбит на мелкие функции. Проверено живьём: без заголовка 403, с заголовком задача приходит paused с обнулёнными счётчиками и отброшенными лишними ключами; подсветка из сайдбара указывает на строку панели (`.dsh-cron-container .dsh-cron-task-highlight`). 127/127 тестов.
|
|
49
|
+
- 2026-09-11 — релиз: bump 0.2.4 → 0.2.5, тег v0.2.5, npm publish, GitHub Release, установка точной версии в production web-профиль MiniAI и production-проверки. Закрыты #34, #39, #40, #41, #42, #115.
|
|
50
|
+
- Отклонения от первоначального ТЗ (зафиксированы в issue): #41 — сделан отдельный маршрут вместо клиентской сборки payload (сервер знает, что конфигурация, а что состояние запуска, и это тестируемо); #42 — только JSON вместо JSON/YAML (парсера YAML в проекте нет, зависимость ради формата не добавляется); состояние фильтров не пишется в URL-хеш, чтобы не конфликтовать с роутингом SPA.
|
package/lib/channels.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Delivery channels and router (#26, #20, #21, #22, #
|
|
2
|
+
* Delivery channels and router (#26, #20, #21, #22, #47, #28).
|
|
3
3
|
*
|
|
4
4
|
* Every adapter is split into a pure payload/request builder (unit-testable
|
|
5
5
|
* without network) and a thin send step using an injected fetch. The router
|
|
@@ -11,7 +11,7 @@ import { formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
|
|
|
11
11
|
import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
|
|
12
12
|
import { resolveTemplateText, truncateText } from './templates.js';
|
|
13
13
|
|
|
14
|
-
export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', '
|
|
14
|
+
export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
15
15
|
|
|
16
16
|
export const CHANNEL_LABELS = {
|
|
17
17
|
telegram: 'Telegram',
|
|
@@ -21,7 +21,6 @@ export const CHANNEL_LABELS = {
|
|
|
21
21
|
ntfy: 'ntfy push',
|
|
22
22
|
bark: 'Bark push',
|
|
23
23
|
pushplus: 'PushPlus',
|
|
24
|
-
email: 'Email (SMTP)',
|
|
25
24
|
tts: 'Voice via dsh-tts',
|
|
26
25
|
gitea: 'Gitea issue',
|
|
27
26
|
};
|
|
@@ -51,6 +50,17 @@ export function resolveChannels(task, settings = {}) {
|
|
|
51
50
|
return out;
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Channel ids a task still references that no longer exist (for example the
|
|
55
|
+
* removed `email` channel). They are filtered out of routing, so they must be
|
|
56
|
+
* reported explicitly — a silently dropped notification is worse than a
|
|
57
|
+
* visible failure.
|
|
58
|
+
*/
|
|
59
|
+
export function unknownChannels(task) {
|
|
60
|
+
if (!Array.isArray(task.channels)) return [];
|
|
61
|
+
return task.channels.filter((id) => !CHANNEL_IDS.includes(id));
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
/** Failure/only-on-failure filtering, per channel. */
|
|
55
65
|
export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
|
|
56
66
|
if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
|
|
@@ -139,42 +149,32 @@ export function buildGiteaIssuePayload({ task, runInfo }) {
|
|
|
139
149
|
};
|
|
140
150
|
}
|
|
141
151
|
|
|
142
|
-
export function buildEmailMessage({ settings = {}, text, task, runInfo, password, timeoutMs }) {
|
|
143
|
-
const to = String(settings.smtpTo || '').trim();
|
|
144
|
-
if (!to) throw new Error('smtpTo is not configured');
|
|
145
|
-
const from = String(settings.smtpFrom || settings.smtpUser || '').trim();
|
|
146
|
-
const failed = isFailed(runInfo.status);
|
|
147
|
-
const bound = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
|
|
148
|
-
return {
|
|
149
|
-
from: from || undefined,
|
|
150
|
-
to,
|
|
151
|
-
subject: `${failed ? '❌' : '✅'} ${task.title || 'DSH Cron'} — ${runInfo.status}`,
|
|
152
|
-
text: truncateText(text, 10000),
|
|
153
|
-
transport: {
|
|
154
|
-
host: settings.smtpHost || '',
|
|
155
|
-
port: Number(settings.smtpPort) || 587,
|
|
156
|
-
secure: Boolean(settings.smtpSecure),
|
|
157
|
-
auth: settings.smtpUser ? { user: settings.smtpUser, pass: password || '' } : undefined,
|
|
158
|
-
// Nodemailer has no AbortSignal support: without these a stalled SMTP
|
|
159
|
-
// server would hold the run (defaults are 2–10 minutes).
|
|
160
|
-
connectionTimeout: bound,
|
|
161
|
-
greetingTimeout: bound,
|
|
162
|
-
socketTimeout: bound,
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
152
|
// ------------------------------------------------------------------ sender
|
|
168
153
|
|
|
169
154
|
export const DEFAULT_DELIVERY_TIMEOUT_MS = 15000;
|
|
170
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Lower bound for a per-channel deadline (#115). A stored value of a few
|
|
158
|
+
* milliseconds would fail every delivery before the request could even be sent,
|
|
159
|
+
* which looks like "delivery is broken" rather than a misconfiguration, so the
|
|
160
|
+
* router clamps instead of trusting the setting blindly.
|
|
161
|
+
*/
|
|
162
|
+
export const MIN_DELIVERY_TIMEOUT_MS = 1000;
|
|
163
|
+
|
|
164
|
+
/** Normalise a configured delivery timeout: finite, >= MIN, else the default. */
|
|
165
|
+
export function resolveDeliveryTimeoutMs(value) {
|
|
166
|
+
const n = Number(value);
|
|
167
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_DELIVERY_TIMEOUT_MS;
|
|
168
|
+
return Math.max(MIN_DELIVERY_TIMEOUT_MS, Math.round(n));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
171
|
/**
|
|
172
172
|
* Bound every outbound request. Without this a single unresponsive endpoint
|
|
173
173
|
* blocks the remaining channels and, because the run is awaited inside the
|
|
174
174
|
* croner callback with protect enabled, silently skips subsequent ticks.
|
|
175
175
|
*/
|
|
176
176
|
function deliverySignal(timeoutMs) {
|
|
177
|
-
const ms =
|
|
177
|
+
const ms = resolveDeliveryTimeoutMs(timeoutMs);
|
|
178
178
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
179
179
|
return AbortSignal.timeout(ms);
|
|
180
180
|
}
|
|
@@ -189,13 +189,12 @@ function isAbort(err) {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
|
-
* Hard deadline around one channel's whole work. Signal-based aborts only
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* that keeps every channel bounded regardless of implementation.
|
|
192
|
+
* Hard deadline around one channel's whole work. Signal-based aborts only help
|
|
193
|
+
* where the callee supports AbortSignal (fetch), while credential resolution
|
|
194
|
+
* ignores it — this is the backstop that keeps every channel bounded.
|
|
196
195
|
*/
|
|
197
196
|
function withDeadline(promise, timeoutMs, channelId) {
|
|
198
|
-
const ms =
|
|
197
|
+
const ms = resolveDeliveryTimeoutMs(timeoutMs);
|
|
199
198
|
return new Promise((resolve, reject) => {
|
|
200
199
|
const timer = setTimeout(() => {
|
|
201
200
|
reject(new Error(`${channelId}: timed out after ${ms} ms`));
|
|
@@ -308,23 +307,6 @@ export const CHANNEL_HANDLERS = {
|
|
|
308
307
|
return sendHttp(http, req, 'pushplus', signal, timeoutMs);
|
|
309
308
|
},
|
|
310
309
|
|
|
311
|
-
async email({ task, runInfo, settings, deps, resolveSecret, timeoutMs }) {
|
|
312
|
-
const password = settings.smtpPasswordRef ? await resolveSecret(settings.smtpPasswordRef) : (settings.smtpPassword || '');
|
|
313
|
-
const message = buildEmailMessage({ settings, text: messageTextFor('email', task, runInfo, settings), task, runInfo, password, timeoutMs });
|
|
314
|
-
let createTransport = deps.createTransport;
|
|
315
|
-
if (!createTransport) {
|
|
316
|
-
try {
|
|
317
|
-
const mod = await import('nodemailer');
|
|
318
|
-
createTransport = (mod.default || mod).createTransport;
|
|
319
|
-
} catch {
|
|
320
|
-
throw new Error('email: nodemailer is not installed in the harness (install it or use another channel)');
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
const transport = createTransport(message.transport);
|
|
324
|
-
await transport.sendMail({ from: message.from, to: message.to, subject: message.subject, text: message.text });
|
|
325
|
-
return { to: message.to };
|
|
326
|
-
},
|
|
327
|
-
|
|
328
310
|
async tts({ task, runInfo, settings, http, signal, timeoutMs }) {
|
|
329
311
|
const base = String(settings.ttsBaseUrl || 'http://127.0.0.1:3080').replace(/\/+$/, '');
|
|
330
312
|
const payload = { text: truncateText(messageTextFor('tts', task, runInfo, settings), 600) };
|
|
@@ -354,22 +336,21 @@ export const CHANNEL_HANDLERS = {
|
|
|
354
336
|
|
|
355
337
|
/**
|
|
356
338
|
* Deliver one run to one channel.
|
|
357
|
-
* `resolveSecret(ref)` resolves credential references; `
|
|
358
|
-
*
|
|
339
|
+
* `resolveSecret(ref)` resolves credential references; `fetchFn` is injectable
|
|
340
|
+
* so every channel is testable without network access.
|
|
359
341
|
*/
|
|
360
|
-
export async function sendToChannel({ channelId, task, runInfo, settings = {}, secrets = {}, fetchFn,
|
|
342
|
+
export async function sendToChannel({ channelId, task, runInfo, settings = {}, secrets = {}, fetchFn, timeoutMs = DEFAULT_DELIVERY_TIMEOUT_MS }) {
|
|
361
343
|
const handler = CHANNEL_HANDLERS[channelId];
|
|
362
344
|
if (!handler) throw new Error(`unknown channel: ${channelId}`);
|
|
363
345
|
const resolveSecret = typeof secrets.resolveSecret === 'function' ? secrets.resolveSecret : async () => null;
|
|
364
346
|
const http = fetchFn || globalThis.fetch;
|
|
365
|
-
const effectiveTimeout =
|
|
347
|
+
const effectiveTimeout = resolveDeliveryTimeoutMs(timeoutMs);
|
|
366
348
|
const work = handler({
|
|
367
349
|
task,
|
|
368
350
|
runInfo,
|
|
369
351
|
settings,
|
|
370
352
|
secrets,
|
|
371
353
|
http,
|
|
372
|
-
deps,
|
|
373
354
|
resolveSecret,
|
|
374
355
|
signal: deliverySignal(effectiveTimeout),
|
|
375
356
|
timeoutMs: effectiveTimeout,
|
|
@@ -386,10 +367,8 @@ export async function sendToChannel({ channelId, task, runInfo, settings = {}, s
|
|
|
386
367
|
* hold the cron callback open. Never throws: per-channel failures are returned
|
|
387
368
|
* in `failures`.
|
|
388
369
|
*/
|
|
389
|
-
export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, fetchFn
|
|
390
|
-
const timeoutMs =
|
|
391
|
-
? Number(settings.deliveryTimeoutMs)
|
|
392
|
-
: DEFAULT_DELIVERY_TIMEOUT_MS;
|
|
370
|
+
export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, fetchFn }) {
|
|
371
|
+
const timeoutMs = resolveDeliveryTimeoutMs(settings.deliveryTimeoutMs);
|
|
393
372
|
|
|
394
373
|
let channels = [];
|
|
395
374
|
try {
|
|
@@ -403,7 +382,7 @@ export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, f
|
|
|
403
382
|
if (!shouldSendToChannel(channelId, task, runInfo, settings)) {
|
|
404
383
|
return { channel: channelId, skipped: true };
|
|
405
384
|
}
|
|
406
|
-
const detail = await sendToChannel({ channelId, task, runInfo, settings, secrets, fetchFn,
|
|
385
|
+
const detail = await sendToChannel({ channelId, task, runInfo, settings, secrets, fetchFn, timeoutMs });
|
|
407
386
|
return { channel: channelId, detail: detail || null };
|
|
408
387
|
} catch (err) {
|
|
409
388
|
return { channel: channelId, error: (err && err.message) || String(err) };
|
|
@@ -413,6 +392,17 @@ export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, f
|
|
|
413
392
|
const delivered = [];
|
|
414
393
|
const skipped = [];
|
|
415
394
|
const failures = [];
|
|
395
|
+
// Report ids that no longer exist before the per-channel results, so a task
|
|
396
|
+
// still carrying a removed channel fails loudly instead of silently.
|
|
397
|
+
let removed = [];
|
|
398
|
+
try {
|
|
399
|
+
removed = unknownChannels(task);
|
|
400
|
+
} catch {
|
|
401
|
+
removed = [];
|
|
402
|
+
}
|
|
403
|
+
for (const channelId of removed) {
|
|
404
|
+
failures.push({ channel: channelId, error: `unknown channel: ${channelId}` });
|
|
405
|
+
}
|
|
416
406
|
for (const outcome of outcomes) {
|
|
417
407
|
if (outcome.skipped) skipped.push(outcome.channel);
|
|
418
408
|
else if (outcome.error) failures.push({ channel: outcome.channel, error: outcome.error });
|