@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.
- package/README.md +27 -21
- package/docs/README.ru.md +31 -20
- package/docs/README.zh.md +28 -21
- package/docs/design/DESIGN.md +13 -7
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/docs/plans/0.2.6-economy-block.md +61 -0
- package/lib/api.js +399 -0
- package/lib/channels.js +51 -61
- package/lib/client.js +727 -44
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +171 -359
- package/lib/llm-ask.js +141 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +226 -80
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +16 -5
- package/lib/task-patch.js +81 -0
- package/lib/task-transfer.js +230 -0
- package/lib/templates.js +4 -0
- package/package.json +1 -1
|
@@ -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, #
|
|
2
|
+
* Delivery channels and router (#26, #20, #21, #22, #47, #28).
|
|
3
3
|
*
|
|
4
4
|
* Every adapter is split into a pure payload/request builder (unit-testable
|
|
5
5
|
* without network) and a thin send step using an injected fetch. The router
|
|
@@ -11,7 +11,7 @@ import { formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
|
|
|
11
11
|
import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
|
|
12
12
|
import { resolveTemplateText, truncateText } from './templates.js';
|
|
13
13
|
|
|
14
|
-
export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', '
|
|
14
|
+
export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
15
15
|
|
|
16
16
|
export const CHANNEL_LABELS = {
|
|
17
17
|
telegram: 'Telegram',
|
|
@@ -21,7 +21,6 @@ export const CHANNEL_LABELS = {
|
|
|
21
21
|
ntfy: 'ntfy push',
|
|
22
22
|
bark: 'Bark push',
|
|
23
23
|
pushplus: 'PushPlus',
|
|
24
|
-
email: 'Email (SMTP)',
|
|
25
24
|
tts: 'Voice via dsh-tts',
|
|
26
25
|
gitea: 'Gitea issue',
|
|
27
26
|
};
|
|
@@ -51,6 +50,17 @@ export function resolveChannels(task, settings = {}) {
|
|
|
51
50
|
return out;
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Channel ids a task still references that no longer exist (for example the
|
|
55
|
+
* removed `email` channel). They are filtered out of routing, so they must be
|
|
56
|
+
* reported explicitly — a silently dropped notification is worse than a
|
|
57
|
+
* visible failure.
|
|
58
|
+
*/
|
|
59
|
+
export function unknownChannels(task) {
|
|
60
|
+
if (!Array.isArray(task.channels)) return [];
|
|
61
|
+
return task.channels.filter((id) => !CHANNEL_IDS.includes(id));
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
/** Failure/only-on-failure filtering, per channel. */
|
|
55
65
|
export function shouldSendToChannel(channelId, task, runInfo, settings = {}) {
|
|
56
66
|
if (channelId === 'kanban') return shouldCreateKanbanCard(task, runInfo);
|
|
@@ -139,42 +149,32 @@ export function buildGiteaIssuePayload({ task, runInfo }) {
|
|
|
139
149
|
};
|
|
140
150
|
}
|
|
141
151
|
|
|
142
|
-
export function buildEmailMessage({ settings = {}, text, task, runInfo, password, timeoutMs }) {
|
|
143
|
-
const to = String(settings.smtpTo || '').trim();
|
|
144
|
-
if (!to) throw new Error('smtpTo is not configured');
|
|
145
|
-
const from = String(settings.smtpFrom || settings.smtpUser || '').trim();
|
|
146
|
-
const failed = isFailed(runInfo.status);
|
|
147
|
-
const bound = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_DELIVERY_TIMEOUT_MS;
|
|
148
|
-
return {
|
|
149
|
-
from: from || undefined,
|
|
150
|
-
to,
|
|
151
|
-
subject: `${failed ? '❌' : '✅'} ${task.title || 'DSH Cron'} — ${runInfo.status}`,
|
|
152
|
-
text: truncateText(text, 10000),
|
|
153
|
-
transport: {
|
|
154
|
-
host: settings.smtpHost || '',
|
|
155
|
-
port: Number(settings.smtpPort) || 587,
|
|
156
|
-
secure: Boolean(settings.smtpSecure),
|
|
157
|
-
auth: settings.smtpUser ? { user: settings.smtpUser, pass: password || '' } : undefined,
|
|
158
|
-
// Nodemailer has no AbortSignal support: without these a stalled SMTP
|
|
159
|
-
// server would hold the run (defaults are 2–10 minutes).
|
|
160
|
-
connectionTimeout: bound,
|
|
161
|
-
greetingTimeout: bound,
|
|
162
|
-
socketTimeout: bound,
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
152
|
// ------------------------------------------------------------------ sender
|
|
168
153
|
|
|
169
154
|
export const DEFAULT_DELIVERY_TIMEOUT_MS = 15000;
|
|
170
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Lower bound for a per-channel deadline (#115). A stored value of a few
|
|
158
|
+
* milliseconds would fail every delivery before the request could even be sent,
|
|
159
|
+
* which looks like "delivery is broken" rather than a misconfiguration, so the
|
|
160
|
+
* router clamps instead of trusting the setting blindly.
|
|
161
|
+
*/
|
|
162
|
+
export const MIN_DELIVERY_TIMEOUT_MS = 1000;
|
|
163
|
+
|
|
164
|
+
/** Normalise a configured delivery timeout: finite, >= MIN, else the default. */
|
|
165
|
+
export function resolveDeliveryTimeoutMs(value) {
|
|
166
|
+
const n = Number(value);
|
|
167
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_DELIVERY_TIMEOUT_MS;
|
|
168
|
+
return Math.max(MIN_DELIVERY_TIMEOUT_MS, Math.round(n));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
171
|
/**
|
|
172
172
|
* Bound every outbound request. Without this a single unresponsive endpoint
|
|
173
173
|
* blocks the remaining channels and, because the run is awaited inside the
|
|
174
174
|
* croner callback with protect enabled, silently skips subsequent ticks.
|
|
175
175
|
*/
|
|
176
176
|
function deliverySignal(timeoutMs) {
|
|
177
|
-
const ms =
|
|
177
|
+
const ms = resolveDeliveryTimeoutMs(timeoutMs);
|
|
178
178
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
179
179
|
return AbortSignal.timeout(ms);
|
|
180
180
|
}
|
|
@@ -189,13 +189,12 @@ function isAbort(err) {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
|
-
* Hard deadline around one channel's whole work. Signal-based aborts only
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* that keeps every channel bounded regardless of implementation.
|
|
192
|
+
* Hard deadline around one channel's whole work. Signal-based aborts only help
|
|
193
|
+
* where the callee supports AbortSignal (fetch), while credential resolution
|
|
194
|
+
* ignores it — this is the backstop that keeps every channel bounded.
|
|
196
195
|
*/
|
|
197
196
|
function withDeadline(promise, timeoutMs, channelId) {
|
|
198
|
-
const ms =
|
|
197
|
+
const ms = resolveDeliveryTimeoutMs(timeoutMs);
|
|
199
198
|
return new Promise((resolve, reject) => {
|
|
200
199
|
const timer = setTimeout(() => {
|
|
201
200
|
reject(new Error(`${channelId}: timed out after ${ms} ms`));
|
|
@@ -308,23 +307,6 @@ export const CHANNEL_HANDLERS = {
|
|
|
308
307
|
return sendHttp(http, req, 'pushplus', signal, timeoutMs);
|
|
309
308
|
},
|
|
310
309
|
|
|
311
|
-
async email({ task, runInfo, settings, deps, resolveSecret, timeoutMs }) {
|
|
312
|
-
const password = settings.smtpPasswordRef ? await resolveSecret(settings.smtpPasswordRef) : (settings.smtpPassword || '');
|
|
313
|
-
const message = buildEmailMessage({ settings, text: messageTextFor('email', task, runInfo, settings), task, runInfo, password, timeoutMs });
|
|
314
|
-
let createTransport = deps.createTransport;
|
|
315
|
-
if (!createTransport) {
|
|
316
|
-
try {
|
|
317
|
-
const mod = await import('nodemailer');
|
|
318
|
-
createTransport = (mod.default || mod).createTransport;
|
|
319
|
-
} catch {
|
|
320
|
-
throw new Error('email: nodemailer is not installed in the harness (install it or use another channel)');
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
const transport = createTransport(message.transport);
|
|
324
|
-
await transport.sendMail({ from: message.from, to: message.to, subject: message.subject, text: message.text });
|
|
325
|
-
return { to: message.to };
|
|
326
|
-
},
|
|
327
|
-
|
|
328
310
|
async tts({ task, runInfo, settings, http, signal, timeoutMs }) {
|
|
329
311
|
const base = String(settings.ttsBaseUrl || 'http://127.0.0.1:3080').replace(/\/+$/, '');
|
|
330
312
|
const payload = { text: truncateText(messageTextFor('tts', task, runInfo, settings), 600) };
|
|
@@ -354,22 +336,21 @@ export const CHANNEL_HANDLERS = {
|
|
|
354
336
|
|
|
355
337
|
/**
|
|
356
338
|
* Deliver one run to one channel.
|
|
357
|
-
* `resolveSecret(ref)` resolves credential references; `
|
|
358
|
-
*
|
|
339
|
+
* `resolveSecret(ref)` resolves credential references; `fetchFn` is injectable
|
|
340
|
+
* so every channel is testable without network access.
|
|
359
341
|
*/
|
|
360
|
-
export async function sendToChannel({ channelId, task, runInfo, settings = {}, secrets = {}, fetchFn,
|
|
342
|
+
export async function sendToChannel({ channelId, task, runInfo, settings = {}, secrets = {}, fetchFn, timeoutMs = DEFAULT_DELIVERY_TIMEOUT_MS }) {
|
|
361
343
|
const handler = CHANNEL_HANDLERS[channelId];
|
|
362
344
|
if (!handler) throw new Error(`unknown channel: ${channelId}`);
|
|
363
345
|
const resolveSecret = typeof secrets.resolveSecret === 'function' ? secrets.resolveSecret : async () => null;
|
|
364
346
|
const http = fetchFn || globalThis.fetch;
|
|
365
|
-
const effectiveTimeout =
|
|
347
|
+
const effectiveTimeout = resolveDeliveryTimeoutMs(timeoutMs);
|
|
366
348
|
const work = handler({
|
|
367
349
|
task,
|
|
368
350
|
runInfo,
|
|
369
351
|
settings,
|
|
370
352
|
secrets,
|
|
371
353
|
http,
|
|
372
|
-
deps,
|
|
373
354
|
resolveSecret,
|
|
374
355
|
signal: deliverySignal(effectiveTimeout),
|
|
375
356
|
timeoutMs: effectiveTimeout,
|
|
@@ -386,10 +367,8 @@ export async function sendToChannel({ channelId, task, runInfo, settings = {}, s
|
|
|
386
367
|
* hold the cron callback open. Never throws: per-channel failures are returned
|
|
387
368
|
* in `failures`.
|
|
388
369
|
*/
|
|
389
|
-
export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, fetchFn
|
|
390
|
-
const timeoutMs =
|
|
391
|
-
? Number(settings.deliveryTimeoutMs)
|
|
392
|
-
: DEFAULT_DELIVERY_TIMEOUT_MS;
|
|
370
|
+
export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, fetchFn }) {
|
|
371
|
+
const timeoutMs = resolveDeliveryTimeoutMs(settings.deliveryTimeoutMs);
|
|
393
372
|
|
|
394
373
|
let channels = [];
|
|
395
374
|
try {
|
|
@@ -403,7 +382,7 @@ export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, f
|
|
|
403
382
|
if (!shouldSendToChannel(channelId, task, runInfo, settings)) {
|
|
404
383
|
return { channel: channelId, skipped: true };
|
|
405
384
|
}
|
|
406
|
-
const detail = await sendToChannel({ channelId, task, runInfo, settings, secrets, fetchFn,
|
|
385
|
+
const detail = await sendToChannel({ channelId, task, runInfo, settings, secrets, fetchFn, timeoutMs });
|
|
407
386
|
return { channel: channelId, detail: detail || null };
|
|
408
387
|
} catch (err) {
|
|
409
388
|
return { channel: channelId, error: (err && err.message) || String(err) };
|
|
@@ -413,6 +392,17 @@ export async function deliverRun({ task, runInfo, settings = {}, secrets = {}, f
|
|
|
413
392
|
const delivered = [];
|
|
414
393
|
const skipped = [];
|
|
415
394
|
const failures = [];
|
|
395
|
+
// Report ids that no longer exist before the per-channel results, so a task
|
|
396
|
+
// still carrying a removed channel fails loudly instead of silently.
|
|
397
|
+
let removed = [];
|
|
398
|
+
try {
|
|
399
|
+
removed = unknownChannels(task);
|
|
400
|
+
} catch {
|
|
401
|
+
removed = [];
|
|
402
|
+
}
|
|
403
|
+
for (const channelId of removed) {
|
|
404
|
+
failures.push({ channel: channelId, error: `unknown channel: ${channelId}` });
|
|
405
|
+
}
|
|
416
406
|
for (const outcome of outcomes) {
|
|
417
407
|
if (outcome.skipped) skipped.push(outcome.channel);
|
|
418
408
|
else if (outcome.error) failures.push({ channel: outcome.channel, error: outcome.error });
|