@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 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-featured panel to inspect, filter, pause, trigger, and create recurring tasks.
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, email, voice (`dsh-tts`) and Gitea, with `{variable}` message templates and secrets referenced by DSH credential name.
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 + 10 channels)"]
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, email (SMTP), voice via `dsh-tts`, and Gitea issues:
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, SMTP passwords and the Telegram bot token are entered as the NAME of a DSH credential (`botTokenRef`, `ntfyTokenRef`, `pushplusTokenRef`, `smtpPasswordRef`, `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 and the SMTP transport (`connectionTimeout`/`greetingTimeout`/`socketTimeout`), none of which support abort signals.
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, email, голос (`dsh-tts`) и Gitea, с шаблонами сообщений `{переменные}` и секретами по имени credential в DSH.
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/>(шаблоны + 10 каналов)"]
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, email (SMTP), голос через `dsh-tts` и issue в Gitea:
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'ов, пароль SMTP и токен Telegram вводятся как ИМЯ credential в DSH (`botTokenRef`, `ntfyTokenRef`, `pushplusTokenRef`, `smtpPasswordRef`, `giteaTokenRef`); значение резолвится в момент отправки через credentials-сервис DSH с фолбэком на переменную окружения и никогда не проходит через настройки плагина. Webhook-URL и ключ устройства Bark содержат секрет внутри, поэтому хранятся в настройках плагина, но всегда отдаются в браузер замаскированными, а замаскированное значение из UI никогда не перезаписывает сохранённое.
162
- * **Таймаут доставки** — каждый запрос канала ограничен (`deliveryTimeoutMs`, по умолчанию 15000 мс, задаётся в панели настроек или `settings.yaml`), каналы отправляются параллельно: недоступный endpoint фиксируется как сбой и не задерживает остальные каналы и следующий тик расписания. Ограничение действует на весь обработчик канала, включая резолв credential'ов и SMTP-транспорт (`connectionTimeout`/`greetingTimeout`/`socketTimeout`), которые не поддерживают abort-сигнал.
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、邮件、语音(`dsh-tts`)与 Gitea,支持 `{变量}` 消息模板与按 DSH 凭据名称引用的密钥。
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/>(模板 + 10 个渠道)"]
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、邮件(SMTP)、语音(`dsh-tts`)以及 Gitea issue:
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、SMTP 密码与 Telegram bot token 填写 DSH 凭据的名称(`botTokenRef`、`ntfyTokenRef`、`pushplusTokenRef`、`smtpPasswordRef`、`giteaTokenRef`),发送时通过 DSH credentials 服务解析,并可回退到环境变量,且绝不会经过插件设置。webhook URL 与 Bark 设备键本身内嵌密钥,因此保存在插件设置文件中,但返回浏览器时始终为掩码,界面回传的掩码值也不会覆盖已保存的值。
162
- * **投递超时** —— 每个渠道请求都有上限(`deliveryTimeoutMs`,默认 15000 毫秒,可在设置面板或 `settings.yaml` 中调整),且各渠道并发发送:无响应的端点只记录为失败,不会拖慢其他渠道或下一次调度。限制作用于整个渠道处理过程,也覆盖凭据解析与 SMTP 传输(`connectionTimeout`/`greetingTimeout`/`socketTimeout`)——这些都不支持 abort 信号。
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>` 列出模型 |
@@ -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, топики, поля SMTP, base URL) и `channelTemplates` — карта шаблонов по каналам.
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, Email, Voice, Gitea) и поле шаблона сообщения с подсказкой по переменным.
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, Email, Voice, Gitea) с учётом `onlyOnFailure`.
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, #23, #47, #28).
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', 'email', 'tts', 'gitea'];
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 = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_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
- * help where the callee supports AbortSignal (fetch), while credential
194
- * resolution, SMTP and any injected transport ignore it — this is the backstop
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 = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_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; `deps.createTransport`
358
- * is an injectable nodemailer-compatible factory for tests.
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, deps = {}, timeoutMs = DEFAULT_DELIVERY_TIMEOUT_MS }) {
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 = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
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, deps = {} }) {
390
- const timeoutMs = Number(settings.deliveryTimeoutMs) > 0
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, deps, timeoutMs });
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 });