@goodandready/dsh-cron 0.2.36 → 0.2.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -245,6 +245,7 @@ If the daemon was offline at a scheduled time, the run is recorded as `missed` o
245
245
  ### 14. Heartbeat Monitoring (#16-style dead man's switch)
246
246
  * Set `heartbeatUrl` and `heartbeatIntervalSec` in the plugin settings and the scheduler pings that URL on schedule — an external monitor alerts when the pings stop.
247
247
  * A built-in `GET /dsh-cron/heartbeat` endpoint reports liveness, active task count and the last run time for your own watchdogs.
248
+ * **Missed Heartbeat Alerts in `onlyOnFailure` Mode** — Missed heartbeats (`status === 'missed'`) are recognized as active failure events across all channel delivery predicates (`channels.js`, `telegram.js`, `integrations.js`), guaranteeing that Telegram, Discord, Webhook, and Kanban integrations alert immediately when an external process fails to report.
248
249
 
249
250
  ### 15. Declarative Jobs From the Profile Config (#50)
250
251
  Long-lived operational jobs can be declared in the profile configuration instead of being recreated by hand in the UI. The config file owns the jobs it declares: at every plugin start they are created or updated, and a job that disappears from the file is removed.
@@ -294,6 +295,7 @@ Set the token as the plugin setting `apiToken` (masked like every secret). Auth
294
295
  | `POST` | `/dsh-cron/api/tasks/:id/run` | Force an immediate run |
295
296
 
296
297
  The operations reuse the panel handlers, so the `x-dsh-cron-confirm: script` gate for code-executing types and the `409` refusals for config-owned tasks behave exactly as in the UI.
298
+ * **Bearer-Guarded Remote Access** — Remote clients can create and update tasks, ping heartbeats (`/dsh-cron/api/heartbeat/:id`), and preview schedules (`/dsh-cron/api/schedule/preview`) with valid `Authorization: Bearer <token>` credentials without loopback IP restrictions.
297
299
 
