@goodandready/dsh-cron 0.2.4 → 0.2.6

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,61 @@
1
+ # План: блок B — экономия и меньше шума (0.2.6)
2
+
3
+ Живой план блока. Обновляется по факту работы; источник истины по задачам —
4
+ Gitea (`goodandready/dsh-cron`), milestone `0.2.6`.
5
+
6
+ ## Состав блока (согласован владельцем)
7
+
8
+ | Issue | Задача | Сложность |
9
+ |:--|:--|:--|
10
+ | #45 | Каскад моделей Flash → Pro при сбое | H |
11
+ | #44 | «Правило тишины» на базе LLM для задач с внешним выводом | M |
12
+ | #43 | Инспектор сбоев: диагноз и предложение правки промпта | M |
13
+ | #49 | Подстройка задачи через диалог (`cron_get_task`, `cron_update_task`) | M |
14
+ | #48 | Хаб готовых рецептов мониторинга | M |
15
+ | #97 | Техдолг: разбить разросшиеся обработчики (пункт 3 issue) | L |
16
+
17
+ Следующий по согласованию блок C (0.2.7) — #54, #50, #53, #97(остатки), #121.
18
+
19
+ ## Границы и инварианты
20
+
21
+ - Один блок → одна ветка `feat/0.2.6-economy-block` → один PR → один релиз 0.2.6.
22
+ - Новых зависимостей блок не добавляет; вызовы модели идут через сервис харнесса.
23
+ - **Все обращения к модели fail-open:** ошибка, таймаут или невалидный ответ LLM
24
+ не приводят к потере отчёта, зависанию запуска или ложной тишине.
25
+ - Секреты — только credential-ссылки. Ключи и токены в рецептах и промптах не
26
+ хранятся.
27
+ - Публичные экспорты `lib/index.js` не ломаются; поведение существующих
28
+ маршрутов не меняется.
29
+ - Рефакторинг (#97) идёт первым коммитом блока: новые фичи ложатся в уже
30
+ разобранную структуру, а не наоборот.
31
+ - `lib/client.js` остаётся single-file (требование загрузчика DSH).
32
+
33
+ ## Вне scope
34
+
35
+ - Внешний REST API и декларативный конфиг (блок C).
36
+ - Новые каналы доставки и изменения существующих.
37
+ - Обновление зависимостей (`#117` — отдельная ветка).
38
+
39
+ ## План проверки блока
40
+
41
+ - `npm test` до и после каждого пункта; базовый результат на старте — 128/128.
42
+ - Новые тесты: каскад моделей с подставным исполнителем, вердикты правила тишины
43
+ (тихо/громко/ошибка/невалидный JSON), разбор диагноза инспектора, инструменты
44
+ подстройки, валидность всех рецептов и отсутствие деструктивных команд.
45
+ - `bash deploy.sh check` (тесты + гейт размера + кандидат).
46
+ - Приёмка на изолированном MiniPC: установка кандидата, проверка новых полей и
47
+ инструментов через API, отображение в браузере.
48
+ - Независимое ревью PR перед merge.
49
+
50
+ ## Журнал
51
+
52
+ - 2026-09-11 — #97 закрыт первым коммитом: `lib/api.js` (диспетчер + обработчики по областям), `http-utils` получил общие `sendJson`/`rejectCrossOrigin`/`readBody`, `runTask` → `beginRun`/`executeOnce`/`completeRun`, `_run` → диспетчер + подготовка/ход/уборка. `index.js` 1024 → 671 строк; полный набор тестов зелёный без правок ожиданий, добавлен тест на контракты item-маршрутов. **Уточнение после ревью:** утверждение «все затронутые функции ≤ 50 строк» на тот момент было неверным — `completeRun` и `buildTaskRecord` превышали ориентир и разбиты отдельным коммитом.
53
+ - 2026-09-11 — #43 готов: `lib/failure-inspector.js` (промпт, разбор диагноза, fail-open), планировщик диагностирует сбой до записи, история хранит `diagnosis`/`suggestion`/`confidence`, в UI — чекбокс, настройка `inspectorModel`, блок диагноза в истории с кнопкой подстановки правки, переменная `{diagnosis}`. 164/164 тестов.
54
+ - 2026-09-11 — #44 готов: `lib/llm-ask.js` (обёртка над `ctx.llm.stream` со сбором текста, таймаутом и fail-open) и `lib/silent-rule.js` (правило словами → вердикт `{notify, reason}`); планировщик решает о доставке до записи запуска, история хранит `silentSkip`/`silentReason`, в UI есть поле правила и маркер в истории, настройка `silentRuleModel`. 156/156 тестов, включая четыре сценария fail-open.
55
+ - 2026-09-11 — #49 готов: инструменты `cron_get_task`/`cron_update_task`, общий модуль `lib/task-patch.js` (один код для HTTP PATCH и инструмента), действие «Настроить с DSH» в строке задачи, которое открывает сессию с контекстом задачи и предписанием подтверждать code-изменения. 145/145 тестов.
56
+ - 2026-09-11 — #48 готов: каталог `lib/recipes.js` (10 рецептов в 5 категориях, только read-only команды), маршрут `GET /dsh-cron/recipes`, панель подсказок переведена на каталог, пресет рецепта заполняет форму вместе с типом и каналами; тест проверяет валидность каждого рецепта и отсутствие деструктивных команд.
57
+ - 2026-09-11 — #45 готов: `fallbackProvider`/`fallbackModel` у задачи, ровно одна повторная попытка на fallback-модели при `error`/`timeout` (только агентские типы), сумма usage и стоимости обеих попыток, `model` и `fallback` в записи истории, переменная шаблона `{model}`, поле в UI. 134/134 тестов.
58
+ - 2026-09-11 — блок согласован (блок B + #97 → 0.2.6), ТЗ по каждой задаче
59
+ записаны в Gitea, все шесть задач переведены в milestone 0.2.6.
60
+ - 2026-09-11 — независимое ревью блока B дало FAIL и нашло: `cron_update_task` обходил подтверждение переключения в код-исполняющий тип (закрыто структурным гейтом `allowCodeSwitch`/`confirmCodeSwitch` с поведенческими тестами), помощники моделей всегда шли на модель харнесса вместо модели задачи (исправлено передачей задачи в `ask`), дедлайн вызова модели держался только на abort-сигнале (добавлена жёсткая гонка в `lib/llm-ask.js`), чёрный список деструктивных команд обходился `find -delete`, `shred`, `apt purge`, `mv /etc/...` и др. (расширен, те же пробы теперь блокируются), две функции превышали ориентир 50 строк (`completeRun`, `buildTaskRecord` — разбиты), документация отстала (DESIGN.md, index.md, AGENTS.md, RU §14 обновлены). Тестов стало 175.
61
+ - 2026-09-11 — релиз `0.2.6`: bump 0.2.5 → 0.2.6, тег `v0.2.6`, npm publish, GitHub Release, установка точной версии в production web-профиль MiniAI и production-проверки; закрыты #43, #44, #45, #48, #49, #97.
package/lib/api.js ADDED
@@ -0,0 +1,399 @@
1
+ /**
2
+ * REST surface of the plugin: the /dsh-cron/tasks and /dsh-cron/action route
3
+ * families. Extracted from index.js and split into per-scope handlers so each
4
+ * piece stays reviewable (#97); route contracts and response bodies are
5
+ * unchanged.
6
+ */
7
+
8
+ import { randomUUID } from 'node:crypto';
9
+ import { parseScheduleExpression } from './scheduler.js';
10
+ import { normalizeTaskType, CODE_EXECUTING_TYPES } from './runtimes.js';
11
+ import { CHANNEL_IDS } from './channels.js';
12
+ import { applyTaskPatch } from './task-patch.js';
13
+ import { sendJson, rejectCrossOrigin, readBody, pickPatchableFields, SCRIPT_CONFIRM_HEADER } from './http-utils.js';
14
+ import {
15
+ validateTaskType,
16
+ buildDuplicateTask,
17
+ buildTaskExport,
18
+ validateImportDocument,
19
+ planImport,
20
+ hasCodeExecutingTask,
21
+ IMPORT_STRATEGIES,
22
+ RESERVED_TASK_IDS,
23
+ PATCHABLE_TASK_FIELDS,
24
+ } from './task-transfer.js';
25
+
26
+ const NOT_FOUND = { ok: false, error: 'Task not found' };
27
+ const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
28
+
29
+ /**
30
+ * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
31
+ * route families — the #66 alias is kept alive by the same code path instead
32
+ * of a duplicated handler.
33
+ *
34
+ * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
35
+ * with one, the core degrades matching to exact-path equality and every
36
+ * deeper path unrouted (#96). Exported for unit tests.
37
+ */
38
+ export function createCronApiHandler(store, scheduler, collection) {
39
+ return async function handleCronApi(req, res) {
40
+ try {
41
+ const url = new URL(req.url, 'http://127.0.0.1');
42
+ const parts = url.pathname.split('/').filter(Boolean);
43
+ const scope = parts[1]; // 'tasks' | 'action'
44
+ const id = parts[2];
45
+ const action = parts[3];
46
+
47
+ if (!id) {
48
+ await handleTaskCollection({ store, scheduler, collection, req, res, url, scope });
49
+ return;
50
+ }
51
+
52
+ if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
53
+
54
+ // Export/import are collection operations that live under /tasks/:id/…,
55
+ // so they are matched before the generic id handling below.
56
+ if (id === 'export' && req.method === 'GET') {
57
+ handleTaskExport({ store, res });
58
+ return;
59
+ }
60
+ if (id === 'import' && req.method === 'POST') {
61
+ await handleTaskImport({ store, scheduler, req, res });
62
+ return;
63
+ }
64
+
65
+ await handleTaskItem({ store, scheduler, req, res, url, id, action });
66
+ } catch (err) {
67
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
68
+ }
69
+ };
70
+ }
71
+
72
+ // ------------------------------------------------------------- collection
73
+
74
+ async function handleTaskCollection({ store, scheduler, collection, req, res, url, scope }) {
75
+ // The alias family only exists for individual tasks.
76
+ if (scope === 'action' || !collection) {
77
+ sendJson(res, 405, NOT_ALLOWED);
78
+ return;
79
+ }
80
+ if (req.method === 'GET') {
81
+ listTasks({ store, scheduler, collection, res, url });
82
+ return;
83
+ }
84
+ if (req.method === 'POST') {
85
+ await createOrUpdateTask({ store, scheduler, req, res });
86
+ return;
87
+ }
88
+ sendJson(res, 405, NOT_ALLOWED);
89
+ }
90
+
91
+ function listTasks({ store, scheduler, collection, res, url }) {
92
+ const status = url.searchParams.get('status') || 'all';
93
+ const query = url.searchParams.get('query') || '';
94
+ const list = store.list({ status, query }).map((t) => ({
95
+ ...t,
96
+ running: scheduler.isRunning(t.id),
97
+ runningSince: scheduler.runningSince(t.id),
98
+ }));
99
+ const stats = store.getAggregatedStats();
100
+ sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
101
+ }
102
+
103
+ async function createOrUpdateTask({ store, scheduler, req, res }) {
104
+ if (rejectCrossOrigin(req, res)) return;
105
+ const { body, error } = await readBody(req, res);
106
+ if (error) return;
107
+ if (!body.title || !body.schedule || !body.prompt) {
108
+ sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
109
+ return;
110
+ }
111
+ const taskType = normalizeTaskType(body.type);
112
+ if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
113
+ sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
114
+ return;
115
+ }
116
+ const typeError = validateTaskType(taskType, body);
117
+ if (typeError) {
118
+ sendJson(res, 400, { ok: false, error: typeError });
119
+ return;
120
+ }
121
+ const parsed = parseScheduleExpression(body.schedule);
122
+ // A client-supplied id must not shadow the collection routes under
123
+ // /dsh-cron/tasks, otherwise that task could never be fetched or deleted
124
+ // again (#42 review finding).
125
+ if (body.id && RESERVED_TASK_IDS.includes(String(body.id))) {
126
+ sendJson(res, 400, { ok: false, error: `Task id "${body.id}" is reserved` });
127
+ return;
128
+ }
129
+ const task = store.set(buildTaskRecord(store, body, taskType, parsed));
130
+ if (task.status === 'active') {
131
+ scheduler.scheduleTask(task);
132
+ } else {
133
+ scheduler.pauseTask(task.id);
134
+ }
135
+ sendJson(res, 200, { ok: true, task });
136
+ }
137
+
138
+ /** Merge a create/update payload onto the stored task, if any. */
139
+ function buildTaskRecord(store, body, taskType, parsed) {
140
+ const current = body.id ? store.get(body.id) : null;
141
+ const resolvedStatus = body.status || (current ? current.status : 'active');
142
+ return {
143
+ id: body.id,
144
+ title: body.title,
145
+ schedule: parsed.cronPattern || body.schedule,
146
+ scheduleText: body.scheduleText || parsed.humanText,
147
+ prompt: body.prompt,
148
+ type: taskType,
149
+ delivery: body.delivery || 'current',
150
+ status: resolvedStatus,
151
+ provider: body.provider || undefined,
152
+ model: body.model || undefined,
153
+ fallbackProvider: body.fallbackProvider || undefined,
154
+ fallbackModel: body.fallbackModel || undefined,
155
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
156
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
157
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
158
+ overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
159
+ kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
160
+ timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
161
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
162
+ maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
163
+ retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
164
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
165
+ ...mergeExecutionFields(current, body, parsed),
166
+ };
167
+ }
168
+
169
+ // --------------------------------------------------------- export / import
170
+
171
+ function handleTaskExport({ store, res }) {
172
+ const doc = buildTaskExport(store.list({ status: 'all' }));
173
+ sendJson(res, 200, { ok: true, document: doc, count: doc.tasks.length });
174
+ }
175
+
176
+ async function handleTaskImport({ store, scheduler, req, res }) {
177
+ const { body, error } = await readBody(req, res);
178
+ if (error) return;
179
+ const strategy = IMPORT_STRATEGIES.includes(body && body.strategy) ? body.strategy : 'skip';
180
+ const checked = validateImportDocument(body && body.document);
181
+ if (!checked.ok) {
182
+ sendJson(res, 400, { ok: false, error: checked.error });
183
+ return;
184
+ }
185
+ // The import file is external input: importing code-executing tasks needs
186
+ // the same explicit confirmation that creating one over HTTP needs,
187
+ // otherwise a hand-edited file would bypass that gate (#86).
188
+ if (hasCodeExecutingTask(checked.tasks) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
189
+ sendJson(res, 403, {
190
+ ok: false,
191
+ error: `Importing code-executing tasks over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`,
192
+ });
193
+ return;
194
+ }
195
+ const existingIds = store.list({ status: 'all' }).map((x) => x.id);
196
+ const plan = planImport(checked.tasks, existingIds, strategy);
197
+ const summary = { add: plan.add.length, replace: plan.replace.length, skip: plan.skip.length };
198
+ if (body && body.dryRun) {
199
+ sendJson(res, 200, {
200
+ ok: true,
201
+ dryRun: true,
202
+ strategy: plan.strategy,
203
+ summary,
204
+ codeExecuting: hasCodeExecutingTask(plan.add.concat(plan.replace)),
205
+ });
206
+ return;
207
+ }
208
+ const applied = applyImportPlan({ store, scheduler, plan });
209
+ if (applied.error) {
210
+ sendJson(res, 500, { ok: false, error: applied.error });
211
+ return;
212
+ }
213
+ sendJson(res, 200, { ok: true, imported: applied.imported.length, summary, tasks: applied.imported });
214
+ }
215
+
216
+ /**
217
+ * Write an import plan. Every imported task is paused (the file's status is
218
+ * ignored) and every touched id is disarmed explicitly, because writing
219
+ * "paused" does not stop an already-armed croner job. A failed write rolls the
220
+ * store back so a partial import cannot survive.
221
+ */
222
+ function applyImportPlan({ store, scheduler, plan }) {
223
+ const imported = [];
224
+ const rollback = [];
225
+ const disarm = [];
226
+ try {
227
+ for (const task of plan.add) {
228
+ const before = store.get(task.id);
229
+ const created = store.set({ ...task, id: task.id || randomUUID(), status: 'paused', updatedAt: Date.now() });
230
+ imported.push(created);
231
+ rollback.push({ id: created.id, previous: before || null });
232
+ disarm.push(created.id);
233
+ }
234
+ for (const task of plan.replace) {
235
+ const before = store.get(task.id);
236
+ if (!before) continue;
237
+ const replaced = store.set({ ...task, id: task.id, status: 'paused', updatedAt: Date.now() });
238
+ imported.push(replaced);
239
+ rollback.push({ id: replaced.id, previous: before });
240
+ disarm.push(replaced.id);
241
+ }
242
+ } catch (writeErr) {
243
+ for (const entry of rollback.reverse()) {
244
+ try {
245
+ if (entry.previous) store.set(entry.previous);
246
+ else store.delete(entry.id);
247
+ } catch (_) {}
248
+ }
249
+ return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
250
+ }
251
+ // Disarm outside the write transaction: pauseTask is idempotent and a
252
+ // failure here must not undo a successful import.
253
+ for (const id of disarm) {
254
+ try {
255
+ scheduler.pauseTask(id);
256
+ } catch (err) {
257
+ console.error('[dsh-cron] import: could not pause task', id + ':', err.message);
258
+ }
259
+ }
260
+ return { imported };
261
+ }
262
+
263
+ // ------------------------------------------------------------ single task
264
+
265
+ async function handleTaskItem({ store, scheduler, req, res, url, id, action }) {
266
+ if (req.method === 'GET' && (action === 'history' || !action)) {
267
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
268
+ sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
269
+ return;
270
+ }
271
+ if (req.method === 'POST') {
272
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
273
+ if (handled) return;
274
+ }
275
+ if (req.method === 'PATCH') {
276
+ await patchTask({ store, scheduler, req, res, id });
277
+ return;
278
+ }
279
+ if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
280
+ deleteTask({ store, scheduler, res, id });
281
+ return;
282
+ }
283
+ sendJson(res, 405, NOT_ALLOWED);
284
+ }
285
+
286
+ /** Task actions that are plain POSTs. Returns true when one matched. */
287
+ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
288
+ if (action === 'run') {
289
+ if (!store.get(id)) {
290
+ sendJson(res, 404, NOT_FOUND);
291
+ return true;
292
+ }
293
+ await scheduler.triggerManualRun(id);
294
+ sendJson(res, 200, { ok: true, task: store.get(id) });
295
+ return true;
296
+ }
297
+ if (action === 'duplicate') {
298
+ const source = store.get(id);
299
+ if (!source) {
300
+ sendJson(res, 404, NOT_FOUND);
301
+ return true;
302
+ }
303
+ const copy = buildDuplicateTask(source, { id: randomUUID() });
304
+ store.set(copy);
305
+ // A copy starts paused: it must never fire on its own before the user
306
+ // reviews the schedule (a duplicated one-shot may point at a past time).
307
+ scheduler.pauseTask(copy.id);
308
+ sendJson(res, 200, { ok: true, task: copy });
309
+ return true;
310
+ }
311
+ if (action === 'pause' || action === 'resume') {
312
+ const task = action === 'pause' ? scheduler.pauseTask(id) : scheduler.resumeTask(id);
313
+ if (!task) {
314
+ sendJson(res, 404, NOT_FOUND);
315
+ return true;
316
+ }
317
+ sendJson(res, 200, { ok: true, task });
318
+ return true;
319
+ }
320
+ if (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle')) {
321
+ if (!store.get(id)) {
322
+ sendJson(res, 404, NOT_FOUND);
323
+ return true;
324
+ }
325
+ const task = scheduler.toggleTask(id);
326
+ sendJson(res, 200, { ok: true, task });
327
+ return true;
328
+ }
329
+ return false;
330
+ }
331
+
332
+ async function patchTask({ store, scheduler, req, res, id }) {
333
+ const current = store.get(id);
334
+ if (!current) {
335
+ sendJson(res, 404, NOT_FOUND);
336
+ return;
337
+ }
338
+ const { body, error } = await readBody(req, res);
339
+ if (error) return;
340
+ const result = applyTaskPatch({
341
+ store,
342
+ scheduler,
343
+ id,
344
+ body,
345
+ allowCodeSwitch: req.headers[SCRIPT_CONFIRM_HEADER] === 'script',
346
+ });
347
+ if (!result.ok) {
348
+ const status = result.notFound ? 404 : (result.needsConfirmation ? 403 : 400);
349
+ const error = result.needsConfirmation
350
+ ? `Switching a task to the ${normalizeTaskType(body.type)} type over HTTP requires the ${SCRIPT_CONFIRM_HEADER}: script header`
351
+ : result.error;
352
+ sendJson(res, status, { ok: false, error });
353
+ return;
354
+ }
355
+ sendJson(res, 200, { ok: true, task: result.task });
356
+ }
357
+
358
+ function deleteTask({ store, scheduler, res, id }) {
359
+ const current = store.get(id);
360
+ if (current) {
361
+ scheduler.pauseTask(id);
362
+ store.delete(id);
363
+ }
364
+ sendJson(res, 200, { ok: true });
365
+ }
366
+
367
+ /**
368
+ * Runtime and execution settings of a task, merged onto the stored one. Split
369
+ * out of buildTaskRecord so both stay readable.
370
+ */
371
+ function mergeExecutionFields(current, body, parsed) {
372
+ return {
373
+ env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
374
+ cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
375
+ channels: Array.isArray(body.channels)
376
+ ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
377
+ : (current ? current.channels : undefined),
378
+ template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
379
+ silentRule: body.silentRule !== undefined ? String(body.silentRule) : (current ? current.silentRule : ''),
380
+ inspectOnFailure: body.inspectOnFailure !== undefined ? Boolean(body.inspectOnFailure) : (current ? Boolean(current.inspectOnFailure) : false),
381
+ workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
382
+ worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
383
+ keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
384
+ httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
385
+ httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
386
+ httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
387
+ httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
388
+ sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
389
+ sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
390
+ sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
391
+ sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
392
+ dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
393
+ pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
394
+ nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
395
+ skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
396
+ workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
397
+ oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
398
+ };
399
+ }
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 });