@goodandready/dsh-cron 0.2.2 → 0.2.4

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.
@@ -3,7 +3,7 @@
3
3
  ## Product / Purpose
4
4
  - Назначение: Интегрированный планировщик cron-задач и фоновой автоматизации для DeepSeek Harness. Позволяет запускать агентские сессии по расписанию, выполнять автоматические проверки и предоставлять визуальный интерфейс управления задачами.
5
5
  - Аудитория: Пользователи и операторы DeepSeek Harness, автоматизирующие периодические процессы (утренние сводки, проверка тикетов, мониторинг серверов).
6
- - Статус: Active, публичный npm-пакет @goodandready/dsh-cron (текущая линия 0.1.x → 0.2.0).
6
+ - Статус: Active, публичный npm-пакет @goodandready/dsh-cron (линия 0.2.x).
7
7
 
8
8
  ## User Surfaces
9
9
  - Web/UI:
@@ -11,14 +11,14 @@
11
11
  - Кнопка вызова в боковой панели (sidebar-entry) рядом с новой сессией + иконка в шапке сессии (utilities slot).
12
12
  - Кнопка «Создать ⌄» с дропдауном:
13
13
  - 💬 «Создать с DSH» (запуск интерактивного диалога постановки задачи агенту).
14
- - ✏️ «Настроить вручную» (модальная форма: тип, расписание, таймаут, overlap, промпт, модель, уведомления).
14
+ - ✏️ «Настроить вручную» (модальная форма: тип и параметры рантайма, расписание, таймаут, overlap, промпт, модель, каналы доставки, шаблон сообщения).
15
15
  - Табы фильтрации: «Все», «Активные», «На паузе», «Завершённые».
16
16
  - Поисковая строка; сводная статистика (активные задачи, запуски, токены, стоимость).
17
17
  - Карточки задач: статус-переключатель, название, расписание (человекочитаемое + raw cron), действия (запуск, редактирование, удаление).
18
18
  - Блок «Рекомендуемые задачи»: готовые шаблоны (Daily digest, Weekly review, Follow-up monitor) в один клик.