298
300
  ```bash
299
301
  BASE="http://127.0.0.1:3080"
@@ -387,6 +389,8 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
387
389
  - **Priority Queues & Concurrency Pools**: When the concurrent run limit is reached, queued tasks are ordered by `priority` (1 = highest, 10 = lowest) to ensure critical system alerts execute ahead of bulk background jobs.
388
390
  - **Self-Healing Runbooks & Auto-Diagnosis**: Failed tasks automatically execute an optional compensatory `selfHealingCommand` (e.g. system service restart or temp cleanup). Model failures can trigger `autoDiagnose: true` to append an instant root-cause diagnosis.
389
391
  - **Web UI Archive & Pipeline Visualization**: Interactive archive drawer with pagination and full log viewing; visual indicators for `➜ onSuccess` and `↳ onFailure` task connections.
392
+ - **Shell & Disk Pre-flight Gate Hardening**: Supports both `command` and `shell` types with fail-closed semantics for invalid syntax, unknown types, or inspection failures. Disk space checks support exact path and threshold syntax (e.g. `/:10%`, `/data:500MB`).
393
+ - **Complete Reliability Field Persistence**: HTTP POST task creation/editing and `cron_create_task` tool fully preserve and store all 13 reliability and session configuration fields (`agentPreset`, `targetSessionId`, `targetSessionReset`, `onSuccess`, `onFailure`, `heartbeatIntervalSeconds`, `gracePeriodSeconds`, `preflightType`, `preflightTarget`, `priority`, `concurrencyGroup`, `selfHealingCommand`, `autoDiagnose`, `fallbackProvider`, `fallbackModel`, `silentRule`, `inspectOnFailure`).
390
394
 
391
395
  ---
392
396
 
@@ -448,6 +452,7 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
448
452
  - **Modular Client Architecture (#150)**: Decomposed monolithic `lib/client.js` (~3950 lines) into 14 focused, single-responsibility fragments under `lib/client-src/` (none exceeding 580 lines). Integrated zero-dependency build script `scripts/build-client.mjs` wired into `package.json` (`build:client`, `pretest`). Development fragments are excluded from npm distribution via `"files": ["lib/*.js", ...]`.
449
453
  - **DSH Theme Tokens & Visual Standardization (#149)**: Replaced inline styles on task type badges (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) with dedicated `.dsh-cron-tag-*` CSS classes powered by semantic `--dsh-cron-*` theme variables. Modal overlay now adapts dynamically using `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`, and keyframe pulse animations use theme variables without hardcoded RGBA.
450
454
  - **Label Governance & Triage Audit (#95)**: Standardized 100% of repository issues and triage on the canonical repo-level label set.
455
+ - **Style Self-Healing on Dynamic DOM Sweeps (#263 / GH-4)**: Watches `<head>` and `<html>` via `MutationObserver` alongside a 2s backstop interval and `visibilitychange` listener (`startStyleSelfHeal`), automatically re-attaching or repairing the `<style id="dsh-cron-styles">` tag if swept by host head re-renders, theme switches, or neighbor cleanups.
451
456
 
452
457
  ---
453
458
 
package/README.ru.md CHANGED
@@ -256,6 +256,8 @@ cron({
256
256
  - **Очереди с приоритетами**: При достижении лимита параллелизма задачи упорядочиваются по полю `priority` (1 — наивысший, 10 — низший).
257
257
  - **Команды самоисцеления и авто-диагностика (Self-Healing)**: Автоматический запуск компенсирующей команды `selfHealingCommand` при падении задачи; опция `autoDiagnose` для генерации AI-диагностики причин сбоя.
258
258
  - **Интерактивный архив логов в UI**: Модальное окно просмотра истории с пагинацией и полным выводом логов, визуальные ссылки конвейеров `➜ onSuccess` и `↳ onFailure`.
259
+ - **Безопасные preflight-проверки shell и диска**: Поддержка типов проверок `command` и `shell` с принципом fail-closed при синтаксических ошибках или сбоях проверки. Проверка диска поддерживает явные пути и пороговые значения (например, `/:10%`, `/data:500MB`).
260
+ - **Полное сохранение параметров надёжности**: Создание и редактирование задач через HTTP POST и агентский инструмент `cron_create_task` полностью сохраняют все 13 полей надёжности и управления сессиями (`agentPreset`, `targetSessionId`, `targetSessionReset`, `onSuccess`, `onFailure`, `heartbeatIntervalSeconds`, `gracePeriodSeconds`, `preflightType`, `preflightTarget`, `priority`, `concurrencyGroup`, `selfHealingCommand`, `autoDiagnose`, `fallbackProvider`, `fallbackModel`, `silentRule`, `inspectOnFailure`).
259
261
 
260
262
  ---
261
263
 
@@ -317,6 +319,7 @@ cron({
317
319
  - **Модульная архитектура клиентской части (#150)**: Монолитный файл `lib/client.js` (~3950 строк) декомпозирован на 14 независимых специализированных модулей в каталоге `lib/client-src/` (каждый строго <= 580 строк). Сборка `lib/client.js` выполняется легковесным скриптом `scripts/build-client.mjs` перед запуском тестов и сборки. Исходные фрагменты исключены из npm-дистрибутива через `"files": ["lib/*.js", ...]`.
318
320
  - **Семантические токены тем DSH (#149)**: Инлайн-стили тегов типа задач (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) переведены на выделенные CSS-классы `.dsh-cron-tag-*` на базе переменных темы `--dsh-cron-*`. Оверлей модальных окон переведен на адаптивную маску `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`, а анимация пульсации избавлена от захардкоженных значений RGBA.
319
321
  - **Аудит меток репозитория (#95)**: Подтверждена 100% консистентность использования канонического репозиторного набора меток и фильтрации задач.
322
+ - **Самовосстановление стилей при динамических перерисовках DOM (#263 / GH-4)**: Отслеживает `<head>` и `<html>` через `MutationObserver`, резервный интервал 2с и событие `visibilitychange` (`startStyleSelfHeal`), мгновенно восстанавливая узел `<style id="dsh-cron-styles">` при перерисовке заголовка хостом, смене темы или очистке соседними модулями.
320
323
 
321
324
  ---
322
325
 
@@ -378,6 +381,7 @@ dsh-cron:
378
381
  ### 14. Мониторинг heartbeat (dead man's switch)
379
382
  * Задайте `heartbeatUrl` и `heartbeatIntervalSec` в настройках плагина — планировщик будет пинговать этот адрес по расписанию, и внешний монитор сообщит, когда пинги прекратятся.
380
383
  * Встроенный эндпоинт `GET /dsh-cron/heartbeat` сообщает живость, число активных задач и время последнего запуска для ваших собственных сторожей.
384
+ * **Оповещения о пропущенных heartbeat в режиме `onlyOnFailure`** — Пропущенные heartbeat (`status === 'missed'`) распознаются как полноценное состояние сбоя во всех фильтрах отправки (`channels.js`, `telegram.js`, `integrations.js`), гарантируя своевременную отправку тревог в Telegram, Discord, Webhook и Kanban при отсутствии отчётов от внешних служб.
381
385
 
382
386
  ### 15. Задачи из конфига профиля (#50)
383
387
  Долгоживущие эксплуатационные задачи можно объявлять в конфиге профиля, а не пересоздавать руками в интерфейсе. Владелец объявленных задач — файл конфига: при каждом старте плагина они создаются или обновляются, а задача, исчезнувшая из файла, удаляется.
@@ -427,6 +431,7 @@ dsh-cron:
427
431
  | `POST` | `/dsh-cron/api/tasks/:id/run` | Принудительный немедленный запуск |
428
432
 
429
433
  Операции переиспользуют обработчики панели, поэтому гейт `x-dsh-cron-confirm: script` для код-исполняющих типов и отказ `409` для конфиг-задач действуют здесь так же, как в UI.
434
+ * **Удалённый доступ по Bearer-токену** — Внешние клиенты с любого разрешённого IP могут создавать и обновлять задачи, отправлять heartbeat-сигналы (`/dsh-cron/api/heartbeat/:id`) и запрашивать предпросмотр расписания (`/dsh-cron/api/schedule/preview`) с валидным `Authorization: Bearer <token>`.
430
435
 
431
436
  ```bash
