@goodandready/dsh-cron 0.2.3 → 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.
@@ -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.
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Delivery channels and router (#26, #20, #21, #22, #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', '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
+ tts: 'Voice via dsh-tts',
25
+ gitea: 'Gitea issue',
26
+ };
27
+
28
+ const isFailed = (status) => status === 'error' || status === 'timeout';
29
+
30
+ /** The message text for a channel: custom template, else built-in defaults. */
31
+ export function messageTextFor(channelId, task, runInfo, settings = {}) {
32
+ const templates = (settings && settings.channelTemplates) || {};
33
+ const template = templates[channelId] || settings.template || '';
34
+ return resolveTemplateText({ template, task, runInfo });
35
+ }
36
+
37
+ /**
38
+ * Which channels deliver a given run.
39
+ * Explicit per-task channels win; otherwise the legacy flags decide.
40
+ */
41
+ export function resolveChannels(task, settings = {}) {
42
+ if (Array.isArray(task.channels)) {
43
+ const explicit = task.channels.filter((id) => CHANNEL_IDS.includes(id));
44
+ if (explicit.length) return explicit;
45
+ }
46
+ const out = [];
47
+ const notify = task.notifyTelegram !== undefined ? task.notifyTelegram : settings.notifyTelegram;
48
+ if (notify) out.push('telegram');
49
+ if ((task.kanbanMode || 'none') !== 'none') out.push('kanban');
50
+ return out;
51
+ }
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
+
64
+ /** Failure/only-on-failure filtering, per channel. */
65
+ export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
66
+ if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
67
+ const onlyOnFail = task.onlyOnFailure !== undefined
68
+ ? Boolean(task.onlyOnFailure)
69
+ : Boolean(settings.onlyOnFailure);
70
+ if (onlyOnFail && !isFailed(runInfo.status)) return false;
71
+ return true;
72
+ }
73
+
74
+ // ---------------------------------------------------------------- builders
75
+
76
+ export function buildDiscordPayload({ text, task, runInfo }) {
77
+ const failed = isFailed(runInfo.status);
78
+ return {
79
+ content: truncateText(text, 1900),
80
+ embeds: [{
81
+ title: `${failed ? '❌' : '✅'} ${truncateText(task.title || 'Task', 200)}`,
82
+ description: truncateText(failed ? (runInfo.error || '') : (runInfo.output || ''), 1900) || undefined,
83
+ color: failed ? 0xef4444 : 0x10b981,
84
+ footer: { text: truncateText(task.scheduleText || task.schedule || '', 200) },
85
+ timestamp: new Date(runInfo.at || Date.now()).toISOString(),
86
+ }],
87
+ };
88
+ }
89
+
90
+ export function buildSlackPayload({ text }) {
91
+ return { text: truncateText(text, 3000) };
92
+ }
93
+
94
+ export function buildNtfyRequest({ settings = {}, text, task, token }) {
95
+ const base = String(settings.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
96
+ const topic = String(settings.ntfyTopic || '').trim();
97
+ if (!topic) throw new Error('ntfyTopic is not configured');
98
+ const headers = {
99
+ Title: truncateText(task && task.title ? task.title : 'DSH Cron', 120),
100
+ Tags: isFailed(task && task.lastStatus) ? 'warning' : 'robot',
101
+ 'Content-Type': 'text/plain; charset=utf-8',
102
+ };
103
+ if (token) headers.Authorization = `Bearer ${token}`;
104
+ return { url: `${base}/${encodeURIComponent(topic)}`, method: 'POST', headers, body: truncateText(text, 4000) };
105
+ }
106
+
107
+ export function buildBarkRequest({ settings = {}, text, task }) {
108
+ const server = String(settings.barkServerUrl || 'https://api.day.app').replace(/\/+$/, '');
109
+ const key = String(settings.barkKey || '').trim();
110
+ if (!key) throw new Error('barkKey is not configured');
111
+ const title = truncateText(task && task.title ? task.title : 'DSH Cron', 100);
112
+ return {
113
+ url: `${server}/${encodeURIComponent(key)}/${encodeURIComponent(title)}/${encodeURIComponent(truncateText(text, 1200))}`,
114
+ method: 'GET',
115
+ headers: {},
116
+ };
117
+ }
118
+
119
+ export function buildPushplusRequest({ settings = {}, text, task, token }) {
120
+ const effective = token || settings.pushplusToken || '';
121
+ if (!effective) throw new Error('pushplus token is not configured');
122
+ const url = String(settings.pushplusUrl || 'https://www.pushplus.plus/send').trim();
123
+ return {
124
+ url,
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({
128
+ token: effective,
129
+ title: truncateText(task && task.title ? task.title : 'DSH Cron', 100),
130
+ content: truncateText(text, 4000),
131
+ template: 'markdown',
132
+ }),
133
+ };
134
+ }
135
+
136
+ export function buildGiteaIssuePayload({ task, runInfo }) {
137
+ const failed = isFailed(runInfo.status);
138
+ return {
139
+ title: `${failed ? '[Cron failure]' : '[Cron run]'} ${truncateText(task.title || 'Task', 200)}`,
140
+ body: [
141
+ `**Cron task:** ${task.title} (\`${task.id}\`)`,
142
+ `**Schedule:** ${task.scheduleText || task.schedule || ''}`,
143
+ `**Status:** ${runInfo.status}`,
144
+ `**Started at:** ${new Date(runInfo.at || Date.now()).toISOString()}`,
145
+ runInfo.error ? `\n**Error:**\n\`\`\`\n${truncateText(runInfo.error, 1000)}\n\`\`\`` : '',
146
+ runInfo.output ? `\n**Output:**\n\`\`\`\n${truncateText(runInfo.output, 1000)}\n\`\`\`` : '',
147
+ ].filter(Boolean).join('\n\n'),
148
+ labels: failed ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
149
+ };
150
+ }
151
+
152
+ // ------------------------------------------------------------------ sender
153
+
154
+ export const DEFAULT_DELIVERY_TIMEOUT_MS = 15000;
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
+ /**
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 = resolveDeliveryTimeoutMs(timeoutMs);
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 help
193
+ * where the callee supports AbortSignal (fetch), while credential resolution
194
+ * ignores it — this is the backstop that keeps every channel bounded.
195
+ */
196
+ function withDeadline(promise, timeoutMs, channelId) {
197
+ const ms = resolveDeliveryTimeoutMs(timeoutMs);
198
+ return new Promise((resolve, reject) => {
199
+ const timer = setTimeout(() => {
200
+ reject(new Error(`${channelId}: timed out after ${ms} ms`));
201
+ }, ms);
202
+ Promise.resolve(promise).then(
203
+ (value) => { clearTimeout(timer); resolve(value); },
204
+ (err) => { clearTimeout(timer); reject(err); },
205
+ );
206
+ });
207
+ }
208
+
209
+ async function sendHttp(fetchFn, request, channelId, signal, timeoutMs) {
210
+ let res;
211
+ try {
212
+ res = await fetchFn(request.url, {
213
+ method: request.method,
214
+ headers: request.headers,
215
+ body: request.body,
216
+ signal,
217
+ });
218
+ } catch (err) {
219
+ if (isAbort(err)) throw new Error(`${channelId}: timed out after ${timeoutMs} ms`);
220
+ throw err;
221
+ }
222
+ if (!res || !res.ok) {
223
+ const status = res && res.status ? res.status : 'no-response';
224
+ throw new Error(`${channelId}: HTTP ${status}`);
225
+ }
226
+ return { status: res.status };
227
+ }
228
+
229
+ /** POST/PUT style JSON request helper used by the tts and gitea channels. */
230
+ async function postJson(http, url, headers, body, channelId, signal, timeoutMs) {
231
+ let res;
232
+ try {
233
+ res = await http(url, { method: 'POST', headers, body, signal });
234
+ } catch (err) {
235
+ if (isAbort(err)) throw new Error(`${channelId}: timed out after ${timeoutMs} ms`);
236
+ throw err;
237
+ }
238
+ if (!res || !res.ok) throw new Error(`${channelId}: HTTP ${res && res.status ? res.status : 'no-response'}`);
239
+ return res.json().catch(() => ({}));
240
+ }
241
+
242
+ /**
243
+ * One handler per channel. Each returns a small detail object and throws a
244
+ * descriptive Error on failure; the router turns that into a failure entry.
245
+ */
246
+ export const CHANNEL_HANDLERS = {
247
+ async telegram({ task, runInfo, settings, secrets, http, signal, timeoutMs }) {
248
+ const botToken = secrets.botToken || '';
249
+ const chatId = String(settings.chatId || '').trim();
250
+ if (!botToken || !chatId) throw new Error('telegram: botToken or chatId is not configured');
251
+ const text = settings.template
252
+ ? messageTextFor('telegram', task, runInfo, settings)
253
+ : formatTaskTelegramMessage(task, runInfo);
254
+ try {
255
+ await sendTelegramMessage({ botToken, chatId, text, fetchFn: http, signal });
256
+ } catch (err) {
257
+ if (isAbort(err)) throw new Error(`telegram: timed out after ${timeoutMs} ms`);
258
+ throw err;
259
+ }
260
+ return { chatId };
261
+ },
262
+
263
+ async kanban({ task, runInfo, settings, http, signal }) {
264
+ const isErr = isFailed(runInfo.status);
265
+ const result = await createKanbanCard({
266
+ title: `${isErr ? '[Cron failure]' : '[Cron completed]'} ${task.title}`,
267
+ body: buildGiteaIssuePayload({ task, runInfo }).body,
268
+ board: 'main',
269
+ column: isErr ? 'backlog' : 'done',
270
+ labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
271
+ kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000',
272
+ fetchFn: http,
273
+ signal,
274
+ });
275
+ if (!result.success) throw new Error(`kanban: ${result.error}`);
276
+ return { column: isErr ? 'backlog' : 'done' };
277
+ },
278
+
279
+ async discord({ task, runInfo, settings, http, signal, timeoutMs }) {
280
+ const url = String(settings.discordWebhookUrl || '').trim();
281
+ if (!url) throw new Error('discord: discordWebhookUrl is not configured');
282
+ const payload = buildDiscordPayload({ text: messageTextFor('discord', task, runInfo, settings), task, runInfo });
283
+ return sendHttp(http, { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, 'discord', signal, timeoutMs);
284
+ },
285
+
286
+ async slack({ task, runInfo, settings, http, signal, timeoutMs }) {
287
+ const url = String(settings.slackWebhookUrl || '').trim();
288
+ if (!url) throw new Error('slack: slackWebhookUrl is not configured');
289
+ const payload = buildSlackPayload({ text: messageTextFor('slack', task, runInfo, settings) });
290
+ return sendHttp(http, { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }, 'slack', signal, timeoutMs);
291
+ },
292
+
293
+ async ntfy({ task, runInfo, settings, secrets, http, signal, timeoutMs, resolveSecret }) {
294
+ const token = settings.ntfyTokenRef ? await resolveSecret(settings.ntfyTokenRef) : null;
295
+ const req = buildNtfyRequest({ settings, text: messageTextFor('ntfy', task, runInfo, settings), task, token });
296
+ return sendHttp(http, req, 'ntfy', signal, timeoutMs);
297
+ },
298
+
299
+ async bark({ task, runInfo, settings, http, signal, timeoutMs }) {
300
+ const req = buildBarkRequest({ settings, text: messageTextFor('bark', task, runInfo, settings), task });
301
+ return sendHttp(http, req, 'bark', signal, timeoutMs);
302
+ },
303
+
304
+ async pushplus({ task, runInfo, settings, secrets, http, signal, timeoutMs, resolveSecret }) {
305
+ const token = settings.pushplusTokenRef ? await resolveSecret(settings.pushplusTokenRef) : null;
306
+ const req = buildPushplusRequest({ settings, text: messageTextFor('pushplus', task, runInfo, settings), task, token });
307
+ return sendHttp(http, req, 'pushplus', signal, timeoutMs);
308
+ },
309
+
310
+ async tts({ task, runInfo, settings, http, signal, timeoutMs }) {
311
+ const base = String(settings.ttsBaseUrl || 'http://127.0.0.1:3080').replace(/\/+$/, '');
312
+ const payload = { text: truncateText(messageTextFor('tts', task, runInfo, settings), 600) };
313
+ const data = await postJson(http, `${base}/dsh-tts/speak`, { 'Content-Type': 'application/json' }, JSON.stringify(payload), 'tts', signal, timeoutMs);
314
+ return { provider: data.provider || 'dsh-tts', tookMs: data.tookMs || null };
315
+ },
316
+
317
+ async gitea({ task, runInfo, settings, http, signal, timeoutMs, resolveSecret }) {
318
+ const base = String(settings.giteaBaseUrl || '').replace(/\/+$/, '');
319
+ const repo = String(settings.giteaRepo || '').trim();
320
+ if (!base || !repo) throw new Error('gitea: giteaBaseUrl and giteaRepo are required');
321
+ const token = settings.giteaTokenRef ? await resolveSecret(settings.giteaTokenRef) : '';
322
+ if (!token) throw new Error(`gitea: credential "${settings.giteaTokenRef || ''}" did not resolve`);
323
+ const payload = buildGiteaIssuePayload({ task, runInfo });
324
+ const issue = await postJson(
325
+ http,
326
+ `${base}/api/v1/repos/${repo}/issues`,
327
+ { 'Content-Type': 'application/json', Authorization: `token ${token}` },
328
+ JSON.stringify(payload),
329
+ 'gitea',
330
+ signal,
331
+ timeoutMs,
332
+ );
333
+ return { number: issue.number || null };
334
+ },
335
+ };
336
+
337
+ /**
338
+ * Deliver one run to one channel.
339
+ * `resolveSecret(ref)` resolves credential references; `fetchFn` is injectable
340
+ * so every channel is testable without network access.
341
+ */
342
+ export async function sendToChannel({ channelId, task, runInfo, settings = {}, secrets = {}, fetchFn, timeoutMs = DEFAULT_DELIVERY_TIMEOUT_MS }) {
343
+ const handler = CHANNEL_HANDLERS[channelId];
344
+ if (!handler) throw new Error(`unknown channel: ${channelId}`);
345
+ const resolveSecret = typeof secrets.resolveSecret === 'function' ? secrets.resolveSecret : async () => null;
346
+ const http = fetchFn || globalThis.fetch;
347
+ const effectiveTimeout = resolveDeliveryTimeoutMs(timeoutMs);
348
+ const work = handler({
349
+ task,
350
+ runInfo,
351
+ settings,
352
+ secrets,
353
+ http,
354
+ resolveSecret,
355
+ signal: deliverySignal(effectiveTimeout),
356
+ timeoutMs: effectiveTimeout,
357
+ });
358
+ return withDeadline(work, effectiveTimeout, channelId);
359
+ }
360
+
361
+ /**
362
+ * Deliver a finished run to every applicable channel.
363
+ *
364
+ * Channels are dispatched concurrently and every channel is individually
365
+ * bounded (AbortSignal for fetch-based channels plus a hard deadline around the
366
+ * whole handler), so a slow endpoint cannot delay the rest of the report nor
367
+ * hold the cron callback open. Never throws: per-channel failures are returned
368
+ * in `failures`.
369
+ */
370
+ export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, fetchFn }) {
371
+ const timeoutMs = resolveDeliveryTimeoutMs(settings.deliveryTimeoutMs);
372
+
373
+ let channels = [];
374
+ try {
375
+ channels = resolveChannels(task, settings);
376
+ } catch (err) {
377
+ return { channels: [], delivered: [], skipped: [], failures: [{ channel: 'router', error: (err && err.message) || String(err) }] };
378
+ }
379
+
380
+ const outcomes = await Promise.all(channels.map(async (channelId) => {
381
+ try {
382
+ if (!shouldSendToChannel(channelId, task, runInfo, settings)) {
383
+ return { channel: channelId, skipped: true };
384
+ }
385
+ const detail = await sendToChannel({ channelId, task, runInfo, settings, secrets, fetchFn, timeoutMs });
386
+ return { channel: channelId, detail: detail || null };
387
+ } catch (err) {
388
+ return { channel: channelId, error: (err && err.message) || String(err) };
389
+ }
390
+ }));
391
+
392
+ const delivered = [];
393
+ const skipped = [];
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
+ }
406
+ for (const outcome of outcomes) {
407
+ if (outcome.skipped) skipped.push(outcome.channel);
408
+ else if (outcome.error) failures.push({ channel: outcome.channel, error: outcome.error });
409
+ else delivered.push({ channel: outcome.channel, detail: outcome.detail });
410
+ }
411
+ return { channels, delivered, skipped, failures };
412
+ }