@goodandready/dsh-cron 0.2.1 → 0.2.3
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 -2
- package/docs/README.ru.md +24 -3
- package/docs/README.zh.md +22 -2
- package/lib/client.js +208 -62
- package/lib/http-utils.js +11 -5
- package/lib/index.js +93 -12
- package/lib/runner.js +29 -1
- package/lib/scheduler.js +117 -50
- package/lib/store.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -122,9 +122,21 @@ Powered by `croner`, supporting standard 5-field cron expressions plus user-frie
|
|
|
122
122
|
* `*/15 * * * *` — every 15 minutes
|
|
123
123
|
* `0 0 * * 0` — every Sunday at midnight
|
|
124
124
|
* `every 10m` / `every 2h` / `every 30s` — natural duration intervals
|
|
125
|
-
* `daily` / `hourly` / `weekdays` shortcuts
|
|
125
|
+
* `daily` / `hourly` / `weekdays` shortcuts, plus standard `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` and `@every 30m`
|
|
126
|
+
* **Per-task time zones** — set an IANA zone (e.g. `Europe/Berlin`) on a task; without it the schedule follows the server's local time
|
|
126
127
|
* **One-shot tasks**: `at: 2026-09-05T15:00:00Z` (exact ISO timestamp) or relative delays `in 20m` / `in 2h` (Russian aliases such as `через 15 минут` are accepted too). One-shot tasks flip to `completed` automatically after their single run and are listed under the **Completed** tab.
|
|
127
128
|
|
|
129
|
+
### 5. Execution Reliability
|
|
130
|
+
* **Automatic retries** — set `maxRetries` and a base `retryBackoffMs` per task; failed runs (`error`/`timeout`) are retried with exponential backoff, and the attempt counter resets on success.
|
|
131
|
+
* **Misfire policies** — choose per task what happens when the daemon was offline at a scheduled time: `skip` (default — record the gap), `runOnce` (execute once, late), or `catchUpAll` (run late and record the gap). A missed one-shot under `skip` is retired as `completed` instead of firing stale.
|
|
132
|
+
* **Concurrency limit** — `maxConcurrent` (plugin setting) caps parallel runs; extra runs are recorded as `skipped` with a reason.
|
|
133
|
+
* **Live execution indicator** — the task list shows a pulsing status icon and a running timer for the task in flight.
|
|
134
|
+
|
|
135
|
+
### 6. Session Integration & Permissions
|
|
136
|
+
* **Per-task permission presets** — `default`, `read-only`, `workspace-write`, or `full` are applied to the task's agent session before the prompt runs.
|
|
137
|
+
* **Session auto-archive** — isolated cron sessions are archived after each run (best-effort) so they do not clutter the chat list.
|
|
138
|
+
* **History → session navigation** — every LLM run records its session; open it straight from the run history entry.
|
|
139
|
+
|
|
128
140
|
### 5. Telegram Notifications & Delivery Routing
|
|
129
141
|
Direct integration with the Telegram Bot API delivers execution reports and error traces straight to your messenger:
|
|
130
142
|
|
|
@@ -148,6 +160,10 @@ Prevent rogue processes from stacking concurrent duplicate executions:
|
|
|
148
160
|
|
|
149
161
|
If the daemon was offline at a scheduled time, the run is recorded as `missed` on startup, so gaps in the history stay visible.
|
|
150
162
|
|
|
163
|
+
### 8. Heartbeat Monitoring (#16-style dead man's switch)
|
|
164
|
+
* Set `heartbeatUrl` and `heartbeatIntervalSec` in the plugin settings and the scheduler pings that URL on schedule — an external monitor alerts when the pings stop.
|
|
165
|
+
* A built-in `GET /dsh-cron/heartbeat` endpoint reports liveness, active task count and the last run time for your own watchdogs.
|
|
166
|
+
|
|
151
167
|
---
|
|
152
168
|
|
|
153
169
|
## 📦 Installation
|
|
@@ -174,6 +190,10 @@ dsh-cron:
|
|
|
174
190
|
notifyTelegram: false # deliver reports for every task globally
|
|
175
191
|
onlyOnFailure: false # deliver reports only for failed runs
|
|
176
192
|
kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API base URL
|
|
193
|
+
defaultTimezone: "" # default IANA time zone for schedules (empty = server local)
|
|
194
|
+
maxConcurrent: 0 # max parallel task runs (0 = unlimited)
|
|
195
|
+
heartbeatUrl: "" # dead man's snitch URL pinged on the heartbeat interval
|
|
196
|
+
heartbeatIntervalSec: 0 # heartbeat ping interval in seconds (0 = off)
|
|
177
197
|
```
|
|
178
198
|
|
|
179
199
|
### Configuration Parameters
|
|
@@ -185,6 +205,10 @@ dsh-cron:
|
|
|
185
205
|
| `notifyTelegram` | `boolean` | `false` | Global switch: deliver run reports to Telegram |
|
|
186
206
|
| `onlyOnFailure` | `boolean` | `false` | Global switch: deliver reports only for `error`/`timeout` runs |
|
|
187
207
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Base URL of the `dsh-kanban` HTTP API used for automatic card creation |
|
|
208
|
+
| `defaultTimezone` | `string` | `""` | Default IANA time zone for task schedules; empty = server local time |
|
|
209
|
+
| `maxConcurrent` | `number` | `0` | Cap on parallel task runs; extra runs are recorded as `skipped` (0 = unlimited) |
|
|
210
|
+
| `heartbeatUrl` | `string` | `""` | Dead man's snitch URL pinged every `heartbeatIntervalSec` while the scheduler is alive |
|
|
211
|
+
| `heartbeatIntervalSec` | `number` | `0` | Heartbeat ping interval in seconds (0 = disabled) |
|
|
188
212
|
|
|
189
213
|
Notes:
|
|
190
214
|
|
|
@@ -212,7 +236,8 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
|
|
|
212
236
|
| `GET` | `/dsh-cron/models` | List LLM providers; `?provider=<id>` lists models |
|
|
213
237
|
| `POST` | `/dsh-cron/chat/start` | Start a "Create with DSH" agent session with the task-setup instructions |
|
|
214
238
|
| `GET` | `/dsh-cron/settings` | Client-safe settings (token masked) |
|
|
215
|
-
| `POST` | `/dsh-cron/settings` | Update integration settings |
|
|
239
|
+
| `POST` | `/dsh-cron/settings` | Update integration settings (applied through the settings service) |
|
|
240
|
+
| `GET` | `/dsh-cron/heartbeat` | Liveness probe: active task count, last run time, server time |
|
|
216
241
|
| `POST` | `/dsh-cron/telegram/test` | Send a Telegram test message |
|
|
217
242
|
| `POST` | `/dsh-cron/kanban/test` | Create a Kanban connectivity-test card |
|
|
218
243
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy alias for the task action routes (`run`, `toggle`, `delete`, `history`) |
|
package/docs/README.ru.md
CHANGED
|
@@ -121,9 +121,21 @@ cron_create_task({
|
|
|
121
121
|
* `*/15 * * * *` — каждые 15 минут
|
|
122
122
|
* `0 0 * * 0` — каждое воскресенье в полночь
|
|
123
123
|
* `every 10m` / `every 2h` / `every 30s` — естественные интервалы
|
|
124
|
-
* алиасы `daily` / `hourly` / `weekdays`
|
|
124
|
+
* алиасы `daily` / `hourly` / `weekdays`, а также стандартные `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` и `@every 30m`
|
|
125
|
+
* **Часовые пояса** — для задачи можно указать IANA-зону (например, `Europe/Berlin`); без неё расписание живёт в серверном времени
|
|
125
126
|
* **Разовые задачи**: `at: 2026-09-05T15:00:00Z` (точный ISO-таймстемп) или относительные задержки `in 20m` / `in 2h` (принимаются и русские варианты вроде `через 15 минут`). После единственного запуска задача автоматически переходит в `completed` и отображается на табе **Завершённые**.
|
|
126
127
|
|
|
128
|
+
### 5. Надёжность исполнения
|
|
129
|
+
* **Автоповторы** — `maxRetries` и база `retryBackoffMs` на задачу: упавшие запуски (error/timeout) повторяются с экспоненциальной задержкой, счётчик сбрасывается после успеха.
|
|
130
|
+
* **Misfire-политики** — что делать с пропущенным за время простоя запуском: `skip` (по умолчанию — записать пропуск), `runOnce` (выполнить один раз с опозданием) или `catchUpAll` (выполнить и зафиксировать пропуск). Пропущенный one-shot при `skip` уходит в `completed` без выполнения.
|
|
131
|
+
* **Лимит параллельности** — настройка `maxConcurrent` ограничивает число одновременных запусков; лишние помечаются `skipped` с причиной.
|
|
132
|
+
* **Живой индикатор** — в списке задач пульсирует статус и идёт таймер текущего запуска.
|
|
133
|
+
|
|
134
|
+
### 6. Интеграция сессий и права
|
|
135
|
+
* **Permission-пресеты на задачу** — `default`, `read-only`, `workspace-write` или `full` применяются к сессии агента перед запуском промпта.
|
|
136
|
+
* **Автоархивация сессий** — изолированные cron-сессии архивируются после запуска (best-effort), не засоряя список чатов.
|
|
137
|
+
* **История → сессия** — каждый LLM-запуск хранит свою сессию; открыть диалог можно прямо из записи истории.
|
|
138
|
+
|
|
127
139
|
### 5. Уведомления Telegram
|
|
128
140
|
Прямая интеграция с Telegram Bot API доставляет отчёты о запусках и трейсы ошибок в мессенджер:
|
|
129
141
|
|
|
@@ -170,6 +182,10 @@ dsh-cron:
|
|
|
170
182
|
notifyTelegram: false # глобально отправлять отчёты о всех задачах
|
|
171
183
|
onlyOnFailure: false # отправлять отчёты только при сбоях
|
|
172
184
|
kanbanBaseUrl: "http://127.0.0.1:3000" # базовый URL HTTP API dsh-kanban
|
|
185
|
+
defaultTimezone: "" # IANA-зона по умолчанию (пусто = серверное время)
|
|
186
|
+
maxConcurrent: 0 # максимум параллельных запусков (0 = без лимита)
|
|
187
|
+
heartbeatUrl: "" # URL dead man's snitch, пингуется по интервалу
|
|
188
|
+
heartbeatIntervalSec: 0 # интервал heartbeat-пинга в секундах (0 = выключено)
|
|
173
189
|
```
|
|
174
190
|
|
|
175
191
|
### Параметры
|
|
@@ -181,11 +197,15 @@ dsh-cron:
|
|
|
181
197
|
| `notifyTelegram` | `boolean` | `false` | Глобальный выключатель доставки отчётов в Telegram |
|
|
182
198
|
| `onlyOnFailure` | `boolean` | `false` | Глобальный режим «только при сбоях» (`error`/`timeout`) |
|
|
183
199
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Базовый URL HTTP API `dsh-kanban` для автоматических карточек |
|
|
200
|
+
| `defaultTimezone` | `string` | `""` | IANA-зона по умолчанию для расписаний; пусто = серверное время |
|
|
201
|
+
| `maxConcurrent` | `number` | `0` | Лимит параллельных запусков; лишние помечаются `skipped` (0 = без лимита) |
|
|
202
|
+
| `heartbeatUrl` | `string` | `""` | URL dead man's snitch, пингуемый каждый `heartbeatIntervalSec`, пока жив планировщик |
|
|
203
|
+
| `heartbeatIntervalSec` | `number` | `0` | Интервал heartbeat-пинга в секундах (0 = выключено) |
|
|
184
204
|
|
|
185
205
|
Примечания:
|
|
186
206
|
|
|
187
207
|
* История запусков ограничена **50 записями на задачу** (фиксировано); в записи хранится до 4000 символов вывода.
|
|
188
|
-
* Задачи выполняются в **локальном часовом поясе
|
|
208
|
+
* Задачи выполняются в **локальном часовом поясе сервера**, если для задачи не указана своя IANA-зона; cron-выражения вычисляет `croner` по часам хоста.
|
|
189
209
|
* Задачи сохраняются в каталоге данных DSH (`cron/tasks.json`) и переживают перезапуск; пропущенные one-shot запуски обнаруживаются при старте.
|
|
190
210
|
|
|
191
211
|
---
|
|
@@ -208,7 +228,8 @@ dsh-cron:
|
|
|
208
228
|
| `GET` | `/dsh-cron/models` | Список LLM-провайдеров; `?provider=<id>` — модели |
|
|
209
229
|
| `POST` | `/dsh-cron/chat/start` | Старт агентской сессии «Создать с DSH» с инструкциями планировщика |
|
|
210
230
|
| `GET` | `/dsh-cron/settings` | Настройки для клиента (токен замаскирован) |
|
|
211
|
-
| `POST` | `/dsh-cron/settings` | Обновление настроек интеграций |
|
|
231
|
+
| `POST` | `/dsh-cron/settings` | Обновление настроек интеграций (через службу настроек) |
|
|
232
|
+
| `GET` | `/dsh-cron/heartbeat` | Probe живости: число активных задач, время последнего запуска |
|
|
212
233
|
| `POST` | `/dsh-cron/telegram/test` | Тестовое сообщение в Telegram |
|
|
213
234
|
| `POST` | `/dsh-cron/kanban/test` | Тестовая карточка в Kanban |
|
|
214
235
|
| `*` | `/dsh-cron/action/:id/:action` | Legacy-алиас действий над задачей (`run`, `toggle`, `delete`, `history`) |
|
package/docs/README.zh.md
CHANGED
|
@@ -121,9 +121,16 @@ cron_create_task({
|
|
|
121
121
|
* `*/15 * * * *` —— 每 15 分钟
|
|
122
122
|
* `0 0 * * 0` —— 每周日午夜
|
|
123
123
|
* `every 10m` / `every 2h` / `every 30s` —— 自然语言间隔
|
|
124
|
-
* `daily` / `hourly` / `weekdays`
|
|
124
|
+
* `daily` / `hourly` / `weekdays` 快捷方式,以及标准 `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` 与 `@every 30m`
|
|
125
|
+
* **任务级时区** —— 可为任务设置 IANA 时区(如 `Europe/Berlin`);未设置时按服务器本地时间调度
|
|
125
126
|
* **一次性任务**:`at: 2026-09-05T15:00:00Z`(精确 ISO 时间戳)或相对延时 `in 20m` / `in 2h`(也接受 `через 15 минут` 之类的俄语输入)。一次性任务在单次运行后自动转为 `completed`,显示在 **已完成** 标签下。
|
|
126
127
|
|
|
128
|
+
### 5. 执行可靠性
|
|
129
|
+
* **自动重试** —— 按任务设置 `maxRetries` 与基础 `retryBackoffMs`:失败(`error`/`timeout`)的运行按指数退避自动重试,成功后计数归零。
|
|
130
|
+
* **Misfire 策略** —— 选择守护进程离线期间错过的运行如何处理:`skip`(默认 —— 记录缺口)、`runOnce`(迟执行一次)或 `catchUpAll`(迟执行并记录缺口)。`skip` 下错过的一次性任务直接转为 `completed`,不再过期触发。
|
|
131
|
+
* **并发上限** —— 插件设置 `maxConcurrent` 限制并行运行数;超出的运行记录为 `skipped` 并附原因。
|
|
132
|
+
* **实时执行指示** —— 任务列表中的脉冲状态图标与运行计时器。
|
|
133
|
+
|
|
127
134
|
### 5. Telegram 通知与投递路由
|
|
128
135
|
通过与 Telegram Bot API 的直接集成,将执行报告与错误跟踪推送到你的即时通讯工具:
|
|
129
136
|
|
|
@@ -146,6 +153,10 @@ cron_create_task({
|
|
|
146
153
|
|
|
147
154
|
如果守护进程在计划时刻处于离线状态,启动时该次运行会被记录为 `missed`,历史空档始终可见。
|
|
148
155
|
|
|
156
|
+
### 8. 心跳监控(Dead man's switch)
|
|
157
|
+
* 在插件设置中配置 `heartbeatUrl` 与 `heartbeatIntervalSec`,调度器会按间隔 GET 该地址 —— 外部监控可在心跳停止时告警。
|
|
158
|
+
* 内置 `GET /dsh-cron/heartbeat` 端点返回存活状态、活跃任务数与最近运行时间,便于自建看门狗。
|
|
159
|
+
|
|
149
160
|
---
|
|
150
161
|
|
|
151
162
|
## 📦 安装
|
|
@@ -170,6 +181,10 @@ dsh-cron:
|
|
|
170
181
|
notifyTelegram: false # 全局投递所有任务的报告
|
|
171
182
|
onlyOnFailure: false # 仅失败时投递报告
|
|
172
183
|
kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API 基础地址
|
|
184
|
+
defaultTimezone: "" # 默认 IANA 时区(空 = 服务器本地)
|
|
185
|
+
maxConcurrent: 0 # 最大并行运行数(0 = 不限)
|
|
186
|
+
heartbeatUrl: "" # 心跳上报 URL(dead man's snitch)
|
|
187
|
+
heartbeatIntervalSec: 0 # 心跳间隔秒数(0 = 关闭)
|
|
173
188
|
```
|
|
174
189
|
|
|
175
190
|
### 配置参数
|
|
@@ -181,6 +196,10 @@ dsh-cron:
|
|
|
181
196
|
| `notifyTelegram` | `boolean` | `false` | 全局开关:向 Telegram 投递运行报告 |
|
|
182
197
|
| `onlyOnFailure` | `boolean` | `false` | 全局开关:仅对 `error`/`timeout` 运行投递报告 |
|
|
183
198
|
| `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | 用于自动卡片的 `dsh-kanban` HTTP API 基础地址 |
|
|
199
|
+
| `defaultTimezone` | `string` | `""` | 任务调度的默认 IANA 时区;空 = 服务器本地时间 |
|
|
200
|
+
| `maxConcurrent` | `number` | `0` | 并行运行上限;超出的运行记录为 `skipped`(0 = 不限) |
|
|
201
|
+
| `heartbeatUrl` | `string` | `""` | 心跳上报 URL,调度器存活期间按 `heartbeatIntervalSec` 间隔 GET |
|
|
202
|
+
| `heartbeatIntervalSec` | `number` | `0` | 心跳间隔秒数(0 = 关闭) |
|
|
184
203
|
|
|
185
204
|
说明:
|
|
186
205
|
|
|
@@ -208,7 +227,8 @@ dsh-cron:
|
|
|
208
227
|
| `GET` | `/dsh-cron/models` | 列出 LLM 提供方;`?provider=<id>` 列出模型 |
|
|
209
228
|
| `POST` | `/dsh-cron/chat/start` | 启动带任务配置指令的“由 DSH 创建”智能体会话 |
|
|
210
229
|
| `GET` | `/dsh-cron/settings` | 客户端安全设置(令牌掩码显示) |
|
|
211
|
-
| `POST` | `/dsh-cron/settings` |
|
|
230
|
+
| `POST` | `/dsh-cron/settings` | 更新集成设置(通过设置服务应用) |
|
|
231
|
+
| `GET` | `/dsh-cron/heartbeat` | 存活探针:活跃任务数与最近运行时间 |
|
|
212
232
|
| `POST` | `/dsh-cron/telegram/test` | 发送 Telegram 测试消息 |
|
|
213
233
|
| `POST` | `/dsh-cron/kanban/test` | 创建 Kanban 连通性测试卡片 |
|
|
214
234
|
| `*` | `/dsh-cron/action/:id/:action` | 任务操作路由的兼容别名(`run`、`toggle`、`delete`、`history`) |
|
package/lib/client.js
CHANGED
|
@@ -114,6 +114,20 @@ window.__ModuleLoader__.load({
|
|
|
114
114
|
'form.promptLabelLlm': 'Instruction / Prompt for the agent',
|
|
115
115
|
'form.promptPlaceholderScript': 'curl -fsSL https://... || exit 1',
|
|
116
116
|
'form.promptPlaceholderLlm': 'Describe exactly what the agent should do...',
|
|
117
|
+
'form.timezoneLabel': 'Time zone (IANA, optional)',
|
|
118
|
+
'form.timezonePlaceholder': 'e.g. Europe/Berlin (empty = server local)',
|
|
119
|
+
'form.misfireLabel': 'On missed run (after downtime)',
|
|
120
|
+
'form.misfireSkip': 'Skip the missed run (skip)',
|
|
121
|
+
'form.misfireRunOnce': 'Run once late (runOnce)',
|
|
122
|
+
'form.misfireCatchUp': 'Run late once and record the gap (catchUpAll)',
|
|
123
|
+
'form.retriesLabel': 'Automatic retries on failure (0 = off)',
|
|
124
|
+
'form.permissionLabel': 'Session permissions',
|
|
125
|
+
'form.permissionDefault': 'Default (as configured in DSH)',
|
|
126
|
+
'form.permissionReadonly': 'Read-only',
|
|
127
|
+
'form.permissionWrite': 'Workspace write',
|
|
128
|
+
'form.permissionFull': 'Full',
|
|
129
|
+
'list.runningNow': 'Running now',
|
|
130
|
+
'history.openSession': 'Open session',
|
|
117
131
|
'form.notifyTitle': 'Telegram notifications',
|
|
118
132
|
'form.notifyTelegram': 'Send the run report to Telegram',
|
|
119
133
|
'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
|
|
@@ -180,20 +194,57 @@ window.__ModuleLoader__.load({
|
|
|
180
194
|
} catch (e) {}
|
|
181
195
|
const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
|
|
182
196
|
|
|
183
|
-
//
|
|
184
|
-
//
|
|
197
|
+
// Own React error boundary (#106): the core's SlotErrorBoundary renders an
|
|
198
|
+
// empty div on crash, so a slot failure would silently vanish. Ours keeps
|
|
199
|
+
// the failure visible with a retry.
|
|
200
|
+
function createErrorBoundary() {
|
|
201
|
+
if (!React || typeof React.Component !== 'function') {
|
|
202
|
+
return function NoopBoundary(props) { return (props && props.children) || null; };
|
|
203
|
+
}
|
|
204
|
+
return class ErrorBoundary extends React.Component {
|
|
205
|
+
constructor(props) {
|
|
206
|
+
super(props);
|
|
207
|
+
this.state = { hasError: false, error: null };
|
|
208
|
+
}
|
|
209
|
+
static getDerivedStateFromError(error) {
|
|
210
|
+
return { hasError: true, error };
|
|
211
|
+
}
|
|
212
|
+
componentDidCatch(error, errorInfo) {
|
|
213
|
+
console.error('[dsh-cron] React error:', error, errorInfo);
|
|
214
|
+
}
|
|
215
|
+
render() {
|
|
216
|
+
if (this.state.hasError) {
|
|
217
|
+
return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
|
|
218
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
|
|
219
|
+
React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
|
|
220
|
+
React.createElement('button', {
|
|
221
|
+
type: 'button',
|
|
222
|
+
className: 'dsh-cron-btn-secondary',
|
|
223
|
+
style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
|
|
224
|
+
onClick: () => this.setState({ hasError: false, error: null })
|
|
225
|
+
}, 'Retry')
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return (this.props && this.props.children) || null;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const ErrorBoundary = createErrorBoundary();
|
|
233
|
+
|
|
234
|
+
// Status colors: core semantic state tokens first (same set dsh-clinebot
|
|
235
|
+
// uses), plugin fallbacks as a single point of change (#105).
|
|
185
236
|
const STYLES = `
|
|
186
237
|
:root {
|
|
187
|
-
--dsh-cron-success: var(--dsw-alias-
|
|
188
|
-
--dsh-cron-success-bg:
|
|
189
|
-
--dsh-cron-danger: var(--dsw-alias-
|
|
190
|
-
--dsh-cron-danger-bg:
|
|
191
|
-
--dsh-cron-info: var(--dsw-alias-
|
|
192
|
-
--dsh-cron-info-bg:
|
|
193
|
-
--dsh-cron-accent: var(--dsw-alias-
|
|
194
|
-
--dsh-cron-accent-bg:
|
|
195
|
-
--dsh-cron-warning: var(--dsw-alias-
|
|
196
|
-
--dsh-cron-warning-bg:
|
|
238
|
+
--dsh-cron-success: var(--dsw-alias-state-success-primary, #10b981);
|
|
239
|
+
--dsh-cron-success-bg: rgba(16, 185, 129, 0.1);
|
|
240
|
+
--dsh-cron-danger: var(--dsw-alias-state-error-primary, #ef4444);
|
|
241
|
+
--dsh-cron-danger-bg: rgba(239, 68, 68, 0.1);
|
|
242
|
+
--dsh-cron-info: var(--dsw-alias-state-brand-primary, #60a5fa);
|
|
243
|
+
--dsh-cron-info-bg: rgba(96, 165, 250, 0.12);
|
|
244
|
+
--dsh-cron-accent: var(--dsw-alias-state-info-primary, #c084fc);
|
|
245
|
+
--dsh-cron-accent-bg: rgba(168, 85, 247, 0.12);
|
|
246
|
+
--dsh-cron-warning: var(--dsw-alias-state-warning-primary, #eab308);
|
|
247
|
+
--dsh-cron-warning-bg: rgba(234, 179, 8, 0.12);
|
|
197
248
|
}
|
|
198
249
|
[data-pane=conversation], [class*=centerCol] {
|
|
199
250
|
position: relative !important;
|
|
@@ -218,43 +269,46 @@ window.__ModuleLoader__.load({
|
|
|
218
269
|
.dsh-cron-entry-label { text-overflow: ellipsis; overflow: hidden; }
|
|
219
270
|
[data-dsh-frame][data-sidebar-collapsed] .dsh-cron-entry-label { display: none; }
|
|
220
271
|
|
|
221
|
-
.dsh-cron-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base, #171717); color: var(--dsw-alias-label-primary, #ededed); overflow-y: auto; padding:
|
|
222
|
-
.dsh-cron-container { max-width:
|
|
272
|
+
.dsh-cron-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base, #171717); color: var(--dsw-alias-label-primary, #ededed); overflow-y: auto; padding: 24px 48px 32px; z-index: 100; box-sizing: border-box; }
|
|
273
|
+
.dsh-cron-container { max-width: 960px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; padding: 8px 0 32px; box-sizing: border-box; }
|
|
223
274
|
|
|
224
|
-
.dsh-cron-top-bar { display: flex; align-items: center; gap: 12px;
|
|
275
|
+
.dsh-cron-top-bar { display: flex; align-items: center; gap: 12px; }
|
|
225
276
|
.dsh-cron-back-btn { appearance: none; font: inherit; cursor: pointer; color: var(--dsw-alias-label-secondary, #9ca3af); background: 0 0; border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; align-items: center; gap: 6px; padding: 4px 12px; font-size: 13px; display: inline-flex; flex: none; height: 32px; box-sizing: border-box; }
|
|
226
|
-
.dsh-cron-back-btn:hover { color: var(--dsw-alias-label-primary, #ededed); background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); }
|
|
277
|
+
.dsh-cron-back-btn:hover { color: var(--dsw-alias-label-primary, #ededed); background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06))); }
|
|
227
278
|
|
|
228
|
-
.dsh-cron-header { display: flex; justify-content: space-between; align-items: flex-start;
|
|
229
|
-
.dsh-cron-title { font-size:
|
|
230
|
-
.dsh-cron-subtitle { font-size: 14px; color: var(--dsw-alias-label-secondary, #9ca3af); }
|
|
231
|
-
.dsh-cron-create-btn { appearance: none; display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-
|
|
279
|
+
.dsh-cron-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--dsw-alias-border-l2, #383838); }
|
|
280
|
+
.dsh-cron-title { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 6px; }
|
|
281
|
+
.dsh-cron-subtitle { font-size: 14px; color: var(--dsw-alias-label-secondary, #9ca3af); line-height: 1.5; }
|
|
282
|
+
.dsh-cron-create-btn { appearance: none; display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-2, #262626); color: var(--dsw-alias-label-primary, #fff); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 9999px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; position: relative; }
|
|
232
283
|
.dsh-cron-create-btn:hover { background: var(--dsw-alias-bg-layer-hover, var(--dsw-alias-interactive-bg-hover, #333)); }
|
|
233
284
|
.dsh-cron-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 200px; background: var(--dsw-alias-bg-layer-3, #212121); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 12px; padding: 6px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); z-index: 200; }
|
|
234
285
|
.dsh-cron-dropdown-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; background: transparent; border: none; border-radius: 8px; color: var(--dsw-alias-label-primary, #ededed); font-size: 13.5px; text-align: left; cursor: pointer; }
|
|
235
286
|
.dsh-cron-dropdown-item:hover { background: var(--dsw-alias-interactive-bg-hover, #2a2a2a); }
|
|
236
|
-
.dsh-cron-search-bar { position: relative; margin-bottom:
|
|
237
|
-
.dsh-cron-search-input { width: 100%; background: var(--dsw-alias-bg-layer-
|
|
287
|
+
.dsh-cron-search-bar { position: relative; margin-bottom: 0; }
|
|
288
|
+
.dsh-cron-search-input { width: 100%; height: 36px; background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 0 12px 0 38px; color: var(--dsw-alias-label-primary, inherit); font-size: 13px; outline: none; box-sizing: border-box; }
|
|
289
|
+
.dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
|
|
238
290
|
.dsh-cron-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary, #666); }
|
|
239
|
-
.dsh-cron-tabs { display: flex;
|
|
240
|
-
.dsh-cron-tab { appearance: none; background: transparent; border:
|
|
241
|
-
.dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff);
|
|
242
|
-
.dsh-cron-task-list { display: flex; flex-direction: column; gap: 12px; margin-bottom:
|
|
291
|
+
.dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
292
|
+
.dsh-cron-tab { appearance: none; background: transparent; border: 1px solid var(--dsw-alias-border-l2, #383838); padding: 4px 12px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #9ca3af); font-size: 12px; font-weight: 500; cursor: pointer; }
|
|
293
|
+
.dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); }
|
|
294
|
+
.dsh-cron-task-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 0; }
|
|
243
295
|
.dsh-cron-task-item { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--dsw-alias-bg-layer-3, #1e1e1e); border: 1px solid var(--dsw-alias-border-l2, #2e2e2e); border-radius: 12px; cursor: pointer; transition: background 0.15s, border-color 0.15s; }
|
|
244
|
-
.dsh-cron-task-item:hover, .dsh-cron-task-item:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-border-l1, #444); outline: none; }
|
|
245
|
-
.dsh-cron-task-prompt-preview { font-size: 12.5px; color: var(--dsw-alias-label-
|
|
246
|
-
.dsh-cron-type-tag { display: inline-
|
|
247
|
-
.dsh-cron-task-model-tag { display: inline-
|
|
296
|
+
.dsh-cron-task-item:hover, .dsh-cron-task-item:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l1, #444)); outline: none; }
|
|
297
|
+
.dsh-cron-task-prompt-preview { font-size: 12.5px; color: var(--dsw-alias-label-secondary, #888); margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 520px; line-height: 1.3; }
|
|
298
|
+
.dsh-cron-type-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #383838); text-transform: uppercase; font-weight: 500; margin-left: 8px; vertical-align: middle; }
|
|
299
|
+
.dsh-cron-task-model-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #383838); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-secondary, #aaa); margin-left: 8px; vertical-align: middle; }
|
|
248
300
|
.dsh-cron-task-actions { display: flex; gap: 8px; }
|
|
249
|
-
.dsh-cron-icon-btn { background: transparent; border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 6px 12px; font-size: 12.5px; color: var(--dsw-alias-label-
|
|
250
|
-
.dsh-cron-icon-btn:hover { background: var(--dsw-alias-
|
|
251
|
-
.dsh-cron-
|
|
301
|
+
.dsh-cron-icon-btn { appearance: none; font: inherit; background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 6px 12px; font-size: 12.5px; font-weight: 500; color: var(--dsw-alias-label-primary, #aaa); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; transition: all 0.15s ease; }
|
|
302
|
+
.dsh-cron-icon-btn:hover { background: var(--dsw-alias-bg-layer-4, #2a2a2a); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #444)); }
|
|
303
|
+
.dsh-cron-icon-btn.dsh-cron-danger { color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger-bg); }
|
|
304
|
+
.dsh-cron-icon-btn.dsh-cron-danger:hover { background: var(--dsh-cron-danger-bg); border-color: var(--dsh-cron-danger); }
|
|
305
|
+
.dsh-cron-task-status-btn { background: none; border: 1px solid var(--dsw-alias-border-l2, #444); border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; flex-shrink: 0; }
|
|
252
306
|
.dsh-cron-task-status-btn[data-active="true"] { color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
|
|
253
|
-
.dsh-cron-recs-title { font-size:
|
|
307
|
+
.dsh-cron-recs-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
|
|
254
308
|
.dsh-cron-recs-list { display: flex; flex-direction: column; gap: 10px; }
|
|
255
|
-
.dsh-cron-rec-card { display: flex; align-items: center; gap: 16px; padding:
|
|
256
|
-
.dsh-cron-rec-card:hover, .dsh-cron-rec-card:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-border-l2, #383838); outline: none; }
|
|
257
|
-
.dsh-cron-rec-icon { width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-
|
|
309
|
+
.dsh-cron-rec-card { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--dsw-alias-bg-layer-3, #1c1c1c); border: 1px solid var(--dsw-alias-border-l2, #282828); border-radius: 12px; cursor: pointer; transition: background 0.15s; }
|
|
310
|
+
.dsh-cron-rec-card:hover, .dsh-cron-rec-card:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); outline: none; }
|
|
311
|
+
.dsh-cron-rec-icon { width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
258
312
|
.dsh-cron-rec-content { flex: 1; min-width: 0; }
|
|
259
313
|
.dsh-cron-rec-title { font-size: 14.5px; font-weight: 500; margin-bottom: 4px; }
|
|
260
314
|
.dsh-cron-rec-time { font-size: 13px; color: var(--dsw-alias-label-secondary, #9ca3af); font-weight: normal; margin-left: 8px; }
|
|
@@ -265,14 +319,17 @@ window.__ModuleLoader__.load({
|
|
|
265
319
|
.dsh-cron-modal-title { font-size: 18px; font-weight: 600; margin-bottom: 18px; }
|
|
266
320
|
.dsh-cron-form-group { margin-bottom: 16px; }
|
|
267
321
|
.dsh-cron-form-group label { display: block; font-size: 13px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 6px; }
|
|
268
|
-
.dsh-cron-form-group input, .dsh-cron-form-group textarea, .dsh-cron-form-group select { width: 100%; background: var(--dsw-alias-bg-
|
|
322
|
+
.dsh-cron-form-group input, .dsh-cron-form-group textarea, .dsh-cron-form-group select { width: 100%; background: var(--dsw-alias-bg-layer-2, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 8px 12px; color: var(--dsw-alias-label-primary, #fff); font-size: 13px; outline: none; box-sizing: border-box; }
|
|
323
|
+
.dsh-cron-form-group input:focus, .dsh-cron-form-group textarea:focus, .dsh-cron-form-group select:focus { outline: none; border-color: var(--dsh-cron-info); }
|
|
269
324
|
.dsh-cron-form-group select { cursor: pointer; }
|
|
270
325
|
.dsh-cron-form-group textarea { min-height: 80px; resize: vertical; }
|
|
271
326
|
.dsh-cron-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
272
327
|
.dsh-cron-modal-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 20px; }
|
|
273
|
-
.dsh-cron-btn-primary { background: var(--dsw-alias-label-primary, #ededed); color: var(--dsw-alias-bg-
|
|
328
|
+
.dsh-cron-btn-primary { background: var(--dsw-alias-label-primary, #ededed); color: var(--dsw-alias-bg-layer-3, #111); border: 1px solid transparent; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
|
|
329
|
+
.dsh-cron-btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
|
274
330
|
.dsh-cron-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
275
|
-
.dsh-cron-btn-secondary { background: transparent; color: var(--dsw-alias-label-
|
|
331
|
+
.dsh-cron-btn-secondary { appearance: none; font: inherit; background: var(--dsw-alias-bg-layer-2, transparent); color: var(--dsw-alias-label-primary, #999); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; transition: all 0.15s ease; }
|
|
332
|
+
.dsh-cron-btn-secondary:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, #2a2a2a); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #444)); }
|
|
276
333
|
.dsh-cron-chat-box { position: relative; background: var(--dsw-alias-bg-base, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 12px; padding: 12px; }
|
|
277
334
|
.dsh-cron-chat-input { width: 100%; background: transparent; border: none; color: var(--dsw-alias-label-primary, #fff); font-size: 14px; min-height: 100px; resize: vertical; outline: none; box-sizing: border-box; line-height: 1.5; }
|
|
278
335
|
.dsh-cron-chat-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l1, #222); }
|
|
@@ -281,15 +338,15 @@ window.__ModuleLoader__.load({
|
|
|
281
338
|
.dsh-cron-send-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
|
282
339
|
.dsh-cron-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
283
340
|
|
|
284
|
-
.dsh-cron-card { background: var(--dsw-alias-bg-layer-
|
|
285
|
-
.dsh-cron-card-head-btn { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding:
|
|
341
|
+
.dsh-cron-card { background: var(--dsw-alias-bg-layer-3, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 12px; padding: 18px 20px; margin-bottom: 0; display: flex; flex-direction: column; gap: 14px; }
|
|
342
|
+
.dsh-cron-card-head-btn { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding: 0; }
|
|
286
343
|
.dsh-cron-card-head-btn .dsh-cron-card-title { flex: 1; }
|
|
287
|
-
.dsh-cron-card-title { font-size:
|
|
344
|
+
.dsh-cron-card-title { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
|
|
288
345
|
.dsh-cron-chev { margin-left: auto; flex: none; color: var(--dsw-alias-label-tertiary, #888); transition: transform 0.16s; display: flex; align-items: center; }
|
|
289
346
|
.dsh-cron-chev-open { transform: rotate(180deg); }
|
|
290
|
-
.dsh-cron-card-body { border-top: 1px solid var(--dsw-alias-border-
|
|
347
|
+
.dsh-cron-card-body { border-top: 1px solid var(--dsw-alias-border-l2, #333); margin-top: 2px; padding-top: 14px; }
|
|
291
348
|
.dsh-cron-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
292
|
-
.dsh-cron-card-desc { font-size: 13px; color: var(--dsw-alias-label-secondary, #888);
|
|
349
|
+
.dsh-cron-card-desc { font-size: 13px; color: var(--dsw-alias-label-secondary, #888); line-height: 1.4; }
|
|
293
350
|
|
|
294
351
|
.dsh-cron-modal-nav { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l2, #2e2e2e); margin-bottom: 18px; padding-bottom: 2px; }
|
|
295
352
|
.dsh-cron-modal-tab { appearance: none; background: transparent; border: none; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13.5px; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; font-weight: 500; transition: color 0.15s, border-color 0.15s; }
|
|
@@ -298,21 +355,21 @@ window.__ModuleLoader__.load({
|
|
|
298
355
|
.dsh-cron-history-list { display: flex; flex-direction: column; gap: 10px; max-height: 420px; overflow-y: auto; padding-right: 4px; }
|
|
299
356
|
.dsh-cron-history-item { background: var(--dsw-alias-bg-base, #161616); border: 1px solid var(--dsw-alias-border-l1, #2a2a2a); border-radius: 8px; padding: 12px 14px; font-size: 12.5px; }
|
|
300
357
|
.dsh-cron-history-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
301
|
-
.dsh-cron-history-status { font-weight:
|
|
302
|
-
.dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); }
|
|
303
|
-
.dsh-cron-history-status[data-status="error"], .dsh-cron-history-status[data-status="timeout"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); }
|
|
304
|
-
.dsh-cron-history-status[data-status="skipped"], .dsh-cron-history-status[data-status="missed"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); }
|
|
358
|
+
.dsh-cron-history-status { font-weight: 500; font-size: 11px; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #383838); }
|
|
359
|
+
.dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
|
|
360
|
+
.dsh-cron-history-status[data-status="error"], .dsh-cron-history-status[data-status="timeout"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
|
|
361
|
+
.dsh-cron-history-status[data-status="skipped"], .dsh-cron-history-status[data-status="missed"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
|
|
305
362
|
.dsh-cron-history-time { color: var(--dsw-alias-label-tertiary, #777); font-size: 12px; }
|
|
306
363
|
.dsh-cron-history-output { background: var(--dsw-alias-bg-base, #0c0c0c); border: 1px solid var(--dsw-alias-border-l1, #222); border-radius: 6px; padding: 8px 10px; font-family: monospace; font-size: 12px; color: var(--dsw-alias-label-secondary, #bbb); max-height: 140px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 6px; }
|
|
307
364
|
.dsh-cron-history-empty { text-align: center; padding: 36px 0; color: var(--dsw-alias-label-tertiary, #666); font-size: 13.5px; }
|
|
308
365
|
|
|
309
|
-
.dsh-cron-stats-bar { display: grid; grid-template-columns: repeat(
|
|
310
|
-
.dsh-cron-stat-card { background: var(--dsw-alias-bg-layer-
|
|
311
|
-
.dsh-cron-stat-val { font-size:
|
|
312
|
-
.dsh-cron-stat-lbl { font-size:
|
|
313
|
-
.dsh-cron-oneshot-tag { font-size: 11px; padding: 2px
|
|
314
|
-
.dsh-cron-cost-tag { font-size: 11px; padding: 2px
|
|
315
|
-
.dsh-cron-tokens-tag { font-size: 11px; padding: 2px
|
|
366
|
+
.dsh-cron-stats-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 0; }
|
|
367
|
+
.dsh-cron-stat-card { background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.02)); border: 1px solid var(--dsw-alias-border-l2, #222); border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
|
|
368
|
+
.dsh-cron-stat-val { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary, #fff); }
|
|
369
|
+
.dsh-cron-stat-lbl { font-size: 12px; color: var(--dsw-alias-label-secondary, #777); margin-top: 0; }
|
|
370
|
+
.dsh-cron-oneshot-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-accent); background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); font-weight: 500; }
|
|
371
|
+
.dsh-cron-cost-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-success); background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); font-weight: 500; }
|
|
372
|
+
.dsh-cron-tokens-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-info); background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); }
|
|
316
373
|
.dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #ccc); cursor: pointer; user-select: none; }
|
|
317
374
|
.dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: var(--dsw-alias-label-primary, #ededed); }
|
|
318
375
|
.dsh-cron-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 4px; }
|
|
@@ -320,6 +377,8 @@ window.__ModuleLoader__.load({
|
|
|
320
377
|
.dsh-cron-status-banner[data-type="success"] { background: var(--dsh-cron-success-bg); border: 1px solid var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
321
378
|
.dsh-cron-status-banner[data-type="error"] { background: var(--dsh-cron-danger-bg); border: 1px solid var(--dsh-cron-danger); color: var(--dsh-cron-danger); }
|
|
322
379
|
.dsh-cron-status-banner[data-type="info"] { background: var(--dsh-cron-info-bg); border: 1px solid var(--dsh-cron-info); color: var(--dsh-cron-info); }
|
|
380
|
+
@keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); } 70% { box-shadow: 0 0 0 9px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } }
|
|
381
|
+
.dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
323
382
|
`;
|
|
324
383
|
|
|
325
384
|
function ensureStyles() {
|
|
@@ -441,8 +500,26 @@ window.__ModuleLoader__.load({
|
|
|
441
500
|
const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
|
|
442
501
|
const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
|
|
443
502
|
const [formKanbanMode, setFormKanbanMode] = React.useState('none');
|
|
503
|
+
const [formTimezone, setFormTimezone] = React.useState('');
|
|
504
|
+
const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
|
|
505
|
+
const [formMaxRetries, setFormMaxRetries] = React.useState(0);
|
|
506
|
+
const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
|
|
444
507
|
const [stats, setStats] = React.useState(null);
|
|
445
508
|
|
|
509
|
+
// Live execution indicator (#35): a light 1s tick only while some task
|
|
510
|
+
// is actually running.
|
|
511
|
+
const tasksRef = React.useRef([]);
|
|
512
|
+
const [nowTick, setNowTick] = React.useState(0);
|
|
513
|
+
React.useEffect(() => {
|
|
514
|
+
tasksRef.current = tasks;
|
|
515
|
+
}, [tasks]);
|
|
516
|
+
React.useEffect(() => {
|
|
517
|
+
const tick = setInterval(() => {
|
|
518
|
+
if (tasksRef.current.some((t2) => t2.running)) setNowTick((v) => v + 1);
|
|
519
|
+
}, 1000);
|
|
520
|
+
return () => clearInterval(tick);
|
|
521
|
+
}, []);
|
|
522
|
+
|
|
446
523
|
// Settings modal state
|
|
447
524
|
const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
|
|
448
525
|
const [settingsBotToken, setSettingsBotToken] = React.useState('');
|
|
@@ -641,6 +718,10 @@ window.__ModuleLoader__.load({
|
|
|
641
718
|
setFormTimeoutSeconds(1800);
|
|
642
719
|
setFormOverlapPolicy('skip');
|
|
643
720
|
setFormKanbanMode('none');
|
|
721
|
+
setFormTimezone('');
|
|
722
|
+
setFormMisfirePolicy('skip');
|
|
723
|
+
setFormMaxRetries(0);
|
|
724
|
+
setFormPermissionPreset('default');
|
|
644
725
|
if (preset) {
|
|
645
726
|
setFormTitle(preset.title || '');
|
|
646
727
|
setFormSchedule(preset.schedule || '');
|
|
@@ -691,6 +772,10 @@ window.__ModuleLoader__.load({
|
|
|
691
772
|
setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
|
|
692
773
|
setFormOverlapPolicy(task.overlapPolicy || 'skip');
|
|
693
774
|
setFormKanbanMode(task.kanbanMode || 'none');
|
|
775
|
+
setFormTimezone(task.timezone || '');
|
|
776
|
+
setFormMisfirePolicy(task.misfirePolicy || 'skip');
|
|
777
|
+
setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
|
|
778
|
+
setFormPermissionPreset(task.permissionPreset || 'default');
|
|
694
779
|
if (task.provider) loadModels(task.provider);
|
|
695
780
|
else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
|
|
696
781
|
loadTaskHistory(task.id);
|
|
@@ -721,6 +806,10 @@ window.__ModuleLoader__.load({
|
|
|
721
806
|
onlyOnFailure: formOnlyOnFailure,
|
|
722
807
|
timeoutSeconds: Number(formTimeoutSeconds) || 1800,
|
|
723
808
|
overlapPolicy: formOverlapPolicy,
|
|
809
|
+
timezone: formTimezone.trim(),
|
|
810
|
+
misfirePolicy: formMisfirePolicy,
|
|
811
|
+
maxRetries: Number(formMaxRetries) || 0,
|
|
812
|
+
permissionPreset: formPermissionPreset,
|
|
724
813
|
kanbanMode: formKanbanMode,
|
|
725
814
|
};
|
|
726
815
|
// Cross-origin hardening: script tasks need the explicit confirm
|
|
@@ -897,7 +986,7 @@ window.__ModuleLoader__.load({
|
|
|
897
986
|
},
|
|
898
987
|
React.createElement('button', {
|
|
899
988
|
type: 'button',
|
|
900
|
-
className: 'dsh-cron-task-status-btn',
|
|
989
|
+
className: 'dsh-cron-task-status-btn' + (task.running ? ' dsh-cron-pulse' : ''),
|
|
901
990
|
'data-active': task.status === 'active' ? 'true' : undefined,
|
|
902
991
|
title: task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
|
|
903
992
|
'aria-label': task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
|
|
@@ -919,7 +1008,10 @@ window.__ModuleLoader__.load({
|
|
|
919
1008
|
),
|
|
920
1009
|
React.createElement('div', { className: 'dsh-cron-task-sched' },
|
|
921
1010
|
task.scheduleText || task.schedule,
|
|
922
|
-
task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : ''
|
|
1011
|
+
task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : '',
|
|
1012
|
+
task.running && task.runningSince
|
|
1013
|
+
? ' · ' + T_KEY(t, 'list.runningNow') + ' ' + Math.max(0, Math.round((Date.now() - task.runningSince) / 1000)) + 's'
|
|
1014
|
+
: ''
|
|
923
1015
|
),
|
|
924
1016
|
task.prompt ? React.createElement('div', {
|
|
925
1017
|
className: 'dsh-cron-task-prompt-preview',
|
|
@@ -945,7 +1037,7 @@ window.__ModuleLoader__.load({
|
|
|
945
1037
|
}, React.createElement('span', svgProps(ICON_EDIT))),
|
|
946
1038
|
React.createElement('button', {
|
|
947
1039
|
type: 'button',
|
|
948
|
-
className: 'dsh-cron-icon-btn',
|
|
1040
|
+
className: 'dsh-cron-icon-btn dsh-cron-danger',
|
|
949
1041
|
title: T_KEY(t, 'actions.delete'),
|
|
950
1042
|
'aria-label': T_KEY(t, 'actions.delete'),
|
|
951
1043
|
onClick: () => handleDelete(task.id)
|
|
@@ -1042,7 +1134,13 @@ window.__ModuleLoader__.load({
|
|
|
1042
1134
|
)
|
|
1043
1135
|
),
|
|
1044
1136
|
run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
|
|
1045
|
-
run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error)
|
|
1137
|
+
run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error),
|
|
1138
|
+
run.sessionId && React.createElement('button', {
|
|
1139
|
+
type: 'button',
|
|
1140
|
+
className: 'dsh-cron-btn-secondary',
|
|
1141
|
+
style: { marginTop: '8px', fontSize: '12px', padding: '4px 10px' },
|
|
1142
|
+
onClick: () => openSession(ctx, run.sessionId)
|
|
1143
|
+
}, T_KEY(t, 'history.openSession'))
|
|
1046
1144
|
))
|
|
1047
1145
|
),
|
|
1048
1146
|
React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '18px' } },
|
|
@@ -1114,7 +1212,52 @@ window.__ModuleLoader__.load({
|
|
|
1114
1212
|
)
|
|
1115
1213
|
)
|
|
1116
1214
|
),
|
|
1215
|
+
React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1216
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1217
|
+
React.createElement('label', null, T_KEY(t, 'form.timezoneLabel')),
|
|
1218
|
+
React.createElement('input', {
|
|
1219
|
+
value: formTimezone,
|
|
1220
|
+
placeholder: T_KEY(t, 'form.timezonePlaceholder'),
|
|
1221
|
+
onChange: (e) => setFormTimezone(e.target.value)
|
|
1222
|
+
})
|
|
1223
|
+
),
|
|
1224
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1225
|
+
React.createElement('label', null, T_KEY(t, 'form.misfireLabel')),
|
|
1226
|
+
React.createElement('select', {
|
|
1227
|
+
value: formMisfirePolicy,
|
|
1228
|
+
onChange: (e) => setFormMisfirePolicy(e.target.value)
|
|
1229
|
+
},
|
|
1230
|
+
React.createElement('option', { value: 'skip' }, T_KEY(t, 'form.misfireSkip')),
|
|
1231
|
+
React.createElement('option', { value: 'runOnce' }, T_KEY(t, 'form.misfireRunOnce')),
|
|
1232
|
+
React.createElement('option', { value: 'catchUpAll' }, T_KEY(t, 'form.misfireCatchUp'))
|
|
1233
|
+
)
|
|
1234
|
+
)
|
|
1235
|
+
),
|
|
1236
|
+
React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1117
1237
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1238
|
+
React.createElement('label', null, T_KEY(t, 'form.retriesLabel')),
|
|
1239
|
+
React.createElement('input', {
|
|
1240
|
+
type: 'number',
|
|
1241
|
+
min: 0,
|
|
1242
|
+
value: formMaxRetries,
|
|
1243
|
+
placeholder: '0',
|
|
1244
|
+
onChange: (e) => setFormMaxRetries(e.target.value)
|
|
1245
|
+
})
|
|
1246
|
+
),
|
|
1247
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1248
|
+
React.createElement('label', null, T_KEY(t, 'form.permissionLabel')),
|
|
1249
|
+
React.createElement('select', {
|
|
1250
|
+
value: formPermissionPreset,
|
|
1251
|
+
onChange: (e) => setFormPermissionPreset(e.target.value)
|
|
1252
|
+
},
|
|
1253
|
+
React.createElement('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
|
|
1254
|
+
React.createElement('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
|
|
1255
|
+
React.createElement('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
|
|
1256
|
+
React.createElement('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
|
|
1257
|
+
)
|
|
1258
|
+
)
|
|
1259
|
+
),
|
|
1260
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1118
1261
|
React.createElement('label', null, T_KEY(t, 'form.kanbanLabel')),
|
|
1119
1262
|
React.createElement('select', {
|
|
1120
1263
|
value: formKanbanMode,
|
|
@@ -1666,7 +1809,7 @@ window.__ModuleLoader__.load({
|
|
|
1666
1809
|
column.appendChild(container);
|
|
1667
1810
|
if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
|
|
1668
1811
|
root = ReactDOM.createRoot(container);
|
|
1669
|
-
root.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate }));
|
|
1812
|
+
root.render(React.createElement(ErrorBoundary, null, React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate })));
|
|
1670
1813
|
} else if (ReactDOM && typeof ReactDOM.render === 'function') {
|
|
1671
1814
|
ReactDOM.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate }), container);
|
|
1672
1815
|
}
|
|
@@ -1752,12 +1895,15 @@ window.__ModuleLoader__.load({
|
|
|
1752
1895
|
// The settings card lives ONLY in the plugins settings tab (#102): no
|
|
1753
1896
|
// top-level section fallback — the flat sidebar list is a shared core
|
|
1754
1897
|
// resource, and the card slot is proven on current builds (#85).
|
|
1898
|
+
function SettingsCardWithBoundary(props) {
|
|
1899
|
+
return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
|
|
1900
|
+
}
|
|
1755
1901
|
const cardOk = registerIntoMount(ctx, 'settings.plugin.item', {
|
|
1756
1902
|
name: 'settings.plugin.item',
|
|
1757
1903
|
key: NS,
|
|
1758
1904
|
locale: NS,
|
|
1759
1905
|
inject: () => ({ ctx, toggle }),
|
|
1760
|
-
},
|
|
1906
|
+
}, SettingsCardWithBoundary);
|
|
1761
1907
|
if (!cardOk) {
|
|
1762
1908
|
console.warn('[dsh-cron] settings.plugin.item slot unavailable — settings card not registered');
|
|
1763
1909
|
}
|
package/lib/http-utils.js
CHANGED
|
@@ -8,12 +8,15 @@ export const MAX_BODY_BYTES = 1024 * 1024;
|
|
|
8
8
|
export const SCRIPT_CONFIRM_HEADER = 'x-dsh-cron-confirm';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Parse a JSON request body with a hard size cap.
|
|
11
|
+
* Parse a JSON request body with a hard byte size cap.
|
|
12
|
+
* Chunks are accumulated as Buffers so multi-byte UTF-8 characters split
|
|
13
|
+
* across chunk boundaries decode correctly (#106, #97 follow-up).
|
|
12
14
|
* Rejects with err.statusCode = 413 when the body exceeds the limit.
|
|
13
15
|
*/
|
|
14
16
|
export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
15
17
|
return new Promise((resolve, reject) => {
|
|
16
|
-
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let bytes = 0;
|
|
17
20
|
let settled = false;
|
|
18
21
|
const fail = (err) => {
|
|
19
22
|
if (settled) return;
|
|
@@ -22,17 +25,20 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
|
22
25
|
};
|
|
23
26
|
req.on('data', (chunk) => {
|
|
24
27
|
if (settled) return;
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
29
|
+
bytes += buf.length;
|
|
30
|
+
if (bytes > limit) {
|
|
27
31
|
fail(Object.assign(new Error('Request body exceeds the allowed size'), { statusCode: 413 }));
|
|
28
32
|
req.destroy();
|
|
33
|
+
return;
|
|
29
34
|
}
|
|
35
|
+
chunks.push(buf);
|
|
30
36
|
});
|
|
31
37
|
req.on('end', () => {
|
|
32
38
|
if (settled) return;
|
|
33
39
|
settled = true;
|
|
34
40
|
try {
|
|
35
|
-
resolve(
|
|
41
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
|
|
36
42
|
} catch (err) {
|
|
37
43
|
reject(err);
|
|
38
44
|
}
|
package/lib/index.js
CHANGED
|
@@ -24,6 +24,10 @@ export const Config = z.object({
|
|
|
24
24
|
notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
|
|
25
25
|
onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
|
|
26
26
|
kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
|
|
27
|
+
defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
|
|
28
|
+
maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
|
|
29
|
+
heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
|
|
30
|
+
heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
|
|
27
31
|
});
|
|
28
32
|
|
|
29
33
|
const PATCHABLE_TASK_FIELDS = [
|
|
@@ -35,6 +39,11 @@ const PATCHABLE_TASK_FIELDS = [
|
|
|
35
39
|
'delivery',
|
|
36
40
|
'provider',
|
|
37
41
|
'model',
|
|
42
|
+
'timezone',
|
|
43
|
+
'misfirePolicy',
|
|
44
|
+
'maxRetries',
|
|
45
|
+
'retryBackoffMs',
|
|
46
|
+
'permissionPreset',
|
|
38
47
|
'notifyTelegram',
|
|
39
48
|
'onlyOnFailure',
|
|
40
49
|
'timeoutSeconds',
|
|
@@ -71,7 +80,7 @@ async function readBody(req, res) {
|
|
|
71
80
|
* snapshot stays authoritative (#102). Returns null when the settings
|
|
72
81
|
* service is unavailable (headless profiles fall back to store-only writes).
|
|
73
82
|
*/
|
|
74
|
-
async function applySettingsToScope(settingsScope, body) {
|
|
83
|
+
export async function applySettingsToScope(settingsScope, body) {
|
|
75
84
|
if (!settingsScope || typeof settingsScope.set !== 'function') return null;
|
|
76
85
|
const errors = [];
|
|
77
86
|
for (const key of ['botToken', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl']) {
|
|
@@ -85,6 +94,18 @@ async function applySettingsToScope(settingsScope, body) {
|
|
|
85
94
|
return errors;
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Drop values that must never re-enter the store from the client: a masked
|
|
99
|
+
* secret echoed back by the settings UI would overwrite the real token.
|
|
100
|
+
*/
|
|
101
|
+
export function sanitizeSettingsPayload(body) {
|
|
102
|
+
const payload = Object.assign({}, body);
|
|
103
|
+
if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
|
|
104
|
+
delete payload.botToken;
|
|
105
|
+
}
|
|
106
|
+
return payload;
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
/**
|
|
89
110
|
* Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
|
|
90
111
|
* route families — the #66 alias is kept alive by the same code path instead
|
|
@@ -92,9 +113,9 @@ async function applySettingsToScope(settingsScope, body) {
|
|
|
92
113
|
*
|
|
93
114
|
* IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
|
|
94
115
|
* with one, the core degrades matching to exact-path equality and every
|
|
95
|
-
* deeper path unrouted (#96).
|
|
116
|
+
* deeper path unrouted (#96). Exported for unit tests.
|
|
96
117
|
*/
|
|
97
|
-
function createCronApiHandler(store, scheduler, collection) {
|
|
118
|
+
export function createCronApiHandler(store, scheduler, collection) {
|
|
98
119
|
return async function handleCronApi(req, res) {
|
|
99
120
|
try {
|
|
100
121
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
@@ -112,7 +133,11 @@ function createCronApiHandler(store, scheduler, collection) {
|
|
|
112
133
|
if (req.method === 'GET') {
|
|
113
134
|
const status = url.searchParams.get('status') || 'all';
|
|
114
135
|
const query = url.searchParams.get('query') || '';
|
|
115
|
-
const list = store.list({ status, query })
|
|
136
|
+
const list = store.list({ status, query }).map((t) => ({
|
|
137
|
+
...t,
|
|
138
|
+
running: scheduler.isRunning(t.id),
|
|
139
|
+
runningSince: scheduler.runningSince(t.id),
|
|
140
|
+
}));
|
|
116
141
|
const stats = store.getAggregatedStats();
|
|
117
142
|
sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
|
|
118
143
|
return;
|
|
@@ -150,6 +175,11 @@ function createCronApiHandler(store, scheduler, collection) {
|
|
|
150
175
|
timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
|
|
151
176
|
overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
|
|
152
177
|
kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
|
|
178
|
+
timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
|
|
179
|
+
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
|
|
180
|
+
maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
|
|
181
|
+
retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
|
|
182
|
+
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
|
|
153
183
|
oneShot: Boolean(parsed.isOneShot || body.oneShot),
|
|
154
184
|
});
|
|
155
185
|
if (task.status === 'active') {
|
|
@@ -174,6 +204,10 @@ function createCronApiHandler(store, scheduler, collection) {
|
|
|
174
204
|
}
|
|
175
205
|
|
|
176
206
|
if (req.method === 'POST' && action === 'run') {
|
|
207
|
+
if (!store.get(id)) {
|
|
208
|
+
sendJson(res, 404, { ok: false, error: 'Task not found' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
177
211
|
await scheduler.triggerManualRun(id);
|
|
178
212
|
sendJson(res, 200, { ok: true, task: store.get(id) });
|
|
179
213
|
return;
|
|
@@ -259,9 +293,9 @@ function createCronApiHandler(store, scheduler, collection) {
|
|
|
259
293
|
|
|
260
294
|
/**
|
|
261
295
|
* Single implementation behind the cron_create_task / cron_schedule_task
|
|
262
|
-
* alias pair (#70, #92).
|
|
296
|
+
* alias pair (#70, #92). Exported for unit tests.
|
|
263
297
|
*/
|
|
264
|
-
function executeCreateTask(store, scheduler, args) {
|
|
298
|
+
export function executeCreateTask(store, scheduler, args) {
|
|
265
299
|
const taskType = args.type === 'script' ? 'script' : 'llm';
|
|
266
300
|
const parsed = parseScheduleExpression(args.schedule);
|
|
267
301
|
const task = store.set({
|
|
@@ -278,6 +312,11 @@ function executeCreateTask(store, scheduler, args) {
|
|
|
278
312
|
onlyOnFailure: Boolean(args.onlyOnFailure),
|
|
279
313
|
timeoutSeconds: Number(args.timeoutSeconds) || 1800,
|
|
280
314
|
overlapPolicy: args.overlapPolicy || 'skip',
|
|
315
|
+
timezone: args.timezone ? String(args.timezone).trim() : '',
|
|
316
|
+
misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
|
|
317
|
+
maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
|
|
318
|
+
retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
|
|
319
|
+
permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
|
|
281
320
|
kanbanMode: args.kanbanMode || 'none',
|
|
282
321
|
oneShot: Boolean(parsed.isOneShot),
|
|
283
322
|
});
|
|
@@ -291,16 +330,21 @@ function executeCreateTask(store, scheduler, args) {
|
|
|
291
330
|
|
|
292
331
|
const createTaskParameters = {
|
|
293
332
|
title: { type: 'string', description: 'Short task name', required: true },
|
|
294
|
-
schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
|
|
333
|
+
schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), shorthand ("@daily", "@every 30m"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
|
|
295
334
|
prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
|
|
296
335
|
type: { type: 'string', enum: ['prompt', 'script'], description: 'Task type' },
|
|
297
336
|
delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
|
|
298
337
|
provider: { type: 'string', description: 'Model provider (optional)' },
|
|
299
338
|
model: { type: 'string', description: 'Model (optional)' },
|
|
339
|
+
timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
|
|
300
340
|
notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
|
|
301
341
|
onlyOnFailure: { type: 'boolean', description: 'Send reports only for failures' },
|
|
302
342
|
timeoutSeconds: { type: 'number', description: 'Execution time limit in seconds' },
|
|
303
343
|
overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Overlap policy' },
|
|
344
|
+
misfirePolicy: { type: 'string', enum: ['skip', 'runOnce', 'catchUpAll'], description: 'What to do with a run missed during downtime' },
|
|
345
|
+
maxRetries: { type: 'number', description: 'Automatic retry attempts on failure (0 = off)' },
|
|
346
|
+
retryBackoffMs: { type: 'number', description: 'Base backoff between retries in ms, doubled per attempt' },
|
|
347
|
+
permissionPreset: { type: 'string', enum: ['default', 'read-only', 'workspace-write', 'full'], description: 'Permission preset applied to the task session' },
|
|
304
348
|
kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
|
|
305
349
|
};
|
|
306
350
|
|
|
@@ -320,7 +364,10 @@ const createTaskOutput = {
|
|
|
320
364
|
export function apply(ctx, config) {
|
|
321
365
|
const store = new TaskStore();
|
|
322
366
|
const runner = new SessionRunner(ctx);
|
|
323
|
-
const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts)
|
|
367
|
+
const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
|
|
368
|
+
maxConcurrent: config && config.maxConcurrent,
|
|
369
|
+
defaultTimezone: config && config.defaultTimezone,
|
|
370
|
+
});
|
|
324
371
|
|
|
325
372
|
// Register settings in the Harness settings service (#71, #102): the scope
|
|
326
373
|
// is the source of truth for the core, scope.watch mirrors values into the
|
|
@@ -339,6 +386,9 @@ export function apply(ctx, config) {
|
|
|
339
386
|
}
|
|
340
387
|
});
|
|
341
388
|
}
|
|
389
|
+
sctx.effect(() => () => {
|
|
390
|
+
cronSettingsScope = null;
|
|
391
|
+
});
|
|
342
392
|
} catch (e) {
|
|
343
393
|
console.warn('[dsh-cron] settings registration warning:', e.message);
|
|
344
394
|
}
|
|
@@ -346,8 +396,21 @@ export function apply(ctx, config) {
|
|
|
346
396
|
|
|
347
397
|
scheduler.start();
|
|
348
398
|
|
|
399
|
+
// Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
|
|
400
|
+
// external monitor can alert when this scheduler stops responding.
|
|
401
|
+
let heartbeatTimer = null;
|
|
402
|
+
if (config && config.heartbeatUrl && Number(config.heartbeatIntervalSec) > 0) {
|
|
403
|
+
const intervalMs = Number(config.heartbeatIntervalSec) * 1000;
|
|
404
|
+
heartbeatTimer = setInterval(() => {
|
|
405
|
+
fetch(config.heartbeatUrl, { method: 'GET' }).catch((err) => {
|
|
406
|
+
console.warn('[dsh-cron] heartbeat ping failed:', err.message);
|
|
407
|
+
});
|
|
408
|
+
}, intervalMs);
|
|
409
|
+
}
|
|
410
|
+
|
|
349
411
|
ctx.effect(() => {
|
|
350
412
|
return () => {
|
|
413
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
351
414
|
scheduler.stopAll();
|
|
352
415
|
};
|
|
353
416
|
}, 'dsh-cron: lifecycle');
|
|
@@ -432,10 +495,7 @@ export function apply(ctx, config) {
|
|
|
432
495
|
if (error) return;
|
|
433
496
|
// Masked secrets echoed back by the client must never overwrite the
|
|
434
497
|
// stored token (store.saveSettings guards this too, #86-era).
|
|
435
|
-
const payload =
|
|
436
|
-
if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
|
|
437
|
-
delete payload.botToken;
|
|
438
|
-
}
|
|
498
|
+
const payload = sanitizeSettingsPayload(body);
|
|
439
499
|
const scopeErrors = await applySettingsToScope(cronSettingsScope, payload);
|
|
440
500
|
if (scopeErrors === null) {
|
|
441
501
|
store.saveSettings(payload);
|
|
@@ -450,6 +510,27 @@ export function apply(ctx, config) {
|
|
|
450
510
|
}
|
|
451
511
|
}), 'dsh-cron: /settings');
|
|
452
512
|
|
|
513
|
+
// GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
|
|
514
|
+
ctx.effect(() => ctx.webServer.register({
|
|
515
|
+
kind: 'exact',
|
|
516
|
+
path: '/dsh-cron/heartbeat',
|
|
517
|
+
handler: async (req, res) => {
|
|
518
|
+
try {
|
|
519
|
+
const tasks = store.list({ status: 'active' });
|
|
520
|
+
const lastRuns = tasks.map((t) => t.lastRunAt || 0);
|
|
521
|
+
sendJson(res, 200, {
|
|
522
|
+
ok: true,
|
|
523
|
+
activeTasks: tasks.length,
|
|
524
|
+
running: scheduler.runningCount(),
|
|
525
|
+
lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
|
|
526
|
+
serverTime: Date.now(),
|
|
527
|
+
});
|
|
528
|
+
} catch (err) {
|
|
529
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}), 'dsh-cron: /heartbeat');
|
|
533
|
+
|
|
453
534
|
// POST /dsh-cron/telegram/test
|
|
454
535
|
ctx.effect(() => ctx.webServer.register({
|
|
455
536
|
kind: 'exact',
|
package/lib/runner.js
CHANGED
|
@@ -130,6 +130,18 @@ export class SessionRunner {
|
|
|
130
130
|
agentOptions: provider && model ? { provider, model } : undefined,
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
// Per-job permission preset (#32): read-only | workspace-write | full.
|
|
134
|
+
if (task.permissionPreset && task.permissionPreset !== 'default') {
|
|
135
|
+
try {
|
|
136
|
+
const presets = this.ctx.get && this.ctx.get('permissionPresets');
|
|
137
|
+
if (presets && typeof presets.set === 'function') {
|
|
138
|
+
presets.set(handle.agent.session, task.permissionPreset);
|
|
139
|
+
}
|
|
140
|
+
} catch (ppErr) {
|
|
141
|
+
console.warn('[dsh-cron] permission preset not applied:', ppErr.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
await raceAbort(handle.agent.whenIdle());
|
|
134
146
|
handle.agent.followup(createUserMessage({
|
|
135
147
|
content: [{ type: 'text', text: task.prompt }],
|
|
@@ -159,9 +171,14 @@ export class SessionRunner {
|
|
|
159
171
|
return {
|
|
160
172
|
output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
|
|
161
173
|
usage,
|
|
162
|
-
costUsd
|
|
174
|
+
costUsd,
|
|
175
|
+
sessionId: handle.agent.session?.id || null
|
|
163
176
|
};
|
|
164
177
|
} finally {
|
|
178
|
+
let archivedSessionId = null;
|
|
179
|
+
try {
|
|
180
|
+
archivedSessionId = (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
|
|
181
|
+
} catch {}
|
|
165
182
|
try {
|
|
166
183
|
if (handle && typeof handle.dispose === 'function') {
|
|
167
184
|
await handle.dispose();
|
|
@@ -171,6 +188,17 @@ export class SessionRunner {
|
|
|
171
188
|
} catch (disposeErr) {
|
|
172
189
|
console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
|
|
173
190
|
}
|
|
191
|
+
// Best-effort: keep ephemeral cron sessions out of the main chat
|
|
192
|
+
// list (#30). The archive call degrades silently when the core
|
|
193
|
+
// does not expose it.
|
|
194
|
+
if (archivedSessionId && this.ctx && this.ctx.get) {
|
|
195
|
+
try {
|
|
196
|
+
const sessions = this.ctx.get('sessions');
|
|
197
|
+
if (sessions && typeof sessions.archive === 'function') {
|
|
198
|
+
sessions.archive(archivedSessionId);
|
|
199
|
+
}
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
174
202
|
}
|
|
175
203
|
} catch (err) {
|
|
176
204
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|
package/lib/scheduler.js
CHANGED
|
@@ -99,8 +99,19 @@ export function parseScheduleExpression(input) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// 4.
|
|
102
|
+
// 4. Standard @-shorthands (#15)
|
|
103
103
|
const lower = str.toLowerCase();
|
|
104
|
+
if (lower === '@hourly') return { cronPattern: '0 * * * *', humanText: 'Every hour' };
|
|
105
|
+
if (lower === '@daily' || lower === '@midnight') return { cronPattern: '0 0 * * *', humanText: 'Every day at 00:00' };
|
|
106
|
+
if (lower === '@weekly') return { cronPattern: '0 0 * * 0', humanText: 'Every week on Sunday at 00:00' };
|
|
107
|
+
if (lower === '@monthly') return { cronPattern: '0 0 1 * *', humanText: 'Every month on the 1st at 00:00' };
|
|
108
|
+
if (lower === '@yearly' || lower === '@annually') return { cronPattern: '0 0 1 1 *', humanText: 'Every year on Jan 1 at 00:00' };
|
|
109
|
+
const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
|
|
110
|
+
if (everyAlias) {
|
|
111
|
+
return parseScheduleExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 5. Friendly recurring aliases (English plus accepted Russian input aliases)
|
|
104
115
|
if (lower === 'daily' || lower === 'каждый день') {
|
|
105
116
|
return { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
|
|
106
117
|
}
|
|
@@ -126,35 +137,75 @@ export function parseScheduleExpression(input) {
|
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
export class TaskScheduler {
|
|
129
|
-
constructor(store, executeFn) {
|
|
140
|
+
constructor(store, executeFn, options = {}) {
|
|
130
141
|
this.store = store;
|
|
131
142
|
this.executeFn = executeFn;
|
|
143
|
+
this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
|
|
144
|
+
this.defaultTimezone = options.defaultTimezone || ''; // #13
|
|
132
145
|
this.jobs = new Map(); // taskId -> Cron instance
|
|
133
146
|
this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
|
|
147
|
+
this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
|
|
134
148
|
this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
|
|
135
149
|
}
|
|
136
150
|
|
|
151
|
+
isRunning(taskId) {
|
|
152
|
+
return this.running.has(taskId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
runningCount() {
|
|
156
|
+
return this.running.size;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
runningSince(taskId) {
|
|
160
|
+
const run = this.running.get(taskId);
|
|
161
|
+
return run ? run.startedAt : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clearRetryTimer(taskId) {
|
|
165
|
+
const timer = this.retryTimers.get(taskId);
|
|
166
|
+
if (timer !== undefined) {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
this.retryTimers.delete(taskId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
start() {
|
|
138
173
|
const tasks = this.store.list();
|
|
139
174
|
const now = Date.now();
|
|
140
175
|
for (const task of tasks) {
|
|
141
|
-
if (task.status
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
176
|
+
if (task.status !== 'active') continue;
|
|
177
|
+
// Detect missed execution if daemon was offline (threshold: > 5 minutes)
|
|
178
|
+
const missed = task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000;
|
|
179
|
+
if (missed) {
|
|
180
|
+
const missedAt = task.nextRunAt;
|
|
181
|
+
const policy = task.misfirePolicy || 'skip'; // #12: skip | runOnce | catchUpAll
|
|
182
|
+
console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()} (misfire: ${policy})`);
|
|
183
|
+
this.store.recordRun(task.id, {
|
|
184
|
+
at: missedAt,
|
|
185
|
+
status: 'missed',
|
|
186
|
+
durationMs: 0,
|
|
187
|
+
output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
|
|
188
|
+
error: null,
|
|
189
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
190
|
+
costUsd: 0,
|
|
191
|
+
});
|
|
192
|
+
if (policy === 'skip') {
|
|
193
|
+
// A missed one-shot has no future slot left: retire it instead of
|
|
194
|
+
// arming a timer in the past.
|
|
195
|
+
if (task.oneShot) {
|
|
196
|
+
task.status = 'completed';
|
|
197
|
+
this.store.set(task);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
this.scheduleTask(task);
|
|
201
|
+
continue;
|
|
155
202
|
}
|
|
203
|
+
// runOnce / catchUpAll: run late exactly once, then resume the schedule.
|
|
156
204
|
this.scheduleTask(task);
|
|
205
|
+
setImmediate(() => this.runTask(task.id));
|
|
206
|
+
continue;
|
|
157
207
|
}
|
|
208
|
+
this.scheduleTask(task);
|
|
158
209
|
}
|
|
159
210
|
}
|
|
160
211
|
|
|
@@ -173,6 +224,13 @@ export class TaskScheduler {
|
|
|
173
224
|
}
|
|
174
225
|
this.timers.clear();
|
|
175
226
|
|
|
227
|
+
for (const [id, timer] of this.retryTimers.entries()) {
|
|
228
|
+
try {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
this.retryTimers.clear();
|
|
233
|
+
|
|
176
234
|
for (const [, run] of this.running.entries()) {
|
|
177
235
|
try {
|
|
178
236
|
run.controller.abort(new Error('Scheduler stopped'));
|
|
@@ -193,9 +251,11 @@ export class TaskScheduler {
|
|
|
193
251
|
}
|
|
194
252
|
|
|
195
253
|
if (task.status !== 'active') return;
|
|
254
|
+
this.clearRetryTimer(task.id);
|
|
196
255
|
|
|
197
256
|
try {
|
|
198
257
|
const parsed = parseScheduleExpression(task.schedule);
|
|
258
|
+
const timezone = task.timezone || this.defaultTimezone || undefined;
|
|
199
259
|
|
|
200
260
|
// Handle One-shot task
|
|
201
261
|
if (parsed.isOneShot) {
|
|
@@ -216,7 +276,7 @@ export class TaskScheduler {
|
|
|
216
276
|
}
|
|
217
277
|
|
|
218
278
|
// Handle Recurring Cron job
|
|
219
|
-
const job = new Cron(parsed.cronPattern, { protect: true }, async () => {
|
|
279
|
+
const job = new Cron(parsed.cronPattern, { protect: true, ...(timezone ? { timezone } : {}) }, async () => {
|
|
220
280
|
await this.runTask(task.id);
|
|
221
281
|
});
|
|
222
282
|
|
|
@@ -235,6 +295,23 @@ export class TaskScheduler {
|
|
|
235
295
|
const task = this.store.get(taskId);
|
|
236
296
|
if (!task) return;
|
|
237
297
|
|
|
298
|
+
// Global concurrency throttle (#52): a run that would exceed the limit is
|
|
299
|
+
// skipped and recorded; already-running tasks are never throttled against
|
|
300
|
+
// themselves.
|
|
301
|
+
if (!this.running.has(taskId) && this.maxConcurrent > 0 && this.running.size >= this.maxConcurrent) {
|
|
302
|
+
console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
|
|
303
|
+
this.store.recordRun(taskId, {
|
|
304
|
+
at: Date.now(),
|
|
305
|
+
status: 'skipped',
|
|
306
|
+
durationMs: 0,
|
|
307
|
+
output: `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`,
|
|
308
|
+
error: null,
|
|
309
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
310
|
+
costUsd: 0,
|
|
311
|
+
});
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
238
315
|
const overlapPolicy = task.overlapPolicy || 'skip';
|
|
239
316
|
|
|
240
317
|
// Check overlap with currently running task
|
|
@@ -278,6 +355,7 @@ export class TaskScheduler {
|
|
|
278
355
|
let error = null;
|
|
279
356
|
let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
|
|
280
357
|
let costUsd = 0;
|
|
358
|
+
let sessionId = null;
|
|
281
359
|
|
|
282
360
|
try {
|
|
283
361
|
if (typeof this.executeFn === 'function') {
|
|
@@ -286,6 +364,7 @@ export class TaskScheduler {
|
|
|
286
364
|
output = res.output || '';
|
|
287
365
|
usage = res.usage || usage;
|
|
288
366
|
costUsd = res.costUsd || 0;
|
|
367
|
+
sessionId = res.sessionId || null;
|
|
289
368
|
} else {
|
|
290
369
|
output = String(res || '');
|
|
291
370
|
}
|
|
@@ -307,6 +386,7 @@ export class TaskScheduler {
|
|
|
307
386
|
error,
|
|
308
387
|
usage,
|
|
309
388
|
costUsd,
|
|
389
|
+
sessionId,
|
|
310
390
|
};
|
|
311
391
|
this.store.recordRun(taskId, runInfo);
|
|
312
392
|
|
|
@@ -383,6 +463,25 @@ export class TaskScheduler {
|
|
|
383
463
|
}
|
|
384
464
|
}
|
|
385
465
|
|
|
466
|
+
// 4. Retry with exponential backoff on failure (#18)
|
|
467
|
+
const failed = status === 'error' || status === 'timeout';
|
|
468
|
+
const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
|
|
469
|
+
if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
|
|
470
|
+
task.attempts = (task.attempts || 0) + 1;
|
|
471
|
+
const backoffMs = (Number(task.retryBackoffMs) > 0 ? Number(task.retryBackoffMs) : 30000) * Math.pow(2, task.attempts - 1);
|
|
472
|
+
const attempt = task.attempts;
|
|
473
|
+
this.store.set(task);
|
|
474
|
+
this.clearRetryTimer(taskId);
|
|
475
|
+
this.retryTimers.set(taskId, setTimeout(() => {
|
|
476
|
+
this.retryTimers.delete(taskId);
|
|
477
|
+
this.runTask(taskId);
|
|
478
|
+
}, backoffMs));
|
|
479
|
+
console.log(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${task.title}" in ${backoffMs}ms`);
|
|
480
|
+
} else if (!failed && task.attempts) {
|
|
481
|
+
task.attempts = 0;
|
|
482
|
+
this.store.set(task);
|
|
483
|
+
}
|
|
484
|
+
|
|
386
485
|
if (queuedCount > 0) {
|
|
387
486
|
setImmediate(() => {
|
|
388
487
|
this.runTask(taskId);
|
|
@@ -396,6 +495,7 @@ export class TaskScheduler {
|
|
|
396
495
|
if (!task) return null;
|
|
397
496
|
task.status = 'paused';
|
|
398
497
|
task.nextRunAt = null;
|
|
498
|
+
this.clearRetryTimer(taskId);
|
|
399
499
|
if (this.jobs.has(taskId)) {
|
|
400
500
|
this.jobs.get(taskId).stop();
|
|
401
501
|
this.jobs.delete(taskId);
|
|
@@ -434,40 +534,7 @@ export class TaskScheduler {
|
|
|
434
534
|
}
|
|
435
535
|
}
|
|
436
536
|
|
|
437
|
-
removeTask(taskId) {
|
|
438
|
-
if (this.jobs.has(taskId)) {
|
|
439
|
-
this.jobs.get(taskId).stop();
|
|
440
|
-
this.jobs.delete(taskId);
|
|
441
|
-
}
|
|
442
|
-
if (this.timers.has(taskId)) {
|
|
443
|
-
clearTimeout(this.timers.get(taskId));
|
|
444
|
-
this.timers.delete(taskId);
|
|
445
|
-
}
|
|
446
|
-
const running = this.running.get(taskId);
|
|
447
|
-
if (running) {
|
|
448
|
-
try {
|
|
449
|
-
running.controller.abort(new Error('Task deleted'));
|
|
450
|
-
} catch {}
|
|
451
|
-
this.running.delete(taskId);
|
|
452
|
-
}
|
|
453
|
-
return this.store.delete(taskId);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
537
|
triggerManualRun(taskId) {
|
|
457
538
|
return this.runTask(taskId);
|
|
458
539
|
}
|
|
459
|
-
|
|
460
|
-
getNextRun(schedule) {
|
|
461
|
-
try {
|
|
462
|
-
const parsed = parseScheduleExpression(schedule);
|
|
463
|
-
if (parsed.isOneShot) {
|
|
464
|
-
return parsed.targetTimestamp;
|
|
465
|
-
}
|
|
466
|
-
const tempJob = new Cron(parsed.cronPattern);
|
|
467
|
-
const next = tempJob.nextRun();
|
|
468
|
-
return next ? next.getTime() : null;
|
|
469
|
-
} catch {
|
|
470
|
-
return null;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
540
|
}
|
package/lib/store.js
CHANGED