19
19
  - Карточка настроек в слоте settings.plugin.item, key = namespace `dsh-cron`; отдельный раздел настроек не используется (#102).
20
- - LLM Tools: cron_create_task (+ alias cron_schedule_task), cron_list_tasks, cron_pause_task, cron_resume_task, cron_delete_task, cron_run_task.
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.
20
+ - 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` — карта шаблонов по каналам.
22
22
  - Chat / Slash Commands: отсутствуют (ранее заявленные /cron-команды не были реализованы и удалены из документации; решение 2026-09-09).
23
23
 
24
24
  ## Visual Direction
@@ -42,8 +42,9 @@
42
42
  - CronSidebarButton: кнопка в левом сайдбаре DSH.
43
43
  - CronScreen: основной оверлей со списком, табами, статистикой и рекомендациями.
44
44
  - CreateDropdown: всплывающее меню выбора способа создания.
45
- - ManualTaskModal: модальная форма создания/редактирования (вкладки «Параметры» / «История запусков»).
46
- - SettingsModal: настройки Telegram/Kanban с тестами доставки.
45
+ - ManualTaskModal: модальная форма создания/редактирования (вкладки «Параметры» / «История запусков»); блок каналов доставки — сетка чекбоксов (Telegram, dsh-kanban, Discord, Slack, ntfy, Bark, PushPlus, Email, Voice, Gitea) и поле шаблона сообщения с подсказкой по переменным.
46
+ - SettingsModal: настройки доставки с тестами; три сворачиваемые секции — «Credentials (references)», «Delivery channels», «Message templates» (шапка-кнопка, aria-expanded, шеврон).
47
+ - DeliverySettingsForm: общая форма настроек доставки, одна реализация для SettingsModal и CronSettingsCard; секреты вводятся только по имени credential-ссылки.
47
48
  - TaskItem: строка задачи с переключателем состояния и действиями.
48
49
  - RecommendationCard: плашка с готовым шаблоном.
49
50
  - CronSettingsCard: карточка параметров плагина в настройках; свёрнута по умолчанию, шапка-кнопка с шевроном разворачивает тело (aria-expanded), кнопка «Открыть панель задач» — внутри раскрытого тела (решение 2026-09-10, #100).
@@ -57,10 +58,15 @@
57
58
  ## User Flows
58
59
  1. Создание через DSH-чат: «напоминай каждый день в 9 утра...» → «Создать с DSH» → агент уточняет тип (LLM/NO-LLM), расписание, модель, Silent Rule → после подтверждения вызывает cron_create_task → задача появляется на экране.
59
60
  2. Создание вручную: кнопка сайдбара → «Создать ⌄» → «Настроить вручную» → форма → сохранение.
60
- 3. Выполнение по расписанию: croner/таймер one-shot → запуск shell-команды или изолированной агентской сессии → запись в историю → доставка отчёта (Telegram/Kanban по настройкам).
61
+ 3. Выполнение по расписанию: croner/таймер one-shot → запуск по выбранному рантайму (агентская сессия, shell, node, python, http, ssh, docker) → запись в историю → доставка отчёта в выбранные каналы (Telegram, Kanban, Discord, Slack, ntfy, Bark, PushPlus, Email, Voice, Gitea) с учётом `onlyOnFailure`.
61
62
  4. Разбор инцидента: история запусков в карточке задачи → статус, длительность, вывод/ошибка.
62
63
 
63
64
  ## Locked Design Decisions
65
+ - 2026-09-11 — Доставка вынесена в отдельный слой: сообщение рендерится шаблоном `{var}` (#25), транспорт — адаптеры каналов с чистым builder'ом payload и инжектируемым fetch, маршрутизатор собирает ошибки каналов и не роняет запуск (#26, #20–#23, #28, #47). Явно выбранные каналы задачи перекрывают legacy-флаги `notifyTelegram`/`kanbanMode`.
66
+ - 2026-09-11 — Доставка не может заблокировать планировщик: каждый канал ограничен таймаутом (`deliveryTimeoutMs`, по умолчанию 15 с), каналы отправляются параллельно, а ошибки (включая таймаут) собираются в `failures`. Причина: `protect: true` в croner пропускал бы следующие тики, пока висит незавершённая доставка (находка независимого review PR #114).
67
+ - 2026-09-11 — Значения с секретом внутри (webhook-URL Discord/Slack, ключ Bark) маскируются при отдаче в браузер, а замаскированное значение, вернувшееся от UI, не перезаписывает сохранённое; сырые credential-ключи отклоняются и в store, и на входе `/dsh-cron/settings`.
68
+ - 2026-09-11 — Секреты доставки хранятся только как credential-ссылки (#51): настройки содержат имя credential, значение резолвится в момент отправки через DSH credentials-сервис с фолбэком на ENV; store отказывается сохранять сырые secret-ключи.
69
+ - 2026-09-11 — Каталог данных плагина: `DSH_DATA_DIR` → `DSH_HOME/data` → `~/.dsh/data`. Причина: изолированный профиль не должен писать в чужой домашний каталог (issue #112, найдено на приёмке в тест-контуре).
64
70
  - 2026-09-09 — Пакет надёжности ядра (v0.1.24): буфер shell-задач 10МБ, атомарное сохранение с PID, аудит пропущенных запусков при рестарте (missed), фоновый поллинг UI (8с).
65
71
  - 2026-09-03 — Публичный скоуп @goodandready/dsh-cron; оверлей через mountSidebarEntry/mountScreen аналогично dsh-kanban; двойная кнопка «Создать ⌄».
66
72
  - 2026-09-09 — Слот карточки настроек: settings.plugin.item с key/namespace `dsh-cron` (совпадение с серверной регистрацией). Причина: контракт слота настроек (#85). Changed 2026-09-10 (#102): settings.section fallback удалён — карточка только во вкладке плагинов.
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Delivery channels and router (#26, #20, #21, #22, #23, #47, #28).
3
+ *
4
+ * Every adapter is split into a pure payload/request builder (unit-testable
5
+ * without network) and a thin send step using an injected fetch. The router
6
+ * decides which channels a run goes to and never throws: failures are
7
+ * collected so one broken channel cannot hide another.
8
+ */
9
+
10
+ import { formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
11
+ import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
12
+ import { resolveTemplateText, truncateText } from './templates.js';
13
+
14
+ export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'email', 'tts', 'gitea'];
15
+
16
+ export const CHANNEL_LABELS = {
17
+ telegram: 'Telegram',
18
+ kanban: 'dsh-kanban card',
19
+ discord: 'Discord webhook',
20
+ slack: 'Slack webhook',
21
+ ntfy: 'ntfy push',
22
+ bark: 'Bark push',
23
+ pushplus: 'PushPlus',
24
+ email: 'Email (SMTP)',
25
+ tts: 'Voice via dsh-tts',
26
+ gitea: 'Gitea issue',
27
+ };
28
+
29
+ const isFailed = (status) => status === 'error' || status === 'timeout';
30
+
31
+ /** The message text for a channel: custom template, else built-in defaults. */
32
+ export function messageTextFor(channelId, task, runInfo, settings = {}) {
33
+ const templates = (settings && settings.channelTemplates) || {};
34
+ const template = templates[channelId] || settings.template || '';
35
+ return resolveTemplateText({ template, task, runInfo });
36
+ }
37
+
38
+ /**
39
+ * Which channels deliver a given run.
40
+ * Explicit per-task channels win; otherwise the legacy flags decide.
41
+ */
42
+ export function resolveChannels(task, settings = {}) {
43
+ if (Array.isArray(task.channels)) {
44
+ const explicit = task.channels.filter((id) => CHANNEL_IDS.includes(id));
45
+ if (explicit.length) return explicit;
46
+ }
47
+ const out = [];
48
+ const notify = task.notifyTelegram !== undefined ? task.notifyTelegram : settings.notifyTelegram;
49
+ if (notify) out.push('telegram');
50
+ if ((task.kanbanMode || 'none') !== 'none') out.push('kanban');
51
+ return out;
52
+ }
53
+
54
+ /** Failure/only-on-failure filtering, per channel. */
55
+ export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
56
+ if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
57
+ const onlyOnFail = task.onlyOnFailure !== undefined
58
+ ? Boolean(task.onlyOnFailure)
59
+ : Boolean(settings.onlyOnFailure);
60
+ if (onlyOnFail && !isFailed(runInfo.status)) return false;
61
+ return true;
62
+ }
63
+
64
+ // ---------------------------------------------------------------- builders
65
+
66
+ export function buildDiscordPayload({ text, task, runInfo }) {
67
+ const failed = isFailed(runInfo.status);
68
+ return {
69
+ content: truncateText(text, 1900),
70
+ embeds: [{
71
+ title: `${failed ? '❌' : '✅'} ${truncateText(task.title || 'Task', 200)}`,
72
+ description: truncateText(failed ? (runInfo.error || '') : (runInfo.output || ''), 1900) || undefined,
73
+ color: failed ? 0xef4444 : 0x10b981,
74
+ footer: { text: truncateText(task.scheduleText || task.schedule || '', 200) },
75
+ timestamp: new Date(runInfo.at || Date.now()).toISOString(),
76
+ }],
77
+ };
78
+ }
79
+
80
+ export function buildSlackPayload({ text }) {
81
+ return { text: truncateText(text, 3000) };
82
+ }
83
+
84
+ export function buildNtfyRequest({ settings = {}, text, task, token }) {
85
+ const base = String(settings.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
86
+ const topic = String(settings.ntfyTopic || '').trim();
87
+ if (!topic) throw new Error('ntfyTopic is not configured');
88
+ const headers = {
89
+ Title: truncateText(task && task.title ? task.title : 'DSH Cron', 120),
90
+ Tags: isFailed(task && task.lastStatus) ? 'warning' : 'robot',
91
+ 'Content-Type': 'text/plain; charset=utf-8',
92
+ };
93
+ if (token) headers.Authorization = `Bearer ${token}`;
94
+ return { url: `${base}/${encodeURIComponent(topic)}`, method: 'POST', headers, body: truncateText(text, 4000) };
95
+ }
96
+
97
+ export function buildBarkRequest({ settings = {}, text, task }) {
98
+ const server = String(settings.barkServerUrl || 'https://api.day.app').replace(/\/+$/, '');
99
+ const key = String(settings.barkKey || '').trim();
100
+ if (!key) throw new Error('barkKey is not configured');
101
+ const title = truncateText(task && task.title ? task.title : 'DSH Cron', 100);
102
+ return {
103
+ url: `${server}/${encodeURIComponent(key)}/${encodeURIComponent(title)}/${encodeURIComponent(truncateText(text, 1200))}`,
104
+ method: 'GET',
105
+ headers: {},
106
+ };
107
+ }
108
+
109
+ export function buildPushplusRequest({ settings = {}, text, task, token }) {
110
+ const effective = token || settings.pushplusToken || '';
111
+ if (!effective) throw new Error('pushplus token is not configured');
112
+ const url = String(settings.pushplusUrl || 'https://www.pushplus.plus/send').trim();
113
+ return {
114
+ url,
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({
118
+ token: effective,
119
+ title: truncateText(task && task.title ? task.title : 'DSH Cron', 100),
120
+ content: truncateText(text, 4000),
121
+ template: 'markdown',
122
+ }),
123
+ };
124
+ }
125
+
126
+ export function buildGiteaIssuePayload({ task, runInfo }) {
127
+ const failed = isFailed(runInfo.status);
128
+ return {
129
+ title: `${failed ? '[Cron failure]' : '[Cron run]'} ${truncateText(task.title || 'Task', 200)}`,
130
+ body: [
131
+ `**Cron task:** ${task.title} (\`${task.id}\`)`,
132
+ `**Schedule:** ${task.scheduleText || task.schedule || ''}`,
133
+ `**Status:** ${runInfo.status}`,
134
+ `**Started at:** ${new Date(runInfo.at || Date.now()).toISOString()}`,
135
+ runInfo.error ? `\n**Error:**\n\`\`\`\n${truncateText(runInfo.error, 1000)}\n\`\`\`` : '',
136
+ runInfo.output ? `\n**Output:**\n\`\`\`\n${truncateText(runInfo.output, 1000)}\n\`\`\`` : '',
137
+ ].filter(Boolean).join('\n\n'),
138
+ labels: failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
139
+ };
140
+ }
141
+
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
+ // ------------------------------------------------------------------ sender
168
+
169
+ export const DEFAULT_DELIVERY_TIMEOUT_MS = 15000;
170
+
171
+ /**
172
+ * Bound every outbound request. Without this a single unresponsive endpoint
173
+ * blocks the remaining channels and, because the run is awaited inside the
174
+ * croner callback with protect enabled, silently skips subsequent ticks.
175
+ */
176
+ function deliverySignal(timeoutMs) {
177
+ const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
178
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
179
+ return AbortSignal.timeout(ms);
180
+ }
181
+ const controller = new AbortController();
182
+ const timer = setTimeout(() => controller.abort(), ms);
183
+ if (typeof timer.unref === 'function') timer.unref();
184
+ return controller.signal;
185
+ }
186
+
187
+ function isAbort(err) {
188
+ return Boolean(err) && (err.name === 'AbortError' || err.name === 'TimeoutError');
189
+ }
190
+
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.
196
+ */
197
+ function withDeadline(promise, timeoutMs, channelId) {
198
+ const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
199
+ return new Promise((resolve, reject) => {
200
+ const timer = setTimeout(() => {
201
+ reject(new Error(`${channelId}: timed out after ${ms} ms`));
202
+ }, ms);
203
+ Promise.resolve(promise).then(
204
+ (value) => { clearTimeout(timer); resolve(value); },
205
+ (err) => { clearTimeout(timer); reject(err); },
206
+ );
207
+ });
208
+ }
209
+
210
+ async function sendHttp(fetchFn, request, channelId, signal, timeoutMs) {
211
+ let res;
212
+ try {
213
+ res = await fetchFn(request.url, {
214
+ method: request.method,
215
+ headers: request.headers,
216
+ body: request.body,
217
+ signal,
218
+ });
219
+ } catch (err) {
220
+ if (isAbort(err)) throw new Error(`${channelId}: timed out after ${timeoutMs} ms`);
221
+ throw err;
222
+ }
223
+ if (!res || !res.ok) {
224
+ const status = res && res.status ? res.status : 'no-response';
225
+ throw new Error(`${channelId}: HTTP ${status}`);
226
+ }
227
+ return { status: res.status };
228
+ }
229
+
230
+ /** POST/PUT style JSON request helper used by the tts and gitea channels. */
231
+ async function postJson(http, url, headers, body, channelId, signal, timeoutMs) {
232
+ let res;
233
+ try {
234
+ res = await http(url, { method: 'POST', headers, body, signal });
235
+ } catch (err) {
236
+ if (isAbort(err)) throw new Error(`${channelId}: timed out after ${timeoutMs} ms`);
237
+ throw err;
238
+ }
239
+ if (!res || !res.ok) throw new Error(`${channelId}: HTTP ${res && res.status ? res.status : 'no-response'}`);
240
+ return res.json().catch(() => ({}));
241
+ }
242
+
243
+ /**
244
+ * One handler per channel. Each returns a small detail object and throws a
245
+ * descriptive Error on failure; the router turns that into a failure entry.
246
+ */
247
+ export const CHANNEL_HANDLERS = {
248
+ async telegram({ task, runInfo, settings, secrets, http, signal, timeoutMs }) {
249
+ const botToken = secrets.botToken || '';
250
+ const chatId = String(settings.chatId || '').trim();
251
+ if (!botToken || !chatId) throw new Error('telegram: botToken or chatId is not configured');
252
+ const text = settings.template
253
+ ? messageTextFor('telegram', task, runInfo, settings)
254
+ : formatTaskTelegramMessage(task, runInfo);
255
+ try {
256
+ await sendTelegramMessage({ botToken, chatId, text, fetchFn: http, signal });
257
+ } catch (err) {
258
+ if (isAbort(err)) throw new Error(`telegram: timed out after ${timeoutMs} ms`);
259
+ throw err;
260
+ }
261
+ return { chatId };
262
+ },
263
+
264
+ async kanban({ task, runInfo, settings, http, signal }) {
265
+ const isErr = isFailed(runInfo.status);
266
+ const result = await createKanbanCard({
267
+ title: `${isErr ? '[Cron failure]' : '[Cron completed]'} ${task.title}`,
268
+ body: buildGiteaIssuePayload({ task, runInfo }).body,
269
+ board: 'main',
270
+ column: isErr ? 'backlog' : 'done',
271
+ labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
272
+ kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000',
273
+ fetchFn: http,
274
+ signal,
275
+ });
276
+ if (!result.success) throw new Error(`kanban: ${result.error}`);
277
+ return { column: isErr ? 'backlog' : 'done' };
278
+ },
279
+
280
+ async discord({ task, runInfo, settings, http, signal, timeoutMs }) {
281
+ const url = String(settings.discordWebhookUrl || '').trim();
282
+ if (!url) throw new Error('discord: discordWebhookUrl is not configured');
283
+ const payload = buildDiscordPayload({ text: messageTextFor('discord', task, runInfo, settings), task, runInfo });
284
+ return sendHttp(http, { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, 'discord', signal, timeoutMs);
285
+ },
286
+
287
+ async slack({ task, runInfo, settings, http, signal, timeoutMs }) {
288
+ const url = String(settings.slackWebhookUrl || '').trim();
289
+ if (!url) throw new Error('slack: slackWebhookUrl is not configured');
290
+ const payload = buildSlackPayload({ text: messageTextFor('slack', task, runInfo, settings) });
291
+ return sendHttp(http, { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, 'slack', signal, timeoutMs);
292
+ },
293
+
294
+ async ntfy({ task, runInfo, settings, secrets, http, signal, timeoutMs, resolveSecret }) {
295
+ const token = settings.ntfyTokenRef ? await resolveSecret(settings.ntfyTokenRef) : null;
296
+ const req = buildNtfyRequest({ settings, text: messageTextFor('ntfy', task, runInfo, settings), task, token });
297
+ return sendHttp(http, req, 'ntfy', signal, timeoutMs);
298
+ },
299
+
300
+ async bark({ task, runInfo, settings, http, signal, timeoutMs }) {
301
+ const req = buildBarkRequest({ settings, text: messageTextFor('bark', task, runInfo, settings), task });
302
+ return sendHttp(http, req, 'bark', signal, timeoutMs);
303
+ },
304
+
305
+ async pushplus({ task, runInfo, settings, secrets, http, signal, timeoutMs, resolveSecret }) {
306
+ const token = settings.pushplusTokenRef ? await resolveSecret(settings.pushplusTokenRef) : null;
307
+ const req = buildPushplusRequest({ settings, text: messageTextFor('pushplus', task, runInfo, settings), task, token });
308
+ return sendHttp(http, req, 'pushplus', signal, timeoutMs);
309
+ },
310
+
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
+ async tts({ task, runInfo, settings, http, signal, timeoutMs }) {
329
+ const base = String(settings.ttsBaseUrl || 'http://127.0.0.1:3080').replace(/\/+$/, '');
330
+ const payload = { text: truncateText(messageTextFor('tts', task, runInfo, settings), 600) };
331
+ const data = await postJson(http, `${base}/dsh-tts/speak`, { 'Content-Type': 'application/json' }, JSON.stringify(payload), 'tts', signal, timeoutMs);
332
+ return { provider: data.provider || 'dsh-tts', tookMs: data.tookMs || null };
333
+ },
334
+
335
+ async gitea({ task, runInfo, settings, http, signal, timeoutMs, resolveSecret }) {
336
+ const base = String(settings.giteaBaseUrl || '').replace(/\/+$/, '');
337
+ const repo = String(settings.giteaRepo || '').trim();
338
+ if (!base || !repo) throw new Error('gitea: giteaBaseUrl and giteaRepo are required');
339
+ const token = settings.giteaTokenRef ? await resolveSecret(settings.giteaTokenRef) : '';
340
+ if (!token) throw new Error(`gitea: credential "${settings.giteaTokenRef || ''}" did not resolve`);
341
+ const payload = buildGiteaIssuePayload({ task, runInfo });
342
+ const issue = await postJson(
343
+ http,
344
+ `${base}/api/v1/repos/${repo}/issues`,
345
+ { 'Content-Type': 'application/json', Authorization: `token ${token}` },
346
+ JSON.stringify(payload),
347
+ 'gitea',
348
+ signal,
349
+ timeoutMs,
350
+ );
351
+ return { number: issue.number || null };
352
+ },
353
+ };
354
+
355
+ /**
356
+ * Deliver one run to one channel.
357
+ * `resolveSecret(ref)` resolves credential references; `deps.createTransport`
358
+ * is an injectable nodemailer-compatible factory for tests.
359
+ */
360
+ export async function sendToChannel({ channelId, task, runInfo, settings = {}, secrets = {}, fetchFn, deps = {}, timeoutMs = DEFAULT_DELIVERY_TIMEOUT_MS }) {
361
+ const handler = CHANNEL_HANDLERS[channelId];
362
+ if (!handler) throw new Error(`unknown channel: ${channelId}`);
363
+ const resolveSecret = typeof secrets.resolveSecret === 'function' ? secrets.resolveSecret : async () => null;
364
+ const http = fetchFn || globalThis.fetch;
365
+ const effectiveTimeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
366
+ const work = handler({
367
+ task,
368
+ runInfo,
369
+ settings,
370
+ secrets,
371
+ http,
372
+ deps,
373
+ resolveSecret,
374
+ signal: deliverySignal(effectiveTimeout),
375
+ timeoutMs: effectiveTimeout,
376
+ });
377
+ return withDeadline(work, effectiveTimeout, channelId);
378
+ }
379
+
380
+ /**
381
+ * Deliver a finished run to every applicable channel.
382
+ *
383
+ * Channels are dispatched concurrently and every channel is individually
384
+ * bounded (AbortSignal for fetch-based channels plus a hard deadline around the
385
+ * whole handler), so a slow endpoint cannot delay the rest of the report nor
386
+ * hold the cron callback open. Never throws: per-channel failures are returned
387
+ * in `failures`.
388
+ */
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;
393
+
394
+ let channels = [];
395
+ try {
396
+ channels = resolveChannels(task, settings);
397
+ } catch (err) {
398
+ return { channels: [], delivered: [], skipped: [], failures: [{ channel: 'router', error: (err && err.message) || String(err) }] };
399
+ }
400
+
401
+ const outcomes = await Promise.all(channels.map(async (channelId) => {
402
+ try {
403
+ if (!shouldSendToChannel(channelId, task, runInfo, settings)) {
404
+ return { channel: channelId, skipped: true };
405
+ }
406
+ const detail = await sendToChannel({ channelId, task, runInfo, settings, secrets, fetchFn, deps, timeoutMs });
407
+ return { channel: channelId, detail: detail || null };
408
+ } catch (err) {
409
+ return { channel: channelId, error: (err && err.message) || String(err) };
410
+ }
411
+ }));
412
+
413
+ const delivered = [];
414
+ const skipped = [];
415
+ const failures = [];
416
+ for (const outcome of outcomes) {
417
+ if (outcome.skipped) skipped.push(outcome.channel);
418
+ else if (outcome.error) failures.push({ channel: outcome.channel, error: outcome.error });
419
+ else delivered.push({ channel: outcome.channel, detail: outcome.detail });
420
+ }
421
+ return { channels, delivered, skipped, failures };
422
+ }