432
437
  BASE="http://127.0.0.1:3080"
package/README.zh.md CHANGED
@@ -237,6 +237,7 @@ cron({
237
237
  ### 14. 心跳监控(Dead man's switch)
238
238
  * 在插件设置中配置 `heartbeatUrl` 与 `heartbeatIntervalSec`,调度器会按间隔 GET 该地址 —— 外部监控可在心跳停止时告警。
239
239
  * 内置 `GET /dsh-cron/heartbeat` 端点返回存活状态、活跃任务数与最近运行时间,便于自建看门狗。
240
+ * **`onlyOnFailure` 模式支持心跳超时告警** — 遗漏心跳(`status === 'missed'`)在各通知渠道过滤判定(`channels.js`、`telegram.js`、`integrations.js`)中被统一视作失败状态,确保外部守护进程中断时 Telegram、Discord、Webhook 与看板即刻告警。
240
241
 
241
242
  ### 15. 来自配置的声明式任务(#50)
242
243
  长期运行的任务可以直接声明在配置文件里,而无需在界面中手工重建。配置文件拥有这些任务:每次插件启动时会创建或更新它们,从文件中消失的任务会被删除。
@@ -286,6 +287,7 @@ dsh-cron:
286
287
  | `POST` | `/dsh-cron/api/tasks/:id/run` | 强制执行一次 |
287
288
 
288
289
  这些操作复用面板处理器,因此对会执行代码类型的 `x-dsh-cron-confirm: script` 门禁以及对配置任务的 `409` 拒绝与 UI 完全一致。
290
+ * **Bearer Token 远程访问支持** — 远程客户端凭有效 `Authorization: Bearer <token>` 凭据可直接创建/修改任务、打卡心跳(`/dsh-cron/api/heartbeat/:id`)及预览调度(`/dsh-cron/api/schedule/preview`),解除非本机限制。
289
291
 
290
292
  ```bash
291
293
  BASE="http://127.0.0.1:3080"
@@ -379,6 +381,8 @@ bash deploy.sh verify [exact-version]
379
381
  - **优先级队列与并发池(Priority Queues)**:并发满载时,等待队列严格依据任务 `priority`(1 最高,10 最低)调度。
380
382
  - **自愈脚本与 AI 根因诊断(Self-Healing)**:任务失败后自动执行补偿指令 `selfHealingCommand`(例如重启服务或清理临时空间);`autoDiagnose` 自动生成 AI 故障根因摘要。
381
383
  - **UI 交互式归档与管道全景**:支持分页浏览任务历史运行全量输出,直观展示 `➜ 成功触发` 与 `↳ 失败触发` 关联关系。
384
+ - **Shell 与磁盘前置检查强化**:前置检查统一支持 `command` 与 `shell` 类型,未知类型或异常格式一律安全关闭(fail-closed);磁盘检查支持完整路径与阈值(如 `/:10%`、`/data:500MB`)。
385
+ - **可靠性与会话字段完整持久化**:HTTP POST 任务创建/修改接口与 `cron_create_task` 智能体工具完整保留并存储全部 13 个可靠性与会话高级字段(`agentPreset`、`targetSessionId`、`targetSessionReset`、`onSuccess`、`onFailure`、`heartbeatIntervalSeconds`、`gracePeriodSeconds`、`preflightType`、`preflightTarget`、`priority`、`concurrencyGroup`、`selfHealingCommand`、`autoDiagnose`、`fallbackProvider`、`fallbackModel`、`silentRule`、`inspectOnFailure`)。
382
386
 
383
387
  ---
384
388
 
@@ -440,6 +444,7 @@ bash deploy.sh verify [exact-version]
440
444
  - **客户端模块化架构 (#150)**: 将庞大的单文件 `lib/client.js` (约 3950 行) 拆分为 `lib/client-src/` 下的 14 个高内聚模块文件 (各模块严格 <= 580 行)。集成零依赖构建脚本 `scripts/build-client.mjs` 并接入 `package.json` (`build:client`, `pretest`),通过 `"files": ["lib/*.js", ...]` 避免开发源码冗余打包进 npm 发布包。
441
445
  - **DSH 语义化主题变量对齐 (#149)**: 将任务类型标签 (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) 的内联样式全部替换为基于主题变量的 `.dsh-cron-tag-*` 类;模态框遮罩层接入自适应主题遮罩变量 `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`,移除脉冲动画关键帧中的硬编码 RGBA。
442
446
  - **标签治理与工单审计 (#95)**: 审计并确认全仓库 100% 统一规范使用仓库级标签集。
447
+ - **动态 DOM 清理下的样式自动恢复(#263 / GH-4)**: 通过 `MutationObserver`、2 秒兜底定时器与 `visibilitychange` 事件(`startStyleSelfHeal`)监听 `<head>` 与 `<html>`,当宿主重绘 `<head>`、切换主题或邻近插件清理样式时,自动重新注入并修复 `<style id="dsh-cron-styles">` 标签。
443
448
 
444
449
  ---
445
450
 
@@ -3,12 +3,12 @@ import { sendJson, readBody, rejectCrossOrigin } from './http-utils.js';
3
3
 
4
4
  const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
5
5
 
6
- export function handleHeartbeatPing({ store, req, res, taskId }) {
6
+ export function handleHeartbeatPing({ store, req, res, taskId, apiToken: explicitToken }) {
7
7
  if (req.method !== 'POST') {
8
8
  sendJson(res, 405, NOT_ALLOWED);
9
9
  return;
10
10
  }
11
- const apiToken = store?.getSettings?.()?.apiToken;
11
+ const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
12
12
  if (rejectCrossOrigin(req, res, { apiToken })) return;
13
13
 
14
14
  const task = store.get(taskId);
@@ -24,8 +24,8 @@ export function handleHeartbeatPing({ store, req, res, taskId }) {
24
24
  sendJson(res, 200, result);
25
25
  }
26
26
 
27
- export async function handleSchedulePreview({ req, res, url, store }) {
28
- const apiToken = store?.getSettings?.()?.apiToken;
27
+ export async function handleSchedulePreview({ req, res, url, store, apiToken: explicitToken }) {
28
+ const apiToken = explicitToken || store?.getSettings?.()?.apiToken;
29
29
  if (rejectCrossOrigin(req, res, { apiToken })) return;
30
30
 
31
31
  let schedule = '';
package/lib/api.js CHANGED
@@ -484,6 +484,19 @@ function mergeExecutionFields(current, body, parsed) {
484
484
  dailyCostLimitUsd: body.dailyCostLimitUsd !== undefined ? (body.dailyCostLimitUsd === null ? null : (Number(body.dailyCostLimitUsd) > 0 ? Number(body.dailyCostLimitUsd) : null)) : (current ? current.dailyCostLimitUsd : undefined),
485
485
  tokenLimit: body.tokenLimit !== undefined ? (body.tokenLimit === null ? null : (Number(body.tokenLimit) > 0 ? Number(body.tokenLimit) : null)) : (current ? current.tokenLimit : undefined),
486
486
  pausedReason: body.pausedReason !== undefined ? (body.pausedReason ? String(body.pausedReason) : null) : (current ? current.pausedReason : undefined),
487
+ agentPreset: body.agentPreset !== undefined ? String(body.agentPreset).trim() : (current ? (current.agentPreset || '') : ''),
488
+ targetSessionId: body.targetSessionId !== undefined ? String(body.targetSessionId).trim() : (current ? (current.targetSessionId || '') : ''),
489
+ targetSessionReset: body.targetSessionReset !== undefined ? String(body.targetSessionReset).trim() : (current ? (current.targetSessionReset || 'never') : 'never'),
490
+ onSuccess: body.onSuccess !== undefined ? String(body.onSuccess).trim() : (current ? (current.onSuccess || '') : ''),
491
+ onFailure: body.onFailure !== undefined ? String(body.onFailure).trim() : (current ? (current.onFailure || '') : ''),
492
+ heartbeatIntervalSeconds: body.heartbeatIntervalSeconds !== undefined ? Number(body.heartbeatIntervalSeconds) : (current ? (Number(current.heartbeatIntervalSeconds) || 0) : 0),
493
+ gracePeriodSeconds: body.gracePeriodSeconds !== undefined ? Number(body.gracePeriodSeconds) : (current ? (Number(current.gracePeriodSeconds) || 300) : 300),
494
+ preflightType: body.preflightType !== undefined ? String(body.preflightType).trim().toLowerCase() : (current ? (current.preflightType || 'none') : 'none'),
495
+ preflightTarget: body.preflightTarget !== undefined ? String(body.preflightTarget).trim() : (current ? (current.preflightTarget || '') : ''),
496
+ priority: body.priority !== undefined ? (Number(body.priority) || 5) : (current ? (Number(current.priority) || 5) : 5),
497
+ concurrencyGroup: body.concurrencyGroup !== undefined ? String(body.concurrencyGroup).trim() : (current ? (current.concurrencyGroup || 'default') : 'default'),
498
+ selfHealingCommand: body.selfHealingCommand !== undefined ? String(body.selfHealingCommand).trim() : (current ? (current.selfHealingCommand || '') : ''),
499
+ autoDiagnose: body.autoDiagnose !== undefined ? Boolean(body.autoDiagnose) : (current ? Boolean(current.autoDiagnose) : false),
487
500
  };
488
501
  }
489
502
 
@@ -495,14 +508,14 @@ function mergeExecutionFields(current, body, parsed) {
495
508
  * and reuses the handlers above, so the confirmation gate for code-executing
496
509
  * tasks and the config-owned refusals are the same code, not a second copy.
497
510
  */
498
- export async function handleExternalTaskRequest({ store, scheduler, req, res, url }) {
511
+ export async function handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken }) {
499
512
  const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, [tasks|heartbeat|schedule], :id?, :action?
500
513
  if (parts[2] === 'heartbeat' && parts[3]) {
501
- handleHeartbeatPing({ store, req, res, taskId: parts[3] });
514
+ handleHeartbeatPing({ store, req, res, taskId: parts[3], apiToken });
502
515
  return;
503
516
  }
504
517
  if (parts[2] === 'schedule' && parts[3] === 'preview') {
505
- await handleSchedulePreview({ req, res, url });
518
+ await handleSchedulePreview({ req, res, url, store, apiToken });
506
519
  return;
507
520
  }
508
521
  const id = parts[3];
@@ -517,7 +530,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
517
530
  return;
518
531
  }
519
532
  if (req.method === 'POST') {
520
- await createOrUpdateTask({ store, scheduler, req, res });
533
+ await createOrUpdateTask({ store, scheduler, req, res, apiToken });
521
534
  return;
522
535
  }
523
536
  sendJson(res, 405, NOT_ALLOWED);
@@ -525,7 +538,7 @@ export async function handleExternalTaskRequest({ store, scheduler, req, res, ur
525
538
  }
526
539
 
527
540
  if (req.method === 'POST') {
528
- const handled = await handleItemPost({ store, scheduler, req, res, url, id, action });
541
+ const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, apiToken });
529
542
  if (handled) return;
530
543
  }
531
544
  if (req.method === 'GET' && !action) {
package/lib/channels.js CHANGED
@@ -15,7 +15,8 @@ export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'b
15
15
 
16
16
  // CHANNEL_LABELS removed (#155) — localized via lib/client.js
17
17
 
18
- const isFailed = (status) => status === 'error' || status === 'timeout';
18
+ export const isFailureStatus = (status) => status === 'error' || status === 'timeout' || status === 'missed';
19
+ export const isFailed = (status) => isFailureStatus(status);
19
20
 
20
21
  /** The message text for a channel: custom template, else built-in defaults. */
21
22
  function messageTextFor(channelId, task, runInfo, settings = {}) {
package/lib/client.js CHANGED
@@ -847,17 +847,63 @@ window.__ModuleLoader__.load({
847
847
  }
848
848
  `;
849
849
 
850
+ const STYLE_ID = 'dsh-cron-styles';
851
+
850
852
  function ensureStyles() {
851
- if (typeof document === 'undefined') return;
852
- let el = document.getElementById('dsh-cron-styles');
853
- if (!el) {
853
+ if (typeof document === 'undefined') return null;
854
+ let el = document.getElementById(STYLE_ID);
855
+ if (el === null) {
856
+ const head = document.head || document.documentElement;
857
+ if (!head) return null;
854
858
  el = document.createElement('style');
855
- el.id = 'dsh-cron-styles';
859
+ el.id = STYLE_ID;
856
860
  // Mark ownership before insertion so neighbor cleanups leave it alone (#91)
857
861
  el.dataset.dshPlugin = 'dsh-cron';
858
- document.head.appendChild(el);
862
+ el.textContent = STYLES;
863
+ head.appendChild(el);
864
+ return el;
859
865
  }
860
- el.textContent = STYLES;
866
+ if (el.textContent !== STYLES) el.textContent = STYLES;
867
+ return el;
868
+ }
869
+
870
+ // Registry is shared across hot reloads so the newest instance owns exactly
871
+ // one watcher instead of stacking one per module evaluation.
872
+ const STYLE_HEAL_KEY = '__dshCronStyleHeal';
873
+ const STYLE_HEAL_INTERVAL_MS = 2000;
874
+
875
+ /**
876
+ * Keep the stylesheet attached: watch <head> (and <html>, which catches a
877
+ * replaced <head>) and re-attach on demand, with a slow interval as the
878
+ * backstop. heal() is idempotent and writes nothing while healthy.
879
+ */
880
+ function startStyleSelfHeal() {
881
+ if (typeof document === 'undefined') return () => {};
882
+ if (typeof window !== 'undefined' && typeof window[STYLE_HEAL_KEY] === 'function') {
883
+ try { window[STYLE_HEAL_KEY](); } catch (_err) { void _err; }
884
+ }
885
+ const heal = () => { try { ensureStyles(); } catch (_err) { void _err; } };
886
+ heal();
887
+ let observer = null;
888
+ try {
889
+ if (typeof MutationObserver === 'function') {
890
+ observer = new MutationObserver(heal);
891
+ if (document.head) observer.observe(document.head, { childList: true });
892
+ if (document.documentElement) observer.observe(document.documentElement, { childList: true });
893
+ }
894
+ } catch (_err) { observer = null; }
895
+ let timer = null;
896
+ try { timer = setInterval(heal, STYLE_HEAL_INTERVAL_MS); } catch (_err) { timer = null; }
897
+ const onVisibility = () => { if (document.visibilityState !== 'hidden') heal(); };
898
+ try { document.addEventListener('visibilitychange', onVisibility); } catch (_err) { void _err; }
899
+ const stop = () => {
900
+ try { if (observer) observer.disconnect(); } catch (_err) { void _err; }
901
+ try { if (timer !== null) clearInterval(timer); } catch (_err) { void _err; }
902
+ try { document.removeEventListener('visibilitychange', onVisibility); } catch (_err) { void _err; }
903
+ if (typeof window !== 'undefined' && window[STYLE_HEAL_KEY] === stop) window[STYLE_HEAL_KEY] = undefined;
904
+ };
905
+ if (typeof window !== 'undefined') window[STYLE_HEAL_KEY] = stop;
906
+ return stop;
861
907
  }
862
908
 
863
909
  function createErrorBoundary() {
@@ -4014,6 +4060,9 @@ window.__ModuleLoader__.load({
4014
4060
 
4015
4061
  const applyActive = () => {
4016
4062
  if (toggle.isOpen()) {
4063
+ // Repair a dropped stylesheet before the panel paints, so opening the
4064
+ // screen never shows an unstyled page (see startStyleSelfHeal).
4065
+ ensureStyles();
4017
4066
  document.documentElement.setAttribute(ACTIVE_ATTR, '');
4018
4067
  document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }));
4019
4068
  } else {
@@ -4070,7 +4119,7 @@ window.__ModuleLoader__.load({
4070
4119
  }
4071
4120
 
4072
4121
  function apply(ctx) {
4073
- ensureStyles();
4122
+ const stopStyleSelfHeal = startStyleSelfHeal();
4074
4123
 
4075
4124
  // Register the English canonical strings; rebinding the translator lets
4076
4125
  // the translation plugin drive the active language at runtime (#87).
@@ -4158,7 +4207,7 @@ window.__ModuleLoader__.load({
4158
4207
  mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
4159
4208
  mountCronScreen(ctx, toggle)
4160
4209
  ];
4161
- return () => { for (const dispose of off) dispose(); };
4210
+ return () => { for (const dispose of off) dispose(); stopStyleSelfHeal(); };
4162
4211
  }, 'dsh-cron: client overlay');
4163
4212
 
4164
4213
  exports.slots = { card: cardSlot, chip: chipSlot };
package/lib/cron-tool.js CHANGED
@@ -251,6 +251,60 @@ export const cronToolParameters = {
251
251
  type: 'number',
252
252
  description: 'Maximum allowed token usage before auto-pausing',
253
253
  },
254
+ agentPreset: {
255
+ type: 'string',
256
+ description: 'Agent preset name applied to the task session',
257
+ },
258
+ targetSessionId: {
259
+ type: 'string',
260
+ description: 'Persistent session ID for context continuity',
261
+ },
262
+ targetSessionReset: {
263
+ type: 'string',
264
+ enum: ['never', 'daily', 'weekly'],
265
+ description: 'Context rotation policy for target session: never | daily | weekly',
266
+ },
267
+ onSuccess: {
268
+ type: 'string',
269
+ description: 'Task ID to trigger on successful run',
270
+ },
271
+ onFailure: {
272
+ type: 'string',
273
+ description: 'Task ID to trigger on failed run',
274
+ },
275
+ heartbeatIntervalSeconds: {
276
+ type: 'number',
277
+ description: 'Expected heartbeat interval in seconds (0 = disabled)',
278
+ },
279
+ gracePeriodSeconds: {
280
+ type: 'number',
281
+ description: 'Grace period before alerting on missed heartbeat',
282
+ },
283
+ preflightType: {
284
+ type: 'string',
285
+ enum: ['none', 'http', 'shell', 'command', 'disk'],
286
+ description: 'Pre-flight health check type: none | http | shell | disk',
287
+ },
288
+ preflightTarget: {
289
+ type: 'string',
290
+ description: 'Pre-flight check target: URL, shell command, or path:minPercent / path:minMb',
291
+ },
292
+ concurrencyGroup: {
293
+ type: 'string',
294
+ description: 'Named concurrency group to serialize related tasks',
295
+ },
296
+ priority: {
297
+ type: 'number',
298
+ description: 'Task priority (1 = highest, 10 = lowest, default 5)',
299
+ },
300
+ selfHealingCommand: {
301
+ type: 'string',
302
+ description: 'Shell command executed to recover from run failure',
303
+ },
304
+ autoDiagnose: {
305
+ type: 'boolean',
306
+ description: 'Automatically run AI failure diagnosis and store in runbook',
307
+ },
254
308
  };
255
309
 
256
310
  export const cronToolOutput = {
@@ -66,7 +66,7 @@ export function createExternalApiHandler({ store, scheduler, getToken }) {
66
66
  // for example) surfaces as a thrown error from the shared handlers, and an
67
67
  // unhandled rejection here would answer nothing at all.
68
68
  try {
69
- await handleExternalTaskRequest({ store, scheduler, req, res, url });
69
+ await handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken: expected });
70
70
  } catch (err) {
71
71
  sendJson(res, (err && err.statusCode) || 500, { ok: false, error: (err && err.message) || String(err) });
72
72
  }
@@ -91,7 +91,7 @@ export function shouldCreateKanbanCard(task, runInfo) {
91
91
  if (mode === 'none') return false;
92
92
  if (mode === 'always') return true;
93
93
  if (mode === 'on_failure') {
94
- return runInfo.status === 'error' || runInfo.status === 'timeout';
94
+ return runInfo.status === 'error' || runInfo.status === 'timeout' || runInfo.status === 'missed';
95
95
  }
96
96
  return false;
97
97
  }
@@ -24,12 +24,21 @@ export function addUsage(a, b) {
24
24
  * Execute pre-flight check gate before running task body.
25
25
  */
26
26
  export async function executePreflight(task) {
27
- const type = String(task.preflightType || 'none').toLowerCase();
27
+ const type = String(task.preflightType || 'none').trim().toLowerCase();
28
28
  const target = String(task.preflightTarget || '').trim();
29
- if (!type || type === 'none' || !target) {
29
+ if (!type || type === 'none') {
30
30
  return { ok: true };
31
31
  }
32
32
 
33
+ const KNOWN_PREFLIGHT_TYPES = ['http', 'command', 'shell', 'disk'];
34
+ if (!KNOWN_PREFLIGHT_TYPES.includes(type)) {
35
+ return { ok: false, reason: `Unknown preflight type "${type}"` };
36
+ }
37
+
38
+ if (!target) {
39
+ return { ok: false, reason: `Preflight target is required for type "${type}"` };
40
+ }
41
+
33
42
  if (type === 'http') {
34
43
  try {
35
44
  const controller = new AbortController();
@@ -43,7 +52,7 @@ export async function executePreflight(task) {
43
52
  }
44
53
  }
45
54
 
46
- if (type === 'command') {
55
+ if (type === 'command' || type === 'shell') {
47
56
  return new Promise((resolve) => {
48
57
  exec(target, { timeout: 5000 }, (err, stdout, stderr) => {
49
58
  if (err) {
@@ -56,23 +65,49 @@ export async function executePreflight(task) {
56
65
  }
57
66
 
58
67
  if (type === 'disk') {
59
- const requiredMb = Number(target) || 100;
68
+ let diskPath = task.cwd || process.cwd();
69
+ let requirement = target;
70
+
71
+ // Check for "path:requirement", taking care not to split Windows drive letters (e.g. C:\:10%)
72
+ const lastColon = target.lastIndexOf(':');
73
+ if (lastColon > 0) {
74
+ const candidatePath = target.slice(0, lastColon).trim();
75
+ const candidateReq = target.slice(lastColon + 1).trim();
76
+ if (candidateReq) {
77
+ diskPath = candidatePath;
78
+ requirement = candidateReq;
79
+ }
80
+ }
81
+
82
+ const isPercent = requirement.endsWith('%');
83
+ const numericVal = parseFloat(requirement);
84
+ if (isNaN(numericVal) || numericVal <= 0) {
85
+ return { ok: false, reason: `Invalid preflight disk target requirement: "${requirement}"` };
86
+ }
87
+
60
88
  try {
61
- if (typeof fs.statfsSync === 'function') {
62
- const stats = fs.statfsSync(process.cwd());
89
+ if (typeof fs.statfsSync !== 'function') {
90
+ return { ok: false, reason: 'statfsSync is not available on this platform' };
91
+ }
92
+ const stats = fs.statfsSync(diskPath);
93
+ if (isPercent) {
94
+ const freePercent = stats.blocks > 0 ? (stats.bavail / stats.blocks) * 100 : 0;
95
+ if (freePercent < numericVal) {
96
+ return { ok: false, reason: `Free disk space on "${diskPath}" (${freePercent.toFixed(1)}%) is below required ${numericVal}%` };
97
+ }
98
+ } else {
63
99
  const freeMb = Math.round((stats.bavail * stats.bsize) / (1024 * 1024));
64
- if (freeMb < requiredMb) {
65
- return { ok: false, reason: `Free disk space (${freeMb} MB) is below required ${requiredMb} MB` };
100
+ if (freeMb < numericVal) {
101
+ return { ok: false, reason: `Free disk space on "${diskPath}" (${freeMb} MB) is below required ${numericVal} MB` };
66
102
  }
67
103
  }
68
104
  return { ok: true };
69
105
  } catch (err) {
70
- // Fail-open if statfsSync is unavailable or throws
71
- return { ok: true };
106
+ return { ok: false, reason: `Preflight disk check failed: filesystem "${diskPath}" not accessible (${err.message})` };
72
107
  }
73
108
  }
74
109
 
75
- return { ok: true };
110
+ return { ok: false, reason: `Unsupported preflight type "${type}"` };
76
111
  }
77
112
 
78
113
  /** Only agent-mediated types use a model, and only a failed run falls back. */
@@ -18,6 +18,23 @@ export function executeCreateTask(store, scheduler, args) {
18
18
  status: 'active',
19
19
  provider: args.provider,
20
20
  model: args.model,
21
+ fallbackProvider: args.fallbackProvider ? String(args.fallbackProvider).trim() : undefined,
22
+ fallbackModel: args.fallbackModel ? String(args.fallbackModel).trim() : undefined,
23
+ silentRule: args.silentRule !== undefined ? String(args.silentRule) : '',
24
+ inspectOnFailure: args.inspectOnFailure !== undefined ? Boolean(args.inspectOnFailure) : false,
25
+ agentPreset: args.agentPreset ? String(args.agentPreset).trim() : '',
26
+ targetSessionId: args.targetSessionId ? String(args.targetSessionId).trim() : '',
27
+ targetSessionReset: args.targetSessionReset ? String(args.targetSessionReset).trim() : 'never',
28
+ onSuccess: args.onSuccess ? String(args.onSuccess).trim() : '',
29
+ onFailure: args.onFailure ? String(args.onFailure).trim() : '',
30
+ heartbeatIntervalSeconds: Number(args.heartbeatIntervalSeconds) || 0,
31
+ gracePeriodSeconds: Number(args.gracePeriodSeconds) || 300,
32
+ preflightType: args.preflightType ? String(args.preflightType).trim().toLowerCase() : 'none',
33
+ preflightTarget: args.preflightTarget ? String(args.preflightTarget).trim() : '',
34
+ concurrencyGroup: args.concurrencyGroup ? String(args.concurrencyGroup).trim() : 'default',
35
+ priority: Number(args.priority) || 5,
36
+ selfHealingCommand: args.selfHealingCommand ? String(args.selfHealingCommand).trim() : '',
37
+ autoDiagnose: Boolean(args.autoDiagnose),
21
38
  notifyTelegram: Boolean(args.notifyTelegram),
22
39
  onlyOnFailure: Boolean(args.onlyOnFailure),
23
40
  timeoutSeconds: Number(args.timeoutSeconds) || 1800,
package/lib/task-patch.js CHANGED
@@ -27,8 +27,8 @@ export function isCodeExecutionIntroduced(current, body) {
27
27
  if (!current || !body) return false;
28
28
  if (body.type !== undefined && isCodeTypeSwitch(current, body.type)) return true;
29
29
 
30
- const hadPreflight = Boolean(current.preflightCommand && String(current.preflightCommand).trim()) || current.preflightType === 'command';
31
- const hasPreflight = Boolean(body.preflightCommand && String(body.preflightCommand).trim()) || body.preflightType === 'command';
30
+ const hadPreflight = Boolean(current.preflightCommand && String(current.preflightCommand).trim()) || current.preflightType === 'command' || current.preflightType === 'shell';
31
+ const hasPreflight = Boolean(body.preflightCommand && String(body.preflightCommand).trim()) || body.preflightType === 'command' || body.preflightType === 'shell';
32
32
  if (hasPreflight && !hadPreflight) return true;
33
33
 
34
34
  const hadSelfHealing = Boolean(current.selfHealingCommand && String(current.selfHealingCommand).trim());
@@ -228,7 +228,7 @@ export function isCodeExecutingTask(task) {
228
228
  const CODE_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
229
229
  if (CODE_TYPES.includes(normalizeTaskType(task.type))) return true;
230
230
  if (task.command && String(task.command).trim()) return true;
231
- if (task.preflightType === 'command' || (task.preflightCommand && String(task.preflightCommand).trim())) return true;
231
+ if (task.preflightType === 'command' || task.preflightType === 'shell' || (task.preflightCommand && String(task.preflightCommand).trim())) return true;
232
232
  if (task.selfHealingCommand && String(task.selfHealingCommand).trim()) return true;
233
233
  if (task.preflightScript && String(task.preflightScript).trim()) return true;
234
234
  return false;
package/lib/telegram.js CHANGED
@@ -210,7 +210,7 @@ export function shouldNotifyTask(task, runInfo, globalSettings = {}) {
210
210
  const isEnabled = task.notifyTelegram ?? globalSettings.notifyTelegram ?? false;
211
211
  if (!isEnabled) return false;
212
212
 
213
- const failed = runInfo.status === 'error' || runInfo.status === 'timeout';
213
+ const failed = runInfo.status === 'error' || runInfo.status === 'timeout' || runInfo.status === 'missed';
214
214
  const onlyOnFail = task.onlyOnFailure ?? globalSettings.onlyOnFailure ?? false;
215
215
  if (onlyOnFail && !failed) {
216
216
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",